JevCode / Cas d'écosystème

Récupération de la structure

Reconstitue le Markdown à partir d'un texte brut ayant perdu son formatage lors de deux requêtes : l'une assemble les lignes retournées à la ligne, l'autre classe chaque bloc (titre, liste, code, appel à l'action) avec des questions compagnes lues uniquement lorsque cela est pertinent.

Traduit automatiquement depuis le en, non relu. À utiliser comme référence rapide uniquement.

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

Ce cookbook prend du texte brut dont le balisage a été supprimé (lignes retournées à la ligne au milieu d’une phrase, sans marqueurs de titre, sans puces de liste) et reconstruit la structure en Markdown : titres, paragraphes, listes, citations, code, encadrés. L’entrée est une note 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 modifier les mots. Ici, le modèle ne génère jamais de texte : il répond à des questions précises sur le document (cette ligne reprend-elle une phrase en cours ? quel type de contenu ce bloc contient-il ?), et le code s’occupe du rendu, de sorte que chaque caractère de la sortie provient de l’entrée, et que chaque jugement s’accompagne d’une probabilité.

L’ensemble du pipeline repose sur deux requêtes API par document, exécutées séquentiellement :

  • Pass 1, stitch : une Noul question (une question par 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 sont envoyées dans une seule requête, et les lignes qui poursuivent une phrase coupée sont fusionnées à nouveau en blocs.
  • Pass 2, classify : une Choice question (choisir une option dans une liste, avec une probabilité pour chaque option) par bloc fusionné, parmi les options : titre, paragraphe, élément de liste, citation, code ou note (une note, une astuce ou un avertissement mis à part du texte principal). Les blocs n’existent qu’une fois que le pass 1 a répondu, il s’agit donc d’une deuxième requête ; elle inclut également des questions compagnons pour chaque bloc (niveau du titre, ordre des étapes, type de note) dont les réponses ne sont lues que lorsque le type du bloc les rend pertinentes.
  • Les preuves directes restent dans le code. Les lignes vides et les marqueurs explicites (- , 1., #) sont lus dans le code, jamais envoyés au modèle pour être réexaminés ; ce mémo a conservé ses lignes vides mais a perdu tous les marqueurs. Le modèle ne reçoit que les questions que le code ne peut pas résoudre à partir du texte.

Tous les comportements sont spécifiés dans les critères de la question de passe-2 : trois dictionnaires de descriptions en une ligne, ainsi que les critères vrai/faux de la question d’étape à l’intérieur de classify_questions. Le reste du code n’est que de la connectique autour d’eux. Les chiffres des coûts et de la latence se trouvent dans l’annexe : deux allers-retours, 10 211 jetons, 0,8 s, 0,0015 $ pour ce mémo.

Installation

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

puis définis TYPESAFE_API_KEY. Chaque appel API est mis en cache dans json_cache.json, qui est livré avec le cookbook, de sorte que le nouveau rendu rejoue les chiffres publiés sans appeler l’API. Supprime ce fichier pour tout relancer 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 : une note d’équipe qui a perdu sa mise en forme

Le document de test est une note sur une migration du système de build, dans l’état où elle arrive dans une boîte de réception en texte brut : des paragraphes retournés à la ligne au milieu d’une phrase, une commande shell seule sur une ligne nue, deux listes sans puces ni numéros, un avertissement sans aucun marquage indiquant qu’il s’en agit. Le texte est récupéré depuis un gist épinglé afin que les numéros du manuel 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

La division des lignes, le suivi des lignes vides et l’attribution des identifiants se font dans le code ; aucun modèle n’est impliqué. Chaque ligne reçoit un identifiant court (L014| ) ; ces identifiants sont du texte ordinaire que le modèle lit dans le cadre de l’état, et les questions et réponses font référence aux lignes par ces identifiants (le même schéma que celui du recette 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 : assemblage des phrases fractionnées

Une question Noul par paire de lignes adjacentes, toutes dans une seule demande ; les paires séparées par une ligne vide sont ignorées. La question est délibérément étroite (« cette ligne reprend-elle une phrase en cours ? »), ce qui se rapproche d’un fait objectif concernant le texte. L’annexe couvre à la fois le choix de la formulation et la manière dont les seuils de fusion ont été dérivés.

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

La limite de fusion dépend de la manière dont la ligne précédente se termine. Après une ligne orpheline (sans ponctuation de fin de phrase), une probabilité de jonction de 0,2 ou plus fusionne la paire ; après une ponctuation terminale (. ! ? : ;), la limite augmente à 0,5. L’annexe détaille les probabilités sous-jacentes à ces deux valeurs.

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 : classification des blocs

Chaque bloc assemblé reçoit une question Choice : quel type de contenu s’agit-il ? Ces trois dictionnaires, ainsi que les critères vrai/faux de la question de l’étape situés dans classify_questions ci-dessous, constituent la spécification entière du classifieur. Il n’y a aucune autre logique. Pour adapter le pipeline à vos propres documents, modifiez 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 tuyauterie : construire les questions, envoyer une requête, lire les réponses en retour. Si le type revient heading, le rendu a besoin d’un niveau de titre ; si list_item, si l’ordre a de l’importance ; si callout, quel type. Les types ne sont pas encore connus, et attendre leur retour signifierait un troisième aller-retour, donc les questions complémentaires sont posées dès le départ dans la même requête. La plupart de ces réponses ne sont jamais lues : la probabilité d’étape d’un paragraphe ne signifie rien et est simplement ignorée. Une question supplémentaire ajoute peu, car l’état est la plupart des jetons et est envoyé une fois de toute façon, tandis qu’un aller-retour supplémentaire ajoute une latence équivalente à une requête complè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 se trouve dans ce tableau, et la colonne compagnon montre les réponses initiales mises à profit : les trois lignes « Choses à faire avant lundi » portent des probabilités d’étape proches de 0,9 (elles s’afficheront sous forme de liste numérotée), les trois lignes d’équipe se situent près de 0,1 (liste à puces), et l’avertissement non marqué concernant le script du médecin a été classé comme une mise en avant de type warning. L’annexe examine le seul bloc sur lequel le modèle avait des doutes.

Rendu

Le code assemble la page à partir des jugements. Les éléments de liste consécutifs forment une seule liste, numérotée lorsque la moyenne des probabilités d’étape des éléments est d’au moins 0,5. Ce seuil est une décision de niveau groupe, aucune question unique n’est 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 provient de l’entrée. Le pipeline a seulement choisi les limites, les types et la mise en forme.

Ouvrez-le dans le terrain de jeu

Ce lien de partage contient les blocs assemblés et l’ensemble complet des questions de la passe 2. Ouvrez-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})"))

Ouvrir la note assemblée + 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 jonction par ligne issues 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 se répartissent en deux bandes distinctes : les sauts de ligne qui scindent une phrase obtiennent un score de 0,39 et plus, tandis que les sauts de ligne intentionnels de l’auteur obtiennent un score proche de zéro. Mais l’endroit où placer la limite entre ces bandes dépend de la façon dont la ligne précédente se termine, une donnée que le code peut lire directement :

  • Après une ligne pendante (sans ponctuation de fin de phrase), tout score de 0,2 ou plus compte comme une continuation. Les continuations réelles peuvent avoir un score aussi bas que 0,39 ici (L004| make the switch for real.), donc un seuil unique prudent à 0,5 fragmenterait des paragraphes sains.
  • Après une ponctuation terminale (un caractère qui termine une phrase ou une clause : . ! ? : ;), le seuil monte à 0,5. La liste d’équipe du mémo montre pourquoi : L015| The platform team suit deux-points et obtient 0,22. C’est un signal faible mais non nul de « la phrase continue », qui dépasserait le seuil de 0,2 et fusionnerait la liste avec la phrase qui l’introduit. Aucun seuil unique ne convient aux deux cas ; une fois que le code vérifie la ponctuation en premier, les deux plages se séparent.

Pourquoi la question est « au milieu de la phrase » et non « dans le même paragraphe »

La première version de ce pipeline a posé la question évidente : « ces deux lignes font-elles partie du même paragraphe ? » Elle a échoué d’une manière spécifique. Une série de lignes courtes sous un titre (une liste tapée sans puces) est un paragraphe au sens large : les lignes sont regroupées et partagent un sujet. Interrogé sur les paragraphes, le modèle répond oui à chaque paire, et la passe de fusion fusionne toute la liste en un seul bloc long.

Même document, même forme de demande, 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 des paragraphes, chaque élément de liste non marqué obtient un score supérieur à 0,75 et les deux listes se réduisent. Le mémo fusionne en quelques blocs à rallonge. « Même paragraphe » demande au modèle de juger si le sujet se poursuit, et entre les éléments de liste, c’est le cas. « Reprend au milieu d’une phrase » porte sur le texte lui-même. Lorsqu’un jugement subjectif alimente un seuil, la question doit nommer le fait le plus précis qui le détermine. Ici, la formulation fait la différence entre 17 blocs et 12.

Le bloc de plus faible confiance

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 des membres est véritablement ambiguë : elle nomme ce qui suit (ce qui ressemble à un titre), constitue une phrase complète (ce qui ressemble à un paragraphe), et se trouve à l’endroit où une note ou un encadré serait attendu. Les probabilités se répartissent en conséquence (paragraphe 0,53, élément de liste 0,24, encadré 0,19), et une interface utilisateur peut mettre cela en évidence — par exemple, souligner pour examen tout bloc dont le niveau de confiance du type (la probabilité associée au choix dominant) est inférieur à 0,55.