Files
PTSRaiAgent/app/llm/rag/corpus.py
T
ElebobaandClaude Opus 5.5 fe29ebd629
CI / ruff · pytest (pull_request) Successful in 1m2s
fix(llm): RAG citation pages, scrub placeholders, disease-aware document choice
- converter marks a paragraph glued across a page break with ⟦стр. N⟧; the chunker
  strips the marker and extends the chunk's page range (wrong-page citations 24 -> 3,
  the rest are scale-form table rows split by a page)
- [NAME]/[PHONE] placeholders from privacy.scrub() no longer count as unknown words
  (a scrubbed question used to fall below the coverage gate)
- a question naming one disease boosts its guideline in retrieval and passage choice
  ("ТМС при депрессии" was answered from the PTSD guideline); "антидепрессант" is not a cue

Refs #62

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 04:31:59 +03:00

351 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Knowledge-base corpus: `config/rag/sources.yaml` + txt files -> citable chunks.
The txt files come from `docs/source/rag/pdf_to_txt.py`: paragraphs are separated by blank lines, pages by
`--- стр. N ---` markers, tables are markdown, text transcribed from figures follows an
`[Изображение: ...]` line. Chunking rules:
* a chunk never crosses a section boundary (numbered headings "2.1 ...", "Приложение Г5", ...), so its
section title is reliable context; the title is stored separately and indexed with extra weight;
* "Комментарии: ..." after a recommendation stays with the recommendation (the comment explains it);
* a line starting in lowercase continues the previous paragraph (cut by a page break the converter missed);
* tables are split by rows, every table piece repeats the table header, so a chunk is readable alone;
* each chunk keeps the page range it came from, for the citation "[КР ПТСР 2023, стр. 45]"; inline
"⟦стр. N⟧" markers (paragraph glued across a page break) are removed from the text but move the range.
"""
import re
from dataclasses import dataclass, field
from functools import lru_cache
from pathlib import Path
import yaml
from pydantic import BaseModel, Field
from app.config import get_settings
Audience = str # "doctor" | "patient" | "team"
PAGE_MARKER = re.compile(r"^--- стр\. (\d+) ---$")
# A paragraph that flows onto the next page is glued by the converter; "⟦стр. N⟧" marks where page N begins.
INLINE_PAGE = re.compile(r"\s*⟦стр\. (\d+)⟧\s*")
TABLE_DIVIDER = re.compile(r"^\|(-+\|)+$")
# "2.1 Жалобы и анамнез", "3.1.2. Купирующая терапия ..." — a numbered subsection title.
SUBSECTION = re.compile(r"^(\d{1,2}(?:\.\d{1,2})+)\.?\s+([А-ЯЁA-Z].*)$") # not dates "28.07.2022 N ..."
# Top-level KR sections: "1. Краткая информация ...", "3. Лечение, включая ...". Plain "1. ..." lines are
# list items, so a top-level heading must start with one of the standard KR section names.
TOPLEVEL = re.compile(
r"^([1-9])\.\s+(Краткая информация|Диагностика|Лечение|Медицинская реабилитация|Профилактика"
r"|Организация оказания|Дополнительная информация)"
)
NAMED = re.compile(
r"^(Приложение [А-ЯЁ]\d*(?:-[А-ЯЁ]+\d*)?\b.*|Критерии оценки качества медицинской помощи|Список литературы"
r"|Список сокращений|Термины и определения)$"
)
# Unnumbered short title lines (team specs): capitalised, short, no final punctuation, not a list item.
SHORT_TITLE = re.compile(r"^[А-ЯЁ][^.:;!?|]{2,60}$")
COMMENT = re.compile(r"^Комментари[йи]\s*:")
SENTENCE_SPLIT = re.compile(r"(?<=[.!?…])\s+(?=[А-ЯЁA-Z0-9«\"(])")
# Abbreviations after which a capital letter does not start a new sentence: "(см. Приложение Б5)".
ABBREVIATION_END = re.compile(
r"(?:\b(?:см|т\.\s?е|т\.\s?д|т\.\s?п|др|напр|рис|табл|стр|г|гг|им|ср|англ|лат|греч)|\b[А-ЯЁA-Z])\.$"
)
MAX_TITLE_CHARS = 300
class DocumentMeta(BaseModel):
id: str
file: str
title: str
short: str
issuer: str
year: int
icd: list[str] = Field(default_factory=list)
audiences: list[Audience]
class ChunkingConfig(BaseModel):
max_chars: int = 1200
min_chars: int = 200
skip_sections: list[str] = Field(default_factory=list)
class RetrievalConfig(BaseModel):
top_k: int = 5
bm25_k1: float = 1.5
bm25_b: float = 0.75
title_weight: int = 2
min_score: float = 3.0
rerank_candidates: int = 30
window_sentences: int = 2
window_weight: float = 0.5
# section title prefix -> score multiplier, per audience ({"patient": {"Приложение В": 1.5}})
section_boost: dict[str, dict[str, float]] = Field(default_factory=dict)
# doc id -> regexes naming its disease; a question naming one disease favours that document
doc_cues: dict[str, list[str]] = Field(default_factory=dict)
doc_cue_boost: float = 1.0
class AnswerConfig(BaseModel):
max_chunks: int = 3
max_passages: int = 2
max_sentences: int = 3
max_chars: int = 600
min_coverage: float = 0.5
second_passage_ratio: float = 0.8
neighbor_ratio: float = 0.5
class RagConfig(BaseModel):
documents: list[DocumentMeta]
chunking: ChunkingConfig = ChunkingConfig()
retrieval: RetrievalConfig = RetrievalConfig()
answer: AnswerConfig = AnswerConfig()
synonyms: list[list[str]] = Field(default_factory=list)
@dataclass(frozen=True)
class Chunk:
id: str # "<doc>#<n>"
doc: DocumentMeta
section: str # nearest heading ("" before the first one)
page_start: int
page_end: int
text: str # display text: paragraphs and markdown table rows
@property
def pages(self) -> str:
return str(self.page_start) if self.page_start == self.page_end else f"{self.page_start}–{self.page_end}"
@property
def citation(self) -> str:
return f"[{self.doc.short}, стр. {self.pages}]"
@dataclass
class _Unit:
"""Indivisible piece of a chunk: a paragraph (with its comment) or one table row."""
text: str
page: int # page where the unit starts
table_header: str | None = None # "| a | b |\n|---|---|" for table rows
breaks: list[tuple[int, int]] = field(default_factory=list) # (offset in text, page starting there)
@property
def page_end(self) -> int:
return self.breaks[-1][1] if self.breaks else self.page
def page_at(self, offset: int) -> int:
page = self.page
for start, p in self.breaks:
if start <= offset:
page = p
return page
def append(self, line: str, page: int, sep: str) -> None:
"""Glue a continuation line; record a page break if it comes from a later page."""
base = len(self.text) + len(sep)
text, breaks = _strip_inline_pages(line)
if page > self.page_end:
self.breaks.append((base, page))
self.breaks.extend((base + off, p) for off, p in breaks)
self.text += sep + text
def _strip_inline_pages(line: str) -> tuple[str, list[tuple[int, int]]]:
"""Text without "⟦стр. N⟧" markers + (offset, page) of each marker in the cleaned text."""
out, breaks, pos = [], [], 0
for m in INLINE_PAGE.finditer(line):
out.append(line[pos : m.start()])
breaks.append((sum(len(x) for x in out) + 1, int(m.group(1))))
out.append(" ")
pos = m.end()
out.append(line[pos:])
return "".join(out), breaks
def _new_unit(line: str, page: int) -> _Unit:
text, breaks = _strip_inline_pages(line)
return _Unit(text, page, breaks=breaks)
@dataclass
class _Section:
title: str
units: list[_Unit] = field(default_factory=list)
def rag_dir() -> Path:
return get_settings().config_dir / "rag"
@lru_cache
def load_config(path: Path | None = None) -> RagConfig:
path = path or rag_dir() / "sources.yaml"
return RagConfig.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
def heading(line: str) -> str | None:
"""The line's text if it is a section heading, else `None`."""
if len(line) > MAX_TITLE_CHARS or line.startswith("|"):
return None
if SUBSECTION.match(line) or TOPLEVEL.match(line) or NAMED.match(line):
return line
return None
def _is_short_title(block: list[str], next_block: list[str] | None) -> bool:
"""A lone short line followed by a body paragraph (unnumbered headings in team docs)."""
return (
len(block) == 1
and bool(SHORT_TITLE.match(block[0]))
and next_block is not None
and not PAGE_MARKER.match(next_block[0])
and (len(next_block) > 1 or len(next_block[0]) > 80 or next_block[0].startswith("|"))
)
def _blocks(text: str) -> list[list[str]]:
"""Blank-line separated blocks of non-empty lines."""
return [[ln for ln in b.splitlines() if ln.strip()] for b in re.split(r"\n\s*\n", text) if b.strip()]
def _sections(text: str, skip: list[str]) -> list[_Section]:
blocks = _blocks(text)
sections = [_Section(title="")]
page = 1
skipping = False
for i, block in enumerate(blocks):
first = block[0]
if first.startswith("# ") or first.startswith("Источник:"):
continue # file header written by the converter
m = PAGE_MARKER.match(first)
if m:
page = int(m.group(1))
continue
nxt = next((b for b in blocks[i + 1 :] if not PAGE_MARKER.match(b[0])), None)
if first.startswith("|"):
if not skipping:
sections[-1].units.extend(_table_units(block, page))
continue
if first.startswith("[Изображение"):
if not skipping: # transcribed figure: one unit, keeps its own line breaks
sections[-1].units.append(_new_unit("\n".join(block[1:]) if len(block) > 1 else first, page))
continue
for j, line in enumerate(block):
title = heading(line)
if title is None and j == 0 and _is_short_title(block, nxt):
title = line
if title is not None:
skipping = any(title.startswith(s) for s in skip)
sections.append(_Section(title=title))
continue
if skipping:
continue
units = sections[-1].units
if COMMENT.match(line) and units and units[-1].table_header is None:
units[-1].append(line, page, "\n")
elif line[0].islower() and units and units[-1].table_header is None:
units[-1].append(line, page, " ") # paragraph continued after a page break or a figure
else:
units.append(_new_unit(line, page))
return [s for s in sections if s.units]
def _table_units(block: list[str], page: int) -> list[_Unit]:
if len(block) >= 2 and TABLE_DIVIDER.match(block[1]):
header = f"{block[0]}\n{block[1]}"
rows = block[2:]
else: # continuation without a header line (should not happen with the converter, but be safe)
header, rows = "", block
return [_Unit(row, page, table_header=header) for row in rows]
def split_sentences(text: str) -> list[str]:
"""Sentences of one line/paragraph; does not split after "см.", "т.е.", initials."""
out: list[str] = []
for part in SENTENCE_SPLIT.split(text):
if out and ABBREVIATION_END.search(out[-1]):
out[-1] += " " + part
else:
out.append(part)
return [p.strip() for p in out if p.strip()]
def _split_long(unit: _Unit, max_chars: int) -> list[_Unit]:
if len(unit.text) <= max_chars or unit.table_header is not None:
return [unit]
parts: list[str] = []
for sentence in split_sentences(unit.text):
if parts and len(parts[-1]) + 1 + len(sentence) <= max_chars:
parts[-1] += " " + sentence
else:
parts.append(sentence)
out: list[_Unit] = []
pos = 0
for part in parts:
start = unit.text.find(part[:40], pos)
start = pos if start < 0 else start
end = start + len(part) - 1
first, last = unit.page_at(start), unit.page_at(end)
out.append(_Unit(part, first, breaks=[(len(part) - 1, last)] if last != first else []))
pos = start + len(part)
return out
def _render(units: list[_Unit]) -> str:
out: list[str] = []
header: str | None = None
for u in units:
if u.table_header is not None:
if header != u.table_header:
header = u.table_header
if header:
out.append(header)
out.append(u.text)
else:
header = None
out.append(u.text)
return "\n".join(out)
def _pack(section: _Section, max_chars: int, min_chars: int) -> list[list[_Unit]]:
units = [p for u in section.units for p in _split_long(u, max_chars)]
groups: list[list[_Unit]] = [[]]
size = 0
for u in units:
if groups[-1] and size + len(u.text) > max_chars:
groups.append([])
size = 0
groups[-1].append(u)
size += len(u.text) + 1
if len(groups) > 1 and sum(len(u.text) for u in groups[-1]) < min_chars:
groups[-2].extend(groups.pop())
return [g for g in groups if g]
def chunk_document(doc: DocumentMeta, text: str, cfg: ChunkingConfig) -> list[Chunk]:
chunks: list[Chunk] = []
for section in _sections(text, cfg.skip_sections):
for group in _pack(section, cfg.max_chars, cfg.min_chars):
chunks.append(
Chunk(
id=f"{doc.id}#{len(chunks)}",
doc=doc,
section=section.title,
page_start=group[0].page,
page_end=max(u.page_end for u in group),
text=_render(group),
)
)
return chunks
@lru_cache
def load_chunks(config_path: Path | None = None) -> tuple[Chunk, ...]:
cfg = load_config(config_path)
base = (config_path.parent if config_path else rag_dir()).resolve()
out: list[Chunk] = []
for doc in cfg.documents:
out.extend(chunk_document(doc, (base / doc.file).read_text(encoding="utf-8"), cfg.chunking))
return tuple(out)