Cap 22: Structured Output, Validation Loops y Context Management

Por: Artiko
claude-codestructured-outputvalidationcontext-managementtool-usejson-schemacertificacion

1. Structured Output con tool_use y JSON schemas

Por qué tool_use es más confiable que “responde en JSON”

Una instrucción como “responde únicamente en JSON con este formato…” produce resultados frágiles. Claude puede agregar texto antes del bloque JSON, usar comillas incorrectas o simplemente olvidar el formato bajo presión de contexto largo.

tool_use resuelve esto estructuralmente: el modelo debe llamar la herramienta para completar su turno. La respuesta llega garantizada en el campo input del bloque tool_use, parseada automáticamente por el SDK.

Ventajas concretas:

  • El SDK deserializa el JSON por ti (sin JSON.parse manual)
  • tool_choice: { type: "any" } fuerza al modelo a llamar alguna herramienta
  • Los campos required y enum se respetan con mucha mayor consistencia

Matiz importante sobre la validación del schema: con una tool normal el schema no se garantiza. El modelo lo sigue con alta consistencia, pero el cumplimiento estricto es opt-in. Solo hay garantía en dos casos:

  1. strict: true, que va como campo top-level de la definición de la tool (no dentro de input_schema) y exige que el schema declare additionalProperties: false y required.
  2. Structured outputs con output_config.format.

Sin ninguna de las dos, el schema es una guía fuerte y sigue haciendo falta validar en tu lado — que es exactamente lo que construye la sección 2 de este capítulo.

Structured Outputs (GA): output_config.format

Además de tool_use, la API expone structured outputs, hoy en GA, para forzar que la respuesta de texto cumpla un JSON Schema sin necesidad de definir una herramienta:

const response = await client.messages.create({
  model: "claude-opus-4-8",
  max_tokens: 2048,
  output_config: {
    format: {
      type: "json_schema",
      schema: {
        type: "object",
        properties: {
          vendor: { type: "string" },
          amount_total: { type: "number" },
          currency: { type: "string", enum: ["USD", "EUR", "MXN", "ARS", "CLP", "COP"] },
        },
        required: ["vendor", "amount_total", "currency"],
        additionalProperties: false,
      },
    },
  },
  messages: [
    { role: "user", content: `Extrae los datos de la siguiente factura:\n\n${documentText}` },
  ],
});

Notas de migración:

  • El antiguo output_format y la cabecera beta structured-outputs-2025-11-13 solo funcionan durante un periodo de transición: el campo vigente es output_config.format.
  • strict: true en la definición de una tool y output_config.format son mecanismos complementarios: el primero endurece la salida de tool_use, el segundo endurece la salida de texto.

Cuándo usar cada uno:

NecesidadMecanismo
El modelo debe elegir entre varias accionestools + tool_choice
Una única forma de salida, sin accionesoutput_config.format
Garantía dura del schema en una toolstrict: true en la tool

Anatomía de un schema robusto

import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic();

// Schema para extracción de facturas
const invoiceTool: Anthropic.Tool = {
  name: "extract_invoice",
  description:
    "Extrae los datos estructurados de una factura. Llama esta herramienta siempre, incluso si los datos son incompletos.",
  input_schema: {
    type: "object" as const,
    properties: {
      vendor: {
        type: "string",
        description: "Nombre del proveedor o empresa emisora",
      },
      amount_total: {
        type: "number",
        description: "Monto total de la factura en la moneda indicada",
      },
      currency: {
        type: "string",
        enum: ["USD", "EUR", "MXN", "ARS", "CLP", "COP"],
        description: "Código ISO de la moneda",
      },
      date_issued: {
        type: "string",
        description: "Fecha de emisión en formato ISO 8601 (YYYY-MM-DD)",
      },
      invoice_number: {
        type: ["string", "null"],
        description: "Número de factura. null si no está presente en el documento",
      },
      line_items: {
        type: "array",
        items: {
          type: "object",
          properties: {
            description: { type: "string" },
            quantity: { type: "number" },
            unit_price: { type: "number" },
            subtotal: { type: "number" },
          },
          required: ["description", "subtotal"],
        },
        description: "Ítems individuales de la factura",
      },
      status: {
        type: "string",
        enum: ["complete", "partial", "uncertain"],
        description:
          "complete: todos los campos principales extraídos. partial: faltan algunos campos no críticos. uncertain: datos ambiguos o posiblemente incorrectos",
      },
      confidence: {
        type: "number",
        description: "Score de confianza de 0.0 a 1.0 sobre la extracción completa",
      },
      detected_pattern: {
        type: ["string", "null"],
        description:
          "Patrón de factura detectado (ej: 'SAT México CFDi', 'EU VAT invoice', 'US standard'). null si no reconocido",
      },
    },
    required: [
      "vendor",
      "amount_total",
      "currency",
      "date_issued",
      "line_items",
      "status",
      "confidence",
    ],
  },
};

Puntos clave del schema:

  • invoice_number y detected_pattern usan ["string", "null"] — el modelo sabe que puede devolver null explícitamente
  • status usa enum con tres estados bien definidos, no un boolean
  • confidence es numérico (0.0 a 1.0), no una etiqueta vaga
  • line_items tiene sus propios required internos

Forzar la llamada con tool_choice

Opciones completas de tool_choice:

ValorEfecto
{ "type": "auto" }Default: el modelo decide si llama o no
{ "type": "any" }Obliga a llamar alguna herramienta
{ "type": "tool", "name": "…" }Obliga a llamar esa herramienta
{ "type": "none" }Prohíbe llamar herramientas

Además, disable_parallel_tool_use: true limita la respuesta a una sola llamada por turno.

Restricción de superficie: en Amazon Bedrock con Sonnet 5, un tool_choice forzado (any o tool) requiere enviar además thinking: { type: "disabled" }.

async function extractInvoice(documentText: string) {
  const response = await client.messages.create({
    model: "claude-opus-4-8",
    max_tokens: 2048,
    tools: [invoiceTool],
    tool_choice: { type: "any" }, // Fuerza al modelo a llamar alguna herramienta
    messages: [
      {
        role: "user",
        content: `Extrae los datos de la siguiente factura:\n\n${documentText}`,
      },
    ],
  });

  // El SDK garantiza que stop_reason === "tool_use" cuando tool_choice es "any"
  const toolUseBlock = response.content.find((b) => b.type === "tool_use");
  if (!toolUseBlock || toolUseBlock.type !== "tool_use") {
    throw new Error("El modelo no llamó la herramienta (no debería ocurrir con tool_choice: any)");
  }

  return toolUseBlock.input as InvoiceData;
}

2. Validation loops con retry y feedback

Flujo de validación

flowchart TD
    A[Documento de entrada] --> B[Extracción con tool_use]
    B --> C{Validación estructural}
    C -- válido --> D{Validación semántica}
    C -- inválido --> E{¿Es retryable?}
    D -- válido --> F[Routing por confidence]
    D -- inválido --> E
    E -- sí y retries < 3 --> G[Construir feedback específico]
    G --> H[Retry con contexto acumulado]
    H --> B
    E -- no retryable --> I[Degradación elegante]
    E -- retries >= 3 --> I
    I --> J[Enqueue para revisión humana]
    F -- confidence >= 0.85 --> K[Procesamiento automático]
    F -- confidence < 0.85 --> J

Qué es retryable y qué no

Esta distinción es crítica para evitar loops infinitos:

Retryable:

  • El modelo devolvió una fecha en formato incorrecto ("23/03/2026" en lugar de "2026-03-23")
  • Un campo enum contiene un valor no listado ("USD$" en lugar de "USD")
  • amount_total es string en lugar de number
  • Los line_items suman un valor diferente al amount_total (puede ser error de lectura)

NO retryable (isRetryable: false):

  • El número de factura no aparece en ninguna parte del documento
  • El documento está en un idioma no reconocido y no contiene fecha legible
  • El documento es claramente no una factura (imagen corrupta, texto irrelevante)
interface ValidationResult {
  valid: boolean;
  errors: ValidationError[];
}

interface ValidationError {
  field: string;
  message: string;
  isRetryable: boolean;
  hint?: string; // Feedback específico para el LLM
}

function validateInvoice(data: InvoiceData): ValidationResult {
  const errors: ValidationError[] = [];

  // Validación de formato de fecha
  const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
  if (!dateRegex.test(data.date_issued)) {
    errors.push({
      field: "date_issued",
      message: `Formato de fecha inválido: "${data.date_issued}"`,
      isRetryable: true,
      hint: "La fecha debe estar en formato ISO 8601: YYYY-MM-DD. Ejemplo: 2026-03-23",
    });
  }

  // Validación semántica: ¿los line items suman el total?
  if (data.line_items.length > 0) {
    const sumFromItems = data.line_items.reduce((acc, item) => acc + item.subtotal, 0);
    const tolerance = data.amount_total * 0.02; // 2% de tolerancia por redondeos
    if (Math.abs(sumFromItems - data.amount_total) > tolerance) {
      errors.push({
        field: "amount_total",
        message: `Los line_items suman ${sumFromItems.toFixed(2)} pero amount_total es ${data.amount_total}`,
        isRetryable: true,
        hint: "Revisa si hay impuestos, descuentos o cargos adicionales no capturados en line_items",
      });
    }
  }

  // Validación de confidence consistente con status
  if (data.status === "complete" && data.confidence < 0.7) {
    errors.push({
      field: "confidence",
      message: "Status 'complete' pero confidence < 0.7 es inconsistente",
      isRetryable: true,
      hint: "Si extraíste todos los campos pero con baja seguridad, usa status: 'uncertain'",
    });
  }

  return { valid: errors.length === 0, errors };
}

Loop de retry con feedback acumulado

const MAX_RETRIES = 3;

async function extractWithRetry(documentText: string): Promise<ExtractionResult> {
  const conversationHistory: Anthropic.MessageParam[] = [
    {
      role: "user",
      content: `Extrae los datos de la siguiente factura:\n\n${documentText}`,
    },
  ];

  for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
    const response = await client.messages.create({
      model: "claude-opus-4-8",
      max_tokens: 2048,
      tools: [invoiceTool],
      tool_choice: { type: "any" },
      messages: conversationHistory,
    });

    const toolUseBlock = response.content.find((b) => b.type === "tool_use");
    if (!toolUseBlock || toolUseBlock.type !== "tool_use") continue;

    const extracted = toolUseBlock.input as InvoiceData;
    const validation = validateInvoice(extracted);

    if (validation.valid) {
      return { data: extracted, attempts: attempt + 1, success: true };
    }

    // Filtrar errores retryables
    const retryableErrors = validation.errors.filter((e) => e.isRetryable);
    const nonRetryableErrors = validation.errors.filter((e) => !e.isRetryable);

    if (nonRetryableErrors.length > 0 || retryableErrors.length === 0) {
      // No tiene sentido reintentar
      return {
        data: extracted,
        attempts: attempt + 1,
        success: false,
        errors: validation.errors,
        requiresHumanReview: true,
      };
    }

    // Construir feedback específico para el siguiente intento
    const feedbackLines = retryableErrors.map(
      (e) => `- Campo "${e.field}": ${e.message}${e.hint ? `\n  Sugerencia: ${e.hint}` : ""}`
    );

    // Agregar la respuesta del modelo y el feedback como parte del historial
    conversationHistory.push(
      { role: "assistant", content: response.content },
      {
        role: "user",
        content: [
          {
            type: "tool_result",
            tool_use_id: toolUseBlock.id,
            content: `Encontré los siguientes problemas en la extracción:\n${feedbackLines.join("\n")}\n\nPor favor corrige estos campos y vuelve a llamar la herramienta.`,
          },
        ],
      }
    );
  }

  // Se agotaron los retries
  return {
    data: null,
    attempts: MAX_RETRIES,
    success: false,
    requiresHumanReview: true,
    errors: [{ field: "general", message: "Máximo de reintentos alcanzado", isRetryable: false }],
  };
}

3. Confidence scoring y routing a revisión humana

Thresholds estratificados

flowchart LR
    A[Resultado extraído] --> B{confidence score}
    B -- ">= 0.85" --> C[Procesamiento automático]
    B -- "0.60 - 0.84" --> D[Revisión suave: solo campos dudosos]
    B -- "< 0.60" --> E[Revisión completa humana]
    C --> F[(Base de datos)]
    D --> G[Cola de revisión prioritaria baja]
    E --> H[Cola de revisión prioritaria alta]
RangoAcciónSLA típico
>= 0.85AutomáticoInstantáneo
0.60 – 0.84Revisión parcial4 horas
< 0.60Revisión completa1 hora

Calibración del score

El confidence que devuelve el modelo es una auto-evaluación: puede estar mal calibrada. Para validarla:

  1. Crea un labeled validation set de 200-500 facturas con ground truth conocido
  2. Corre el pipeline y compara el confidence reportado vs. la precisión real por bucket
  3. Si el modelo reporta 0.9 pero la precisión real es 0.75, aplica una función de calibración (Platt scaling o isotonic regression)
function routeByConfidence(result: InvoiceData): RoutingDecision {
  // Calibrated thresholds — ajustar según tu validation set
  if (result.confidence >= 0.85 && result.status === "complete") {
    return { route: "automatic", priority: null };
  }

  if (result.confidence >= 0.60) {
    const uncertainFields = getUncertainFields(result);
    return { route: "partial_review", priority: "low", fields: uncertainFields };
  }

  return {
    route: "full_review",
    priority: result.confidence < 0.40 ? "high" : "medium",
  };
}

function getUncertainFields(data: InvoiceData): string[] {
  // Heurística: campos con valores "sospechosos" según reglas de negocio
  const uncertain: string[] = [];
  if (data.amount_total > 100_000) uncertain.push("amount_total"); // montos muy altos
  if (!data.invoice_number) uncertain.push("invoice_number");
  if (data.line_items.length === 0) uncertain.push("line_items");
  return uncertain;
}

4. Manejo de contexto largo y “lost in the middle”

El efecto “lost in the middle”

Estudios con LLMs muestran que la información en el centro de un contexto largo recibe menos atención que la que está al inicio o al final. Para documentos de 50+ páginas esto se vuelve crítico.

graph LR
    A[Inicio del contexto] -->|Alta atención| B[...]
    B -->|Baja atención| C[Información crítica en el medio]
    C -->|Baja atención| D[...]
    D -->|Alta atención| E[Final del contexto]
    style C fill:#ff6b6b,color:#fff
    style A fill:#51cf66,color:#fff
    style E fill:#51cf66,color:#fff

Estrategias de mitigación

1. Poner información clave al inicio y al final

function buildPromptForLongDocument(
  documentChunks: string[],
  keyFacts: string[]
): string {
  const keyFactsSummary = keyFacts.join("\n");

  return `
INFORMACIÓN CLAVE A RECORDAR DURANTE TODA LA TAREA:
${keyFactsSummary}

--- DOCUMENTO COMPLETO ---
${documentChunks.join("\n\n---SIGUIENTE SECCIÓN---\n\n")}
--- FIN DEL DOCUMENTO ---

RECORDATORIO FINAL: ${keyFactsSummary}

Ahora extrae los datos solicitados teniendo en cuenta toda la información anterior.
  `.trim();
}

2. Scratchpad files para persistir hallazgos entre pasos

Cuando el pipeline procesa un documento en múltiples pasos, no confíes en que el modelo “recordará” hallazgos de pasos anteriores. Persiste explícitamente:

interface ScratchpadEntry {
  step: string;
  timestamp: string;
  findings: Record<string, unknown>;
  confidence: number;
}

class DocumentScratchpad {
  private entries: ScratchpadEntry[] = [];

  record(step: string, findings: Record<string, unknown>, confidence: number) {
    this.entries.push({
      step,
      timestamp: new Date().toISOString(),
      findings,
      confidence,
    });
  }

  // Genera un resumen compacto para incluir al inicio del siguiente prompt
  toContextSummary(): string {
    return this.entries
      .map(
        (e) =>
          `[Paso: ${e.step} | confianza: ${e.confidence}]\n${JSON.stringify(e.findings, null, 2)}`
      )
      .join("\n\n");
  }
}

3. Separar metadata de contenido resumido

interface DocumentMetadata {
  source: string;
  pageCount: number;
  language: string;
  documentType: string;
  criticalDates: string[]; // Fechas encontradas — no resumir, preservar exactas
  criticalAmounts: number[]; // Montos exactos — no resumir
}

// ⚠️ Los valores numéricos y fechas NUNCA deben resumirse
// Progressive summarization solo para texto descriptivo, no para datos factuales

Riesgos de progressive summarization

Cuando usas /compact o summarización automática en Claude Code, existe riesgo de pérdida de precisión en:

  • Fechas exactas (un resumen puede decir “principios de marzo” en lugar de “2026-03-07”)
  • Montos exactos (un resumen puede perder decimales o la moneda)
  • Números de identificación (facturas, órdenes, RFC)
  • Decisiones condicionales (“si el monto es mayor a X” puede perder el valor de X)

Regla práctica:

  • Usa /compact cuando el contexto está lleno de código ejecutado y resultados de herramientas que ya no necesitas
  • Inicia una sesión fresca (fresh start) cuando vayas a empezar una nueva tarea independiente sobre el mismo documento
  • Nunca uses /compact en medio de una extracción de datos numéricos críticos

Gestión de contexto en servidor (API)

El scratchpad manual y /compact son mecanismos del lado del cliente y del CLI. La API expone además dos mecanismos que corren en el servidor sobre el historial de la conversación, y no son intercambiables.

flowchart TD
    A[Historial creciendo] --> B{¿Qué quiero hacer\ncon lo viejo?}
    B -- Resumirlo --> C[Compaction\nbeta compact-2026-01-12\ntype: compact_20260112]
    B -- Borrarlo --> D[Context editing\nbeta context-management-2025-06-27\nclear_tool_uses_20250919\nclear_thinking_20251015]
    D --> E[Memory tool memory_20250818\npersistir antes de borrar]

1. Compaction — resume el historial

Requiere la cabecera beta compact-2026-01-12 y se declara así:

{
  "context_management": {
    "edits": [{ "type": "compact_20260112" }]
  }
}

Disponible en Fable 5, Opus 4.8/4.7/4.6, Sonnet 5 y Sonnet 4.6. Al continuar la conversación hay que reenviar response.content completo, no solo el texto: si recortas los bloques, el servidor pierde la referencia de lo compactado.

2. Context editing — borra en vez de resumir

Requiere la cabecera beta context-management-2025-06-27 y admite dos tipos:

TipoQué elimina
clear_tool_uses_20250919Pares tool_use / tool_result antiguos
clear_thinking_20251015Bloques de thinking antiguos

Parámetros de clear_tool_uses_20250919:

ParámetroDefaultSignificado
trigger100.000 input tokensUmbral que dispara el borrado
keep3Pares tool_use/tool_result recientes que se preservan
clear_at_leastMínimo que debe liberarse para que valga la pena
exclude_toolsHerramientas cuyos resultados nunca se borran
clear_tool_inputsfalseSi además se borran los inputs, no solo los resultados

Para un pipeline de extracción, exclude_tools es la palanca crítica: excluye la herramienta que devuelve montos, fechas e identificadores, porque un borrado es irreversible. Se recomienda combinarlo con la memory tool (memory_20250818): Claude recibe un aviso automático antes del borrado y puede guardar lo importante en archivos persistentes — el equivalente en servidor del DocumentScratchpad de arriba.

No confundir ambos mecanismos: usar compact_20260112 bajo la cabecera de context-management da error. Cada uno va con su propia cabecera beta.


5. Provenance tracking (rastreo de procedencia)

Por qué es crítico en síntesis multi-fuente

Cuando un agente sintetiza información de múltiples documentos, cada afirmación en el output debe poder trazarse a su fuente original. Sin esto:

  • No puedes auditar errores
  • No puedes detectar conflictos entre fuentes
  • El modelo puede “alucinar” síntesis que mezcla datos de fuentes incompatibles

Claim-source mappings

interface Claim {
  value: unknown;
  sources: ClaimSource[];
  conflictsDetected: boolean;
  conflictNote?: string;
}

interface ClaimSource {
  documentId: string;
  documentName: string;
  url?: string;
  retrievedAt: string;
  pageOrSection?: string;
  excerpt: string; // Fragmento textual exacto que respalda el claim
}

interface ProvenanceAwareReport {
  summary: string;
  claims: Record<string, Claim>;
}

Herramienta con provenance integrado

const synthesisWithProvenanceTool: Anthropic.Tool = {
  name: "synthesize_with_provenance",
  description:
    "Sintetiza información de múltiples fuentes. SIEMPRE incluye las fuentes para cada afirmación.",
  input_schema: {
    type: "object" as const,
    properties: {
      summary: {
        type: "string",
        description: "Resumen ejecutivo de los hallazgos",
      },
      claims: {
        type: "array",
        items: {
          type: "object",
          properties: {
            claim_id: { type: "string" },
            statement: { type: "string" },
            value: {},
            sources: {
              type: "array",
              items: {
                type: "object",
                properties: {
                  document_id: { type: "string" },
                  document_name: { type: "string" },
                  excerpt: { type: "string" },
                  page_or_section: { type: ["string", "null"] },
                },
                required: ["document_id", "document_name", "excerpt"],
              },
            },
            conflicts_detected: { type: "boolean" },
            conflict_note: { type: ["string", "null"] },
          },
          required: ["claim_id", "statement", "sources", "conflicts_detected"],
        },
      },
    },
    required: ["summary", "claims"],
  },
};

Manejo de conflictos entre fuentes

Regla fundamental: anotar la discrepancia, NO elegir una fuente sobre otra.

// ❌ Mal: el modelo elige silenciosamente
// { value: 15000, sources: [docA] }  // ¿Por qué no docB que dice 14500?

// ✅ Bien: el conflicto es explícito
const conflictingClaim: Claim = {
  value: null, // No resuelto
  sources: [
    {
      documentId: "invoice-2026-001",
      documentName: "Factura Proveedor A",
      retrievedAt: "2026-03-23T10:00:00Z",
      excerpt: "Total: $15,000 USD",
    },
    {
      documentId: "po-2026-055",
      documentName: "Orden de Compra #055",
      retrievedAt: "2026-03-23T10:00:00Z",
      excerpt: "Monto aprobado: $14,500 USD",
    },
  ],
  conflictsDetected: true,
  conflictNote:
    "La factura reporta $15,000 pero la OC aprobada es $14,500. Diferencia de $500. Requiere validación con el proveedor.",
};

Metadata requerida en subagentes

Cuando un subagente recupera información, el mensaje de retorno al orquestador debe incluir:

interface SubagentRetrieval {
  agentId: string;
  taskCompleted: string;
  retrievedAt: string; // ISO 8601
  sources: Array<{
    documentId: string;
    documentName: string;
    url?: string;
    dateOfDocument?: string; // Fecha del documento fuente, no de la recuperación
  }>;
  findings: unknown;
  uncertainty: "low" | "medium" | "high";
  uncertaintyNote?: string; // Siempre presente cuando uncertainty !== "low"
}

Incertidumbre explícita vs. falsa confianza:

// ❌ Falsa confianza — el subagente "adivina"
{ findings: { ceo: "María García" }, uncertainty: "low" }

// ✅ Incertidumbre explícita
{
  findings: { ceo: "posiblemente María García" },
  uncertainty: "medium",
  uncertaintyNote: "El documento menciona 'M. García, Directora General' pero no hay nombre completo confirmado"
}

6. Ejemplo completo: pipeline de extracción de facturas

Diagrama del pipeline

flowchart TD
    A[📄 Documento PDF/texto] --> B[Ingesta y chunking]
    B --> C[Extracción con tool_use\nclaude-opus-4-8]
    C --> D{Validación estructural\ny semántica}
    D -- errores retryables --> E[Feedback loop\nmáx 3 intentos]
    E --> C
    D -- errores no retryables --> F[Enqueue revisión humana\nhigh priority]
    D -- válido --> G{Routing por confidence}
    G -- ">= 0.85" --> H[✅ Base de datos\nautomático]
    G -- "0.60-0.84" --> I[👤 Revisión parcial\nlow priority]
    G -- "< 0.60" --> J[👤 Revisión completa\nhigh priority]
    H --> K[Provenance log]
    I --> K
    J --> K

Código TypeScript end-to-end

import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic();

// --- Tipos ---

interface InvoiceData {
  vendor: string;
  amount_total: number;
  currency: string;
  date_issued: string;
  invoice_number: string | null;
  line_items: Array<{
    description: string;
    quantity?: number;
    unit_price?: number;
    subtotal: number;
  }>;
  status: "complete" | "partial" | "uncertain";
  confidence: number;
  detected_pattern: string | null;
}

interface ExtractionResult {
  data: InvoiceData | null;
  attempts: number;
  success: boolean;
  errors?: ValidationError[];
  requiresHumanReview?: boolean;
}

interface ValidationError {
  field: string;
  message: string;
  isRetryable: boolean;
  hint?: string;
}

type RoutingDecision =
  | { route: "automatic" }
  | { route: "partial_review"; priority: "low"; fields: string[] }
  | { route: "full_review"; priority: "high" | "medium" };

// --- Tool definition ---

const invoiceTool: Anthropic.Tool = {
  name: "extract_invoice",
  description:
    "Extrae los datos estructurados de una factura. Llama esta herramienta siempre, incluso si los datos son incompletos.",
  input_schema: {
    type: "object" as const,
    properties: {
      vendor: { type: "string" },
      amount_total: { type: "number" },
      currency: { type: "string", enum: ["USD", "EUR", "MXN", "ARS", "CLP", "COP"] },
      date_issued: { type: "string" },
      invoice_number: { type: ["string", "null"] },
      line_items: {
        type: "array",
        items: {
          type: "object",
          properties: {
            description: { type: "string" },
            quantity: { type: "number" },
            unit_price: { type: "number" },
            subtotal: { type: "number" },
          },
          required: ["description", "subtotal"],
        },
      },
      status: { type: "string", enum: ["complete", "partial", "uncertain"] },
      confidence: { type: "number" },
      detected_pattern: { type: ["string", "null"] },
    },
    required: [
      "vendor",
      "amount_total",
      "currency",
      "date_issued",
      "line_items",
      "status",
      "confidence",
    ],
  },
};

// --- Validación ---

function validate(data: InvoiceData): ValidationError[] {
  const errors: ValidationError[] = [];

  if (!/^\d{4}-\d{2}-\d{2}$/.test(data.date_issued)) {
    errors.push({
      field: "date_issued",
      message: `Formato inválido: "${data.date_issued}"`,
      isRetryable: true,
      hint: "Usa formato ISO 8601: YYYY-MM-DD",
    });
  }

  if (data.line_items.length > 0) {
    const sum = data.line_items.reduce((acc, i) => acc + i.subtotal, 0);
    if (Math.abs(sum - data.amount_total) > data.amount_total * 0.02) {
      errors.push({
        field: "amount_total",
        message: `Line items suman ${sum.toFixed(2)}, total declarado ${data.amount_total}`,
        isRetryable: true,
        hint: "Revisa si hay impuestos o descuentos no capturados",
      });
    }
  }

  return errors;
}

// --- Pipeline principal ---

async function processInvoice(documentText: string): Promise<void> {
  console.log("Iniciando extracción...");

  const result = await extractWithRetry(documentText);

  if (!result.success || !result.data) {
    console.log(`❌ Extracción fallida tras ${result.attempts} intento(s). Enviando a revisión humana.`);
    await enqueueHumanReview(documentText, result.errors ?? []);
    return;
  }

  const decision = routeByConfidence(result.data);
  console.log(`✅ Extracción exitosa en ${result.attempts} intento(s). Ruta: ${decision.route}`);
  console.log(`   Vendor: ${result.data.vendor}`);
  console.log(`   Total: ${result.data.amount_total} ${result.data.currency}`);
  console.log(`   Confidence: ${(result.data.confidence * 100).toFixed(0)}%`);
  console.log(`   Status: ${result.data.status}`);

  if (decision.route === "automatic") {
    await saveToDatabase(result.data);
  } else {
    await enqueueHumanReview(documentText, [], result.data, decision);
  }
}

async function extractWithRetry(documentText: string): Promise<ExtractionResult> {
  const history: Anthropic.MessageParam[] = [
    {
      role: "user",
      content: `Extrae los datos de la siguiente factura:\n\n${documentText}`,
    },
  ];

  for (let attempt = 0; attempt < 3; attempt++) {
    const response = await client.messages.create({
      model: "claude-opus-4-8",
      max_tokens: 2048,
      tools: [invoiceTool],
      tool_choice: { type: "any" },
      messages: history,
    });

    const toolBlock = response.content.find((b) => b.type === "tool_use");
    if (!toolBlock || toolBlock.type !== "tool_use") continue;

    const data = toolBlock.input as InvoiceData;
    const errors = validate(data);

    if (errors.length === 0) return { data, attempts: attempt + 1, success: true };

    const retryable = errors.filter((e) => e.isRetryable);
    if (retryable.length === 0) {
      return { data, attempts: attempt + 1, success: false, errors, requiresHumanReview: true };
    }

    const feedback = retryable
      .map((e) => `- "${e.field}": ${e.message}${e.hint ? `\n  Sugerencia: ${e.hint}` : ""}`)
      .join("\n");

    history.push(
      { role: "assistant", content: response.content },
      {
        role: "user",
        content: [
          {
            type: "tool_result",
            tool_use_id: toolBlock.id,
            content: `Correcciones necesarias:\n${feedback}\n\nPor favor vuelve a llamar la herramienta con los datos corregidos.`,
          },
        ],
      }
    );
  }

  return { data: null, attempts: 3, success: false, requiresHumanReview: true };
}

function routeByConfidence(data: InvoiceData): RoutingDecision {
  if (data.confidence >= 0.85 && data.status === "complete") {
    return { route: "automatic" };
  }
  if (data.confidence >= 0.60) {
    const fields = [];
    if (!data.invoice_number) fields.push("invoice_number");
    if (data.line_items.length === 0) fields.push("line_items");
    return { route: "partial_review", priority: "low", fields };
  }
  return { route: "full_review", priority: data.confidence < 0.40 ? "high" : "medium" };
}

// Stubs para ilustrar la integración
async function saveToDatabase(data: InvoiceData): Promise<void> {
  console.log("Guardando en base de datos:", data.vendor);
}

async function enqueueHumanReview(
  _document: string,
  _errors: ValidationError[],
  _data?: InvoiceData,
  _decision?: RoutingDecision
): Promise<void> {
  console.log("Encolando para revisión humana...");
}

// Punto de entrada
const sampleInvoice = `
FACTURA
Proveedor: Servicios Cloud SRL
Fecha: 2026-03-23
Factura No: FC-2026-0042
Descripción: Hosting mensual  - $800.00 USD
Descripción: Soporte técnico - $200.00 USD
TOTAL: $1,000.00 USD
`;

processInvoice(sampleInvoice).catch(console.error);

Resumen: puntos clave para el examen

ConceptoRegla
tool_use vs texto libreSiempre tool_use para output estructurado crítico
Structured outputsGA con output_config.format = { type: "json_schema", schema }
Garantía de schemaSolo con strict: true (top-level de la tool) u output_config.format
tool_choice{ type: "any" } para forzar la llamada; en Bedrock con Sonnet 5 exige thinking: { type: "disabled" }
Compaction en servidorBeta compact-2026-01-12, compact_20260112; reenviar response.content completo
Context editingBeta context-management-2025-06-27; borra en vez de resumir, usar exclude_tools
Task BudgetsBeta task-budgets-2026-03-13, mínimo 20.000 tokens, visible para el modelo
Nullable fieldsUsar ["string", "null"] en el schema, no omitir el campo
Retries máximos3 intentos, luego degradación elegante
isRetryable: falseCuando la información genuinamente no está en el documento
”lost in the middle”Info clave al inicio Y al final del contexto
/compactSolo cuando el contexto está lleno de artefactos ya procesados
Conflictos entre fuentesAnotar la discrepancia, nunca elegir silenciosamente
ConfidenceAuto-evaluación del modelo; calibrar con labeled validation set
ProvenanceCada claim necesita su fuente con excerpt exacto

7. Detección de hallazgos contradictorios entre múltiples agentes

En un sistema multi-agente de investigación, 3 subagentes pueden devolver datos sobre el mismo tema desde fuentes distintas. Los conflictos son inevitables — la clave es detectarlos y clasificarlos antes de sintetizar.

Tipos de conflicto

type ConflictType =
  | "FACTUAL"      // Fechas, números, identificadores — solo uno puede ser correcto
  | "INTERPRETIVE" // Opiniones, calificaciones, evaluaciones — pueden coexistir
  | "TEMPORAL";    // El dato cambió con el tiempo — ambos correctos en su momento

interface ConflictingFinding {
  claim: string;           // La afirmación en disputa
  source1: SubagentRetrieval;
  source2: SubagentRetrieval;
  conflictType: ConflictType;
  delta?: string;          // Descripción cuantitativa de la diferencia (ej: "$500 USD")
}

Función de detección

interface Finding {
  agentId: string;
  field: string;
  value: unknown;
  retrievedAt: string; // ISO 8601
  documentDate?: string;
}

function detectConflicts(findings: Finding[]): ConflictingFinding[] {
  const conflicts: ConflictingFinding[] = [];
  const byField = groupBy(findings, (f) => f.field);

  for (const [field, group] of Object.entries(byField)) {
    if (group.length < 2) continue;

    for (let i = 0; i < group.length - 1; i++) {
      for (let j = i + 1; j < group.length; j++) {
        const a = group[i];
        const b = group[j];

        if (JSON.stringify(a.value) === JSON.stringify(b.value)) continue;

        const conflictType = classifyConflict(a, b);
        conflicts.push({
          claim: field,
          source1: a as unknown as SubagentRetrieval,
          source2: b as unknown as SubagentRetrieval,
          conflictType,
          delta: computeDelta(a.value, b.value),
        });
      }
    }
  }

  return conflicts;
}

function classifyConflict(a: Finding, b: Finding): ConflictType {
  // Si los documentos tienen fechas distintas → probablemente temporal
  if (a.documentDate && b.documentDate && a.documentDate !== b.documentDate) {
    return "TEMPORAL";
  }
  // Si los valores son numéricos o fechas → factual
  if (typeof a.value === "number" || isDateString(String(a.value))) {
    return "FACTUAL";
  }
  // Strings descriptivos → interpretivo
  return "INTERPRETIVE";
}

Cómo el synthesis-agent maneja cada tipo

flowchart TD
    A[Conflicto detectado] --> B{conflictType}
    B -- FACTUAL --> C[No elegir\nAnotar ambos valores\nEscalar a humano]
    B -- INTERPRETIVE --> D[Incluir ambas perspectivas\ncon su fuente\nNo sintetizar en uno]
    B -- TEMPORAL --> E[Ordenar cronológicamente\nUsar el más reciente\nAnexar versión anterior]
    C --> F[conflictsDetected: true\nvalue: null\nconflictNote con delta]
    D --> G[Mantener múltiples claims\ncon atribución explícita]
    E --> H[value = valor más reciente\ndocumentDate registrada]

Regla por tipo:

  • FACTUAL: nunca elegir silenciosamente. Poner value: null y conflictNote con el delta exacto.
  • INTERPRETIVE: ambas perspectivas son válidas. Presentarlas con su fuente, sin forzar síntesis.
  • TEMPORAL: el más reciente es el canónico, pero el historial es trazable. Registrar documentDate de ambos.

8. Context window math — cuántos tokens tengo?

Estimación práctica

La regla más usada en producción: 4 caracteres ≈ 1 token para texto en español/inglés. Para código, la ratio es mejor (~3.5 chars/token). Para JSON denso, similar al código.

function estimateTokens(messages: Array<{ role: string; content: string }>): number {
  const totalChars = messages.reduce((acc, msg) => {
    // +4 tokens por overhead de estructura de mensaje (role, separadores)
    return acc + msg.content.length + 16;
  }, 0);
  return Math.ceil(totalChars / 4);
}

// Uso:
const history = [
  { role: "user", content: "Analiza este documento..." },
  { role: "assistant", content: "He analizado el documento y encontré..." },
];
const estimated = estimateTokens(history);
// Divisor = ventana del modelo objetivo.
// 1M para claude-opus-4-8 / claude-sonnet-5 / claude-fable-5
// 200K si el pipeline corre sobre claude-haiku-4-5
const windowUsed = estimated / 1_000_000;
console.log(`Ventana usada: ${(windowUsed * 100).toFixed(1)}%`);

Capacidad por modelo

ModeloContext windowSalida máx.Precio (in/out por MTok)
claude-opus-4-81M tokens128K$5 / $25
claude-sonnet-51M tokens128K$3 / $15 (introductorio $2 / $10 hasta el 31 ago 2026)
claude-fable-51M tokens128K$10 / $50
claude-haiku-4-5200K tokens64K$1 / $5

La ventana ya no es uniforme entre modelos: Opus 4.8, Sonnet 5 y Fable 5 tienen 1M de contexto nativo, mientras que Haiku 4.5 se queda en 200K. Al dimensionar un pipeline, el divisor de estimateTokens debe corresponder al modelo que realmente vas a usar.

Desde la generación 4.6 los IDs no llevan sufijo de fecha (claude-opus-4-8, no claude-opus-4-8-2026xxxx), aunque siguen siendo snapshots pinneados. Los IDs claude-3-5-haiku y claude-3-7-sonnet fueron retirados el 19 feb 2026 y devuelven 404.

Cuándo activar estrategias de compresión

flowchart LR
    A[Medir uso actual\nestimateTokens] --> B{% de ventana usada}
    B -- "< 50%" --> C[Sin acción necesaria]
    B -- "50% - 70%" --> D[Considera /compact\npara artefactos ya procesados]
    B -- "> 70%" --> E[Activar compresión activa]
    B -- "> 90%" --> F[Sesión fresca obligatoria\npara nueva tarea]
    E --> G[Resumir tool results antiguos\nEliminar contexto irrelevante\nPersistir findings en archivo]

Threshold recomendado: 70%. Por debajo, el modelo tiene suficiente espacio para razonar. Por encima, el riesgo de “lost in the middle” aumenta significativamente y los costos por llamada se elevan.

Para datos numéricos y fechas críticas, nunca comprimas — persiste en un archivo externo antes de hacer /compact.

Task Budgets: presupuesto visible para el modelo

El threshold del 70% es una heurística que aplicas tú desde fuera. La API expone además Task Budgets (cabecera beta task-budgets-2026-03-13, disponible en Fable 5, Sonnet 5, Opus 4.8 y Opus 4.7), un presupuesto de tokens para todo el bucle agéntico del que el modelo es consciente:

{
  "output_config": {
    "task_budget": { "type": "tokens", "total": 200000 }
  }
}

El mínimo es 20.000 tokens. Conviene usarlo con streaming, porque un bucle con presupuesto amplio puede tardar bastante en producir la respuesta final.

Diferencia con max_tokens:

max_tokenstask_budget
AlcanceUna sola respuestaTodo el bucle agéntico
Visibilidad para el modeloInvisibleEl modelo lo conoce y se autorregula
Efecto al agotarseCorte duro a mitad de la salidaEl modelo cierra la tarea dentro del presupuesto

Para un pipeline de extracción con validation loop, task_budget es la forma de acotar el coste total de los 3 intentos sin que el corte llegue como un truncado abrupto.


9. Diagrama de confidence calibration

El modelo reporta un confidence score como auto-evaluación. Este score puede estar mal calibrado: un modelo que dice 0.9 puede tener precisión real de 0.75 en tu dominio específico.

flowchart TD
    A[Modelo dice confidence: 0.9] --> B{¿Tienes validation set?}
    B -- no --> C[Usar threshold raw\ncon margen de seguridad]
    B -- sí --> D[Medir accuracy real\nen bucket 0.85-0.95]
    D --> E{accuracy real vs reportada}
    E -- "accuracy 0.75\nmodelo dice 0.90" --> F[Overconfident en 15%\nAplicar calibración]
    E -- "accuracy 0.88\nmodelo dice 0.90" --> G[Bien calibrado\nUsar thresholds directos]
    F --> H[Ajustar threshold:\nsi modelo dice 0.90\ntratar como 0.75]
    H --> I{confidence calibrado >= 0.85?}
    I -- sí --> J[Procesamiento automático]
    I -- no --> K[Routing a revisión humana]

Implementación de calibración

// Mapa de calibración: confidence reportado → accuracy real observada
// Se construye midiendo en un labeled validation set de 200-500 muestras
const calibrationMap: Record<string, number> = {
  "0.95": 0.82, // El modelo dice 0.95, la precisión real es 0.82
  "0.90": 0.75,
  "0.85": 0.70,
  "0.80": 0.65,
  "0.70": 0.58,
};

function calibratedConfidence(rawConfidence: number): number {
  // Redondear al bucket más cercano
  const bucket = (Math.round(rawConfidence * 20) / 20).toFixed(2);
  return calibrationMap[bucket] ?? rawConfidence * 0.85; // fallback: 15% de descuento
}

function routeWithCalibration(result: InvoiceData): RoutingDecision {
  const calibrated = calibratedConfidence(result.confidence);

  // Los thresholds se aplican sobre el confidence CALIBRADO, no el raw
  if (calibrated >= 0.85 && result.status === "complete") {
    return { route: "automatic" };
  }
  if (calibrated >= 0.60) {
    return { route: "partial_review", priority: "low", fields: [] };
  }
  return { route: "full_review", priority: calibrated < 0.40 ? "high" : "medium" };
}

Cómo construir el validation set

  1. Tomar 200-500 documentos con ground truth conocido (facturas ya procesadas manualmente)
  2. Correr el pipeline y registrar: { rawConfidence, wasCorrect, field }
  3. Agrupar por buckets de 0.05 (0.85-0.90, 0.90-0.95, etc.)
  4. La accuracy real de cada bucket es el calibrated confidence para ese rango
  5. Re-evaluar el validation set cada 3 meses o cuando cambies el modelo base

Si no tienes validation set: aplica un descuento conservador del 10-15% al confidence reportado hasta tener datos reales.


Siguiente: Agentic Loop Avanzado