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
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()