Skip to content

Commit c204c6f

Browse files
Merge pull request #8 from appunite/feature/flatten-structured-output
Flatten schema for structured output compatibility
2 parents b55ac25 + a586bc7 commit c204c6f

8 files changed

Lines changed: 393 additions & 474 deletions

File tree

README.md

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ curl -X POST http://localhost:8000/extract \
7676
-F "file=@invoice.pdf"
7777
```
7878

79-
**Custom schema** (all fields must be `required`; use `anyOf` with `null` for nullable fields):
79+
**Custom schema:**
8080

8181
```bash
8282
curl -X POST http://localhost:8000/extract \
@@ -90,16 +90,15 @@ curl -X POST http://localhost:8000/extract \
9090
"description": "Unique invoice identifier (e.g. INV-2025-001)"
9191
},
9292
"total_amount": {
93-
"anyOf": [{"type": "number"}, {"type": "null"}],
93+
"type": "number",
9494
"description": "Total invoice amount including tax"
9595
},
9696
"vendor_name": {
9797
"type": "string",
9898
"description": "Name of the company that issued the invoice"
9999
}
100100
},
101-
"required": ["invoice_number", "total_amount", "vendor_name"],
102-
"additionalProperties": false
101+
"required": ["invoice_number", "total_amount", "vendor_name"]
103102
}'
104103
```
105104

@@ -116,6 +115,26 @@ curl -X POST http://localhost:8000/extract \
116115
}
117116
```
118117

118+
#### Custom schema constraints
119+
120+
The `output_schema` is compiled into a structured output grammar by the Anthropic SDK. This imposes constraints:
121+
122+
**Make all fields required.** For missing values, the LLM returns empty string (`""`) for text and `0` for numbers. Do not use nullable/optional fields.
123+
124+
**Do not use union types.** `"type": ["string", "null"]` causes an SDK assertion error. `"anyOf"` works but counts against complexity limits.
125+
126+
**Keep schemas flat.** Nested objects with multiple fields compound grammar complexity. Use flat keys like `seller_address_street` instead of `seller_address.street`.
127+
128+
**Complexity limits** (hard, non-configurable):
129+
130+
| Limit | Value |
131+
|---|---|
132+
| Optional parameters | 24 total |
133+
| Union type parameters (`anyOf`, type arrays) | 16 total |
134+
| Compilation timeout | 180 seconds |
135+
136+
Each optional parameter roughly doubles grammar state space. Schemas with >18 optional params will likely timeout.
137+
119138
#### `GET /health`
120139

121140
Health check endpoint (no auth required).

scripts/generate_schema.py

Lines changed: 3 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Generate default_invoice_schema.json from the Pydantic InvoiceSchema model.
22
3-
Produces a flat, human-readable JSON Schema by inlining $ref definitions
4-
and stripping Pydantic-specific keys (title, default, $defs, examples).
3+
Inlines $ref definitions and strips Pydantic-specific keys (title, default,
4+
$defs, examples) to produce a clean, human-readable JSON Schema.
55
66
Usage:
77
uv run python -m scripts.generate_schema
@@ -28,35 +28,6 @@ def _resolve_refs(node: Any, defs: dict[str, Any]) -> Any:
2828
return node
2929

3030

31-
def _simplify_any_of(node: Any) -> Any:
32-
"""Convert anyOf: [{type: X}, {type: null}] to type: [X, null]."""
33-
if isinstance(node, dict):
34-
if "anyOf" in node:
35-
types = node["anyOf"]
36-
if (
37-
len(types) == 2
38-
and all(isinstance(t, dict) and set(t.keys()) == {"type"} for t in types)
39-
):
40-
merged_type = [t["type"] for t in types]
41-
result = {k: v for k, v in node.items() if k != "anyOf"}
42-
result["type"] = merged_type
43-
return {k: _simplify_any_of(v) for k, v in result.items()}
44-
45-
if len(types) == 2:
46-
null_types = [t for t in types if isinstance(t, dict) and t.get("type") == "null"]
47-
obj_types = [t for t in types if t not in null_types]
48-
if len(null_types) == 1 and len(obj_types) == 1:
49-
obj = _simplify_any_of(obj_types[0])
50-
if isinstance(obj, dict) and "type" in obj:
51-
obj["type"] = [obj["type"], "null"]
52-
return {**{k: _simplify_any_of(v) for k, v in node.items() if k != "anyOf"}, **obj}
53-
54-
return {k: _simplify_any_of(v) for k, v in node.items()}
55-
if isinstance(node, list):
56-
return [_simplify_any_of(item) for item in node]
57-
return node
58-
59-
6031
def _strip_keys(node: Any, keys: set[str]) -> Any:
6132
"""Remove unwanted keys recursively."""
6233
if isinstance(node, dict):
@@ -70,9 +41,7 @@ def generate() -> dict:
7041
raw = InvoiceSchema.model_json_schema()
7142
defs = raw.pop("$defs", {})
7243
resolved = _resolve_refs(raw, defs)
73-
simplified = _simplify_any_of(resolved)
74-
cleaned = _strip_keys(simplified, {"title", "default", "examples"})
75-
return cleaned
44+
return _strip_keys(resolved, {"title", "default", "examples"})
7645

7746

7847
def main() -> None:

src/app/prompts/extraction.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,21 @@
77
88
Rules:
99
- Extract ONLY data explicitly present in the document.
10-
- If a field cannot be found, use null.
10+
- For fields not found on the invoice: if the schema allows null, return null. If the field is
11+
required, return an empty string for text fields and 0 for numeric fields.
1112
- For numeric fields, extract the numeric value without currency symbols or thousand separators.
12-
- For date fields, use ISO 8601 format (YYYY-MM-DD). Be aware that US invoices use MM/DD/YYYY and European invoices use DD/MM/YYYY. If the format is ambiguous (e.g. 03/04/2025), use other clues such as language, currency, or the fact that most invoices are issued around the current date.
13-
- For tax identification numbers, correct obvious OCR errors (O→0, l→1, S→5) when the result produces a valid ID.
14-
- For line items: extract only actual product/service lines, skip subtotals, totals, and summary rows.
13+
- For date fields, use ISO 8601 format (YYYY-MM-DD). Be aware that US invoices use MM/DD/YYYY and
14+
European invoices use DD/MM/YYYY. If the format is ambiguous (e.g. 03/04/2025), use other clues such
15+
as language, currency, or the fact that most invoices are issued around the current date.
16+
- For tax identification numbers, correct obvious OCR errors (O→0, l→1, S→5) when the result
17+
produces a valid ID.
18+
- For line items: extract only actual product/service lines, skip subtotals, totals, and summary
19+
rows.
1520
- Be precise — do not infer or guess values that are not clearly stated.
21+
22+
Additional context:
1623
{context}
24+
1725
Document text:
18-
{document_text}"""
26+
{document_text}
27+
"""

0 commit comments

Comments
 (0)