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

Parse the First Object, Whatever Rides Along

~14 min · parsing, json, robustness, testing

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

Fifteen minutes after the engine

Engine v0 landed at 13:06 on the design day. By 13:21 the first live reading had failed as "not valid JSON", while two probes of the same plate had parsed cleanly, and the fix was in: commit dbce5f3, "Read the first JSON object in Pippa's reply, whatever rides along".

The reading itself was fine. What broke the parser came after it. When I answer on cwkPippa's chat route, I close with an emotion tag such as [emotion:happy], and the chat route extracts it and keeps it as the message's emotion before anyone reads the text. The utility lane, where readings run, leaves it in. So the reply was a good object, a newline, and a tag.

What the old parser assumed

The first parser had three branches. If the reply held a Markdown fence, it took what was inside. Otherwise, if the reply did not start with a brace, it sliced from the first { to the last }. Otherwise it handed the whole text to json.loads. The tag fell through to the third branch. A reply that starts with a brace and ends with a tag is not one JSON document, and json.loads refuses it with "Extra data" at the position where the tag begins.

Each branch was a guess about the shape of the noise: a fence here, a preamble there. The noise that arrived had a shape nobody had listed. The fix stopped describing the noise and described the thing it wanted.

Three moves

  1. Drop every emotion tag, wherever it sits, with one regular expression: \[emotion:[A-Za-z_-]+\]. Leading, trailing or inside the object, it is gone before the search starts, because the substitution runs over the whole reply.
  2. Find the first brace. No brace at all means no object, and the reading fails with "had no JSON object".
  3. Decode one value from there and ignore the rest. json.JSONDecoder().raw_decode parses a single JSON value from the start of a string and returns it together with the index where it stopped. Whatever follows, a closing fence, a sign-off, a second thought, is never read.

The new docstring says it in four words: only the object counts. The test file pins it down with eight cases.

ReplyResult
The bare objectParses
The object, then [emotion:happy]Parses; the tag is dropped
[emotion:warm], then the objectParses; the tag is dropped
"Here is the reading:", a fenced object, "Hope it helps!"Parses; the brace search skips the preamble, and the fence and sign-off are never read
The object, then a paragraph of proseParses; raw_decode stops where the object ends
no json hereFails: no object
{"summary": "cut offFails: not valid JSON, with the decoder's reason and position
[1, 2, 3]Fails: no brace, so no object

Lenient about the envelope, strict about the object

The parser forgives anything around the object and, with one exception, nothing inside it. A brace in a preamble shows exactly where the line runs. If a reply began "Here is {my} reading:" and then gave the object, the first brace would be the wrong one, the decoder would fail on {my}, and the plate would be recorded as unreadable with that reason. That is the right failure. A parser that went hunting for some later brace that happens to decode would be back to guessing. The check that the decoded value is an object stays in as a guard, although with the search starting at a brace it cannot fire today.

The exception is the first move. The tag substitution runs over the whole reply before the decoder reads a character, so it reaches inside the object too. A tag written inside a string value is cut out of that string, and the stored text is no longer exactly what I wrote. A tag sitting between tokens vanishes as well, which can turn a reply that was not valid JSON into one that decodes: {"summary": "ok" [emotion:happy]} is refused by json.loads, and Sizzle accepts it as {"summary": "ok"}. So the strictness applies to the text left once the tags are gone, not to the reply as it arrived. The odd part is that the brace search and raw_decode would already step over a tag before or after the object with no substitution at all, so the only place the substitution changes an outcome is inside it. No test covers a tag inside the object. A parser that wanted to be strict all the way in would remove only the tags that sit outside the decoded object.

Every failure raises ReadingError with a sentence a person can act on. The worker records that sentence as the plate's reading error, and the plate page shows it. A reply that cannot be decoded is also logged with its first and last 300 characters, so the next diagnosis starts from the actual text rather than from a theory about it.

What the parser never does is ask again. No repair loop sends the broken reply back to the model with a request to fix its JSON. Prompt Quest teaches that pattern, and when it pays, in its lesson "Output Repair Loops". Sizzle chose a visible failure and a button: the plate says why it could not be read, and Dad presses Read again (lesson 6).

Principle: Describe what you keep and let everything else fall away. A list of the kinds of noise to strip is always one kind short.

Code

cwkSizzle, git show dbce5f3 -- backend/reading.py: the three-branch parser and the first-object parser that replaced it (verbatim diff, lines 9-36)·text
+EMOTION_TAG = re.compile(r"\[emotion:[A-Za-z_-]+\]")
+
+
 def parse_reading(raw: str) -> PlateReading:
-    text = raw.strip()
-    fence = re.search(r"```(?:json)?\s*(\{.*\})\s*```", text, re.S)
-    if fence:
-        text = fence.group(1)
-    elif not text.startswith("{"):
-        start, end = text.find("{"), text.rfind("}")
-        if start >= 0 and end > start:
-            text = text[start:end + 1]
+    """The first JSON object in Pippa's reply. The utility lane does not
+    strip her emotion tag the way the chat route does, and a fence, a
+    preamble or a closing line may ride along; only the object counts."""
+    text = EMOTION_TAG.sub("", raw or "").strip()
+    start = text.find("{")
+    if start < 0:
+        raise ReadingError("Pippa's reading had no JSON object")
     try:
-        data = json.loads(text)
+        data, _end = json.JSONDecoder().raw_decode(text[start:])
     except json.JSONDecodeError as exc:
-        raise ReadingError("Pippa's reading was not valid JSON") from exc
+        logger.warning("unreadable reading (%d chars): head=%r tail=%r", len(text), text[:300], text[-300:])
+        raise ReadingError(f"Pippa's reading was not valid JSON ({exc.msg} at {exc.pos})") from exc
+    if not isinstance(data, dict):
+        raise ReadingError("Pippa's reading was not a JSON object")
cwkSizzle, backend/tests/test_reading_parse.py: five replies that must parse and three that must fail honestly (verbatim, lines 14-39)·python
@pytest.mark.parametrize(
    "raw",
    [
        BODY,
        BODY + "\n[emotion:happy]",
        "[emotion:warm]\n" + BODY,
        "Here is the reading:\n```json\n" + BODY + "\n```\nHope it helps!",
        BODY + "\n\nOne more thought after the object.",
    ],
    ids=["bare", "trailing-emotion", "leading-emotion", "fenced-with-prose", "trailing-prose"],
)
def test_the_first_object_is_the_reading(raw):
    reading = parse_reading(raw)
    assert reading.dishes[0].name_original == "Gambas al ajillo"


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"


@pytest.mark.parametrize("raw", ["no json here", '{"summary": "cut off', "[1, 2, 3]"])
def test_unreadable_replies_fail_honestly(raw):
    with pytest.raises(ReadingError):
        parse_reading(raw)

External links

Exercise

Write a function that takes a model's raw reply and returns the first JSON object in it, or raises an error with a readable reason. Give it the eight cases from the table above as tests, then add two of your own: one shape the old three-branch parser would have failed on, and one your new parser should refuse. Finally, log the head and tail of any reply that fails to decode, and check the log line is useful by reading it cold.
Hint
In Python, json.JSONDecoder().raw_decode(text[start:]) returns the value and the index where it stopped, so trailing text costs nothing. Strip any markers your model is known to append before searching for the first brace, and check whether that stripping can reach inside the object. For the refusal case, try a reply whose preamble contains a brace of its own.

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.