Módulo 8: Capstone The Andes Cargo Genai Extractor
4. Recorrido end-to-end: el camino de escalamiento a IA, mixto y declarado
Descripción
Esta lección recorre la mitad derecha del diagrama de la lección 2 — el camino de escalamiento— y, a diferencia de la lección 3, no puede completarse de punta a punta con código ejecutado en su totalidad, por la misma razón que cada módulo anterior de esta guía ya confirmó: Bedrock nunca se invoca aquí. Lo que esta lección sí hace, con la misma honestidad exacta de siempre, es correr todo lo que sí se puede correr, hasta el punto exacto de la llamada a bedrock:InvokeModel, y desde ahí, con una respuesta simulada —un dict fijo, etiquetado sin ambigüedad— seguir corriendo los guardrails propios de verdad. El resultado es un recorrido genuinamente mixto: ManifestParseFailed se dispara, de verdad, dos veces; extract-shipment-manifest-fields corre, de verdad, hasta construir la solicitud exacta a Bedrock; y pre_invoke_checks.py/post_invoke_checks.py (M4) corren, de verdad, sobre la respuesta simulada, con dos desenlaces distintos.
Conexión con el módulo
Esta es la lección donde el handler.py que el M5.6 firmó con cosign —sin mostrar nunca su contenido completo— aparece, por primera vez en esta guía, con su código entero. Reusa, sin modificar una sola línea, scrub_pii() (M4.5) y validate_shipment_fields() (M4.6) — el mismo patrón de tres pasos que defense_in_depth_flow.py (M4.8) ya probó, ahora envuelto en la forma real de un handler de Lambda disparado por un evento de EventBridge.
Analogía: el mostrador humano, hasta el punto exacto de levantar el teléfono
Retoma el mostrador de atención de la lección 2: cuando el cajero automático no puede resolver un trámite, la fila avanza hacia una persona en el mostrador. Esa persona hace, de verdad, varias cosas antes de resolver el caso: revisa el documento que trae el cliente, redacta internamente qué le va a preguntar a un especialista si hace falta escalar más, y hasta levanta el teléfono para marcar el número del especialista. Esta lección es exactamente ese recorrido, hasta el momento exacto en que el teléfono empieza a marcar — nunca hasta que el especialista responde. Lo que la persona del mostrador hizo antes de esa llamada (revisar el documento, redactar la pregunta) es completamente real, verificable, repetible. Lo que el especialista respondería es, en esta lección, una respuesta simulada, escrita a mano de antemano, para que el resto del procedimiento —lo que la persona del mostrador hace después de recibir una respuesta, sea cual sea— también se pueda demostrar sin necesitar, de verdad, que el teléfono conecte.
Paso 1 — ManifestParseFailed, disparado de verdad, dos veces
Dos manifiestos nuevos, ninguno usado antes en esta guía, cada uno con un tipo distinto de fallo de parseo — la misma disciplina de variedad que el M7.4 ya aplicó a su lote de 50:
#!/usr/bin/env python3
"""ai_escalation_path_walkthrough.py -- Module 8, lesson 4. Builds two REAL
ManifestParseFailed events -- process-shipment-manifest (Module 1, lesson 3)
would publish exactly these, byte for byte, given this exact input text.
Never uses random or datetime.now()."""
from __future__ import annotations
import json
# --- parse_manifest() / SHIPMENT_FIELDS_SCHEMA: unmodified, M1.3 / M4.6 ---
def parse_manifest(text: str) -> dict:
fields = {}
for line in text.strip().splitlines():
if "=" in line:
key, _, value = line.partition("=")
fields[key.strip()] = value.strip()
return fields
SHIPMENT_FIELDS_SCHEMA = (
"shipmentId",
"originCountry",
"destinationCountry",
"carrier",
"weightKg",
)
# --- Dos manifiestos nuevos, ninguno usado antes en esta guia -------------
FREE_TEXT_4478 = (
"Hey, quick one -- picking up 95kg from our Cali site tomorrow, "
"heading to Guayaquil, same carrier as last time (AndesExpress). "
"Can you confirm receipt? Ref AC-4478."
)
PARTIAL_4479 = "shipmentId=4479\noriginCountry=Chile\ncarrier=RutaSur\n"
def build_manifest_parse_failed_event(manifest_key: str, raw_text: str) -> dict:
"""Exactly the event shape Module 1, lesson 3 already established --
reused here without any change, applied to two new manifests."""
parsed = parse_manifest(raw_text)
missing = sorted(set(SHIPMENT_FIELDS_SCHEMA) - set(parsed.keys()))
reason = "no key=value pairs found" if not parsed else f"missing required field(s): {', '.join(missing)}"
detail = {"manifestKey": manifest_key, "rawText": raw_text, "reason": reason}
return {
"Source": "andescargo.shipments",
"DetailType": "Manifest Parse Failed",
"Detail": json.dumps(detail),
"EventBusName": "andes-cargo-events",
}
if __name__ == "__main__":
event_4478 = build_manifest_parse_failed_event(
"manifests/year=2026/month=08/shipment-4478-manifest.txt", FREE_TEXT_4478
)
event_4479 = build_manifest_parse_failed_event(
"manifests/year=2026/month=08/shipment-4479-manifest.txt", PARTIAL_4479
)
print(json.dumps(event_4478, indent=2))
print()
print(json.dumps(event_4479, indent=2))
python3 ai_escalation_path_walkthrough.py
Qué esperar (literal — corrido de verdad; parse_manifest() sin ningún cambio respecto a M1.3):
{
"Source": "andescargo.shipments",
"DetailType": "Manifest Parse Failed",
"Detail": "{\"manifestKey\": \"manifests/year=2026/month=08/shipment-4478-manifest.txt\", \"rawText\": \"Hey, quick one -- picking up 95kg from our Cali site tomorrow, heading to Guayaquil, same carrier as last time (AndesExpress). Can you confirm receipt? Ref AC-4478.\", \"reason\": \"no key=value pairs found\"}",
"EventBusName": "andes-cargo-events"
}
{
"Source": "andescargo.shipments",
"DetailType": "Manifest Parse Failed",
"Detail": "{\"manifestKey\": \"manifests/year=2026/month=08/shipment-4479-manifest.txt\", \"rawText\": \"shipmentId=4479\\noriginCountry=Chile\\ncarrier=RutaSur\\n\", \"reason\": \"missing required field(s): destinationCountry, weightKg\"}",
"EventBusName": "andes-cargo-events"
}
Dos eventos, dos razones distintas: 4478 no tiene ningún = en el texto ("no key=value pairs found"); 4479 sí tiene tres de los cinco campos, pero le faltan dos ("missing required field(s): destinationCountry, weightKg"). Ambos publicarían, de verdad, en el bus andes-cargo-events — este código es exactamente el que process-shipment-manifest corre, sin ninguna diferencia.
Paso 2 — handler.py, el código completo de extract-shipment-manifest-fields
El M5.6 firmó functions/extract-shipment-manifest-fields/handler.py con cosign, sin mostrar su contenido más allá de "el handler mínimo, no pulido". Aquí, por primera vez en esta guía, su código completo:
#!/usr/bin/env python3
"""functions/extract-shipment-manifest-fields/handler.py -- Module 8, lesson 4
of genai-on-aws-production-guide. The Lambda handler BedrockManifestExtractorRole
(Module 3, lesson 4) executes as, triggered by ManifestParseFailed (Module 1,
lesson 3). Deliberately minimal, never a polished prompt (Module 1, lesson 4
-- the boundary this guide holds without exception).
Wires pre_invoke_checks.scrub_pii() and post_invoke_checks.validate_shipment_fields()
(Module 4, lessons 5-6) -- REAL, unmodified, tested code -- around the exact
point where bedrock-runtime.invoke_model() would be called. That call is
built in full (build_invoke_request()) but NEVER executed anywhere in this
guide -- Bedrock is "Included in Plans: Ultimate" only (Module 3, lesson 6;
Module 4, lesson 7). This is the same three-step order
guardrails/defense_in_depth_flow.py (Module 4, lesson 8) already tested in
isolation, now wired to a real EventBridge event shape.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "guardrails"))
from post_invoke_checks import validate_shipment_fields # noqa: E402
from pre_invoke_checks import scrub_pii # noqa: E402
GUARDRAIL_ID = "andes-cargo-manifest-extractor-guardrail" # bedrock.tf, Module 3/4
GUARDRAIL_VERSION = "DRAFT"
MODEL_ID = "amazon.nova-lite-v1:0" # GENAI-COST-PROFILE.md, Module 2, section 2
PROMPT_TEMPLATE = (
"Extract shipment fields from the manifest text below. Return a JSON "
"object with exactly these keys: shipmentId, originCountry, "
"destinationCountry, carrier, weightKg. If a field is not present in "
"the text, omit the key rather than guessing.\n\nManifest text:\n{text}"
)
def build_invoke_request(redacted_text: str) -> dict:
"""The exact request bedrock-runtime.invoke_model() would receive --
REAL, executed code. Constructing this dict never touches a network."""
return {
"modelId": MODEL_ID,
"guardrailIdentifier": GUARDRAIL_ID,
"guardrailVersion": GUARDRAIL_VERSION,
"body": json.dumps({"inputText": PROMPT_TEMPLATE.format(text=redacted_text)}),
}
def handle_manifest_parse_failed(event: dict, representative_model_response: dict) -> dict:
"""1. scrub_pii() over rawText -- REAL, always.
2. build_invoke_request() -- REAL, constructs the exact call.
>>> response = bedrock_runtime.invoke_model(**request) <-- the line
this guide never executes. representative_model_response, injected
explicitly by the caller, stands in for `response` from here on.
3. validate_shipment_fields() over that response -- REAL, always.
4. The write-to-Shipments decision: only if step 3 passed.
"""
detail = json.loads(event["Detail"])
raw_text = detail["rawText"]
scrub_result = scrub_pii(raw_text)
request = build_invoke_request(scrub_result.redacted_text)
# response = bedrock_runtime.invoke_model(**request) # NEVER executed --
# see Module 3, lesson 6 and Module 4, lesson 7 for why.
candidate = representative_model_response
validation = validate_shipment_fields(candidate)
return {
"manifestKey": detail["manifestKey"],
"requestModelId": request["modelId"],
"requestGuardrailId": request["guardrailIdentifier"],
"requestBodyBytes": len(request["body"]),
"piiFoundInInput": scrub_result.found_pii,
"schemaValid": validation.is_valid,
"schemaErrors": validation.errors,
"wouldWriteToShipments": validation.is_valid,
}
def format_outcome(outcome: dict) -> str:
lines = [
f"manifestKey: {outcome['manifestKey']}",
f"requestModelId: {outcome['requestModelId']}",
f"requestGuardrailId: {outcome['requestGuardrailId']}",
f"pre_invoke_checks: {'PII FOUND (redacted)' if outcome['piiFoundInInput'] else 'CLEAN'}",
f"post_invoke_checks: {'PASS' if outcome['schemaValid'] else 'FAIL'}",
]
for err in outcome["schemaErrors"]:
lines.append(f" - {err}")
decision = "WRITE to Shipments" if outcome["wouldWriteToShipments"] else "DO NOT WRITE to Shipments"
lines.append(f"DECISION: {decision}")
return "\n".join(lines)
Fíjate en la línea comentada dentro de handle_manifest_parse_failed(): # response = bedrock_runtime.invoke_model(**request) — el punto exacto, marcado en el código mismo, donde esta guía se detiene. Todo lo que está antes de esa línea es real; todo lo que está después usa representative_model_response, nunca el resultado de esa llamada comentada.
Paso 3 — Corriendo el recorrido completo, dos escenarios
if __name__ == "__main__":
# Escenario A: shipment 4478, respuesta representativa COMPLETA.
outcome_a = handle_manifest_parse_failed(
event_4478,
representative_model_response={
"shipmentId": "4478",
"originCountry": "Colombia",
"destinationCountry": "Ecuador",
"carrier": "AndesExpress",
"weightKg": "95",
},
)
print("=== Escenario A: shipment 4478, respuesta representativa completa ===")
print(format_outcome(outcome_a))
print()
# Escenario B: shipment 4479, respuesta representativa SIN weightKg --
# el modelo extrajo lo que el texto SI tenia, pero no pudo inventar el
# peso porque el manifiesto original tampoco lo tenia.
outcome_b = handle_manifest_parse_failed(
event_4479,
representative_model_response={
"shipmentId": "4479",
"originCountry": "Chile",
"destinationCountry": "Peru",
"carrier": "RutaSur",
},
)
print("=== Escenario B: shipment 4479, respuesta representativa incompleta ===")
print(format_outcome(outcome_b))
python3 handler.py
Qué esperar — manifestKey/requestModelId/requestGuardrailId/pre_invoke_checks literal (código real, ejecutado); post_invoke_checks/DECISION literal sobre la respuesta representativa (el chequeo mismo es real; el candidato que evalúa es un dict fijo, etiquetado como tal en esta lección, nunca la salida de una invocación):
=== Escenario A: shipment 4478, respuesta representativa completa ===
manifestKey: manifests/year=2026/month=08/shipment-4478-manifest.txt
requestModelId: amazon.nova-lite-v1:0
requestGuardrailId: andes-cargo-manifest-extractor-guardrail
pre_invoke_checks: CLEAN
post_invoke_checks: PASS
DECISION: WRITE to Shipments
=== Escenario B: shipment 4479, respuesta representativa incompleta ===
manifestKey: manifests/year=2026/month=08/shipment-4479-manifest.txt
requestModelId: amazon.nova-lite-v1:0
requestGuardrailId: andes-cargo-manifest-extractor-guardrail
pre_invoke_checks: CLEAN
post_invoke_checks: FAIL
- missing required field(s): weightKg
DECISION: DO NOT WRITE to Shipments
Dos desenlaces distintos, sobre el mismo código real: el Escenario A pasa porque la respuesta representativa —construida, a propósito, para coincidir con lo que el manifiesto real describía (95kg, Cali a Guayaquil, AndesExpress)— tiene los cinco campos. El Escenario B falla porque la respuesta representativa refleja, honestamente, que el manifiesto original de 4479 nunca mencionó un peso — ni el parser determinista, ni una extracción razonable, podrían haber inventado un dato que el texto nunca tuvo. post_invoke_checks.py lo rechaza, exactamente como está diseñado, sin importar que la razón del faltante sea "el modelo no lo extrajo" o "el dato nunca estuvo en el texto".
Paso 4 — pytest, confirmando el handler.py completo
"""test_handler.py -- Module 8, lesson 4. Exercises handle_manifest_parse_failed()
end to end, with fixed events and fixed representative responses. No random,
no datetime.now()."""
from ai_escalation_path_walkthrough import event_4478, event_4479
from handler import build_invoke_request, handle_manifest_parse_failed
def test_build_invoke_request_uses_nova_lite():
request = build_invoke_request("clean text")
assert request["modelId"] == "amazon.nova-lite-v1:0"
assert request["guardrailIdentifier"] == "andes-cargo-manifest-extractor-guardrail"
def test_scenario_a_complete_response_writes_to_shipments():
outcome = handle_manifest_parse_failed(
event_4478,
{"shipmentId": "4478", "originCountry": "Colombia", "destinationCountry": "Ecuador",
"carrier": "AndesExpress", "weightKg": "95"},
)
assert outcome["wouldWriteToShipments"] is True
assert outcome["schemaErrors"] == ()
def test_scenario_b_incomplete_response_blocks_the_write():
outcome = handle_manifest_parse_failed(
event_4479,
{"shipmentId": "4479", "originCountry": "Chile", "destinationCountry": "Peru", "carrier": "RutaSur"},
)
assert outcome["wouldWriteToShipments"] is False
assert "missing required field(s): weightKg" in outcome["schemaErrors"][0]
def test_request_never_contains_a_response_field():
"""The request this handler builds has no key related to a response --
confirms, structurally, that build_invoke_request() only ever
constructs an outbound request, never simulates an inbound one."""
request = build_invoke_request("some text")
assert "response" not in request
assert set(request.keys()) == {"modelId", "guardrailIdentifier", "guardrailVersion", "body"}
pytest test_handler.py -v -p no:randomly
Qué esperar (literal — corrido de verdad):
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 4 items
test_handler.py::test_build_invoke_request_uses_nova_lite PASSED [ 25%]
test_handler.py::test_scenario_a_complete_response_writes_to_shipments PASSED [ 50%]
test_handler.py::test_scenario_b_incomplete_response_blocks_the_write PASSED [ 75%]
test_handler.py::test_request_never_contains_a_response_field PASSED [100%]
============================== 4 passed in 0.01s ===============================
Paso 5 — El punto exacto, dicho una vez más, sin rodeos
LO QUE ESTA LECCION EJECUTO DE VERDAD DESDE AQUI, REPRESENTATIVO
parse_manifest() (Paso 1) bedrock_runtime.invoke_model(
build_manifest_parse_failed_event() (Paso 1) **request) -- NUNCA se
scrub_pii() (Paso 2, dentro del handler) descomenta, en ningun
build_invoke_request() (Paso 2) punto de esta guia
validate_shipment_fields() (Paso 2)
la decision WRITE / DO NOT WRITE (Paso 2) representative_model_response
-- un dict fijo, escrito a
Verificado con pytest (Paso 4) mano, etiquetado como tal
en el nombre del parametro
Ninguna parte de esta lección invoca un modelo de Bedrock. La razón, ya confirmada dos veces en esta guía con evidencia distinta: el M3.6 confirmó, en vivo, que tflocal apply sobre el guardrail se detiene porque Bedrock está "Included in Plans: Ultimate" — un nivel de LocalStack por encima del gratuito sobre el que corre todo este laboratorio; el M4.7 confirmó, con el schema real de ApplyGuardrail, que incluso si el recurso existiera, confirmar que bloquea de verdad requeriría una invocación real, no solo su existencia. Esta lección no repite esas investigaciones — las hereda, y construye alrededor de ellas la mayor cantidad de código real posible.
Errores comunes
Descomentar la línea bedrock_runtime.invoke_model(**request) "solo para ver qué pasa" (de curiosidad rompiendo la regla más estricta de esta guía). Qué pasa: alguien, siguiendo esta lección en su propia máquina con una cuenta AWS real y acceso a Bedrock, descomenta la línea para probarla de verdad. Cómo detectarlo: si tu copia de handler.py tiene esa línea activa. Cómo corregirlo: nada te impide, en tu propia cuenta, hacerlo — pero al hacerlo, sales del alcance exacto que esta guía declaró desde el M1.2: "NO corre ninguna inferencia real de modelo". Si lo haces, tu resultado ya no es "esta guía ejecutada", es "esta guía extendida por ti, con tu propia cuenta" — una distinción honesta que vale la pena mantener, la misma que el M3.6 ya pidió para apply.
Presentar el DECISION: WRITE to Shipments del Escenario A como si el registro ya estuviera escrito en la tabla real (de confundir la decisión con la ejecución). Qué pasa: alguien, mostrando esta lección en una entrevista, dice "el sistema escribió el envío 4478 en Shipments". Cómo detectarlo: si tu descripción de esta lección no distingue entre "decidió que escribiría" y "escribió de verdad". Cómo corregirlo: handle_manifest_parse_failed() decide si escribiría, con base en validation.is_valid — nunca llama, en ningún punto de su código, a write_shipment_record() ni a ninguna operación de DynamoDB. La frase correcta: "el handler determinó, con guardrails reales, que este candidato tendría la forma correcta para escribirse" — no "el sistema completó la escritura".
Asumir que las dos respuestas representativas de esta lección (Escenario A completa, Escenario B incompleta) fueron elegidas al azar, sin relación con el texto original de cada manifiesto (de perder la coherencia narrativa del ejemplo). Qué pasa: alguien, replicando este patrón, construye una respuesta representativa que no tiene ninguna relación lógica con el manifiesto de entrada. Cómo detectarlo: si tu candidato representativo inventa un país de origen que el texto original ni siquiera mencionaba. Cómo corregirlo: cada respuesta representativa de esta lección refleja, con precisión, lo que el manifiesto original sí decía —el Escenario B, específicamente, no inventa un weightKg porque el manifiesto real (4479) nunca lo mencionó—; una extracción representativa honesta nunca "mejora" el texto original, solo reorganiza en JSON lo que el texto sí contenía, exactamente la misma disciplina de honestidad que sostiene cada representativeModelResponse de evals/fixtures/sample_manifests.json (M7.6).
Ejercicios
Ejercicio 1 — Construye, tú mismo, un tercer escenario con un manifiesto nuevo (por ejemplo, 4480) y una respuesta representativa que incluya un campo inesperado, como confidenceScore. Predice el resultado antes de correrlo.
Ver solución
post_invoke_checks.validate_shipment_fields() marcaría unexpected_fields = ("confidenceScore",), con is_valid = False — exactamente el mismo caso que el M4.6 (test_unexpected_extra_field_fails) y el M7.6 (fixture 4476) ya probaron por separado. pre_invoke_checks seguiría reportando según el texto de entrada de 4480, sin relación con este campo — la decisión final sería DO NOT WRITE to Shipments, con el error exacto listado. Este ejercicio confirma que handler.py, al reusar validate_shipment_fields() sin ningún cambio, hereda automáticamente cada regla que esa función ya aplica, sin necesitar duplicar ninguna lógica.
Ejercicio 2 — Explica por qué build_invoke_request() incluye guardrailIdentifier y guardrailVersion en la solicitud, en vez de dejar que Bedrock use un guardrail "por defecto". ¿Qué pasaría si esos dos campos se omitieran?
Ver solución
Bedrock no tiene ningún concepto de guardrail "por defecto" para una cuenta — cada invocación decide, explícitamente, si referencia un guardrail o no. El M4.4, Brecha 4 ya lo explicó con precisión: un guardrail gestionado solo protege una invocación que efectivamente lo referencia mediante estos dos parámetros; si build_invoke_request() los omitiera, la solicitud resultante invocaría el modelo sin ninguna de las seis políticas activas, sin que ningún error visible lo señalara — exactamente el riesgo operativo que esa misma lección nombró como la Brecha 4. Declararlos aquí, explícitamente, en el código del handler, es la mitigación directa de ese riesgo.
Ejercicio 3 — Predice qué cambiaría en el Qué esperar del Paso 3 de esta lección si, algún día, esta guía se ejecutara contra una cuenta AWS real con Bedrock habilitado, y la línea comentada de handler.py se activara. ¿Qué campos del outcome seguirían siendo idénticos, y cuáles cambiarían?
Ver solución
manifestKey, requestModelId, requestGuardrailId y piiFoundInInput seguirían siendo idénticos — dependen exclusivamente de parse_manifest(), scrub_pii() y build_invoke_request(), ninguno de los cuales necesita una invocación real. schemaValid, schemaErrors y wouldWriteToShipments sí podrían cambiar, porque dependerían de la respuesta real del modelo en vez de la respuesta representativa fijada a mano en esta lección — Nova Lite podría, por ejemplo, extraer el weightKg del Escenario B si el modelo infiere un valor razonable a partir de contexto (algo que esta guía nunca puede confirmar sin la invocación real). Este ejercicio confirma, con precisión, exactamente qué parte del handler.py de esta lección es agnóstica a si Bedrock existe o no, y qué parte depende enteramente de la respuesta real.
Resumen y siguiente paso
Esta lección recorrió el camino de escalamiento de punta a punta, mostrando por primera vez el contenido completo de handler.py: ManifestParseFailed disparado de verdad, dos veces, con dos razones de fallo distintas; pre_invoke_checks.py/post_invoke_checks.py corriendo de verdad, sobre respuestas representativas etiquetadas sin ambigüedad; y la construcción real, ejecutada, de la solicitud exacta a bedrock:InvokeModel — con la línea que la ejecutaría comentada, explícitamente, en el código mismo. Confirmaste el flujo completo con cuatro casos de pytest, incluida una prueba de que la solicitud construida nunca contiene ningún campo de respuesta.
Antes de avanzar deberías poder: explicar, señalando la línea exacta de handler.py, dónde termina lo real y empieza lo representativo; construir tu propio tercer escenario con un manifiesto y una respuesta representativa nuevos; y defender, sin dudar, por qué "decidió que escribiría" no es lo mismo que "escribió".
La lección 5 reúne, en un solo documento, cada pieza que quedó representativa en las ocho lecciones de este módulo Y en los siete módulos anteriores — el ledger de honestidad completo de esta guía entera, con la razón técnica exacta de cada fila.
Recursos
- Este mismo curso, Módulo 1, lección 3 — el origen del evento
ManifestParseFailed, reconstruido dos veces en el Paso 1 de esta lección. - Este mismo curso, Módulo 4, lecciones 5, 6 y 8 — el origen de
scrub_pii(),validate_shipment_fields()y el patrón de tres pasos quehandler.pyreusa sin ningún cambio. - Este mismo curso, Módulo 3, lección 6 y Módulo 4, lección 7 — la fuente de los dos hallazgos ("Ultimate-only", el schema de
ApplyGuardrail) citados en el Paso 5 de esta lección. - AWS Docs —
InvokeModel— referencia oficial de los parámetros quebuild_invoke_request()construye, incluidosguardrailIdentifier/guardrailVersion.