Documentação

Recuperação de estrutura

Reconstrói Markdown a partir de texto simples que perdeu a formatação em dois pedidos: um volta a juntar as linhas quebradas, outro classifica cada bloco (título, lista, código, callout).

Este cookbook pega em texto simples cuja marcação foi removida (linhas quebradas a meio da frase, sem marcadores de título, sem marcadores de lista) e reconstrói a estrutura como Markdown: títulos, parágrafos, listas, citações, código, callouts. A entrada é um memorando de equipa exatamente nesse estado.

Um modelo de geração de texto poderia reescrever o texto em Markdown, mas uma reescrita também pode mudar as palavras. Aqui o modelo nunca gera texto: ele responde a perguntas estreitas sobre o documento (esta linha continua a meio de uma frase? que tipo de conteúdo é este bloco?), e o código faz a renderização, por isso cada caráter da saída vem da entrada, e cada julgamento carrega uma probabilidade.

Todo o pipeline são dois pedidos à API por documento, executados em sequência:

  • Passo 1, emendar: uma pergunta Noul (uma pergunta sim/não cuja resposta é a probabilidade de que “sim” esteja correta) por par adjacente de linhas, a perguntar se a quebra de linha dividiu uma frase entre as duas. Todos os pares vão num único pedido, e as linhas que continuam uma frase dividida são reunidas de volta em blocos.
  • Passo 2, classificar: uma pergunta Choice (escolhe uma opção de uma lista, com uma probabilidade para cada opção) por bloco reunido, escolhendo entre título, parágrafo, item de lista, citação, código ou callout (uma nota, dica ou aviso destacado do texto principal). Os blocos só existem depois de o passo 1 ter respondido, por isso este é um segundo pedido; este também inclui perguntas companheiras para cada bloco (nível de título, ordem dos passos, tipo de callout) cujas respostas só são lidas quando o tipo do bloco as torna relevantes.
  • Evidência direta fica no código. Linhas em branco e marcadores explícitos (- , 1., #) são lidos no código, nunca enviados ao modelo para reconsiderar; este memorando manteve as suas linhas em branco mas perdeu todos os marcadores. O modelo recebe apenas as perguntas que o código não consegue responder a partir do texto.

Todo o comportamento está especificado nos critérios das perguntas do passo 2: três dicionários de descrições de uma linha, mais os critérios true/false da pergunta de passo dentro de classify_questions. O resto do código é apenas a infraestrutura à volta deles. Os números de custo e latência estão no apêndice: duas idas e voltas, 10 211 tokens, 0,8s, $0,0015 para este memorando.

Configuração

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

depois define TYPESAFE_API_KEY. Todas as chamadas à API são guardadas em cache em json_cache.json, que acompanha o cookbook, por isso voltar a renderizar reproduz os números publicados sem chamar a API. Apaga esse ficheiro para voltar a correr tudo ao vivo.

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

O documento: um memorando de equipa que perdeu a formatação

O documento de teste é um memorando sobre uma migração de sistema de build, no estado em que chega a uma caixa de entrada de texto simples: parágrafos quebrados a meio da frase, um comando de shell numa linha isolada, duas listas sem marcadores nem números, um aviso sem nada que o identifique como tal. O texto é buscado de um gist fixado para que os números do cookbook permaneçam reproduzíveis.

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

A divisão de linhas, o rastreamento de linhas em branco e a marcação de ids acontecem no código; nenhum modelo está envolvido. Cada linha recebe um id curto (L014| ); os ids são texto comum que o modelo lê como parte do estado, e perguntas e respostas se referem às linhas por esses ids (o mesmo esquema do cookbook de pesquisa semântica).

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

Passo 1: emendar frases divididas

Uma pergunta Noul por par adjacente de linhas, todas num único pedido; pares separados por uma linha em branco são ignorados. A pergunta é deliberadamente estreita (“esta linha continua a meio de uma frase?”), o que fica perto de um facto objetivo sobre o texto. O apêndice cobre tanto a escolha da redação quanto como os limiares de junção foram derivados.

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

O ponto de corte para juntar depende de como a linha anterior termina. Depois de uma linha pendente (uma sem pontuação de fim de frase), uma probabilidade de junção de 0,2 ou mais junta o par; depois de pontuação terminal (. ! ? : ;), o corte sobe para 0,5. O apêndice percorre as probabilidades por trás dos dois números.

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.

Passo 2: classificar blocos

Cada bloco emendado recebe uma pergunta Choice: que tipo de conteúdo é este? Estes três dicionários, mais os critérios true/false da pergunta de passo dentro de classify_questions abaixo, são a especificação inteira do classificador. Não há outra lógica. Para adaptar o pipeline aos teus próprios documentos, edita estas descrições.

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

Tudo abaixo é infraestrutura: montar as perguntas, enviar um pedido, ler as respostas de volta. Se o tipo voltar heading, o renderizador precisa de um nível de título; se list_item, se a ordem importa; se callout, de que tipo. Os tipos ainda não são conhecidos, e esperar por eles significaria uma terceira ida e volta, por isso as perguntas companheiras são feitas desde o início no mesmo pedido. A maioria dessas respostas nunca é lida: a probabilidade de passo de um parágrafo não significa nada e é simplesmente ignorada. Uma pergunta extra acrescenta pouco, já que o estado é a maior parte dos tokens e é enviado uma vez de qualquer forma, enquanto uma ida e volta extra acrescenta um pedido inteiro de latência.

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.

O julgamento de cada bloco está nessa tabela, e a coluna de companheiras mostra as respostas antecipadas a serem usadas: as três linhas “Things to do before Monday” têm probabilidades de passo perto de 0,9 (vão renderizar como lista numerada), as três linhas de equipas ficam perto de 0,1 (com marcas), e o aviso não marcado sobre o script doctor foi classificado como um callout do tipo warning. O apêndice analisa o único bloco sobre o qual o modelo ficou em dúvida.

Renderização

O código monta a página a partir dos julgamentos. Itens de lista consecutivos viram uma lista, numerada quando a média das probabilidades de passo dos itens é pelo menos 0,5. Esse limiar é uma decisão de nível de grupo que nenhuma pergunta perguntou diretamente.

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.

Cada palavra acima vem da entrada. O pipeline só escolheu fronteiras, tipos e marcação.

Abre no playground

Este link de partilha contém os blocos emendados e o conjunto completo de perguntas do passo 2. Abre para correr a classificação ao vivo.

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})"))
Abre o memorando emendado + perguntas no playground do TypeSafe →

Apêndice

Custo e latência

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

Duas idas e voltas, 10 211 tokens, 0,8s, $0,0015.

De onde vêm os limiares de junção

As probabilidades de junção por linha do passo 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

As probabilidades caem em duas faixas separadas: quebras de linha que dividem uma frase pontuam 0,39 para cima, quebras que o autor quis pontuam perto de zero. Mas onde colocar o corte entre as faixas depende de como a linha anterior termina, um facto que o código consegue ler diretamente:

  • Depois de uma linha pendente (uma sem pontuação de fim de frase), qualquer coisa em 0,2 ou acima conta como continuação. Continuações verdadeiras pontuam tão baixo quanto 0,39 aqui (L004| make the switch for real.), então um único corte cauteloso em 0.5 despedaçaria parágrafos saudáveis.
  • Depois de pontuação terminal (um caractere que encerra uma frase ou oração: . ! ? : ;), o corte sobe para 0,5. A lista de equipas do memorando mostra por quê: L015| The platform team segue dois-pontos e pontua 0,22. Este é um sinal baixo mas não nulo de “isto continua a frase”, e passaria do corte de 0,2 e juntaria a lista à frase que a introduz. Nenhum limiar único serve para os dois casos; uma vez que o código verifica a pontuação primeiro, as duas faixas se separam.

Porque é que a pergunta é “a meio da frase” e não “mesmo parágrafo”

A primeira versão deste pipeline fazia a pergunta óbvia: “estas duas linhas fazem parte do mesmo parágrafo?” Falhou de um modo específico. Uma sequência de linhas curtas sob um título (uma lista escrita sem marcadores) é um parágrafo no sentido amplo: as linhas ficam juntas e partilham um tópico. Questionado sobre parágrafos, o modelo diz sim a cada par, e o passo de emenda junta a lista inteira num único bloco longo.

Mesmo documento, mesma forma de pedido, só a redação mudou:

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)

Com a redação de parágrafo, todo o item de lista sem marcador pontua acima de 0,75 e ambas as listas colapsam. O memorando funde-se em alguns blocos corridos. “Mesmo parágrafo” pede que o modelo julgue se o tópico se mantém, e entre itens de lista ele mantém-se. “Continua a meio da frase” pergunta sobre o próprio texto. Quando um julgamento alimenta um limiar, a pergunta deve nomear o facto mais estreito que o decide. Aqui a redação é a diferença entre 17 blocos e 12.

O bloco de menor confiança

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

A frase que introduz a lista de equipas é genuinamente ambígua - nomeia o que vem a seguir (como um título), é uma frase completa (como um parágrafo) e fica onde um callout iria. As probabilidades se distribuem de acordo (paragraph 0.53, list_item 0.24, callout 0.19), e uma UI pode expor isso - por exemplo, sublinhar para revisão qualquer bloco cuja confiança de tipo (a probabilidade por trás da escolha vencedora) esteja abaixo de 0,55.