Документация

Восстановление структуры

Восстанавливает Markdown из простого текста, потерявшего форматирование, за два запроса: один снова сшивает разорванные строки, другой классифицирует каждый блок (заголовок, список, код, врезка).

Этот cookbook берёт простой текст, у которого снята разметка (строки разорваны посреди предложения, нет маркеров заголовков, нет маркеров списка), и восстанавливает структуру в виде Markdown: заголовки, абзацы, списки, цитаты, код, врезки. На входе — памятка команды именно в таком состоянии.

Модель генерации текста могла бы переписать текст в Markdown, но переписывание может заодно изменить слова. Здесь модель вообще не порождает текст: она отвечает на узкие вопросы о документе (продолжает ли эта строка предложение? что это за блок по типу содержимого?), а рендеринг выполняет код, поэтому каждый символ вывода берётся из входа, и каждое суждение несёт вероятность.

Весь конвейер — это два запроса к API на документ, выполняемые последовательно:

  • Проход 1, сшивание: по одному вопросу Noul (вопрос «да/нет», ответ на который — вероятность того, что верно «да») на каждую соседнюю пару строк; спрашивается, разорвал ли перенос строки предложение между ними. Все пары идут в одном запросе, а строки, продолжающие разорванное предложение, снова сливаются в блоки.
  • Проход 2, классификация: по одному вопросу Choice (выбрать один вариант из списка, с вероятностью для каждого варианта) на каждый слитый блок — выбор среди заголовка, абзаца, элемента списка, цитаты, кода или врезки (заметки, подсказки или предупреждения, выделенных из основного текста). Блоки появляются только после того, как ответил проход 1, поэтому это второй запрос; он также несёт сопутствующие вопросы для каждого блока (уровень заголовка, порядок шагов, вид врезки), ответы на которые читаются лишь тогда, когда тип блока делает их уместными.
  • Прямые признаки остаются в коде. Пустые строки и явные маркеры (- , 1., #) читает код, они никогда не отправляются модели на переосмысление; эта памятка сохранила пустые строки, но потеряла все маркеры. Модель получает только те вопросы, на которые код не может ответить по тексту.

Всё поведение задано в критериях вопросов прохода 2: три словаря однострочных описаний плюс критерии true/false для вопроса о шагах внутри classify_questions. Остальной код — это обвязка вокруг них. Числа по стоимости и задержке — в приложении: два круговых рейса, 10,211 токенов, 0.8 с, $0.0015 для этой памятки.

Установка

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

затем задайте TYPESAFE_API_KEY. Каждый вызов API кэшируется в json_cache.json, а он поставляется вместе с cookbook, поэтому повторный рендеринг воспроизводит опубликованные числа без обращения к API. Удалите этот файл, чтобы перезапустить всё вживую.

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

Документ: памятка команды, потерявшая форматирование

Тестовый документ — это памятка о миграции системы сборки в том виде, в каком она приходит в почтовый ящик с простым текстом: абзацы разорваны посреди предложения, команда оболочки стоит на голой строке, два списка без маркеров и нумерации, предупреждение без единого признака, что это предупреждение. Текст берётся из закреплённого gist, чтобы числа в cookbook оставались воспроизводимыми.

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

Разбиение на строки, отслеживание пустых строк и присвоение id — всё это делается в коде; модель не участвует. Каждая строка получает короткий id (L014| ); id — это обычный текст, который модель читает как часть state, а вопросы и ответы ссылаются на строки по этим id (та же схема, что и в cookbook по семантическому поиску).

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

Проход 1: сшивание разорванных предложений

Один вопрос Noul на каждую соседнюю пару строк, все в одном запросе; пары, разделённые пустой строкой, пропускаются. Вопрос намеренно узкий («продолжает ли эта строка предложение?»), то есть он близок к объективному факту о тексте. В приложении разобраны и выбор формулировки, и то, как выведены пороги слияния.

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

Порог слияния зависит от того, как заканчивается предыдущая строка. После оборванной строки (без завершающего предложение знака препинания) пара сливается при вероятности склейки 0.2 и выше; после концевого знака препинания (. ! ? : ;) порог поднимается до 0.5. В приложении разобраны вероятности, стоящие за этими двумя числами.

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.

Проход 2: классификация блоков

Каждый сшитый блок получает вопрос Choice: что это за блок по типу содержимого? Эти три словаря плюс критерии true/false для вопроса о шагах внутри classify_questions ниже — это вся спецификация классификатора. Никакой другой логики нет. Чтобы адаптировать конвейер под свои документы, отредактируйте эти описания.

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

Всё, что ниже, — обвязка: собрать вопросы, отправить один запрос, прочитать ответы обратно. Если тип вернулся heading, рендереру нужен уровень заголовка; если list_item — важен ли порядок; если callout — какой именно вид. Типы пока неизвестны, а ожидание их означало бы третий круговой рейс, поэтому сопутствующие вопросы задаются заранее, в том же запросе. Большинство этих ответов никогда не читается: вероятность шага для абзаца ничего не значит и просто игнорируется. Лишний вопрос добавляет немного, так как state — это большая часть токенов и он отправляется один раз в любом случае, тогда как лишний круговой рейс добавляет задержку целого запроса.

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.

Суждение каждого блока есть в этой таблице, а столбец сопутствующих вопросов показывает, как заранее полученные ответы идут в дело: три строки «Things to do before Monday» несут вероятности шага около 0.9 (они отрендерятся как нумерованный список), три строки команд стоят около 0.1 (маркированный список), а непомеченное предупреждение о скрипте doctor классифицировано как врезка вида warning. В приложении разбирается один блок, в котором модель была не уверена.

Рендеринг

Код собирает страницу из суждений. Идущие подряд элементы списка становятся одним списком и нумеруются, когда среднее вероятностей шага по элементам не меньше 0.5. Этот порог — решение группового уровня, которое ни один отдельный вопрос не задаёт напрямую.

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.

Каждое слово выше взято из входа. Конвейер выбрал только границы, типы и разметку.

Открыть в playground

Эта ссылка для общего доступа содержит сшитые блоки и полный набор вопросов прохода 2. Откройте её, чтобы заново запустить классификацию вживую.

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})"))
Открыть сшитую памятку и вопросы в playground TypeSafe →

Приложение

Стоимость и задержка

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

Два круговых рейса, 10,211 токенов, 0.8 с, $0.0015.

Откуда берутся пороги склейки

Построчные вероятности склейки из прохода 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

Вероятности попадают в две отдельные полосы: переносы, разрывающие предложение, дают 0.39 и выше, а переносы, которые автор имел в виду, — близко к нулю. Но где провести границу между полосами, зависит от того, как заканчивается предыдущая строка, — факта, который код может прочитать напрямую:

  • После оборванной строки (без завершающего предложение знака препинания) всё, что 0.2 и выше, считается продолжением. Настоящие продолжения здесь дают всего 0.39 (L004| make the switch for real.), поэтому единственный осторожный порог 0.5 разбил бы здоровые абзацы.
  • После концевого знака препинания (символа, завершающего предложение или клаузу: . ! ? : ;) порог поднимается до 0.5. Список команды в памятке показывает почему: L015| The platform team идёт после двоеточия и даёт 0.22. Это низкий, но ненулевой сигнал «это продолжает предложение», и он перешагнул бы порог 0.2 и слил список с вводящим его предложением. Ни один единый порог не работает для обоих случаев; как только код сначала проверяет пунктуацию, полосы разделяются.

Почему вопрос «продолжает предложение», а не «тот же абзац»

Первая версия этого конвейера задавала очевидный вопрос: «эти две строки — часть одного абзаца?» Она провалилась вполне определённым образом. Череда коротких строк под заголовком (список, набранный без маркеров) является абзацем в широком смысле: строки стоят рядом и делят общую тему. На вопрос об абзацах модель отвечает «да» на каждую пару, и проход сшивания сливает весь список в один длинный блок.

Тот же документ, та же форма запроса, изменилась только формулировка:

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)

С формулировкой про абзац каждый немаркированный элемент списка даёт выше 0.75, и оба списка схлопываются. Памятка сливается в несколько длинных блоков. «Тот же абзац» просит модель судить, переносится ли тема, а между элементами списка она переносится. «Продолжает предложение» спрашивает о самом тексте. Когда суждение питает порог, вопрос должен называть самый узкий факт, который его решает. Здесь формулировка — это разница между 17 блоками и 12.

Блок с наименьшей уверенностью

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

Предложение, вводящее список команды, действительно неоднозначно — оно называет то, что идёт дальше (похоже на заголовок), является полным предложением (похоже на абзац) и стоит там, где обычно стоит врезка. Вероятности распределяются соответственно (paragraph 0.53, list_item 0.24, callout 0.19), и интерфейс может это показать — например, подчёркивать для проверки любой блок, у которого уверенность в типе (вероятность за победившим выбором) меньше 0.55.