Восстановление структуры Markdown
Reconstructs Markdown from plain text that lost its formatting in two requests: one stitches hard-wrapped lines back together, one classifies every block (heading, list, code, callout) with companion questions read only when relevant.
Восстановление разметки Markdown из неформатированного текста за два запроса: первый объединяет разорванные строки обратно в предложения, второй классифицирует каждый блок (заголовок, список, код, выноска) с сопутствующими вопросами, считываемыми только при необходимости.
В этом руководстве рассматривается простой текст, из которого была удалена вся разметка (строки жестко разорваны посередине предложений, отсутствуют маркеры заголовков и списков), и восстанавливается его структура в формате Markdown: заголовки, абзацы, списки, цитаты, код и выноски (callouts). В качестве входных данных используется рабочая памятка команды именно в таком виде.
Модель генерации текста могла бы переписать текст в Markdown, однако при генерации слова могут исказиться или измениться. Здесь модель ничего не генерирует: она лишь отвечает на узкие вопросы о документе (продолжает ли эта строка незаконченное предложение? каким типом контента является этот блок?), а сборку выполняет программный код, поэтому каждый символ результирующего текста берется из исходного документа, а каждое решение сопровождается вероятностью.
Весь пайплайн состоит из двух последовательных запросов к API на документ:
- Проход 1, объединение (stitch): один вопрос
Noul(вопрос «да/нет», ответом на который является вероятность истинности утверждения) на каждую пару соседних строк с вопросом о том, разорвал ли перенос строки предложение на две части. Все пары передаются в одном запросе, а строки, продолжающие разорванное предложение, объединяются обратно в блоки. - Проход 2, классификация (classify): один вопрос
Choice(выбор одного варианта из списка с распределением вероятностей по всем вариантам) на каждый объединенный блок, выбирающий между заголовком, абзацем, элементом списка, цитатой, кодом или выноской (примечанием, советом или предупреждением). Блоки появляются только после ответа первого прохода, поэтому это отдельный второй запрос; он также содержит сопутствующие вопросы для каждого блока (уровень заголовка, порядок шагов, тип выноски), ответы на которые считываются только тогда, когда тип блока делает их актуальными. - Прямые признаки обрабатываются кодом. Пустые строки и явные маркеры (
-,1.,#) считываются кодом и никогда не отправляются модели на перепроверку; в этой памятке сохранились пустые строки, но были утрачены все маркеры. Модели передаются только те вопросы, на которые код не может ответить по тексту напрямую.
Вся логика поведения задается критериями вопросов второго прохода: тремя словарями с краткими описаниями, а также критериями true/false для вопроса о шагах внутри classify_questions. Весь остальной код — это связующая обвязка. Данные о стоимости и задержке приведены в приложении: два обращения к API, 10 211 токенов, 0.8 с, $0.0015 для этой памятки.
Настройка
pip install ipython "typesafe-sdk>=0.5.7" cooksafe --extra-index-url https://pypi.typesafe.ai/
затем задайте TYPESAFE_API_KEY. Каждый вызов API кэшируется в json_cache.json, поставляемый с руководством, поэтому повторный рендеринг воспроизводит опубликованные показатели без вызова 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, что гарантирует воспроизводимость цифр руководства.
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 (L014| ); эти идентификаторы — обычный текст, который модель читает как часть состояния (state), а вопросы и ответы ссылаются на строки по этим ID (та же схема, что и в руководстве по семантическому поиску).
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 — какого типа выноска. Типы блоков заранее неизвестны, а ожидание ответа по ним потребовало бы третьего обращения к API, поэтому сопутствующие вопросы задаются превентивно в том же самом запросе. Большинство этих ответов никогда не считываются: вероятность шага для обычного абзаца не имеет значения и просто игнорируется. Дополнительный вопрос практически не влияет на стоимость, так как состояние составляет подавляющую часть токенов и отправляется в любом случае один раз, в то время как дополнительное обращение к API добавило бы полную задержку на целый запрос.
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.
Решение по каждому блоку приведено в этой таблице, а столбец companion показывает, как используются заранее полученные ответы: три строки списка «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_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})"))
Открыть объединенную памятку + вопросы в песочнице 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
Два обращения к API, 10 211 токенов, 0.8 с, $0.0015.
Откуда берутся пороговые значения объединения
Построчные вероятности объединения из первого прохода:
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.