Strukturwiederherstellung
Rekonstruiert Markdown aus reinem Text, der seine Formatierung verloren hat, in zwei Anfragen: eine fügt hart umbrochene Zeilen wieder zusammen, eine klassifiziert jeden Block (Überschrift, Liste, Code, Callout).
Dieses Cookbook nimmt reinen Text, dessen Auszeichnung entfernt wurde (Zeilen mitten im Satz hart umbrochen, keine Überschriften-Marker, keine Listenaufzählungszeichen), und rekonstruiert daraus die Struktur als Markdown: Überschriften, Absätze, Listen, Zitate, Code, Callouts. Die Eingabe ist ein Team-Memo, das sich genau in diesem Zustand befindet.
Ein Textgenerierungsmodell könnte den Text in Markdown umschreiben, aber eine Umschreibung kann auch die Wörter verändern. Hier generiert das Modell nie Text: Es beantwortet enge Fragen über das Dokument (setzt diese Zeile mitten im Satz an? welche Art von Inhalt ist dieser Block?), und der Code erledigt das Rendern, sodass jedes Zeichen der Ausgabe aus der Eingabe stammt und jedes Urteil eine Wahrscheinlichkeit trägt.
Die gesamte Pipeline besteht aus zwei API-Anfragen pro Dokument, die nacheinander laufen:
- Durchgang 1, Zusammenfügen: eine
Noul-Frage (eine Ja/Nein-Frage, deren Antwort die Wahrscheinlichkeit ist, dass „Ja“ zutrifft) pro benachbartem Zeilenpaar, die fragt, ob der Zeilenumbruch einen Satz über die beiden Zeilen hinweg geteilt hat. Alle Paare gehen in eine einzige Anfrage, und Zeilen, die einen geteilten Satz fortsetzen, werden wieder zu Blöcken zusammengefügt. - Durchgang 2, Klassifizieren: eine
Choice-Frage (eine Option aus einer Liste wählen, mit einer Wahrscheinlichkeit für jede Option) pro zusammengefügtem Block, die zwischen Überschrift, Absatz, Listeneintrag, Zitat, Code oder Callout wählt (eine Notiz, ein Tipp oder eine Warnung, die vom Haupttext abgesetzt ist). Die Blöcke existieren erst, nachdem Durchgang 1 geantwortet hat, das ist also eine zweite Anfrage; sie trägt außerdem Begleitfragen für jeden Block (Überschrift-Ebene, Schritt-Reihenfolge, Callout-Art), deren Antworten nur dann gelesen werden, wenn der Typ des Blocks sie relevant macht. - Direkte Belege bleiben im Code. Leerzeilen und explizite Marker (
-,1.,#) werden im Code gelesen und nie an das Modell geschickt, damit es sie überdenkt; dieses Memo behielt seine Leerzeilen, verlor aber jeden Marker. Das Modell bekommt nur die Fragen, die der Code nicht aus dem Text beantworten kann.
Das gesamte Verhalten ist in den Kriterien der Durchgang-2-Fragen festgelegt: drei Dicts
mit einzeiligen Beschreibungen plus die Wahr/Falsch-Kriterien der Schritt-Frage in
classify_questions. Der übrige Code ist Beiwerk drumherum. Die Kosten- und Latenzzahlen
stehen im Anhang: zwei Roundtrips, 10,211 Tokens, 0.8s, $0.0015 für dieses Memo.
Einrichtung
pip install ipython 'cooksafe>=0.2.0,<0.3.0'
Lege dann TYPESAFE_API_KEY fest. Jeder API-Aufruf wird in json_cache.json
zwischengespeichert, das mit dem Cookbook ausgeliefert wird, sodass ein erneutes Rendern
die veröffentlichten Zahlen ohne API-Aufrufe wiedergibt. Lösche diese Datei, um alles live
neu auszuführen.
import os
import re
import urllib.request
from pathlib import Path
from time import perf_counter
from cooksafe import JsonCache, make_playground_link
from IPython.display import Markdown, display
from typesafe_sdk import Choice, Noul, NoulCriteria, TypeSafeClient
TYPESAFE_MODEL = "jev-1.12"
PRICE = (0.042, 0.00) # $ per 1M tokens (input, output); TypeSafe jev-1.12 as of 2026-09
client = TypeSafeClient(api_key=os.environ["TYPESAFE_API_KEY"], timeout=120.0)
json_cache = JsonCache(Path("json_cache.json"))
Das Dokument: ein Team-Memo, das seine Formatierung verloren hat
Das Testdokument ist ein Memo über eine Build-System-Migration, in dem Zustand, in dem es in einem Nur-Text-Posteingang ankommt: Absätze mitten im Satz hart umbrochen, ein Shell-Befehl auf einer nackten Zeile, zwei Listen ohne Aufzählungszeichen oder Nummern, eine Warnung, die nichts als solche kennzeichnet. Der Text wird aus einem festgeschriebenen Gist abgerufen, damit die Zahlen des Cookbooks reproduzierbar bleiben.
GIST = (
"https://gist.githubusercontent.com/eugene-shvarts/6df7daf97233bf92bcdd6b386a0fa561"
"/raw/5da03690611fb6ddcbaabdb91fb9f91d9751b113/build-memo.txt"
)
@json_cache
def fetch_document(url: str) -> str:
request = urllib.request.Request(url, headers={"User-Agent": "typesafe-cookbook/1.0"})
with urllib.request.urlopen(request) as response:
return response.read().decode()
RAW = fetch_document(GIST)
print(RAW[:560])
Migration to the new build system
Hi everyone, quick heads up about the build system migration that is
happening next week. We have been running the new pipeline in shadow
mode for three weeks and the results look solid, so it is time to
make the switch for real.
What changes for you
The old make targets keep working until the end of the month. The new
entrypoint is a single command that wraps everything, including the
docs build that used to be separate.
bun run build
Generated artifacts no longer need to be committed. The new pipeline
uploads them
Das Aufteilen der Zeilen, das Verfolgen von Leerzeilen und das Vergeben von ids passieren
alle im Code; kein Modell ist beteiligt.
Jede Zeile bekommt eine kurze id (L014| ); die ids sind gewöhnlicher Text, den das Modell
als Teil des Zustands liest, und Fragen und Antworten verweisen über diese ids auf Zeilen
(dasselbe Schema wie im Cookbook zur semantischen Suche).
def to_lines(text: str) -> list[dict]:
lines, gap = [], False
for raw in text.split("\n"):
stripped = re.sub(r"[\t ]+", " ", raw).strip()
if not stripped:
gap = bool(lines) # a leading blank is not a break
continue
lines.append({"text": stripped, "gap": gap})
gap = False
return lines
def tag(items: list[dict], prefix: str) -> str:
return "\n".join(
f"{chr(10) if item['gap'] else ''}{prefix}{i:03d}| {item['text']}"
for i, item in enumerate(items)
)
def line_id(i: int) -> str:
return f"L{i:03d}"
def block_id(i: int) -> str:
return f"B{i:03d}"
LINES = to_lines(RAW)
print(f"{len(LINES)} non-blank lines. The model sees, e.g.:")
print("\n".join(tag(LINES, "L").splitlines()[19:24]))
28 non-blank lines. The model sees, e.g.:
L013| The cutover touches three teams, so check whether you are on this
L014| list before you plan anything for Monday:
L015| The platform team
L016| The web client team
L017| Whoever still owns the release tooling
Durchgang 1: geteilte Sätze zusammenfügen
Eine Noul-Frage pro benachbartem Zeilenpaar, alle in einer Anfrage; Paare, die durch eine Leerzeile getrennt sind, werden übersprungen. Die Frage ist bewusst eng gefasst („setzt diese Zeile mitten im Satz an?“), was nahe an einem objektiven Fakt über den Text liegt. Der Anhang behandelt sowohl die Wortwahl als auch die Herleitung der Zusammenfüge-Schwellenwerte.
def join_question(i: int) -> Noul:
return Noul(
instructions=f"Does line {line_id(i)} pick up mid-sentence, continuing a sentence left unfinished at the end of line {line_id(i - 1)}?",
criteria=NoulCriteria(
true="The line starts in the middle of a sentence that began on the previous line - the line break tore the sentence apart",
false="The line begins a new sentence, item, heading, or thought of its own",
),
)
@json_cache
def stitch(wording: str = "mid-sentence") -> dict:
make = join_question if wording == "mid-sentence" else naive_join_question
questions = {line_id(i): make(i) for i in range(1, len(LINES)) if not LINES[i]["gap"]}
started = perf_counter()
response = client.system_one(
state=tag(LINES, "L"), questions=questions, model=TYPESAFE_MODEL
)
return {
"joins": [
response.answers[line_id(i)].noul if line_id(i) in response.answers else 0.0
for i in range(len(LINES))
],
"seconds": round(perf_counter() - started, 2),
"usage": [response.usage.input_tokens, response.usage.output_tokens],
}
result = stitch()
print(f"{sum(1 for l in LINES if not l['gap']) - 1} pair questions, one request, "
f"{result['seconds']}s")
16 pair questions, one request, 0.32s
Der Schwellenwert fürs Zusammenfügen hängt davon ab, wie die vorherige Zeile endet. Nach
einer hängenden Zeile (einer ohne satzbeendende Interpunktion) fügt eine
Join-Wahrscheinlichkeit von 0.2 oder mehr das Paar zusammen; nach Satzzeichen (. ! ?
: ;) steigt der Schwellenwert auf 0.5. Der Anhang geht die Wahrscheinlichkeiten hinter
den beiden Zahlen durch.
JOIN_AFTER_DANGLING, JOIN_AFTER_TERMINAL = 0.2, 0.5
def ends_terminal(text: str) -> bool:
return re.search(r'[.!?:;…]["\')\]]*$', text) is not None
def merge(joins: list[float]) -> list[dict]:
blocks = []
for i, line in enumerate(LINES):
bar = (
JOIN_AFTER_TERMINAL
if i and ends_terminal(LINES[i - 1]["text"])
else JOIN_AFTER_DANGLING
)
if blocks and not line["gap"] and joins[i] >= bar:
blocks[-1]["text"] += " " + line["text"]
blocks[-1]["lines"].append(i)
else:
blocks.append({"text": line["text"], "lines": [i], "gap": line["gap"]})
return blocks
blocks = merge(result["joins"])
healed = len(LINES) - len(blocks)
print(f"{len(LINES)} lines -> {len(blocks)} blocks ({healed} line breaks healed)")
for i, block in enumerate(blocks):
n = len(block["lines"])
print(f"{block_id(i)} {n} line{'s' if n > 1 else ' '} {block['text'][:62]}")
28 lines -> 17 blocks (11 line breaks healed)
B000 1 line Migration to the new build system
B001 4 lines Hi everyone, quick heads up about the build system migration t
B002 1 line What changes for you
B003 3 lines The old make targets keep working until the end of the month.
B004 1 line bun run build
B005 3 lines Generated artifacts no longer need to be committed. The new pi
B006 2 lines The cutover touches three teams, so check whether you are on t
B007 1 line The platform team
B008 1 line The web client team
B009 1 line Whoever still owns the release tooling
B010 1 line Things to do before Monday
B011 1 line Update your local toolchain to version 2.4 or later
B012 1 line Delete the old build cache directory
B013 1 line Run the doctor script and fix anything it flags
B014 3 lines If the doctor script reports a red result on the toolchain che
B015 2 lines As Dana put it in the kickoff, "a migration nobody notices is
B016 1 line Thanks, and shout if anything looks off.
Durchgang 2: Blöcke klassifizieren
Jeder zusammengefügte Block bekommt eine Choice-Frage: Welche Art von Inhalt ist das?
Diese drei Dicts plus die Wahr/Falsch-Kriterien der Schritt-Frage in classify_questions
unten sind die gesamte Spezifikation des Klassifikators. Es gibt keine weitere Logik. Um
die Pipeline an deine eigenen Dokumente anzupassen, bearbeite diese Beschreibungen.
TYPE_CRITERIA = {
"heading": "A short label or title that names the document or the section that follows it - not a full sentence of content",
"paragraph": "Running prose: one or more complete sentences of explanatory or narrative text",
"list_item": "One entry in a list of parallel items - an ingredient, a feature, a task, an attendee; reads as one of several sibling entries",
"quote": "Words attributed to a person or source - quoted speech, a citation, an excerpt someone else wrote",
"code": "Computer code, a shell command, terminal output, or a config snippet meant to be read verbatim",
"callout": "A warning, tip, or important note that interrupts the flow to flag something the reader must not miss",
}
HLEVEL_CRITERIA = {
"title": "The title of the whole document",
"section": "A major section heading within the document",
"subsection": "A minor heading nested under a section",
}
CALLOUT_CRITERIA = {
"note": "Neutral extra information the reader should be aware of",
"tip": "A helpful suggestion or shortcut that makes things easier",
"warning": "A caution about something that can go wrong or cause harm",
}
Alles Folgende ist Beiwerk: die Fragen bauen, eine Anfrage senden, die Antworten
zurücklesen. Kommt der Typ als heading zurück, braucht der Renderer eine
Überschrift-Ebene; bei list_item, ob die Reihenfolge zählt; bei callout, welche Art.
Die Typen sind noch nicht bekannt, und darauf zu warten würde einen dritten Roundtrip
bedeuten, also werden die Begleitfragen gleich vorab in derselben Anfrage gestellt. Die
meisten dieser Antworten werden nie gelesen: Die Schritt-Wahrscheinlichkeit eines Absatzes
bedeutet nichts und wird einfach ignoriert. Eine zusätzliche Frage kostet wenig, da der
Zustand den Großteil der Tokens ausmacht und so oder so einmal gesendet wird, während ein
zusätzlicher Roundtrip eine volle Anfrage Latenz hinzufügt.
HEADING_MAX_CHARS = 90 # longer blocks can't render as headings, so don't ask
def classify_questions(texts: list[str]) -> dict:
questions = {}
for i, text in enumerate(texts):
bid = block_id(i)
questions[f"type_{bid}"] = Choice(
instructions=f"What kind of content is block {bid}?", criteria=TYPE_CRITERIA
)
if len(text) <= HEADING_MAX_CHARS:
questions[f"hlevel_{bid}"] = Choice(
instructions=f"As a heading, what level would block {bid} occupy in this document's structure?",
criteria=HLEVEL_CRITERIA,
)
questions[f"step_{bid}"] = Noul(
instructions=f"Is block {bid} an instruction in a sequence where the order of the items matters?",
criteria=NoulCriteria(
true="It is one step of a procedure - the items around it must happen in order",
false="Order is irrelevant - it is a loose collection, or not a list item at all",
),
)
questions[f"callout_{bid}"] = Choice(
instructions=f"What kind of aside is block {bid}?", criteria=CALLOUT_CRITERIA
)
return questions
@json_cache
def classify(texts: list[str], gaps: list[bool]) -> dict:
tagged = tag([{"text": t, "gap": g} for t, g in zip(texts, gaps)], "B")
questions = classify_questions(texts)
started = perf_counter()
response = client.system_one(state=tagged, questions=questions, model=TYPESAFE_MODEL)
judgments = []
for i in range(len(texts)):
bid = block_id(i)
type_answer = response.answers[f"type_{bid}"]
hlevel = response.answers.get(f"hlevel_{bid}")
judgments.append(
{
"type": type_answer.choice,
"confidence": type_answer.confidence,
"probabilities": type_answer.probabilities,
"hlevel": hlevel.choice if hlevel else "section",
"step": response.answers[f"step_{bid}"].noul,
"callout": response.answers[f"callout_{bid}"].choice,
}
)
return {
"judgments": judgments,
"n_questions": len(questions),
"seconds": round(perf_counter() - started, 2),
"usage": [response.usage.input_tokens, response.usage.output_tokens],
}
classified = classify([b["text"] for b in blocks], [b["gap"] for b in blocks])
for block, judgment in zip(blocks, classified["judgments"]):
block.update(judgment)
print(f"{classified['n_questions']} questions about {len(blocks)} blocks, one request, "
f"{classified['seconds']}s\n")
print(f"{'block':<6}{'type':<11}{'conf':<6}{'companion used':<18}text")
for i, b in enumerate(blocks):
companion = {
"heading": f"level={b['hlevel']}",
"list_item": f"step={b['step']:.2f}",
"callout": f"kind={b['callout']}",
}.get(b["type"], "-")
print(f"{block_id(i):<6}{b['type']:<11}{b['confidence']:.2f} {companion:<18}"
f"{b['text'][:46]}")
62 questions about 17 blocks, one request, 0.51s
block type conf companion used text
B000 heading 0.99 level=title Migration to the new build system
B001 paragraph 0.98 - Hi everyone, quick heads up about the build sy
B002 heading 0.75 level=section What changes for you
B003 paragraph 0.89 - The old make targets keep working until the en
B004 code 1.00 - bun run build
B005 paragraph 0.90 - Generated artifacts no longer need to be commi
B006 paragraph 0.43 - The cutover touches three teams, so check whet
B007 list_item 0.99 step=0.15 The platform team
B008 list_item 1.00 step=0.16 The web client team
B009 list_item 0.99 step=0.12 Whoever still owns the release tooling
B010 heading 0.96 level=section Things to do before Monday
B011 list_item 0.98 step=0.86 Update your local toolchain to version 2.4 or
B012 list_item 0.99 step=0.87 Delete the old build cache directory
B013 list_item 0.92 step=0.90 Run the doctor script and fix anything it flag
B014 callout 0.65 kind=warning If the doctor script reports a red result on t
B015 quote 0.99 - As Dana put it in the kickoff, "a migration no
B016 paragraph 0.92 - Thanks, and shout if anything looks off.
Das Urteil jedes Blocks steht in dieser Tabelle, und die Begleit-Spalte zeigt, wie die
vorab gestellten Antworten zum Einsatz kommen: Die drei Zeilen unter „Things to do before
Monday“ tragen Schritt-Wahrscheinlichkeiten nahe 0.9 (sie werden als nummerierte Liste
gerendert), die drei Team-Zeilen liegen nahe 0.1 (mit Aufzählungszeichen), und die
unmarkierte Warnung über das Doctor-Skript wurde als Callout der Art warning
klassifiziert. Der Anhang betrachtet den einen Block, bei dem sich das Modell unsicher war.
Rendern
Der Code setzt die Seite aus den Urteilen zusammen. Aufeinanderfolgende Listeneinträge werden zu einer Liste, nummeriert, wenn der Mittelwert der Schritt-Wahrscheinlichkeiten der Einträge mindestens 0.5 beträgt. Dieser Schwellenwert ist eine Entscheidung auf Gruppenebene, die keine einzelne Frage direkt gestellt hat.
STEP_THRESHOLD = 0.5
HEADING_MARK = {"title": "#", "section": "##", "subsection": "###"}
CALLOUT_MARK = {"note": "NOTE", "tip": "TIP", "warning": "WARNING"}
def to_markdown(blocks: list[dict]) -> str:
groups = []
for b in blocks:
if b["type"] in ("list_item", "code") and groups and groups[-1][0] == b["type"]:
groups[-1][1].append(b)
else:
groups.append((b["type"], [b]))
parts = []
for kind, items in groups:
if kind == "list_item":
ordered = sum(b["step"] for b in items) / len(items) >= STEP_THRESHOLD
parts.append("\n".join(
f"{n + 1}. {b['text']}" if ordered else f"- {b['text']}"
for n, b in enumerate(items)
))
elif kind == "code":
parts.append("```\n" + "\n".join(b["text"] for b in items) + "\n```")
elif kind == "heading":
parts.append(f"{HEADING_MARK[items[0]['hlevel']]} {items[0]['text']}")
elif kind == "quote":
parts.append(f"> {items[0]['text']}")
elif kind == "callout":
parts.append(f"> [!{CALLOUT_MARK[items[0]['callout']]}]\n> {items[0]['text']}")
else:
parts.append(items[0]["text"])
return "\n\n".join(parts) + "\n"
markdown = to_markdown(blocks)
print(markdown)
# Migration to the new build system
Hi everyone, quick heads up about the build system migration that is happening next week. We have been running the new pipeline in shadow mode for three weeks and the results look solid, so it is time to make the switch for real.
## What changes for you
The old make targets keep working until the end of the month. The new entrypoint is a single command that wraps everything, including the docs build that used to be separate.
```
bun run build
```
Generated artifacts no longer need to be committed. The new pipeline uploads them to the registry automatically, and checking them in just creates merge conflicts.
The cutover touches three teams, so check whether you are on this list before you plan anything for Monday:
- The platform team
- The web client team
- Whoever still owns the release tooling
## Things to do before Monday
1. Update your local toolchain to version 2.4 or later
2. Delete the old build cache directory
3. Run the doctor script and fix anything it flags
> [!WARNING]
> If the doctor script reports a red result on the toolchain check, do not proceed with the migration. Ping the infra channel first and we will sort it out together.
> As Dana put it in the kickoff, "a migration nobody notices is the only kind worth shipping."
Thanks, and shout if anything looks off.
Jedes Wort oben stammt aus der Eingabe. Die Pipeline hat nur Grenzen, Typen und Auszeichnung gewählt.
Im Playground öffnen
Dieser Share-Link enthält die zusammengefügten Blöcke und den vollständigen Fragensatz von Durchgang 2. Öffne ihn, um die Klassifizierung live neu auszuführen.
playground_link = make_playground_link(
tag(blocks, "B"),
classify_questions([b["text"] for b in blocks]),
models=[TYPESAFE_MODEL],
)
display(Markdown(f"🔗 [Open the stitched memo + questions in the TypeSafe playground]({playground_link})"))
Öffne das zusammengefügte Memo + die Fragen im TypeSafe-Playground →
Anhang
Kosten und Latenz
tokens = [result["usage"], classified["usage"]]
total_in, total_out = sum(t[0] for t in tokens), sum(t[1] for t in tokens)
cost = total_in / 1e6 * PRICE[0] + total_out / 1e6 * PRICE[1]
n_joins = sum(1 for l in LINES if not l["gap"]) - 1
print(f"pass 1 {n_joins} questions {result['seconds']}s")
print(f"pass 2 {classified['n_questions']} questions {classified['seconds']}s")
print(f"total {total_in + total_out:,} tokens "
f"{result['seconds'] + classified['seconds']:.1f}s ${cost:.4f}")
pass 1 16 questions 0.32s
pass 2 62 questions 0.51s
total 10,211 tokens 0.8s $0.0003
Zwei Roundtrips, 10,211 Tokens, 0.8s, $0.0015.
Woher die Join-Schwellenwerte kommen
Die Join-Wahrscheinlichkeiten pro Zeile aus Durchgang 1:
print("join line")
for i, line in enumerate(LINES[:18]):
join = " " if i == 0 or line["gap"] else f"{result['joins'][i]:.2f}"
print(f"{join} {line_id(i)}| {line['text'][:66]}")
join line
L000| Migration to the new build system
L001| Hi everyone, quick heads up about the build system migration that
0.77 L002| happening next week. We have been running the new pipeline in shad
0.62 L003| mode for three weeks and the results look solid, so it is time to
0.39 L004| make the switch for real.
L005| What changes for you
L006| The old make targets keep working until the end of the month. The
0.42 L007| entrypoint is a single command that wraps everything, including th
0.59 L008| docs build that used to be separate.
L009| bun run build
L010| Generated artifacts no longer need to be committed. The new pipeli
0.48 L011| uploads them to the registry automatically, and checking them in
0.40 L012| just creates merge conflicts.
L013| The cutover touches three teams, so check whether you are on this
0.50 L014| list before you plan anything for Monday:
0.22 L015| The platform team
0.11 L016| The web client team
0.12 L017| Whoever still owns the release tooling
Die Wahrscheinlichkeiten landen in zwei getrennten Bändern: Zeilenumbrüche, die einen Satz teilen, erreichen 0.39 und mehr, Umbrüche, die der Autor beabsichtigt hat, liegen nahe null. Wo aber die Grenze zwischen den Bändern zu ziehen ist, hängt davon ab, wie die vorherige Zeile endet – ein Fakt, den der Code direkt lesen kann:
- Nach einer hängenden Zeile (einer ohne satzbeendende Interpunktion) zählt alles ab 0.2
als Fortsetzung. Echte Fortsetzungen erreichen hier Werte bis hinunter zu 0.39 (
L004| make the switch for real.), ein einzelner vorsichtiger Schwellenwert bei 0.5 würde also gesunde Absätze auseinanderreißen. - Nach Satzzeichen (einem Zeichen, das einen Satz oder Teilsatz beendet:
.!?:;) steigt der Schwellenwert auf 0.5. Die Team-Liste des Memos zeigt, warum:L015| The platform teamfolgt auf einen Doppelpunkt und erreicht 0.22. Das ist ein schwaches, aber von null verschiedenes Signal für „dies setzt den Satz fort“, und es würde den Schwellenwert von 0.2 überschreiten und die Liste mit dem Satz zusammenführen, der sie einleitet. Kein einzelner Schwellenwert funktioniert für beide Fälle; sobald der Code zuerst die Interpunktion prüft, trennen sich die beiden Bänder.
Warum die Frage „mid-sentence“ und nicht „same paragraph“ lautet
Die erste Version dieser Pipeline stellte die naheliegende Frage: „Gehören diese beiden Zeilen zum selben Absatz?“ Sie scheiterte auf eine bestimmte Weise. Eine Folge kurzer Zeilen unter einer Überschrift (eine ohne Aufzählungszeichen getippte Liste) ist im weiten Sinn ein Absatz: Die Zeilen stehen zusammen und teilen ein Thema. Nach Absätzen gefragt, sagt das Modell zu jedem Paar ja, und der Zusammenfüge-Durchgang verschmilzt die ganze Liste zu einem langen Block.
Dasselbe Dokument, dieselbe Form der Anfrage, nur die Wortwahl geändert:
def naive_join_question(i: int) -> Noul:
return Noul(
instructions=f"Are lines {line_id(i - 1)} and {line_id(i)} part of the same paragraph?",
criteria=NoulCriteria(
true="The two lines belong to the same paragraph of running text",
false="The two lines belong to different paragraphs or different pieces of content",
),
)
naive = stitch("same-paragraph")
print(f"{'':14}{'mid-sentence':>13}{'same paragraph':>16}")
for i in (15, 16, 17, 20, 21):
print(f"{line_id(i)}{'':2}{LINES[i]['text'][:36]:<38}"
f"{result['joins'][i]:>7.2f}{naive['joins'][i]:>13.2f}")
print(f"\nblocks after merge: {len(blocks)} (mid-sentence) vs "
f"{len(merge(naive['joins']))} (same paragraph)")
mid-sentence same paragraph
L015 The platform team 0.22 0.77
L016 The web client team 0.11 0.81
L017 Whoever still owns the release tooli 0.12 0.78
L020 Delete the old build cache directory 0.08 0.88
L021 Run the doctor script and fix anythi 0.05 0.91
blocks after merge: 17 (mid-sentence) vs 12 (same paragraph)
Mit der Absatz-Formulierung erreicht jeder unmarkierte Listeneintrag über 0.75, und beide Listen fallen zusammen. Das Memo verschmilzt zu ein paar langen Blöcken. „Same paragraph“ bittet das Modell zu beurteilen, ob das Thema übergeht – und zwischen Listeneinträgen tut es das. „Picks up mid-sentence“ fragt nach dem Text selbst. Wenn ein Urteil einen Schwellenwert speist, sollte die Frage den engsten Fakt benennen, der ihn entscheidet. Hier ist die Wortwahl der Unterschied zwischen 17 Blöcken und 12.
Der Block mit der geringsten Konfidenz
uncertain = min(blocks, key=lambda b: b["confidence"])
print(f'"{uncertain["text"]}"')
print(f"confidence {uncertain['confidence']:.2f}: ", end="")
print(", ".join(f"{k} {v:.2f}" for k, v in
sorted(uncertain["probabilities"].items(), key=lambda kv: -kv[1])[:3]))
"The cutover touches three teams, so check whether you are on this list before you plan anything for Monday:"
confidence 0.43: paragraph 0.53, list_item 0.24, callout 0.19
Der Satz, der die Team-Liste einleitet, ist wirklich mehrdeutig – er benennt, was folgt (überschriftartig), ist ein vollständiger Satz (absatzartig) und steht dort, wo ein Callout hingehöre. Die Wahrscheinlichkeiten verteilen sich entsprechend (paragraph 0.53, list_item 0.24, callout 0.19), und eine UI kann das sichtbar machen – zum Beispiel jeden Block unterstreichen, dessen Typ-Konfidenz (die Wahrscheinlichkeit hinter der gewählten Option) unter 0.55 liegt, damit er geprüft wird.