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 teamsuit 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.