Saltar al contenido

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:

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.