Recuperación de estructura
Reconstruye Markdown a partir de texto plano que perdió su formato en dos solicitudes: una une de nuevo las líneas con saltos forzados y otra clasifica cada bloque (título, lista, código, nota) mediante preguntas complementarias que solo se leen cuando son relevantes.
Traducido automáticamente del en, sin revisión. Solo como referencia rápida.

Este cookbook toma texto plano cuyo marcado ha sido eliminado (líneas con salto duro a mitad de frase, sin marcadores de encabezado, sin viñetas de lista) y reconstruye la estructura como Markdown: encabezados, párrafos, listas, citas, código, llamadas de atención. La entrada es un memo de equipo en exactamente ese estado.
Un modelo de generación de texto podría reescribir el texto en Markdown, pero una reescripción también puede cambiar las palabras. Aquí el modelo nunca genera texto: responde preguntas concretas sobre el documento (¿esta línea retoma el texto a mitad de frase? ¿qué tipo de contenido es este bloque?), y el código se encarga del renderizado, por lo que cada carácter del output proviene del input, y cada juicio conlleva una probabilidad.
Todo el pipeline consta de dos solicitudes API por documento, ejecutadas en secuencia:
- Paso 1, unir: una
Noulpregunta (una pregunta de sí/no cuya respuesta es la probabilidad de que sí sea correcta) por cada par de líneas adyacentes, preguntando si el salto de línea dividió una oración entre las dos. Todos los pares van en una sola solicitud, y las líneas que continúan una oración dividida se vuelven a unir en bloques. - Paso 2, clasificar: una
Choicepregunta (elegir una opción de una lista, con una probabilidad para cada opción) por bloque unido, eligiendo entre encabezado, párrafo, elemento de lista, cita, código o nota al margen (una nota, consejo o advertencia separada del texto principal). Los bloques solo existen una vez que el paso 1 ha respondido, por lo que esta es una segunda solicitud; también lleva preguntas acompañantes para cada bloque (nivel de encabezado, orden de pasos, tipo de nota al margen) cuyas respuestas se leen solo cuando el tipo de bloque las hace relevantes. - La evidencia directa permanece en el código. Las líneas en blanco y los marcadores explícitos (
-,1.,#) se leen en el código, nunca se envían al modelo para que los reconsidere; este memorándum conservó sus líneas en blanco pero perdió cada marcador. El modelo solo recibe las preguntas que el código no puede responder a partir del texto.
Todo el comportamiento está especificado en los criterios de la pregunta de la fase 2: tres diccionarios de descripciones de una línea, más los criterios de verdadero/falso de la pregunta de la etapa dentro de classify_questions. El resto del código es la infraestructura que los rodea. Los números de coste y latencia están en el apéndice: dos idas y vueltas, 10,211 tokens, 0,8 s, $0,0015 para este memorándum.
Configuración
pip install ipython "typesafe-sdk>=0.5.7" cooksafe --extra-index-url https://pypi.typesafe.ai/
entonces establece TYPESAFE_API_KEY. Cada llamada a la API se almacena en caché en json_cache.json, que se incluye con el libro de recetas, por lo que volver a renderizar reproduce los números publicados sin llamar a la API.
Elimina ese archivo para volver a ejecutar todo en tiempo 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"))
El documento: un memo del equipo que perdió su formato
El documento de prueba es un memo sobre una migración del sistema de compilación, en el estado en que llega a una bandeja de entrada de texto plano: párrafos con salto de línea forzado a mitad de frase, un comando de shell en una línea desnuda, dos listas sin viñetas ni números, una advertencia sin ningún marcador que la identifique como tal. El texto se obtiene de un gist fijado para que los números del manual de recetas permanezcan reproducibles.
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
El corte de líneas, el seguimiento de líneas en blanco y la asignación de identificadores ocurren todo en código; ningún modelo está involucrado.
Cada línea recibe un identificador corto (L014| ); los identificadores son texto normal que el modelo lee como parte del estado, y las preguntas y respuestas hacen referencia a las líneas mediante estos identificadores (el mismo esquema que el cookbook de búsqueda 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
Pase 1: unir oraciones divididas
Una pregunta de Noul por cada par adyacente de líneas, todo en una sola solicitud; los pares separados por una línea en blanco se omiten. La pregunta es deliberadamente estrecha («¿recoge esta línea una frase a medias?»), lo cual se acerca a un hecho objetivo sobre el texto. El apéndice cubre tanto la elección de la redacción como cómo se derivaron los umbrales de fusión.
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
El límite para la fusión depende de cómo termine la línea anterior. Tras una línea colgante (una sin puntuación final de oración), una probabilidad de unión de 0.2 o superior fusiona el par; tras puntuación terminal (. ! ? : ;), el límite sube a 0.5. El apéndice recorre las probabilidades detrás de los dos 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.
Pase 2: clasificación de bloques
Cada bloque ensamblado recibe una pregunta Choice: ¿qué tipo de contenido es este? Estos tres diccionarios, junto con los criterios de verdadero/falso de la pregunta del paso dentro de classify_questions a continuación, constituyen la especificación completa del clasificador. No hay otra lógica. Para adaptar la canalización a tus propios documentos, edita estas descripciones.
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",
}
Todo lo siguiente es tubería: construye las preguntas, envía una solicitud, lee las respuestas de vuelta.
Si el tipo devuelve heading, el renderizador necesita un nivel de encabezado; si list_item,
si el orden importa; si callout, qué tipo. Los tipos aún no se conocen, y esperar
a ellos significaría una tercera ida y vuelta, por lo que las preguntas complementarias se hacen de antemano en
la misma solicitud. La mayoría de estas respuestas nunca se leen: la probabilidad de paso de un párrafo
no significa nada y se ignora simplemente. Una pregunta adicional añade poco, ya que el estado es
la mayoría de los tokens y se envía una vez de cualquier manera, mientras que una ida y vuelta adicional añade una latencia de solicitud 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.
El juicio de cada bloque está en esa tabla, y la columna complementaria muestra las respuestas iniciales que se están utilizando: las tres líneas de «Cosas que hacer antes del lunes» tienen probabilidades de paso cercanas a 0.9 (se renderizarán como una lista numerada), las tres líneas del equipo se sitúan cerca de 0.1 (viñetas), y la advertencia sin marcar sobre el guion del médico se clasificó como una cita de tipo warning. El apéndice examina el único bloque sobre el que el modelo tenía dudas.
Renderizado
El código ensambla la página a partir de las sentencias. Los elementos de lista consecutivos se convierten en una sola lista, numerada cuando la media de las probabilidades de paso de los elementos es al menos 0.5. Ese umbral es una decisión a nivel de grupo, no una pregunta directa a una sola cuestión.
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 palabra arriba proviene de la entrada. La tubería solo eligió límites, tipos y marcado.
Ábrelo en el playground
Este enlace de compartición contiene los bloques ensamblados y el conjunto completo de preguntas de la pasada 2. Ábrelo para volver a ejecutar la clasificación en tiempo real.
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})"))
Abrir el memorando cosido + preguntas en el playground de TypeSafe →
Apéndice
Costo y latencia
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
Dos idas y vueltas, 10.211 tokens, 0,8 s, $0,0015.
De dónde provienen los umbrales de unión
Las probabilidades de unión por línea de la pasada 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
Las probabilidades se agrupan en dos bandas separadas: los saltos de línea que dividen una oración obtienen una puntuación de 0.39 o superior, mientras que los saltos que el autor pretendía obtienen una puntuación cercana a cero. Pero dónde colocar el punto de corte entre las bandas depende de cómo termina la línea anterior, un hecho que el código puede leer directamente:
- Después de una línea colgante (una sin puntuación final de oración), cualquier valor de 0.2 o superior cuenta como continuación. Las continuaciones reales puntúan tan bajo como 0.39 aquí (
L004| make the switch for real.), por lo que un límite único cauteloso en 0.25 rompería párrafos saludables. - Después de puntuación terminal (un carácter que termina una oración o cláusula:
.!?:;), el límite sube a 0.5. La lista de equipo del memorándum muestra por qué:L015| The platform teamsigue a dos puntos y puntúa 0.22. Esa es una señal baja pero no nula de “esto continúa la oración”, y superaría el límite de 0.2 y fusionaría la lista con la oración que la introduce. Ningún umbral único funciona para ambos casos; una vez que el código verifica la puntuación primero, las dos bandas se separan.
Por qué la pregunta es “a mitad de frase” y no “del mismo párrafo”
La primera versión de esta tubería planteó la pregunta obvia: “¿forman estas dos líneas parte del mismo párrafo?” Falló de una manera específica. Una serie de líneas cortas bajo un encabezado (una lista escrita sin viñetas) es un párrafo en el sentido amplio: las líneas están juntas y comparten un tema. Al preguntarle sobre párrafos, el modelo responde que sí a cada par, y el paso de unión fusiona toda la lista en un único bloque largo.
Mismo documento, misma forma de solicitud, solo cambia la redacción:
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)
Con la redacción del párrafo, cada elemento de lista sin marcar obtiene una puntuación superior a 0.75 y ambas listas colapsan. El memorándum se fusiona en algunos bloques de oraciones interminadas. “Mismo párrafo” pide al modelo que juzgue si el tema se mantiene, y entre los elementos de la lista, sí lo hace. “Se retoma a mitad de frase” pregunta sobre el texto en sí. Cuando una decisión subjetiva alimenta un umbral, la pregunta debe nombrar el hecho más específico que lo determina. Aquí la redacción es la diferencia entre 17 bloques y 12.
El bloque de menor confianza
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 frase que introduce la lista de equipos es genuinamente ambigua: nombra lo que sigue (类似标题), es una oración completa (similar a párrafo), y se sitúa donde iría una llamada a la acción. Las probabilidades se distribuyen en consecuencia (párrafo 0.53, elemento_de_lista 0.24, llamada 0.19), y una interfaz de usuario puede mostrar eso - por ejemplo, subrayar para revisión cualquier bloque cuya confianza de tipo (la probabilidad detrás de la elección ganadora) esté por debajo de 0.55.