Lab 06-02: Extracting data as JSON¶
Objective¶
Turn three free-form real estate listings into JSON objects with typed fields, using
response_format with a JSON Schema on /v1/chat/completions. Compare with the same request without the constraint,
then do the part no grammar does for you: check that the values are right.
Prerequisites¶
- Lab 04-01 (server running,
curl) and module 06 § 3-4 (what the converter converts). - Python 3 (standard library only).
Materials¶
| What | Where | Size |
|---|---|---|
ggml-org/Qwen3.5-0.8B-GGUF (file Qwen3.5-0.8B-Q8_0.gguf, Apache 2.0) |
Hugging Face, via -hf |
785 MiB |
annunci.txt: three listings separated by === (Bologna, Padova, Ravenna) |
lab folder | 0.5 KB |
schema-annuncio.json: schema with enum, integer + minimum/maximum, boolean, maxLength, required, additionalProperties: false |
lab folder | 0.7 KB |
estrai.py: sends each listing with response_format (or without, with --senza-schema) and applies minimal checks |
lab folder | 4 KB |
Steps¶
-
Server with a single slot, an alias and
-c 2048(one listing plus the system prompt fit in ~250 tokens): -
Read the schema and ask yourself, for each constraint, whether the converter will enforce it (table in § 3 of the module):
enumyes;integerwithminimum/maximumyes;maxLengthyes;requiredyes (and in schema order);additionalProperties: falseis the default anyway. Thenotefield is not inrequired: the model may omit it. -
Read the system prompt in
estrai.py. It lists the fields and their meaning ("locali: integer; if not stated, estimate from the text", "prezzo_euro: for rentals, the monthly rent") because the schema does not reach the model: without this description the model does not knowlocaliexists until the grammar forces it token by token, and at that point it guesses. -
With the schema:
Output:
server http://127.0.0.1:8080 · 3 annunci · vincolo schema: SÌ --- annuncio 1: Vendesi luminoso trilocale di 85 mq in zona Saragozza a Bolo… {"titolo": "Luminoso trilocale", "tipo": "appartamento", "citta": "Bologna", "metri_quadri": 85, "locali": 2, "prezzo_euro": 245000, "ascensore": true, "note": "Cantina inclusa"} controlli superati --- annuncio 2: Affittasi ufficio open space di circa 140 metri quadri nel c… {"titolo": "Ufficio Open Space", "tipo": "ufficio", "citta": "Padova", "metri_quadri": 140, "locali": 2, "prezzo_euro": 1900, "ascensore": false, "note": "opzionale"} controlli superati --- annuncio 3: Villa indipendente a Ravenna, 6 locali su due piani, 220 mq,… {"titolo": "Villa indipendente Ravenna", "tipo": "appartamento", "citta": "Ravenna", "metri_quadri": 220, "locali": 6, "prezzo_euro": 520000, "ascensore": true, "note": "Da ristrutturare parzialmente"} controlli superati risultato: 3/3 annunci validi. Ora confronta i VALORI con il testo: sono giusti?Three JSON objects out of three decode, all required fields are present, all types are right. Now do what the last line asks.
-
Hunt for errors in the values. Compare each object with
annunci.txt. The errors in the output above:Listing Field Model Text Why 1 locali2 "trilocale" = 3 missing lexical knowledge 2 note"opzionale"(nothing) it copied the word "opzionale" from the system prompt 3 tipoappartamento"Villa indipendente" enumrespected, wrong value3 ascensoretruenot mentioned the booleanforces a choice: it choseNone of these can be detected by a schema: they are all allowed values.
metri_quadri,prezzo_euro,cittaand the office's monthly rent are right in all three. -
Without the schema, same prompt:
--- annuncio 1 … {"titolo": "Luminoso trilocale", …, "locali": "intero", …} controlli falliti: locali non è un intero: 'intero' --- annuncio 2 … {…, "locali": "2", …, "note": null} controlli falliti: locali non è un intero: '2' --- annuncio 3 … {…, "locali": "6 locali", …} controlli falliti: locali non è un intero: '6 locali' risultato: 0/3 annunci validi.The model returned decodable JSON (this time without a Markdown fence, which is not guaranteed), but
localiis a string in all three cases:"intero"copied from the prompt,"2","6 locali". This is the error the grammar removes:"locali":followed by a digit, no choice. -
Look at the outgoing request. Add
print(json.dumps(body["response_format"])[:200])inchiedi(): the form sent is the OpenAI one,{"type": "json_schema", "json_schema": {"name": "annuncio", "schema": {…}}}, which the server accepts along with{"type": "json_schema", "schema": {…}}. Withcurlyou can send the same thing by hand (§ 4 of the module). -
Fix it with the prompt, not the schema. Add to the system prompt: «trilocale = 3 locali, bilocale = 2; villa → tipo "villa"; ascensore false se non citato; note solo se presenti nel testo, altrimenti ometti il campo» (three-room = 3 rooms, two-room = 2; villa → type "villa"; elevator false if not mentioned; notes only if present in the text, otherwise omit the field). Run it again and count how many errors from step 5 disappear: three out of four (
locali: 3,noteomitted for the office,ascensore: falsefor the villa), but the villa staystipo: "appartamento"even with the rule written in the prompt. The prompt helps, the real solution is a more capable model; the grammar only gives you certainty about the format. -
Make the schema stricter:
"citta": {"type": "string", "enum": ["Bologna", "Padova", "Ravenna"]}and"locali": {"type": "integer", "minimum": 1, "maximum": 8}. Run it again: the grammar now enforces these ranges too (remember:minimum/maximumapply only tointeger). -
Stop the server with Ctrl+C.
flowchart TD
A[free-text listing] --> B["system prompt<br/>(describes the fields)"]
B --> C["/v1/chat/completions<br/>response_format: json_schema"]
C --> D{decodable JSON?}
D -- "with schema: always" --> E["controlla() types and required"]
D -- "without schema: sometimes not" --> X[discard]
E --> F{"right values?<br/>(only you can tell)"}
F -- yes --> G[use the data]
F -- no --> H["improve prompt / model<br/>validate downstream"]Check the result¶
- With the schema: 3/3 decodable JSON objects with all
requiredfields and correct types. - Without the schema: at least one listing with a field of the wrong type or not decodable.
- You filled in the table of value errors (at least three) and can explain why the schema does not see them.
- You measured how many errors disappear by changing only the prompt.
Questions and variations¶
- Remove
"additionalProperties": falsefrom the schema: does anything change? (No: it is already the llama.cpp default; withtruethe model could add keys at will). - Use
"prezzo_euro": {"type": "number", "minimum": 0}instead ofinteger: can the model write-1? (Yes: bounds are ignored fornumber). - Use
"type": "json_object"without a schema: how many listings passcontrolla()? - Do the same work with the
openaiSDK andresponse_format={"type": "json_schema", "json_schema": {...}}: only the transport changes. - With Pydantic: define
class Annuncio(BaseModel)and passAnnuncio.model_json_schema(); thenAnnuncio.model_validate_json(content)as downstream validation (remember theadditionalPropertiesdefault). - Measure
timings.predicted_per_secondwith and without the schema over 10 runs: is there a measurable cost to the constraint with a schema of this size? - A larger model (
ggml-org/gemma-3-4b-it-qat-GGUF, ~3 GB): how many errors from step 5 disappear on their own?
Deliverable¶
A file lab-06-02.md with: the two outputs of estrai.py; the table of value errors; the modified system prompt
and the "before/after" count; the strict schema from step 9 and a sample output; the answer to the
question "which of the four errors in step 5 could be avoided with the schema alone, and how?".