03 · Un bucle acotado, y qué pasa cuando el modelo se equivoca
Esto es un fichero que se ejecuta:
examples/agentes/03_extraccion_acotada.py↗ Esta página lo transcribe y enseña lo que imprime. Si dejan de coincidir, falla un test.
Dominio: la plataforma de inteligencia documental — extracción agéntica acotada sobre boletas de pago, con validación determinista detrás. Su README lo dice en esas palabras, y las dos importan:
- acotada — un bucle sin techo es un incidente esperando: el modelo se
atasca, reintenta y factura.
Limitspone el techo. - determinista detrás — el modelo extrae, el código valida. Si la validación falla, el error vuelve al modelo para que rectifique en vez de propagarse.
Lo que este ejemplo añade sobre el 02: el camino de error. Un agente que solo se prueba cuando acierta no está probado.
Cómo correrlo
uv run python examples/agentes/03_extraccion_acotada.py
No hace falta configurar nada: sin modelo, las respuestas van guionizadas y todo lo
demás es real — las herramientas se ejecutan, el journal se escribe, el consumo se mide.
Con AXONIUM_CLIENT_ID o SYNAPTUM_BASE_URL en el entorno, el mismo fichero sin tocar
habla con un modelo de verdad; lo que cambia entonces es lo que diga el modelo, no el
código. Ver Modelos.
Lo que imprime
03 · Extracción acotada, con el camino de error
───────────────────────────────────────────────
sin inferencia · respuestas guionizadas (exporta SYNAPTUM_BASE_URL para usar un modelo real)
→ leer_pagina
→ validar_ruc
ROSA MERCEDES QUISPE HUAMAN · 2026-09
bruto 4,302.50 − descuentos 548.55 = neto 3,753.95
✓ el neto cuadra con bruto − descuentos
llamadas al modelo: 3 (una se perdió en un JSON truncado y se reintentó sola)
from __future__ import annotations
import asyncio
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Annotated
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from synaptum import Agent, FinalStep, Limits, ModelStep, Phase, Session, ToolStep, tool
from synaptum.testing import calls, says
from comun import encabezado, gateway, nombre_del_modelo
@dataclass
class Boleta:
ruc: str
trabajador: str
periodo: str
bruto: float
descuentos: float
neto: float
Herramientas: OCR y una validación que el modelo NO hace
PAGINA = """
EMPRESA CONSTRUCTORA DEL SUR S.A.C.
RUC 20481234567
BOLETA DE PAGO - SETIEMBRE 2026
Trabajador: ROSA MERCEDES QUISPE HUAMAN
Remuneración básica 4,200.00
Asignación familiar 102.50
--------------------------------
Total bruto 4,302.50
AFP Integra (10%) 430.25
EsSalud 0.00
Impuesto 5ta categoría 118.30
--------------------------------
Total descuentos 548.55
NETO A PAGAR 3,753.95
"""
@tool(idempotent=True)
async def leer_pagina(
documento_id: Annotated[str, "Identificador del documento en el almacén"],
pagina: Annotated[int, "Número de página, empezando en 1"] = 1,
) -> str:
"""Texto de una página, tal como lo devuelve el OCR."""
return PAGINA.strip()
@tool(idempotent=True)
async def validar_ruc(
ruc: Annotated[str, "RUC de 11 dígitos"],
) -> str:
"""Comprueba un RUC contra el padrón: dígito verificador y estado.
Esto **no lo hace el modelo**. Un dígito verificador es aritmética, y
pedírsela a un modelo de lenguaje es convertir algo exacto en algo probable.
"""
if len(ruc) != 11 or not ruc.isdigit():
return f"RUC {ruc} inválido: deben ser 11 dígitos."
return f"RUC {ruc}: ACTIVO · CONSTRUCTORA DEL SUR S.A.C."
async def main() -> None:
encabezado("03 · Extracción acotada, con el camino de error")
agente = Agent(
"extractor",
model=nombre_del_modelo(),
instructions=(
"Extraes datos de boletas de pago peruanas. Lee la página, valida el "
"RUC con la herramienta y devuelve el objeto. Los importes van como "
"número, sin separador de miles."
),
tools=[leer_pagina, validar_ruc],
output=Boleta,
# El techo. Sin él, un modelo que no converge reintenta hasta que alguien
# mira la factura. `max_steps` es corrección, no política: la política de
# cuánto puede gastar un run vive en el arnés, no aquí.
limits=Limits(max_steps=8, max_retries=2),
)
guion = [
calls("leer_pagina", documento_id="doc-88231", pagina=1),
calls("validar_ruc", ruc="20481234567"),
# Primer intento del modelo: JSON mal formado. Pasa, y pasa más de lo que
# nadie admite. El bucle lo trata como reintentable —el muestreo es
# estocástico— y vuelve a pedirlo sin que el llamante se entere.
says('{"ruc": "20481234567", "trabajador": "ROSA MERCEDES QUISPE HUAMAN",'),
says(
'{"ruc": "20481234567", "trabajador": "ROSA MERCEDES QUISPE HUAMAN", '
'"periodo": "2026-09", "bruto": 4302.50, "descuentos": 548.55, '
'"neto": 3753.95}'
),
]
sesion = Session("doc-88231", gateway(guion, tools=[leer_pagina, validar_ruc]))
llamadas_al_modelo = 0
async for paso in agente.run("Extrae la boleta doc-88231.", session=sesion):
match paso:
case ToolStep(phase=Phase.ATTEMPTED, call=llamada) if llamada:
print(f" → {llamada.name}")
case ModelStep(phase=Phase.COMPLETED):
llamadas_al_modelo += 1
case FinalStep(output=b):
print(f"\n {b.trabajador} · {b.periodo}")
print(f" bruto {b.bruto:,.2f} − descuentos {b.descuentos:,.2f} "
f"= neto {b.neto:,.2f}")
# La validación que el modelo no puede garantizar, hecha aquí.
assert abs(b.bruto - b.descuentos - b.neto) < 0.01, "el neto no cuadra"
print(" ✓ el neto cuadra con bruto − descuentos")
print(f"\n llamadas al modelo: {llamadas_al_modelo} "
f"(una se perdió en un JSON truncado y se reintentó sola)")
if __name__ == "__main__":
asyncio.run(main())
Lo que esto enseña
Lo que hay que llevarse: la validación estructurada vive dentro del
reintento. Un objeto mal formado no es un fallo del run, es una muestra
mala — y el bucle lo sabe porque la taxonomía de errores lo dice, no porque
alguien escribiera un try aquí.
El fichero entero, para clonarlo y tocarlo: examples/agentes/03_extraccion_acotada.py ↗
Está en examples/ con los otros quince, y todos corren igual.