Documentación

Recuperación de estructura

Reconstruye Markdown a partir de texto plano que perdió su formato en dos solicitudes: una vuelve a unir las líneas cortadas, otra clasifica cada bloque (encabezado, lista, código, aviso).

Este cookbook toma texto plano al que se le ha quitado el marcado (líneas cortadas 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, avisos. La entrada es un memo de equipo que está exactamente en ese estado.

Un modelo de generación de texto podría reescribir el texto en Markdown, pero una reescritura también puede cambiar las palabras. Aquí el modelo nunca genera texto: responde a preguntas estrechas sobre el documento (¿esta línea continúa una frase? ¿qué tipo de contenido es este bloque?), y el código hace el renderizado, así que cada carácter de la salida viene de la entrada, y cada juicio lleva una probabilidad.

Todo el pipeline son dos solicitudes a la API por documento, ejecutadas en secuencia:

  • Pasada 1, unir: una pregunta Noul (una pregunta de sí/no cuya respuesta es la probabilidad de que el sí sea correcto) por cada par de líneas adyacentes, que pregunta si el salto de línea dividió una frase entre las dos. Todos los pares van en una sola solicitud, y las líneas que continúan una frase dividida se vuelven a fusionar en bloques.
  • Pasada 2, clasificar: una pregunta Choice (elige una opción de una lista, con una probabilidad para cada opción) por cada bloque fusionado, eligiendo entre encabezado, párrafo, elemento de lista, cita, código o aviso (una nota, un consejo o una advertencia que se aparta del texto principal). Los bloques solo existen una vez que la pasada 1 ha respondido, así que esta es una segunda solicitud; también lleva preguntas complementarias para cada bloque (nivel de encabezado, orden de paso, tipo de aviso) cuyas respuestas solo se leen cuando el tipo del bloque las hace relevantes.
  • La evidencia directa se queda 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 memo conservó sus líneas en blanco pero perdió todos los marcadores. El modelo solo recibe las preguntas que el código no puede responder a partir del texto.

Todo el comportamiento se especifica en los criterios de las preguntas de la pasada 2: tres diccionarios de descripciones de una línea, más los criterios de verdadero/falso de la pregunta de paso dentro de classify_questions. El resto del código es fontanería alrededor de ellos. Los números de coste y latencia están en el apéndice: dos viajes de ida y vuelta, 10,211 tokens, 0.8s, $0.0015 para este memo.

Preparación

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

luego define TYPESAFE_API_KEY. Cada llamada a la API se guarda en caché en json_cache.json, que se incluye con el cookbook, así que volver a renderizar reproduce los números publicados sin llamar a la API. Elimina ese archivo para volver a ejecutar todo en 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"))

El documento: un memo de equipo que perdió su formato

El documento de prueba es un memo sobre una migración de sistema de compilación, en el estado en que llega a una bandeja de texto plano: párrafos cortados a mitad de frase, un comando de shell en una línea suelta, dos listas sin viñetas ni números, una advertencia sin nada que la marque como tal. El texto se descarga de un gist fijado para que los números del cookbook sigan siendo 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

La división de líneas, el seguimiento de líneas en blanco y el etiquetado con ids ocurren todos en el código; no interviene ningún modelo. Cada línea recibe un id corto (L014| ); los ids son texto corriente que el modelo lee como parte del estado, y las preguntas y respuestas se refieren a las líneas por estos ids (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

Pasada 1: unir frases divididas

Una pregunta Noul por cada par de líneas adyacentes, todas en una sola solicitud; los pares separados por una línea en blanco se omiten. La pregunta es deliberadamente estrecha («¿esta línea continúa una frase?»), lo que se acerca a un hecho objetivo sobre el texto. El apéndice cubre tanto la elección de la redacción como la forma en que 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 umbral para fusionar depende de cómo termina la línea anterior. Tras una línea colgante (una sin puntuación de fin de frase), una probabilidad de unión de 0.2 o más fusiona el par; tras puntuación terminal (. ! ? : ;), el umbral 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.

Pasada 2: clasificar bloques

Cada bloque unido recibe una pregunta Choice: ¿qué tipo de contenido es este? Estos tres diccionarios, más los criterios de verdadero/falso de la pregunta de paso dentro de classify_questions abajo, son toda la especificación del clasificador. No hay otra lógica. Para adaptar el pipeline 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 de abajo es fontanería: construir las preguntas, enviar una solicitud, leer las respuestas. Si el tipo vuelve como heading, el renderizador necesita un nivel de encabezado; si es list_item, si el orden importa; si es callout, de qué tipo. Los tipos aún no se conocen, y esperarlos supondría un tercer viaje de ida y vuelta, así que las preguntas complementarias se hacen por adelantado 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 simplemente se ignora. Una pregunta extra añade poco, ya que el estado es la mayor parte de los tokens y se envía una sola vez de todos modos, mientras que un viaje de ida y vuelta extra añade una solicitud completa de latencia.

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 de complementarias muestra las respuestas por adelantado puestas en uso: las tres líneas de «Things to do before Monday» llevan probabilidades de paso cerca de 0.9 (se renderizarán como una lista numerada), las tres líneas de equipos quedan cerca de 0.1 (con viñetas), y la advertencia sin marcar sobre el script doctor se clasificó como un aviso de tipo warning. El apéndice examina el único bloque del que el modelo no estaba seguro.

Renderizado

El código arma la página a partir de los juicios. 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 que ninguna pregunta individual pidió directamente.

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 de arriba viene de la entrada. El pipeline solo eligió límites, tipos y marcado.

Ábrelo en el playground

Este enlace compartido contiene los bloques unidos y el conjunto completo de preguntas de la pasada 2. Ábrelo para volver a ejecutar la clasificación en 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 el memo unido + las preguntas en el playground de TypeSafe →

Apéndice

Coste 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 viajes de ida y vuelta, 10,211 tokens, 0.8s, $0.0015.

De dónde vienen 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 caen en dos bandas separadas: los saltos de línea que dividen una frase puntúan 0.39 o más; los saltos que el autor quiso puntúan cerca de cero. Pero dónde poner el umbral entre las bandas depende de cómo termina la línea anterior, un hecho que el código puede leer directamente:

  • Tras una línea colgante (una sin puntuación de fin de frase), cualquier valor de 0.2 o más cuenta como continuación. Las continuaciones reales puntúan tan bajo como 0.39 aquí (L004| make the switch for real.), así que un único umbral prudente en 0.5 partiría párrafos sanos.
  • Tras puntuación terminal (un carácter que termina una frase o cláusula: . ! ? : ;), el umbral sube a 0.5. La lista de equipos del memo muestra por qué: L015| The platform team va detrás de dos puntos y puntúa 0.22. Esa es una señal baja pero distinta de cero de «esto continúa la frase», y superaría el umbral de 0.2 y fusionaría la lista con la frase que la introduce. Ningún umbral único sirve para ambos casos; una vez que el código comprueba primero la puntuación, las dos bandas se separan.

Por qué la pregunta es «continúa la frase» y no «mismo párrafo»

La primera versión de este pipeline hacía la pregunta obvia: «¿estas dos líneas forman parte del mismo párrafo?». Fallaba de una manera concreta. Una serie de líneas cortas bajo un encabezado (una lista escrita sin viñetas) es un párrafo en sentido amplio: las líneas van juntas y comparten un tema. Cuando se le pregunta por párrafos, el modelo dice sí a todos los pares, y la pasada de unión fusiona toda la lista en un único bloque largo.

El mismo documento, la misma forma de solicitud, solo cambió 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 de párrafo, cada elemento de lista sin marcar puntúa por encima de 0.75 y ambas listas se colapsan. El memo se fusiona en unos pocos bloques corridos. «Mismo párrafo» pide al modelo juzgar si el tema se mantiene, y entre elementos de lista se mantiene. «Continúa la frase» pregunta por el texto en sí. Cuando un juicio alimenta un umbral, la pregunta debe nombrar el hecho más estrecho que lo decide. 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 viene después (como un encabezado), es una frase completa (como un párrafo) y está donde iría un aviso. Las probabilidades se reparten en consecuencia (párrafo 0.53, list_item 0.24, aviso 0.19), y una interfaz puede mostrar eso: por ejemplo, subrayar para revisión cualquier bloque cuya confianza de tipo (la probabilidad detrás de la opción ganadora) sea inferior a 0.55.