One reply, two audiences
My answer to a photo has two readers. Dad reads my sentences: what the sign says, what matters, what to do next. The app reads data: each line's source and translation, its Vision number, its language, the expressions worth keeping. Both come out of one text stream from cwkPippa, token by token, and Dad is standing on a platform while it arrives.
A single JSON document would be useless until its last brace. Free prose would need scraping. Spark's wire format sits between the two: a Markdown fenced code block whose info string is spark, with one JSON object per line inside it. Everything outside the fence is me talking to Dad. Everything inside is records. A record is complete the moment its newline arrives, so it can be stored and shown while the rest of the reply is still being written.
The vocabulary lives in the docstring of backend/results.py, which says its tests are its specification: line, expression, card, relay and answer. Any other type is kept as extra with its own fields, because I may know something the vocabulary does not, and everything is corpus. The keep has one condition: the object must carry at least one of the vocabulary's text fields (source, translation, pronunciation, note or use) with a value that is still non-empty after trimming. An empty or whitespace-only field does not count, and an unknown object with no such field is dropped. Explanations add root, in lesson 6.
The parser, a line at a time
SparkBlockParser.feed appends whatever text arrived to a buffer and hands every complete line to _line. A chunk can end mid-word, mid-fence or mid-JSON, and nothing is lost: the tail waits in the buffer for its newline. The engine's first parser test feeds a whole reply seven characters at a time for exactly that reason.
Outside the block, a line that opens the fence (three backticks and spark, any case) switches the parser in; every other line is prose. Inside the block:
- a closing fence switches it out;
- a blank line between objects is skipped;
- any other line joins a pending list, and the list is tried as JSON. A dict that parses is returned at once.
- If it does not parse yet and starts with an opening brace, the parser keeps accumulating, up to 40 lines, so a pretty-printed object still arrives whole.
- Anything else (a line that is not JSON, a list, an object that never closes) is dropped and counted in
malformed.
That tolerance has a price. A broken object that starts with a brace holds the lines after it: each one joins the pending list, the joined text never parses, and nothing is returned until the list reaches 40 lines or the fence closes. Then the whole list, the valid records inside it included, is dropped as one malformed count.
finish reads the last line even without a newline, and counts a still-pending object as malformed. The prose property includes a line still being written, so my words appear as they form, except a line beginning with a backtick, which might be a fence opening. A trailing avatar tag like [emotion:...] is split off the prose and becomes my face beside the words.
From object to record
normalize turns each object into what the corpus keeps. A known kind with no source or no translation says nothing and is dropped. Text fields are trimmed. A language or translation_language is kept only when it is shaped like a language tag, so es_ES becomes es-ES and a display name like Latin American Spanish disappears. show survives only as a real boolean. n becomes an integer. Every source gets a source_key, whitespace collapsed and case folded, so the corpus recognises the same text when Dad meets it again.
Shown the moment it exists
The turn's _stream loop feeds each text event to the parser and records whatever comes out before reading the next event. Each recorded result is an operation in the moment's log, and the corpus publishes it to everyone watching that moment over server-sent events. The prose goes out too, as a transient event that is never recorded. On the phone, the list under the photo grows a row per line, and the overlay from lesson 2 fills its boxes one by one.
The first code block below is a reply shaped like the ones I write, for an invented station sign. Run through the real parser in chunks of seven characters, the four records come out at the newline that ends each one, and the prose is the two short sentences above the fence, with zero malformed lines.
Once per turn, and revisions as new results
Streams get cut. When cwkPippa retries a reply, it sends a stream_reset, and the parser's reset throws away the cut attempt's buffer and prose. The engine keeps one thing across the reset: the recorded map of what this turn already stored, keyed by result_key. A line is keyed by its Vision number, a root by root and expression, anything else by its text.
So when the retry writes line 1 again with the same content, it is skipped. When it writes line 1 with a different translation, the new result is recorded with revises pointing at the first. Nothing is overwritten; the log is append-only (Track 2). The engine's test plays exactly this: line 1, a reset, line 1 again, line 2, then line 1 with a better translation. The moment ends with three results, and the third revises the first.
The clients do the rest. The phone's MomentState.current and the web's currentResults follow each revises chain and show the newest version in the original's place. A stream that dies halfway keeps what arrived. How the turn ends depends on how it died. When cwkPippa reports the cut as an error event inside the stream, the turn is marked completed and partial if it recorded anything, failed only if it recorded nothing. When the connection itself breaks and the client raises, the turn is marked failed even after lines were recorded, and those lines stay in the moment all the same.