release: dev → main (сдача хакатона) #106
@@ -0,0 +1,24 @@
|
||||
"""Knowledge base (RAG, #60): clinical guidelines -> citable chunks -> BM25 retrieval -> grounded answers.
|
||||
|
||||
from app.llm.rag import answer, retrieve
|
||||
hits = retrieve("синдром отмены антидепрессантов", audience="doctor")
|
||||
reply = await answer(scrub(text), audience="patient") # None -> rag.fallback_text()
|
||||
|
||||
Corpus and parameters: `config/rag/sources.yaml`; PDF sources and the converter: `docs/source/rag/`.
|
||||
"""
|
||||
|
||||
from app.llm.rag.corpus import Chunk, load_chunks
|
||||
from app.llm.rag.index import Hit, get_index, retrieve
|
||||
from app.llm.rag.qa import Citation, RagAnswer, answer, fallback_text
|
||||
|
||||
__all__ = [
|
||||
"Chunk",
|
||||
"Citation",
|
||||
"Hit",
|
||||
"RagAnswer",
|
||||
"answer",
|
||||
"fallback_text",
|
||||
"get_index",
|
||||
"load_chunks",
|
||||
"retrieve",
|
||||
]
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Manual check of the knowledge base on synthetic questions.
|
||||
|
||||
python -m app.llm.rag "через сколько начинают действовать антидепрессанты"
|
||||
python -m app.llm.rag --audience doctor "синдром отмены антидепрессантов"
|
||||
python -m app.llm.rag --search "ДПДГ при ПТСР" # raw retrieval: chunks, scores, passages
|
||||
python -m app.llm.rag --stats # corpus summary
|
||||
|
||||
The question always goes through `privacy.scrub()` first; the CLI never bypasses it.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
from collections import Counter
|
||||
|
||||
from app.core import privacy
|
||||
from app.llm.gateway import get_gateway
|
||||
from app.llm.rag.index import get_index
|
||||
from app.llm.rag.qa import answer, fallback_text
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(prog="python -m app.llm.rag")
|
||||
parser.add_argument("question", nargs="?")
|
||||
parser.add_argument("--audience", choices=["patient", "doctor", "team"], default="patient")
|
||||
parser.add_argument("--search", action="store_true", help="show retrieved chunks instead of an answer")
|
||||
parser.add_argument("--top", type=int, default=5)
|
||||
parser.add_argument("--stats", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
index = get_index()
|
||||
if args.stats:
|
||||
by_doc = Counter(c.doc.short for c in index.chunks)
|
||||
print(f"chunks={len(index.chunks)}", ", ".join(f"{d}: {n}" for d, n in by_doc.items()))
|
||||
return 0
|
||||
if not args.question:
|
||||
parser.error("question is required")
|
||||
question = privacy.scrub(args.question)
|
||||
|
||||
if args.search:
|
||||
for hit in index.search(question, audience=args.audience, top_k=args.top, min_score=0):
|
||||
print(f"{hit.score:6.1f} (bm25 {hit.bm25:5.1f}) {hit.chunk.citation} {hit.chunk.section}")
|
||||
print(f" > {hit.passage[:300]}")
|
||||
return 0
|
||||
|
||||
print(f"provider={get_gateway().provider_name} audience={args.audience}\n")
|
||||
reply = asyncio.run(answer(question, audience=args.audience, index=index))
|
||||
print(reply.text if reply else fallback_text(args.audience))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,297 @@
|
||||
"""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]".
|
||||
"""
|
||||
|
||||
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+) ---$")
|
||||
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)
|
||||
|
||||
|
||||
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
|
||||
table_header: str | None = None # "| a | b |\n|---|---|" for table rows
|
||||
|
||||
|
||||
@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(_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].text += "\n" + line
|
||||
elif line[0].islower() and units and units[-1].table_header is None:
|
||||
units[-1].text += " " + line # paragraph continued after a page break or a figure
|
||||
else:
|
||||
units.append(_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)
|
||||
return [_Unit(p, unit.page) for p in parts]
|
||||
|
||||
|
||||
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=group[-1].page,
|
||||
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)
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Lexical retrieval over the corpus: Okapi BM25 on Russian Snowball stems + synonym expansion.
|
||||
|
||||
Offline and deterministic (ADR-004): no embeddings, no network. Stop words are dropped before stemming;
|
||||
the section title is indexed `title_weight` times so a match with "3.2 Психофармакотерапия" counts more
|
||||
than a passing mention in the body.
|
||||
|
||||
Re-ranking: the best `rerank_candidates` chunks by BM25 get a bonus for their densest passage — a window
|
||||
of `window_sentences` consecutive sentences scored by the idf of query terms it contains. A chunk that
|
||||
answers in one place beats a long one that mentions the words in passing; the passage itself is returned
|
||||
with the hit and is what `answer()` quotes from.
|
||||
"""
|
||||
|
||||
import math
|
||||
import re
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
|
||||
from app.llm.rag.corpus import Audience, Chunk, RagConfig, load_chunks, load_config, split_sentences
|
||||
from app.llm.rag.stemmer import stem
|
||||
|
||||
_WORD = re.compile(r"[а-яёa-z0-9]+(?:-[а-яёa-z0-9]+)*", re.IGNORECASE)
|
||||
|
||||
# Function words and generic KR boilerplate that carry no topical signal.
|
||||
STOP_WORDS = frozenset(
|
||||
"""
|
||||
а без более бы был была были было быть в вам вас ваш весь во вот все всего всех вы где да даже для до его
|
||||
ее если есть еще же за и из или им их к как ко когда кто ли либо мне может можно мой мы на над не него нее
|
||||
нет ни них но ну о об однако он она они оно от по под при про с со так также такой там те то тоже только
|
||||
том тот у уже хотя чего чем что чтобы эта эти это этот я ли ней нем
|
||||
какой какая какое какие каков такое такая такие сколько почему зачем меня мой моя мое мои себя сегодня будет
|
||||
""".split() # noqa: SIM905 — a word list reads better as text
|
||||
)
|
||||
|
||||
|
||||
def tokens(text: str) -> list[str]:
|
||||
"""Stems of content words (hyphenated words are kept whole and also split: "когнитивно-поведенческая")."""
|
||||
out: list[str] = []
|
||||
for word in _WORD.findall(text.lower().replace("ё", "е")):
|
||||
parts = [p for p in word.split("-") if p not in STOP_WORDS and (len(p) > 1 or p.isdigit())]
|
||||
if "-" in word and len(parts) > 1:
|
||||
out.append("-".join(stem(p) for p in parts)) # the compound itself, in any case form
|
||||
out.extend(stem(p) for p in parts)
|
||||
return out
|
||||
|
||||
|
||||
EXPANSION_WEIGHT = 0.7
|
||||
SYNONYM_CREDIT = 0.8
|
||||
PREFIX_LEN = 6
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Hit:
|
||||
chunk: Chunk
|
||||
score: float # BM25 + passage bonus (ranking key)
|
||||
bm25: float
|
||||
passage: str # densest window of the chunk for the query
|
||||
|
||||
|
||||
def sentences(text: str) -> list[str]:
|
||||
"""Sentences of a chunk; every line (table row, list item) is split on its own."""
|
||||
return [s for line in text.splitlines() for s in split_sentences(line)]
|
||||
|
||||
|
||||
class SynonymExpander:
|
||||
"""Adds the stems of every phrase in a synonym group when any phrase of the group is in the query.
|
||||
|
||||
Added stems weigh `EXPANSION_WEIGHT`: they widen recall but should not outrank the patient's own words.
|
||||
"""
|
||||
|
||||
def __init__(self, groups: list[list[str]]) -> None:
|
||||
self._groups = [[tokens(phrase) for phrase in group] for group in groups]
|
||||
# stem -> stems of the other phrases of its groups ("отменя" -> {"отмен", "брос", ...})
|
||||
self.related: dict[str, set[str]] = {}
|
||||
for group in self._groups:
|
||||
stems = {t for phrase in group for t in phrase}
|
||||
for t in stems:
|
||||
self.related.setdefault(t, set()).update(stems - {t})
|
||||
|
||||
def expand(self, query_tokens: list[str]) -> dict[str, float]:
|
||||
weights: dict[str, float] = {}
|
||||
for t in query_tokens:
|
||||
weights[t] = weights.get(t, 0.0) + 1.0
|
||||
present = set(query_tokens)
|
||||
for group in self._groups:
|
||||
if any(phrase and set(phrase) <= present for phrase in group):
|
||||
for phrase in group:
|
||||
for t in phrase:
|
||||
if t not in present: # a 3-word expansion of "птср" is one concept, not three
|
||||
weights[t] = max(weights.get(t, 0.0), EXPANSION_WEIGHT / len(phrase))
|
||||
return weights
|
||||
|
||||
|
||||
class BM25Index:
|
||||
def __init__(self, chunks: tuple[Chunk, ...], cfg: RagConfig) -> None:
|
||||
r = cfg.retrieval
|
||||
self._k1, self._b, self._min_score, self._top_k = r.bm25_k1, r.bm25_b, r.min_score, r.top_k
|
||||
self._candidates, self._window, self._window_weight = r.rerank_candidates, r.window_sentences, r.window_weight
|
||||
self._section_boost = r.section_boost
|
||||
self.chunks = chunks
|
||||
self._sentences = [[(s, set(tokens(s))) for s in sentences(c.text)] for c in chunks]
|
||||
self._expander = SynonymExpander(cfg.synonyms)
|
||||
self._tf: list[Counter[str]] = []
|
||||
df: Counter[str] = Counter()
|
||||
for c in chunks:
|
||||
tf = Counter(tokens(c.text) + tokens(c.section) * r.title_weight)
|
||||
self._tf.append(tf)
|
||||
df.update(tf.keys())
|
||||
self._len = [sum(tf.values()) for tf in self._tf]
|
||||
self._avg_len = sum(self._len) / max(len(self._len), 1)
|
||||
n = len(chunks)
|
||||
self._idf = {t: math.log(1 + (n - f + 0.5) / (f + 0.5)) for t, f in df.items()}
|
||||
|
||||
def query_terms(self, query: str) -> dict[str, float]:
|
||||
"""Query stems -> weight (repeated words count more, synonym expansions less)."""
|
||||
return self._expander.expand(tokens(query))
|
||||
|
||||
def _score(self, i: int, terms: dict[str, float]) -> float:
|
||||
tf, norm = self._tf[i], self._k1 * (1 - self._b + self._b * self._len[i] / self._avg_len)
|
||||
score = 0.0
|
||||
for term, weight in terms.items():
|
||||
f = tf.get(term)
|
||||
if f:
|
||||
score += self._idf[term] * f * (self._k1 + 1) / (f + norm) * weight
|
||||
return score
|
||||
|
||||
def boost(self, chunk: Chunk, audience: Audience | None) -> float:
|
||||
"""Section multiplier for the audience (`retrieval.section_boost`), 1.0 by default."""
|
||||
boosts = self._section_boost.get(audience or "", {})
|
||||
return next((b for prefix, b in boosts.items() if chunk.section.startswith(prefix)), 1.0)
|
||||
|
||||
def overlap(self, text: str, terms: dict[str, float]) -> float:
|
||||
"""Sum of idf × weight of the query terms present in `text` (sentence relevance for `answer()`)."""
|
||||
present = set(tokens(text))
|
||||
return sum(self._idf.get(t, 0.0) * w for t, w in terms.items() if t in present)
|
||||
|
||||
def coverage(self, text: str, query: str) -> float:
|
||||
"""Share of the query's own (not synonym-expanded) idf mass found in `text`, 0..1.
|
||||
|
||||
A query word whose synonym — or a word with the same `PREFIX_LEN`-letter prefix, for stems Snowball
|
||||
leaves apart ("скрининговый"/"скрининг") — is in the text counts as `SYNONYM_CREDIT` of itself.
|
||||
A word absent from the corpus counts with twice the maximal idf — the strongest sign of an off-topic
|
||||
question: "как установить windows" is then mostly uncovered and `answer()` says there is
|
||||
no answer instead of quoting the nearest paragraph about "установить диагноз".
|
||||
"""
|
||||
own = set(tokens(query))
|
||||
if not own:
|
||||
return 0.0
|
||||
unseen = 2 * max(self._idf.values(), default=1.0)
|
||||
total = sum(self._idf.get(t, unseen) for t in own)
|
||||
present = set(tokens(text))
|
||||
prefixes = {p[:PREFIX_LEN] for p in present if len(p) >= PREFIX_LEN}
|
||||
covered = 0.0
|
||||
for t in own:
|
||||
if t in present:
|
||||
covered += self._idf[t]
|
||||
elif self._expander.related.get(t, set()) & present or (
|
||||
len(t) >= PREFIX_LEN and t[:PREFIX_LEN] in prefixes
|
||||
):
|
||||
covered += SYNONYM_CREDIT * self._idf.get(t, unseen) # said in the corpus' own words
|
||||
return min(1.0, covered / total)
|
||||
|
||||
def _passage(self, i: int, terms: dict[str, float]) -> tuple[float, str]:
|
||||
sents = self._sentences[i]
|
||||
best = (0.0, sents[0][0] if sents else "")
|
||||
for start in range(len(sents)):
|
||||
window = sents[start : start + self._window]
|
||||
present = set().union(*(toks for _, toks in window))
|
||||
score = sum(self._idf.get(t, 0.0) * w for t, w in terms.items() if t in present)
|
||||
if score > best[0]:
|
||||
best = (score, " ".join(text for text, _ in window))
|
||||
return best
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
audience: Audience | None = None,
|
||||
docs: set[str] | None = None,
|
||||
top_k: int | None = None,
|
||||
min_score: float | None = None,
|
||||
) -> list[Hit]:
|
||||
"""Best chunks for `query`, filtered by audience/doc ids; empty if nothing passes `min_score` (BM25)."""
|
||||
terms = self.query_terms(query)
|
||||
if not terms:
|
||||
return []
|
||||
threshold = self._min_score if min_score is None else min_score
|
||||
scored = [
|
||||
(s, i)
|
||||
for i, c in enumerate(self.chunks)
|
||||
if (audience is None or audience in c.doc.audiences)
|
||||
and (docs is None or c.doc.id in docs)
|
||||
and (s := self._score(i, terms)) >= threshold
|
||||
]
|
||||
scored.sort(key=lambda x: (-x[0], x[1]))
|
||||
hits = []
|
||||
for bm25, i in scored[: max(self._candidates, top_k or 0)]:
|
||||
bonus, passage = self._passage(i, terms)
|
||||
chunk = self.chunks[i]
|
||||
hits.append(Hit(chunk, (bm25 + self._window_weight * bonus) * self.boost(chunk, audience), bm25, passage))
|
||||
hits.sort(key=lambda h: -h.score) # stable: BM25 order breaks ties
|
||||
return hits[: top_k or self._top_k]
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_index() -> BM25Index:
|
||||
return BM25Index(load_chunks(), load_config())
|
||||
|
||||
|
||||
def retrieve(query: str, **kwargs) -> list[Hit]:
|
||||
"""Module-level shortcut: `retrieve("когда начинает действовать антидепрессант", audience="patient")`."""
|
||||
return get_index().search(query, **kwargs)
|
||||
@@ -0,0 +1,317 @@
|
||||
"""Grounded answers from the knowledge base: every sentence shown is quoted from a cited chunk.
|
||||
|
||||
Pipeline (deterministic except for the optional patient rephrasing):
|
||||
|
||||
1. **Dose guard (patient).** Dose questions are never answered — the dose is the doctor's decision.
|
||||
2. **Retrieve.** Top `answer.max_chunks` chunks for the audience (`team` documents never reach patients
|
||||
or doctors, see `config/rag/sources.yaml`).
|
||||
3. **Sentences.** Chunks are split into sentences; KR boilerplate is stripped (literature refs "[1, 15]").
|
||||
Doctors also get table rows as "column: value; ..." lines and evidence grades. For patients, sentences
|
||||
about prescribing/dosing or with forbidden content (`QUOTE_FILTER`) are dropped.
|
||||
4. **Passage.** In each chunk the best sentence (idf-weighted overlap with the query) is extended with
|
||||
adjacent relevant sentences into one contiguous passage — the answer reads as written in the source,
|
||||
not as a collage. The best passage, plus a second one if it scores close, is kept.
|
||||
5. **No-answer gate.** If the passages cover less than `answer.min_coverage` of the question's idf mass
|
||||
(e.g. "рецепт борща"), there is no answer -> `None`.
|
||||
6. **Rephrase (patient only).** The excerpt may be simplified via `gateway.phrase("rag_answer")`; a
|
||||
rephrasing that adds content (`med_qa.adds_content`) is rejected and the excerpt is shown verbatim.
|
||||
Doctors always get the verbatim text.
|
||||
7. **Framing.** Intro, citations "[КР ПТСР 2023, стр. 28]" and the doctor note from `config/texts/rag.yaml`
|
||||
are added outside the LLM, so they are always present.
|
||||
|
||||
Safety screening of the incoming message is the caller's job (guardrail #2): `answer()` expects text that
|
||||
already passed `app.core.safety` and `privacy.scrub()`. Logs carry only codes and counts.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core.types import ScrubbedText
|
||||
from app.llm.gateway import FORBIDDEN_OUTPUT, MAX_REPLY_CHARS, get_gateway
|
||||
from app.llm.med_qa import DOSE_QUESTION, DOSING_SENTENCE, adds_content
|
||||
from app.llm.rag.corpus import TABLE_DIVIDER, AnswerConfig, Audience, Chunk, load_config, split_sentences
|
||||
from app.llm.rag.index import BM25Index, get_index, tokens
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
PHRASE_INTENT = "rag_answer"
|
||||
|
||||
LIT_REFS = re.compile(r"\s*\[\d+(?:\s*[-–,]\s*\d+)*\s*,?\]") # "[1, 15, 16]", "[48]", "[1-3]"
|
||||
GRADE = re.compile(r"\s*Уровень убедительности рекомендаций\s+[ABCАВС]\b.*$", re.IGNORECASE)
|
||||
BULLET = re.compile(r"^\s*(?:[-–—●•▪◦*]|\d+[.)])\s+")
|
||||
COMMENT_LABEL = re.compile(r"^Комментари[йи]\s*[:.]\s*")
|
||||
# KR sentences about prescribing are for the doctor: a patient reading "рекомендуется назначение
|
||||
# пароксетина" or "дозировка титруется" out of context takes it as advice (SPEC §7: no drug/dose advice).
|
||||
PRESCRIBING = re.compile(
|
||||
r"назнач|дозиров|титр|мг\b|мг/|доз[аеуыо]\b|дозы\b|/сут|в сутки|раза? в день|разово"
|
||||
r"|\d+\s*(?:[-–]\s*\d+\s*)?таблет",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# "что такое флэшбек" -> the term; its definition "Флэшбек (flashback – англ.) – ..." is preferred.
|
||||
DEFINITION_QUESTION = re.compile(r"^\s*что (?:такое|значит|означает|это за)\s+(.+?)[?.!\s]*$", re.IGNORECASE)
|
||||
DEFINITION_HEAD = re.compile(r"\s[–—-]\s(?![^(]*\))|\s[–—-]?\s?это\s") # dash outside "(… – англ.)", or "это"
|
||||
DEFINITION_HEAD_CHARS = 60
|
||||
DEFINITION_BONUS = 2.0 # multiplier for a sentence that starts with the asked term (picks it within a chunk)
|
||||
# The gateway's output filter targets the bot's own words. Two of its patterns also hit neutral quotes from
|
||||
# the KR — the definition "ПТСР – это расстройство, которое..." and "диагноз ставится на основании..." —
|
||||
# and would cut exactly the patient-information text. Quotes are checked against the rest of the filter
|
||||
# (dose/drug imperatives, "у вас депрессия", confidentiality promises).
|
||||
QUOTE_EXEMPT_PREFIXES = ("диагноз", "(это|похоже на|признаки)")
|
||||
QUOTE_FILTER = tuple(p for p in FORBIDDEN_OUTPUT if not p.pattern.startswith(QUOTE_EXEMPT_PREFIXES))
|
||||
MIN_SENTENCE_CHARS = 25
|
||||
LEAD_IN_CHARS = 80
|
||||
# A best sentence opening with a reference back ("Это помогает...") needs the sentence it refers to.
|
||||
ANAPHORA = re.compile(
|
||||
r"^(Это|Эти|Этот|Эта|Он|Она|Оно|Они|Такие|Такой|Данн\w+|При этом|Кроме того|Однако|Поэтому)\b"
|
||||
) # a best sentence this short ("Что такое ПТСР?") pulls in the next one as its answer
|
||||
|
||||
|
||||
class Citation(BaseModel):
|
||||
doc: str # document id from sources.yaml
|
||||
short: str # "КР ПТСР 2023"
|
||||
title: str
|
||||
section: str
|
||||
pages: str # "28" | "28–29"
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return f"{self.short}, стр. {self.pages}"
|
||||
|
||||
|
||||
class RagAnswer(BaseModel):
|
||||
text: str # framed answer: intro + body + sources (+ doctor note for patients)
|
||||
excerpt: str # the quoted passages, verbatim
|
||||
citations: list[Citation]
|
||||
audience: Audience
|
||||
coverage: float # share of the question covered by the passages (0..1)
|
||||
phrased_by_llm: bool
|
||||
|
||||
|
||||
class AudienceTexts(BaseModel):
|
||||
fallback: str
|
||||
intro: str
|
||||
sources_label: str
|
||||
doctor_note: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Passage:
|
||||
chunk: Chunk
|
||||
text: str
|
||||
score: float
|
||||
definition: bool = False # starts with the term asked in "что такое X?"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def load_rag_texts(path: Path | None = None) -> dict[str, AudienceTexts]:
|
||||
path = path or get_settings().config_dir / "texts" / "rag.yaml"
|
||||
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
return {k: AudienceTexts.model_validate(v) for k, v in raw.items()}
|
||||
|
||||
|
||||
def fallback_text(audience: Audience = "patient") -> str:
|
||||
"""What the caller sends when `answer()` returns `None`."""
|
||||
return load_rag_texts()[audience].fallback
|
||||
|
||||
|
||||
def _clean(sentence: str, *, keep_grade: bool) -> str:
|
||||
s = BULLET.sub("", sentence)
|
||||
s = COMMENT_LABEL.sub("", s)
|
||||
if not keep_grade: # evidence grades matter to doctors, to a patient they are noise
|
||||
s = GRADE.sub("", s)
|
||||
s = LIT_REFS.sub("", s)
|
||||
return " ".join(s.split()).strip(" ;")
|
||||
|
||||
|
||||
def _cells(line: str) -> list[str]:
|
||||
return [c.strip() for c in line.strip().strip("|").split("|")]
|
||||
|
||||
|
||||
def _row_text(header: list[str], row: list[str]) -> str:
|
||||
pairs = [f"{h}: {v}" if h else v for h, v in zip(header, row, strict=False) if v]
|
||||
return LIT_REFS.sub("", "; ".join(pairs))
|
||||
|
||||
|
||||
def chunk_sentences(chunk: Chunk, *, clinical: bool) -> list[str | None]:
|
||||
"""Readable sentences of a chunk in reading order. `clinical` (doctor/team) keeps table rows as
|
||||
"column: value" lines and evidence grades. `None` marks a dropped row: passages do not span it."""
|
||||
out: list[str | None] = []
|
||||
lines = chunk.text.splitlines()
|
||||
header: list[str] | None = None
|
||||
for i, line in enumerate(lines):
|
||||
if line.startswith("|"):
|
||||
if TABLE_DIVIDER.match(line):
|
||||
continue
|
||||
if i + 1 < len(lines) and TABLE_DIVIDER.match(lines[i + 1]):
|
||||
header = _cells(line)
|
||||
continue
|
||||
out.append(_row_text(header or [], _cells(line)) if clinical else None)
|
||||
continue
|
||||
header = None
|
||||
out.extend(s for s in (_clean(p, keep_grade=clinical) for p in split_sentences(line)) if s)
|
||||
return out
|
||||
|
||||
|
||||
def _allowed(sentence: str | None, audience: Audience) -> bool:
|
||||
if sentence is None or len(sentence) < MIN_SENTENCE_CHARS and not sentence.endswith(("?", ":")):
|
||||
return False
|
||||
if sentence[0].islower():
|
||||
return False # tail of a paragraph cut by the chunk boundary: unreadable without its start
|
||||
if audience == "patient":
|
||||
return not (
|
||||
DOSING_SENTENCE.search(sentence)
|
||||
or PRESCRIBING.search(sentence)
|
||||
or any(p.search(sentence) for p in QUOTE_FILTER)
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _defined_term(question: str) -> set[str] | None:
|
||||
m = DEFINITION_QUESTION.match(question)
|
||||
return set(tokens(m.group(1))) or None if m else None
|
||||
|
||||
|
||||
def _is_definition(sentence: str, defined: set[str]) -> bool:
|
||||
""" "Флэшбек (flashback – англ.) – возврат..." / "ПТСР – это расстройство...": the term, then a dash."""
|
||||
head = DEFINITION_HEAD.split(sentence, maxsplit=1)
|
||||
return len(head) == 2 and len(head[0]) <= DEFINITION_HEAD_CHARS and bool(set(tokens(head[0])) & defined)
|
||||
|
||||
|
||||
def _sentence_score(index: BM25Index, sentence: str, terms: dict[str, float], defined: set[str] | None) -> float:
|
||||
score = index.overlap(sentence, terms)
|
||||
if defined and score > 0 and _is_definition(sentence, defined):
|
||||
score *= DEFINITION_BONUS
|
||||
return score
|
||||
|
||||
|
||||
def best_passage(
|
||||
index: BM25Index,
|
||||
chunk: Chunk,
|
||||
terms: dict[str, float],
|
||||
audience: Audience,
|
||||
cfg: AnswerConfig,
|
||||
defined: set[str] | None = None,
|
||||
) -> Passage | None:
|
||||
"""Best sentence of the chunk extended by adjacent relevant sentences (contiguous, within limits)."""
|
||||
sents = [s if _allowed(s, audience) else None for s in chunk_sentences(chunk, clinical=audience != "patient")]
|
||||
scores = [_sentence_score(index, s, terms, defined) if s else 0.0 for s in sents]
|
||||
if not scores or max(scores) <= 0:
|
||||
return None
|
||||
best = max(range(len(sents)), key=lambda i: (scores[i], -i))
|
||||
head = sents[best]
|
||||
assert head is not None # score > 0 implies an allowed sentence
|
||||
lo = hi = best
|
||||
size = len(head)
|
||||
|
||||
def fits(j: int) -> bool:
|
||||
if not 0 <= j < len(sents):
|
||||
return False
|
||||
s = sents[j]
|
||||
return s is not None and size + len(s) + 1 <= cfg.max_chars
|
||||
|
||||
# A short lead-in (question-like heading, "Комментарии:"-style intro) needs the sentence after it.
|
||||
def length(j: int) -> int:
|
||||
return len(sents[j] or "") + 1
|
||||
|
||||
if (size < LEAD_IN_CHARS or head.endswith(("?", ":"))) and fits(best + 1):
|
||||
hi += 1
|
||||
size += length(hi)
|
||||
if ANAPHORA.match(head) and fits(best - 1):
|
||||
lo -= 1
|
||||
size += length(lo)
|
||||
floor = cfg.neighbor_ratio * scores[best]
|
||||
while hi - lo + 1 < cfg.max_sentences:
|
||||
nxt = next((j for j in (hi + 1, lo - 1) if fits(j) and scores[j] > 0 and scores[j] >= floor), None)
|
||||
if nxt is None:
|
||||
break
|
||||
lo, hi = min(lo, nxt), max(hi, nxt)
|
||||
size += length(nxt)
|
||||
text = " ".join(s for s in sents[lo : hi + 1] if s)
|
||||
definition = scores[best] > index.overlap(head, terms) # got DEFINITION_BONUS
|
||||
return Passage(chunk, text, index.overlap(text, terms) * index.boost(chunk, audience), definition)
|
||||
|
||||
|
||||
def select(index: BM25Index, question: str, audience: Audience, docs: set[str] | None = None) -> list[Passage]:
|
||||
"""Best passage, plus a second one from another chunk if it scores within `second_passage_ratio`."""
|
||||
cfg = load_config().answer
|
||||
hits = index.search(question, audience=audience, docs=docs, top_k=cfg.max_chunks)
|
||||
terms = index.query_terms(question)
|
||||
defined = _defined_term(question)
|
||||
passages = [p for h in hits if (p := best_passage(index, h.chunk, terms, audience, cfg, defined))]
|
||||
if not passages:
|
||||
return []
|
||||
# A definition answers "что такое X?" better than any passage that merely mentions X often.
|
||||
passages.sort(key=lambda p: (not p.definition, -p.score)) # stable: retrieval rank breaks ties
|
||||
top = passages[0]
|
||||
chosen = [top]
|
||||
for p in passages[1:]:
|
||||
if len(chosen) >= cfg.max_passages:
|
||||
break
|
||||
if p.score >= cfg.second_passage_ratio * top.score and p.text != top.text:
|
||||
chosen.append(p)
|
||||
return chosen
|
||||
|
||||
|
||||
def _citation(chunk: Chunk) -> Citation:
|
||||
return Citation(
|
||||
doc=chunk.doc.id, short=chunk.doc.short, title=chunk.doc.title, section=chunk.section, pages=chunk.pages
|
||||
)
|
||||
|
||||
|
||||
async def _rephrase(excerpt: str, question: str) -> tuple[str, bool]:
|
||||
if len(excerpt) > MAX_REPLY_CHARS or any(p.search(excerpt) for p in FORBIDDEN_OUTPUT):
|
||||
return excerpt, False # the gateway would reject any reply this long / with these words anyway
|
||||
text = await get_gateway().phrase(PHRASE_INTENT, {"excerpt": excerpt, "question": question})
|
||||
if " ".join(text.split()) == " ".join(excerpt.split()):
|
||||
return excerpt, False
|
||||
if adds_content(text, excerpt, question):
|
||||
log.warning("rag rephrasing rejected: adds content")
|
||||
return excerpt, False
|
||||
return text, True
|
||||
|
||||
|
||||
async def answer(
|
||||
question: ScrubbedText,
|
||||
*,
|
||||
audience: Audience = "patient",
|
||||
docs: set[str] | None = None,
|
||||
index: BM25Index | None = None,
|
||||
) -> RagAnswer | None:
|
||||
"""Answer strictly from the knowledge base, with citations; `None` -> caller sends `fallback_text()`."""
|
||||
if audience == "patient" and DOSE_QUESTION.search(question.lower()):
|
||||
log.info("rag: dose question redirected to doctor")
|
||||
return None
|
||||
index = index or get_index()
|
||||
passages = select(index, question, audience, docs)
|
||||
if not passages:
|
||||
log.info("rag: no relevant fragment audience=%s", audience)
|
||||
return None
|
||||
coverage = index.coverage(" ".join(p.text for p in passages), question)
|
||||
if coverage < load_config().answer.min_coverage:
|
||||
log.info("rag: low coverage audience=%s coverage=%.2f", audience, coverage)
|
||||
return None
|
||||
citations = [_citation(p.chunk) for p in passages]
|
||||
texts = load_rag_texts()[audience]
|
||||
|
||||
if audience == "patient":
|
||||
excerpt = " ".join(p.text for p in passages)
|
||||
body, by_llm = await _rephrase(excerpt, question)
|
||||
sources = "; ".join(dict.fromkeys(c.label for c in citations))
|
||||
text = f"{texts.intro} {body}\n\n{texts.sources_label}: {sources}."
|
||||
else: # doctor/team: verbatim quotes, each with its own citation
|
||||
excerpt = "\n".join(f"{p.text} [{c.label}]" for p, c in zip(passages, citations, strict=True))
|
||||
by_llm = False
|
||||
text = f"{texts.intro}\n{excerpt}"
|
||||
if texts.doctor_note:
|
||||
text += f"\n\n{texts.doctor_note}"
|
||||
log.info("rag: answered audience=%s passages=%d coverage=%.2f llm=%s", audience, len(passages), coverage, by_llm)
|
||||
return RagAnswer(
|
||||
text=text, excerpt=excerpt, citations=citations, audience=audience, coverage=coverage, phrased_by_llm=by_llm
|
||||
)
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Russian Snowball stemmer (https://snowballstem.org/algorithms/russian/stemmer.html).
|
||||
|
||||
Self-contained because `pyproject.toml` is a frozen contract (#34): no new dependencies for the RAG.
|
||||
Latin words and digits pass through lowercased; "ё" is folded to "е".
|
||||
"""
|
||||
|
||||
import re
|
||||
from functools import lru_cache
|
||||
|
||||
_VOWELS = set("аеиоуыэюя")
|
||||
|
||||
# (suffixes, needs_a_ya): group-1 suffixes count only when preceded by "а"/"я" (which stays in the stem).
|
||||
_PERFECTIVE_GERUND = ((("вшись", "вши", "в"), True), (("ившись", "ывшись", "ивши", "ывши", "ив", "ыв"), False))
|
||||
_REFLEXIVE = ((("ся", "сь"), False),)
|
||||
_ADJECTIVE = (
|
||||
(
|
||||
(
|
||||
"ими", "ыми", "его", "ого", "ему", "ому", "ее", "ие", "ые", "ое", "ей", "ий", "ый", "ой", "ем",
|
||||
"им", "ым", "ом", "их", "ых", "ую", "юю", "ая", "яя", "ою", "ею",
|
||||
),
|
||||
False,
|
||||
),
|
||||
) # fmt: skip
|
||||
_PARTICIPLE = ((("ем", "нн", "вш", "ющ", "щ"), True), (("ивш", "ывш", "ующ"), False))
|
||||
_VERB = (
|
||||
(("ла", "на", "ете", "йте", "ли", "й", "л", "ем", "н", "ло", "но", "ет", "ют", "ны", "ть", "ешь", "нно"), True),
|
||||
(
|
||||
(
|
||||
"ила", "ыла", "ена", "ейте", "уйте", "ите", "или", "ыли", "ей", "уй", "ил", "ыл", "им", "ым", "ен",
|
||||
"ило", "ыло", "ено", "ят", "ует", "уют", "ит", "ыт", "ены", "ить", "ыть", "ишь", "ую", "ю",
|
||||
),
|
||||
False,
|
||||
),
|
||||
) # fmt: skip
|
||||
_NOUN = (
|
||||
(
|
||||
(
|
||||
"иями", "ями", "ами", "ией", "иям", "ием", "иях", "ев", "ов", "ие", "ье", "еи", "ии", "ей", "ой",
|
||||
"ий", "ям", "ем", "ам", "ом", "ах", "ях", "ию", "ью", "ия", "ья", "а", "е", "и", "й", "о", "у",
|
||||
"ы", "ь", "ю", "я",
|
||||
),
|
||||
False,
|
||||
),
|
||||
) # fmt: skip
|
||||
_DERIVATIONAL = ((("ость", "ост"), False),)
|
||||
_SUPERLATIVE = ((("ейше", "ейш"), False),)
|
||||
|
||||
_CYRILLIC_WORD = re.compile(r"^[а-я]+$")
|
||||
|
||||
|
||||
def _strip(word: str, start: int, groups: tuple[tuple[tuple[str, ...], bool], ...]) -> str | None:
|
||||
"""Remove the longest matching suffix lying in word[start:], or `None` if none matches."""
|
||||
best: tuple[int, str] | None = None
|
||||
for suffixes, needs_a_ya in groups:
|
||||
for suffix in suffixes:
|
||||
if not word.endswith(suffix) or len(word) - len(suffix) < start:
|
||||
continue
|
||||
if needs_a_ya and (len(word) - len(suffix) - 1 < start or word[-len(suffix) - 1] not in "ая"):
|
||||
continue
|
||||
if best is None or len(suffix) > best[0]:
|
||||
best = (len(suffix), word[: -len(suffix)])
|
||||
return None if best is None else best[1]
|
||||
|
||||
|
||||
def _regions(word: str) -> tuple[int, int]:
|
||||
"""RV (after the first vowel) and R2 start indices."""
|
||||
rv = next((i + 1 for i, ch in enumerate(word) if ch in _VOWELS), len(word))
|
||||
|
||||
def after_vc(start: int) -> int:
|
||||
for i in range(start + 1, len(word)):
|
||||
if word[i] not in _VOWELS and word[i - 1] in _VOWELS:
|
||||
return i + 1
|
||||
return len(word)
|
||||
|
||||
return rv, after_vc(after_vc(0))
|
||||
|
||||
|
||||
@lru_cache(maxsize=65536)
|
||||
def stem(word: str) -> str:
|
||||
word = word.lower().replace("ё", "е")
|
||||
if not _CYRILLIC_WORD.match(word):
|
||||
return word
|
||||
rv, r2 = _regions(word)
|
||||
|
||||
# Step 1
|
||||
out = _strip(word, rv, _PERFECTIVE_GERUND)
|
||||
if out is None:
|
||||
word = _strip(word, rv, _REFLEXIVE) or word
|
||||
out = _strip(word, rv, _ADJECTIVE)
|
||||
if out is not None:
|
||||
out = _strip(out, rv, _PARTICIPLE) or out
|
||||
else:
|
||||
out = _strip(word, rv, _VERB)
|
||||
if out is None:
|
||||
out = _strip(word, rv, _NOUN)
|
||||
word = out if out is not None else word
|
||||
|
||||
# Step 2
|
||||
if word.endswith("и") and len(word) - 1 >= rv:
|
||||
word = word[:-1]
|
||||
# Step 3
|
||||
word = _strip(word, r2, _DERIVATIONAL) or word
|
||||
# Step 4
|
||||
if word.endswith("нн") and len(word) - 1 >= rv:
|
||||
return word[:-1]
|
||||
sup = _strip(word, rv, _SUPERLATIVE)
|
||||
if sup is not None:
|
||||
word = sup
|
||||
return word[:-1] if word.endswith("нн") else word
|
||||
if word.endswith("ь") and len(word) - 1 >= rv:
|
||||
word = word[:-1]
|
||||
return word
|
||||
@@ -7,3 +7,4 @@
|
||||
- `modules.yaml` — модули и расписания шкал
|
||||
- `lexicon/risk_ru.yaml` — лексикон риска
|
||||
- `texts/` — тексты для пациента (кризисный скрипт, согласие, перефразировки)
|
||||
- `rag/` — база знаний (ADR-011): `sources.yaml` (документы, параметры поиска, синонимы) и `corpus/*.txt`, собранные из PDF в `docs/source/rag/`
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,184 @@
|
||||
# Модуль «Лекарства»: спецификация для разработки
|
||||
|
||||
Источник: meds_module_spec.pdf, страниц: 10. Документ команды от Oct 4, 2026, автор @Andrey. Текст перенесён вручную и сверен с PDF: автоматическое извлечение ломает вёрстку выносок и таблиц. Символ «|» внутри ячеек заменён на «/».
|
||||
|
||||
--- стр. 1 ---
|
||||
|
||||
Модуль следит не за абстрактными «лекарствами», а за конкретным препаратом пациента. Он напоминает о приёме, узнаёт причины пропусков, спрашивает о побочных эффектах этого препарата и работает по 10 правилам (M1–M10). Данные взяты из двух клинических рекомендаций Минздрава: по депрессии (2024) и по ПТСР (2023).
|
||||
|
||||
Что передаём:
|
||||
- этот документ — логика модуля;
|
||||
- файл meds_reference.json (прилагается к ссылке) — справочник на 47 препаратов, тексты для пациента и параметры правил;
|
||||
- вкладка «Справочник препаратов» — тот же справочник в виде таблицы, для проверки медиком.
|
||||
|
||||
Как модуль встраивается в этап 2. Вечерний вопрос о лекарствах, опрос о побочных эффектах на 7-й, 14-й и 28-й день и флаги «меньше 80% дней» и «пропуск 3 дня подряд» не меняются. Модуль добавляет к ним данные о препарате и новые правила.
|
||||
|
||||
| В MVP | После хакатона |
|
||||
|---|---|
|
||||
| Справочник из JSON | Тексты инструкций из ГРЛС в карточке препарата |
|
||||
| Назначение: ввод в кабинете врача или из имитации ЕМИАС | Назначения из ЕМИАС (электронные рецепты) |
|
||||
| Напоминание о приёме и отметка в дневнике | Схема постепенной отмены препарата |
|
||||
| Опрос о побочных эффектах по профилю препарата | Ввод результатов анализов |
|
||||
| Правила M1–M10 и блок в сводке врачу | Проверка взаимодействий для врача |
|
||||
|
||||
--- стр. 2 ---
|
||||
|
||||
Границы: бот следит, но не лечит
|
||||
|
||||
Препарат и дозу всегда выбирает врач. Если приложение начнёт давать советы о лечении, его могут счесть медицинским изделием, а к ним требования строже.
|
||||
|
||||
| Бот делает | Бот не делает никогда |
|
||||
|---|---|
|
||||
| Хранит назначение врача: препарат, дозу, время приёма | Не предлагает препарат, дозу, смену или отмену |
|
||||
| Напоминает о приёме и отмечает, принят ли препарат | На вопросы «можно ли бросить» или «можно ли увеличить дозу» отвечает только «обсудите с врачом» и ставит флаг |
|
||||
| Спрашивает о побочных эффектах, характерных для этого препарата | Не исправляет и не блокирует дозу вне диапазона — только просит проверить ввод (M10) |
|
||||
| Показывает карточку препарата из проверенных источников | Не проверяет для пациента взаимодействия лекарств |
|
||||
| Сигналит врачу по правилам M1–M10 | Не ставит диагноз и не оценивает, «работает ли» лечение, — это делает врач по шкалам |
|
||||
|
||||
Модель данных
|
||||
|
||||
Шесть сущностей. Drug читается из JSON и в приложении не редактируется, остальные создаёт приложение.
|
||||
|
||||
| Сущность | Поля | Откуда берётся |
|
||||
|---|---|---|
|
||||
| Drug | id, name_ru, class, indications.{depression/ptsd}.{min_mg, max_mg, start_mg, off_label}, diary_items[], red_flags[], monitoring[], withdrawal_risk, overdose_danger, max_course_days | meds_reference.json |
|
||||
|
||||
--- стр. 3 ---
|
||||
|
||||
| Сущность | Поля | Откуда берётся |
|
||||
|---|---|---|
|
||||
| Prescription | id, patient_id, drug_id, indication (F32/F33/F43.1), dose_mg_per_day, times[] (например, "09:00"), start_date, source (emias / doctor / patient), status (active / stopped), history[] (дата, старая и новая доза) | имитация ЕМИАС или кабинет врача; ввод пациентом — с пометкой «со слов пациента» |
|
||||
| DoseLog | prescription_id, date, taken (bool), reason (forgot / side_effects / decided_not_to / ran_out), logged_at | вечерний дневник |
|
||||
| SideEffectReport | date, trigger (scheduled / missed_dose / button), fibser.{freq, intensity, burden}, items[]: {item_id, severity: mild/severe}, red_flags[] | опрос о побочных эффектах |
|
||||
| MedEvent | prescription_id, type (start / dose_change / stop), date, baseline_scale_ids[] | создаётся при изменении Prescription |
|
||||
| MedFlag | rule_id (M1…M10), patient_id, prescription_id, created_at, recipient, due_at, status (open / acknowledged), evidence (ответы, по которым сработало правило) | движок правил |
|
||||
|
||||
Связь с расписанием этапа 2. Любой MedEvent включает строку «Начало или смена лекарств»: полные шкалы и опрос о побочных эффектах на 7-й, 14-й, 28-й день, далее раз в 4 недели. Исходный замер для M5 — последняя полная шкала за 14 дней до события. Если её нет, шкалу проводят в день события.
|
||||
|
||||
Дневник и опросы
|
||||
|
||||
В обычный день добавляется только отметка по каждому препарату. Подробно спрашиваем по расписанию или после пропуска.
|
||||
|
||||
--- стр. 4 ---
|
||||
|
||||
1. Напоминание. Push-уведомление во время из Prescription.times: «Время принять {препарат} {доза}» и кнопка «Принял(а)». Нажатие сразу создаёт DoseLog с taken = true, и вечером этот препарат уже не спрашиваем.
|
||||
|
||||
2. Вечерний вопрос (уже есть в ядре этапа 2), теперь по каждому препарату:
|
||||
1. «Приняли ли Вы сегодня лекарства, назначенные врачом?» — да, все / не все / нет.
|
||||
2. Если «не все» — выбрать, какие не приняты.
|
||||
3. По каждому непринятому — причина: забыл(а) · побочные эффекты · решил(а) не принимать · закончились.
|
||||
Причина напрямую запускает правила: «побочные эффекты» → внеплановый опрос (п. 3) и M8, «решил(а) не принимать» → M2, «закончились» → M3.
|
||||
|
||||
3. Опрос о побочных эффектах. Проводится в трёх случаях: по расписанию этапа 2 (7-й, 14-й, 28-й день, далее раз в 4 недели), после пропуска «из-за побочных эффектов» и по кнопке «Сообщить о побочном эффекте», которая доступна всегда.
|
||||
1. Три вопроса FIBSER: как часто, насколько сильно, насколько мешают жить (из этапа 2).
|
||||
2. Если частота > 0 — «Что именно беспокоит?»: список из diary_items всех активных препаратов без повторов, плюс «другое» с текстом. У каждого пункта два ответа: немного / сильно мешает.
|
||||
3. Вопросы о срочных симптомах — только из red_flags активных препаратов, с ответом да / нет. Ответ «да» → M7.
|
||||
Список пунктов для препарата формируется так. Для 16 антидепрессантов берём эффекты с оценкой «++» и «+++» из таблицы 3 КР по депрессии. Для остальных препаратов — эффекты, которые названы в тексте КР. В JSON список уже собран: например, у сертралина это тошнота, бессонница, сексуальные проблемы и головная боль.
|
||||
|
||||
4. Карточки о сроках эффекта. Текст expect_effect_depression показываем в 1-й и 14-й день после начала, expect_effect_ptsd — в 1-й и 7-й. Тексты лежат в JSON, в разделе texts.
|
||||
|
||||
--- стр. 5 ---
|
||||
|
||||
Контрольные точки после начала лечения
|
||||
|
||||
[Изображение: текст перенесён вручную]
|
||||
Схема «Ответ оценивают к 4–6 неделе, лечение длится месяцы». Подзаголовок: «Фазы после начала или смены препарата: что делает модуль и что говорят КР». Таблица по фазам:
|
||||
|
||||
| Строка | Недели 1–2 | Недели 3–6 | Месяцы и годы |
|
||||
|---|---|---|---|
|
||||
| Модуль (что делает бот) | Дни 7 и 14: шкалы и опрос о побочных. Карточка «когда ждать эффекта» | День 28: шкалы, побочные и проверка ответа — M5. Бензодиазепин дольше допустимого — M4 | Шкалы и опрос о побочных раз в 4 недели до конца курса |
|
||||
| Депрессия (КР 2024) | Раннее улучшение (1–2 недели) — благоприятный признак | Эффект через 3–4 недели, у СИОЗС — до 6–8 недель; снижение на 25–50% — ответ недостаточен | Тот же препарат и доза 4–6 мес. после улучшения; при РДР — 3–5 лет |
|
||||
| ПТСР (КР 2023) | СИОЗС: в 1-ю неделю тревога может усилиться; следить за суицидальным риском | Эффект оценивают через 4–6 недель; бензодиазепины — не дольше 3–4 недель | Курс 6–12 месяцев после стабилизации |
|
||||
|
||||
Подпись под схемой: «Фазы после начала лечения · модуль, депрессия, ПТСР».
|
||||
|
||||
M5 проверяет ответ на 28-й день при обоих диагнозах. Для ПТСР это ранняя граница окна из КР: окончательно эффект оценивают до 6-й недели, поэтому флаг — повод для врача посмотреть, а не сигнал тревоги.
|
||||
|
||||
Правила сигналов M1–M10
|
||||
|
||||
К флагам этапа 2 по лекарствам (меньше 80% дней с приёмом, пропуск 3 дня подряд, тяжёлый день алкоголя при приёме лекарств) добавляются десять правил. Пороги и сроки хранятся в rules[].params в JSON и меняются без правки кода. Строки с пометкой «черновик» нужно согласовать с психиатром.
|
||||
|
||||
--- стр. 6 ---
|
||||
|
||||
| ID | Условие | Что делает приложение | Кто получает, срок | Основание |
|
||||
|---|---|---|---|---|
|
||||
| M1 | У препарата withdrawal_risk = high (венлафаксин, пароксетин, ТЦА, бензодиазепины) и он пропущен 2 дня подряд | Текст withdrawal_missed и вопрос «Стало ли хуже после пропуска?» | Лечащий психиатр: ответ «хуже» — 24 ч, иначе — до 3 рабочих дней. Черновик | КР депрессия 3.6; КР ПТСР 3.2 |
|
||||
| M2 | Причина пропуска «решил(а) не принимать» | Тексты dont_stop_alone (+ why_continue_depression при F32/F33), флаг | Лечащий психиатр, до 3 рабочих дней. Черновик | КР депрессия, прил. В; КР ПТСР 6.1 |
|
||||
| M3 | Причина «закончились» | Текст ran_out; 2 дня подряд → флаг | Лечащий врач, до 3 рабочих дней. Черновик | команда |
|
||||
| M4 | Бензодиазепин принимается дольше max_course_days | Отметка в кабинете и сводке | Лечащий психиатр, к следующему визиту | КР ПТСР 3.2 |
|
||||
| M5 | 28-й день после MedEvent: PHQ-9 снизился меньше чем на 50% (F32/F33) или PCL-5 — меньше чем на 10 баллов (F43.1) от исходного | Флаг «нет ответа к 4-й неделе» | Лечащий психиатр, до 3 рабочих дней. Черновик | КР депрессия 3.1–3.2; КР ПТСР 3.2; пороги ответа — этап 2 |
|
||||
|
||||
--- стр. 7 ---
|
||||
|
||||
| ID | Условие | Что делает приложение | Кто получает, срок | Основание |
|
||||
|---|---|---|---|---|
|
||||
| M6 | Сработал сигнал C-SSRS (умеренный или высокий риск), а у пациента активен препарат с overdose_danger | Пометка «препарат опасен при передозировке» в этом же сигнале, новый сигнал не создаётся | Как у сигнала C-SSRS | КР депрессия 3.1.3 |
|
||||
| M7 | Пациент отметил срочный симптом из red_flags | Текст red_flag (врач сегодня; при тяжёлом состоянии 103/112), флаг | Лечащий психиатр, 24 ч. Черновик | КР депрессия 3.1, 3.3; КР ПТСР 3.2 |
|
||||
| M8 | FIBSER «насколько мешают» ≥ 5 из 6 или пропуск «из-за побочных» в 2 из 7 дней | Текст side_effects_general, флаг | Лечащий психиатр, до 3 рабочих дней. Черновик | КР депрессия, прил. В |
|
||||
| M9 | У активного препарата есть monitoring | Строка «Контроль по КР: …» в сводке врачу, это не сигнал | Лечащий врач, в сводке | КР депрессия 3.1, 3.3; КР ПТСР 3.2 |
|
||||
| M10 | При сохранении назначения доза вне min_mg – max_mg для этого диагноза | «Проверьте ввод» тому, кто вводит; пометка в сводке; сохранение не блокируется | Тот, кто вводит, сразу | Приложения А3 обеих КР |
|
||||
|
||||
Все флаги попадают в кабинет врача с датой, правилом и ответами, по которым правило сработало, как в этапе 2.
|
||||
|
||||
Карточка препарата, сводка врачу, демо
|
||||
|
||||
Карточка препарата построена по списку «что сообщить пациенту» из КР по депрессии (приложение В). Поля с источником «инструкция» в MVP показывают «Уточните у врача или в инструкции». Для 2–3 демо-препаратов их можно заполнить вручную.
|
||||
|
||||
--- стр. 8 ---
|
||||
|
||||
| Поле | Источник | Пример: сертралин при ПТСР |
|
||||
|---|---|---|
|
||||
| Название, доза, время приёма | Prescription | Сертралин, 50 мг в сутки, в 09:00 |
|
||||
| Когда ждать эффекта | indications.*.expected_response | Эффект оценивают через 4–6 недель |
|
||||
| Сколько принимать | indications.*.course + решение врача | Обычно 6–12 месяцев после стабилизации |
|
||||
| Частые побочные эффекты | diary_items | Тошнота, бессонница, сексуальные проблемы, головная боль |
|
||||
| Когда срочно к врачу | red_flags | Необычные кровотечения или синяки |
|
||||
| Не прекращать самостоятельно | texts.dont_stop_alone | общий текст |
|
||||
| Как принимать, связь с едой, что делать при пропуске | инструкция (ГРЛС) | — |
|
||||
| Алкоголь, вождение, беременность | инструкция (ГРЛС) | — |
|
||||
|
||||
Блок «Лекарства» в сводке врачу (дополняет сводку этапа 2), по каждому препарату:
|
||||
- препарат, доза, дата начала и неделя приёма рядом с ожидаемым сроком ответа (например, «неделя 3 из 4–6»);
|
||||
- доля дней с приёмом за 4 недели и причины пропусков;
|
||||
- побочные эффекты: FIBSER и пункты «что именно» по замерам, срочные симптомы;
|
||||
- ответ на 28-й день (M5), контроль по КР (M9), пометки M4 и M10.
|
||||
|
||||
Демо: ветеран Иван, ПТСР. Этот сценарий стыкуется с демо этапа 2: там ухудшение на 3-й неделе, здесь видна его причина.
|
||||
|
||||
--- стр. 9 ---
|
||||
|
||||
1. День 0. Врач в кабинете назначает сертралин 50 мг в сутки (препарат 1-й линии по КР ПТСР). Включается расписание «Начало или смена лекарств», пациент получает карточку expect_effect_ptsd.
|
||||
2. День 3. Отметка «не принял — побочные эффекты». Внеплановый опрос: тошнота «немного», бессонница «сильно мешает».
|
||||
3. День 7. Плановые шкалы и опрос о побочных эффектах.
|
||||
4. Дни 17–19. Три дня подряд «решил не принимать». На 17-й день срабатывает M2 (карточка и флаг врачу), на 19-й — флаг этапа 2 «пропуск 3 дня».
|
||||
5. Неделя 3. Ухудшение по дневнику → внеплановый PCL-5 → повторный триаж (сценарий этапа 2). В сводке врач видит, что ухудшение совпало с прекращением приёма из-за бессонницы.
|
||||
|
||||
Для питча: КР по ПТСР (раздел 3.2) прямо рекомендуют для соблюдения режима «ведение дневника приёма лекарств или телефонное приложение с напоминанием». В разделе 6.1 сказано, что пациенты часто бросают лекарства после улучшения и состояние ухудшается.
|
||||
|
||||
Черновые параметры — согласовать с психиатрами
|
||||
|
||||
Код можно писать сейчас: все числа ниже лежат в JSON и заменяются без правки логики. Вопросы к врачам по этапу 2 собраны в документе «Этап 2: вопросы к психиатрам».
|
||||
|
||||
| Параметр | Черновое значение | Основание |
|
||||
|---|---|---|
|
||||
| M1: сколько дней пропуска препарата с риском отмены | 2 дня подряд | риск — КР; число — команда |
|
||||
| M4: предел курса бензодиазепинов | 21 день; диазепам 28; алпразолам 90 | КР ПТСР 3.2 |
|
||||
| M5: день проверки ответа | 28-й день для обоих диагнозов | КР: депрессия 3–4 недели, ПТСР 4–6 недель |
|
||||
| M5: исходный замер | последняя полная шкала за 14 дней до старта, иначе в день старта | команда |
|
||||
| M8: «сильно мешают» | FIBSER-бремя ≥ 5 из 6 или 2 пропуска из-за побочных за 7 дней | порог FIBSER сверить с оригиналом шкалы |
|
||||
|
||||
--- стр. 10 ---
|
||||
|
||||
| Параметр | Черновое значение | Основание |
|
||||
|---|---|---|
|
||||
| M7: список срочных симптомов и текст пациенту | 5 симптомов из JSON, текст red_flag | симптомы — КР; текст — команда |
|
||||
| Сроки реакции | 24 ч / до 3 рабочих дней / к визиту | как в этапе 2 |
|
||||
| Ввод назначения пациентом | разрешён, с пометкой «со слов пациента» | команда |
|
||||
| Все тексты для пациента из texts | черновики | вычитать врачу |
|
||||
|
||||
Источники
|
||||
- Клинические рекомендации «Депрессивный эпизод, рекуррентное депрессивное расстройство», Минздрав РФ, 2024, Российское общество психиатров. Использованы разделы 3.1–3.6 и приложения А3 (табл. 2 — дозы, табл. 3 — побочные эффекты), Б5 (синдром отмены), В (информация для пациента).
|
||||
- Клинические рекомендации «Посттравматическое стрессовое расстройство», Минздрав РФ, 2023, ID 753, Российское общество психиатров. Использованы разделы 3.2 и 6.1, приложения А3 (дозы), Б1 (алгоритм терапии), В. Срок пересмотра — не позднее 2025 года: проверить, не вышла ли новая версия.
|
||||
- «Этап 2: наблюдение после диагноза» — наш документ о расписании, флагах и сроках реакции.
|
||||
|
||||
Расхождения внутри КР отмечены в JSON в поле note. Пример: хлорпротиксен в тексте КР по ПТСР — 15–100 мг, а в таблице — 25–150 мг.
|
||||
@@ -0,0 +1,102 @@
|
||||
# База знаний RAG (#60): документы, параметры поиска, синонимы.
|
||||
# Тексты в corpus/ собраны из PDF конвертером docs/source/rag/pdf_to_txt.py — правьте PDF/figures/manual
|
||||
# и перегенерируйте, а не редактируйте txt руками.
|
||||
#
|
||||
# audiences — кому можно показывать выдержки:
|
||||
# doctor — врач (дашборд, подсказки по КР);
|
||||
# patient — пациент (только экстрактивно, без дозировок, с припиской «решение за врачом»);
|
||||
# team — внутренние документы команды (логика модулей), пациенту и врачу не показываются.
|
||||
|
||||
documents:
|
||||
- id: kr_ptsd
|
||||
file: corpus/kr_ptsd.txt
|
||||
title: "Клинические рекомендации «Посттравматическое стрессовое расстройство»"
|
||||
short: "КР ПТСР 2023"
|
||||
issuer: "Минздрав РФ, Российское общество психиатров"
|
||||
year: 2023
|
||||
icd: [F43.1]
|
||||
audiences: [doctor, patient]
|
||||
- id: kr_depression
|
||||
file: corpus/kr_depression.txt
|
||||
title: "Клинические рекомендации «Депрессивный эпизод, рекуррентное депрессивное расстройство»"
|
||||
short: "КР Депрессия 2024"
|
||||
issuer: "Минздрав РФ, Российское общество психиатров"
|
||||
year: 2024
|
||||
icd: [F32, F33]
|
||||
audiences: [doctor, patient]
|
||||
- id: meds_module_spec
|
||||
file: corpus/meds_module_spec.txt
|
||||
title: "Модуль «Лекарства»: спецификация для разработки"
|
||||
short: "Спецификация модуля «Лекарства»"
|
||||
issuer: "команда MedITron"
|
||||
year: 2026
|
||||
icd: []
|
||||
audiences: [team]
|
||||
|
||||
chunking:
|
||||
max_chars: 1200 # целевой размер фрагмента; абзац длиннее режется по предложениям
|
||||
min_chars: 200 # короткий хвост раздела приклеивается к предыдущему фрагменту того же раздела
|
||||
# Разделы, которые не индексируются (шум для поиска). Сравнение — по началу заголовка.
|
||||
skip_sections:
|
||||
- "Список литературы"
|
||||
- "Список сокращений" # аббревиатуры раскрываются через synonyms ниже, сам список только засоряет поиск
|
||||
|
||||
retrieval:
|
||||
top_k: 5
|
||||
bm25_k1: 1.5
|
||||
bm25_b: 0.75
|
||||
title_weight: 2 # заголовок раздела повторяется в индексе столько раз — совпадение с ним весомее
|
||||
min_score: 3.0 # ниже — считаем, что в базе нет ответа
|
||||
rerank_candidates: 30 # сколько лучших по BM25 фрагментов переранжировать по самому плотному отрывку
|
||||
window_sentences: 2 # размер отрывка (предложений подряд)
|
||||
window_weight: 0.5 # вес оценки отрывка относительно BM25
|
||||
# Множитель оценки для разделов (по началу заголовка) в зависимости от аудитории.
|
||||
# «Приложение В» в обеих КР — «Информация для пациента»: написано для пациента, ему и показываем в первую очередь.
|
||||
# «Приложение Г» — бланки шкал и история их валидации, «Критерии оценки качества» — для организаторов
|
||||
# здравоохранения: пациенту это шум.
|
||||
section_boost:
|
||||
patient:
|
||||
"Приложение В": 1.5
|
||||
"Приложение Г": 0.5
|
||||
"Критерии оценки качества": 0.5
|
||||
|
||||
answer:
|
||||
max_chunks: 5 # в скольких лучших фрагментах ищем отрывок
|
||||
max_passages: 2 # отрывков в ответе (второй — только если почти так же хорош)
|
||||
second_passage_ratio: 0.8
|
||||
max_sentences: 3 # предложений в отрывке (подряд, как в источнике)
|
||||
neighbor_ratio: 0.5 # соседнее предложение входит в отрывок, если его релевантность >= доли лучшего
|
||||
max_chars: 600
|
||||
min_coverage: 0.5 # доля «веса» слов вопроса, найденная в отрывках; ниже — «ответа нет»
|
||||
|
||||
# Синонимы и аббревиатуры: запрос расширяется всеми формами группы (сравнение — по основам слов).
|
||||
synonyms:
|
||||
- ["птср", "посттравматическое стрессовое расстройство"]
|
||||
- ["кптср", "комплексное посттравматическое стрессовое расстройство"]
|
||||
- ["рдр", "рекуррентное депрессивное расстройство"]
|
||||
- ["дэ", "депрессивный эпизод"]
|
||||
- ["ад", "антидепрессант"]
|
||||
- ["сиозс", "селективные ингибиторы обратного захвата серотонина"]
|
||||
- ["сиозсн", "ингибиторы обратного захвата серотонина и норадреналина"]
|
||||
- ["тца", "трициклические антидепрессанты"]
|
||||
- ["кпт", "когнитивно-поведенческая психотерапия"]
|
||||
- ["дпдг", "emdr", "десенсибилизация и переработка травмы движениями глаз"]
|
||||
- ["сопа", "синдром отмены антидепрессантов"]
|
||||
- ["эст", "электросудорожная терапия"]
|
||||
- ["ртмс", "транскраниальная магнитная стимуляция"]
|
||||
- ["кошмар", "кошмарные сновидения"]
|
||||
- ["бессонница", "инсомния", "нарушения сна"]
|
||||
- ["флешбэк", "флэшбек", "навязчивые воспоминания"]
|
||||
- ["суицид", "самоубийство", "суицидальные мысли"]
|
||||
# Разговорная лексика пациента -> термины КР.
|
||||
- ["таблетки", "препарат", "лекарство"]
|
||||
- ["бросить", "отменять", "отменить", "отмена", "прекратить", "прекращение приема"]
|
||||
- ["подействовать", "действовать", "помогать", "эффект", "улучшение"]
|
||||
- ["антидепрессивный", "тимоаналептический"]
|
||||
- ["привыкание", "зависимость"]
|
||||
- ["спорт", "физическая активность", "физические упражнения", "физическая нагрузка"]
|
||||
- ["побочка", "побочные эффекты", "нежелательные явления"]
|
||||
- ["тошнит", "тошнота"]
|
||||
- ["выпить", "спиртное", "алкоголь"]
|
||||
- ["сколько длится", "продолжительность", "длительность"]
|
||||
- ["психолог", "психотерапия"]
|
||||
@@ -21,6 +21,8 @@ phrases:
|
||||
doctor_notified: "Я передал информацию вашему врачу. Он свяжется с вами."
|
||||
# med_qa: базовый смысл = выдержка из инструкции ГРЛС; LLM только упрощает её, ничего не добавляя.
|
||||
med_answer: "{excerpt}"
|
||||
# RAG (#60): базовый смысл = выдержка из клинических рекомендаций; LLM только упрощает её, ничего не добавляя.
|
||||
rag_answer: "{excerpt}"
|
||||
|
||||
# Шаблон саммари для врача (только по структурированным данным).
|
||||
summary:
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Тексты ответов из базы знаний (RAG, #60). Зона clinical вычитывает формулировки.
|
||||
# Сам ответ — ТОЛЬКО предложения из клинических рекомендаций (экстрактивно); здесь — обрамление.
|
||||
# Правила: без советов по дозам, без рекомендаций препаратов, решение о лечении — за врачом.
|
||||
|
||||
patient:
|
||||
# Ничего не нашлось / вопрос о дозе — честно говорим и отправляем к врачу.
|
||||
fallback: "В клинических рекомендациях я не нашёл ответа на этот вопрос. Уточните, пожалуйста, у вашего врача."
|
||||
# Пациент должен понимать, что это выдержка из документа для врачей, а не совет бота.
|
||||
intro: "В клинических рекомендациях Минздрава сказано:"
|
||||
sources_label: "Источник"
|
||||
# Всегда в конце (не проходит через LLM).
|
||||
doctor_note: "Это общая информация из документа для врачей. Что подходит именно вам, решает ваш лечащий врач."
|
||||
|
||||
doctor:
|
||||
fallback: "В базе знаний (КР ПТСР 2023, КР Депрессия 2024) нет фрагмента по этому запросу."
|
||||
intro: "По клиническим рекомендациям:"
|
||||
sources_label: "Источники"
|
||||
doctor_note: ""
|
||||
|
||||
team:
|
||||
fallback: "В документах команды нет фрагмента по этому запросу."
|
||||
intro: "По документам команды:"
|
||||
sources_label: "Источники"
|
||||
doctor_note: ""
|
||||
@@ -3,6 +3,12 @@
|
||||
> Живой журнал: **что** решили и **почему**. Новые записи — сверху. Одна запись = одно решение.
|
||||
> При конфликте с [`docs/info/SPEC.md`](info/SPEC.md) действует ADR.
|
||||
|
||||
## 2026-10-04 — ADR-011: База знаний (RAG) — лексический поиск BM25 и экстрактивные ответы с цитатами
|
||||
**Контекст:** агенту нужны ответы по клиническим рекомендациям (КР ПТСР 2023, КР Депрессия 2024) со ссылкой на источник (#60). Эмбеддинги требуют либо сети (GigaChat Embeddings), либо тяжёлых зависимостей (torch, модель на сотни МБ). При этом демо должно работать офлайн (ADR-004), а `pyproject.toml` заморожен (#34).
|
||||
**Решение:** PDF переводятся в txt один раз, офлайн (`docs/source/rag/pdf_to_txt.py`: таблицы → markdown, маркеры страниц, текст блок-схем перенесён вручную). Корпус лежит в `config/rag/`. Поиск — BM25 по основам слов (свой стеммер Snowball) с синонимами и переранжированием по самому плотному отрывку. Ответ **экстрактивный**: показываются только предложения из найденного фрагмента с цитатой «[КР ПТСР 2023, стр. N]». LLM может лишь упростить их для пациента, а перефразировка с новым содержанием отбраковывается. Если отрывки покрывают слишком малую долю вопроса (`min_coverage`), ответ — «ответа нет, уточните у врача». Пациенту не показываются предложения о назначении и дозах. Внутренние документы команды (`audiences: [team]`) не доходят ни до пациента, ни до врача.
|
||||
**Причина:** работоспособность офлайн и детерминизм важнее recall на перефразированных вопросах. Каждое показанное утверждение можно проверить по странице КР.
|
||||
**Последствия:** разговорные формулировки без общих слов с КР находятся хуже — это лечится словарём `synonyms` в `config/rag/sources.yaml`. Качество поиска контролирует набор контрольных вопросов в `tests/test_llm_rag.py` (hit@3 ≥ 15/18). Гибридный поиск с эмбеддингами можно добавить за тем же интерфейсом `BM25Index.search`.
|
||||
|
||||
## 2026-10-03 — ADR-010: Фокус — ветераны СВО; разработчики делят слои, а не направления
|
||||
**Контекст:** три направления (ветераны, семьи, общая популяция с депрессией) и три разработчика. Если каждый ведёт своё направление, все трое правят одни и те же файлы: движок, API, дашборд.
|
||||
**Решение:** на хакатоне делаем только ветеранов (эпик #35); семьи (#40) и общая популяция (#43) — опциональные направления на будущее, без срока. Разработчики делят **слои**: A — бот (диалог, каналы, LLM), B — backend (API, сервисы, шедулер, адаптеры, БД, ядро), C — дашборд. Между слоями — замороженные контракты (#34) с заглушками, поэтому все трое стартуют одновременно. Направления подключаются как `app/dialog/flows/<module>.py` + `config/modules.yaml`.
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Сверка txt с PDF: какие слова из pdftotext не попали в txt (с учётом количества)."""
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
WORD = re.compile(r"[\wё]+", re.I)
|
||||
|
||||
|
||||
def words(text: str) -> Counter[str]:
|
||||
text = re.sub(r"(\w)-\s*\n\s*(\w)", r"\1\2", text) # переносы
|
||||
return Counter(w.lower() for w in WORD.findall(text))
|
||||
|
||||
|
||||
args = sys.argv[1:]
|
||||
out = None
|
||||
if "--out" in args:
|
||||
i = args.index("--out")
|
||||
out = Path(args[i + 1])
|
||||
del args[i : i + 2]
|
||||
for arg in args:
|
||||
pdf = Path(arg)
|
||||
ref = words(subprocess.run(["pdftotext", str(pdf), "-"], capture_output=True, text=True).stdout)
|
||||
got = words(((out / pdf.name) if out else pdf).with_suffix(".txt").read_text())
|
||||
lost = ref - got
|
||||
total = sum(ref.values())
|
||||
print(f"{pdf.name}: слов {total}, потеряно {sum(lost.values())} ({sum(lost.values()) / total:.2%})")
|
||||
print(" ", lost.most_common(40))
|
||||
@@ -0,0 +1,5 @@
|
||||
Рисунок 1 «Схема течения ДЭ и РДР и этапов терапии» (модель D.J. Kupfer, 1991). Описание графика:
|
||||
- Горизонтальная пунктирная линия делит уровни «Симптомы» (выше) и «Синдром» (ниже). Кривая состояния опускается ниже линии — «Прогрессирование заболевания» до уровня синдрома.
|
||||
- «Эффективное лечение» (стрелка) — кривая поднимается обратно выше линии и выходит на плато: точка «Ремиссия».
|
||||
- После ремиссии на плато отмечена точка «Обострение» (пунктирные ветви вниз — возможный возврат симптомов), далее точка «Выздоровление», затем точка «Рецидив» (пунктирная ветвь вниз).
|
||||
- Подписи этапов терапии под графиком: «Купирующая» (от начала эффективного лечения до ремиссии), «Стабилизирующая» (от ремиссии до выздоровления; в этот период возможно обострение), «Противорецидивная» (после выздоровления; в этот период возможен рецидив).
|
||||
@@ -0,0 +1,62 @@
|
||||
Б1. Блок-схема «Этапы ведения пациента (взрослые) с ДР или РДР». Пошаговая запись. Ссылки «Стр. N, раздел X» стоят на схеме мелким шрифтом рядом с блоками и указывают на страницы исходного документа. Текст блоков сверен с официальной версией КР с рубрикатора Минздрава (КР 301, 2024 г.).
|
||||
|
||||
1. «Пациент с подозрением на ДЭ или РДР» → шаг 2.
|
||||
2. «Диагностика пациента с подозрением на ДЭ или РДР (подсхема Б.2)» (Стр. 17, раздел 2.1–2.5) → вопрос 3.
|
||||
3. «Установлен ли диагноз ДЭ или РДР?»
|
||||
- Нет → «Диагностика другого психического расстройства».
|
||||
- Да → шаг 4.
|
||||
4. «Купирующая терапия (подсхема Б.3 или Б.4)» (Стр. 21, раздел 3.1–3.4) → вопрос 5.
|
||||
5. «Достигнута ли ремиссия?»
|
||||
- Нет → возврат к шагу 4 (купирующая терапия).
|
||||
- Да → «Стабилизирующая терапия» (Стр. 40, раздел 3.5) → вопрос 6.
|
||||
6. «Сохраняется ли ремиссия не менее 6 месяцев?»
|
||||
- Нет → возврат к шагу 4 (купирующая терапия).
|
||||
- Да → вопрос 7.
|
||||
7. «Необходима ли противорецидивная терапия?»
|
||||
- Нет → «Окончание терапии» (Стр. 44, раздел 3.6).
|
||||
- Да → «Противорецидивная терапия, психообразование» (Стр. 41, раздел 3.6) → вопрос 8.
|
||||
8. «Возник ли рецидив ДЭ?»
|
||||
- Да → возврат к шагу 4 (купирующая терапия).
|
||||
- Нет → «Окончание терапии».
|
||||
|
||||
|
||||
Б2. Подсхема «Диагностика пациента (взрослые) с ДР или РДР» (нижняя схема на стр. 52). На этой странице схема в низком разрешении, поэтому текст блоков сверен с той же схемой в официальной версии КР с рубрикатора Минздрава (КР 301, 2024 г.). Пошаговая запись:
|
||||
|
||||
1. «Диагностика пациента с подозрением на ДЭ или РДР» → «Провести психиатрическое и медицинское обследование, определить клинический статус пациента» → вопрос 2.
|
||||
2. «Соответствует ли состояние пациента общим критериям ДЭ?»
|
||||
- Нет → «Перейти к клиническим рекомендациям, наиболее соответствующим состоянию пациента».
|
||||
- Да → вопрос 3.
|
||||
3. «Имеются ли сведения о маниакальном, гипоманиакальном или смешанном эпизоде в анамнезе?»
|
||||
- Да → «Перейти к клиническим рекомендациям "Биполярное аффективное расстройство (F31)"».
|
||||
- Нет → вопрос 4.
|
||||
4. «Является ли текущий эпизод повторным?»
|
||||
- Нет → «Диагностировать ДЭ (F32.-)» → вопрос 5.
|
||||
- Да → «Диагностировать РДР, текущий эпизод (F33.-)» → вопрос 5.
|
||||
5. «Присутствуют ли в состоянии пациента психотические симптомы?»
|
||||
- Да → «Диагностировать ДЭ тяжелой степени с психотическими симптомами (F3x.3)» → вопрос 9.
|
||||
- Нет → вопрос 6.
|
||||
6. «Соответствует ли состояние пациента критериям ДЭ тяжелой степени без психотических симптомов?»
|
||||
- Да → «Диагностировать ДЭ тяжелой степени без психотических симптомов (F3x.2)» → вопрос 10.
|
||||
- Нет → вопрос 7.
|
||||
7. «Соответствует ли состояние пациента критериям ДЭ средней степени?»
|
||||
- Да → «Диагностировать ДЭ средней степени (F3x.1)» → вопрос 10.
|
||||
- Нет → вопрос 8.
|
||||
8. «Соответствует ли состояние пациента критериям ДЭ легкой степени?»
|
||||
- Да → «Диагностировать ДЭ легкой степени (F3x.0)» → вопрос 10.
|
||||
9. (ДЭ с психотическими симптомами) «Может ли дать и дает ли пациент согласие на госпитализацию?»
|
||||
- Да → «Проводить купирующую терапию ДЭ тяжелой степени с психотическими симптомами в условиях стационара».
|
||||
- Нет → «Принять решение о недобровольной госпитализации» → «Проводить купирующую терапию ДЭ тяжелой степени с психотическими симптомами в условиях стационара».
|
||||
10. (ДЭ тяжелой степени без психотических симптомов, средней или легкой степени) «Существует ли суицидальный риск?»
|
||||
- Да → вопрос 11.
|
||||
- Нет → см. примечание ниже.
|
||||
11. «Нуждается ли пациент в стационарном лечении?»
|
||||
- Нет → «Проводить купирующую терапию ДЭ в амбулаторных условиях».
|
||||
- Да → вопрос 12.
|
||||
12. «Может ли дать и дает ли пациент согласие на госпитализацию?»
|
||||
- Да → «Проводить купирующую терапию ДЭ без психотических симптомов в условиях стационара».
|
||||
- Нет → вопрос 13.
|
||||
13. «Есть ли показания для недобровольной госпитализации?»
|
||||
- Да → «Принять решение о недобровольной госпитализации» → «Проводить купирующую терапию ДЭ без психотических симптомов в условиях стационара».
|
||||
- Нет → см. примечание ниже.
|
||||
|
||||
Примечание: на схеме линии «Нет» от вопроса 10 («Существует ли суицидальный риск?») и от вопроса 13 («Есть ли показания для недобровольной госпитализации?») соединены друг с другом. Стрелкой ни к одному блоку они не доведены, так что куда они ведут, по схеме не определить.
|
||||
@@ -0,0 +1,101 @@
|
||||
Б3. Подсхема «Купирующая терапия ДЭ без психотических симптомов» (верхняя схема на стр. 53). На этой странице схема в низком разрешении, поэтому текст блоков сверен с той же схемой в официальной версии КР с рубрикатора Минздрава (cr.minzdrav.gov.ru, КР 301, 2024 г.), где она векторная. Пошаговая запись:
|
||||
|
||||
ЭТАП I
|
||||
1. «Пациент с диагностированным ДЭ без психотических симптомов (единичный либо в рамках РДР)» → шаг 2.
|
||||
2. «Назначить СИОЗС (A1), СИОЗСН (A1), другие АД нового поколения (A1), ТЦА (A1) в течение 3-4 недель. Принять решение о назначении психотерапии (A1)» → вопрос 3.
|
||||
3. «Есть ли клинический эффект и хорошая переносимость?»
|
||||
- Да → вопрос 4.
|
||||
- Нет → «Назначить АД с другим механизмом действия (A1)» → этап II, вопрос 5.
|
||||
4. «Достаточен ли клинический эффект?»
|
||||
- Да → «Продолжить терапию до достижения ремиссии».
|
||||
- Нет → «Повысить дозу до максимальной и продолжить терапию еще 3-4 недели (A1)» → этап II, вопрос 5.
|
||||
|
||||
ЭТАП II
|
||||
5. «Есть ли клинический эффект и хорошая переносимость?»
|
||||
- Да → «Продолжить терапию до достижения ремиссии».
|
||||
- Нет → шаг 6.
|
||||
6. «Уточнить диагноз, диагностировать соматическую патологию, коморбидные психические расстройства, органическое поражение ЦНС, неразрешенные стрессовые факторы, выявить некомплаентность» → вопрос 7. (От этого блока на схеме идёт и линия обратно к вопросу 5.)
|
||||
7. «Обнаружена ли какая-либо причина?»
|
||||
- Да → «Принять соответствующие меры» → возврат к шагу 6.
|
||||
- Нет → этап III, шаг 8.
|
||||
|
||||
ЭТАП III
|
||||
8. «Регистрировать относительную резистентность и перейти к любой из опций III этапа». Опции проверяются по цепочке:
|
||||
8а. «Присоединен ли к АД #лития карбонат (B1), или #ламотриджин (A1), или #карбамазепин (B2)?» Да → вопрос 9; Нет → 8б.
|
||||
8б. «Присоединен ли к АД арипипразол, или кветиапин**, или оланзапин**, или #рисперидон**, или #карипразин**, или #брекспипразол, или #зипрасидон, или #амисульприд (B1)?» Да → вопрос 9; Нет → 8в.
|
||||
8в. «Присоединен ли к АД сульпирид** или флупентиксол** (C4)?» Да → вопрос 9; Нет → возврат к шагу 8.
|
||||
9. «Есть ли клинический эффект и хорошая переносимость?»
|
||||
- Да → «Продолжить терапию до достижения ремиссии».
|
||||
- Нет → шаг 10.
|
||||
10. «Верифицировать диагноз, исключить сопутствующую патологию и лекарственные взаимодействия» → вопрос 11. (От этого блока на схеме идёт и линия обратно к вопросу 9.)
|
||||
11. «Обнаружена ли какая-либо причина?»
|
||||
- Да → «Принять соответствующие меры» → возврат к шагу 10.
|
||||
- Нет → этап IV, шаг 12.
|
||||
|
||||
ЭТАП IV
|
||||
12. «Перейти к любой из опций IV этапа». Опции проверяются по цепочке, каждая «Нет» ведёт к следующей:
|
||||
12а. «Использованы ли другие варианты комбинированной терапии?¹»
|
||||
12б. «Назначены ли другие лекарственные методы аугментации АД?²»
|
||||
12в. «Назначена ли ЭСТ (B2)?»
|
||||
12г. «Назначены ли другие методы электростимуляции мозга?³»
|
||||
12д. «Назначена ли психотерапия (A1)?»
|
||||
12е. «Назначены ли другие нелекарственные методы?⁴»
|
||||
12ж. «Проведена ли одномоментная отмена терапии (C5)?» — Нет → возврат к шагу 12.
|
||||
Любое «Да» → этап V, вопрос 13.
|
||||
|
||||
ЭТАП V
|
||||
13. «Есть ли клинический эффект?»
|
||||
- Да → «Продолжить терапию до достижения ремиссии».
|
||||
- Нет → «Регистрировать абсолютную резистентность» → возврат к шагу 12 (опции IV этапа).
|
||||
|
||||
Сноски к схеме:
|
||||
¹ сочетания двух АД (A1), АД с АВП (B1), с АПП (C4).
|
||||
² # лития карбонат (B1), #ламотриджин (A1), АД + #карбамазепин** (B2), #буспирон (B2).
|
||||
³ ТМС (A1), МКТ (C4), ТПЭСМ (B2), СБН (A2), ГСМ (B3).
|
||||
⁴ светотерапия (B1), депривация сна (B2), физические упражнения (B1), рефлексотерапия (C2), РДТ (C5), ВЛОК (C5), ультрафиолетовое облучение крови (C5), нормобарическая гипоксия (C5), тренировка с биологической обратной связью (B2), плазмаферез (C5), ксенонотерапия (C5).
|
||||
|
||||
|
||||
Б4. Подсхема «Купирующая терапия ДЭ со смешанными чертами» (средняя схема на стр. 53; текст сверен с официальной версией КР с рубрикатора Минздрава). Пошаговая запись:
|
||||
|
||||
1. «Диагностирован депрессивный эпизод с симптомами субсиндромальной гипомании (депрессивный эпизод со смешанными чертами)» → вопрос 2.
|
||||
2. «Получает ли пациент АД?»
|
||||
- Да → вопрос 3.
|
||||
- Нет → ветка Б (шаг 8).
|
||||
|
||||
Ветка А (пациент получает АД):
|
||||
3. «АД эффективен?»
|
||||
- Да → вопрос 4.
|
||||
- Нет → шаг 5.
|
||||
4. «Усиливает ли АД симптомы гипомании?»
|
||||
- Да → шаг 5.
|
||||
- Нет → ИТОГ.
|
||||
5. «Отменить АД (C4), назначить #зипрасидон (B2), или #луразидон (A2), или #карипразин** (B2)» → «Есть эффект?»
|
||||
- Да → ИТОГ.
|
||||
- Нет → шаг 6.
|
||||
6. «Добавить или перевести на нормотимик (B2) или сменить АВП (#зипрасидон (B2), #луразидон (A2), кветиапин** (B2), #карипразин** (B2), оланзапин** (B2))» → «Есть эффект?»
|
||||
- Да → ИТОГ.
|
||||
- Нет → «ЭСТ (C4)».
|
||||
|
||||
Ветка Б (пациент не получает АД):
|
||||
8. «Назначить #зипрасидон (B2), или #луразидон (A2), или #карипразин** (B2)» → «Есть эффект?» Да → ИТОГ; Нет → шаг 9.
|
||||
9. «Сменить АВП первой линии или назначить оланзапин** (B2)» → «Есть эффект?» Да → ИТОГ; Нет → шаг 10.
|
||||
10. «Добавить или перевести на нормотимик (B2)» → «Есть эффект?» Да → ИТОГ; Нет → шаг 11.
|
||||
11. «Добавить АД или использовать оланзапин**+флуоксетин** (C5)» → «Есть эффект?» Да → ИТОГ; Нет → «ЭСТ (C4)».
|
||||
|
||||
ИТОГ: «Эффективную терапию продолжать до достижения ремиссии и переходить к этапу поддерживающей терапии (C4)».
|
||||
|
||||
|
||||
Приложение Б5. «Алгоритм профилактики и ведения пациентов с синдромом отмены приема антидепрессантов (СОПА)» (нижняя схема на стр. 53; текст сверен с официальной версией КР с рубрикатора Минздрава). Пошаговая запись:
|
||||
|
||||
Боковые пометки на схеме: «Проинформировать пациента о СОПА до лечения АД» (до начала лечения) и «Повторно проинформировать пациента о СОПА при отмене АД» (на этапе отмены).
|
||||
|
||||
1. «Отмена АД» → вопрос 2.
|
||||
2. «Пациент принимает высокие дозы АД / испытывал симптомы СОПА после пропуска приема АД / испытывал симптомы СОПА в прошлом при отмене АД / принимает АД с высоким риском развития СОПА»
|
||||
- да → «Высокий риск СОПА» → «Максимально медленное снижение дозы АД» → шаг 3.
|
||||
- нет → «Постепенное снижение дозы АД» → шаг 3.
|
||||
3. «Симптомы СОПА» — по выраженности:
|
||||
- «Легкая выраженность симптомов» → «Необходимо успокоить пациента, объяснив временный характер симптомов».
|
||||
- «Умеренная выраженность симптомов» → «Симптоматическая терапия».
|
||||
- «Тяжелая выраженность симптомов» → два варианта:
|
||||
а) «При отмене СИОЗС или СИОЗСН можно перейти на флуоксетин; при отмене АД из других групп можно перейти на фармакодинамически сходный АД с более длительным периодом полувыведения с прекращением приема исходного АД после нескольких недель лечения новым АД и постепенной отменой последнего в последующем»;
|
||||
б) «Можно возобновить АД в минимально эффективной дозе или в той дозе, при которой не было симптомов отмены, после чего отменить его более дробным и медленным титрованием».
|
||||
@@ -0,0 +1,30 @@
|
||||
Блок-схема «Алгоритм ведения пациента с посттравматическим стрессовым расстройством» (Приложение Б). Пошаговая запись:
|
||||
|
||||
1. Точка входа А: «Обращение к врачу общей практики, медицинскому психологу» → вопрос 2.
|
||||
2. «Было в течение последних шести месяцев выраженное травматическое событие?»
|
||||
- нет → «Дальнейший диагностический поиск».
|
||||
- да → вопрос 3.
|
||||
3. «Имеются предположительные данные за ПТСР или сомнения в оценке состояния?»
|
||||
- нет → «Дальнейший диагностический поиск».
|
||||
- да → «Обращение к врачу-психотерапевту, врачу-психиатру» (шаг 4).
|
||||
4. Точка входа Б: «Обращение к врачу-психотерапевту, врачу-психиатру» → вопрос 5.
|
||||
5. «Имеются диагностические критерии ПТСР?»
|
||||
- нет → «Дальнейший диагностический поиск».
|
||||
- да → «Диагноз ПТСР» → шаг 6.
|
||||
6. «Оценка суицидального риска, тяжести состояния, коморбидной патологии, наличия социальной поддержки» → вопрос 7.
|
||||
7. «Имеются показания для госпитализации (суицидальный риск, наличие выраженной психической или соматической патологии, отсутствие социальной поддержки)?»
|
||||
- нет → «Оказание помощи в первичном звене здравоохранения (психотерапевтический кабинет, ПНД, психотерапевтический центр и др.) амбулаторно или в условиях дневного стационара» → «Проведение курса лечения ПТСР (психотерапия и фармакотерапия)» → «Оценка состояния» (шаг 10).
|
||||
- да → вопрос 8.
|
||||
8. «Есть актуальная соматическая патология, требующая неотложной госпитализации в соматический стационар?»
|
||||
- да → «Госпитализация и лечение в соматическом стационаре» → «Оценка состояния» (шаг 10).
|
||||
- нет → «Госпитализация и лечение в психиатрическом стационаре» → вопрос 9.
|
||||
9. «Есть показания для помещения в психиатрическое отделение с интенсивным наблюдением (суицидальный риск, психотическая симптоматика)?»
|
||||
- нет → «Госпитализация и лечение в отделении пограничных состояний (отделение неврозов)» → «Оценка состояния» (шаг 10).
|
||||
- да → «Госпитализация и лечение в отделении интенсивного наблюдения» → «Оценка состояния» (шаг 10).
|
||||
10. «Оценка состояния» → вопрос 11.
|
||||
11. «Есть необходимость продолжения лечения?»
|
||||
- да → возврат к шагу 6 («Оценка суицидального риска, тяжести состояния, коморбидной патологии, наличия социальной поддержки»).
|
||||
- нет → вопрос 12.
|
||||
12. «Есть необходимость проведения реабилитационных мероприятий?»
|
||||
- нет → «Завершение лечения».
|
||||
- да → «Направление на реабилитацию».
|
||||
@@ -0,0 +1,18 @@
|
||||
Блок-схема «Алгоритм терапии ПТСР» (Приложение Б1). Пошаговая запись. Цифры после названий (1, 2,3,4,5,6, 8, 9, 10, 11) стоят на схеме как сноски; их расшифровки на странице нет. Написание «велафаксином» сохранено как в оригинале (имеется в виду венлафаксин).
|
||||
|
||||
1. «ДИАГНОЗ ПТСР» → вопрос 2.
|
||||
2. «Психотерапевтическое лечение возможно и целесообразно? 1»
|
||||
- да → «Проведение психотерапии» → вопрос 3.
|
||||
- нет → «Монотерапия сертралином/пароксетином/флуоксетином/велафаксином 2,3,4,5,6» → вопрос 3.
|
||||
3. «Лечение эффективно?»
|
||||
- да → «Поддерживающее лечение, реабилитационные мероприятия».
|
||||
- нет → «5,6» → «Смена антидепрессанта на другой из 1-й линии / Совместное применение психотерапии и психофармакотерапии 8» → вопрос 4.
|
||||
4. «Лечение эффективно?»
|
||||
- да → «Поддерживающее лечение, реабилитационные мероприятия».
|
||||
- нет → «Присоединение к терапии рисперидона, кветиапина, оланзапина 9» → вопрос 5.
|
||||
5. «Лечение эффективно?»
|
||||
- да → «Поддерживающее лечение, реабилитационные мероприятия».
|
||||
- нет → «Замена антидепрессанта на амитриптилин, имипрамин, миртазапин 10» → вопрос 6.
|
||||
6. «Лечение эффективно?»
|
||||
- да → «Поддерживающее лечение, реабилитационные мероприятия».
|
||||
- нет → «Переход к стратегиям преодоления резистентности 11».
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,184 @@
|
||||
# Модуль «Лекарства»: спецификация для разработки
|
||||
|
||||
Источник: meds_module_spec.pdf, страниц: 10. Документ команды от Oct 4, 2026, автор @Andrey. Текст перенесён вручную и сверен с PDF: автоматическое извлечение ломает вёрстку выносок и таблиц. Символ «|» внутри ячеек заменён на «/».
|
||||
|
||||
--- стр. 1 ---
|
||||
|
||||
Модуль следит не за абстрактными «лекарствами», а за конкретным препаратом пациента. Он напоминает о приёме, узнаёт причины пропусков, спрашивает о побочных эффектах этого препарата и работает по 10 правилам (M1–M10). Данные взяты из двух клинических рекомендаций Минздрава: по депрессии (2024) и по ПТСР (2023).
|
||||
|
||||
Что передаём:
|
||||
- этот документ — логика модуля;
|
||||
- файл meds_reference.json (прилагается к ссылке) — справочник на 47 препаратов, тексты для пациента и параметры правил;
|
||||
- вкладка «Справочник препаратов» — тот же справочник в виде таблицы, для проверки медиком.
|
||||
|
||||
Как модуль встраивается в этап 2. Вечерний вопрос о лекарствах, опрос о побочных эффектах на 7-й, 14-й и 28-й день и флаги «меньше 80% дней» и «пропуск 3 дня подряд» не меняются. Модуль добавляет к ним данные о препарате и новые правила.
|
||||
|
||||
| В MVP | После хакатона |
|
||||
|---|---|
|
||||
| Справочник из JSON | Тексты инструкций из ГРЛС в карточке препарата |
|
||||
| Назначение: ввод в кабинете врача или из имитации ЕМИАС | Назначения из ЕМИАС (электронные рецепты) |
|
||||
| Напоминание о приёме и отметка в дневнике | Схема постепенной отмены препарата |
|
||||
| Опрос о побочных эффектах по профилю препарата | Ввод результатов анализов |
|
||||
| Правила M1–M10 и блок в сводке врачу | Проверка взаимодействий для врача |
|
||||
|
||||
--- стр. 2 ---
|
||||
|
||||
Границы: бот следит, но не лечит
|
||||
|
||||
Препарат и дозу всегда выбирает врач. Если приложение начнёт давать советы о лечении, его могут счесть медицинским изделием, а к ним требования строже.
|
||||
|
||||
| Бот делает | Бот не делает никогда |
|
||||
|---|---|
|
||||
| Хранит назначение врача: препарат, дозу, время приёма | Не предлагает препарат, дозу, смену или отмену |
|
||||
| Напоминает о приёме и отмечает, принят ли препарат | На вопросы «можно ли бросить» или «можно ли увеличить дозу» отвечает только «обсудите с врачом» и ставит флаг |
|
||||
| Спрашивает о побочных эффектах, характерных для этого препарата | Не исправляет и не блокирует дозу вне диапазона — только просит проверить ввод (M10) |
|
||||
| Показывает карточку препарата из проверенных источников | Не проверяет для пациента взаимодействия лекарств |
|
||||
| Сигналит врачу по правилам M1–M10 | Не ставит диагноз и не оценивает, «работает ли» лечение, — это делает врач по шкалам |
|
||||
|
||||
Модель данных
|
||||
|
||||
Шесть сущностей. Drug читается из JSON и в приложении не редактируется, остальные создаёт приложение.
|
||||
|
||||
| Сущность | Поля | Откуда берётся |
|
||||
|---|---|---|
|
||||
| Drug | id, name_ru, class, indications.{depression/ptsd}.{min_mg, max_mg, start_mg, off_label}, diary_items[], red_flags[], monitoring[], withdrawal_risk, overdose_danger, max_course_days | meds_reference.json |
|
||||
|
||||
--- стр. 3 ---
|
||||
|
||||
| Сущность | Поля | Откуда берётся |
|
||||
|---|---|---|
|
||||
| Prescription | id, patient_id, drug_id, indication (F32/F33/F43.1), dose_mg_per_day, times[] (например, "09:00"), start_date, source (emias / doctor / patient), status (active / stopped), history[] (дата, старая и новая доза) | имитация ЕМИАС или кабинет врача; ввод пациентом — с пометкой «со слов пациента» |
|
||||
| DoseLog | prescription_id, date, taken (bool), reason (forgot / side_effects / decided_not_to / ran_out), logged_at | вечерний дневник |
|
||||
| SideEffectReport | date, trigger (scheduled / missed_dose / button), fibser.{freq, intensity, burden}, items[]: {item_id, severity: mild/severe}, red_flags[] | опрос о побочных эффектах |
|
||||
| MedEvent | prescription_id, type (start / dose_change / stop), date, baseline_scale_ids[] | создаётся при изменении Prescription |
|
||||
| MedFlag | rule_id (M1…M10), patient_id, prescription_id, created_at, recipient, due_at, status (open / acknowledged), evidence (ответы, по которым сработало правило) | движок правил |
|
||||
|
||||
Связь с расписанием этапа 2. Любой MedEvent включает строку «Начало или смена лекарств»: полные шкалы и опрос о побочных эффектах на 7-й, 14-й, 28-й день, далее раз в 4 недели. Исходный замер для M5 — последняя полная шкала за 14 дней до события. Если её нет, шкалу проводят в день события.
|
||||
|
||||
Дневник и опросы
|
||||
|
||||
В обычный день добавляется только отметка по каждому препарату. Подробно спрашиваем по расписанию или после пропуска.
|
||||
|
||||
--- стр. 4 ---
|
||||
|
||||
1. Напоминание. Push-уведомление во время из Prescription.times: «Время принять {препарат} {доза}» и кнопка «Принял(а)». Нажатие сразу создаёт DoseLog с taken = true, и вечером этот препарат уже не спрашиваем.
|
||||
|
||||
2. Вечерний вопрос (уже есть в ядре этапа 2), теперь по каждому препарату:
|
||||
1. «Приняли ли Вы сегодня лекарства, назначенные врачом?» — да, все / не все / нет.
|
||||
2. Если «не все» — выбрать, какие не приняты.
|
||||
3. По каждому непринятому — причина: забыл(а) · побочные эффекты · решил(а) не принимать · закончились.
|
||||
Причина напрямую запускает правила: «побочные эффекты» → внеплановый опрос (п. 3) и M8, «решил(а) не принимать» → M2, «закончились» → M3.
|
||||
|
||||
3. Опрос о побочных эффектах. Проводится в трёх случаях: по расписанию этапа 2 (7-й, 14-й, 28-й день, далее раз в 4 недели), после пропуска «из-за побочных эффектов» и по кнопке «Сообщить о побочном эффекте», которая доступна всегда.
|
||||
1. Три вопроса FIBSER: как часто, насколько сильно, насколько мешают жить (из этапа 2).
|
||||
2. Если частота > 0 — «Что именно беспокоит?»: список из diary_items всех активных препаратов без повторов, плюс «другое» с текстом. У каждого пункта два ответа: немного / сильно мешает.
|
||||
3. Вопросы о срочных симптомах — только из red_flags активных препаратов, с ответом да / нет. Ответ «да» → M7.
|
||||
Список пунктов для препарата формируется так. Для 16 антидепрессантов берём эффекты с оценкой «++» и «+++» из таблицы 3 КР по депрессии. Для остальных препаратов — эффекты, которые названы в тексте КР. В JSON список уже собран: например, у сертралина это тошнота, бессонница, сексуальные проблемы и головная боль.
|
||||
|
||||
4. Карточки о сроках эффекта. Текст expect_effect_depression показываем в 1-й и 14-й день после начала, expect_effect_ptsd — в 1-й и 7-й. Тексты лежат в JSON, в разделе texts.
|
||||
|
||||
--- стр. 5 ---
|
||||
|
||||
Контрольные точки после начала лечения
|
||||
|
||||
[Изображение: текст перенесён вручную]
|
||||
Схема «Ответ оценивают к 4–6 неделе, лечение длится месяцы». Подзаголовок: «Фазы после начала или смены препарата: что делает модуль и что говорят КР». Таблица по фазам:
|
||||
|
||||
| Строка | Недели 1–2 | Недели 3–6 | Месяцы и годы |
|
||||
|---|---|---|---|
|
||||
| Модуль (что делает бот) | Дни 7 и 14: шкалы и опрос о побочных. Карточка «когда ждать эффекта» | День 28: шкалы, побочные и проверка ответа — M5. Бензодиазепин дольше допустимого — M4 | Шкалы и опрос о побочных раз в 4 недели до конца курса |
|
||||
| Депрессия (КР 2024) | Раннее улучшение (1–2 недели) — благоприятный признак | Эффект через 3–4 недели, у СИОЗС — до 6–8 недель; снижение на 25–50% — ответ недостаточен | Тот же препарат и доза 4–6 мес. после улучшения; при РДР — 3–5 лет |
|
||||
| ПТСР (КР 2023) | СИОЗС: в 1-ю неделю тревога может усилиться; следить за суицидальным риском | Эффект оценивают через 4–6 недель; бензодиазепины — не дольше 3–4 недель | Курс 6–12 месяцев после стабилизации |
|
||||
|
||||
Подпись под схемой: «Фазы после начала лечения · модуль, депрессия, ПТСР».
|
||||
|
||||
M5 проверяет ответ на 28-й день при обоих диагнозах. Для ПТСР это ранняя граница окна из КР: окончательно эффект оценивают до 6-й недели, поэтому флаг — повод для врача посмотреть, а не сигнал тревоги.
|
||||
|
||||
Правила сигналов M1–M10
|
||||
|
||||
К флагам этапа 2 по лекарствам (меньше 80% дней с приёмом, пропуск 3 дня подряд, тяжёлый день алкоголя при приёме лекарств) добавляются десять правил. Пороги и сроки хранятся в rules[].params в JSON и меняются без правки кода. Строки с пометкой «черновик» нужно согласовать с психиатром.
|
||||
|
||||
--- стр. 6 ---
|
||||
|
||||
| ID | Условие | Что делает приложение | Кто получает, срок | Основание |
|
||||
|---|---|---|---|---|
|
||||
| M1 | У препарата withdrawal_risk = high (венлафаксин, пароксетин, ТЦА, бензодиазепины) и он пропущен 2 дня подряд | Текст withdrawal_missed и вопрос «Стало ли хуже после пропуска?» | Лечащий психиатр: ответ «хуже» — 24 ч, иначе — до 3 рабочих дней. Черновик | КР депрессия 3.6; КР ПТСР 3.2 |
|
||||
| M2 | Причина пропуска «решил(а) не принимать» | Тексты dont_stop_alone (+ why_continue_depression при F32/F33), флаг | Лечащий психиатр, до 3 рабочих дней. Черновик | КР депрессия, прил. В; КР ПТСР 6.1 |
|
||||
| M3 | Причина «закончились» | Текст ran_out; 2 дня подряд → флаг | Лечащий врач, до 3 рабочих дней. Черновик | команда |
|
||||
| M4 | Бензодиазепин принимается дольше max_course_days | Отметка в кабинете и сводке | Лечащий психиатр, к следующему визиту | КР ПТСР 3.2 |
|
||||
| M5 | 28-й день после MedEvent: PHQ-9 снизился меньше чем на 50% (F32/F33) или PCL-5 — меньше чем на 10 баллов (F43.1) от исходного | Флаг «нет ответа к 4-й неделе» | Лечащий психиатр, до 3 рабочих дней. Черновик | КР депрессия 3.1–3.2; КР ПТСР 3.2; пороги ответа — этап 2 |
|
||||
|
||||
--- стр. 7 ---
|
||||
|
||||
| ID | Условие | Что делает приложение | Кто получает, срок | Основание |
|
||||
|---|---|---|---|---|
|
||||
| M6 | Сработал сигнал C-SSRS (умеренный или высокий риск), а у пациента активен препарат с overdose_danger | Пометка «препарат опасен при передозировке» в этом же сигнале, новый сигнал не создаётся | Как у сигнала C-SSRS | КР депрессия 3.1.3 |
|
||||
| M7 | Пациент отметил срочный симптом из red_flags | Текст red_flag (врач сегодня; при тяжёлом состоянии 103/112), флаг | Лечащий психиатр, 24 ч. Черновик | КР депрессия 3.1, 3.3; КР ПТСР 3.2 |
|
||||
| M8 | FIBSER «насколько мешают» ≥ 5 из 6 или пропуск «из-за побочных» в 2 из 7 дней | Текст side_effects_general, флаг | Лечащий психиатр, до 3 рабочих дней. Черновик | КР депрессия, прил. В |
|
||||
| M9 | У активного препарата есть monitoring | Строка «Контроль по КР: …» в сводке врачу, это не сигнал | Лечащий врач, в сводке | КР депрессия 3.1, 3.3; КР ПТСР 3.2 |
|
||||
| M10 | При сохранении назначения доза вне min_mg – max_mg для этого диагноза | «Проверьте ввод» тому, кто вводит; пометка в сводке; сохранение не блокируется | Тот, кто вводит, сразу | Приложения А3 обеих КР |
|
||||
|
||||
Все флаги попадают в кабинет врача с датой, правилом и ответами, по которым правило сработало, как в этапе 2.
|
||||
|
||||
Карточка препарата, сводка врачу, демо
|
||||
|
||||
Карточка препарата построена по списку «что сообщить пациенту» из КР по депрессии (приложение В). Поля с источником «инструкция» в MVP показывают «Уточните у врача или в инструкции». Для 2–3 демо-препаратов их можно заполнить вручную.
|
||||
|
||||
--- стр. 8 ---
|
||||
|
||||
| Поле | Источник | Пример: сертралин при ПТСР |
|
||||
|---|---|---|
|
||||
| Название, доза, время приёма | Prescription | Сертралин, 50 мг в сутки, в 09:00 |
|
||||
| Когда ждать эффекта | indications.*.expected_response | Эффект оценивают через 4–6 недель |
|
||||
| Сколько принимать | indications.*.course + решение врача | Обычно 6–12 месяцев после стабилизации |
|
||||
| Частые побочные эффекты | diary_items | Тошнота, бессонница, сексуальные проблемы, головная боль |
|
||||
| Когда срочно к врачу | red_flags | Необычные кровотечения или синяки |
|
||||
| Не прекращать самостоятельно | texts.dont_stop_alone | общий текст |
|
||||
| Как принимать, связь с едой, что делать при пропуске | инструкция (ГРЛС) | — |
|
||||
| Алкоголь, вождение, беременность | инструкция (ГРЛС) | — |
|
||||
|
||||
Блок «Лекарства» в сводке врачу (дополняет сводку этапа 2), по каждому препарату:
|
||||
- препарат, доза, дата начала и неделя приёма рядом с ожидаемым сроком ответа (например, «неделя 3 из 4–6»);
|
||||
- доля дней с приёмом за 4 недели и причины пропусков;
|
||||
- побочные эффекты: FIBSER и пункты «что именно» по замерам, срочные симптомы;
|
||||
- ответ на 28-й день (M5), контроль по КР (M9), пометки M4 и M10.
|
||||
|
||||
Демо: ветеран Иван, ПТСР. Этот сценарий стыкуется с демо этапа 2: там ухудшение на 3-й неделе, здесь видна его причина.
|
||||
|
||||
--- стр. 9 ---
|
||||
|
||||
1. День 0. Врач в кабинете назначает сертралин 50 мг в сутки (препарат 1-й линии по КР ПТСР). Включается расписание «Начало или смена лекарств», пациент получает карточку expect_effect_ptsd.
|
||||
2. День 3. Отметка «не принял — побочные эффекты». Внеплановый опрос: тошнота «немного», бессонница «сильно мешает».
|
||||
3. День 7. Плановые шкалы и опрос о побочных эффектах.
|
||||
4. Дни 17–19. Три дня подряд «решил не принимать». На 17-й день срабатывает M2 (карточка и флаг врачу), на 19-й — флаг этапа 2 «пропуск 3 дня».
|
||||
5. Неделя 3. Ухудшение по дневнику → внеплановый PCL-5 → повторный триаж (сценарий этапа 2). В сводке врач видит, что ухудшение совпало с прекращением приёма из-за бессонницы.
|
||||
|
||||
Для питча: КР по ПТСР (раздел 3.2) прямо рекомендуют для соблюдения режима «ведение дневника приёма лекарств или телефонное приложение с напоминанием». В разделе 6.1 сказано, что пациенты часто бросают лекарства после улучшения и состояние ухудшается.
|
||||
|
||||
Черновые параметры — согласовать с психиатрами
|
||||
|
||||
Код можно писать сейчас: все числа ниже лежат в JSON и заменяются без правки логики. Вопросы к врачам по этапу 2 собраны в документе «Этап 2: вопросы к психиатрам».
|
||||
|
||||
| Параметр | Черновое значение | Основание |
|
||||
|---|---|---|
|
||||
| M1: сколько дней пропуска препарата с риском отмены | 2 дня подряд | риск — КР; число — команда |
|
||||
| M4: предел курса бензодиазепинов | 21 день; диазепам 28; алпразолам 90 | КР ПТСР 3.2 |
|
||||
| M5: день проверки ответа | 28-й день для обоих диагнозов | КР: депрессия 3–4 недели, ПТСР 4–6 недель |
|
||||
| M5: исходный замер | последняя полная шкала за 14 дней до старта, иначе в день старта | команда |
|
||||
| M8: «сильно мешают» | FIBSER-бремя ≥ 5 из 6 или 2 пропуска из-за побочных за 7 дней | порог FIBSER сверить с оригиналом шкалы |
|
||||
|
||||
--- стр. 10 ---
|
||||
|
||||
| Параметр | Черновое значение | Основание |
|
||||
|---|---|---|
|
||||
| M7: список срочных симптомов и текст пациенту | 5 симптомов из JSON, текст red_flag | симптомы — КР; текст — команда |
|
||||
| Сроки реакции | 24 ч / до 3 рабочих дней / к визиту | как в этапе 2 |
|
||||
| Ввод назначения пациентом | разрешён, с пометкой «со слов пациента» | команда |
|
||||
| Все тексты для пациента из texts | черновики | вычитать врачу |
|
||||
|
||||
Источники
|
||||
- Клинические рекомендации «Депрессивный эпизод, рекуррентное депрессивное расстройство», Минздрав РФ, 2024, Российское общество психиатров. Использованы разделы 3.1–3.6 и приложения А3 (табл. 2 — дозы, табл. 3 — побочные эффекты), Б5 (синдром отмены), В (информация для пациента).
|
||||
- Клинические рекомендации «Посттравматическое стрессовое расстройство», Минздрав РФ, 2023, ID 753, Российское общество психиатров. Использованы разделы 3.2 и 6.1, приложения А3 (дозы), Б1 (алгоритм терапии), В. Срок пересмотра — не позднее 2025 года: проверить, не вышла ли новая версия.
|
||||
- «Этап 2: наблюдение после диагноза» — наш документ о расписании, флагах и сроках реакции.
|
||||
|
||||
Расхождения внутри КР отмечены в JSON в поле note. Пример: хлорпротиксен в тексте КР по ПТСР — 15–100 мг, а в таблице — 25–150 мг.
|
||||
Binary file not shown.
@@ -0,0 +1,278 @@
|
||||
"""PDF -> TXT для RAG.
|
||||
|
||||
Запуск из корня репо (нужен poppler-utils для pdftotext):
|
||||
uv run --with pdfplumber python docs/source/rag/pdf_to_txt.py docs/source/rag/*.pdf --out config/rag/corpus
|
||||
Сверка результата с PDF:
|
||||
python docs/source/rag/check_txt.py docs/source/rag/*.pdf --out config/rag/corpus
|
||||
|
||||
- текст вне таблиц, строки склеены в абзацы (в том числе через границу страницы);
|
||||
- таблицы -> markdown; у таблицы-продолжения на новой странице повторяется шапка,
|
||||
строка, разорванная границей страницы, склеивается;
|
||||
- колонтитулы (повторяющиеся строки вверху/внизу страниц) вырезаются;
|
||||
- маркеры страниц `--- стр. N ---` для ссылок на источник;
|
||||
- текст с картинок (блок-схемы и т.п.) берётся из figures/<имя pdf>.p<N>.txt
|
||||
(ручная транскрипция) и вставляется в конец своей страницы;
|
||||
- если есть manual/<имя pdf>.txt, берётся он (документы, которые автоматом
|
||||
не разбираются, например вёрстка Chromium с выносками).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pdfplumber
|
||||
|
||||
FIGURES_DIR = Path(__file__).parent / "figures"
|
||||
MANUAL_DIR = Path(__file__).parent / "manual" # вручную выверенные txt: берутся как есть
|
||||
EDGE_LINES = 4 # сколько строк сверху/снизу страницы проверять на колонтитул
|
||||
SENTENCE_END = tuple(".:;!?»)")
|
||||
GRADE_TAIL = re.compile(r"^([ABCАВС]|\(уровень\b.*)$") # "Уровень убедительности рекомендаций\nA\n(уровень ...)"
|
||||
BULLET = re.compile(r"^([●•▪◦\-–—*]|\d+[.)]|[а-яa-z][.)]|[IVXА-Я]{1,4}[.)])\s")
|
||||
HYPHEN = "\x00" # временная метка переноса "сло-\nво", снимается в resolve_hyphens
|
||||
SOFT = "\x01" # временная метка разрыва строки внутри слова в узкой ячейке: "Соверше\nнно"
|
||||
DEFINITION = re.compile(r"^\S+ [–—] ") # "рТМС – ..." в списке сокращений
|
||||
ITEM = re.compile(r"^(\d+\s*[.)\-–]|[А-ЯA-Z]$)") # "1. СНИЖЕННОЕ НАСТРОЕНИЕ", "И" — пункт, не шапка
|
||||
OPTION = re.compile(r"^0 □") # варианты ответа пункта шкалы: "0 □ Отсутствует 1 □ ..."
|
||||
|
||||
|
||||
def norm_edge(line: str) -> str:
|
||||
return re.sub(r"\d+", "#", line.strip())
|
||||
|
||||
|
||||
def strip_junk(line: str, junk: set[str]) -> str:
|
||||
"""Убирает колонтитул: целую строку, удлинённый вариант (на альбомных
|
||||
страницах он шире) и прилипший к концу строки ("...на 7Страница 24 из 117")."""
|
||||
n = norm_edge(line)
|
||||
if n in junk or any(len(j) >= 30 and (n.startswith(j) or j.startswith(n) and len(n) >= 30) for j in junk):
|
||||
return ""
|
||||
for j in junk:
|
||||
if len(j) >= 10 and "#" in j:
|
||||
line = re.sub(re.escape(j).replace("\\#", r"\d+") + r"\s*$", "", line)
|
||||
return line
|
||||
|
||||
|
||||
def clean_cell(cell: str | None) -> str:
|
||||
if not cell:
|
||||
return ""
|
||||
cell = re.sub(r"(\w)-\s*\n\s*(?=[а-яёa-z])", r"\1" + HYPHEN, cell) # перенос в узкой ячейке
|
||||
cell = re.sub(r"([а-яё])\n(?=[а-яё])", r"\1" + SOFT, cell)
|
||||
return re.sub(r"\s*\n\s*", " ", cell).replace("|", "/").strip()
|
||||
|
||||
|
||||
def is_header(row: list[str]) -> bool:
|
||||
"""Шапка — короткие подписи колонок; у таблиц-опросников её нет и первая
|
||||
строка — уже пункт шкалы (его нельзя повторять на страницах-продолжениях)."""
|
||||
return not ITEM.match(row[0]) and all(len(c) <= 100 for c in row)
|
||||
|
||||
|
||||
def merge_split_rows(rows: list[list[str]]) -> list[list[str]]:
|
||||
"""Пункт шкалы, разрезанный на "N. НАЗВАНИЕ" и строку с вариантами "0 □ ...",
|
||||
склеивается обратно в одну строку."""
|
||||
out: list[list[str]] = []
|
||||
for row in rows:
|
||||
if out and OPTION.match(row[0]) and "□" not in out[-1][0]:
|
||||
out[-1] = [f"{a} {c}".strip() for a, c in zip(out[-1], row)]
|
||||
else:
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
|
||||
def table_to_md(header: list[str], body: list[list[str]]) -> str:
|
||||
out = ["| " + " | ".join(header) + " |", "|" + "---|" * len(header)]
|
||||
out += ["| " + " | ".join(r) + " |" for r in body]
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def reflow(lines: list[str]) -> str:
|
||||
"""Склеивает строки, разорванные вёрсткой, обратно в абзацы."""
|
||||
paras: list[str] = []
|
||||
for raw in lines:
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
continue
|
||||
prev = paras[-1] if paras else ""
|
||||
if prev and GRADE_TAIL.match(line):
|
||||
paras[-1] = prev + " " + line
|
||||
continue
|
||||
joinable = (
|
||||
prev
|
||||
and line[0].islower()
|
||||
and not BULLET.match(line)
|
||||
and not DEFINITION.match(line)
|
||||
)
|
||||
if joinable and prev.endswith("-") and not prev.endswith(" -"):
|
||||
paras[-1] = prev[:-1] + HYPHEN + line # перенос или составное слово
|
||||
elif joinable:
|
||||
paras[-1] = prev + " " + line
|
||||
else:
|
||||
paras.append(line)
|
||||
return "\n".join(paras)
|
||||
|
||||
|
||||
def region_text(pdf_path: Path, num: int, page: Any, top: float, bottom: float) -> str:
|
||||
"""Текст горизонтальной полосы страницы. pdftotext надёжнее pdfplumber на
|
||||
буквах с диакритикой (у pdfplumber выходит "K o z a r i ć")."""
|
||||
y = math.floor(top)
|
||||
h = math.ceil(bottom) - y
|
||||
if h <= 0:
|
||||
return ""
|
||||
cmd = ["pdftotext", "-nopgbrk", "-f", str(num), "-l", str(num), "-x", "0", "-y", str(y),
|
||||
"-W", str(math.ceil(page.width)), "-H", str(h), str(pdf_path), "-"]
|
||||
return subprocess.run(cmd, capture_output=True, text=True, check=True).stdout
|
||||
|
||||
|
||||
def word_bag(text: str) -> Counter[str]:
|
||||
return Counter(w.lower() for w in re.findall(r"\w+", text.replace(HYPHEN, "")))
|
||||
|
||||
|
||||
def page_blocks(pdf_path: Path, num: int, page: Any) -> list[tuple[str, Any]]:
|
||||
"""Текстовые полосы и таблицы страницы сверху вниз."""
|
||||
# у таблицы, разорванной страницей, нет нижней (или верхней) рамки — без неё
|
||||
# pdfplumber теряет крайнюю строку; дорисовываем линии по концам вертикалей
|
||||
ends = {round(y) for e in page.vertical_edges if e["height"] > 5 for y in (e["top"], e["bottom"])}
|
||||
settings = {"explicit_horizontal_lines": sorted(ends)}
|
||||
tables = []
|
||||
for t in sorted(page.find_tables(settings), key=lambda t: t.bbox[1]):
|
||||
rows = [[clean_cell(c) for c in row] for row in t.extract()]
|
||||
# рамка, внутри которой текст лежит не в ячейках, — не таблица: иначе текст пропадёт
|
||||
band = word_bag(region_text(pdf_path, num, page, t.bbox[1], t.bbox[3]))
|
||||
if band - word_bag(" ".join(" ".join(r) for r in rows)):
|
||||
continue
|
||||
tables.append((t.bbox[1], t.bbox[3], rows))
|
||||
blocks: list[tuple[str, Any]] = []
|
||||
top = 0.0
|
||||
for t_top, t_bottom, rows in tables:
|
||||
if t_top < top: # вложенная/перекрывающаяся таблица
|
||||
continue
|
||||
blocks.append(("text", region_text(pdf_path, num, page, top, t_top)))
|
||||
blocks.append(("table", rows))
|
||||
top = t_bottom
|
||||
blocks.append(("text", region_text(pdf_path, num, page, top, page.height)))
|
||||
return blocks
|
||||
|
||||
|
||||
def resolve_hyphens(text: str) -> str:
|
||||
""""клинико-\nпсихопатологические" — составное слово, "антихо-\nлинерги-\nческие" —
|
||||
перенос. Склеиваем без дефиса, если склейка встречается в документе целиком или
|
||||
хотя бы одна половинка — не слово (обрывок слога); иначе оставляем дефис."""
|
||||
vocab = {w.lower() for w in re.findall(r"\w+", re.sub(rf"\w*[{HYPHEN}{SOFT}]\w*", " ", text))}
|
||||
|
||||
def fix(m: re.Match[str]) -> str:
|
||||
a, b = m.group(1), m.group(2)
|
||||
whole = (a + b).lower() in vocab
|
||||
compound = a.lower() in vocab and b.lower() in vocab
|
||||
return a + b if whole or not compound else f"{a}-{b}"
|
||||
|
||||
pattern = re.compile(rf"(\w+){HYPHEN}(\w+)")
|
||||
while HYPHEN in text:
|
||||
new = pattern.sub(fix, text, count=0)
|
||||
if new == text:
|
||||
break
|
||||
text = new
|
||||
text = text.replace(HYPHEN, "-")
|
||||
|
||||
def fix_soft(m: re.Match[str]) -> str:
|
||||
a, b = m.group(1), m.group(2)
|
||||
broken = (a + b).lower() in vocab and not (a.lower() in vocab and b.lower() in vocab)
|
||||
return a + b if broken else f"{a} {b}"
|
||||
|
||||
return re.sub(rf"(\w+){SOFT}(\w+)", fix_soft, text).replace(SOFT, " ")
|
||||
|
||||
|
||||
def convert(pdf_path: Path) -> str:
|
||||
manual = MANUAL_DIR / f"{pdf_path.stem}.txt"
|
||||
if manual.exists():
|
||||
return manual.read_text()
|
||||
with pdfplumber.open(pdf_path) as pdf:
|
||||
pages = [page_blocks(pdf_path, n, p) for n, p in enumerate(pdf.pages, 1)]
|
||||
|
||||
# колонтитулы: строки у края страницы, повторяющиеся на >= 40% страниц
|
||||
edge: Counter[str] = Counter()
|
||||
for page in pages:
|
||||
lines = [l for kind, b in page if kind == "text" for l in b.splitlines() if l.strip()]
|
||||
edge.update({norm_edge(l) for l in lines[:EDGE_LINES] + lines[-EDGE_LINES:]})
|
||||
junk = {k for k, n in edge.items() if n >= max(3, 0.4 * len(pages))}
|
||||
|
||||
# blocks: ["page", N] | ["text", str] | ["table", (header, body)] | ["figure", str]
|
||||
blocks: list[list[Any]] = []
|
||||
for num, page in enumerate(pages, 1):
|
||||
blocks.append(["page", num])
|
||||
first_on_page = True
|
||||
for kind, b in page:
|
||||
prev = next((x for x in reversed(blocks) if x[0] != "page"), None)
|
||||
if kind == "text":
|
||||
text = reflow([strip_junk(l, junk) for l in b.splitlines()])
|
||||
if not text:
|
||||
continue
|
||||
if first_on_page and prev and prev[0] == "text" and text[0].islower():
|
||||
# абзац перетёк на новую страницу: дописываем его хвост к началу
|
||||
head, _, rest = text.partition("\n")
|
||||
prev[1] = prev[1] + " " + head
|
||||
if rest:
|
||||
blocks.append(["text", rest])
|
||||
else:
|
||||
blocks.append(["text", text])
|
||||
else:
|
||||
rows = [r for r in b if any(r)]
|
||||
if not rows:
|
||||
continue
|
||||
continues = (
|
||||
first_on_page
|
||||
and prev is not None
|
||||
and prev[0] == "table"
|
||||
and len(prev[1][0]) == len(rows[0])
|
||||
and rows[0] != prev[1][0]
|
||||
)
|
||||
if continues:
|
||||
header, body = prev[1]
|
||||
tail = body if body else [header]
|
||||
if not rows[0][0] or rows[0][0][:1].islower() or (
|
||||
OPTION.match(rows[0][0]) and "□" not in tail[-1][0]
|
||||
):
|
||||
# строка таблицы разорвана границей страницы
|
||||
tail[-1] = [f"{a} {c}".strip() for a, c in zip(tail[-1], rows[0])]
|
||||
if not body:
|
||||
prev[1] = (tail[0], body)
|
||||
rows = rows[1:]
|
||||
if rows and is_header(header):
|
||||
blocks.append(["table", (header, rows)])
|
||||
elif rows: # шапки нет — не дублируем первый пункт
|
||||
blocks.append(["table", (rows[0], rows[1:])])
|
||||
else:
|
||||
blocks.append(["table", (rows[0], rows[1:])])
|
||||
first_on_page = False
|
||||
fig = FIGURES_DIR / f"{pdf_path.stem}.p{num}.txt"
|
||||
if fig.exists():
|
||||
blocks.append(["figure", fig.read_text().strip()])
|
||||
|
||||
out = [f"# {pdf_path.stem}", f"Источник: {pdf_path.name}, страниц: {len(pages)}"]
|
||||
for kind, b in blocks:
|
||||
if kind == "page":
|
||||
out.append(f"--- стр. {b} ---")
|
||||
elif kind == "text":
|
||||
out.append(b)
|
||||
elif kind == "table":
|
||||
header, *body = merge_split_rows([b[0], *b[1]])
|
||||
out.append(table_to_md(header, body))
|
||||
else:
|
||||
out.append("[Изображение: текст перенесён вручную]\n" + b)
|
||||
return resolve_hyphens("\n\n".join(out)) + "\n"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("pdfs", nargs="+", type=Path)
|
||||
parser.add_argument("--out", type=Path, help="папка для txt (по умолчанию рядом с pdf)")
|
||||
args = parser.parse_args()
|
||||
for src in args.pdfs:
|
||||
dst = (args.out / src.name if args.out else src).with_suffix(".txt")
|
||||
dst.write_text(convert(src))
|
||||
print(f"{src.name} -> {dst}")
|
||||
@@ -0,0 +1,309 @@
|
||||
"""RAG knowledge base (#60): corpus integrity, retrieval quality, grounded and safe answers."""
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.types import ScrubbedText
|
||||
from app.llm.gateway import LLMGateway
|
||||
from app.llm.mock import MockTasks
|
||||
from app.llm.rag import qa as answer_module
|
||||
from app.llm.rag.corpus import heading, load_chunks, load_config, rag_dir, split_sentences
|
||||
from app.llm.rag.index import get_index, tokens
|
||||
from app.llm.rag.qa import LIT_REFS, PRESCRIBING, QUOTE_FILTER, answer, fallback_text, select
|
||||
from app.llm.rag.stemmer import stem
|
||||
|
||||
S = ScrubbedText
|
||||
|
||||
# question -> (document, pattern that must occur in one of the top-3 chunks). Answers checked by hand
|
||||
# against the PDFs; the set doubles as the tuning set, so the threshold below guards against regressions.
|
||||
GOLD = [
|
||||
("через сколько начинают действовать антидепрессанты", "kr_depression", r"Тимоаналептический эффект обычно"),
|
||||
("когда антидепрессант даст эффект", "kr_depression", r"Тимоаналептический эффект обычно"),
|
||||
("помогает ли ДПДГ при ПТСР", "kr_ptsd", r"ДПДГ"),
|
||||
("какая психотерапия эффективна при посттравматическом расстройстве", "kr_ptsd", r"наибольшей доказательной"),
|
||||
("как правильно отменять антидепрессанты", "kr_depression", r"постепенное снижение дозы"),
|
||||
("что такое флэшбек", "kr_ptsd", r"Флэшбек \(flashback"),
|
||||
("шкала Гамильтона для депрессии", "kr_depression", r"Шкала Гамильтона для оценки депрессии"),
|
||||
("помощь военнослужащим после боевой психической травмы", "kr_ptsd", r"военнослужащ"),
|
||||
("колумбийская шкала суицидального риска", "kr_depression", r"Колумбийская шкала"),
|
||||
("вызывают ли бензодиазепины зависимость", "kr_ptsd", r"формировать зависимость"),
|
||||
("что сказать пациенту о ПТСР", "kr_ptsd", r"Что такое посттравматическое"),
|
||||
("электросудорожная терапия при резистентной депрессии", "kr_depression", r"лектросудорожн"),
|
||||
("скрининговый опросник ПТСР", "kr_ptsd", r"Trauma Screening"),
|
||||
("физическая активность при депрессии", "kr_depression", r"физическ"),
|
||||
("сертралин при ПТСР", "kr_ptsd", r"ертралин"),
|
||||
("противорецидивная терапия депрессии сколько длится", "kr_depression", r"ротиворецидивн"),
|
||||
("правило M7 срочные симптомы", "meds_module_spec", r"M7"),
|
||||
("через сколько оценивают ответ на лечение", "meds_module_spec", r"4–6 неделе"),
|
||||
]
|
||||
GOLD_MIN_HITS = 15
|
||||
|
||||
OFF_TOPIC = [
|
||||
"какой рецепт борща",
|
||||
"как починить машину",
|
||||
"расписание электричек",
|
||||
"кто выиграл чемпионат по футболу",
|
||||
"курс доллара сегодня",
|
||||
"погода в москве завтра",
|
||||
"как установить windows",
|
||||
"лучший фильм года",
|
||||
"как выучить английский",
|
||||
]
|
||||
|
||||
PATIENT_QUESTIONS = [
|
||||
"через сколько начинают действовать антидепрессанты",
|
||||
"что такое ПТСР",
|
||||
"что такое флэшбек",
|
||||
"помогает ли психотерапия при ПТСР",
|
||||
"как правильно отменять антидепрессанты",
|
||||
"что будет если пить таблетки с алкоголем",
|
||||
"сертралин при ПТСР",
|
||||
"вызывают ли бензодиазепины зависимость",
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_gateway(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(answer_module, "get_gateway", lambda: LLMGateway(MockTasks()))
|
||||
|
||||
|
||||
class FixedPhraseTasks(MockTasks):
|
||||
"""An LLM that returns a fixed rephrasing."""
|
||||
|
||||
name = "fake-llm"
|
||||
|
||||
def __init__(self, reply: str) -> None:
|
||||
self.reply = reply
|
||||
|
||||
async def phrase(self, intent: str, payload: ScrubbedText, base_text: str) -> str | None:
|
||||
return self.reply
|
||||
|
||||
|
||||
def _words(text: str) -> str:
|
||||
return " ".join(re.findall(r"\w+", LIT_REFS.sub("", text).lower()))
|
||||
|
||||
|
||||
# --- stemmer ------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("word", "expected"),
|
||||
[
|
||||
# reference outputs of the Snowball Russian stemmer
|
||||
("вагоне", "вагон"),
|
||||
("важнейшими", "важн"),
|
||||
("гостиница", "гостиниц"),
|
||||
("сделавшись", "сдела"),
|
||||
("посттравматическое", "посттравматическ"),
|
||||
("антидепрессантов", "антидепрессант"),
|
||||
("пробуждения", "пробужден"),
|
||||
("ёлками", "елк"),
|
||||
("SSRI", "ssri"),
|
||||
],
|
||||
)
|
||||
def test_stemmer(word: str, expected: str) -> None:
|
||||
assert stem(word) == expected
|
||||
|
||||
|
||||
def test_tokens_drop_stop_words_and_split_hyphens() -> None:
|
||||
assert tokens("что такое когнитивно-поведенческая терапия") == [
|
||||
"когнитивн-поведенческ",
|
||||
"когнитивн",
|
||||
"поведенческ",
|
||||
"терап",
|
||||
]
|
||||
|
||||
|
||||
# --- corpus -------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _page_of(text: str, line: str) -> int | None:
|
||||
"""Page number of the first line equal to `line`."""
|
||||
page = None
|
||||
for ln in text.splitlines():
|
||||
if m := re.match(r"^--- стр\. (\d+) ---$", ln):
|
||||
page = int(m.group(1))
|
||||
elif ln == line:
|
||||
return page
|
||||
return None
|
||||
|
||||
|
||||
def test_every_page_is_chunked_except_bibliography() -> None:
|
||||
chunks = load_chunks()
|
||||
for doc in load_config().documents:
|
||||
text = (rag_dir() / doc.file).read_text(encoding="utf-8")
|
||||
n_pages = len(re.findall(r"^--- стр\. \d+ ---$", text, re.M))
|
||||
own = [c for c in chunks if c.doc.id == doc.id]
|
||||
assert own, doc.id
|
||||
assert all(1 <= c.page_start <= c.page_end <= n_pages for c in own)
|
||||
missing = set(range(1, n_pages + 1)) - {p for c in own for p in range(c.page_start, c.page_end + 1)}
|
||||
lit, appendix = _page_of(text, "Список литературы"), _page_of(text, "Приложение А1")
|
||||
allowed = set(range(lit + 1, appendix)) if lit and appendix else set()
|
||||
assert missing <= allowed, (doc.id, sorted(missing - allowed))
|
||||
|
||||
|
||||
def test_chunks_are_bounded_and_ids_unique() -> None:
|
||||
chunks = load_chunks()
|
||||
assert len({c.id for c in chunks}) == len(chunks)
|
||||
max_chars = load_config().chunking.max_chars
|
||||
# only a single oversized table row may exceed the limit; prose is split by sentences
|
||||
for c in chunks:
|
||||
if len(c.text) > 2 * max_chars:
|
||||
assert any(len(line) > max_chars for line in c.text.splitlines() if line.startswith("|")), c.id
|
||||
|
||||
|
||||
def test_bibliography_and_abbreviations_not_indexed() -> None:
|
||||
sections = {c.section for c in load_chunks()}
|
||||
assert not any(s.startswith(("Список литературы", "Список сокращений")) for s in sections)
|
||||
assert "3.2. Психофармакотерапия" in sections
|
||||
assert "3.6 Противорецидивная (профилактическая) терапия" in sections
|
||||
|
||||
|
||||
def test_table_pieces_repeat_header() -> None:
|
||||
for c in load_chunks():
|
||||
lines = c.text.splitlines()
|
||||
for i, line in enumerate(lines):
|
||||
if line.startswith("|") and (i == 0 or not lines[i - 1].startswith("|")):
|
||||
assert i + 1 < len(lines) and re.match(r"^\|(-+\|)+$", lines[i + 1]), c.id
|
||||
|
||||
|
||||
def test_hamd_items_not_duplicated_or_split() -> None:
|
||||
"""Converter regression: item 1 of HAM-D was repeated as a fake header on every page, items 2/17 split."""
|
||||
text = (rag_dir() / "corpus" / "kr_depression.txt").read_text(encoding="utf-8")
|
||||
assert text.count("| 1. СНИЖЕННОЕ НАСТРОЕНИЕ") == 1
|
||||
assert re.search(r"^\| 2\. ЧУВСТВО ВИНЫ 0 □", text, re.M)
|
||||
assert re.search(r"^\| 17\. КРИТИКА 0 □", text, re.M)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("line", "is_heading"),
|
||||
[
|
||||
("2.1 Жалобы и анамнез", True),
|
||||
("3.1.2. Купирующая терапия ДЭ средней степени", True),
|
||||
("3. Лечение, включая медикаментозную и немедикаментозную терапии", True),
|
||||
("Приложение Г5", True),
|
||||
("2. Стойкие симптомы повышения психологической чувствительности", False), # list item
|
||||
("28.07.2022 N 69435).", False), # date
|
||||
("| 2.1 | таблица |", False),
|
||||
],
|
||||
)
|
||||
def test_heading(line: str, is_heading: bool) -> None:
|
||||
assert (heading(line) is not None) == is_heading
|
||||
|
||||
|
||||
def test_split_sentences_keeps_abbreviations() -> None:
|
||||
assert split_sentences("Алгоритм ведения (см. Приложение Б5). Далее текст.") == [
|
||||
"Алгоритм ведения (см. Приложение Б5).",
|
||||
"Далее текст.",
|
||||
]
|
||||
|
||||
|
||||
# --- retrieval ----------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_gold_questions_hit_at_3() -> None:
|
||||
index = get_index()
|
||||
misses = [
|
||||
q
|
||||
for q, doc, pattern in GOLD
|
||||
if not any(
|
||||
h.chunk.doc.id == doc and re.search(pattern, h.chunk.text) for h in index.search(q, top_k=3, min_score=0)
|
||||
)
|
||||
]
|
||||
assert len(GOLD) - len(misses) >= GOLD_MIN_HITS, misses
|
||||
|
||||
|
||||
def test_synonyms_expand_abbreviations() -> None:
|
||||
terms = get_index().query_terms("ПТСР")
|
||||
assert terms["птср"] == 1.0
|
||||
assert 0 < terms["посттравматическ"] < 1.0
|
||||
|
||||
|
||||
def test_team_documents_never_reach_patients_or_doctors() -> None:
|
||||
for audience in ("patient", "doctor"):
|
||||
hits = get_index().search("правила M1–M10 модуль лекарства", audience=audience, min_score=0, top_k=20)
|
||||
assert all("team" not in h.chunk.doc.audiences for h in hits)
|
||||
team = get_index().search("правило M7 срочные симптомы", audience="team")
|
||||
assert team and team[0].chunk.doc.id == "meds_module_spec"
|
||||
|
||||
|
||||
def test_patient_information_appendix_boosted() -> None:
|
||||
hit = get_index().search("что такое ПТСР", audience="patient")[0]
|
||||
assert hit.chunk.section.startswith("Приложение В")
|
||||
|
||||
|
||||
# --- answers ------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("question", OFF_TOPIC)
|
||||
async def test_off_topic_has_no_answer(question: str) -> None:
|
||||
assert await answer(S(question), audience="patient") is None
|
||||
assert await answer(S(question), audience="doctor") is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("question", ["можно увеличить дозу сертралина?", "можно пить полтаблетки?"])
|
||||
async def test_dose_question_redirected(question: str) -> None:
|
||||
assert await answer(S(question), audience="patient") is None
|
||||
assert "врач" in fallback_text("patient")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("question", PATIENT_QUESTIONS)
|
||||
async def test_patient_answer_is_extractive_and_safe(question: str) -> None:
|
||||
reply = await answer(S(question), audience="patient")
|
||||
if reply is None: # "ask your doctor" is always a valid outcome
|
||||
return
|
||||
sources = {c.id: _words(c.text) for c in load_chunks()}
|
||||
for sentence in split_sentences(reply.excerpt):
|
||||
assert any(_words(sentence) in text for text in sources.values()), sentence # quoted, not generated
|
||||
assert not PRESCRIBING.search(reply.excerpt)
|
||||
assert not any(p.search(reply.excerpt) for p in QUOTE_FILTER)
|
||||
assert "Источник:" in reply.text and "стр." in reply.text
|
||||
assert reply.text.rstrip().endswith(answer_module.load_rag_texts()["patient"].doctor_note)
|
||||
assert all(c.doc != "meds_module_spec" for c in reply.citations)
|
||||
|
||||
|
||||
async def test_onset_answer_content() -> None:
|
||||
reply = await answer(S("через сколько начинают действовать антидепрессанты"), audience="patient")
|
||||
assert reply is not None
|
||||
assert re.search(r"недел", reply.excerpt)
|
||||
assert reply.coverage >= load_config().answer.min_coverage
|
||||
|
||||
|
||||
async def test_definition_question_prefers_definition() -> None:
|
||||
reply = await answer(S("что такое флэшбек"), audience="patient")
|
||||
assert reply is not None and reply.excerpt.startswith("Флэшбек (flashback")
|
||||
assert reply.citations[0].pages == "2"
|
||||
|
||||
|
||||
async def test_doctor_answer_verbatim_with_citations() -> None:
|
||||
reply = await answer(S("синдром отмены антидепрессантов"), audience="doctor")
|
||||
assert reply is not None and not reply.phrased_by_llm
|
||||
assert re.search(r"\[КР Депрессия 2024, стр\. \d+(–\d+)?\]", reply.text)
|
||||
assert "СОПА" in reply.excerpt
|
||||
|
||||
|
||||
async def test_doctor_sees_dosing_patient_does_not() -> None:
|
||||
q = S("сертралин при ПТСР")
|
||||
doctor = await answer(q, audience="doctor")
|
||||
assert doctor is not None and re.search(r"сертралин\*\* 25-200 мг", doctor.excerpt)
|
||||
patient = await answer(q, audience="patient")
|
||||
assert patient is None or "мг" not in patient.excerpt
|
||||
|
||||
|
||||
async def test_llm_rephrasing_that_adds_numbers_is_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(answer_module, "get_gateway", lambda: LLMGateway(FixedPhraseTasks("Эффект будет через 2 дня.")))
|
||||
reply = await answer(S("через сколько начинают действовать антидепрессанты"), audience="patient")
|
||||
assert reply is not None and not reply.phrased_by_llm
|
||||
assert "2 дня" not in reply.text
|
||||
|
||||
|
||||
async def test_llm_rephrasing_kept_when_faithful(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
q = "помогает ли психотерапия при ПТСР"
|
||||
excerpt = " ".join(p.text for p in select(get_index(), q, "patient"))
|
||||
assert "Это помогает" in excerpt # anaphora pulled in the sentence it refers to
|
||||
simplified = excerpt.replace("Одновременно с этим, психотерапия", "Кроме того, психотерапия")
|
||||
monkeypatch.setattr(answer_module, "get_gateway", lambda: LLMGateway(FixedPhraseTasks(simplified)))
|
||||
reply = await answer(S(q), audience="patient")
|
||||
assert reply is not None and reply.phrased_by_llm
|
||||
assert simplified in reply.text and reply.excerpt == excerpt # the verbatim quote is kept for audit
|
||||
Reference in New Issue
Block a user