release: dev → main (сдача хакатона) #106

Merged
Eleboba merged 128 commits from dev into main 2026-10-04 23:00:56 +03:00
26 changed files with 7776 additions and 0 deletions
Showing only changes of commit 6c212d560c - Show all commits
+24
View File
@@ -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",
]
+52
View File
@@ -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())
+297
View File
@@ -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)
+212
View File
@@ -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)
+317
View File
@@ -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
)
+112
View File
@@ -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
+1
View File
@@ -7,3 +7,4 @@
- `modules.yaml` — модули и расписания шкал - `modules.yaml` — модули и расписания шкал
- `lexicon/risk_ru.yaml` — лексикон риска - `lexicon/risk_ru.yaml` — лексикон риска
- `texts/` — тексты для пациента (кризисный скрипт, согласие, перефразировки) - `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
+184
View File
@@ -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 мг.
+102
View File
@@ -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", "десенсибилизация и переработка травмы движениями глаз"]
- ["сопа", "синдром отмены антидепрессантов"]
- ["эст", "электросудорожная терапия"]
- ["ртмс", "транскраниальная магнитная стимуляция"]
- ["кошмар", "кошмарные сновидения"]
- ["бессонница", "инсомния", "нарушения сна"]
- ["флешбэк", "флэшбек", "навязчивые воспоминания"]
- ["суицид", "самоубийство", "суицидальные мысли"]
# Разговорная лексика пациента -> термины КР.
- ["таблетки", "препарат", "лекарство"]
- ["бросить", "отменять", "отменить", "отмена", "прекратить", "прекращение приема"]
- ["подействовать", "действовать", "помогать", "эффект", "улучшение"]
- ["антидепрессивный", "тимоаналептический"]
- ["привыкание", "зависимость"]
- ["спорт", "физическая активность", "физические упражнения", "физическая нагрузка"]
- ["побочка", "побочные эффекты", "нежелательные явления"]
- ["тошнит", "тошнота"]
- ["выпить", "спиртное", "алкоголь"]
- ["сколько длится", "продолжительность", "длительность"]
- ["психолог", "психотерапия"]
+2
View File
@@ -21,6 +21,8 @@ phrases:
doctor_notified: "Я передал информацию вашему врачу. Он свяжется с вами." doctor_notified: "Я передал информацию вашему врачу. Он свяжется с вами."
# med_qa: базовый смысл = выдержка из инструкции ГРЛС; LLM только упрощает её, ничего не добавляя. # med_qa: базовый смысл = выдержка из инструкции ГРЛС; LLM только упрощает её, ничего не добавляя.
med_answer: "{excerpt}" med_answer: "{excerpt}"
# RAG (#60): базовый смысл = выдержка из клинических рекомендаций; LLM только упрощает её, ничего не добавляя.
rag_answer: "{excerpt}"
# Шаблон саммари для врача (только по структурированным данным). # Шаблон саммари для врача (только по структурированным данным).
summary: summary:
+24
View File
@@ -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: ""
+6
View File
@@ -3,6 +3,12 @@
> Живой журнал: **что** решили и **почему**. Новые записи — сверху. Одна запись = одно решение. > Живой журнал: **что** решили и **почему**. Новые записи — сверху. Одна запись = одно решение.
> При конфликте с [`docs/info/SPEC.md`](info/SPEC.md) действует ADR. > При конфликте с [`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: Фокус — ветераны СВО; разработчики делят слои, а не направления ## 2026-10-03 — ADR-010: Фокус — ветераны СВО; разработчики делят слои, а не направления
**Контекст:** три направления (ветераны, семьи, общая популяция с депрессией) и три разработчика. Если каждый ведёт своё направление, все трое правят одни и те же файлы: движок, API, дашборд. **Контекст:** три направления (ветераны, семьи, общая популяция с депрессией) и три разработчика. Если каждый ведёт своё направление, все трое правят одни и те же файлы: движок, API, дашборд.
**Решение:** на хакатоне делаем только ветеранов (эпик #35); семьи (#40) и общая популяция (#43) — опциональные направления на будущее, без срока. Разработчики делят **слои**: A — бот (диалог, каналы, LLM), B — backend (API, сервисы, шедулер, адаптеры, БД, ядро), C — дашборд. Между слоями — замороженные контракты (#34) с заглушками, поэтому все трое стартуют одновременно. Направления подключаются как `app/dialog/flows/<module>.py` + `config/modules.yaml`. **Решение:** на хакатоне делаем только ветеранов (эпик #35); семьи (#40) и общая популяция (#43) — опциональные направления на будущее, без срока. Разработчики делят **слои**: A — бот (диалог, каналы, LLM), B — backend (API, сервисы, шедулер, адаптеры, БД, ядро), C — дашборд. Между слоями — замороженные контракты (#34) с заглушками, поэтому все трое стартуют одновременно. Направления подключаются как `app/dialog/flows/<module>.py` + `config/modules.yaml`.
+29
View File
@@ -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. «Симптомы СОПА» — по выраженности:
- «Легкая выраженность симптомов» → «Необходимо успокоить пациента, объяснив временный характер симптомов».
- «Умеренная выраженность симптомов» → «Симптоматическая терапия».
- «Тяжелая выраженность симптомов» → два варианта:
а) «При отмене СИОЗС или СИОЗСН можно перейти на флуоксетин; при отмене АД из других групп можно перейти на фармакодинамически сходный АД с более длительным периодом полувыведения с прекращением приема исходного АД после нескольких недель лечения новым АД и постепенной отменой последнего в последующем»;
б) «Можно возобновить АД в минимально эффективной дозе или в той дозе, при которой не было симптомов отмены, после чего отменить его более дробным и медленным титрованием».
+30
View File
@@ -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. «Есть необходимость проведения реабилитационных мероприятий?»
- нет → «Завершение лечения».
- да → «Направление на реабилитацию».
+18
View File
@@ -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.
+184
View File
@@ -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.
+278
View File
@@ -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}")
+309
View File
@@ -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