From 4aac59313d310b590e5e504aaf590a5439794412 Mon Sep 17 00:00:00 2001 From: AR 15 M4 Date: Mon, 4 May 2026 09:59:12 +0500 Subject: [PATCH] =?UTF-8?q?feat(sprint8.5+8.6):=20=D1=87=D0=B0=D0=BD=D0=BA?= =?UTF-8?q?=D0=B5=D1=80=20v2=20(=D0=B8=D0=B5=D1=80=D0=B0=D1=80=D1=85=D0=B8?= =?UTF-8?q?=D1=8F=20H1/H2/H3)=20+=20=D1=80=D0=B5=D0=B3=D1=80=D0=B5=D1=81?= =?UTF-8?q?=D1=81=D0=B8=D1=8F=204=20=D0=B2=D0=B5=D1=82=D0=BE=D0=BA=20?= =?UTF-8?q?=D0=B2=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- config.py | 1 + docs/CHUNKER_v2_TZ.md | 97 +++++++++ docs/SPRINTS.md | 79 ++++++++ services/document_processor.py | 267 ++++++++++++++++++------ static/regression.html | 4 + tests/__init__.py | 0 tests/test_document_processor_v2.py | 302 ++++++++++++++++++++++++++++ 7 files changed, 692 insertions(+), 58 deletions(-) create mode 100644 docs/CHUNKER_v2_TZ.md create mode 100644 tests/__init__.py create mode 100644 tests/test_document_processor_v2.py diff --git a/config.py b/config.py index adca62c..ffce119 100644 --- a/config.py +++ b/config.py @@ -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"} diff --git a/docs/CHUNKER_v2_TZ.md b/docs/CHUNKER_v2_TZ.md new file mode 100644 index 0000000..1f04353 --- /dev/null +++ b/docs/CHUNKER_v2_TZ.md @@ -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_.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-список здесь не хуже по смыслу. diff --git a/docs/SPRINTS.md b/docs/SPRINTS.md index 4047491..6ec9988 100644 --- a/docs/SPRINTS.md +++ b/docs/SPRINTS.md @@ -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_.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_.jsonl` по имени, никаких правок в сервисе. Минимальная работа — добавить опции в селектор режима на странице «Регрессия». + +`new_booking` намеренно оставлен вне скоупа: это state-machine-ветка с многошаговой воронкой, single-turn регрессия неправильно покажет результат — отдельная задача в Спринте 8c (multi-turn). + +### Статус: ✅ Закрыт + +### Задачи + +**UI (`static/regression.html`):** +- [x] В select `id="mode-select"` добавлены 4 опции: + - `` (40 кейсов) + - `` (29 кейсов) + - `` (14 кейсов) + - `` (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. Сценарии + экспорт графа ### Цель diff --git a/services/document_processor.py b/services/document_processor.py index 89cedc6..ad51b87 100644 --- a/services/document_processor.py +++ b/services/document_processor.py @@ -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) diff --git a/static/regression.html b/static/regression.html index 9e2174d..aa2bbd2 100644 --- a/static/regression.html +++ b/static/regression.html @@ -223,6 +223,10 @@ diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_document_processor_v2.py b/tests/test_document_processor_v2.py new file mode 100644 index 0000000..e54bb73 --- /dev/null +++ b/tests/test_document_processor_v2.py @@ -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()