- Pass 1, stitch: one
Noulquestion (a yes/no question whose answer is the probability that yes is correct) per adjacent pair of lines, asking whether the line break split a sentence across the two. All the pairs go in a single request, and lines that continue a split sentence get merged back into blocks. - Pass 2, classify: one
Choicequestion (pick one option from a list, with a probability for every option) per merged block, choosing among heading, paragraph, list item, quote, code, or callout (a note, tip, or warning set apart from the main text). The blocks only exist once pass 1 has answered, so this is a second request; it also carries companion questions for every block (heading level, step order, callout kind) whose answers are read only when the block’s type makes them relevant. - Direct evidence stays in code. Blank lines and explicit markers (
-,1.,#) are read directly and never sent to the model to reconsider; this memo kept its blank lines but lost every marker. The model gets only the questions code cannot answer from the text.
Setup
TYPESAFE_API_KEY. Every API call is cached in json_cache.json. Download it
from
typesafe-public-examples
into the folder you run the script from to replay the published numbers without calling the
API, or leave it out to run everything live.
The document: a team memo that lost its formatting
The test document is a memo about a build-system migration, in the state it arrives in a plain-text inbox: paragraphs hard-wrapped mid-sentence, a shell command sitting on a bare line, two lists with no bullets or numbers, a warning with nothing marking it as one.L014| ). The model reads the ids as
ordinary text in the state, and questions and answers refer to lines by them. The
semantic search cookbook
uses the same scheme.
Pass 1: stitching split sentences
One Noul question per adjacent pair of lines, all in one request; pairs separated by a blank line are skipped. The question is deliberately narrow (“does this line pick up mid-sentence?”), which is close to an objective fact about the text.. ! ? : ;), the cutoff rises to 0.5.
Pass 2: classifying blocks
Each stitched block gets aChoice question: what kind of content is this? These three
dicts, plus the step question’s true/false criteria inside classify_questions below,
are the entire specification of the classifier. To adapt the pipeline to your own
documents, edit these descriptions.
heading, the renderer needs a heading level; if list_item,
whether order matters; if callout, which kind. The types are not known yet, and waiting
for them would mean a third round trip, so the companion questions are asked up front in
the same request. Most of these answers are never read: the step probability of a
paragraph goes unused. An extra question adds little, since the state is most of the
tokens and is sent once either way, while an extra round trip adds a full request of
latency.
warning.
Rendering
Code assembles the page from the judgments. Consecutive list items become one list, numbered when the mean of the items’ step probabilities is at least 0.5. That threshold is a group-level decision no single question asked directly.Open it in the playground
This share link holds the stitched blocks and the full pass-2 question set. Open it to re-run the classification live.Appendix
Where the join thresholds come from
The per-line join probabilities from pass 1:- After a dangling line (one with no sentence-ending punctuation), anything at 0.2 or
above counts as a continuation. True continuations score as low as 0.39 here (
L004| make the switch for real.), so a single cautious cutoff at 0.5 would break up healthy paragraphs. - After terminal punctuation (a character that ends a sentence or clause:
.!?:;), the cutoff rises to 0.5. The memo’s team list shows why:L015| The platform teamfollows a colon and scores 0.22, which would clear the 0.2 cutoff and merge the list into the sentence introducing it. No single threshold works for both cases; once code checks the punctuation first, the two bands separate.

