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
min_chunk_size: int = 200
overlap_sentences: int = 2
excluded_section_headings: list[str] = []
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. Сценарии + экспорт графа
### Цель
+209 -58
View File
@@ -2,11 +2,13 @@ import io
import logging
import re
import uuid
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path
import fitz # pymupdf
import yaml
from docx import Document as DocxDocument
from markdown_it import MarkdownIt
from config import settings
from services.text_cleanup import clean_markdown_text
@@ -32,6 +34,13 @@ class Chunk:
chunk_index: int = 0
@dataclass
class ParsedMarkdown:
"""Результат парсинга md: frontmatter (если был) + список H2-секций."""
frontmatter: dict = field(default_factory=dict)
sections: list[ParsedSection] = field(default_factory=list)
# --- Parsers ---
@@ -194,6 +203,123 @@ def parse_text(file_bytes: bytes, is_markdown: bool = False) -> list[ParsedSecti
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 ---
@@ -208,70 +334,89 @@ def chunk_sections(
min_chunk_size: int | None = None,
overlap_sentences: int | None = None,
) -> list[Chunk]:
"""Чанкинг wiki-секций.
"""Чанкинг секций с инвариантом «один чанк ⊆ одна H2-секция».
- Малые секции (FAQ-ответы) держим целиком один чанк = одна тема.
- Большие секции (регламенты) режем по абзацам, с overlap последних N предложений.
- Мелкие соседние секции склеиваем, чтобы не плодить огрызки.
Ключевые правила (см. docs/CHUNKER_v2_TZ.md):
- Внутри секции разрезаем тело по абзацам (`\\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
min_size = min_chunk_size or settings.min_chunk_size
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:
heading_prefix = f"{section.heading}\n\n" if section.heading else ""
full_text = heading_prefix + section.body
if section.heading and section.heading in excluded:
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:
raw_chunks.append(Chunk(
text=full_text.strip(),
section_chunks = [full_text]
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,
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 chunk in raw_chunks:
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)
for i, c in enumerate(final):
c.chunk_index = i
return final
@@ -280,12 +425,16 @@ def chunk_sections(
def _sections_to_markdown(sections: list[ParsedSection]) -> str:
"""Собрать секции в markdown-подобный текст — используется как raw_text для PDF/DOCX,
чтобы при переиндексации можно было снова пропустить через parse_text."""
"""Собрать секции в markdown-подобный текст для повторной нарезки.
Все секции пишем как H2 это нормализует выгрузки PDF/DOCX, где `heading_level`
может быть 1 или 2. Иначе reindex через `parse_markdown` потерял бы контент:
одиночный H1 трактуется как корень документа, второй H1 WARN-обрыв.
"""
parts = []
for s in sections:
if s.heading:
parts.append(f"{'#' * max(1, s.heading_level)} {s.heading}")
parts.append(f"## {s.heading}")
if s.body:
parts.append(s.body)
return "\n\n".join(parts).strip()
@@ -310,8 +459,9 @@ def process_document(
raw_text = _sections_to_markdown(sections)
elif ext == ".md":
raw_text = file_bytes.decode("utf-8", errors="replace")
cleaned = clean_markdown_text(raw_text)
sections = parse_text(cleaned.encode("utf-8"), is_markdown=True)
_, body_text = _split_frontmatter(raw_text)
cleaned = clean_markdown_text(body_text)
sections = parse_markdown(cleaned, source_label=filename).sections
elif ext == ".txt":
raw_text = file_bytes.decode("utf-8", errors="replace")
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]:
"""Для переиндексации: режем сохранённый текст с актуальными правилами чистки."""
cleaned = clean_markdown_text(raw_text)
sections = parse_text(cleaned.encode("utf-8"), is_markdown=True)
_, body_text = _split_frontmatter(raw_text)
cleaned = clean_markdown_text(body_text)
sections = parse_markdown(cleaned).sections
for s in sections:
s.heading = clean_markdown_text(s.heading) if s.heading else ""
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;">
<option value="router">Роутер (1573 кейса · все ветки)</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>
<span class="sub" id="mode-hint"></span>
</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()