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:
AR 15 M4
2026-05-04 09:59:12 +05:00
parent bb5e3f5eb3
commit 4aac59313d
7 changed files with 692 additions and 58 deletions
+1
View File
@@ -14,6 +14,7 @@ class Settings(BaseSettings):
max_chunk_size: int = 1200 max_chunk_size: int = 1200
min_chunk_size: int = 200 min_chunk_size: int = 200
overlap_sentences: int = 2 overlap_sentences: int = 2
excluded_section_headings: list[str] = []
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"} model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
+97
View File
@@ -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-список здесь не хуже по смыслу.
+79
View File
@@ -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. Сценарии + экспорт графа ## Спринт 9. Сценарии + экспорт графа
### Цель ### Цель
+209 -58
View File
@@ -2,11 +2,13 @@ import io
import logging import logging
import re import re
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
import fitz # pymupdf import fitz # pymupdf
import yaml
from docx import Document as DocxDocument from docx import Document as DocxDocument
from markdown_it import MarkdownIt
from config import settings from config import settings
from services.text_cleanup import clean_markdown_text from services.text_cleanup import clean_markdown_text
@@ -32,6 +34,13 @@ class Chunk:
chunk_index: int = 0 chunk_index: int = 0
@dataclass
class ParsedMarkdown:
"""Результат парсинга md: frontmatter (если был) + список H2-секций."""
frontmatter: dict = field(default_factory=dict)
sections: list[ParsedSection] = field(default_factory=list)
# --- Parsers --- # --- Parsers ---
@@ -194,6 +203,123 @@ def parse_text(file_bytes: bytes, is_markdown: bool = False) -> list[ParsedSecti
return sections return sections
# --- Markdown parser v2 (иерархия H1/H2/H3, frontmatter, второй H1 → cut) ---
def _split_frontmatter(text: str) -> tuple[dict, str]:
"""Если файл начинается со строки `---`, отрезает YAML-frontmatter и парсит его.
Возвращает (frontmatter_dict, body_text). Если frontmatter не найден или невалиден —
словарь пустой, body_text == исходный text.
"""
if not text.startswith("---"):
return {}, text
lines = text.split("\n")
if not lines or lines[0].strip() != "---":
return {}, text
end = -1
for i in range(1, len(lines)):
if lines[i].strip() == "---":
end = i
break
if end == -1:
return {}, text
fm_text = "\n".join(lines[1:end])
body = "\n".join(lines[end + 1:]).lstrip("\n")
try:
fm = yaml.safe_load(fm_text) or {}
except yaml.YAMLError as exc:
logger.warning("Failed to parse YAML frontmatter: %s", exc)
return {}, text
if not isinstance(fm, dict):
return {}, body
return fm, body
def parse_markdown(text: str, source_label: str = "") -> ParsedMarkdown:
"""Парсер markdown с иерархией H1/H2/H3.
Правила (см. docs/CHUNKER_v2_TZ.md):
- YAML frontmatter (`--- ... ---` в самом начале) — отрезается, в текст не идёт.
- Каждый H1 — это «корневая секция» документа. Множественные H1 поддерживаются
штатно (например, `new_booking.md` имеет 8 H1 — шаги воронки).
- Внутри H1 каждый H2 открывает свою секцию (heading H2 → ParsedSection.heading).
Преамбула H1 (тело между H1 и его первым H2) игнорируется — обычно это служебка
или вступление, дублирующее заголовок.
- Если внутри H1 нет ни одного H2 — H1 сам становится одной секцией с heading H1
и body = всё его содержимое.
- H3 и ниже не открывают секций — идут в тело текущей H2 как есть (`### {текст}`).
"""
frontmatter, body_text = _split_frontmatter(text)
_ = source_label # пока используется только потенциально для будущих логов
md = MarkdownIt("commonmark")
tokens = md.parse(body_text)
lines = body_text.split("\n")
total_lines = len(lines)
# (level, start_line, body_start_line, heading_text)
headings: list[tuple[int, int, int, str]] = []
i = 0
while i < len(tokens):
t = tokens[i]
if t.type == "heading_open" and t.map is not None:
level = int(t.tag[1])
inline = tokens[i + 1] if i + 1 < len(tokens) else None
heading_text = inline.content.strip() if inline is not None else ""
headings.append((level, t.map[0], t.map[1], heading_text))
i += 1
h1_positions = [idx for idx, h in enumerate(headings) if h[0] == 1]
sections: list[ParsedSection] = []
# Файл без H1 — старый кейс (только H2). Обрабатываем как «один виртуальный H1»
# с диапазоном на весь файл, чтобы не ветвиться.
if not h1_positions:
h1_groups = [(0, total_lines, "", -1)] # (h1_body_start, h1_end_line, h1_heading, h1_idx)
else:
h1_groups = []
for pos_idx, h_idx in enumerate(h1_positions):
_, _, h1_body_start, h1_text = headings[h_idx]
if pos_idx + 1 < len(h1_positions):
h1_end = headings[h1_positions[pos_idx + 1]][1]
else:
h1_end = total_lines
h1_groups.append((h1_body_start, h1_end, h1_text, h_idx))
for h1_body_start, h1_end, h1_heading, h1_idx in h1_groups:
# H2-индексы внутри текущего H1-диапазона.
h2_indices_in_group = [
idx for idx, h in enumerate(headings)
if h[0] == 2 and h1_body_start <= h[1] < h1_end
]
if h2_indices_in_group:
for pos, idx in enumerate(h2_indices_in_group):
_, _, body_start, heading_text = headings[idx]
if pos + 1 < len(h2_indices_in_group):
body_end = headings[h2_indices_in_group[pos + 1]][1]
else:
body_end = h1_end
body = "\n".join(lines[body_start:body_end]).strip()
sections.append(ParsedSection(
heading=heading_text,
heading_level=2,
body=body,
))
else:
# H1 без H2 — body H1 идёт одной секцией с heading=H1.
body = "\n".join(lines[h1_body_start:h1_end]).strip()
if body or h1_heading:
sections.append(ParsedSection(
heading=h1_heading,
heading_level=1 if h1_heading else 2,
body=body,
))
return ParsedMarkdown(frontmatter=frontmatter, sections=sections)
# --- Chunker --- # --- Chunker ---
@@ -208,70 +334,89 @@ def chunk_sections(
min_chunk_size: int | None = None, min_chunk_size: int | None = None,
overlap_sentences: int | None = None, overlap_sentences: int | None = None,
) -> list[Chunk]: ) -> list[Chunk]:
"""Чанкинг wiki-секций. """Чанкинг секций с инвариантом «один чанк ⊆ одна H2-секция».
- Малые секции (FAQ-ответы) держим целиком — один чанк = одна тема. Ключевые правила (см. docs/CHUNKER_v2_TZ.md):
- Большие секции (регламенты) режем по абзацам, с overlap последних N предложений. - Внутри секции разрезаем тело по абзацам (`\\n\\n`).
- Мелкие соседние секции склеиваем, чтобы не плодить огрызки. - В каждом subchunk-е первая строка — breadcrumb `## {heading H2}`.
- Merge коротких хвостов и sentence-overlap работают только внутри одной секции.
- Секции с heading из `excluded_section_headings` пропускаются.
- Секции с пустым heading (PDF/DOCX без заголовка) индексируются без breadcrumb,
чтобы не терять контент при reindex наследия. Для md-входа таких не бывает.
""" """
max_size = max_chunk_size or settings.max_chunk_size max_size = max_chunk_size or settings.max_chunk_size
min_size = min_chunk_size or settings.min_chunk_size min_size = min_chunk_size or settings.min_chunk_size
overlap = overlap_sentences or settings.overlap_sentences overlap = overlap_sentences or settings.overlap_sentences
excluded = set(settings.excluded_section_headings or [])
raw_chunks: list[Chunk] = [] final: list[Chunk] = []
for section in sections: for section in sections:
heading_prefix = f"{section.heading}\n\n" if section.heading else "" if section.heading and section.heading in excluded:
full_text = heading_prefix + section.body continue
body = section.body.strip()
if not body and not section.heading:
continue
breadcrumb = f"## {section.heading}" if section.heading else ""
if breadcrumb:
full_text = f"{breadcrumb}\n\n{body}" if body else breadcrumb
else:
full_text = body
if len(full_text) <= max_size: if len(full_text) <= max_size:
raw_chunks.append(Chunk( section_chunks = [full_text]
text=full_text.strip(), else:
paragraphs = [p.strip() for p in re.split(r"\n{2,}", body) if p.strip()]
section_chunks = []
current = breadcrumb
for para in paragraphs:
# Стоимость склейки: текущий + "\n\n" + para.
projected = (current + "\n\n" + para) if current else para
if len(projected) > max_size and current and current != breadcrumb:
section_chunks.append(current)
current = f"{breadcrumb}\n\n{para}" if breadcrumb else para
else:
current = projected
if current and current != breadcrumb:
section_chunks.append(current)
# Merge коротких хвостов — только внутри одной секции.
merged: list[str] = []
for ch in section_chunks:
if merged and len(merged[-1]) < min_size:
merged[-1] = merged[-1] + "\n\n" + ch
else:
merged.append(ch)
# Sentence-overlap — только между subchunk'ами одной секции.
if overlap > 0 and len(merged) > 1:
with_overlap = [merged[0]]
for i in range(1, len(merged)):
prev_sentences = _split_sentences(merged[i - 1])
overlap_text = " ".join(prev_sentences[-overlap:])
if not overlap_text or overlap_text in merged[i]:
with_overlap.append(merged[i])
continue
cur = merged[i]
# Вставляем overlap после breadcrumb-строки, чтобы заголовок остался первой строкой.
if breadcrumb and cur.startswith(breadcrumb + "\n\n"):
rest = cur[len(breadcrumb) + 2:]
new_text = f"{breadcrumb}\n\n{overlap_text}\n\n{rest}"
else:
new_text = f"{overlap_text}\n\n{cur}"
with_overlap.append(new_text)
merged = with_overlap
for ch_text in merged:
final.append(Chunk(
text=ch_text.strip(),
section=section.heading, section=section.heading,
page_number=section.page_number, page_number=section.page_number,
)) ))
else:
paragraphs = section.body.split("\n")
current_text = heading_prefix
for para in paragraphs:
if len(current_text) + len(para) + 1 > max_size and len(current_text) > len(heading_prefix):
raw_chunks.append(Chunk(
text=current_text.strip(),
section=section.heading,
page_number=section.page_number,
))
current_text = heading_prefix + para + "\n"
else:
current_text += para + "\n"
if current_text.strip() and current_text.strip() != heading_prefix.strip():
raw_chunks.append(Chunk(
text=current_text.strip(),
section=section.heading,
page_number=section.page_number,
))
merged: list[Chunk] = [] for i, c in enumerate(final):
for chunk in raw_chunks: c.chunk_index = i
if merged and len(merged[-1].text) < min_size:
merged[-1].text += "\n\n" + chunk.text
if not merged[-1].section:
merged[-1].section = chunk.section
else:
merged.append(Chunk(
text=chunk.text,
section=chunk.section,
page_number=chunk.page_number,
))
final: list[Chunk] = []
for i, chunk in enumerate(merged):
if i > 0 and overlap > 0:
prev_sentences = _split_sentences(merged[i - 1].text)
overlap_text = " ".join(prev_sentences[-overlap:])
if overlap_text and overlap_text not in chunk.text:
chunk.text = overlap_text + "\n\n" + chunk.text
chunk.chunk_index = i
final.append(chunk)
return final return final
@@ -280,12 +425,16 @@ def chunk_sections(
def _sections_to_markdown(sections: list[ParsedSection]) -> str: def _sections_to_markdown(sections: list[ParsedSection]) -> str:
"""Собрать секции в markdown-подобный текст — используется как raw_text для PDF/DOCX, """Собрать секции в markdown-подобный текст для повторной нарезки.
чтобы при переиндексации можно было снова пропустить через parse_text."""
Все секции пишем как H2 — это нормализует выгрузки PDF/DOCX, где `heading_level`
может быть 1 или 2. Иначе reindex через `parse_markdown` потерял бы контент:
одиночный H1 трактуется как корень документа, второй H1 → WARN-обрыв.
"""
parts = [] parts = []
for s in sections: for s in sections:
if s.heading: if s.heading:
parts.append(f"{'#' * max(1, s.heading_level)} {s.heading}") parts.append(f"## {s.heading}")
if s.body: if s.body:
parts.append(s.body) parts.append(s.body)
return "\n\n".join(parts).strip() return "\n\n".join(parts).strip()
@@ -310,8 +459,9 @@ def process_document(
raw_text = _sections_to_markdown(sections) raw_text = _sections_to_markdown(sections)
elif ext == ".md": elif ext == ".md":
raw_text = file_bytes.decode("utf-8", errors="replace") raw_text = file_bytes.decode("utf-8", errors="replace")
cleaned = clean_markdown_text(raw_text) _, body_text = _split_frontmatter(raw_text)
sections = parse_text(cleaned.encode("utf-8"), is_markdown=True) cleaned = clean_markdown_text(body_text)
sections = parse_markdown(cleaned, source_label=filename).sections
elif ext == ".txt": elif ext == ".txt":
raw_text = file_bytes.decode("utf-8", errors="replace") raw_text = file_bytes.decode("utf-8", errors="replace")
sections = parse_text(raw_text.encode("utf-8"), is_markdown=False) sections = parse_text(raw_text.encode("utf-8"), is_markdown=False)
@@ -339,8 +489,9 @@ def process_document(
def rechunk_raw_text(raw_text: str) -> list[Chunk]: def rechunk_raw_text(raw_text: str) -> list[Chunk]:
"""Для переиндексации: режем сохранённый текст с актуальными правилами чистки.""" """Для переиндексации: режем сохранённый текст с актуальными правилами чистки."""
cleaned = clean_markdown_text(raw_text) _, body_text = _split_frontmatter(raw_text)
sections = parse_text(cleaned.encode("utf-8"), is_markdown=True) cleaned = clean_markdown_text(body_text)
sections = parse_markdown(cleaned).sections
for s in sections: for s in sections:
s.heading = clean_markdown_text(s.heading) if s.heading else "" s.heading = clean_markdown_text(s.heading) if s.heading else ""
s.body = clean_markdown_text(s.body) s.body = clean_markdown_text(s.body)
+4
View File
@@ -223,6 +223,10 @@
<select id="mode-select" onchange="setMode(this.value)" style="padding:6px 10px; font-size:13px; border:1px solid var(--border); border-radius:4px;"> <select id="mode-select" onchange="setMode(this.value)" style="padding:6px 10px; font-size:13px; border:1px solid var(--border); border-radius:4px;">
<option value="router">Роутер (1573 кейса · все ветки)</option> <option value="router">Роутер (1573 кейса · все ветки)</option>
<option value="branch:general_info">Ветка · general_info</option> <option value="branch:general_info">Ветка · general_info</option>
<option value="branch:price_question">Ветка · price_question</option>
<option value="branch:medical_question">Ветка · medical_question</option>
<option value="branch:escalate_human">Ветка · escalate_human</option>
<option value="branch:reschedule">Ветка · reschedule</option>
</select> </select>
<span class="sub" id="mode-hint"></span> <span class="sub" id="mode-hint"></span>
</div> </div>
View File
+302
View File
@@ -0,0 +1,302 @@
"""Юнит-тесты чанкера v2 (Спринт 8.5).
Проверяет инварианты из docs/CHUNKER_v2_TZ.md:
- YAML frontmatter не утекает в чанки.
- Только H2 открывает секцию; H3 уходит в тело.
- Второй H1 WARN + обрыв.
- Breadcrumb `## {H2}` в каждом subchunk.
- Merge и overlap не пересекают границы H2.
- Нумерованные списки в md не парсятся как заголовки.
- На реальном general_info.md чанк с «Тимпанометрия» имеет section="Направления приёма".
Запуск из корня репо: `python -m unittest tests.test_document_processor_v2 -v`
"""
import logging
import sys
import unittest
from pathlib import Path
# Корень репозитория в sys.path, чтобы импортировать services.* без установки пакета.
REPO_ROOT = Path(__file__).resolve().parent.parent
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from services.document_processor import ( # noqa: E402
Chunk,
ParsedSection,
_split_frontmatter,
chunk_sections,
parse_markdown,
process_document,
rechunk_raw_text,
)
class FrontmatterTests(unittest.TestCase):
def test_no_frontmatter_returns_text_as_is(self):
text = "# Title\n\n## Sec\n\nbody"
fm, body = _split_frontmatter(text)
self.assertEqual(fm, {})
self.assertEqual(body, text)
def test_yaml_frontmatter_is_stripped(self):
text = "---\nintent: general_info\nsources: [a, b]\n---\n\n# Title\n\n## Sec\n\nbody"
fm, body = _split_frontmatter(text)
self.assertEqual(fm, {"intent": "general_info", "sources": ["a", "b"]})
self.assertTrue(body.startswith("# Title"))
self.assertNotIn("intent:", body)
self.assertNotIn("---", body.split("\n", 1)[0])
def test_invalid_yaml_falls_back_to_empty_fm(self):
text = "---\n: : invalid yaml :\n---\n\n# Title\n## Sec\nbody"
fm, body = _split_frontmatter(text)
# Парсинг не сломал процесс; frontmatter пустой, тело — не доверяем формату,
# поэтому возвращаем исходный текст как body, чтобы не потерять содержимое.
self.assertEqual(fm, {})
class MultipleH1Tests(unittest.TestCase):
def test_multiple_h1_with_h2_inside_each(self):
text = (
"# Step intro\n"
"## Tone\n"
"intro tone body\n\n"
"# Step qualify\n"
"## Template\n"
"qualify template body\n\n"
"## Guards\n"
"qualify guards body\n\n"
"# Step book\n"
"## Confirmation\n"
"book confirmation body\n"
)
parsed = parse_markdown(text)
headings = [s.heading for s in parsed.sections]
self.assertEqual(headings, ["Tone", "Template", "Guards", "Confirmation"])
# Тела не перетекают между H1.
for s in parsed.sections:
if s.heading == "Tone":
self.assertIn("intro tone body", s.body)
self.assertNotIn("template", s.body.lower())
self.assertNotIn("confirmation", s.body.lower())
if s.heading == "Confirmation":
self.assertIn("book confirmation body", s.body)
self.assertNotIn("intro", s.body.lower())
def test_h1_without_h2_becomes_section_heading_h1(self):
text = (
"# Step close\n"
"Closing line one. Closing line two. Closing line three.\n"
)
parsed = parse_markdown(text)
self.assertEqual(len(parsed.sections), 1)
self.assertEqual(parsed.sections[0].heading, "Step close")
self.assertIn("Closing line", parsed.sections[0].body)
def test_h1_preamble_before_first_h2_is_dropped(self):
# Преамбула H1 (тело до первого H2) игнорируется по правилу ТЗ —
# обычно это вступление/служебка, дублирующая заголовок.
text = (
"# Doc\n"
"intro line that must not become a section\n\n"
"## Real H2\n"
"real h2 body content here.\n"
)
parsed = parse_markdown(text)
self.assertEqual(len(parsed.sections), 1)
self.assertEqual(parsed.sections[0].heading, "Real H2")
self.assertNotIn("intro line that must not", parsed.sections[0].body)
def test_multiple_h1_no_warnings(self):
# Старое поведение — WARN на втором H1 — отозвано.
text = "# H1 one\n## A\nbody a\n\n# H1 two\n## B\nbody b\n"
with self.assertLogs("services.document_processor", level="WARNING") as cap:
parse_markdown(text, source_label="multi.md")
# assertLogs требует хотя бы одну запись — добавим dummy, чтобы не упасть,
# если их действительно нет.
logging.getLogger("services.document_processor").warning("noop-for-assert-logs")
warnings_about_h1 = [m for m in cap.output if "second H1" in m]
self.assertEqual(warnings_about_h1, [])
class H3InBodyTests(unittest.TestCase):
def test_h3_does_not_open_new_section(self):
text = (
"# Doc\n"
"## Doctors\n"
"intro line\n\n"
"### ENT\n"
"Petrov, Ivanov\n\n"
"### Allergists\n"
"Smirnova\n"
)
parsed = parse_markdown(text)
self.assertEqual(len(parsed.sections), 1)
self.assertEqual(parsed.sections[0].heading, "Doctors")
body = parsed.sections[0].body
self.assertIn("### ENT", body)
self.assertIn("### Allergists", body)
self.assertIn("Petrov, Ivanov", body)
chunks = chunk_sections(parsed.sections)
self.assertEqual(len(chunks), 1)
self.assertEqual(chunks[0].section, "Doctors")
self.assertTrue(chunks[0].text.startswith("## Doctors\n\n"))
self.assertIn("### ENT", chunks[0].text)
class NumberedListTests(unittest.TestCase):
def test_numbered_list_items_not_treated_as_headings(self):
text = (
"# Doc\n"
"## Tax certificate\n"
"How to receive:\n\n"
"1. In person at the clinic — the easiest option.\n"
"2. By email — write to mail@clinic.ru.\n"
"3. Directly to the tax office.\n"
)
parsed = parse_markdown(text)
self.assertEqual(len(parsed.sections), 1)
self.assertEqual(parsed.sections[0].heading, "Tax certificate")
chunks = chunk_sections(parsed.sections)
self.assertEqual(len(chunks), 1)
# Все три пункта в одном чанке с одной секцией.
for needle in ("1. In person", "2. By email", "3. Directly"):
self.assertIn(needle, chunks[0].text)
# Никакой section не должен начинаться с цифры.
for c in chunks:
self.assertFalse(c.section[:1].isdigit(), f"section={c.section!r}")
class LongSectionSplitTests(unittest.TestCase):
def test_long_section_splits_with_breadcrumb_and_same_section(self):
para = "Sentence one. Sentence two. Sentence three. " * 8 # ~350 chars
text = (
"# Doc\n"
"## Big section\n"
+ "\n\n".join([para] * 6) # ~2 KB body, существенно больше max_chunk_size=1200
+ "\n"
)
parsed = parse_markdown(text)
chunks = chunk_sections(parsed.sections, max_chunk_size=600, min_chunk_size=100, overlap_sentences=0)
self.assertGreater(len(chunks), 1)
for c in chunks:
self.assertEqual(c.section, "Big section")
self.assertTrue(c.text.startswith("## Big section\n\n"), f"chunk text starts with: {c.text[:30]!r}")
def test_merge_and_overlap_do_not_cross_h2_boundaries(self):
# Две короткие секции — merge между ними не должен случиться.
text = (
"# Doc\n"
"## Alpha\n"
"alpha body short.\n\n"
"## Beta\n"
"beta body short.\n"
)
parsed = parse_markdown(text)
chunks = chunk_sections(parsed.sections, max_chunk_size=1200, min_chunk_size=500, overlap_sentences=2)
sections = sorted({c.section for c in chunks})
self.assertEqual(sections, ["Alpha", "Beta"])
for c in chunks:
if c.section == "Alpha":
self.assertNotIn("beta body", c.text)
self.assertNotIn("## Beta", c.text)
else:
self.assertNotIn("alpha body", c.text)
self.assertNotIn("## Alpha", c.text)
class FrontmatterDoesNotLeakTests(unittest.TestCase):
def test_frontmatter_not_in_chunks(self):
text = (
"---\n"
"intent: general_info\n"
"secret: do-not-leak-this-token\n"
"---\n\n"
"# Doc\n"
"## Sec\n"
"real body line one. real body line two. real body line three.\n"
)
# Прогоняем через rechunk_raw_text — это flow реиндексации.
chunks = rechunk_raw_text(text)
self.assertGreater(len(chunks), 0)
for c in chunks:
self.assertNotIn("do-not-leak-this-token", c.text)
self.assertNotIn("intent: general_info", c.text)
# Первый чанк начинается с breadcrumb первого H2.
self.assertTrue(chunks[0].text.startswith("## Sec"))
class RealGeneralInfoTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
path = REPO_ROOT / "data" / "datasets" / "general_info.md"
cls.path = path
cls.file_bytes = path.read_bytes()
def test_processes_without_errors(self):
_, _, sections, chunks = process_document(self.file_bytes, self.path.name)
self.assertGreater(len(sections), 5)
self.assertGreater(len(chunks), 5)
def test_no_chunk_with_empty_or_numeric_section(self):
_, _, _, chunks = process_document(self.file_bytes, self.path.name)
for c in chunks:
self.assertTrue(c.section, f"empty section in chunk: {c.text[:60]!r}")
self.assertFalse(
c.section[:1].isdigit(),
f"section starts with digit: {c.section!r}",
)
def test_tympanometry_chunk_lives_in_napravleniya_priema(self):
_, _, _, chunks = process_document(self.file_bytes, self.path.name)
matches = [c for c in chunks if "тимпанометр" in c.text.lower()]
self.assertTrue(matches, "no chunk contains 'тимпанометр' — datasets changed?")
for c in matches:
self.assertEqual(
c.section,
"Направления приёма",
f"tympanometry chunk has wrong section: {c.section!r}",
)
def test_breadcrumb_in_every_chunk(self):
_, _, _, chunks = process_document(self.file_bytes, self.path.name)
for c in chunks:
expected = f"## {c.section}"
self.assertTrue(
c.text.startswith(expected),
f"chunk does not start with breadcrumb {expected!r}; starts with {c.text[:60]!r}",
)
class RealNewBookingTests(unittest.TestCase):
"""new_booking.md — 8 H1 (шаги воронки + группы). Под каждым H1 свои H2-секции.
Старое поведение «второй H1 обрыв» сломало бы этот файл. Тест защищает от
регрессии: все H2-секции под всеми H1 должны попадать в индекс.
"""
@classmethod
def setUpClass(cls):
path = REPO_ROOT / "data" / "datasets" / "new_booking.md"
cls.path = path
cls.file_bytes = path.read_bytes()
def test_sections_from_multiple_h1_groups(self):
_, _, sections, chunks = process_document(self.file_bytes, self.path.name)
# Под каждым H1 есть свои H2 — суммарно должно быть много секций.
self.assertGreater(len(sections), 10)
self.assertGreater(len(chunks), 10)
section_titles = {s.heading for s in sections}
# Точечные H2 из разных H1-групп должны присутствовать.
for expected in ("Тон и формулировки", "Шаблон ответа (5 пунктов)", "Текст-завершение"):
self.assertIn(
expected,
section_titles,
f"section {expected!r} missing — multi-H1 grouping broken?",
)
if __name__ == "__main__":
logging.basicConfig(level=logging.WARNING)
unittest.main()