JevCode / Ökosystem-Beispiele

Strukturrekonstruktion

Rekonstruiert Markdown aus Fließtext, der bei zwei Anfragen sein Format verloren hat: Eine verknüpft hart umgebrochene Zeilen wieder, die andere klassifiziert jeden Block (Überschrift, Liste, Code, Hinweisbox) mit Begleitfragen, die nur bei Relevanz gelesen werden.

Maschinell aus en übersetzt, nicht lektoriert. Nur als Kurzreferenz geeignet.

Quelle: docs.typesafe.ai/cookbooks/autoformatcookbookrecipe
Jumbled text reorganised into distinct blocks

Dieses Kochbuch nimmt Klartext, dessen Markup entfernt wurde (Zeilen sind mitten im Satz hart umgebrochen, keine Überschriftsmarker, keine Aufzählungszeichen), und rekonstruiert die Struktur als Markdown: Überschriften, Absätze, Listen, Zitate, Code, Callouts. Die Eingabe ist ein Teammemo in genau diesem Zustand.

Ein Textgenerierungsmodell könnte den Text in Markdown umschreiben, aber eine Umschreibung kann auch die Wörter verändern. Hier generiert das Modell niemals Text: Es beantwortet enge Fragen zum Dokument (nimmt diese Zeile mitten im Satz auf? welche Art von Inhalt hat dieser Block?), und Code übernimmt das Rendern, sodass jedes Zeichen der Ausgabe aus der Eingabe stammt und jede Entscheidung eine Wahrscheinlichkeit trägt.

Der gesamte Pipeline besteht aus zwei API-Anfragen pro Dokument, die sequenziell ausgeführt werden:

  • Durchgang 1, zusammenfügen: eine Noul Frage (eine Ja/Nein-Frage, deren Antwort die Wahrscheinlichkeit ist, dass „Ja“ korrekt ist) pro benachbartem Zeilenpaar, die abfragt, ob der Zeilenumbruch einen Satz über die beiden Zeilen aufgeteilt hat. Alle Paare werden in einer einzigen Anfrage zusammengefasst, und Zeilen, die einen aufgeteilten Satz fortsetzen, werden wieder zu Blöcken zusammengeführt.
  • Durchgang 2, klassifizieren: eine Choice Frage (eine Option aus einer Liste auswählen, mit einer Wahrscheinlichkeit für jede Option) pro zusammengeführtem Block, bei der zwischen Überschrift, Absatz, Listenelement, Zitat, Code oder Callout (eine Notiz, ein Tipp oder eine Warnung, die vom Haupttext abgesetzt ist) gewählt wird. Die Blöcke existieren erst, nachdem Durchgang 1 beantwortet wurde, daher handelt es sich um eine zweite Anfrage; sie enthält zudem Begleitfragen für jeden Block (Überschriftenstufe, Schritt-Reihenfolge, Callout-Art), deren Antworten nur gelesen werden, wenn der Blocktyp sie relevant macht.
  • Direkte Beweise bleiben im Code. Leerzeilen und explizite Marker (- , 1., #) werden im Code gelesen, niemals an das Modell gesendet, um sie erneut zu prüfen; dieses Memo behielt seine Leerzeilen, verlor jedoch jeden Marker. Das Modell erhält nur die Fragen, die der Code nicht aus dem Text selbst beantworten kann.

Das gesamte Verhalten ist in den Kriterien für die Frage der Pass-2 spezifiziert: drei Dictionaries mit einzeiligen Beschreibungen sowie die Ja/Nein-Kriterien der Schritt-Frage innerhalb von classify_questions. Der Rest des Codes ist nur Infrastruktur um sie herum. Die Kosten- und Latenzangaben finden sich im Anhang: zwei Rundreisen, 10.211 Tokens, 0,8 s, 0,0015 $ für dieses Memo.

Einrichtung

pip install ipython "typesafe-sdk>=0.5.7" cooksafe --extra-index-url https://pypi.typesafe.ai/

dann TYPESAFE_API_KEY setzen. Jeder API-Aufruf wird in json_cache.json zwischengespeichert, das mit dem Kochbuch ausgeliefert wird, sodass das erneute Rendern die veröffentlichten Zahlen abspielt, ohne die API aufzurufen. Löschen Sie 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 Teammemo, das sein Format verloren hat

Das Testdokument ist ein Memo zur Migration eines Build-Systems, im Zustand, wie es in einem Klartext-Posteingang eingeht: Absätze, die mitten im Satz hart umgebrochen sind, ein Shell-Befehl, der auf einer einzelnen Zeile steht, zwei Listen ohne Aufzählungszeichen oder Nummern, eine Warnung, die durch nichts als solche gekennzeichnet ist. Der Text wird aus einem angehefteten Gist abgerufen, damit die Zahlen im Kochbuch 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

Die Zeilenaufteilung, die Verfolgung von Leerzeilen und die ID-Vergabe für Blöcke erfolgen allesamt im Code; kein Modell ist daran beteiligt. Jede Zeile erhält eine kurze ID (L014| ); die IDs sind gewöhnlicher Text, den das Modell als Teil des Zustands liest, und Fragen sowie Antworten beziehen sich auf Zeilen über diese IDs (das gleiche Schema wie im Semantic Search Cookbook).

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: Zusammenfügen getrennter Sätze

Eine Noul-Frage pro benachbartem Zeilenpaar, alle in einer Anfrage; Paare, die durch eine leere Zeile getrennt sind, werden übersprungen. Die Frage ist bewusst eng gefasst („nimmt diese Zeile den Satz mitten drin auf?“), was nahe an einer objektiven Tatsache über den Text liegt. Das Anhang behandelt sowohl die Formulierungswahl als auch die Herleitung der Merge-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ür das Zusammenführen hängt davon ab, wie die vorherige Zeile endet. Nach einer hängenden Zeile (einer ohne Satzzeichen am Ende) führt eine Join-Wahrscheinlichkeit von 0,2 oder höher zum Zusammenführen des Paares; nach terminalen Satzzeichen (. ! ? : ;) steigt der Schwellenwert auf 0,5. Das Anhang erläutert die Wahrscheinlichkeiten hinter diesen beiden Werten.

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 zusammengesetzte Block erhält eine Choice-Frage: Welche Art von Inhalt ist dies? Diese drei Dictionaries, zusammen mit den Ja/Nein-Kriterien der Schritt-Frage innerhalb von classify_questions unten, stellen die gesamte Spezifikation des Klassifikators dar. Es gibt keine weitere Logik. Um die Pipeline an Ihre eigenen Dokumente anzupassen, bearbeiten Sie 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, was folgt, ist nur Infrastruktur: Fragen zusammenstellen, eine Anfrage senden, Antworten zurücklesen. Wenn der Typ heading zurückgibt, benötigt der Renderer eine Überschriftenebene; wenn list_item, ob die Reihenfolge relevant ist; wenn callout, welche Art. Die Typen sind noch nicht bekannt, und auf sie zu warten würde eine dritte Roundtrip-Zeit bedeuten, daher werden die Begleitfragen im Voraus 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 fügt wenig hinzu, da der Zustand den Großteil der Tokens ausmacht und in jedem Fall einmal gesendet wird, während ein zusätzlicher Roundtrip eine volle Latenz der Anfrage 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.

Die Bewertung jedes Blocks befindet sich in dieser Tabelle, und die Begleitspalte zeigt, wie die vorab formulierten Antworten eingesetzt werden: Die drei Zeilen „Things to do before Monday“ weisen Schritt-Wahrscheinlichkeiten nahe 0,9 auf (sie werden als nummerierte Liste gerendert), die drei Team-Zeilen liegen nahe 0,1 (aufgezählt), und die unmarkierte Warnung zum Arzt-Skript wurde als Callout der Art warning klassifiziert. Im Anhang wird der eine Block betrachtet, bei dem das Modell unsicher war.

Rendering

Der Code setzt die Seite aus den Urteilen zusammen. Aufeinanderfolgende Listenelemente werden zu einer einzigen Liste, nummeriert, wenn der Mittelwert der Schritt-Wahrscheinlichkeiten der Elemente mindestens 0,5 beträgt. Diese Schwelle ist eine Entscheidung auf Gruppenebene, keine einzelne Frage, die direkt gestellt wird.

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.

Jede Zeile oben stammt aus der Eingabe. Die Pipeline wählte nur Grenzen, Typen und Markup.

Öffne es im Playground

Dieser Freigabelink enthält die zusammengesetzten Blöcke und den vollständigen Fragekatalog der Pass-2-Runde. Öffnen Sie ihn, um die Klassifizierung live erneut 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 zusammengesetzte Memo + 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 Hin- und Rückfahrten, 10.211 Tokens, 0,8 s, $0,0015.

Woher die Join-Schwellenwerte stammen

Die pro Zeile berechneten Join-Wahrscheinlichkeiten 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 verteilen sich auf zwei getrennte Bänder: Zeilenumbrüche, die einen Satz teilen, erhalten Werte von 0,39 und höher, beabsichtigte Umbrüche des Autors liegen nahe bei null. Wo die Trennlinie zwischen den Bändern verläuft, hängt jedoch davon ab, wie die vorherige Zeile endet, eine Tatsache, die der Code direkt ablesen kann:

  • Nach einer abgebrochenen Zeile (einer ohne Satzzeichen am Ende) zählt alles ab 0,2 als Fortsetzung. Echte Fortsetzungen können hier so niedrig wie 0,39 liegen (L004| make the switch for real.), sodass ein einzelner vorsichtiger Grenzwert von 0,5 gesunde Absätze zerschneiden würde.
  • Nach terminaler Zeichensetzung (ein Zeichen, das einen Satz oder Satzteil beendet: . ! ? : ;) steigt der Grenzwert auf 0,5. Die Teamliste im Memo zeigt, warum: L015| The platform team folgt auf einen Doppelpunkt und erzielt 0,22. Das ist ein niedriger, aber von Null verschiedener „Dies setzt den Satz fort“-Signalwert, und er würde die 0,2-Grenze überschreiten und die Liste in den Satz integrieren, der sie einleitet. Kein einzelner Schwellenwert funktioniert für beide Fälle; sobald der Code die Zeichensetzung zuerst prüft, trennen sich die beiden Bereiche.

Warum die Frage „im Satz“ und nicht „im selben Absatz“ lautet

Die erste Version dieser Pipeline stellte die naheliegende Frage: „Sind diese beiden Zeilen Teil desselben Absatzes?“ Sie scheiterte auf eine bestimmte Weise. Eine Folge kurzer Zeilen unter einer Überschrift (eine Liste, die ohne Aufzählungszeichen eingegeben wurde) ist im weiteren Sinne ein Absatz: Die Zeilen stehen zusammen und teilen ein Thema. Wird nach Absätzen gefragt, antwortet das Modell auf jedes Paar mit Ja, und der Zusammenführungsprozess verschmilzt die gesamte Liste zu einem einzigen langen Block.

Dasselbe Dokument, dieselbe Anfragestruktur, nur die Formulierung wurde 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)

Bei der Absatzformulierung erzielt jedes unmarkierte Listenelement einen Wert über 0.75, und beide Listen fallen zusammen. Das Memo verschmilzt zu einigen zusammenhangslosen Blöcken. „Same paragraph“ bittet das Modell zu beurteilen, ob das Thema weitergeführt wird, und zwischen den Listenelementen tut es das. „Picks up mid-sentence“ fragt nach dem Text selbst. Wenn eine Urteilsentscheidung einen Schwellenwert auslöst, sollte die Frage die engste Tatsache benennen, die sie entscheidet. Hier ist die Formulierung 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 Auflistung des Teams einleitet, ist wirklich mehrdeutig – er benennt das Folgende (überschriftartig), ist ein vollständiger Satz (absatzartig) und befindet sich dort, wo ein Hinweisfeld stehen würde. Die Wahrscheinlichkeiten verteilen sich entsprechend (Absatz 0,53, Listenelement 0,24, Hinweisfeld 0,19), und eine Benutzeroberfläche kann dies anzeigen – zum Beispiel jeden Block zur Überprüfung unterstreichen, dessen Typkonfidenz (die Wahrscheinlichkeit hinter der gewählten Option) unter 0,55 liegt.