Skip to content
C.W.K.
Lesson 04 of 06 · published

Validation and Normalisation

~14 min · pydantic, validation, defaults, vocabulary

Level 0Mise en Place
0 XP0/43 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

A parsed object still has to be a reading

Lesson 3 ended with a Python dict. Whether that dict is a reading is the next question, and PlateReading.model_validate(data) answers it. Seven pydantic models in backend/reading.py mirror the rubric's example object key for key: Technique, Binding, Role, PrincipleReading, SiblingReading, DishReading and PlateReading. If validation raises, the parser turns it into a ReadingError that says the reading did not match the rubric, and the plate fails like any other unreadable reply.

Every field has a default

Read the models and one thing stands out: there is no required field anywhere. Strings default to empty, lists to empty lists, confidence to medium, emotion to warm. A reply that leaves a key out still validates, and the missing part reads as nothing. Even {} is a valid reading: an empty plate, no dishes, no doubts. Keys the rubric never asked for are ignored, which is pydantic's default for extra fields.

That is a deliberate trade. A model that forgets techniques on one dish should not cost Dad the whole plate, so absence is cheap. What stays expensive is a wrong type.

Where the line falls

I ran the parser from this snapshot, with the engine's pinned pydantic 2.13.5, against a handful of small replies to see exactly where lenient stops.

Reply containsOutcome
{}Valid: an empty reading
an unasked key such as "mood"Valid: the key is dropped
"confidence": "HIGH"Valid, then normalised to high
"confidence": 1The whole reading fails: a number is not a string
"summary": nullFails: null is not a string
"dishes": "none"Fails: a string is not a list

The bottom half of that table deserves a minute. One field of the wrong type anywhere in the tree fails every dish on the plate. That is strict, and it fits lesson 3: a reply that breaks the shape becomes a visible failure with a reason, and Read again is one tap away. The softer path, coercing 1 into "1", would hide the break instead: the vocabulary fold below would turn "1" into medium, and a reply that misread the contract would be stored as if it had only picked an odd word.

The _named filter: no nameless dishes, at most eight

One field validator runs on dishes. It keeps a dish only if its original name, or its Korean name when the original is empty, is non-blank after trimming, and then keeps the first eight. The tree finds and proposes dishes by name, so a dish with no name could only become a class called nothing. It is dropped before it gets there. Eight is a ceiling on one plate's dish list, generous even for a tapas board.

Closed vocabularies with a neutral default

After validation the parser walks the reading once more and folds two vocabularies. Confidence must be high, medium or low after trimming and lowercasing, and anything else becomes medium. That applies to the dish and to every technique, binding and principle inside it. Emotion must be one of the eight, and anything else becomes warm.

Why these two, and why after validation rather than as part of it? Both are words the model picks freely, and a free word drifts: "very sure", "High", "hungry". The type is checked strictly, so a number fails. The word is checked leniently, so an odd one lands on a neutral value and the plate survives. Medium is the middle of the scale, so the fold pulls a stray word toward the middle, not toward caution. "Very sure" comes down to medium, and "very low" goes up to it. Warm is my default face, the same one the avatar route falls back to. So the defaults are neutral rather than modest: only the three listed words keep their exact strength, and a doubtful word the list does not know comes out surer than the reply meant.

The test for it is the smallest in the file: set a dish's confidence to "very sure" and assert that medium comes back.

What gets stored

The worker records reading.model_dump(), the validated and normalised model, as the body of the reading.recorded event. The raw reply never enters the journal. So every reading Sizzle has stored has the rubric's shape with its vocabularies closed, and the code that shows a plate never has to defend itself against a model. The architecture's operating rules say it in one sentence: a reading is stored only after it validates against the rubric's schema.

Principle: Be strict about types and lenient about vocabulary. A wrong type means the reply misread the contract. An unexpected word usually means it said the right thing in its own words.

Prompt Quest's lesson "Pydantic / Zod / Schemas as Contracts" covers the wider pattern of a schema as the border between a model and code. This is one app's version of that border, defaults and all.

Code

cwkSizzle, backend/reading.py: the dish and plate models, every field defaulted, and the _named filter (verbatim, lines 125-147)·python
class DishReading(BaseModel):
    name_original: str = ""
    name_ko: str = ""
    cuisine: str = ""
    confidence: str = "medium"
    evidence: str = ""
    dish_ref: str = ""
    techniques: list[Technique] = Field(default_factory=list)
    bindings: list[Binding] = Field(default_factory=list)
    principles: list[PrincipleReading] = Field(default_factory=list)
    siblings: list[SiblingReading] = Field(default_factory=list)


class PlateReading(BaseModel):
    summary: str = ""
    emotion: str = "warm"
    dishes: list[DishReading] = Field(default_factory=list)
    unsure: list[str] = Field(default_factory=list)

    @field_validator("dishes")
    @classmethod
    def _named(cls, dishes: list[DishReading]) -> list[DishReading]:
        return [d for d in dishes if (d.name_original or d.name_ko).strip()][:8]
cwkSizzle, backend/reading.py: folding emotion and confidence onto their vocabularies, the tail of parse_reading and the helper after it (verbatim, lines 172-182)·python
    reading.emotion = reading.emotion.strip().lower() if reading.emotion.strip().lower() in EMOTIONS else "warm"
    for dish in reading.dishes:
        dish.confidence = _confidence(dish.confidence)
        for item in (*dish.techniques, *dish.bindings, *dish.principles):
            item.confidence = _confidence(item.confidence)
    return reading


def _confidence(value: str) -> str:
    value = (value or "").strip().lower()
    return value if value in CONFIDENCE else "medium"
cwkSizzle, backend/tests/test_reading_parse.py: an off-vocabulary confidence becomes medium (verbatim, lines 30-33)·python
def test_confidence_outside_the_vocabulary_becomes_medium():
    data = json.loads(BODY)
    data["dishes"][0]["confidence"] = "very sure"
    assert parse_reading(json.dumps(data)).dishes[0].confidence == "medium"

External links

Exercise

Write pydantic (or Zod) models for a structured reply your app gets from a model, giving every field a default. Add one closed vocabulary, such as a confidence or a status, and normalise it after validation with a neutral fallback. Then build the six-row table from this lesson for your own models: an empty object, an unasked key, an odd-case vocabulary word, a number where a string belongs, a null, and a string where a list belongs. Decide for each row whether the outcome is the one you want.
Hint
Run each case through your real parser instead of predicting it; library versions differ on coercion. If you want a field to be required, ask what Dad, or your user, loses when one reply forgets it. If the answer is the whole record, a default is kinder. Watch for or chains on strings: whitespace is truthy.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.