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