Disenar extraccion estructurada Claude como contrato de datos: JSON Schema defensivo (required reales, union nullable, enum con valvula de escape), tool_choice forzado y parseo desde tool_use.input.
Diseñar la extracción estructurada de un modelo Claude como un contrato de datos verificable, no como prosa que luego se parsea. Se define un JSON Schema defensivo y se fuerza tool_choice para que el modelo emita exactamente ese schema: required que reflejan campos realmente presentes en la fuente, uniones nullable para opcionales (en vez de defaults silenciosos como '' o 0), y enums con válvula de escape ('other' + *_details) para no perder señal cuando el dato no encaja en el catálogo. El resultado es una salida parseable de forma determinista, auditable y resistente a alucinación de campos. [DOC]
json.loads(text) se rompe de forma intermitente (code fences, prosa adicional). [INFERENCIA]'', 0, "N/A") que contamina el dataset aguas abajo. [DOC]tool_choice="auto". Forzar mata la decisión real. [INFERENCIA] → ver kata multi_tool_choice_boundary.null. [DOC]input_schema defensivo; (2) configuración de tool_choice; (3) ruta de parseo desde tool_use.input; (4) gate de validación contra schema con salida a retry/escalada. [DOC]required) de lo que aparece a veces (→ opcional). No marques required por deseo: marca por presencia real. [DOC]["string", "null"], nunca string con default ''. Ausente debe ser representable como null, no como cadena vacía. [DOC]'other' y un campo hermano *_details para capturar el valor textual cuando no encaja. El catálogo evoluciona con evidencia en vez de perder filas. [DOC]type=object + additionalProperties=false: el modelo no puede inventar campos fuera del contrato. [CONFIG]input_schema. El schema vive en la definición de la herramienta, no en el prompt en prosa. [DOC]tool_choice solo cuando no hay decisión de tool que tomar. Si la única acción válida es emitir la estructura → tool_choice={"type":"tool","name":"..."}. Si el modelo debe elegir entre varias → auto, no fuerces. [DOC]tool_use.input, nunca desde el texto. El consumidor lee el bloque tool_use tipado; ni regex ni json.loads sobre prosa. [DOC]stop_reason anómalo, refusal, schema inválido). [DOC]# GOOD: defensive schema, forced tool_choice, parse from tool_use.input
extract_invoice = {
"name": "extract_invoice",
"description": "Emit invoice fields exactly as they appear in the source document.",
"input_schema": {
"type": "object",
"additionalProperties": False, # closed: no invented fields
"properties": {
"invoice_id": {"type": "string"}, # required: always present
"total_amount": {"type": "number"}, # required: always present
"due_date": {"type": ["string", "null"]}, # optional -> nullable, not ""
"status": {
"type": "string",
"enum": ["paid", "pending", "overdue", "other"], # escape valve
},
"status_details": {"type": ["string", "null"]}, # captures 'other'
},
"required": ["invoice_id", "total_amount", "status"],
},
}
resp = client.messages.create(
model="claude-opus-4-1",
max_tokens=1024,
tools=[extract_invoice],
tool_choice={"type": "tool", "name": "extract_invoice"}, # forced
messages=[{"role": "user", "content": document_text}],
)
block = next(b for b in resp.content if b.type == "tool_use")
data = block.input # typed dict, no json.loads on prose
validate(data, extract_invoice["input_schema"]) # gate before accepting# ANTI: "return JSON" in prose + json.loads(text); empty-string defaults; closed enum
resp = client.messages.create(
model="claude-opus-4-1",
max_tokens=1024,
messages=[{
"role": "user",
"content": document_text + "\n\nReturn the invoice as JSON.",
}],
)
text = resp.content[0].text
data = json.loads(text) # breaks when the model adds prose or a code fence
due = data.get("due_date", "") # "" hides a genuinely-absent value
status = data["status"] # closed enum drops every unforeseen real case
# ...and on failure: regex fallback over free text -> silent garbage downstreamstop_reason="max_tokens" con tool_use truncado → el JSON está incompleto; no lo aceptes, sube max_tokens o particiona la fuente, y reintenta. [INFERENCIA]text y no tool_use pese a forzar la tool → trátalo como refusal/error; ruta a escalada, nunca parsees el texto. [INFERENCIA]required llega ausente en datos reales → el inventario estaba mal; degrádalo a nullable, no fuerces un default. [INFERENCIA]'other' → señal de catálogo incompleto; revisa *_details y promueve los casos recurrentes a categorías nuevas. [INFERENCIA]null (columna NOT NULL) → resuélvelo en el consumidor (default explícito documentado), no metiendo '' en el schema. [SUPUESTO]required corresponde a algo realmente presente en la fuente (no a un deseo)? [DOC]nullable y se eliminó todo default ''/0/"N/A"? Ausente = null/unclear, no cadena vacía. [DOC]additionalProperties=false) con propiedades declaradas? [CONFIG]'other' + *_details)? [DOC]tool_choice se fuerza solo cuando no hay una decisión de tool legítima que tomar? [DOC]tool_use.input y nunca desde texto en prosa ni con regex? [DOC]assets/structured-output-design-contract.json y pasa scripts/check.sh con fixtures determinísticas (positivas y negativas)? [CÓDIGO]assets/structured-output-design-contract.json define el paquete JSON que documenta una tool estructurada. [CÓDIGO]assets/json-schema-policy.json exige type=object, additionalProperties=false, required reales y propiedades declaradas. [CÓDIGO]assets/nullable-policy.json prohíbe defaults falsos y requiere unión con null. [CÓDIGO]assets/enum-escape-policy.json exige other + campo *_details para todo enum cerrado. [CÓDIGO]assets/tool-choice-policy.json fija tool_choice={"type":"tool","name":...} cuando la única acción válida es emitir la estructura. [CÓDIGO]assets/refusal-error-policy.json exige canal de error/refusal y parseo desde tool_use.input. [CÓDIGO]scripts/validate_structured_output_design.py valida el paquete offline; scripts/check.sh ejecuta fixtures positivas y negativas. [CÓDIGO]05. [CONFIG]katas-defensive-structured-extraction, katas-headless-code-review. [CONFIG]validation-retry-design, self-correction-loops, provenance-engineering. [CONFIG]fdad39c
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.