Recuperação de estrutura
Reconstrói o Markdown a partir de texto puro que perdeu sua formatação em duas etapas: uma reconecta linhas com quebra rígida e outra classifica cada bloco (título, lista, código, chamada) com perguntas complementares lidas apenas quando relevantes.
Traduzido automaticamente do en, sem revisão. Apenas como referência rápida.

Este cookbook pega texto puro cuja marcação foi removida (linhas com quebra rígida no 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, chamadas. A entrada é um memorando da equipe exatamente nesse estado.
Um modelo de geração de texto poderia reescrever o texto em Markdown, mas uma reescreva também pode alterar as palavras. Aqui o modelo nunca gera texto: ele responde a perguntas estreitas sobre o documento (esta linha retoma no meio da frase? que tipo de conteúdo é este bloco?), e o código faz a renderização, então cada caractere da saída vem da entrada, e cada julgamento carrega uma probabilidade.
O pipeline inteiro consiste em duas solicitações de API por documento, executadas em sequência:
- Pass 1, stitch: uma
Noulpergunta (uma pergunta de sim/não cuja resposta é a probabilidade de o “sim” estar correto) por par de linhas adjacentes, perguntando se a quebra de linha dividiu uma frase entre as duas. Todos os pares vão em uma única solicitação, e as linhas que continuam uma frase dividida são mescladas de volta em blocos. - Pass 2, classify: uma
Choicepergunta (escolha uma opção de uma lista, com uma probabilidade para cada opção) por bloco mesclado, escolhendo entre cabeçalho, parágrafo, item de lista, citação, código ou callout (uma nota, dica ou aviso separado do texto principal). Os blocos só existem depois que o pass 1 respondeu, então esta é uma segunda solicitação; ela também carrega perguntas companheiras para cada bloco (nível do cabeçalho, ordem das etapas, tipo de callout) cujas respostas são lidas apenas quando o tipo do bloco as torna relevantes. - Evidência direta permanece no código. Linhas em branco e marcadores explícitos (
-,1.,#) são lidos no código, nunca enviados ao modelo para reconsideração; este memorando manteve suas linhas em branco mas perdeu todos os marcadores. O modelo recebe apenas as perguntas que o código não pode responder a partir do texto.
Todo o comportamento é especificado nos critérios da pergunta de pass-2: três dicionários de descrições de uma linha, além dos critérios verdadeiro/falso da pergunta da etapa dentro classify_questions. O restante do código é a infraestrutura ao redor deles. Os números de custo e latência estão no apêndice: duas viagens de ida e volta, 10.211 tokens, 0,8s, $0,0015 para este memorando.
Configuração
pip install ipython "typesafe-sdk>=0.5.7" cooksafe --extra-index-url https://pypi.typesafe.ai/
então defina TYPESAFE_API_KEY. Cada chamada de API é armazenada em cache no json_cache.json, que acompanha o cookbook, portanto, reexecutar reproduz os números publicados sem chamar a API.
Exclua esse arquivo para reexecutar tudo em tempo real.
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: uma memo da equipe que perdeu a formatação
O documento de teste é um memorando sobre uma migração do sistema de compilação, no estado em que chega a uma caixa de entrada de texto puro: parágrafos com quebra de linha forçada no meio da frase, um comando de shell em uma linha desprovida, duas listas sem marcadores ou números, um aviso sem nada que o marque como tal. O texto é obtido de um gist fixado para que os números do manual 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 identificação de IDs ocorrem 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 as perguntas e respostas referem-se às linhas por esses ids (o mesmo esquema do livro de receitas de busca 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
Pass 1: costurando frases divididas
Uma pergunta Noul por par de linhas adjacentes, tudo em um único pedido; pares separados por uma linha em branco são ignorados. A pergunta é deliberadamente restrita (“essa linha retoma uma frase interrompida?”), o que se aproxima de um fato objetivo sobre o texto. O apêndice abrange tanto a escolha da formulação quanto como os limiares de fusã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 limite para a fusão depende de como a linha anterior termina. Após uma linha pendente (uma
sem pontuação final de frase), uma probabilidade de junção de 0,2 ou superior funde o
par; após pontuação terminal (. ! ? : ;), o limite aumenta 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.
Pass 2: classificando blocos
Cada bloco costurado recebe uma pergunta Choice: que tipo de conteúdo é este? Estes
três
dicionários, juntamente com os critérios verdadeiro/falso da pergunta da etapa dentro de classify_questions abaixo,
constituem toda a especificação do classificador. Não há outra lógica. Para adaptar o
pipeline aos seus próprios documentos, edite 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 é encanamento: construa as perguntas, envie uma solicitação, leia as respostas de volta.
Se o tipo retornar heading, o renderizador precisa de um nível de título; se list_item,
se a ordem importa; se callout, qual tipo. Os tipos ainda não são conhecidos, e esperar
por eles significaria uma terceira ida e volta, então as perguntas complementares são feitas antecipadamente na
mesma solicitação. A maioria dessas respostas nunca é lida: a probabilidade de etapa de um parágrafo
não significa nada e é simplesmente ignorada. Uma pergunta extra adiciona pouco, já que o estado é
a maioria dos tokens e é enviado uma vez de qualquer maneira, enquanto uma ida e volta extra adiciona uma latência de
solicitação completa.
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 companheira mostra as respostas iniciais sendo colocadas em uso: as três linhas “Coisas a fazer antes de segunda-feira” carregam probabilidades de passo próximas de 0,9 (elas serão renderizadas como uma lista numerada), as três linhas da equipe ficam próximas de 0,1 (com marcadores), e o aviso sem marcação sobre o script do médico foi classificado como um destaque do tipo warning. O apêndice examina o único bloco sobre o qual o modelo tinha dúvidas.
Renderização
O código monta a página a partir dos julgamentos. Itens consecutivos da lista tornam-se uma única lista, numerados quando a média das probabilidades de etapa dos itens for de pelo menos 0,5. Esse limiar é uma decisão de nível de grupo, não uma pergunta única feita 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 é do input. O pipeline apenas escolheu limites, tipos e marcação.
Abra no playground
Este link de compartilhamento contém os blocos costurados e o conjunto completo de perguntas da passagem 2. Abra-o para reexecutar 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})"))
Abra a nota costurada + 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,8 s, $0,0015.
De onde vêm os limiares de junção
As probabilidades de junção por linha da passagem 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 se concentram em duas faixas distintas: as quebras de linha que dividem uma frase pontuam 0,39 e acima, enquanto as quebras que o autor pretendia pontuam perto de zero. Mas onde colocar o limite entre as faixas depende de como a linha anterior termina, um fato que o código pode ler diretamente:
- Após uma linha órfã (sem pontuação final de frase), qualquer valor
igual ou superior a 0.2
conta como continuação. Continuações verdadeiras pontuam tão baixo quanto 0.39 aqui (
L004| make the switch for real.), então um limite único e cauteloso em 0.5 quebraria parágrafos saudáveis. - Após pontuação terminal (um caractere que encerra uma frase ou oração:
.!?:;), o limite sobe para 0.5. A lista de equipe do memorando mostra o porquê:L015| The platform teamsegue dois-pontos e pontua 0.22. Esse é um sinal baixo, mas não nulo, de “isso continua a frase”, e ultrapassaria o limite de 0.2, fundindo a lista à frase que a introduz. Nenhum limite único funciona para ambos os casos; assim que o código verifica a pontuação primeiro, as duas faixas se separam.
Por que a pergunta é “no meio da frase” e não “no mesmo parágrafo”
A primeira versão deste pipeline fez a pergunta óbvia: “essas duas linhas fazem parte do mesmo parágrafo?” Falhou de uma maneira específica. Uma sequência de linhas curtas sob um cabeçalho (uma lista digitada sem marcadores) é um parágrafo no sentido amplo: as linhas ficam juntas e compartilham um tópico. Perguntado sobre parágrafos, o modelo diz sim para cada par, e a passagem de costura mescla toda a lista em um único bloco longo.
Mesmo documento, mesma estrutura de solicitação, apenas a redação alterada:
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 do parágrafo, cada item de lista sem marcação pontua acima de 0,75 e ambas as listas colapsam. A nota se funde em alguns blocos longos e corridos. “Mesmo parágrafo” pede ao modelo que julgue se o tópico se mantém, e entre os itens de lista, ele se mantém. “Retoma no meio da frase” pergunta sobre o próprio texto. Quando uma decisão subjetiva alimenta um limite, a pergunta deve nomear o fato mais específico que a determina. 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 membros é genuinamente ambígua — ela nomeia o que se segue (estilo de cabeçalho), é uma frase completa (estilo de parágrafo) e está posicionada onde um callout normalmente iria. As probabilidades se distribuem de acordo (parágrafo 0.53, list_item 0.24, callout 0.19), e uma interface pode exibir isso — por exemplo, sublinhar para revisão qualquer bloco cuja confiança do tipo (a probabilidade por trás da escolha vencedora) esteja abaixo de 0.55.