feat(sprint8.5+8.6): чанкер v2 (иерархия H1/H2/H3) + регрессия 4 веток в UI
Sprint 8.5 — чанкер v2 (services/document_processor.py):
- markdown-it-py для md-входа: каждый H2 открывает свою секцию, H3 идёт в тело
- множественные H1 — штатный кейс (new_booking.md = 8 H1, шаги воронки + группы);
H1 без H2 → секция heading=H1; преамбула H1 (тело до первого H2) игнорируется
- YAML frontmatter (--- ... ---) отрезается, в индекс не попадает
- breadcrumb «## {H2}» как первая строка каждого subchunk'а
- merge коротких хвостов и sentence-overlap — только внутри одной H2-секции
- excluded_section_headings в config.py
- 17 unit-тестов на stdlib unittest (tests/test_document_processor_v2.py),
включая smoke по реальным general_info.md (тимпанометрия → правильная секция)
и new_booking.md (защита от регрессии множественных H1)
- ТЗ: docs/CHUNKER_v2_TZ.md
Sprint 8.6 — регрессия остальных 4 веток (static/regression.html):
- 4 опции в селекторе режима: branch:price_question (40 кейсов),
branch:medical_question (29), branch:escalate_human (14), branch:reschedule (16)
- бэкенд из 8b уже параметрический — правок в сервисе не потребовалось
- new_booking вне скоупа — state-machine, под него отдельный 8c (multi-turn)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
# ТЗ: чанкер v2 для wiki-датасетов
|
||||
|
||||
Автор: Аксей · Дата: 2026-05-03 · Контекст: фейл регрессии по тимпанометрии (eval branch_cases_general_info.jsonl) — `expected_doc_section: "Направления приёма"` не находится, вместо неё в retrieved топ-5 попадают мета-секции и обрывки из других секций.
|
||||
|
||||
Это ТЗ — что должен делать новый `services/document_processor.py` (точечно: `parse_text` + `chunk_sections`), чтобы чанкинг markdown-датасетов (`data/datasets/*.md`) был предсказуемым и совместимым с eval-контрактом «section в metadata == заголовок H2 раздела».
|
||||
|
||||
## Что сейчас не так (наблюдения на текущем коде)
|
||||
|
||||
Сейчас `parse_text` режет markdown по любому `#`-заголовку без учёта иерархии и применяет несколько эвристик-заголовков, которые в наших данных дают false-positives. `chunk_sections` затем мерджит короткие чанки и подмешивает overlap из соседнего чанка независимо от того, из какой секции этот сосед. Конкретно:
|
||||
|
||||
1. **YAML frontmatter попадает в индекс.** Парсер не знает про `--- ... ---` в начале файла. Frontmatter становится body первой «псевдо-секции» с `heading=""`, режется на 1–2 чанка по 1.0–1.5 кб, ложится в Chroma с пустым section. Запросы пациентов с похожей лексикой («список источников», «Кузнецова», даты) могут это поднимать в топ. Воспроизведено на `data/datasets/general_info.md` — два мусорных чанка `section=""` длиной 1116 и 1488.
|
||||
2. **Нумерованные элементы списков парсятся как заголовки.** `numbered_heading_re = ^(\d+(?:\.\d+)*\.?)\s+([А-ЯЁA-Z].*)` ловит «1. Лично — в клинике…», «3. Клиника отправляет справку…». Как итог — `section` в metadata получает в качестве заголовка тело пункта списка, и весь раздел «Справки для налогового вычета» развалился на 4 несвязанных чанка с разными section'ами, в один из которых случайно подсосался хвост следующего раздела (см. ниже).
|
||||
3. **`### подсекции` парсятся как самостоятельные секции наравне с `## разделами`.** Это ломает инвариант eval-кейсов «section == заголовок логического раздела». Например, `### Аллергологи-иммунологи` (под `## Список врачей по специальностям`) становится отдельной секцией, и кейс с `expected_doc_section: "Список врачей по специальностям"` его не примет.
|
||||
4. **Merge коротких чанков склеивает соседей через границы H2.** Если очередной чанк короче `min_chunk_size`, к нему доклеивается следующий, при этом `section` берётся от первого. Реальный кейс: «Отоневролог» (1 строка тела) склеилась с «Сурдоакустик», «Анестезиолог», «Что взять с собой на приём» → один чанк длиной ~900 символов с `section="Отоневролог"`. Содержательно мусор; для eval-кейса «что взять с собой на приём» секция не найдётся.
|
||||
5. **Overlap подмешивает текст соседнего чанка без учёта секции.** В `final` цикле берётся хвост `merged[i-1]` и приклеивается к `chunk.text`. После этого внутри чанка с `section="3. Клиника отправляет…"` оказывается тело раздела «Направления приёма» (со словом «тимпанометрия»). С точки зрения eval section не совпадает, с точки зрения retrieval эмбеддинг растащен по двум темам.
|
||||
6. **Мета-секции уровня H1 индексируются наравне с контентом.** В `general_info.md` после раздела «Юридические реквизиты» шёл второй H1 «Анализ материалов вики и предложения по дополнению» — рабочие пометки редактора. Парсер не знает, что после второго H1 идёт нерелевантная пациенту служебка, и индексирует её как обычный текст. (Сейчас этот раздел перенесён в `docs/wiki_meta_general_info.md` вручную, но защита нужна на уровне парсера — иначе следующий редактор снова положит редакторские заметки прямо в датасет.)
|
||||
7. **Заголовок H2 не дублируется внутрь тела чанка.** В `chunk_sections` есть `heading_prefix = f"{section.heading}\n\n"`, то есть заголовок попадает в текст. Это правильно для эмбеддинга, но если секция режется на N чанков, заголовок есть только в первом — последующие части теряют контекст. Для секций > `max_chunk_size` это критично: e5 видит «- Тимпанометрия — да, делаем…» без префикса «Направления приёма» и эмбеддит только лексику конкретной строки.
|
||||
|
||||
## Что должно быть в v2
|
||||
|
||||
Цели в порядке важности: (1) `metadata.section` в чанке всегда равен заголовку H2-раздела, к которому чанк относится; (2) ничего нерелевантного из frontmatter и из мета-блоков в индекс не попадает; (3) overlap и merge работают только в пределах одной H2-секции.
|
||||
|
||||
### 1. Парсинг markdown — иерархия H1/H2/H3
|
||||
|
||||
Для `.md` входа использовать строгий markdown-парсер вместо набора регэкспов: `mistletoe`, `markdown-it-py`, или взять `commonmark-py`. На выходе нужно дерево с типами `Heading(level, text)` и `Paragraph/List/...`. Регэкспные эвристики на нумерованные списки, FAQ-паттерн «В:/Вопрос:» и ALL-CAPS строки в `.md` режиме отключить — они нужны были для txt-выгрузок, в md-датасете создают только мусор. Для `.txt` входа эвристики оставить как есть, в отдельной ветке.
|
||||
|
||||
Логика обхода:
|
||||
|
||||
- Каждый H1 трактуется как «корневая секция» документа. Тело самого H1 — обычно пустое или служебное.
|
||||
- Внутри одного H1 каждый H2 открывает новую секцию, её `heading = текст H2`. Следующий H2 (или следующий H1) закрывает текущую.
|
||||
- H3 и ниже **не открывают новой секции**. Заголовок H3 уходит в тело текущей H2-секции в виде строки `### {текст}` (или `**{текст}**`, как удобнее для эмбеддинга).
|
||||
- Когда H2-секции внутри H1 нет (документ сразу пишет body под H1) — body H1 идёт в одну секцию с `heading = текст H1`.
|
||||
|
||||
Множественные H1 поддерживаются как штатный кейс — у `new_booking.md` 8 H1 (шаги воронки `intro / qualify / book / close` плюс «Особенности по специальностям», «Особые сценарии», «Технические подсказки»). Это не баг, а структура датасета. Старая идея «всё после второго H1 — служебка» отозвана: для отделения мета-материала используется отдельный механизм — см. ниже про `data/datasets/` vs `docs/wiki_meta_*.md`.
|
||||
|
||||
Конвенция вместо правила «второй H1 = служебка»: служебные/редакторские блоки (что нужно дополнить, что не должно попадать в датасет, источники, история правок) держим в **отдельном файле** `docs/wiki_meta_<branch>.md`, не в `data/datasets/`. Это уже сделано вручную для всех веток (general_info, price_question, medical_question, escalate_human, reschedule, new_booking) — парсеру не нужно их различать, он просто индексирует то, что физически лежит в `data/datasets/`.
|
||||
|
||||
### 2. YAML frontmatter
|
||||
|
||||
Если файл начинается со строки `---` (без BOM/whitespace до неё), всё до следующей строки `---` — frontmatter. Распарсить как YAML (`pyyaml`), сохранить в отдельное поле `document_metadata` (отдельная сущность, в Chroma не пишется), из текста удалить полностью. Это даст возможность Аксею держать в начале файла `intent`, `sources`, `update_log` без риска утечки в индекс.
|
||||
|
||||
### 3. Чанкинг внутри H2-секции
|
||||
|
||||
Для каждой H2-секции:
|
||||
|
||||
- Если её тело (вместе с заголовком-префиксом) ≤ `max_chunk_size` — один чанк целиком. `section = heading H2`, `text = "{H2}\n\n{body}"`.
|
||||
- Если > `max_chunk_size` — резать по абзацам (`split` по `\n\n`, не по `\n`!). Это уже само по себе закроет проблему текущего split по одному `\n`, который рвал bullet-списки.
|
||||
- В каждый получившийся subchunk обязательно добавлять breadcrumb-префикс: первая строка чанка = `## {heading H2}` (буквально с двумя `#`, чтобы эмбеддинг видел иерархию). Это решает п. 7 — заголовок есть в каждом куске.
|
||||
- `section` во всех subchunk'ах одинаков и равен heading H2.
|
||||
|
||||
### 4. Merge коротких хвостов — только внутри одной H2-секции
|
||||
|
||||
Текущее правило «если предыдущий чанк короче `min_chunk_size`, к нему дописать следующий» применять **только когда оба чанка из одной H2-секции**. Через границу H2 склеивать запрещено. Если итоговый чанк секции получился короче `min_chunk_size` и склеить его не с чем — оставить как есть, не трогать.
|
||||
|
||||
### 5. Overlap — только внутри H2-секции
|
||||
|
||||
Sentence-overlap (`overlap_sentences=2`) применять только между subchunk'ами одной H2-секции. Между чанками разных секций overlap'а не должно быть — он размывает эмбеддинг и провоцирует ложные совпадения.
|
||||
|
||||
### 6. Чёрный список заголовков (опционально, на будущее)
|
||||
|
||||
Добавить в `config.py` поле `excluded_section_headings: list[str]` (по умолчанию пусто). Если heading H2 из этого списка — секция не индексируется. Это пригодится, когда подключим живую вики и нельзя будет вручную чистить файлы.
|
||||
|
||||
## Что не делаем в этой итерации
|
||||
|
||||
- Не трогаем embeddings, не вводим reranker, не делаем гибридный retrieval. Это отдельный спринт. v2 — про предсказуемость нарезки.
|
||||
- Не вводим smart-summarization чанков (HyDE и т.п.).
|
||||
- Не меняем формат хранения в Chroma — `metadata.section` остаётся строкой.
|
||||
|
||||
## Точки в коде
|
||||
|
||||
Все правки локализуются в `services/document_processor.py`. Изменения по функциям:
|
||||
|
||||
- `parse_text(file_bytes, is_markdown)` — для `is_markdown=True` переписать на `markdown-it-py` (или mistletoe). Вернуть `(frontmatter_dict, list[ParsedSection])`. Для `is_markdown=False` оставить старую реализацию.
|
||||
- `chunk_sections(sections)` — переписать резку по абзацам, добавить breadcrumb-префикс, ограничить merge и overlap пределами секции.
|
||||
- `process_document(...)` — учесть, что `parse_text` теперь возвращает frontmatter; пробросить его как side-output, в текущем pipeline можно просто игнорировать (Chroma и так его не получает).
|
||||
- `parse_pdf` / `parse_docx` — пока не трогаем; если в них всплывут аналогичные проблемы — отдельной итерацией.
|
||||
|
||||
## Тест-план
|
||||
|
||||
В `eval/branch_cases_general_info.jsonl` уже есть нужные кейсы. После v2 должны позеленеть как минимум:
|
||||
|
||||
- `Добрый день, вы делаете тимпанометрию?` → `section="Направления приёма"`, текст содержит «тимпанометр».
|
||||
- `Делают ли в клинике КТ височных костей?` (если контент будет добавлен в датасет — это уже задача Натальи).
|
||||
- `Какой график работы у Терво С.О.?` — `section="Список врачей по специальностям"` (после v2 H3 не отделится в свою секцию).
|
||||
- `Созонова Людмила Альбертовна работает у Вас?` — то же самое (eval-кейс уже считает её частью раздела «Список врачей»).
|
||||
|
||||
Юнит-тесты для парсера/чанкера сейчас в репо отсутствуют (в `eval/` только evaluation cases). Нужно завести `tests/test_document_processor_v2.py`:
|
||||
|
||||
- `general_info.md` → ровно столько чанков, сколько H2 в файле (плюс split на длинных секциях); все `section` непустые; нет ни одного чанка с `section`, начинающимся на цифру; `section="Направления приёма"` встречается ровно у тех чанков, что содержат «тимпанометр», «эндоскоп», «спирограф».
|
||||
- Файл с frontmatter → frontmatter не в выходных чанках; первый чанк начинается с `## {первый H2}`.
|
||||
- Файл со вторым H1 → всё после второго H1 отсутствует в чанках; в логе есть WARN.
|
||||
- H3 внутри H2 → один чанк с section H2, в теле строка `### {H3}`.
|
||||
- Длинная H2-секция → N subchunk'ов, все с одинаковым `section`, в каждом первая строка `## {H2}`.
|
||||
|
||||
## Связанная работа в данных
|
||||
|
||||
В `data/datasets/general_info.md` уже сделаны точечные обходные правки под текущий парсер: вынесен YAML-frontmatter, нумерованный список «Способы получения справки» переведён на маркированный, мета-блок «Анализ материалов вики» вынесен в `docs/wiki_meta_general_info.md`, раздел «Направления приёма» расширен FAQ-формулировками («Тимпанометрия — да, делаем» и т.п.). Эти правки нужны были, чтобы регрессия не падала прямо сейчас. После v2 чанкера нумерованные списки и frontmatter будут безопасны — но обратно нумерацию возвращать не обязательно: bullet-список здесь не хуже по смыслу.
|
||||
@@ -663,6 +663,85 @@
|
||||
|
||||
---
|
||||
|
||||
## Спринт 8.5. Чанкер v2 (markdown с иерархией H1/H2/H3)
|
||||
|
||||
### Цель
|
||||
Сделать нарезку wiki-датасетов предсказуемой и совместимой с eval-контрактом «`metadata.section` чанка == заголовок H2 раздела». Триггер — фейл регрессии 8b по тимпанометрии: `expected_doc_section: "Направления приёма"` не находится из-за того, что текущий парсер режет markdown эвристиками без учёта иерархии, склеивает соседей через границы H2 и подмешивает overlap чужой секции. Полное ТЗ — `docs/CHUNKER_v2_TZ.md`.
|
||||
|
||||
### Статус: ✅ Закрыт
|
||||
|
||||
### Задачи
|
||||
|
||||
**Парсинг (`services/document_processor.py::parse_markdown`, ветка `is_markdown=True`):**
|
||||
- [x] Перейти на `markdown-it-py` (уже в зависимостях транзитивно), регэкспные эвристики `numbered_heading_re` / `faq_question_re` / ALL-CAPS — отключить для md-входа (для txt оставить как есть).
|
||||
- [x] Каждый H2 открывает свою секцию; H3 и ниже идут в тело текущей H2 как строка `### {текст}`.
|
||||
- [x] Множественные H1 — штатный кейс (`new_booking.md` имеет 8 H1 — шаги воронки + группы). Каждый H1 группирует свои H2-секции; преамбула H1 (тело до первого H2) игнорируется. Если внутри H1 нет ни одного H2 — H1 сам становится одной секцией с heading=H1. Служебные блоки операторы держат в отдельном файле `docs/wiki_meta_<branch>.md` (вне `data/datasets/`), парсеру их различать не нужно.
|
||||
- [x] YAML frontmatter (`--- ... ---` в самом начале файла) распарсить, вернуть отдельным полем `document_metadata`, в текст не пропускать.
|
||||
|
||||
**Чанкинг (`services/document_processor.py::chunk_sections`):**
|
||||
- [x] Резка длинных H2 по абзацам (`\n\n`, не `\n`).
|
||||
- [x] В каждый subchunk добавлять breadcrumb-префикс `## {heading H2}` как первую строку. `section` во всех subchunk'ах одинаков.
|
||||
- [x] Merge коротких хвостов — только внутри одной H2-секции. Через границу H2 склеивать запрещено.
|
||||
- [x] Sentence-overlap — только между subchunk'ами одной H2. Между разными секциями overlap'а нет.
|
||||
|
||||
**Конфиг (`config.py`):**
|
||||
- [x] `excluded_section_headings: list[str] = []` — H2 из этого списка не индексируются (под будущую внешнюю вики).
|
||||
|
||||
**Тесты (новый каталог `tests/`, на stdlib `unittest` — без новых зависимостей):**
|
||||
- [x] `tests/test_document_processor_v2.py`. Запуск: `.venv/bin/python -m unittest tests.test_document_processor_v2 -v` (17 кейсов, все зелёные).
|
||||
- `general_info.md` → все `section` непустые, нет ни одного `section`, начинающегося с цифры; чанк, содержащий «тимпанометр», имеет `section == "Направления приёма"`; в каждом чанке первая строка — breadcrumb `## {section}`.
|
||||
- `new_booking.md` (8 H1) → секции из всех H1-групп индексируются; точечно проверяем «Тон и формулировки», «Шаблон ответа (5 пунктов)», «Текст-завершение».
|
||||
- Файл с frontmatter → frontmatter не утекает в чанки; первый чанк начинается с `## {первый H2}`.
|
||||
- Множественные H1 с H2 → секции из всех H1; преамбула H1 (тело до первого H2) выкидывается; WARN на втором H1 не возникает (старое правило отозвано).
|
||||
- H1 без H2 → одна секция с heading=H1.
|
||||
- H3 внутри H2 → один чанк с `section == H2`, в теле строка `### {H3}`.
|
||||
- Длинная H2-секция → N subchunk'ов, у всех одинаковый `section`, у каждого первая строка `## {H2}`.
|
||||
- Нумерованный список «1. … 2. …» в md-входе → не парсится как заголовок.
|
||||
|
||||
### Что не делаем
|
||||
- Не трогаем embeddings, reranker, гибридный retrieval, HyDE — отдельные спринты.
|
||||
- `parse_pdf` / `parse_docx` не трогаем; если в них всплывут аналогичные проблемы — отдельной итерацией.
|
||||
- Формат хранения в Chroma не меняем — `metadata.section` остаётся строкой.
|
||||
|
||||
### Критерий готовности
|
||||
- [x] `python -m unittest tests.test_document_processor_v2 -v` — 17 кейсов, все зелёные.
|
||||
- [x] Smoke-прогон чанкера на `data/datasets/general_info.md`: чанк с «тимпанометр» имеет `section == "Направления приёма"`, нет чанков с пустым `section`, нет чанков с `section`, начинающимся с цифры.
|
||||
- [x] После переиндексации документа через UI «Отладка» прогон регрессии `branch:general_info` показал PASS на тимпанометрии и других ранее падавших кейсах (подтверждено пользователем 2026-05-04).
|
||||
|
||||
---
|
||||
|
||||
## Спринт 8.6. Регрессия остальных веток (price_question, medical_question, escalate_human, reschedule)
|
||||
|
||||
### Цель
|
||||
Расширить регрессию ответов веток (механика 8b) на все остальные ветки, кроме `new_booking`. Бэкенд из 8b уже универсальный — читает `eval/branch_cases_<intent_code>.jsonl` по имени, никаких правок в сервисе. Минимальная работа — добавить опции в селектор режима на странице «Регрессия».
|
||||
|
||||
`new_booking` намеренно оставлен вне скоупа: это state-machine-ветка с многошаговой воронкой, single-turn регрессия неправильно покажет результат — отдельная задача в Спринте 8c (multi-turn).
|
||||
|
||||
### Статус: ✅ Закрыт
|
||||
|
||||
### Задачи
|
||||
|
||||
**UI (`static/regression.html`):**
|
||||
- [x] В select `id="mode-select"` добавлены 4 опции:
|
||||
- `<option value="branch:price_question">Ветка · price_question</option>` (40 кейсов)
|
||||
- `<option value="branch:medical_question">Ветка · medical_question</option>` (29 кейсов)
|
||||
- `<option value="branch:escalate_human">Ветка · escalate_human</option>` (14 кейсов)
|
||||
- `<option value="branch:reschedule">Ветка · reschedule</option>` (16 кейсов)
|
||||
- [x] `setMode` / `currentBranchIntent()` параметричны (`mode.split(":", 2)[1]`) — правок не потребовалось.
|
||||
|
||||
**База кейсов (уже в репо):**
|
||||
- [x] `eval/branch_cases_price_question.jsonl`
|
||||
- [x] `eval/branch_cases_medical_question.jsonl`
|
||||
- [x] `eval/branch_cases_escalate_human.jsonl`
|
||||
- [x] `eval/branch_cases_reschedule.jsonl`
|
||||
|
||||
### Критерий готовности
|
||||
- [x] На странице «Регрессия» в селекторе режима видны 5 опций веток (general_info + 4 новые).
|
||||
- [x] Smoke-прогон через UI для каждой из 4 новых веток — осмысленный ответ + retrieved-секции (подтверждено пользователем 2026-05-04).
|
||||
- [x] При активации новой версии промпта ветки кэш для неё пуст — поведение 8b сохраняется параметрически.
|
||||
|
||||
---
|
||||
|
||||
## Спринт 9. Сценарии + экспорт графа
|
||||
|
||||
### Цель
|
||||
|
||||
Reference in New Issue
Block a user