Skip to main content
This cookbook takes plain text whose markup has been stripped (lines hard-wrapped mid-sentence, no heading markers, no list bullets) and reconstructs the structure as Markdown: headings, paragraphs, lists, quotes, code, callouts. The input is a team memo in exactly that state. A text-generation model could rewrite the text into Markdown, but a rewrite can also change the words. Here the model never generates text: it answers narrow questions about the document (does this line pick up mid-sentence? what kind of content is this block?), and code does the rendering, so every character of the output comes from the input, and every judgment carries a probability. The whole pipeline is two API requests per document, run in sequence:
  • Pass 1, stitch: one Noul question (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 Choice question (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

then set 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.
Splitting lines, tracking blank lines and tagging ids all happen before the request; no model is involved. Each line gets a short id (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 for merging depends on how the previous line ends. After a dangling line (one with no sentence-ending punctuation), a join probability of 0.2 or above merges the pair; after terminal punctuation (. ! ? : ;), the cutoff rises to 0.5.

Pass 2: classifying blocks

Each stitched block gets a Choice 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.
If the type comes back 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.
The companion column shows the up-front answers in use: the three “Things to do before Monday” lines carry step probabilities near 0.9 (they will render as a numbered list), the three team lines sit near 0.1 (bulleted), and the unmarked warning about the doctor script was classified as a callout of kind 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.
Every word above is from the input. The pipeline only chose boundaries, types, and markup.

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.
Open the stitched memo + questions in the TypeSafe playground →

Appendix

Where the join thresholds come from

The per-line join probabilities from pass 1:
The probabilities land in two separate bands: line breaks that split a sentence score 0.39 and up, breaks the author meant score close to zero. But where to put the cutoff between the bands depends on how the previous line ends, a fact code can read directly:
  • 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 team follows 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.

Why the question is “mid-sentence” and not “same paragraph”

The first version of this pipeline asked the obvious question: “are these two lines part of the same paragraph?” A run of short lines under a heading (a list typed without bullets) is a paragraph in the loose sense: the lines sit together and share a topic. Asked about paragraphs, the model says yes to every pair, and the stitch pass merges the whole list into one long block.
With the paragraph wording, every unmarked list item scores above 0.75 and both lists collapse. “Same paragraph” asks the model to judge whether the topic carries over, and between list items it does. “Picks up mid-sentence” asks about the text itself. When a judgment call feeds a threshold, the question should name the narrowest fact that decides it. Here the wording is the difference between 17 blocks and 12.

The lowest-confidence block

The sentence introducing the team list is ambiguous: it names what follows (heading-like), is a complete sentence (paragraph-like), and sits where a callout would go. The probabilities spread accordingly (paragraph 0.53, list_item 0.24, callout 0.19), and a UI can surface that, for example by underlining for review any block whose type confidence (the probability behind the winning choice) is under 0.55.