Documentation

Récupération de structure

Reconstruit du Markdown à partir d’un texte brut qui a perdu sa mise en forme, en deux requêtes : l’une recoud les lignes coupées, l’autre classe chaque bloc (titre, liste, code, encadré).

Ce cookbook prend un texte brut dont le balisage a été retiré (lignes coupées en plein milieu d’une phrase, aucun marqueur de titre, aucune puce de liste) et reconstruit la structure en Markdown : titres, paragraphes, listes, citations, code, encadrés. L’entrée est un mémo d’équipe exactement dans cet état.

Un modèle de génération de texte pourrait réécrire le texte en Markdown, mais une réécriture peut aussi changer les mots. Ici, le modèle ne génère jamais de texte : il répond à des questions étroites sur le document (cette ligne reprend-elle une phrase en cours ? de quel type de contenu s’agit-il ?), et le code fait le rendu, donc chaque caractère de la sortie vient de l’entrée, et chaque jugement porte une probabilité.

Tout le pipeline représente deux requêtes API par document, exécutées à la suite :

  • Passe 1, recoudre : une question Noul (une question oui/non dont la réponse est la probabilité que « oui » soit correct) par paire de lignes adjacentes, demandant si le saut de ligne a coupé une phrase entre les deux. Toutes les paires partent dans une seule requête, et les lignes qui poursuivent une phrase coupée sont recollées en blocs.
  • Passe 2, classer : une question Choice (choisir une option dans une liste, avec une probabilité pour chaque option) par bloc fusionné, choisissant entre titre, paragraphe, élément de liste, citation, code ou encadré (une note, une astuce ou un avertissement mis à part du texte principal). Les blocs n’existent qu’une fois la passe 1 répondue, donc c’est une seconde requête ; elle porte aussi des questions compagnes pour chaque bloc (niveau de titre, ordre des étapes, type d’encadré) dont les réponses ne sont lues que lorsque le type du bloc les rend pertinentes.
  • Les indices directs restent dans le code. Les lignes vides et les marqueurs explicites (- , 1., #) sont lus dans le code, jamais envoyés au modèle pour réexamen ; ce mémo a gardé ses lignes vides mais perdu tous ses marqueurs. Le modèle ne reçoit que les questions auxquelles le code ne peut pas répondre à partir du texte.

Tout le comportement est spécifié dans les critères des questions de la passe 2 : trois dictionnaires de descriptions d’une ligne, plus les critères vrai/faux de la question d’étape dans classify_questions. Le reste du code n’est que de la plomberie autour. Les chiffres de coût et de latence sont dans l’annexe : deux allers-retours, 10 211 jetons, 0,8 s, 0,0015 $ pour ce mémo.

Configuration

pip install ipython 'cooksafe>=0.2.0,<0.3.0'

puis définis TYPESAFE_API_KEY. Chaque appel API est mis en cache dans json_cache.json, livré avec le cookbook, donc un nouveau rendu rejoue les chiffres publiés sans appeler l’API. Supprime ce fichier pour tout réexécuter en direct.

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"))

Le document : un mémo d’équipe qui a perdu sa mise en forme

Le document de test est un mémo sur une migration de système de build, dans l’état où il arrive dans une boîte de réception en texte brut : des paragraphes coupés en plein milieu d’une phrase, une commande shell posée sur une ligne nue, deux listes sans puces ni numéros, un avertissement que rien ne signale comme tel. Le texte est récupéré depuis un gist figé pour que les chiffres du cookbook restent reproductibles.

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

Le découpage des lignes, le suivi des lignes vides et l’étiquetage des identifiants se font tous dans le code ; aucun modèle n’intervient. Chaque ligne reçoit un identifiant court (L014| ) ; les identifiants sont du texte ordinaire que le modèle lit comme partie de l’état, et les questions et réponses se réfèrent aux lignes par ces identifiants (le même schéma que le cookbook de recherche sémantique).

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

Passe 1 : recoudre les phrases coupées

Une question Noul par paire de lignes adjacentes, toutes dans une seule requête ; les paires séparées par une ligne vide sont ignorées. La question est volontairement étroite (« cette ligne reprend-elle une phrase en cours ? »), ce qui est proche d’un fait objectif sur le texte. L’annexe couvre à la fois le choix de formulation et la façon dont les seuils de fusion ont été établis.

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

Le seuil de fusion dépend de la façon dont la ligne précédente se termine. Après une ligne en suspens (sans ponctuation de fin de phrase), une probabilité de jointure de 0,2 ou plus fusionne la paire ; après une ponctuation terminale (. ! ? : ;), le seuil monte à 0,5. L’annexe détaille les probabilités derrière ces deux nombres.

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.

Passe 2 : classer les blocs

Chaque bloc recousu reçoit une question Choice : de quel type de contenu s’agit-il ? Ces trois dictionnaires, plus les critères vrai/faux de la question d’étape dans classify_questions ci-dessous, constituent toute la spécification du classifieur. Il n’y a pas d’autre logique. Pour adapter le pipeline à tes propres documents, modifie ces descriptions.

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",
}

Tout ce qui suit est de la plomberie : construire les questions, envoyer une requête, relire les réponses. Si le type revient heading, le moteur de rendu a besoin d’un niveau de titre ; si c’est list_item, de savoir si l’ordre compte ; si c’est callout, de quel genre. Les types ne sont pas encore connus, et les attendre voudrait dire un troisième aller-retour, donc les questions compagnes sont posées d’emblée dans la même requête. La plupart de ces réponses ne sont jamais lues : la probabilité d’étape d’un paragraphe ne veut rien dire et est simplement ignorée. Une question de plus ajoute peu, puisque l’état représente l’essentiel des jetons et n’est envoyé qu’une fois dans les deux cas, tandis qu’un aller-retour supplémentaire ajoute toute la latence d’une requête.

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.

Le jugement de chaque bloc figure dans ce tableau, et la colonne compagne montre les réponses anticipées mises à profit : les trois lignes « Things to do before Monday » portent des probabilités d’étape proches de 0,9 (elles s’afficheront en liste numérotée), les trois lignes d’équipe se situent près de 0,1 (puces), et l’avertissement non marqué au sujet du script doctor a été classé comme encadré de type warning. L’annexe examine le seul bloc dont le modèle n’était pas sûr.

Rendu

Le code assemble la page à partir des jugements. Des éléments de liste consécutifs deviennent une seule liste, numérotée quand la moyenne des probabilités d’étape des éléments atteint au moins 0,5. Ce seuil est une décision au niveau du groupe qu’aucune question n’a posée directement.

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.

Chaque mot ci-dessus vient de l’entrée. Le pipeline n’a choisi que les frontières, les types et le balisage.

Ouvre-le dans le playground

Ce lien de partage contient les blocs recousus et l’ensemble complet des questions de la passe 2. Ouvre-le pour relancer la classification en direct.

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})"))
Ouvre le mémo recousu + les questions dans le playground TypeSafe →

Annexe

Coût et latence

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

Deux allers-retours, 10 211 jetons, 0,8 s, 0,0015 $.

D’où viennent les seuils de jointure

Les probabilités de jointure par ligne de la passe 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

Les probabilités tombent dans deux bandes distinctes : les sauts de ligne qui coupent une phrase obtiennent 0,39 et plus, les sauts voulus par l’auteur obtiennent près de zéro. Mais où placer le seuil entre les bandes dépend de la façon dont la ligne précédente se termine, un fait que le code peut lire directement :

  • Après une ligne en suspens (sans ponctuation de fin de phrase), tout ce qui atteint 0,2 ou plus compte comme une continuation. De vraies continuations obtiennent ici jusqu’à 0,39 (L004| make the switch for real.), donc un seuil unique et prudent à 0,5 casserait des paragraphes sains.
  • Après une ponctuation terminale (un caractère qui termine une phrase ou une proposition : . ! ? : ;), le seuil monte à 0,5. La liste d’équipe du mémo montre pourquoi : L015| The platform team suit un deux-points et obtient 0,22. C’est un signal « ceci poursuit la phrase » faible mais non nul, et il franchirait le seuil de 0,2 et fusionnerait la liste dans la phrase qui l’introduit. Aucun seuil unique ne fonctionne pour les deux cas ; une fois que le code vérifie d’abord la ponctuation, les deux bandes se séparent.

Pourquoi la question porte sur « en cours de phrase » et non sur « même paragraphe »

La première version de ce pipeline posait la question évidente : « ces deux lignes font-elles partie du même paragraphe ? » Elle échouait d’une manière précise. Une suite de lignes courtes sous un titre (une liste saisie sans puces) est un paragraphe au sens large : les lignes vont ensemble et partagent un sujet. Interrogé sur les paragraphes, le modèle répond oui à chaque paire, et la passe de recousage fusionne toute la liste en un seul long bloc.

Même document, même forme de requête, seule la formulation a changé :

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)

Avec la formulation « paragraphe », chaque élément de liste non marqué obtient plus de 0,75 et les deux listes s’effondrent. Le mémo fusionne en quelques blocs d’un seul tenant. « Même paragraphe » demande au modèle de juger si le sujet se poursuit, et entre éléments de liste c’est le cas. « Reprend en cours de phrase » porte sur le texte lui-même. Quand un jugement alimente un seuil, la question doit nommer le fait le plus étroit qui le tranche. Ici, la formulation fait la différence entre 17 blocs et 12.

Le bloc le moins sûr

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

La phrase qui introduit la liste d’équipe est vraiment ambiguë – elle nomme ce qui suit (comme un titre), est une phrase complète (comme un paragraphe) et se trouve là où irait un encadré. Les probabilités se répartissent en conséquence (paragraphe 0,53, list_item 0,24, callout 0,19), et une interface peut le signaler – par exemple, souligner pour révision tout bloc dont la confiance de type (la probabilité derrière le choix gagnant) est inférieure à 0,55.