From fe29ebd629b9ccdaec5d3aca6a47aa7faf9eab47 Mon Sep 17 00:00:00 2001 From: Lacky1234union Date: Sun, 4 Oct 2026 04:31:59 +0300 Subject: [PATCH 01/12] fix(llm): RAG citation pages, scrub placeholders, disease-aware document choice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - converter marks a paragraph glued across a page break with ⟦стр. N⟧; the chunker strips the marker and extends the chunk's page range (wrong-page citations 24 -> 3, the rest are scale-form table rows split by a page) - [NAME]/[PHONE] placeholders from privacy.scrub() no longer count as unknown words (a scrubbed question used to fall below the coverage gate) - a question naming one disease boosts its guideline in retrieval and passage choice ("ТМС при депрессии" was answered from the PTSD guideline); "антидепрессант" is not a cue Refs #62 Co-Authored-By: Claude Opus 5.5 --- app/llm/rag/corpus.py | 69 +++++++++++++++++++--- app/llm/rag/index.py | 27 +++++++-- app/llm/rag/qa.py | 6 +- config/rag/corpus/kr_depression.txt | 58 +++++++++---------- config/rag/corpus/kr_ptsd.txt | 88 ++++++++++++++--------------- config/rag/sources.yaml | 6 ++ docs/source/rag/pdf_to_txt.py | 8 ++- tests/test_llm_rag.py | 35 ++++++++++++ 8 files changed, 206 insertions(+), 91 deletions(-) diff --git a/app/llm/rag/corpus.py b/app/llm/rag/corpus.py index cdb82c3..98785db 100644 --- a/app/llm/rag/corpus.py +++ b/app/llm/rag/corpus.py @@ -9,7 +9,8 @@ The txt files come from `docs/source/rag/pdf_to_txt.py`: paragraphs are separate * "Комментарии: ..." 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]". +* each chunk keeps the page range it came from, for the citation "[КР ПТСР 2023, стр. 45]"; inline + "⟦стр. N⟧" markers (paragraph glued across a page break) are removed from the text but move the range. """ import re @@ -25,6 +26,8 @@ from app.config import get_settings Audience = str # "doctor" | "patient" | "team" PAGE_MARKER = re.compile(r"^--- стр\. (\d+) ---$") +# A paragraph that flows onto the next page is glued by the converter; "⟦стр. N⟧" marks where page N begins. +INLINE_PAGE = re.compile(r"\s*⟦стр\. (\d+)⟧\s*") TABLE_DIVIDER = re.compile(r"^\|(-+\|)+$") # "2.1 Жалобы и анамнез", "3.1.2. Купирующая терапия ..." — a numbered subsection title. SUBSECTION = re.compile(r"^(\d{1,2}(?:\.\d{1,2})+)\.?\s+([А-ЯЁA-Z].*)$") # not dates "28.07.2022 N ..." @@ -77,6 +80,9 @@ class RetrievalConfig(BaseModel): window_weight: float = 0.5 # section title prefix -> score multiplier, per audience ({"patient": {"Приложение В": 1.5}}) section_boost: dict[str, dict[str, float]] = Field(default_factory=dict) + # doc id -> regexes naming its disease; a question naming one disease favours that document + doc_cues: dict[str, list[str]] = Field(default_factory=dict) + doc_cue_boost: float = 1.0 class AnswerConfig(BaseModel): @@ -120,8 +126,46 @@ class _Unit: """Indivisible piece of a chunk: a paragraph (with its comment) or one table row.""" text: str - page: int + page: int # page where the unit starts table_header: str | None = None # "| a | b |\n|---|---|" for table rows + breaks: list[tuple[int, int]] = field(default_factory=list) # (offset in text, page starting there) + + @property + def page_end(self) -> int: + return self.breaks[-1][1] if self.breaks else self.page + + def page_at(self, offset: int) -> int: + page = self.page + for start, p in self.breaks: + if start <= offset: + page = p + return page + + def append(self, line: str, page: int, sep: str) -> None: + """Glue a continuation line; record a page break if it comes from a later page.""" + base = len(self.text) + len(sep) + text, breaks = _strip_inline_pages(line) + if page > self.page_end: + self.breaks.append((base, page)) + self.breaks.extend((base + off, p) for off, p in breaks) + self.text += sep + text + + +def _strip_inline_pages(line: str) -> tuple[str, list[tuple[int, int]]]: + """Text without "⟦стр. N⟧" markers + (offset, page) of each marker in the cleaned text.""" + out, breaks, pos = [], [], 0 + for m in INLINE_PAGE.finditer(line): + out.append(line[pos : m.start()]) + breaks.append((sum(len(x) for x in out) + 1, int(m.group(1)))) + out.append(" ") + pos = m.end() + out.append(line[pos:]) + return "".join(out), breaks + + +def _new_unit(line: str, page: int) -> _Unit: + text, breaks = _strip_inline_pages(line) + return _Unit(text, page, breaks=breaks) @dataclass @@ -185,7 +229,7 @@ def _sections(text: str, skip: list[str]) -> list[_Section]: 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)) + sections[-1].units.append(_new_unit("\n".join(block[1:]) if len(block) > 1 else first, page)) continue for j, line in enumerate(block): title = heading(line) @@ -199,11 +243,11 @@ def _sections(text: str, skip: list[str]) -> list[_Section]: continue units = sections[-1].units if COMMENT.match(line) and units and units[-1].table_header is None: - units[-1].text += "\n" + line + units[-1].append(line, page, "\n") 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 + units[-1].append(line, page, " ") # paragraph continued after a page break or a figure else: - units.append(_Unit(line, page)) + units.append(_new_unit(line, page)) return [s for s in sections if s.units] @@ -236,7 +280,16 @@ def _split_long(unit: _Unit, max_chars: int) -> list[_Unit]: parts[-1] += " " + sentence else: parts.append(sentence) - return [_Unit(p, unit.page) for p in parts] + out: list[_Unit] = [] + pos = 0 + for part in parts: + start = unit.text.find(part[:40], pos) + start = pos if start < 0 else start + end = start + len(part) - 1 + first, last = unit.page_at(start), unit.page_at(end) + out.append(_Unit(part, first, breaks=[(len(part) - 1, last)] if last != first else [])) + pos = start + len(part) + return out def _render(units: list[_Unit]) -> str: @@ -280,7 +333,7 @@ def chunk_document(doc: DocumentMeta, text: str, cfg: ChunkingConfig) -> list[Ch doc=doc, section=section.title, page_start=group[0].page, - page_end=group[-1].page, + page_end=max(u.page_end for u in group), text=_render(group), ) ) diff --git a/app/llm/rag/index.py b/app/llm/rag/index.py index e432ca9..b020bb9 100644 --- a/app/llm/rag/index.py +++ b/app/llm/rag/index.py @@ -20,6 +20,8 @@ from app.llm.rag.corpus import Audience, Chunk, RagConfig, load_chunks, load_con from app.llm.rag.stemmer import stem _WORD = re.compile(r"[а-яёa-z0-9]+(?:-[а-яёa-z0-9]+)*", re.IGNORECASE) +# `privacy.scrub()` placeholders ("[NAME]", "[PHONE]") are not words of the question. +_PLACEHOLDER = re.compile(r"\[[A-Z_]+\]") # Function words and generic KR boilerplate that carry no topical signal. STOP_WORDS = frozenset( @@ -36,7 +38,7 @@ STOP_WORDS = frozenset( 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("ё", "е")): + for word in _WORD.findall(_PLACEHOLDER.sub(" ", 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 @@ -97,6 +99,8 @@ class BM25Index: 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._doc_cues = {doc: re.compile("|".join(cues), re.IGNORECASE) for doc, cues in r.doc_cues.items() if cues} + self._doc_cue_boost = r.doc_cue_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) @@ -124,10 +128,20 @@ class BM25Index: 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.""" + def boost(self, chunk: Chunk, audience: Audience | None, cued: set[str] | None = None) -> float: + """Section multiplier for the audience (`retrieval.section_boost`) × disease-cue multiplier.""" boosts = self._section_boost.get(audience or "", {}) - return next((b for prefix, b in boosts.items() if chunk.section.startswith(prefix)), 1.0) + section = next((b for prefix, b in boosts.items() if chunk.section.startswith(prefix)), 1.0) + return section * (self._doc_cue_boost if cued and chunk.doc.id in cued else 1.0) + + def cued_docs(self, query: str) -> set[str]: + """Documents whose disease the query names ("ТМС при депрессии" -> {"kr_depression"}). + + Only a one-sided mention counts: a question naming both disorders favours neither. + """ + q = _PLACEHOLDER.sub(" ", query).replace("ё", "е") + cued = {doc for doc, cue in self._doc_cues.items() if cue.search(q)} + return cued if len(cued) < len(self._doc_cues) else set() 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()`).""" @@ -193,11 +207,14 @@ class BM25Index: and (s := self._score(i, terms)) >= threshold ] scored.sort(key=lambda x: (-x[0], x[1])) + cued = self.cued_docs(query) 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.append( + Hit(chunk, (bm25 + self._window_weight * bonus) * self.boost(chunk, audience, cued), bm25, passage) + ) hits.sort(key=lambda h: -h.score) # stable: BM25 order breaks ties return hits[: top_k or self._top_k] diff --git a/app/llm/rag/qa.py b/app/llm/rag/qa.py index e2794fd..fdaf7b6 100644 --- a/app/llm/rag/qa.py +++ b/app/llm/rag/qa.py @@ -198,6 +198,7 @@ def best_passage( audience: Audience, cfg: AnswerConfig, defined: set[str] | None = None, + cued: 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")] @@ -235,7 +236,7 @@ def best_passage( 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) + return Passage(chunk, text, index.overlap(text, terms) * index.boost(chunk, audience, cued), definition) def select(index: BM25Index, question: str, audience: Audience, docs: set[str] | None = None) -> list[Passage]: @@ -244,7 +245,8 @@ def select(index: BM25Index, question: str, audience: Audience, docs: set[str] | 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))] + cued = index.cued_docs(question) + passages = [p for h in hits if (p := best_passage(index, h.chunk, terms, audience, cfg, defined, cued))] if not passages: return [] # A definition answers "что такое X?" better than any passage that merely mentions X often. diff --git a/config/rag/corpus/kr_depression.txt b/config/rag/corpus/kr_depression.txt index 79171b5..814ec66 100644 --- a/config/rag/corpus/kr_depression.txt +++ b/config/rag/corpus/kr_depression.txt @@ -73,7 +73,7 @@ F32/F33 со здоровьем: Депрессивный эпизод (ДЭ) - аффективное расстройство, характеризующееся в типичных случаях снижением настроения, утратой интересов и удовольствия, снижением энергичности, которое может привести к повышенной утомляемости и сниженной активности. Отмечается выраженная утомляемость даже при незначительном усилии. К другим симптомам относятся сниженная способность к сосредоточению и вниманию; сниженные самооценка и чувство уверенности в себе; идеи виновности и уничижения (даже при легком типе эпизода); мрачное и пессимистическое видение будущего; идеи или действия, направленные на самоповреждение или суицид; нарушенный сон; сниженный аппетит [1]. Рекуррентное депрессивное расстройство (РДР) - аффективное расстройство, характеризующееся повторными эпизодами депрессий, как указано в F32, без анамнестических данных об отдельных эпизодах приподнятого настроения, гиперактивности, которые могли отвечать критериям гипоманиакального, маниакального или смешанного аффективного эпизода (F30.-) [1]. 1.2 Этиология и патогенез заболевания или состояния (группы заболеваний или состояний) -Этиология и патогенез ДЭ и РДР до настоящего времени окончательно не установлены. По всей видимости, возникновение депрессивных расстройств детерминировано сочетанием множества факторов: биологических (конституционально-генетических), психологических и социальных. Среди биологических концепций традиционной является моноаминовая теория депрессии, согласно которой основным патогенетическим механизмом ее возникновения является недостаточность синаптической активности моноаминов (серотонина, дофамина, норадреналина) головного мозга. Как значимые рассматриваются и другие патофизиологические процессы: проявления психосоциального и биологического дистресса, хронобиологическая гипотеза, нарушение процессов нейропластичности, дисрегуляция гипоталамо-гипофизарно-надпочечниковой оси, синтеза нейротрофинов, транскрипционного фактора CREB, а также нейроанатомические особенности гиппокампа, миндалевидного тела (амигдалы), прилежащего ядра и других областей мозга [2, 3, 4]. +Этиология и патогенез ДЭ и РДР до настоящего времени окончательно не установлены. По всей видимости, возникновение депрессивных расстройств детерминировано сочетанием множества факторов: биологических (конституционально-генетических), психологических и социальных. Среди биологических концепций традиционной является моноаминовая теория депрессии, согласно которой основным патогенетическим механизмом ее возникновения является недостаточность синаптической активности моноаминов (серотонина, дофамина, норадреналина) головного мозга. Как значимые рассматриваются и другие патофизиологические процессы: проявления психосоциального и биологического дистресса, хронобиологическая гипотеза, нарушение процессов нейропластичности, дисрегуляция гипоталамо-гипофизарно-надпочечниковой оси, синтеза нейротрофинов, транскрипционного фактора CREB, а также нейроанатомические особенности гиппокампа, ⟦стр. 3⟧ миндалевидного тела (амигдалы), прилежащего ядра и других областей мозга [2, 3, 4]. --- стр. 3 --- @@ -125,7 +125,7 @@ F33.9 - Рекуррентное депрессивное расстройств В типичных случаях больной с ДЭ страдает от сниженного настроения, утраты интересов и удовольствия, снижения энергичности, которое может привести к повышенной утомляемости и сниженной активности. Отмечается выраженная утомляемость даже при незначительном усилии. К другим симптомам относятся: сниженная способность к сосредоточению и вниманию, сниженные самооценка и чувство уверенности в себе, идеи виновности и уничижения (даже при легком типе эпизода), мрачное и пессимистическое видение будущего, идеи или действия, направленные на самоповреждение или суицид, нарушенный сон, сниженный аппетит. Сниженное настроение мало колеблется в течение дней, и часто нет реакции на окружающие обстоятельства, но могут быть характерные суточные колебания. В некоторых случаях тревога, отчаяние и двигательная ажитация временами могут быть более выраженными, чем депрессия, а изменения настроения также могут быть маскированы дополнительными симптомами: раздражительностью, чрезмерным употреблением алкоголя, истерическим поведением, обострением предшествующих фобических или навязчивых симптомов, ипохондрическими идеями. Некоторые из вышеуказанных симптомов могут быть выраженными и обнаруживают характерные признаки, которые считаются как имеющие специальное клиническое значение. Наиболее типичными примерами являются соматические и психотические симптомы. -В некоторых случаях в структуре депрессивного эпизода наблюдаются субсиндромальные маниакальные симптомы, независимо от того, возникают ли они в рамках БАР или РДР. МКБ-10 не разделяет смешанные состояния по доминирующему аффекту. В отличие от МКБ-10, в DSM-5 вводится уточняющий спецификатор "смешанные черты" и выделяются депрессивный эпизод со смешанными чертами и маниакальный эпизод со смешанными чертами. +В некоторых случаях в структуре депрессивного эпизода наблюдаются субсиндромальные маниакальные симптомы, независимо от того, возникают ли они в рамках БАР или РДР. МКБ-10 не разделяет смешанные состояния по доминирующему аффекту. В отличие от МКБ-10, в DSM-5 вводится ⟦стр. 5⟧ уточняющий спецификатор "смешанные черты" и выделяются депрессивный эпизод со смешанными чертами и маниакальный эпизод со смешанными чертами. --- стр. 5 --- @@ -247,7 +247,7 @@ A. критерии F33 удовлетворяются для прошлых э F33.8 Другие рекуррентные депрессивные расстройства F33.9 Рекуррентное депрессивное расстройство неуточненное 2.1 Жалобы и анамнез -● Всем пациентам с целью первичной диагностики рекомендуется прием (осмотр, консультация) врача-психиатра первичный с психопатологическим обследованием и оценкой риска суицидального поведения [16, 17, 18, 19]. +● Всем пациентам с целью первичной диагностики рекомендуется прием (осмотр, ⟦стр. 8⟧ консультация) врача-психиатра первичный с психопатологическим обследованием и оценкой риска суицидального поведения [16, 17, 18, 19]. --- стр. 8 --- @@ -273,7 +273,7 @@ F33.9 Рекуррентное депрессивное расстройство - о наличии травм, инфекционных заболеваний головного мозга, соматических заболеваний, предшествовавших развитию депрессивных симптомов [36]; - об употреблении психоактивных веществ или лекарственных препаратов, которые могут вызвать симптомы депрессии, непосредственно перед началом депрессивного эпизода [37, 38, 39, 40]. Уровень убедительности рекомендаций С (уровень достоверности доказательств - 5). -● Всем пациентам с диагностированным первичным или повторным депрессивным эпизодом, получающим терапию, рекомендуется прием (осмотр, консультация) врача-психиатра повторный с проведением оценки эффективности лечения [16, 17]. +● Всем пациентам с диагностированным первичным или повторным ⟦стр. 9⟧ депрессивным эпизодом, получающим терапию, рекомендуется прием (осмотр, консультация) врача-психиатра повторный с проведением оценки эффективности лечения [16, 17]. --- стр. 9 --- @@ -296,7 +296,7 @@ F33.9 Рекуррентное депрессивное расстройство (Human immunodeficiency virus HIV 2) в крови в рамках первичного обследования, при поступлении в стационар и далее каждые 12 месяцев для исключения ассоциации с ВИЧ-инфекцией, гепатитом, сифилисом [52, 53, 54]. Уровень убедительности рекомендаций C (уровень достоверности доказательств - 4). -● Пациентам с впервые диагностированным ДЭ или РДР либо при наличии клинических или анамнестических показаний с целью исключения патологии щитовидной железы рекомендуется проведение исследования уровня тиреотропного гормона (ТТГ) в крови, уровня свободного трийодтиронина (СТ3) в крови, уровня свободного тироксина (СТ4) сыворотки крови для оценки функции щитовидной железы [ +● Пациентам с впервые диагностированным ДЭ или РДР либо при наличии ⟦стр. 10⟧ клинических или анамнестических показаний с целью исключения патологии щитовидной железы рекомендуется проведение исследования уровня тиреотропного гормона (ТТГ) в крови, уровня свободного трийодтиронина (СТ3) в крови, уровня свободного тироксина (СТ4) сыворотки крови для оценки функции щитовидной железы [ --- стр. 10 --- @@ -324,7 +324,7 @@ F33.9 Рекуррентное депрессивное расстройство (см. приложение Г4). ● Пациентам ДЭ или РДР с сопутствующими расстройствами личности и/или дифференциально-диагностическими трудностями рекомендуется прием (тестирование, консультация) медицинского психолога первичный и проведение патопсихологического-экспериментального-психологического (психодиагностического) исследования [72]. Уровень убедительности рекомендаций C (уровень достоверности доказательств - 5). -● Пациентам с ДЭ или РДР перед назначением терапии с целью проведения дифференциальной диагностики с другими заболеваниями, исключения противопоказаний к ряду препаратов, которые назначаются при ДЭ, при необходимости госпитализации и для оценки различных рисков соматическому здоровью рекомендуется прием (осмотр, консультация) врача-терапевта, прием (осмотр, консультация) врача-невролога, прием (осмотр, консультация) врача-офтальмолога, для женщин - прием (осмотр, консультация) врача-акушера-гинеколога; для женщин фертильного возраста, не использующих надежные методы контрацепции, рекомендуется проведение теста на беременность; для мужчин, предъявляющих жалобы на расстройства сексуальной сферы, рекомендуется прием (осмотр, консультация) врача-уролога [17, 36, 42, 43, 44, 73, 74]. +● Пациентам с ДЭ или РДР перед назначением терапии с целью проведения дифференциальной диагностики с другими заболеваниями, исключения противопоказаний к ряду препаратов, которые назначаются при ДЭ, при необходимости госпитализации и для оценки различных рисков соматическому здоровью ⟦стр. 11⟧ рекомендуется прием (осмотр, консультация) врача-терапевта, прием (осмотр, консультация) врача-невролога, прием (осмотр, консультация) врача-офтальмолога, для женщин - прием (осмотр, консультация) врача-акушера-гинеколога; для женщин фертильного возраста, не использующих надежные методы контрацепции, рекомендуется проведение теста на беременность; для мужчин, предъявляющих жалобы на расстройства сексуальной сферы, рекомендуется прием (осмотр, консультация) врача-уролога [17, 36, 42, 43, 44, 73, 74]. --- стр. 11 --- @@ -360,7 +360,7 @@ F33.9 Рекуррентное депрессивное расстройство Не существует доказательных данных о большей эффективности или более быстром начале действия АД какого-либо одного класса, хотя при тяжелых, требующих госпитализации, депрессиях некоторые ТЦА (амитриптилин** и кломипрамин**) и венлафаксин оказываются несколько эффективнее, чем селективные ингибиторы обратного захвата серотонина (СИОЗС) [82, 83]. В отношении АД второго поколения сравнительный метаанализ 117 РКИ, включавший 25928 больных, показал, что миртазапин, эсциталопрам, венлафаксин и сертралин** были несколько более эффективны, чем дулоксетин, флуоксетин**, флувоксамин и пароксетин** [84]. В более позднем метаанализе тех же авторов [85] на основе 522 РКИ с участием 116 477 человек и применением 21 антидепрессанта было показано, что показатели отношения шансов были достаточно близкими для различных антидепрессантов как при оценке эффективности, так и переносимости, с широкими доверительными интервалами в большинстве сравнительных анализов. При непосредственном сравнении агомелатин**, амитриптилин**, эсциталопрам, миртазапин, пароксетин**, венлафаксин, и вортиоксетин показали более высокую эффективность, чем другие антидепрессанты, тогда как флуоксетин**, флувоксамин и тразодон оказались менее эффективными препаратами. Что касается переносимости, агомелатин**, циталопрам, эсциталопрам, флуоксетин**, сертралин** и вортиоксетин переносились лучше, чем другие антидепрессанты. АД прежде всего различаются по профилю побочных эффектов, потенциалу взаимодействия с другими препаратами и опасности передозировки (Приложение А3, табл. 3) [86]. АД из классов СИОЗС, СИОЗСН, а также другие АД второго и третьего поколений в целом переносятся лучше, чем препараты первого поколения (ТЦА), что способствует уменьшению вероятности самостоятельного отказа пациентов от их приема. Для ТЦА наиболее характерны антихолинергические (сухость во рту, запоры, нарушение зрения, задержка мочи, тахикардия), сердечно-сосудистые ( α-адренергическая блокада, ортостатическая гипотензия, брадиаритмия, тахикардия), антигистаминные (седация) побочные эффекты, прибавка веса и неврологические симптомы (легкий миоклонус, судорожные состояния при передозировке, делирий у пожилых пациентов). -Учитывая, что СИОЗС обладают лучшей переносимостью по сравнению с ТЦА и редко оказывают свойственные ТЦА антихолинергическое и кардиотоксическое действие, они и другие антидепрессанты новых поколений являются препаратами первого выбора у больных с сердечно-сосудистыми заболеваниями, а также в амбулаторной практике. ТЦА не должны назначаться пациентам с сердечно-сосудистыми заболеваниями умеренной и выраженной тяжести, закрытоугольной глаукомой, гипертрофией простаты, с когнитивными нарушениями, судорожными состояниями и делириозными расстройствами. +Учитывая, что СИОЗС обладают лучшей переносимостью по сравнению с ТЦА и редко оказывают свойственные ТЦА антихолинергическое и кардиотоксическое действие, они и другие антидепрессанты новых поколений являются препаратами первого выбора у больных с сердечно-сосудистыми заболеваниями, а также в амбулаторной практике. ТЦА не должны назначаться пациентам с сердечно-сосудистыми заболеваниями умеренной и выраженной тяжести, ⟦стр. 13⟧ закрытоугольной глаукомой, гипертрофией простаты, с когнитивными нарушениями, судорожными состояниями и делириозными расстройствами. --- стр. 13 --- @@ -383,7 +383,7 @@ F33.9 Рекуррентное депрессивное расстройство , 99], агомелатина** [89, 90] и вортиоксетина [91]. Уровень убедительности рекомендаций A (уровень достоверности доказательств - 1). Комментарии: ТЦА при купирующей терапии ДЭ средней степени обычно рассматривают как препараты второго выбора в связи с менее благоприятным профилем переносимости, как отмечалось выше [83, 85]. К препаратам второго выбора также можно отнести тразодон и миансерин. Тразодон является специфическим серотонинергическим АД (в соответствии с АТХ другие антидепрессанты) и, вероятно, обладает сравнимой с СИОЗС эффективностью, но иным профилем побочных эффектов, а именно: при применении тразодона чаще встречаются седация и ортостатическая гипотензия, зато реже возникают сексуальные нарушения (однако в редких случаях возникает тяжелое осложнение в виде приапизма), бессонница, обострение тревоги и тошнота по сравнению с СИОЗС [100, 101]. Миансерин, как и родственный ему миртазапин, по механизму действия относится к норадренергическим и специфическим серотонинергическим антидепрессантам -(в соответствии с АТХ - другие антидепрессанты), обладает преимущественно седативным эффектом и имеет довольно слабую доказательную базу, хотя в нескольких публикациях 80-х годов по результатам РКИ сообщалось о его сходной с амитриптилином эффективности [102, 103]. Еще более слабой доказательной базой обладает обладают отечественный антидепрессант трициклической структуры пипофезин** (в соответствии с АТХ - другие антидепрессанты), по которому имеются лишь данные открытых исследований [104]. Эффективность другого отечественного препарата пирлиндола (в соответствии с АТХ - другие антидепрессанты), также изучалась только в открытых исследованиях [105]; кроме того, он, являясь по механизму действия ИМАО-А, может быть эффективен при лечении атипичных депрессий и на стадиях резистентности к терапии [106]. +(в соответствии с АТХ - другие антидепрессанты), обладает преимущественно седативным эффектом и имеет довольно слабую доказательную базу, хотя в нескольких публикациях 80-х годов по результатам РКИ сообщалось о его сходной с амитриптилином эффективности [102, 103]. Еще более ⟦стр. 14⟧ слабой доказательной базой обладает обладают отечественный антидепрессант трициклической структуры пипофезин** (в соответствии с АТХ - другие антидепрессанты), по которому имеются лишь данные открытых исследований [104]. Эффективность другого отечественного препарата пирлиндола (в соответствии с АТХ - другие антидепрессанты), также изучалась только в открытых исследованиях [105]; кроме того, он, являясь по механизму действия ИМАО-А, может быть эффективен при лечении атипичных депрессий и на стадиях резистентности к терапии [106]. --- стр. 14 --- @@ -411,7 +411,7 @@ F33.9 Рекуррентное депрессивное расстройство 133, 134, 135, 136, 137, 138, 139, 140, 141]. В настоящее время не существует каких-либо лекарственных препаратов, имеющих официальное разрешение к медицинскому применению при лечении ДЭ со смешанными чертами. Однако большинство АВП, хотя и не все, оказались эффективными при депрессии со смешанными чертами и/или при депрессии [142]. Большинство исследований, изучавших эффективность АВП -(атипичных антипсихотических средств) при лечении депрессии со смешанными чертами, проводились у пациентов с БАР, поэтому их результаты должны экстраполироваться на РДР с определенной осторожностью. Имеется также опыт эффективного использования АПП и производных бензодиазепина при лечении депрессии со смешанными чертами. Однако АПП имеют много тяжелых побочных эффектов, а производные бензодиазепина - риск развития зависимости [29]. +(атипичных антипсихотических средств) при лечении депрессии со смешанными чертами, проводились ⟦стр. 15⟧ у пациентов с БАР, поэтому их результаты должны экстраполироваться на РДР с определенной осторожностью. Имеется также опыт эффективного использования АПП и производных бензодиазепина при лечении депрессии со смешанными чертами. Однако АПП имеют много тяжелых побочных эффектов, а производные бензодиазепина - риск развития зависимости [29]. --- стр. 15 --- @@ -437,7 +437,7 @@ F33.9 Рекуррентное депрессивное расстройство 3.2 Купирующая терапия ДЭ на втором этапе Минимум в 30-50 % случаев ДЭ у пациентов не достигается достаточная эффективность любого из выбранных АД при адекватно проведенной терапии первой линии. В такой ситуации необходимо еще раз проверить, правильно ли был поставлен диагноз, достаточна ли доза назначенного препарата и степень комплаентности пациента. В случае отсутствия динамики или недостаточного клинического эффекта (степень редукции симптоматики от 25 % до 50 %) в течение 3-4 недель терапии, либо плохой переносимости антидепрессивной фармакотерапии следует переходить ко второму курсу или этапу терапии. -Доказательства обоснования приведенных стратегий весьма ограничены, поскольку РКИ на больших выборках больных не проводилось. В настоящее время нет четкого консенсуса о предпочтительности какой-либо из стратегий при лечении пациентов, не отвечающих на терапию. Особенно это касается препаратов второго выбора. При анализе данных проекта STAR*D было показано, что процент пациентов с ремиссиями можно увеличить с 27 %, достигнутых при использовании терапии первого выбора, до кумулятивного уровня 67 % - после всех четырех последующих шагов терапевтически вмешательств. Однако вероятность достижения ремиссии после первых двух курсов терапии была выше +Доказательства обоснования приведенных стратегий весьма ограничены, поскольку РКИ на больших выборках больных не проводилось. В настоящее время нет четкого консенсуса о предпочтительности какой-либо из стратегий при лечении пациентов, не отвечающих на терапию. Особенно это касается препаратов второго выбора. При анализе данных проекта STAR*D было показано, что процент ⟦стр. 16⟧ пациентов с ремиссиями можно увеличить с 27 %, достигнутых при использовании терапии первого выбора, до кумулятивного уровня 67 % - после всех четырех последующих шагов терапевтически вмешательств. Однако вероятность достижения ремиссии после первых двух курсов терапии была выше --- стр. 16 --- @@ -487,7 +487,7 @@ F33.9 Рекуррентное депрессивное расстройство 100, 150, 300 мг/сут. Максимальная доза не должна превышать 600 мг/сут [192, 193]. При этом необходимо учитывать возможность появления седации и набора веса. При применении оланзапина** в целях аугментации эффекта флуоксетина** рекомендуемый диапазон доз составляет 5-10 мг/сут (доза 5 мг вне указанного режима применения препарата в инструкции по медицинскому применению оланзапина**) [211]. Наиболее часто возникают такие побочные эффекты, как увеличение веса, седация и повышение пролактина. При применении #карипразина** диапазон доз варьируется от 1,5 до 3 мг/сут; стартовая доза - 1,5 мг/сут [199, 200, 201, 202, 391]. Наиболее частыми нежелательными явлениями при применении #карипразина** являются экстрапирамидные расстройства, акатизия. Что касается применения #рисперидона** при аугментации антидепрессивной терапии, рекомендуемой стартовой дозой является 0,5 мг/сут, а диапазон доз составляет 0,25-3 мг/сут [197]. Следует учитывать возможность развития гиперпролактинемии и экстрапирамидных расстройств. -Метаанализ 9 РКИ подтвердил эффективность #брекспипразола при резистентной депрессии при его назначении в начальной дозе 0,5-1 мг с возможным повышением до 3 мг (рекомендуемая средняя доза - 2 мг) [203]. Побочными эффектами #брекспипразола являются акатизия, инсомния, беспокойство, сонливость и увеличение веса. В нескольких РКИ было показано достоверное преимущество эффективности аугментации #зипрасидоном по сравнению с плацебо [204, 205]. Рекомендуемой начальной дозой является 20 мг с возможным повышением до 40-160 мг/сут. Среди побочных эффектов чаще всего возникают акатизия, увеличение веса и увеличение интервала QTc [212]. Данные об аугментирующей эффективности #амисульприда ограничены открытыми исследованиями, в которых препарат добавлялся к базовой антидепрессивной терапии в начальной дозе 50 мг с возможным еженедельным повышением на 25 мг до максимальной дозы 200 мг/сут [206], либо в фиксированной дозе 50 мг/сут [207]. Наиболее частым побочным эффектом при использовании +Метаанализ 9 РКИ подтвердил эффективность #брекспипразола при резистентной депрессии при его назначении в начальной дозе 0,5-1 мг с возможным повышением до 3 мг (рекомендуемая средняя доза - 2 мг) [203]. Побочными эффектами #брекспипразола являются акатизия, инсомния, беспокойство, сонливость и увеличение веса. В нескольких РКИ было показано достоверное преимущество эффективности аугментации #зипрасидоном по сравнению с плацебо [204, 205]. Рекомендуемой начальной дозой является 20 мг с возможным повышением до 40-160 мг/сут. Среди побочных ⟦стр. 18⟧ эффектов чаще всего возникают акатизия, увеличение веса и увеличение интервала QTc [212]. Данные об аугментирующей эффективности #амисульприда ограничены открытыми исследованиями, в которых препарат добавлялся к базовой антидепрессивной терапии в начальной дозе 50 мг с возможным еженедельным повышением на 25 мг до максимальной дозы 200 мг/сут [206], либо в фиксированной дозе 50 мг/сут [207]. Наиболее частым побочным эффектом при использовании --- стр. 18 --- @@ -529,7 +529,7 @@ L-триптофаном (биологически активная добавк СИОЗС с анксиолитиком азаспиродекандиона производным - буспироном (частичный агонист рецепторов 5-НТ1А) [230, 231, 232]. Уровень убедительности рекомендаций B (уровень достоверности доказательств - 2). -● Пациентам с ДЭ с терапевтической резистентностью, у которых терапия первого, второго и третьего этапов оказалась неэффективной, с целью преодоления резистентности рекомендована транскраниальная магнитная стимуляция (ТМС) либо в качестве монотерапии, либо для аугментации эффекта АД [233, 234, 235, 236]. +● Пациентам с ДЭ с терапевтической резистентностью, у которых терапия первого, второго и третьего этапов оказалась неэффективной, с целью преодоления резистентности рекомендована транскраниальная магнитная стимуляция (ТМС) либо в ⟦стр. 20⟧ качестве монотерапии, либо для аугментации эффекта АД [233, 234, 235, 236]. --- стр. 20 --- @@ -573,7 +573,7 @@ L-триптофаном (биологически активная добавк #карбамазепином** в дозе 200-600 мг/сут [282, 283, 284]. Уровень убедительности рекомендаций C (уровень достоверности доказательств - 4). Комментарии. Психофармакотерапия была и остается до настоящего времени единственным эффективным методом предотвращения рецидивов РДР. До введения в клиническую практику АД нового поколения для предотвращения рецидивов РДР использовались препараты нормотимического действия. Несмотря на эффективность противорецидивного действия солей лития при РДР в нескольких РКИ [285, 286], в настоящее время более изученными и признанными для профилактики РДР препаратами являются АД. #Карбамазепин** рекомендуется использовать при непереносимости АД и солей лития [17]. -Уже первые исследования противорецидивной эффективности ТЦА [256, 286] при РДР показали их преимущество перед плацебо. Способность достоверно уменьшать риск рецидива депрессии выявлена также у препарата тетрациклической структуры (с различным механизмом действия) - миансерина [287]. Показано, что снижение дозы уменьшает эффективность профилактической терапии [256, 259]. В то же время, препятствием для длительного использования гетероциклических АД является почти неизбежное развитие у больных побочных эффектов, ограничивающее возможность применения адекватных доз препаратов. ТЦА не рекомендуются для профилактики РДР из-за кардиотоксичности и способности накапливаться в сердечной мышце при длительном приеме. Длительная терапия ТЦА допустима лишь в случае неэффективности других АД и только при условии регулярного контроля переносимости и ЭКГ. В отличие от ТЦА АД нового поколения обладают хорошей переносимостью во всем диапазоне дозировок, что является важным их преимуществом при проведении длительной терапии. +Уже первые исследования противорецидивной эффективности ТЦА [256, 286] при РДР показали их преимущество перед плацебо. Способность достоверно уменьшать риск рецидива депрессии выявлена также у препарата тетрациклической структуры (с различным механизмом действия) - миансерина [287]. Показано, что снижение дозы уменьшает эффективность ⟦стр. 22⟧ профилактической терапии [256, 259]. В то же время, препятствием для длительного использования гетероциклических АД является почти неизбежное развитие у больных побочных эффектов, ограничивающее возможность применения адекватных доз препаратов. ТЦА не рекомендуются для профилактики РДР из-за кардиотоксичности и способности накапливаться в сердечной мышце при длительном приеме. Длительная терапия ТЦА допустима лишь в случае неэффективности других АД и только при условии регулярного контроля переносимости и ЭКГ. В отличие от ТЦА АД нового поколения обладают хорошей переносимостью во всем диапазоне дозировок, что является важным их преимуществом при проведении длительной терапии. --- стр. 22 --- @@ -610,7 +610,7 @@ L-триптофаном (биологически активная добавк 307, 308, 311, 312, 313, 314, 315, 316]. Уровень убедительности рекомендаций A (уровень достоверности доказательств - 1). Комментарии. Комбинация одного из валидизированных методов психотерапии и фармакотерапии может применяться в качестве инициального лечения пациентов с легкой, умеренной и тяжелой депрессией и имеет ряд преимуществ по сравнению с использованием монотерапии антидепрессантами: улучшение показателей краткосрочного восстановления пациентов, более быстрый терапевтический ответ, снижение частоты рецидивов и выраженности резидуальной депрессивной симптоматики, улучшение долгосрочного социального функционирования, повышение комплаенса, более высокая удовлетворенность пациентов и снижение долгосрочных затрат на здравоохранение и социальные услуги [294, 312, 317, 318]. -Проведение психотерапии у больных с депрессией является особенно целесообразным при наличии значимых психосоциальных стрессоров, межличностных и семейных конфликтов, сопутствующих личностных нарушений, предпочтений пациента, доступности квалифицированных специалистов, сведений о положительном ответе на определенную психотерапевтическую интервенцию в прошлом [295]. Сравнительное сопоставление различных методов психотерапии затруднительно в связи со значительным многообразием их разновидностей, вариабельностью получаемых результатов, а также прослеживающейся в ряде случаев пристрастностью авторов публикаций [319, 320]. Вместе с тем, по мнению некоторых авторов, ведущую роль в достижении терапевтического эффекта играют не столько специфические особенности метода, сколько неспецифические психологические факторы (например, качество контакта между пациентом и терапевтом, а также ожидания от эффектов терапии со стороны пациента и специалиста, осуществляющего терапию) [321]. +Проведение психотерапии у больных с депрессией является особенно целесообразным при наличии значимых психосоциальных стрессоров, межличностных и семейных конфликтов, сопутствующих личностных нарушений, предпочтений пациента, доступности квалифицированных специалистов, сведений о положительном ответе на определенную психотерапевтическую интервенцию в прошлом [295]. Сравнительное сопоставление различных методов психотерапии затруднительно в связи со значительным многообразием их разновидностей, вариабельностью получаемых результатов, а также прослеживающейся в ряде случаев пристрастностью авторов публикаций [319, 320]. Вместе с тем, по мнению некоторых авторов, ведущую роль в достижении терапевтического эффекта играют не столько специфические особенности метода, сколько неспецифические психологические факторы (например, качество контакта между пациентом и ⟦стр. 24⟧ терапевтом, а также ожидания от эффектов терапии со стороны пациента и специалиста, осуществляющего терапию) [321]. --- стр. 24 --- @@ -627,7 +627,7 @@ L-триптофаном (биологически активная добавк Комментарии. Психодинамическая психотерапия нацелена на углубленное самопознание пациентом своей психики и причин своего поведения, что приводит к самоосвобождению от симптомов и страданий, связанных с депрессией. Возникающие в процессе терапии инсайты нередко сопровождаются корректирующими переживаниями, которые могут оказывать конструктивное влияние на поведение и стиль реагирования пациента. В центре внимания терапии обычно находятся отношения между пациентом и терапевтом, а также преодоление незрелых защит "Я", искажающих восприятие и переработку информации при депрессии. Основными приемами психодинамической психотерапии являются конфронтация, прояснение, интерпретация и проработка. Их суть заключается в повторяющемся обращении клиента к переживаниям, связанным с прошлым или текущими психологическими проблемами, их переоценке, в привитии навыка аналитического отношения к себе и жизни. Все это способствует развитию у пациента способности вступать в доверительные отношения, формированию более зрелых защит и расширению спектра переживаемых эмоций. ● Пациентам с ДЭ или РДР с целью с целью повышения эффективности лечения рекомендуется проведение ТОО путем разъяснения больным роли негативных моделей мышления в возникновении депрессии и формировании у них осознанности на основе медитативных практик [298, 313, 335, 336, 337, 338, 339, 340, 341]. Уровень убедительности рекомендаций B (уровень достоверности доказательств - 1). -Комментарии. По существу ТОО базируется на активном применении медитативных практик, с целью взглянуть на себя и свои психические процессы как бы со стороны, перейдя в метакогнитивную позицию. Указанная позиция позволяет пациенту "растождествиться" с дезадаптивными мыслями, чувствами и реакциями. Этот метод может использоваться более широко, поскольку является более доступным многим пациентам с депрессией, которые испытывают сложности при отслеживании и оспаривании имеющихся у них дисфункциональных когниций. +Комментарии. По существу ТОО базируется на активном применении медитативных практик, с целью взглянуть на себя и свои психические процессы как бы со стороны, перейдя в метакогнитивную позицию. Указанная позиция позволяет пациенту "растождествиться" с дезадаптивными мыслями, чувствами и реакциями. Этот метод может использоваться более широко, ⟦стр. 25⟧ поскольку является более доступным многим пациентам с депрессией, которые испытывают сложности при отслеживании и оспаривании имеющихся у них дисфункциональных когниций. --- стр. 25 --- @@ -652,7 +652,7 @@ L-триптофаном (биологически активная добавк Уровень убедительности рекомендаций B (уровень достоверности доказательств - 1). Комментарий: Применение светотерапии целесообразно прежде всего при сезонном аффективном расстройстве - подтипе РДР, при котором выявляется зависимость депрессивных эпизодов от времени года, обычно возникающих в осенне-зимний период. - Депривация сна [356, 357]; -Уровень убедительности рекомендаций B (уровень достоверности доказательств - 2). +Уровень убедительности рекомендаций B (уровень достоверности ⟦стр. 26⟧ доказательств - 2). --- стр. 26 --- @@ -687,7 +687,7 @@ L-триптофаном (биологически активная добавк ● Всем пациентам с ДЭ или РДР рекомендуется проведение различных методов психотерапии (см. Подраздел 3.7. Иное лечение. Психотерапия) с целью поддержания ремиссии, снижения интенсивности рецидивирования и улучшения социального функционирования больных на профилактическом этапе лечения [294, 298, 303, 313, 314, 334, 337]. Уровень убедительности рекомендаций A (уровень достоверности доказательств - 1). -● Всем пациентам, а также родственникам пациентов с целью повышения осведомленности о природе заболевания, его течении, прогнозе и методах лечения рекомендуется применение групповой психообразовательной работы с больными с психическими расстройствами и расстройствами поведения и групповой психообразовательной работы с родственниками больного с психическими расстройствами и расстройствами поведения [240, 372, 373]. +● Всем пациентам, а также родственникам пациентов с целью повышения осведомленности о природе заболевания, его течении, прогнозе и методах лечения рекомендуется применение групповой психообразовательной работы с больными с психическими расстройствами и расстройствами поведения и групповой психообразовательной работы с родственниками больного с психическими ⟦стр. 27⟧ расстройствами и расстройствами поведения [240, 372, 373]. --- стр. 27 --- @@ -783,7 +783,7 @@ J Affect Disord. 2013; 148(1): 28-36. 18. Maurer D.M., Raymond T.J., Davis B.N. Depression: screening and diagnosis. Am Fam Physician. 2018; 98(8):508-515. 19. Цукарзи Э.Э. Суицид: оценка рисков и первая помощь//Современная терапия психических расстройств. - 2011. - N 2. - С. 30-39. -20. Benazzi F., Akiskal H.S. Psychometric delineation of the most discriminant symptoms of depressive mixed states. Psychiatry Res. 2006; 141(1): 81-88. +20. Benazzi F., Akiskal H.S. Psychometric delineation of the most discriminant symptoms of depressive ⟦стр. 30⟧ mixed states. Psychiatry Res. 2006; 141(1): 81-88. --- стр. 30 --- @@ -822,7 +822,7 @@ JM, Danchin N, Limosin F, Lemogne C. Association between depression and anemia i 27238642. 42. Steffen A., Nubel J., Jacobi F., Batzing J., Holstiege J. Mental and somatic comorbidity of depression: a comprehensive cross-sectional analysis of 202 diagnosis groups using German nationwide ambulatory claims data. BMC Psychiatry. 2020; 20(1):142. 43. Penninx B.W., Milaneschi Y., Lamers F., Vogelzangs N. Understanding the somatic consequences of depression: biological mechanisms and the role of depression symptom profile. BMC Med. 2013; 11:129. -44. Maier W., Falkai P. The epidemiology of comorbidity between depression, anxiety disorders and somatic diseases. Int Clin Psychopharmacol. 1999 May; 14 Suppl 2:S1-6. +44. Maier W., Falkai P. The epidemiology of comorbidity between depression, anxiety disorders and ⟦стр. 31⟧ somatic diseases. Int Clin Psychopharmacol. 1999 May; 14 Suppl 2:S1-6. --- стр. 31 --- @@ -864,7 +864,7 @@ T.G., Divall P., Subramaniam H., Mukaetova-Ladinska E., Beishon L. Investigating 299:144-158. doi: 10.1016/j.jad.2021.11.037. Epub 2021 Nov 18. PMID: 34800572. 68. Wang Y., Zhang H., Tang S. et al. Assessing regional cerebral blood flow in depression using 320-slice computed tomography. PLoS One. 2014; 9(9):e107735. -69. Jiang X., Wang X., Jia L. et al. Structural and functional alterations in untreated patients with major depressive disorder and bipolar disorder experiencing first depressive episode: A magnetic resonance imaging study combined with follow-up. J Affect Disord. 202; 279:324-333. +69. Jiang X., Wang X., Jia L. et al. Structural and functional alterations in untreated patients with major ⟦стр. 32⟧ depressive disorder and bipolar disorder experiencing first depressive episode: A magnetic resonance imaging study combined with follow-up. J Affect Disord. 202; 279:324-333. --- стр. 32 --- @@ -901,7 +901,7 @@ Syst Rev. 2017 Jul 5; 7(7):CD011520. 92. Linde K., Berner M.M., Kriston L. St John's wort for major depression. Cochrane Database Syst Rev 2008; (4):CD000448. 93. Kasper S., Caraci F., Forti B. et al. Efficacy and tolerability of Hypericum extract for the treatment of mild to moderate depression. Eur Neuropsychopharmacol. 2010 Nov; 20(11):747-65. -94. Bauer M., Tharmanathan P., Volz H.P. et al. The effect of venlafaxine compared with other antidepressants and placebo in the treatment of major depression: a meta-analysis. Eur Arch Psychiatry Clin +94. Bauer M., Tharmanathan P., Volz H.P. et al. The effect of venlafaxine compared with other ⟦стр. 33⟧ antidepressants and placebo in the treatment of major depression: a meta-analysis. Eur Arch Psychiatry Clin --- стр. 33 --- @@ -983,7 +983,7 @@ Psychopharmacol. 2015. 29(5): 459-525. Evidence-based guidelines for treating bipolar disorder: revised second edition. Recommendations from the British Association for Psychopharmacology. J Psychopharmacol. 2009; 23(4): 346-388. 138. Grunze H, Vieta E, Goodwin GM, et al. The World Federation of Societies of Biological -Psychiatry (WFSBP) Guidelines for the Biological Treatment of Bipolar Disorders: update 2010 on the treatment of acute bipolar depression. World J Biol Psychiatry. 2010; 11(2): 81-109. +Psychiatry (WFSBP) Guidelines for the Biological Treatment of Bipolar Disorders: update 2010 on the ⟦стр. 35⟧ treatment of acute bipolar depression. World J Biol Psychiatry. 2010; 11(2): 81-109. --- стр. 35 --- @@ -1067,7 +1067,7 @@ Episodes with Mixed Features: A Systematic Review. Clin Psychopharmacol Neurosci Carvalho AF. Mixed states in bipolar and major depressive disorders: systematic review and quality appraisal of guidelines. Acta Psychiatr Scand. 2018 Sep; 138(3):196-222. 181. Houston JP, Tohen M, Degenhardt EK, Jamal HH, Liu LL, Ketter TA. Olanzapine-divalproex combination versus divalproex monotherapy in the treatment of bipolar mixed episodes: a double-blind, placebo-controlled study. J Clin Psychiatry. 2009 Nov; 70(11):1540-7. 182. Rocha FL, Fuzikawa C, Riera R, Hara C. Combination of antidepressants in the treatment of major depressive disorder: a systematic review and meta-analysis. J Clin Psychopharmacol. 2012 Apr; 32(2):278-81. -183. Carpenter L.L., Yasmin S., Price L.H. A double-blind, placebo-controlled study of antidepressant augmentation with mirtazapine. Biol Psychiatry 2002; 51 (2): 183-188. +183. Carpenter L.L., Yasmin S., Price L.H. A double-blind, placebo-controlled study of antidepressant ⟦стр. 37⟧ augmentation with mirtazapine. Biol Psychiatry 2002; 51 (2): 183-188. --- стр. 37 --- @@ -1144,7 +1144,7 @@ Psychiatry. 1998; 59 Suppl 5:51-8/. 227. UK ECT Review Group. Efficacy and safety of electroconvulsive therapy in depressive disorders: a systematic review and meta-analysis. Lancet. 2003; 361(9360):799-808. 228. Sackeim H.A., Prudic J., Devanand D.P. et al. A prospective, randomized, double-blind comparison of bilateral and right unilateral electroconvulsive therapy at different stimulus intensities. Arch Gen Psychiatry 2000; 57 (5): 425-434. -229. Прохорова И.С. Некоторые теоретические и практические аспекты применения одномоментной отмены психотропных средств. Автореферат дисс канд. мед. наук. М., 1975; 22 с. +229. Прохорова И.С. Некоторые теоретические и практические аспекты применения ⟦стр. 39⟧ одномоментной отмены психотропных средств. Автореферат дисс канд. мед. наук. М., 1975; 22 с. --- стр. 39 --- @@ -1237,7 +1237,7 @@ Assessment, Impact, and Management. Focus (Am Psychiatr Publ); 14(2):194-206. do 2021; 40 с. 272. Ахапкин Р.В. Когнитивные нарушения при депрессивных расстройствах: анализ проблемы и перспективы решения (обзор литературы)//Современная Терапия Психических Расстройств. 2015. Т. N 4. СС. 2-7. -273. McIntyre RS, Xiao HX, Syeda K. et al. The prevalence, measurement, and treatment of the cognitive dimension/domain in major depressive disorder. CNS Drugs. 2015; 29(7):577-89. +273. McIntyre RS, Xiao HX, Syeda K. et al. The prevalence, measurement, and treatment of the ⟦стр. 41⟧ cognitive dimension/domain in major depressive disorder. CNS Drugs. 2015; 29(7):577-89. --- стр. 41 --- @@ -1325,7 +1325,7 @@ Driel ML. The effects of social group interventions for depression: Systematic r 49(6):361-72. 319. Kaiser T. et al. Heterogeneity of Treatment Effects in Trials on Psychotherapy of Depression//Clin. Psychol. Sci. Pract. 2022. Vol. 29, N 3. -320. Cristea I.A. et al. Sponsorship bias in the comparative efficacy of psychotherapy and pharmacotherapy for adult depression: Meta-analysis//British Journal of Psychiatry. 2017. Vol. 210, N 1. +320. Cristea I.A. et al. Sponsorship bias in the comparative efficacy of psychotherapy and ⟦стр. 43⟧ pharmacotherapy for adult depression: Meta-analysis//British Journal of Psychiatry. 2017. Vol. 210, N 1. --- стр. 43 --- @@ -1421,7 +1421,7 @@ B.C. Г.П., Цуцульковская М.Я. Аффективные и шизоаффективные психозы: Материалы науч.-практ. конф. с междунар. участием. 7-8 апреля 1998 г.; с. 386-390. 367. Кекелидзе З.И., Тваладзе М.Г., Чечелашвили А.П. и соавт. Роль и место инфузий облученной ультрафиолетом аутокрови в лечении депрессивных состояний. Социальная и клиническая психиатрия 1993; N 3: с. 88-94. -368. Каримулаев И.А., Калинин В.В., Мосолов С.Н. Применение метода адаптации к периодической нормобарической гипоксии в лечении фармакорезистентных депрессий. В книге: Новые достижения в терапии психических заболеваний./ред. С.Н. Мосолов/- М., 2002; c. 579-592. +368. Каримулаев И.А., Калинин В.В., Мосолов С.Н. Применение метода адаптации к периодической нормобарической гипоксии в лечении фармакорезистентных депрессий. В книге: Новые ⟦стр. 45⟧ достижения в терапии психических заболеваний./ред. С.Н. Мосолов/- М., 2002; c. 579-592. --- стр. 45 --- @@ -1525,7 +1525,7 @@ Family Physician. - 2006. - Vol. 74 (3). - Pp. 449-456. Минюсте России 14.11.2022 N 70940). 4. Приказ Минздрава России от 30.06.2022 N 453н "Об утверждении Порядка диспансерного наблюдения за лицом, страдающим хроническим и затяжным психическим расстройством с тяжелыми стойкими или часто обостряющимися болезненными проявлениями"(Зарегистрировано в Минюсте России 28.07.2022 N 69429). -5. Приказ Министерства Здравоохранения Российской Федерации от 20.12.2012 N 1219н "Об утверждении стандарта первичной медико-социальной помощи при депрессиях легкой и средней степени тяжести и смешанном тревожном и депрессивном расстройстве в амбулаторных условиях психоневрологического диспансера (диспансерного отделения, кабинета)". +5. Приказ Министерства Здравоохранения Российской Федерации от 20.12.2012 N 1219н "Об утверждении стандарта первичной медико-социальной помощи при депрессиях легкой и средней ⟦стр. 48⟧ степени тяжести и смешанном тревожном и депрессивном расстройстве в амбулаторных условиях психоневрологического диспансера (диспансерного отделения, кабинета)". --- стр. 48 --- @@ -1961,7 +1961,7 @@ Mosolov S.N., Yaltonskaya P.A., Senko O.V., Angst J. Validation of the Russian v □ □ 2) Как Вы обычно оцениваете себя по сравнению с другими людьми? -Вне зависимости от того, как Вы чувствуете себя сейчас, укажите, как вы обычно воспринимаете себя по другими людьми. Выберите наиболее подходящую формулировку из перечисленных ниже: +Вне зависимости от того, как Вы чувствуете себя сейчас, укажите, как вы обычно воспринимаете ⟦стр. 62⟧ себя по другими людьми. Выберите наиболее подходящую формулировку из перечисленных ниже: --- стр. 62 --- diff --git a/config/rag/corpus/kr_ptsd.txt b/config/rag/corpus/kr_ptsd.txt index d56ec02..bef1826 100644 --- a/config/rag/corpus/kr_ptsd.txt +++ b/config/rag/corpus/kr_ptsd.txt @@ -37,7 +37,7 @@ ID: 753 # – в инструкции по применению не указано данное заболевание или расстройство Термины и определения Травма – событие, связанное с мощным психотравмирующим воздействием и сопровождающимся стрессом экстремального характера. -Стресс – совокупность неспецифических адаптационных (нормальных) реакций организма на воздействие различных неблагоприятных факторов-стрессоров (физических или психологических), нарушающее его гомеостаз, а также соответствующее состояние нервной системы организма (или организма в целом). В медицине, физиологии, психологии выделяют положительную (эустресс от др. греч. +Стресс – совокупность неспецифических адаптационных (нормальных) реакций ⟦стр. 2⟧ организма на воздействие различных неблагоприятных факторов-стрессоров (физических или психологических), нарушающее его гомеостаз, а также соответствующее состояние нервной системы организма (или организма в целом). В медицине, физиологии, психологии выделяют положительную (эустресс от др. греч. --- стр. 2 --- @@ -62,7 +62,7 @@ ID: 753 ПТСР развивается вследствие неспособности индивида справиться с экзогенным психотравмирующим воздействием чрезвычайного характера. В настоящее время активно исследуется вклад генетических факторов, в том числе с целью поиска возможных новых препаратов для лечения ПТСР, на сегодняшний день имеется большая база генетических исследований, которые указывают на полигенный характер фенотипа ПТСР, при этом вопрос о соотношении собственно генетических и средовых факторов остается открытым. Хорошо известно, что предшествующая травматизация повышает риск развития ПТСР, но пока нет однозначного описания патогенетических механизмов подобной сенситизации [3, 4, 5, 6]. Острые стрессовые реакция являются нормальным ответом на чрезвычайное воздействия у ряда людей. Из-за дефицита ресурсов адаптабельности естественный ответ на стресс принимает патологический характер, в частности нарушается функционирование гипоталамо-гипофизарно-надпочечниковой оси и симпатоадреналовой системы, что приводит к манифестации симптомов ПТСР, повышенной готовности к тревожным реакциям, физиологическому возбуждению и гипервигилитету [7, 8, 9, 10, 11, 12, 13]. В когнитивных моделях ПТСР, которые в настоящее время нашли свое подтверждение в исследованиях с использованием современных возможностей нейровизуализации, патогенез ПТСР тесно связан с нарушением процессов переработки информации и интеграции травматического опыта в общую биографическую память пациента. Было выявлено, что травматические воспоминания ассоциированы со сниженной активностью левой нижней фронтальной коры, что дает возможность предположить, что в этих воспоминаниях преобладают эмоциональные и сенсорные элементы без соответствующих лингвистических ассоциаций. -Информация о травме существует в активной рабочей памяти до тех пор, пока не произойдет ее осмысление. Таким образом формируется цикл оживления травматических воспоминаниях под воздействием триггеров, связанных с травмой и стремлением избегать неприятный опыт с попытками контролировать ход мыслей и избегать столкновения со всем, что может напоминать о травме, что в свою очередь ухудшает переработку травматического опыта. В норме эпизоды навязчивых наплывов травматических переживаний чередуются с периодами избегания, со временем по мере завершения процесса эти состояния становятся реже и менее интенсивными. В ряде случаев может возникать конфликт между потребностью в осмыслении и существующей системой отношений, в этом случае возникает конфликт с последующим развитием избегающего поведения, эмоционального онемения, как проявления психологической защиты, что формирует почву для последующей манифестации коморбидных депрессивных расстройств. В этих случаях аффект утрачивает свою сигнальную функцию, поскольку любая сильная эмоция воспринимается как опасность возвращения травматического опыта [17, 18, 19, 20]. +Информация о травме существует в активной рабочей памяти до тех пор, пока не ⟦стр. 4⟧ произойдет ее осмысление. Таким образом формируется цикл оживления травматических воспоминаниях под воздействием триггеров, связанных с травмой и стремлением избегать неприятный опыт с попытками контролировать ход мыслей и избегать столкновения со всем, что может напоминать о травме, что в свою очередь ухудшает переработку травматического опыта. В норме эпизоды навязчивых наплывов травматических переживаний чередуются с периодами избегания, со временем по мере завершения процесса эти состояния становятся реже и менее интенсивными. В ряде случаев может возникать конфликт между потребностью в осмыслении и существующей системой отношений, в этом случае возникает конфликт с последующим развитием избегающего поведения, эмоционального онемения, как проявления психологической защиты, что формирует почву для последующей манифестации коморбидных депрессивных расстройств. В этих случаях аффект утрачивает свою сигнальную функцию, поскольку любая сильная эмоция воспринимается как опасность возвращения травматического опыта [17, 18, 19, 20]. --- стр. 4 --- @@ -122,7 +122,7 @@ F43 "Реакция на тяжелый стресс и нарушения ад Диагноз включает дополнения к трем типичным кластерам симптомов ПТСР: 1. Стойкие длительные нарушения в аффективной сфере (повышенная эмоциональная реактивность, отсутствие эмоций, развитие диссоциативных состояний); 2. Поведенческие нарушения (вспышки ярости, безрассудное или саморазрушающее поведение); -3. Изменения в сфере представлений о самом себе (стойкие негативные представления о себе, как об униженном, побежденном и ничего не стоящем человеке, которые могут сопровождаться глубокими и всеохватывающими чувствами стыда, вины или несостоятельности); +3. Изменения в сфере представлений о самом себе (стойкие негативные представления о себе, как об униженном, побежденном и ничего не стоящем человеке, которые могут ⟦стр. 7⟧ сопровождаться глубокими и всеохватывающими чувствами стыда, вины или несостоятельности); --- стр. 7 --- @@ -142,7 +142,7 @@ F43 "Реакция на тяжелый стресс и нарушения ад 1. Повторяющиеся переживания травмирующего события (флэшбеки, представленные в навязчивых реминисценциях, ярких навязчивых воспоминаниях или повторяющихся снах). В большинстве случаев они неприятны и аффективно насыщенны [1, 15, 16]. 2. Избегание действий и ситуаций, напоминающих о травме (фактическое избегание, чувство "онемения" и эмоционального притупления, отрешенности, невосприимчивости к окружающему, эмоциональная отстраненность, ангедония, ограничительное поведение) [1, 15, 16, 33]. -3. Чрезмерное физиологическое возбуждение (бессонница, связанная с наплывом неприятных воспоминаний о травматическом событии, гипервигилитет сверхнастороженность, повышенная реакция испуга) [1, 14, 15, 16, 33]. +3. Чрезмерное физиологическое возбуждение (бессонница, связанная с наплывом ⟦стр. 8⟧ неприятных воспоминаний о травматическом событии, гипервигилитет сверхнастороженность, повышенная реакция испуга) [1, 14, 15, 16, 33]. --- стр. 8 --- @@ -165,7 +165,7 @@ F43 "Реакция на тяжелый стресс и нарушения ад 6. Сниженная выраженность аффекта (неспособность, например, к чувству любви). 7. Чувство отсутствия перспективы в будущем (например, отсутствие ожиданий по поводу карьеры, женитьбы, детей или пожелания долгой жизни). В целом ПТСР вызывает клинически значимое тяжелое эмоциональное состояние или нарушения в социальной, профессиональной или других важных сферах жизнедеятельности. -В динамике развития расстройств на первом этапе ПТСР пациент погружен в мир переживаний, связанных с травмой, отмечаются наиболее выраженные в этот период флэшбеки. На этом этапе нередко отмечается обострение (или диагностируются впервые) хронических соматических заболеваний, особенно тех, в основе которых участвуют психосоматические механизмы формирования (язвенная болезнь двенадцатиперстной кишки и желудка, холецистит, холангит, колит, запоры, бронхиальная астма и др.). Отмечаются также сексологические нарушения: снижение либидо и эрекции. Стоит отметить, что специфические симптомы, проявляющиеся в чувстве беспомощности, растерянности, вторгающихся пугающих образов, стойких руминациях о прогрессировании заболевания, могут в значительной степени влиять на совладание с соматическим заболеванием. +В динамике развития расстройств на первом этапе ПТСР пациент погружен в мир ⟦стр. 9⟧ переживаний, связанных с травмой, отмечаются наиболее выраженные в этот период флэшбеки. На этом этапе нередко отмечается обострение (или диагностируются впервые) хронических соматических заболеваний, особенно тех, в основе которых участвуют психосоматические механизмы формирования (язвенная болезнь двенадцатиперстной кишки и желудка, холецистит, холангит, колит, запоры, бронхиальная астма и др.). Отмечаются также сексологические нарушения: снижение либидо и эрекции. Стоит отметить, что специфические симптомы, проявляющиеся в чувстве беспомощности, растерянности, вторгающихся пугающих образов, стойких руминациях о прогрессировании заболевания, могут в значительной степени влиять на совладание с соматическим заболеванием. --- стр. 9 --- @@ -223,7 +223,7 @@ F43 "Реакция на тяжелый стресс и нарушения ад Комментарии: В МКБ-10 выделены следующие критерии ПТСР: А. Больной должен быть подвержен воздействию стрессорного события или ситуации (как коротко, так и длительно длящихся) исключительно угрожающего или катастрофического характера, что способно вызвать общий дистресс почти у любого индивидуума. -Б. стойкие навязчивые воспоминания или "оживление стрессора в навязчивых реминисценциях, ярких воспоминаниях или повторяющихся снах, либо повторные переживания горя при воздействии обстоятельств, напоминающих или ассоциирующихся со стрессором. +Б. стойкие навязчивые воспоминания или "оживление стрессора в навязчивых реминисценциях, ярких воспоминаниях или повторяющихся снах, либо повторные ⟦стр. 11⟧ переживания горя при воздействии обстоятельств, напоминающих или ассоциирующихся со стрессором. --- стр. 11 --- @@ -353,7 +353,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50]. Более полно клинико-психологическая и экспериментально-психологическая диагностика может проводиться медицинским психологом в зависимости от конкретных диагностических задач или для определения мишеней психотерапевтического воздействия. Выбор методов и методик психологической диагностики относится к компетенции медицинского психолога, при соблюдении требования о представлении в тексте психологического заключения испрашиваемых лечащим врачом данных. Могут применяться различные шкалы, прошедшие адаптацию и валидизацию, в зависимости от целей исследования. Методики могут применяться факультативно, и перечень возможных методик для проведения экспериментально-психологического обследования не ограничен. При этом одной из важнейших составляющих участия медицинского психолога в оказании медицинской помощи пациентам с ПТСР является психологическая диагностика. Используются все основные виды, методы и методики, принятые как в психологической диагностике в целом, так и в ее прикладном клиническом разделе, именуемом "медицинской психодиагностикой" или "психологической диагностикой в клинике". В соответствии с базовыми теоретическими положениями психологической диагностики, в клинической практике используются и клинико-психологические (экспертные) методы (клиническая беседа, наблюдение, интервью, анализ продуктов деятельности и т.п.), и экспериментально психологические методы. Опросниковые методы опираются в значительной степени на самооценочную природу получаемых данных и имеют свои ограничения в части объективности таких данных. С другой стороны, они в большей степени отражают индивидуально-психологические особенности самой личности и ее сохранность/измененность в ситуации болезни, а также раскрывают широкий комплекс присущих испытуемому связей, отношений, способов реагирования и совладания, иных клинически значимых характеристик личности и личностно-средового взаимодействия и др. Такие данные должны расцениваться в рамках персонализированного подхода при реализации биопсихосоциальной парадигмы современных антропоцентрированных и клинических наук. При этом клинические методы органично вплетены в процедуры экспериментально-психологического исследования и используются непосредственно в процессе выполнения испытуемым проб и тестовых методик Отличительной особенностью применения клинико- и экспериментальнопсихологических методов при решении задач психологической диагностики является учет и анализ специфики, обусловленной измененным болезнью психическим и психологическим статусом испытуемого. 3. Лечение, включая медикаментозную и немедикаментозную терапии, диетотерапию, обезболивание, медицинские показания и противопоказания к применению методов лечения -ПТСР – заболевание, которое имеет высокую коморбидностъ с большим количеством психических и соматических расстройств. В лечении пациентов с ПТСР и коморбидными нарушениями целесообразно также опираться на клинические рекомендации и стандарты оказания помощи для этих заболеваний. +ПТСР – заболевание, которое имеет высокую коморбидностъ с большим количеством психических и соматических расстройств. В лечении пациентов с ПТСР и коморбидными ⟦стр. 17⟧ нарушениями целесообразно также опираться на клинические рекомендации и стандарты оказания помощи для этих заболеваний. --- стр. 17 --- @@ -383,7 +383,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50]. - диазепам** 5-15 мг/сут (при психомоторном возбуждении, связанном с тревогой, коморбидных острых тревожно-фобических, тревожно-депрессивных состояниях). Дозу обычно делят на 2-3 приема. Длительность курса диазепама** не должна превышать 4 недель, в тяжелых случаях допускается до 8-12 недель, включая время постепенного прекращения [57, 303, 304, 307, 308]. Уровень убедительности рекомендаций C (уровень достоверности доказательств 5). - лоразепам** 2-10 мг/сут (при тревоге, раздражительности, нарушениях сна, вегетативных нарушениях). Обычно назначают 2-3 мг/сут, разделенные на 1-3 приема, а затем при необходимости, увеличивают суточную дозу препарата до поддерживающей, которая чаще всего составляет от 2 мг до 6 мг в сутки, разделенная на 1-3 приема. Для купирования симптомов рекомендуется применять как можно меньшие эффективные дозы. -Дозу лоразепама** необходимо увеличивать постепенно, начиная с увеличения дозы, применяемой вечером. Максимально можно назначить до 10 мг в сутки. При нарушениях сна, вызванных тревогой, обычно назначают раз в сутки перед сном от 2 до 5 мг лоразепама** [57, 303, 309]. +Дозу лоразепама** необходимо увеличивать постепенно, начиная с увеличения дозы, применяемой вечером. Максимально можно назначить до 10 мг в сутки. При нарушениях сна, вызванных тревогой, обычно назначают раз в сутки перед сном от 2 до 5 мг ⟦стр. 19⟧ лоразепама** [57, 303, 309]. --- стр. 19 --- @@ -413,7 +413,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50]. (15-80 мг/сут) для купирования этих проявлений [67, 68]. Уровень убедительности рекомендаций C (уровень достоверности доказательств 5). Комментарии: курсовое лечение необходимо начинать с приема 2,5-5 мг в вечернее время с постепенным увеличением суточной дозы до требуемого эффекта. Суточная доза может быть распределена на 3-4 приема. Длительность курсового лечения может составлять от 2 до 6 и более месяцев и определяется врачом в зависимости от состояния в соответствии с инструкцией по медицинскому применению препарата. При необходимости для достижения седативного или снотворного эффекта может применяться эпизодически (за 2030 мин до сна). Побочные эффекты крайне редки и выражены незначительно. Препарат может применяться в комбинированной терапии с другими средствами. -- Пациентам с ПТСР в качестве анксиолитиков альтернативных производным бензодиазепина рекомендовано назначение этифоксина (150-200 мг/сут) для устранения тревоги, страха, внутреннего напряжения, раздражительности, а также признаков повышенных настороженности и вегетативного возбуждения [69, 70, 71, 72, 72, 74]. +- Пациентам с ПТСР в качестве анксиолитиков альтернативных производным бензодиазепина рекомендовано назначение этифоксина (150-200 мг/сут) для устранения тревоги, страха, внутреннего напряжения, раздражительности, а также признаков ⟦стр. 21⟧ повышенных настороженности и вегетативного возбуждения [69, 70, 71, 72, 72, 74]. --- стр. 21 --- @@ -427,7 +427,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50]. (симптоматическое лечение тревоги). Он не угнетает кору головного мозга, но его действие может быть связано с подавлением активности некоторых ключевых зон субкортикальной области центральной нервной системы. В случае применения пероральных лекарственных форм седативный эффект наступает через 30-45 минут. Гидроксизин** также обладает спазмолитическим и симпатолитическим действием, демонстрирует умеренную анальгетическую активность. Рекомендуемая дозировка 25-100 мг/сут. Поскольку реакции на прием гидроксизина** отличаются изменчивостью, рекомендуется начинать лечение с низких доз препарата и постепенно повышать до оптимальной, регулируя ее в соответствии с ответом пациента на терапию. В случае если тревожность проявляется бессонницей, основная доза принимается вечером перед сном. Длительность определяется индивидуально, курс обычно составляет 4-8 недель. Препарат может применяться в комбинированной терапии с другими средствами. - Пациентам с ПТСР рекомендуется применение анксиолитиков, не относящихся к группе производные бензодиазепина – буспирона (20-30 мг/сут) при наличии коморбидных тревожных расстройств для снижения уровня тревоги [75, 78]. Уровень убедительности рекомендаций C (уровень достоверности доказательств 2). -Комментарии: в отличие от производных бензодиазепина буспирон не обладает миорелаксантными противосудорожными свойствами. Его седативный эффект значительно слабее эффекта типичных анксиолитиков и не нарушает психомоторные функции. По анксиолитической активности буспирон примерно аналогичен эффекту производных бензодиазепина. Буспирон не вызывает толерантности и зависимости, а после завершения курса лечения не развиваются симптомы отмены. Действие буспирона развивается постепенно. Терапевтический эффект начинает проявляться между 7-м и 14-м днями терапии, а максимальный эффект достигается примерно через 4 недели после начала лечения. Поэтому этот препарат не назначается эпизодически. Рекомендованная начальная доза составляет 15 мг, эту дозу можно повышать на 5 мг в сутки каждые 2-3 дня. Обычная суточная доза 20-30 мг в сутки. Максимальная однократная доза составляет 30 мг, максимальная суточная не должна превышать 60 мг. Препарат может применяться в комбинированной терапии с другими средствами. +Комментарии: в отличие от производных бензодиазепина буспирон не обладает миорелаксантными противосудорожными свойствами. Его седативный эффект значительно слабее эффекта типичных анксиолитиков и не нарушает психомоторные функции. По анксиолитической активности буспирон примерно аналогичен эффекту производных бензодиазепина. Буспирон не вызывает толерантности и зависимости, а после завершения курса лечения не развиваются симптомы отмены. Действие буспирона развивается постепенно. Терапевтический эффект начинает проявляться между 7-м и 14-м днями терапии, а максимальный эффект достигается примерно через 4 недели после начала лечения. Поэтому этот препарат не назначается эпизодически. Рекомендованная начальная доза составляет 15 мг, эту дозу можно повышать на 5 мг в сутки каждые 2-3 дня. Обычная суточная доза 20-30 мг в сутки. Максимальная однократная доза составляет 30 мг, максимальная суточная не должна превышать 60 мг. Препарат может применяться в ⟦стр. 22⟧ комбинированной терапии с другими средствами. --- стр. 22 --- @@ -513,7 +513,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50]. - Пациентам с ПТСР рекомендовано использование когнитивной психотерапии длительностью 15-20 сессий, которые проводятся еженедельно индивидуально и/или в группе для модификация пессимистических и катастрофических оценок и воспоминаний, связанных с психотравмой, с целью преодоления поведенческих и когнитивных паттернов, поддерживающих избегание и препятствующих нормальному повседневному функционированию [130, 131]. Уровень убедительности рекомендаций A (уровень достоверности доказательств – 2). Комментарии: основная задача терапии – модификация пессимистических и катастрофических оценок и воспоминаний, связанных с психотравмой, с целью преодоления поведенческих и когнитивных паттернов, поддерживающих избегание и препятствующих нормальному повседневному функционированию. -Пациент под руководством психотерапевта обучается идентификации внутренних и внешних стимулов, а также специфических триггеров, поддерживающих симптомы ПТСР. С целью уменьшения выраженности интрузий проводится тщательная оценка воспоминаний и интегрирование травматического опыта. Для работы с дисфункциональными мыслями, связанными с оценкой травмы и глубинными убеждениями, поддерживающими ощущения постоянной угрозы, рекомендован сократовский диалог. Дополнительной мишенью являются дисфункциональные когнитивные и поведенческие паттерны, которые блокируют адаптивные копинг-стратегии и восстановление последовательных воспоминаний о травматическом событии, к примеру, руминации, поиск безопасности, подавление мыслей. +Пациент под руководством психотерапевта обучается идентификации внутренних и внешних стимулов, а также специфических триггеров, поддерживающих симптомы ПТСР. С целью уменьшения выраженности интрузий проводится тщательная оценка воспоминаний и интегрирование травматического опыта. Для работы с дисфункциональными мыслями, связанными с оценкой травмы и глубинными убеждениями, поддерживающими ощущения постоянной угрозы, рекомендован сократовский диалог. Дополнительной мишенью являются дисфункциональные когнитивные и поведенческие паттерны, которые блокируют ⟦стр. 27⟧ адаптивные копинг-стратегии и восстановление последовательных воспоминаний о травматическом событии, к примеру, руминации, поиск безопасности, подавление мыслей. --- стр. 27 --- @@ -528,7 +528,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50]. - Пациентам с ПТСР рекомендовано использование индивидуальной когнитивноповеденческой психотерапии с пролонгированной экспозицией – 15-20 сеансов с целью повышения переносимости неприятных стимулов, связанных с травматическим опытом, обучения пациентов постепенно соприкоснуться к чувствам, воспоминаниям и ситуациям, связанных с травмой [140, 141, 142, 143, 144, 145, 146, 147, 148]. Уровень убедительности рекомендаций A (уровень достоверности доказательств 1). Комментарии: основная цель этого метода повысить переносимость неприятных стимулов, связанных с травматическим опытом, она направлена на обучение пациентов постепенно соприкоснуться к чувствам, воспоминаниям и ситуациям, связанных с травмой. -Основной задачей является обучение, что триггеры и воспоминания являются безопасными и переносимыми и их не стоит избегать. Длительность терапии составляет около 3 месяцев с еженедельными сессиями от 60 до 120 минут, всего проводится от 8 до 15 сессий, в ряде случаев рекомендовано от 15 до 20. В начале терапии врач-психотерапевт описывает план лечения и валидирует травматический опыт пациента, затем проводится обучение навыкам совладания с тревогой и дыхательным упражнениям. После этого проводится собственно экспозиция, для ее успешного проведения должен быть сформирован терапевтический альянс и атмосфера безопасности, где в условиях эмоциональной поддержки возможно столкновение с очень пугающими стимулами. Экспозиция может проводиться в воображении, или в качестве домашнего задания in vivo, в настоящее время активно используются программы виртуальной реальности (ВР) для проведения экспозиции. При проведении ВР экспозиции длительность сессии составляет 45-60 минут, каждая сцена повторяется до тех, пока уровень дистресса не снизится вдвое по сравнению с первым предъявлением. Следующая сцена используется после того, как пациент подтвердит свою готовность, задача терапии сделать дискомфорт переносимым. Темп психотерапии определяется состоянием и индивидуальными особенностями пациента [147, 148]. +Основной задачей является обучение, что триггеры и воспоминания являются безопасными и переносимыми и их не стоит избегать. Длительность терапии составляет около 3 месяцев с еженедельными сессиями от 60 до 120 минут, всего проводится от 8 до 15 сессий, в ряде случаев рекомендовано от 15 до 20. В начале терапии врач-психотерапевт описывает план лечения и валидирует травматический опыт пациента, затем проводится обучение навыкам совладания с тревогой и дыхательным упражнениям. После этого проводится собственно экспозиция, для ее успешного проведения должен быть сформирован терапевтический альянс и атмосфера безопасности, где в условиях эмоциональной поддержки возможно столкновение с очень пугающими стимулами. Экспозиция может проводиться в воображении, или в качестве домашнего задания in vivo, в настоящее время активно используются программы виртуальной реальности (ВР) для проведения экспозиции. При проведении ВР экспозиции длительность сессии составляет 45-60 минут, каждая сцена ⟦стр. 28⟧ повторяется до тех, пока уровень дистресса не снизится вдвое по сравнению с первым предъявлением. Следующая сцена используется после того, как пациент подтвердит свою готовность, задача терапии сделать дискомфорт переносимым. Темп психотерапии определяется состоянием и индивидуальными особенностями пациента [147, 148]. --- стр. 28 --- @@ -541,7 +541,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50]. Комментарии: само название определяет основную цель терапии – формирование альтернативной оценки травматического опыта, который часто пациентами однозначно воспринимается как невыносимый и безысходный, поведенческий модуль направлен на выработку оптимальных паттернов поведения в процессе сопоставления различных, иногда противоречивых вариантов. Задача психотерапевта в каждом индивидуальном случае найти оптимальный баланс между принятием и изменением, для чего используются соответствующие техники, для решения отдельных задач могут дополнительно использоваться отдельные приемы ТФ-КПТ, психотерапии, сфокусированной на сострадании, психотерапии принятия и ответственности. В процессе диалектической бихевиоральной терапии ПТСР используются майндфуллнесс, обучение переносимости дистресса и навыкам эмоциональной регуляции, повышение межличностной эффективности, экспозиция и предотвращение ответа, противоположное поведение, валидация, самопринятие. - Пациентам с ПТСР рекомендовано использование "Десенсибилизации и переработки движениями глаз" (ДПДГ), длительностью 6-12 сессий для переработки травматического опыта [155, 156, 157, 158, 159, 160]. Уровень убедительности рекомендаций A (уровень достоверности доказательств 2). -Комментарии: "Десенсибилизации и переработки движениями глаз" (ДПДГ) – метод конфронтации с травматическим опытом, с применением направленной билатеральной стимуляции (посредством ритмичных движений глазами) с одновременным образным представлением травматического события. Движения глаз и другие формы стимуляции двойного фокуса внимания, помимо глазных движений возможно использование звуковой стимуляции или постукивание по различным частям тела, обеспечивает одновременную десенсибилизацию и когнитивное переструктурирование, а также интеграцию травматических воспоминаний и уменьшение выраженности симптомов ПТСР. Это метод индивидуальной психотерапии длительностью 6-12 сессий, которые могут проводиться последовательно каждый день. Ф. Шапиро – автор метода основывается на том, что эмоциональная травма может нарушить работу системы переработки информации, поэтому она будет сохраняться в форме, обусловленной травматическим переживанием, и способствует формированию интрузионных симптомов посттравматического синдрома. +Комментарии: "Десенсибилизации и переработки движениями глаз" (ДПДГ) – метод конфронтации с травматическим опытом, с применением направленной билатеральной стимуляции (посредством ритмичных движений глазами) с одновременным образным представлением травматического события. Движения глаз и другие формы стимуляции ⟦стр. 29⟧ двойного фокуса внимания, помимо глазных движений возможно использование звуковой стимуляции или постукивание по различным частям тела, обеспечивает одновременную десенсибилизацию и когнитивное переструктурирование, а также интеграцию травматических воспоминаний и уменьшение выраженности симптомов ПТСР. Это метод индивидуальной психотерапии длительностью 6-12 сессий, которые могут проводиться последовательно каждый день. Ф. Шапиро – автор метода основывается на том, что эмоциональная травма может нарушить работу системы переработки информации, поэтому она будет сохраняться в форме, обусловленной травматическим переживанием, и способствует формированию интрузионных симптомов посттравматического синдрома. --- стр. 29 --- @@ -654,7 +654,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50]. Профилактика ПТСР заключается во внедрении принципов здорового образа жизни, мероприятий, направленных на поддержание психического здоровья среди населения, ограничение употребления алкоголя и психоактивных веществ. а) Непсихологические и немедикаментозные профилактические мероприятия для взрослых. В области психического здоровья в отечественной практике выстраивание системы профилактики принимает форму психологического просвещения населения. -Психологическое просвещение является активной формой психологической профилактики, основными задачами которой являются: +Психологическое просвещение является активной формой психологической ⟦стр. 34⟧ профилактики, основными задачами которой являются: --- стр. 34 --- @@ -672,7 +672,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50]. Уровень убедительности рекомендаций C (уровень достоверности доказательств 5). Комментарии: Меры вторичной профилактики могут быть реализованы после воздействия травмирующего фактора (наступления травмирующего события), отвечающего критериям, потенциально влекущим формирование ПТСР (внезапность, субъективно переживаемая угроза жизни, утрата). В отечественной практике вторичную профилактику условно можно обозначить двумя направлениями частично взаимосвязанными друг с другом содержанием и являющимися при этом универсальными рекомендациями для переживших травматическое событие: экстренная психологическая помощь и психологическое просвещение. Экстренная психологическая помощь (ЭПП) включает в себя также информационно-психологическую поддержку, индивидуальную, психологическую помощь. Экстренная психологическая помощь (Emergency psychological aid) – целостная система мероприятий, направленных на оптимизацию актуального психического состояния, включающая в себя как отдельные специальные методы психологического воздействия, так и организацию особой среды, которая помогает в снижении интенсивности воздействия травматического события на человека. ЭПП может быть оказана человеку во времени максимально приближенно от момента воздействия травмирующего события. -Задачи такого вида психологической помощи включают в себя удовлетворение актуальных потребностей человека (информация, социальная поддержка и т.д.), в том числе переживание травматического опыта, а в качестве цели ставит сохранение психологических и физических ресурсов человека, переживающего травматическое событие [224, 225]. +Задачи такого вида психологической помощи включают в себя удовлетворение актуальных потребностей человека (информация, социальная поддержка и т.д.), в том числе переживание травматического опыта, а в качестве цели ставит сохранение психологических ⟦стр. 35⟧ и физических ресурсов человека, переживающего травматическое событие [224, 225]. --- стр. 35 --- @@ -687,7 +687,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50]. 218, 219, 220, 228, 229]. Уровень убедительности рекомендаций C (уровень достоверности доказательств 5). Комментарии: индивидуальная психологическая помощь (ИПП) представляет собой форму работы, направленную на актуализацию личностных ресурсов, принятие сложившейся ситуации, поиск и актуализацию стратегий совладания, регуляцию актуального психического состояния. Одним из инструментов ЭПП является фасилитация эмоционального отреагирования человека относительно произошедшего травматического события. Номинация эмоционального состояния, которая происходит в ходе оказания ЭПП, позволяет снизить влияние травматических переживаний на состояние человека непосредственно после произошедшего, актуализировать ресурсы, необходимые для совладания со сложившейся ситуацией [228]. -Также сохранение социальной активности, выстраивание сети социальной поддержки и актуализация социальных связей может быть обоснованной профилактической практикой для людей, переживающих травматическое событие, особенно в контексте изменения базовых позитивных убеждений в отношении мира и окружения. Обнаружены свидетельства о положительном влиянии социальной поддержки, в том числе и в виде отношений с близкими, на способность человека справляться с травматической ситуацией [216, 217, 218]. +Также сохранение социальной активности, выстраивание сети социальной поддержки и актуализация социальных связей может быть обоснованной профилактической практикой для людей, переживающих травматическое событие, особенно в контексте изменения ⟦стр. 36⟧ базовых позитивных убеждений в отношении мира и окружения. Обнаружены свидетельства о положительном влиянии социальной поддержки, в том числе и в виде отношений с близкими, на способность человека справляться с травматической ситуацией [216, 217, 218]. --- стр. 36 --- @@ -704,7 +704,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50]. - В отношении лиц, столкнувшихся с чрезвычайным, катастрофическим стрессом, рекомендуется проводить психологические интервенции с целью снижения риска ПТСР [224, 225, 226, 227]. Уровень убедительности рекомендаций C (уровень достоверности доказательств 5). -Комментарии: Одной из самых сложных задач является выявление людей, нуждающихся в помощи, потому что взаимосвязь между острыми и хроническими стрессовыми реакциями сложна и обуславливается множеством факторов, которые не могут быть с точностью проиндексированы в острой фазе. Проблемы, вызванные переживанием боли, потерей дохода, прерыванием трудовой деятельности или смертью близкого, общее социальное неблагополучие, которое часто сопровождает травмирующее событие, могут отвлечь человека от предложения провести интервенцию. Лишь небольшая часть остро травмированных людей на самом деле принимает предложения о лечении [235]. В связи с этим особо важное значение имеет необходимость постоянного мониторинга некоторых подгрупп после травмы вместо того, чтобы делать акцент на раннем вмешательстве. Тем не менее существует несколько подходов, принятых эффективными на ранних этапах вмешательства. +Комментарии: Одной из самых сложных задач является выявление людей, нуждающихся в помощи, потому что взаимосвязь между острыми и хроническими стрессовыми реакциями сложна и обуславливается множеством факторов, которые не могут быть с точностью проиндексированы в острой фазе. Проблемы, вызванные переживанием боли, потерей дохода, прерыванием трудовой деятельности или смертью близкого, общее социальное неблагополучие, которое часто сопровождает травмирующее событие, могут отвлечь человека от предложения провести интервенцию. Лишь небольшая часть остро травмированных людей на самом деле принимает предложения о лечении [235]. В связи с этим особо важное значение имеет необходимость постоянного мониторинга некоторых подгрупп после травмы вместо того, чтобы делать акцент на раннем вмешательстве. Тем не ⟦стр. 37⟧ менее существует несколько подходов, принятых эффективными на ранних этапах вмешательства. --- стр. 37 --- @@ -729,7 +729,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50]. 1. Принцип раннего выявления психических расстройств: Способность оказать своевременную психотерапевтическую и психиатрическую помощь на ранних этапах развития психических нарушений зависит от того, насколько быстро выявляются лица, пострадавшие в ходе чрезвычайной ситуации. Максимально раннее выявление лиц, не только с уже сформировавшимися психическими расстройствами, но и нарушениями субклинического уровня, предотвращает углубление психических расстройств и их хронификацию, способствует профилактике развития социальной дезадаптации. Раннему выявлению также способствует широкое информирование населения о причинах и клинических проявлениях ПТСР, об учреждениях, оказывающих медицинскую и психологическую помощь, о возможностях обращать по телефонам "горячей линии". 2. Принцип доступности медико-психологической помощи: -В условиях чрезвычайной ситуации (ЧС) резко увеличивается число нуждающихся в помощи при ограниченном количестве специалистов, способных оказать необходимую помощь. Целесообразно организовывать "горячие линии". Куда могут обращаться как пациенты, так и специалист, получать психологическую помощи и информацию об организации медицинской и психологической помощи при ПТСР, учреждениях ее оказывающих. При этом помощь должна носить не разовый, а постоянный характер с учетом возможности длительного течения психических расстройств. Наиболее оптимальной представляется организация медико-психиатрической +В условиях чрезвычайной ситуации (ЧС) резко увеличивается число нуждающихся в помощи при ограниченном количестве специалистов, способных оказать необходимую ⟦стр. 38⟧ помощь. Целесообразно организовывать "горячие линии". Куда могут обращаться как пациенты, так и специалист, получать психологическую помощи и информацию об организации медицинской и психологической помощи при ПТСР, учреждениях ее оказывающих. При этом помощь должна носить не разовый, а постоянный характер с учетом возможности длительного течения психических расстройств. Наиболее оптимальной представляется организация медико-психиатрической --- стр. 38 --- @@ -745,7 +745,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50]. Основная цель психотерапии лиц с ПТСР, длительное время находившихся в условиях ЧС, заключается в восстановлении удовлетворительного уровня функционирования. Для этого необходимы некоторые дополнительные ресурсы "Я" или более гибкие эмоциональные, когнитивные и поведенческие реакции, позволяющие справляться с имеющимися последствиями психических травм и будущими стрессами. Важной составляющей в преодолении пережитых психотравмирующих событий являются убеждения и верования пациентов. Психотерапия и психологическая коррекция способствуют переосмыслению произошедшего и формирует основу для адаптивного разрешения психотравмирующей ситуации. 6. Принцип динамического сопровождения: -Очень часто пациенты с ПТСР прекращают посещение врача и/или прием медикаментов после некоторого улучшения состояния, что влечет за собой обострение психопатологической симптоматики и ухудшение состояния. Как следствие этого у пациентов появляется разочарование в проводимом лечении и снижается доверие к лечащему врачу. При организации психотерапевтической помощи на уровне первичного медицинского звена появляется возможность наблюдения за состоянием пациента в период посещения им поликлиники или во время патронажного медсестринского обхода. +Очень часто пациенты с ПТСР прекращают посещение врача и/или прием медикаментов после некоторого улучшения состояния, что влечет за собой обострение психопатологической симптоматики и ухудшение состояния. Как следствие этого у пациентов появляется разочарование в проводимом лечении и снижается доверие к лечащему врачу. При организации психотерапевтической помощи на уровне первичного ⟦стр. 39⟧ медицинского звена появляется возможность наблюдения за состоянием пациента в период посещения им поликлиники или во время патронажного медсестринского обхода. --- стр. 39 --- @@ -847,7 +847,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50]. - появление особой ранимости и сенситивности, повышенной чувствительности к несправедливости, легкость возникновения чувства вины перед боевыми товарищами; - низкая восприимчивость к малозначимым раздражителям или обстоятельствам, требующим дополнительных психических затрат, в том числе регресс высших личностных свойств со снижением восприимчивости к чужим страданиям и смерти, уходом от решения нравственных проблем, облегчающих господство групповых ценностей; - ослабление интрапсихической переработки впечатлений, снижение способности к вербализации впечатлений и переживаний. -Подобные эмоциональные изменения, особенности переживания значимых событий, коммуникации и системы ценностных образований формируются на фоне длительного воздействия боевых стресс-факторов и становятся своего рода приспособлением к реалиям жизни на войне. Наряду с общими психологическими характеристиками в каждом конкретном случае имеются и свои нюансы, что зависит от изначально присущих особенностей личности и конкретного содержания психотравмирующих обстоятельств. +Подобные эмоциональные изменения, особенности переживания значимых событий, коммуникации и системы ценностных образований формируются на фоне длительного ⟦стр. 43⟧ воздействия боевых стресс-факторов и становятся своего рода приспособлением к реалиям жизни на войне. Наряду с общими психологическими характеристиками в каждом конкретном случае имеются и свои нюансы, что зависит от изначально присущих особенностей личности и конкретного содержания психотравмирующих обстоятельств. --- стр. 43 --- @@ -882,7 +882,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50]. - Незамедлительность (Immediate) – начало ее сразу, как только у солдата снижается боеспособность, а не после наступления общей дезадаптации; - Централизованность (Central) – осуществление помощи в определенном месте, отдельно от терапевтических и хирургических пациентов или Контактность (Contact) – координацию контактов между солдатом и его подразделением и командирами с целью ориентации обеих сторон на коммуникацию, осознание и проявление взаимной необходимости с целью быстрой реинтеграции; - Выжидание (Expectant) – намерение при первой возможности возвратить солдата в строй, -- Проксимальность (Proximal) – проведение коррекции максимально ближе к подразделению, в котором служит пострадавший, +- Проксимальность (Proximal) – проведение коррекции максимально ближе к ⟦стр. 45⟧ подразделению, в котором служит пострадавший, --- стр. 45 --- @@ -895,7 +895,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50]. Комментарии: Условием эффективности оказания психиатрической помощи военнослужащим, подвергшимся воздействию боевого стресса, является осуществление в войсковом звене медицинской сортировки по уровневому принципу (предболезненный, пограничный, психотический) и по предполагаемой длительности дезадаптации с выделением трех групп пострадавших [35, 274, 275]: - 1-я группа (кратковременно выбывшие из строя) – эти военнослужащие переносят предболезненные, чаще всего синдромально незавершенные нарушения адаптации или реакции на стресс, которые при своевременной поддержке имеют транзиторный, очень кратковременный характер. Помощь этой категории пострадавших военнослужащих следует оказывать на 1-м этапе психиатрической помощи – в непосредственной близости от своих воинских подразделений на основе принципов BICEPS. Подлежат возврату в свои подразделения в срок до 1 суток. - 2-я группа (уже представляет собой санитарные потери) – это пострадавшие с кратковременными стрессовыми реакциями, нуждающиеся в лечении до 5-7 суток и получающие помощь на 2-м этапе психиатрической помощи – силами Групп психического здоровья (ГПЗ), приданных лечебным учреждениям, дислоцированным в зоне боевых действий (например, Медицинский отряд специального назначения, МОСН). Помощь им оказывается также на основе принципов BICEPS. -- 3-я группа (санитарные потери) – это военнослужащие с затяжными и тяжелыми психическими расстройствами, нуждающиеся в более длительном стационарном лечении и эвакуации на последующие этапы – в психиатрические отделения военных госпиталей или, в крайнем случае и при большей доступности, в ближайшие гражданские психиатрические стационары. +- 3-я группа (санитарные потери) – это военнослужащие с затяжными и тяжелыми психическими расстройствами, нуждающиеся в более длительном стационарном лечении и ⟦стр. 46⟧ эвакуации на последующие этапы – в психиатрические отделения военных госпиталей или, в крайнем случае и при большей доступности, в ближайшие гражданские психиатрические стационары. --- стр. 46 --- @@ -904,7 +904,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50]. - Лечение военнослужащих, подвергшихся воздействию боевого стресса, на 1-м и 2-м этапам медицинской эвакуации (в войсковом звене медицинской помощи) с целью вторичной профилактики ПТСР рекомендовано проводить с акцентом на предоставление безопасности, отдыха, сна, питания, с минимальным использованием лекарственных средств и ограниченным применением краткосрочных методов психотерапии [35, 274, 275]. Уровень убедительности рекомендаций C (уровень достоверности доказательств 5). Комментарии: на 1-м этапе сортировки следует насколько можно избегать установки диагноза, так как здесь нет специалистов-психиатров, нет времени на дифференциальную диагностику, нужно избежать стигматизации и идентификации пострадавшего с ролью пациента. На 1-м этапе лечение должно быть простым и кратковременным. Купируется возбуждение. Обязательно обеспечить сон, так как его восстановление оказывается необходимым условием обратного развития многих реакций не стресс и почти всегда – реакций предболезненного уровня. Задачей лекарственной терапии является обеспечение эвакуации пациентов с тяжелыми психическими расстройствами. Психотерапия возможна, если потери невелики и число пострадавших, поступающих на этапы эвакуации, очень небольшое. Важной составляющей помощи является выявление признаков черепно-мозговой травмы и мониторинг состояния контуженных. Появление анизокории, признаков даже легкой спутанности или любого ухудшения физического или психического состояния контуженного требует немедленной эвакуации, так как при отсутствии лечения быстро может наступить угнетение сознания и остановка дыхания. Решение об эвакуации на 2-й этап психиатрической помощи принимается, только если спустя сутки или двое у военнослужащего сохраняются выраженные симптомы реакции на стресс, расстройства адаптации или имеются признаки контузии и, самое главное, явно нарушена боеспособность. -2-й этап помощи может быть представлен медицинскими подразделениями, например, медицинским отрядам специального назначения, которым должны быть приданы группы медико-психологического сопровождения (в составе врача-психиатра и медицинского психолога), и где может быть проведена диагностика психического расстройства и оказан весь объем необходимой помощи. Однако надо ясно определиться, что при отсутствии специалистов психиатров МОСН не может выполнять функции 2-го этапа медицинской помощи пострадавшим с психическими расстройствами. На практике часто бывает так, что функции 1-го и 2-го этапов психиатрической помощи смешиваются, но это ухудшает исходы реакций боевого стресса и повышает риск развития ПТСР. Выбор применяемых на II этапе методов коррекции определяется необходимостью быстрого лечения большого числа пострадавших. Психотерапия, если есть возможность ее применять, должна быть очень кратковременная, отличаться простотой, а ее эффективность – в наименьшей степени зависеть от искусства конкретного специалиста. При выборе средств фармакологической коррекции предпочтение по-прежнему должно отдаваться препаратам производным бензодиазепина. Последнее время в качестве средства предупреждения ПТСР предлагается +2-й этап помощи может быть представлен медицинскими подразделениями, например, медицинским отрядам специального назначения, которым должны быть приданы группы медико-психологического сопровождения (в составе врача-психиатра и медицинского психолога), и где может быть проведена диагностика психического расстройства и оказан весь объем необходимой помощи. Однако надо ясно определиться, что при отсутствии специалистов психиатров МОСН не может выполнять функции 2-го этапа медицинской помощи пострадавшим с психическими расстройствами. На практике часто бывает так, что функции 1-го и 2-го этапов психиатрической помощи смешиваются, но это ухудшает исходы реакций боевого стресса и повышает риск развития ПТСР. Выбор применяемых на II этапе методов коррекции определяется необходимостью быстрого лечения большого числа ⟦стр. 47⟧ пострадавших. Психотерапия, если есть возможность ее применять, должна быть очень кратковременная, отличаться простотой, а ее эффективность – в наименьшей степени зависеть от искусства конкретного специалиста. При выборе средств фармакологической коррекции предпочтение по-прежнему должно отдаваться препаратам производным бензодиазепина. Последнее время в качестве средства предупреждения ПТСР предлагается --- стр. 47 --- @@ -916,7 +916,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50]. Отсутствие рекомендаций применения антидепрессантов объясняется недостатком исследований их эффективности в ближайшем после травмирующего стресса периоде, а также отсроченным терапевтическим эффектом этой группы лекарственных средств, что делает бесперспективным их назначение на этапах медицинской эвакуации. Отсутствие рекомендаций применения антипсихотиков объясняется высоким развитием побочных эффектов и негативным влиянием на боеспособность военнослужащих, которые, как ожидается, в ближайшее время вернуться в строй. Применение антипсихотиков поэтому допустимо лишь для купирования возбуждения и обеспечения эвакуации пациентов с психотическими расстройствами. Низкий уровень доказательности связан с минимально доступным количеством проведенных научных исследований, что объясняется крайней затруднительностью их проведения в условиях военных действий. -- Военнослужащим, поступающим на 2-й этап медицинской эвакуации в связи с острыми реакциями на стресс, расстройствами адаптации, особенно сочетающимися с признаками или анамнестическими данными о перенесенной контузии, после исключения противопоказаний, рекомендовано применение #гидрокортизона** (20-40 мг/сут) перорально для ослабления тревоги, неврологической симптоматики и снижения риска последующего возникновения ПТСР [275, 276, 277, 278, 279, 280, 282, 283, 284]. +- Военнослужащим, поступающим на 2-й этап медицинской эвакуации в связи с острыми реакциями на стресс, расстройствами адаптации, особенно сочетающимися с признаками или анамнестическими данными о перенесенной контузии, после исключения противопоказаний, рекомендовано применение #гидрокортизона** (20-40 мг/сут) ⟦стр. 48⟧ перорально для ослабления тревоги, неврологической симптоматики и снижения риска последующего возникновения ПТСР [275, 276, 277, 278, 279, 280, 282, 283, 284]. --- стр. 48 --- @@ -984,7 +984,7 @@ N Список литературы 1. ПСИХОТЕРАПИЯ: УЧЕБНИК/под ред. А.В. Васильевой, Т.А. Караваевой, Н.Г. Незнанова. – Москва: ГЭОТАР-Медиа, 2022. – 864 с.: ил. – DOI: 10.33029/9704-6485-4-VKN2022-1-86 -2. МКБ. Международная классификация болезней, 10-й пересмотр. Психические расстройства и расстройства поведения. Класс V МКБ-10, адаптированный для использования в Российской Федерации, М.: Российское общество психиатров, 1998. +2. МКБ. Международная классификация болезней, 10-й пересмотр. Психические расстройства и расстройства поведения. Класс V МКБ-10, адаптированный для ⟦стр. 50⟧ использования в Российской Федерации, М.: Российское общество психиатров, 1998. --- стр. 50 --- @@ -1070,7 +1070,7 @@ Nov; 56(4). P. 340-350. 2012. Mar 18; 2(2). e000312. 38. Taylor B.C., Hagel E.M., Carlson K.F. et al. Prevalence and costs of co-occurring traumatic brain injury with and without psychiatric disturbance and pain among Afghanistan and Iraq War Veteran V.A. users. Med. Care. 2012. Apr; 50(4). P. 342-346. -39. Wilk J.E., Herrell R.K., Wynn G.H. et al. Mild traumatic brain injury (concussion), posttraumatic stress disorder, and depression in U.S. Soldiers involved in combat deployments: association with postdeployment symptoms. Psychosom. Med. 2012. Apr; 74(3). P. 249-257 +39. Wilk J.E., Herrell R.K., Wynn G.H. et al. Mild traumatic brain injury (concussion), ⟦стр. 52⟧ posttraumatic stress disorder, and depression in U.S. Soldiers involved in combat deployments: association with postdeployment symptoms. Psychosom. Med. 2012. Apr; 74(3). P. 249-257 --- стр. 52 --- @@ -1104,7 +1104,7 @@ JPsychotraumatol. 10(1): 169548 53. Hoskins M. D. et al. Pharmacological therapy for post-traumatic stress disorder: a systematic review and meta-analysis of monotherapy, augmentation and head-to-head approaches//European Journal of Psychotraumatology. – 2021. – T. 12. – N. 1. – C. 1802920. 54. Lee DJ, Schnitzlein CW, Wolf JP et al. Psychotherapy versus pharmacotherapy for posttraumatic stress disorder: systemic review and meta-analyses to determine first-line treatments. Depress Anxiety 2016; 33: 792-806. -55. Merz J, Schwarzer G, Gerger H. Comparative efficacy and acceptability of pharmacological, psychotherapeutic, and combination treatments in adults with posttraumatic stress disorder: a network meta-analysis. JAMA PsychiatryPsychiatry 2019; 76: 904 – 13.]. +55. Merz J, Schwarzer G, Gerger H. Comparative efficacy and acceptability of pharmacological, psychotherapeutic, and combination treatments in adults with posttraumatic stress ⟦стр. 53⟧ disorder: a network meta-analysis. JAMA PsychiatryPsychiatry 2019; 76: 904 – 13.]. --- стр. 53 --- @@ -1137,7 +1137,7 @@ Eur J Clin Pharmacol 1982; 22: 2: 137-142; 71. Servant D, Graziani P, Moyse D, Parquet P. Treatment of adjustment disorder with anxiety: efficacy and tolerance of etifoxine in a doubleblind controlled study. Encephale. 1998 NovDec; 24(6): 569 – 74; 72. Nguyen N, Fakra E, Pradel V, et al. Efficacy of etifoxine compared to lorazepam monotherapy in the treatment of patients with adjustment disorders with anxiety: a double-blind controlled study in general practice. Hum Psychopharmacol. 2006 Apr; 21(3): 139 – 49; 73. Cottin J, Gouraud A, Jean-Pastor M, et al. Safety profile of etifoxine: A French pharmacovigilance survey. Fundam Clin Pharmacol. 2016 Apr; 30(2): 147 – 52. doi:10.1111/fcp.12169.Epub 2016 Jan 26; -74. Micallef J, Soubrouillard C, Guet F, et al. A double blind parallel group placebo controlled comparison of sedative and mnesic effects of etifoxine and lorazepam in healthy subjects +74. Micallef J, Soubrouillard C, Guet F, et al. A double blind parallel group placebo controlled ⟦стр. 54⟧ comparison of sedative and mnesic effects of etifoxine and lorazepam in healthy subjects --- стр. 54 --- @@ -1227,7 +1227,7 @@ PTSD: A double-blind, placebo-controlled study. Am J Psychiatry 2002; 159(10): 1 Психиатрия и психофармакотерапия. 2016; 18 (2): 26-32 108. Беккер Р.А., Быков Ю.В. Клиническое применение перициазина (Неулептила). Психиатрия и психофармакотерапия. 2018; 20 (2): 16-31. -109. Stahl SM. Stahl"s essential psychopharmacology: Case studies. Cambridge university press, 2015. Stahl SM. Stahl"s essential psychopharmacology: neuroscientific basis and practical applications. Cambridge university press, 2013. +109. Stahl SM. Stahl"s essential psychopharmacology: Case studies. Cambridge university press, 2015. Stahl SM. Stahl"s essential psychopharmacology: neuroscientific basis and practical ⟦стр. 56⟧ applications. Cambridge university press, 2013. --- стр. 56 --- @@ -1383,7 +1383,7 @@ Frontiers in Psychology, 8. doi:10.3389/fpsyg.2017.01668 166. Lewis, C., Roberts, N. P., Andrew, M., Starling, E., & Bisson, J. I. (2020). Psychological therapies for post-traumatic stress disorder in adults: systematic review and meta-analysis. European Journal of Psychotraumatology, 11(1), 1729633. doi:10.1080/20008198.2020.1729633 167. Khan, A.M., Dar, S., Ahmed, R., Bachu, R., Adnan, M., & Kotapati, V. P. (2018). -Cognitive Behavioral Therapy versus Eye Movement Desensitization and Reprocessing in Patients with Posttraumatic Stress Disorder: Systematic Review and Meta-analysis of Randomized Clinical +Cognitive Behavioral Therapy versus Eye Movement Desensitization and Reprocessing in Patients ⟦стр. 60⟧ with Posttraumatic Stress Disorder: Systematic Review and Meta-analysis of Randomized Clinical --- стр. 60 --- @@ -1509,7 +1509,7 @@ Vol. 99, N 26. P. e20700. Posttraumatic Stress Disorder: A Systematic Review and Meta-Analysis//J Trauma Dissociation. 2018. Vol. 19, N 1. P. 39-58. 214. B. A. van der Kolk, The body keeps the score: Mind, brain and body in the transformation of trauma. 2014. -215. J.W. Hopper, P.A. Frewen, B.A. van der Kolk, and R.A. Lanius, "Neural correlates of reexperiencing, avoidance, and dissociation in PTSD: Symptom dimensions and emotion dysregulation in responses to script-driven trauma imagery," J Trauma Stress, vol. 20, no. 5, 2007, doi:10.1002/jts.20284. +215. J.W. Hopper, P.A. Frewen, B.A. van der Kolk, and R.A. Lanius, "Neural correlates of reexperiencing, avoidance, and dissociation in PTSD: Symptom dimensions and emotion ⟦стр. 63⟧ dysregulation in responses to script-driven trauma imagery," J Trauma Stress, vol. 20, no. 5, 2007, doi:10.1002/jts.20284. --- стр. 63 --- @@ -1546,7 +1546,7 @@ Promote Recovery From Posttraumatic Stress?," Psychological Science in the Publi Way, "Putting feelings into words: Affect labeling disrupts amygdala activity in response to affective stimuli: Research article," Psychol Sci, vol. 18, no. 5, 2007, doi:10.1111/j.14679280.2007.01916.x. 229. L. Iyadurai et al., "Preventing intrusive memories after trauma via a brief intervention involving Tetris computer game play in the emergency department: A proof-of-concept randomized controlled trial," Mol Psychiatry, vol. 23, no. 3, 2018, doi:10.1038/mp.2017.23. 230. M. Gould, N. Greenberg, and J. Hetherton, "Stigma and the military: Evaluation of a -PTSD psychoeducational program," in Journal of Traumatic Stress, 2007, vol. 20, no. 4. doi:10.1002/jts.20233. +PTSD psychoeducational program," in Journal of Traumatic Stress, 2007, vol. 20, no. 4. ⟦стр. 64⟧ doi:10.1002/jts.20233. --- стр. 64 --- @@ -1576,7 +1576,7 @@ Arch Gen Psychiatry, vol. 65, no. 6, 2008, doi: 10.1001/archpsyc.65.6.659. 245. C. Freyth, K. Elsesser, T. Lohrmann, and G. Sartory, "Effects of additional prolonged exposure to psychoeducation and relaxation in acute stress disorder," J Anxiety Disord, vol. 24, no. 8, 2010, doi: 10.1016/j.janxdis.2010.06.016. 246. R. D. V. Nixon, "Cognitive Processing Therapy Versus Supportive Counseling for Acute -Stress Disorder Following Assault: A Randomized Pilot Trial," Behav Ther, vol. 43, no. 4, 2012, doi: 10.1016/j.beth.2012.05.001. +Stress Disorder Following Assault: A Randomized Pilot Trial," Behav Ther, vol. 43, no. 4, 2012, ⟦стр. 65⟧ doi: 10.1016/j.beth.2012.05.001. --- стр. 65 --- @@ -1647,7 +1647,7 @@ Department of the Army. July, 2006. 115 p. Battlemind Debriefing and Battlemind Training as Early Interventions With Soldiers Returning From Iraq: Randomization by Platoon. Journal of consulting and clinical psychology. 77. 92840.10.1037/a0016877. 278. Руководство для командиров по контролю над боевым стрессом (Полевой Устав -Сухопутных войск США FM 22-51): сокр. пер. с англ. А.П. Нечаева//Зарубежная военная медицина (Информационный сборник). – N 96. – СПб, 1999. – С. 8-84. +Сухопутных войск США FM 22-51): сокр. пер. с англ. А.П. Нечаева//Зарубежная военная ⟦стр. 67⟧ медицина (Информационный сборник). – N 96. – СПб, 1999. – С. 8-84. --- стр. 67 --- @@ -1915,7 +1915,7 @@ German-speaking countries. European Archives of Psychiatry and Clinical Neurosci Информация для пациента Что такое посттравматическое стрессовое расстройство (ПТСР)? ПТСР – это расстройство, которое может развиться после перенесенного очень сильного стресса, который является тяжелым практически для любого человека и превышает нормальные возможности психики к адаптации. -К таким психотравмирующим воздействиям относят угрозу жизни, нападение, пытки, насилие, природные и техногенные катастрофы, военные действия, террористические акты, пребывание в плену или концентрационном лагере, свидетельство гибели другого человека, жизнеугрожающее заболевание, получение известия о неожиданной или насильственной смерти близкого человека и другие. +К таким психотравмирующим воздействиям относят угрозу жизни, нападение, пытки, насилие, природные и техногенные катастрофы, военные действия, террористические акты, пребывание в плену или концентрационном лагере, свидетельство гибели другого человека, ⟦стр. 77⟧ жизнеугрожающее заболевание, получение известия о неожиданной или насильственной смерти близкого человека и другие. --- стр. 77 --- @@ -2016,7 +2016,7 @@ Scale – CAPS) разработана в двух вариантах (Weathers Применение методики не только дает возможность оценить по пятибалльной шкале каждый симптом с точки зрения частоты и интенсивности проявления, но и определить достоверность полученной информации. Шкала CAPS применяется, как правило, дополнительно к Структурированному клиническому интервью (СКИД) для клинической диагностики уровня выраженности симптоматики ПТСР и частоты ее проявления. Ее используют, если в ходе интервью диагностируется наличие каких-либо симптомов ПТСР или всего расстройства в целом (Weathers F.W., Litz B.T., 1994; Blake D.D., 1995). -CAPS-1 позволяет оценить частоту встречаемости и интенсивность проявления индивидуальных симптомов расстройства, а также степень их влияния на социальную активность и производственную деятельность пациента. С помощью этой шкалы можно определить степень улучшения состояния при повторном исследовании по сравнению с предыдущим, валидность результатов и общую интенсивность симптомов. Если возможно, желательно использовать шкалу CAPS-1 в комплексе с прочими методами диагностики +CAPS-1 позволяет оценить частоту встречаемости и интенсивность проявления индивидуальных симптомов расстройства, а также степень их влияния на социальную активность и производственную деятельность пациента. С помощью этой шкалы можно определить степень улучшения состояния при повторном исследовании по сравнению с предыдущим, валидность результатов и общую интенсивность симптомов. Если возможно, ⟦стр. 81⟧ желательно использовать шкалу CAPS-1 в комплексе с прочими методами диагностики --- стр. 81 --- @@ -2401,7 +2401,7 @@ Kudler, 1989); | ОТМЕЧЕНО ЛИ 15 КАК ДА? | | НЕТ ДА ПТСР в настоящее время | | | Ключ (интерпретация) -Наличие утвердительных ответов на вопросы, направленные на диагностический критерий A, дают интервьюеру основание для дальнейшей беседы по следующим блокам вопросов, направленных на диагностические критерии B – травматическое событие настойчиво повторяется в переживании, C – постоянное избегание стимулов и блокировка эмоциональных реакций, связанных с травмой, D – постоянные симптомы возрастающего возбуждения, E – длительность протекания расстройства (более чем 1 месяц), F – расстройство вызывает клинически значимое тяжелое эмоциональное состояние или нарушения в социальной, профессиональной или других важных сферах жизнедеятельности. +Наличие утвердительных ответов на вопросы, направленные на диагностический ⟦стр. 93⟧ критерий A, дают интервьюеру основание для дальнейшей беседы по следующим блокам вопросов, направленных на диагностические критерии B – травматическое событие настойчиво повторяется в переживании, C – постоянное избегание стимулов и блокировка эмоциональных реакций, связанных с травмой, D – постоянные симптомы возрастающего возбуждения, E – длительность протекания расстройства (более чем 1 месяц), F – расстройство вызывает клинически значимое тяжелое эмоциональное состояние или нарушения в социальной, профессиональной или других важных сферах жизнедеятельности. --- стр. 93 --- @@ -2425,7 +2425,7 @@ Kudler, 1989); Обработка полученных данных ШОВТС проводится по ключу: субшкала "вторжение" (IN): суммируются баллы пунктов 1, 2, 3, 6, 9, 16, 20; – позволяет выявлять у испытуемого ночные кошмары, навязчивые мысли, связанные с травмирующим событием субшкала "избегание" (AV): суммируются баллы пунктов 5, 7, 8, 11, 12, 13, 17, 22; – позволяет выявлять у испытуемого симптомы избегания, включающие попытки смягчения или избегания переживаний, связанных с воздействием кризисного события субшкала "физиологическое возбуждение" (AR): суммируются баллы пунктов 4, 10, 14, 15, 18, 19, 21; – позволяет выявлять у испытуемого злость и раздражительность, гипертрофированную реакцию испуга, психофизиологическое возбуждение, обусловленное воспоминаниями о событии, бессонницу. Все пункты имеют прямое значение. -Опросник предназначен для изучения наличия, степени выраженности и симптоматической структуры посттравматического стрессового расстройства. Шкала зарекомендовала себя как надежный, валидный инструмент, прошедший кросс-культурную апробацию. Шкала может быть включена в батарею психодиагностических методик для работы с лицами, у которых имеется или подозревается ПТСР. +Опросник предназначен для изучения наличия, степени выраженности и симптоматической структуры посттравматического стрессового расстройства. Шкала ⟦стр. 94⟧ зарекомендовала себя как надежный, валидный инструмент, прошедший кросс-культурную апробацию. Шкала может быть включена в батарею психодиагностических методик для работы с лицами, у которых имеется или подозревается ПТСР. --- стр. 94 --- @@ -2487,7 +2487,7 @@ Mississippi Scale) – клиническая тестовая методика, Опросник состоит из 35 вопросов в "военном" и 39 вопросов в "гражданском" вариантах. 30 вопросов образуют три основные шкалы, которые соотносятся с тремя группами симптомов ПТСР согласно DSM-III: 11 вопросов первой шкалы описывают симптомы группы вторжения, 11 симптомов второй шкалы описывают симптомы группы избегания, 8 вопросов третьей шкалы описывают симптомы возбудимости. Пять оставшихся вопросов описывают симптомы, относящиеся к чувству вины и суицидальным наклонностям. Популяционные исследования показали высокую внутреннюю согласованность военного (0,94) и гражданского (0,86) вариантов шкалы. Шкала обладает 87% чувствительностью и 63% специфичностью в диагностике ПТСР у контингента психически больных. -Ответы на вопросы даются по шкале Ликкерта. Несмотря на группирование вопросов в четыре субшкалы, высчитывается только один итоговый коэффициент. Часть вопросов имеет перевернутые значения, что позволяет снизить установочность испытуемого или заполнение бланка в произвольном порядке. +Ответы на вопросы даются по шкале Ликкерта. Несмотря на группирование вопросов в четыре субшкалы, высчитывается только один итоговый коэффициент. Часть вопросов имеет ⟦стр. 96⟧ перевернутые значения, что позволяет снизить установочность испытуемого или заполнение бланка в произвольном порядке. --- стр. 96 --- @@ -2561,7 +2561,7 @@ Mississippi Scale) – клиническая тестовая методика, Семейное положение _____________ Детей ____________________ Профессия ____________________________________________________ Вес ____________ Рост ____________ -Ниже приводятся утверждения, обобщающие различный опыт людей, принимавших участие в боевых действиях. У каждого утверждения есть шкала от "1" до "5". Пользуясь данной шкалой, укажите, в какой степени вы согласны или не согласны с данным утверждением. Дайте только один ответ на каждое утверждение: Совершенно неверно, +Ниже приводятся утверждения, обобщающие различный опыт людей, принимавших участие в боевых действиях. У каждого утверждения есть шкала от "1" до "5". Пользуясь данной шкалой, укажите, в какой степени вы согласны или не согласны с данным ⟦стр. 99⟧ утверждением. Дайте только один ответ на каждое утверждение: Совершенно неверно, --- стр. 99 --- @@ -2644,7 +2644,7 @@ Taylor, C.A. Mora) - вопросник - другое (уточнить) Назначение: -"Шкала оценки интенсивности боевого опыта" Т. Кина (Combat Exposure Scale) данная методика предназначена для оценки количественных параметров частоты и продолжительности участия военнослужащих в различных экстремальных ситуациях в условиях боевых действий. Для оценки влияния интенсивности боевого опыта на психологическое состояние участников военных конфликтов рекомендуется использовать эту методику совместно с другими методиками, такими как "Шкала для клинической диагностики ПТСР" (Clinical-administered PTSD Scale – CAPS, Weathers F.W. et al., 1992; +"Шкала оценки интенсивности боевого опыта" Т. Кина (Combat Exposure Scale) данная методика предназначена для оценки количественных параметров частоты и ⟦стр. 102⟧ продолжительности участия военнослужащих в различных экстремальных ситуациях в условиях боевых действий. Для оценки влияния интенсивности боевого опыта на психологическое состояние участников военных конфликтов рекомендуется использовать эту методику совместно с другими методиками, такими как "Шкала для клинической диагностики ПТСР" (Clinical-administered PTSD Scale – CAPS, Weathers F.W. et al., 1992; --- стр. 102 --- @@ -2770,7 +2770,7 @@ Weathers F.W, 1993), "Миссисипская шкала для оценки п L и др., 1990)], вопросы которой касались переживаний субъекта в то время, когда с ним происходило потенциально психотравмирующее событие. Обследуемым предлагается ответить на 9 вопросов, в которых описаны различные диссоциативные состояния, такие, например, как дереализация, деперсонализация, опыт выхода из тела и др. Ответы на вопросы даются в формате пятибалльной шкалы. Общий балл подсчитывается как среднее арифметическое по 9 пунктам. -Диагностические критерии следующие: у здоровых показатели от 9 до 15 баллов; у пациентов с посттравматическими стрессовыми расстройствами – от 19 до 45 баллов. +Диагностические критерии следующие: у здоровых показатели от 9 до 15 баллов; у ⟦стр. 106⟧ пациентов с посттравматическими стрессовыми расстройствами – от 19 до 45 баллов. --- стр. 106 --- @@ -2900,7 +2900,7 @@ F.W., 1986) | 0% | 10% | 20% | 30% | 40% | 50% | 60% | 70% | 80% | 90% | 100% | |---|---|---|---|---|---|---|---|---|---|---| -12) ... испытывают чувство нереальности окружающих их людей и предметов, а может быть, и всего окружающего их мира. +12) ... испытывают чувство нереальности окружающих их людей и предметов, а может ⟦стр. 110⟧ быть, и всего окружающего их мира. --- стр. 110 --- @@ -2947,7 +2947,7 @@ F.W., 1986) | 0% | 10% | 20% | 30% | 40% | 50% | 60% | 70% | 80% | 90% | 100% | |---|---|---|---|---|---|---|---|---|---|---| -21) ... находясь в одиночестве, иногда замечают, что громко разговаривают сами с собой. +21) ... находясь в одиночестве, иногда замечают, что громко разговаривают сами с ⟦стр. 111⟧ собой. --- стр. 111 --- diff --git a/config/rag/sources.yaml b/config/rag/sources.yaml index fd409ab..e4fb819 100644 --- a/config/rag/sources.yaml +++ b/config/rag/sources.yaml @@ -59,6 +59,12 @@ retrieval: "Приложение В": 1.5 "Приложение Г": 0.5 "Критерии оценки качества": 0.5 + # Заболевание в вопросе -> профильная КР (регулярные выражения по вопросу в нижнем регистре). + # Если названы оба — ни одна не получает преимущества. Иначе «ТМС при депрессии» отвечался цитатой из КР ПТСР. + doc_cues: + kr_depression: ["(? markdown; у таблицы-продолжения на новой странице повторяется шапка, строка, разорванная границей страницы, склеивается; - колонтитулы (повторяющиеся строки вверху/внизу страниц) вырезаются; -- маркеры страниц `--- стр. N ---` для ссылок на источник; +- маркеры страниц `--- стр. N ---` для ссылок на источник; абзац, перетёкший на новую + страницу, склеивается, а место перехода помечается `⟦стр. N⟧`; - текст с картинок (блок-схемы и т.п.) берётся из figures/<имя pdf>.p.txt (ручная транскрипция) и вставляется в конец своей страницы; - если есть manual/<имя pdf>.txt, берётся он (документы, которые автоматом @@ -211,9 +212,10 @@ def convert(pdf_path: Path) -> str: if not text: continue if first_on_page and prev and prev[0] == "text" and text[0].islower(): - # абзац перетёк на новую страницу: дописываем его хвост к началу + # абзац перетёк на новую страницу: дописываем его хвост к началу, а место + # перехода помечаем ⟦стр. N⟧ — чанкер по метке ставит верную страницу в цитату head, _, rest = text.partition("\n") - prev[1] = prev[1] + " " + head + prev[1] = prev[1] + f" ⟦стр. {num}⟧ " + head if rest: blocks.append(["text", rest]) else: diff --git a/tests/test_llm_rag.py b/tests/test_llm_rag.py index 13e3545..b840dfd 100644 --- a/tests/test_llm_rag.py +++ b/tests/test_llm_rag.py @@ -307,3 +307,38 @@ async def test_llm_rephrasing_kept_when_faithful(monkeypatch: pytest.MonkeyPatch 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 + + +# --- regressions from the post-merge audit (#62) ------------------------------------------------------- + + +def test_paragraph_flowing_to_next_page_cites_both_pages() -> None: + """The converter glues a paragraph across a page break and marks the join with ⟦стр. N⟧.""" + chunks = load_chunks() + assert not any("⟦" in c.text for c in chunks) # markers never reach the text + # KR PTSD: the comment on antipsychotics starts on p.22 and continues on p.23 + chunk = next(c for c in chunks if "Препараты могут применяться в комбинированной терапии" in c.text) + assert chunk.page_start <= 23 <= chunk.page_end + + +async def test_scrub_placeholders_do_not_break_answers() -> None: + plain = await answer(S("что такое ПТСР"), audience="patient") + scrubbed = await answer(S("[NAME], [PHONE], что такое ПТСР"), audience="patient") + assert plain is not None and scrubbed is not None + assert scrubbed.excerpt == plain.excerpt + assert tokens("[NAME] сон") == ["сон"] + + +def test_question_naming_a_disease_prefers_its_guideline() -> None: + index = get_index() + assert index.cued_docs("ТМС при депрессии") == {"kr_depression"} + assert index.cued_docs("кошмары после войны") == {"kr_ptsd"} + assert index.cued_docs("депрессия и ПТСР") == set() # both named: no preference + assert index.cued_docs("когда подействуют антидепрессанты") == set() # a drug class, not the disease + hit = index.search("транскраниальная магнитная стимуляция при депрессии", audience="patient")[0] + assert hit.chunk.doc.id == "kr_depression" + + +async def test_answer_follows_disease_cue() -> None: + reply = await answer(S("транскраниальная магнитная стимуляция при депрессии"), audience="patient") + assert reply is not None and reply.citations[0].doc == "kr_depression" From b72af71e661d88fe7278cd2f3bf2369ee1f9df0e Mon Sep 17 00:00:00 2001 From: Lacky1234union Date: Sun, 4 Oct 2026 11:27:42 +0300 Subject: [PATCH 02/12] feat(bot): answer treatment questions from the clinical-guideline knowledge base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - menu button «Вопрос о лечении» -> kb.question -> rag.answer (KR quote + source) or «уточните у врача»; dose questions get a fixed doctor redirect - free text in idle that looks like a question (no check-in signals, not a drug question) -> knowledge base; no answer -> the usual support phrase - routing order unchanged: consent/safety first, side effects/quit/alcohol -> check-in, own drug -> med_qa (instruction only, SPEC §7 p.5) - question cues, small talk and texts in config/texts/knowledge.yaml; scrub placeholders before "что такое X" no longer hide the definition question Refs #64 Co-Authored-By: Claude Opus 5.5 --- app/dialog/flows/core.py | 1 + app/dialog/flows/knowledge.py | 102 ++++++++++++++++ app/dialog/flows/veteran_daily.py | 9 +- app/dialog/texts.py | 1 + app/llm/rag/qa.py | 3 +- config/texts/dialog.yaml | 1 + config/texts/knowledge.yaml | 51 ++++++++ tests/test_dialog_knowledge.py | 188 ++++++++++++++++++++++++++++++ 8 files changed, 353 insertions(+), 3 deletions(-) create mode 100644 app/dialog/flows/knowledge.py create mode 100644 config/texts/knowledge.yaml create mode 100644 tests/test_dialog_knowledge.py diff --git a/app/dialog/flows/core.py b/app/dialog/flows/core.py index 2d3ec47..7447731 100644 --- a/app/dialog/flows/core.py +++ b/app/dialog/flows/core.py @@ -39,6 +39,7 @@ def menu_reply(ctx: Ctx, text: str | None = None) -> Reply: buttons = [ [Button(label=b.checkin, data="menu:checkin")], [Button(label=b.med_question, data="menu:med_question")], + [Button(label=b.kb_question, data="menu:kb_question")], [Button(label=b.contact_doctor, data="menu:contact_doctor")], [Button(label=b.revoke, data="consent:revoke")], ] diff --git a/app/dialog/flows/knowledge.py b/app/dialog/flows/knowledge.py new file mode 100644 index 0000000..00b2382 --- /dev/null +++ b/app/dialog/flows/knowledge.py @@ -0,0 +1,102 @@ +"""Questions about the condition and its treatment → knowledge base of clinical guidelines (#64, RAG #60). + + menu «Вопрос о лечении» ──▶ kb.question (free text) ──▶ rag.answer: KR quote + source, or «уточните у врача» + free text in idle that looks like a question (veteran_daily `text:idle`, after the check-in signals and the + drug question) ──▶ rag.answer, or the usual support phrase when the KB has no answer + +The engine has already scrubbed the text, checked consent and run the safety check (ACUTE never gets here). +Questions about the patient's own drug stay with `med_qa` (instruction only, SPEC §7 п.5); this flow answers +general questions from the guidelines, extractively and without doses (`app.llm.rag.qa`). Logs carry codes only. +""" + +import logging +import re +from functools import lru_cache +from pathlib import Path + +import yaml +from pydantic import BaseModel, ConfigDict + +from app.config import get_settings +from app.dialog.contracts import Event, Reply +from app.dialog.flows.core import menu_reply +from app.dialog.fsm import Ctx, Step, abandonable, handler_for, state, trigger +from app.llm import rag +from app.llm.med_qa import DOSE_QUESTION + +log = logging.getLogger(__name__) + +QUESTION_STATE = "kb.question" +# `scrub()` placeholders and the punctuation around them: "[NAME], что такое ПТСР?" starts with "что такое". +_PLACEHOLDERS = re.compile(r"^(?:\s*\[[A-Z_]+\][\s,.:;!-]*)+") + + +class KnowledgeTexts(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + prompt: str + dose_redirect: str + question_starts: list[str] + small_talk: list[str] + + +@lru_cache +def load_knowledge_texts(path: Path | None = None) -> KnowledgeTexts: + path = path or get_settings().config_dir / "texts" / "knowledge.yaml" + return KnowledgeTexts.model_validate(yaml.safe_load(path.read_text(encoding="utf-8"))) + + +@lru_cache +def _question_start() -> re.Pattern[str]: + starts = sorted(load_knowledge_texts().question_starts, key=len, reverse=True) + return re.compile(r"^\s*(?:" + "|".join(re.escape(s) for s in starts) + r")\b", re.IGNORECASE) + + +@lru_cache +def _small_talk() -> re.Pattern[str]: + return re.compile(r"\b(?:" + "|".join(re.escape(s) for s in load_knowledge_texts().small_talk) + r")\b", re.I) + + +def looks_like_question(text: str) -> bool: + """A "?" anywhere or a question word at the start ("что такое флэшбек", "можно ли ..."); small talk + ("как дела?") is not a question for the knowledge base.""" + text = _PLACEHOLDERS.sub("", text) + if _small_talk().search(text): + return False + return "?" in text or bool(_question_start().search(text)) + + +async def knowledge_answer(ctx: Ctx, *, explicit: bool) -> str | None: + """KB answer for `ctx.text`. No answer → «уточните у врача» if the patient asked via the menu (`explicit`), + `None` otherwise (the caller keeps its own reply, e.g. the support phrase).""" + if ctx.text is None: + return None + if DOSE_QUESTION.search(ctx.text.lower()): + log.info("kb: dose question redirected to doctor") + return load_knowledge_texts().dose_redirect + reply = await rag.answer(ctx.text, audience="patient") + if reply is not None: + log.info("kb: answered docs=%s", ",".join(c.doc for c in reply.citations)) + return reply.text + log.info("kb: no answer explicit=%s", explicit) + return rag.fallback_text("patient") if explicit else None + + +@trigger("button:menu:kb_question") +async def _menu_question(ctx: Ctx, event: Event) -> Step: + return Step(replies=[Reply(text=load_knowledge_texts().prompt)], next_state=QUESTION_STATE) + + +@state(QUESTION_STATE) +async def _question(ctx: Ctx, event: Event) -> Step: + if event.type != "text" or ctx.text is None: + # A menu button or a reminder instead of the question: drop the question and let idle handle it. + step = Step(next_state="idle") + idle = handler_for("idle") + return step.then(await idle(ctx, event)) if idle is not None else step + text = await knowledge_answer(ctx, explicit=True) + return Step(replies=[menu_reply(ctx, text)], next_state="idle") + + +# A question asked yesterday must not swallow today's message. +abandonable("kb") diff --git a/app/dialog/flows/veteran_daily.py b/app/dialog/flows/veteran_daily.py index 1ea496c..984e3ca 100644 --- a/app/dialog/flows/veteran_daily.py +++ b/app/dialog/flows/veteran_daily.py @@ -47,6 +47,7 @@ from app.db import repo from app.dialog.contracts import Button, Event, Reply from app.dialog.flows import intake from app.dialog.flows.core import menu_reply +from app.dialog.flows.knowledge import knowledge_answer, looks_like_question from app.dialog.flows.meds import ( answer_question, checkin_of, @@ -423,11 +424,13 @@ async def _visit(ctx: Ctx, event: Event) -> Step: @trigger("text:idle", replace=True) async def _idle_text(ctx: Ctx, event: Event) -> Step: """Free text outside the check-in: drug question → instruction excerpt; side effects / quit intent / alcohol - → today's check-in (the rules react); otherwise the neutral support phrase with the menu.""" + → today's check-in (the rules react); another question → clinical guidelines (knowledge base); otherwise + the neutral support phrase with the menu.""" assert ctx.text is not None facts = await ctx.gateway.extract_checkin(ctx.text) if facts is None: - return Step(replies=[menu_reply(ctx, await ctx.gateway.phrase("support"))]) + text = await knowledge_answer(ctx, explicit=False) if looks_like_question(ctx.text) else None + return Step(replies=[menu_reply(ctx, text or await ctx.gateway.phrase("support"))]) fields: dict[str, Any] = {} if facts.quit_intent: fields["quit_intent"] = True @@ -445,6 +448,8 @@ async def _idle_text(ctx: Ctx, event: Event) -> Step: await save_checkin(ctx, **fields) if text is None and facts.med_question and not facts.quit_intent: text = await answer_question(ctx, await current_prescription(ctx)) + if text is None and not fields and looks_like_question(ctx.text): + text = await knowledge_answer(ctx, explicit=False) # general question → clinical guidelines (#64) if text is None: text = await ctx.gateway.phrase("support") return Step(replies=[menu_reply(ctx, text)], notify_monitoring=bool(fields or facts.side_effects)) diff --git a/app/dialog/texts.py b/app/dialog/texts.py index c209dac..2c987b3 100644 --- a/app/dialog/texts.py +++ b/app/dialog/texts.py @@ -47,6 +47,7 @@ class AgeGateTexts(_Strict): class IdleButtons(_Strict): checkin: str med_question: str + kb_question: str contact_doctor: str revoke: str diff --git a/app/llm/rag/qa.py b/app/llm/rag/qa.py index fdaf7b6..0bfec08 100644 --- a/app/llm/rag/qa.py +++ b/app/llm/rag/qa.py @@ -56,6 +56,7 @@ PRESCRIBING = re.compile( ) # "что такое флэшбек" -> the term; its definition "Флэшбек (flashback – англ.) – ..." is preferred. DEFINITION_QUESTION = re.compile(r"^\s*что (?:такое|значит|означает|это за)\s+(.+?)[?.!\s]*$", re.IGNORECASE) +LEADING_PLACEHOLDERS = re.compile(r"^(?:\s*\[[A-Z_]+\][\s,.:;!-]*)+") # "[NAME], что такое ПТСР?" 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) @@ -174,7 +175,7 @@ def _allowed(sentence: str | None, audience: Audience) -> bool: def _defined_term(question: str) -> set[str] | None: - m = DEFINITION_QUESTION.match(question) + m = DEFINITION_QUESTION.match(LEADING_PLACEHOLDERS.sub("", question)) return set(tokens(m.group(1))) or None if m else None diff --git a/config/texts/dialog.yaml b/config/texts/dialog.yaml index 9361d70..19690c9 100644 --- a/config/texts/dialog.yaml +++ b/config/texts/dialog.yaml @@ -63,6 +63,7 @@ idle: buttons: checkin: "Как я сегодня" med_question: "Вопрос о лекарстве" + kb_question: "Вопрос о лечении" contact_doctor: "Связаться с врачом" revoke: "Отозвать согласие" # Заглушки кнопок меню; ветки ветерана (#36/#37) и med_qa (#15) подменяют их своими триггерами. diff --git a/config/texts/knowledge.yaml b/config/texts/knowledge.yaml new file mode 100644 index 0000000..e9c5cba --- /dev/null +++ b/config/texts/knowledge.yaml @@ -0,0 +1,51 @@ +# Вопросы пациента о состоянии и лечении → база знаний по клиническим рекомендациям (#64, RAG #60). +# Зона clinical вычитывает формулировки. Сам ответ — только цитата из КР с источником (config/texts/rag.yaml +# задаёт его обрамление); здесь — тексты диалога и признаки того, что свободное сообщение — вопрос. + +# Кнопка меню «Вопрос о лечении» → бот просит написать вопрос. +prompt: |- + Напишите ваш вопрос о состоянии или лечении — например, «что такое флэшбек?» или «помогает ли психотерапия?». + Я отвечу цитатой из клинических рекомендаций Минздрава и укажу источник. Вопросы о дозе и о вашем препарате решает врач. + +# Вопрос о дозе (увеличить, уменьшить, полтаблетки): бот не отвечает по существу — это решение врача. +dose_redirect: "Дозу и схему приёма подбирает только ваш врач — менять их самостоятельно нельзя. Могу передать ваш вопрос врачу: нажмите «Связаться с врачом»." + +# Свободное сообщение считается вопросом, если в нём есть «?» или оно начинается с одного из этих слов. +# Тогда бот ищет ответ в базе знаний; если ответа нет — отвечает обычной фразой поддержки. +question_starts: + - "что такое" + - "что значит" + - "что означает" + - "почему" + - "зачем" + - "как" + - "когда" + - "сколько" + - "можно ли" + - "нужно ли" + - "надо ли" + - "помогает ли" + - "поможет ли" + - "правда ли" + - "бывает ли" + - "что делать" + - "что будет" + - "чем" + - "где" + - "есть ли" + +# Бытовые фразы — не вопросы о лечении, даже со «?»: «как дела?», «как ты?». База знаний для них не ищется +# (иначе «как дела» находит «линия делит» — у слов одна основа), бот отвечает обычной фразой поддержки. +small_talk: + - "как дела" + - "как ты" + - "как вы" + - "как жизнь" + - "как поживаете" + - "что нового" + - "кто ты" + - "ты кто" + - "ты бот" + - "ты робот" + - "привет" + - "спасибо" diff --git a/tests/test_dialog_knowledge.py b/tests/test_dialog_knowledge.py new file mode 100644 index 0000000..8da2bf6 --- /dev/null +++ b/tests/test_dialog_knowledge.py @@ -0,0 +1,188 @@ +"""Knowledge base in the dialog (#64): «Вопрос о лечении» menu, questions in idle free text, routing order +(safety → check-in signals → drug question → guidelines → support).""" + +from collections.abc import AsyncIterator +from datetime import datetime +from typing import Any + +import pytest + +from app.config import Settings +from app.core import clock +from app.db import repo, session +from app.db.bootstrap import init_db +from app.db.clinical import Prescription +from app.dialog.contracts import Event, Reply +from app.dialog.engine import Engine +from app.dialog.flows.knowledge import QUESTION_STATE, load_knowledge_texts, looks_like_question +from app.integrations.base import DrugSection, integrations +from app.llm import med_qa as med_qa_module +from app.llm.gateway import LLMGateway +from app.llm.med_qa import load_med_qa_texts +from app.llm.mock import MockTasks +from app.llm.rag import fallback_text +from app.llm.rag import qa as rag_qa +from app.llm.texts import render_phrase +from app.services.contracts import NoopMonitoring + +TOKEN = "k" * 32 +MEM = "sqlite+aiosqlite:///:memory:" +NOW = datetime(2026, 10, 7, 8, 0) +SUPPORT = render_phrase("support", {}) +KB = load_knowledge_texts() + + +class FakeDrugInfo: + async def sections(self, inn: str) -> list[DrugSection]: + text = "Улучшение обычно отмечается через 2–4 недели регулярного приёма." + return [DrugSection(inn=inn, section="onset", text=text, source="синтетика")] + + +@pytest.fixture +async def engine(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[Engine]: + monkeypatch.setattr(clock, "get_settings", lambda: Settings(_env_file=None, demo_clock=True)) + monkeypatch.setattr(integrations, "drug_info", FakeDrugInfo()) + monkeypatch.setattr(med_qa_module, "get_gateway", lambda: LLMGateway(MockTasks())) + monkeypatch.setattr(rag_qa, "get_gateway", lambda: LLMGateway(MockTasks())) + clock.set_now(NOW) + await session.configure(identity_url=MEM, clinical_url=MEM) + await init_db() + async with session.get_clinical_session() as s: + await repo.upsert_profile( + s, token=TOKEN, doctor_id="doc", modules=["veteran"], sex="m", birth_year=1990, therapy_start=NOW.date() + ) + s.add(Prescription(token=TOKEN, drug="сертралин", dose="50 мг", schedule={"times": ["08:00"]})) + await repo.set_dialog_state(s, token=TOKEN, state="idle", data={"consent": True}, now=clock.now()) + yield Engine(gateway=LLMGateway(MockTasks()), monitoring=NoopMonitoring()) + clock.reset() + await session.configure() + + +async def press(e: Engine, data: str) -> list[Reply]: + return await e.handle(TOKEN, Event(type="button", button=data)) + + +async def say(e: Engine, t: str) -> list[Reply]: + return await e.handle(TOKEN, Event(type="text", text=t)) + + +async def state() -> tuple[str, dict[str, Any]]: + async with session.get_clinical_session() as s: + row = await repo.get_dialog_state(s, TOKEN) + assert row is not None + return row.state, dict(row.data) + + +def buttons(replies: list[Reply]) -> list[str]: + return [b.data for r in replies for row in r.buttons for b in row] + + +def joined(replies: list[Reply]) -> str: + return "\n".join(r.text for r in replies) + + +# --- question detection -------------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + ("что такое флэшбек", True), + ("Можно ли вылечить ПТСР", True), + ("кошмары снятся, это нормально?", True), + ("сегодня 6 из 10", False), + ("спал плохо", False), + ("какой-то день", False), # "как" only as a whole word at the start + ("как дела?", False), # small talk + ("Привет! Как ты?", False), + ("[NAME], что такое ПТСР", True), # scrub placeholder before the question + ], +) +def test_looks_like_question(text: str, expected: bool) -> None: + assert looks_like_question(text) is expected + + +# --- menu «Вопрос о лечении» ------------------------------------------------------------------------------ + + +async def test_menu_has_knowledge_button(engine: Engine) -> None: + r = await engine.handle(TOKEN, Event(type="start")) + assert "menu:kb_question" in buttons(r) + + +async def test_menu_question_answers_from_guidelines(engine: Engine) -> None: + r = await press(engine, "menu:kb_question") + assert joined(r) == KB.prompt + assert (await state())[0] == QUESTION_STATE + + r = await say(engine, "что такое флэшбек?") + text = joined(r) + assert "Флэшбек (flashback" in text + assert "Источник: КР ПТСР 2023, стр. 2" in text + assert rag_qa.load_rag_texts()["patient"].doctor_note in text + assert "menu:kb_question" in buttons(r) # back to the menu + assert (await state())[0] == "idle" + + +async def test_menu_question_without_answer_sends_to_doctor(engine: Engine) -> None: + await press(engine, "menu:kb_question") + r = await say(engine, "какой рецепт борща") + assert fallback_text("patient") in joined(r) + assert (await state())[0] == "idle" + + +@pytest.mark.parametrize("question", ["можно увеличить дозу?", "можно пить полтаблетки?"]) +async def test_dose_question_redirected_to_doctor(engine: Engine, question: str) -> None: + await press(engine, "menu:kb_question") + r = await say(engine, question) + assert joined(r).startswith(KB.dose_redirect) + assert "клинических рекомендациях" not in joined(r) + + +async def test_button_instead_of_question_leaves_the_flow(engine: Engine) -> None: + await press(engine, "menu:kb_question") + r = await press(engine, "menu:contact_doctor") + assert "врач" in joined(r) + assert (await state())[0] == "idle" + + +async def test_scrubbed_question_still_answered(engine: Engine) -> None: + await press(engine, "menu:kb_question") + r = await say(engine, "Иван Петров, 89161234567, что такое ПТСР?") + assert "ПТСР – это расстройство" in joined(r) + + +# --- free text in idle ------------------------------------------------------------------------------------ + + +async def test_free_text_question_answered_from_guidelines(engine: Engine) -> None: + r = await say(engine, "помогает ли психотерапия при ПТСР?") + text = joined(r) + assert "В клинических рекомендациях Минздрава сказано:" in text + assert "Источник: КР ПТСР 2023" in text + + +@pytest.mark.parametrize("message", ["сегодня обычный день", "как дела?"]) +async def test_free_text_without_answer_keeps_support(engine: Engine, message: str) -> None: + r = await say(engine, message) + assert joined(r) == SUPPORT + assert "Источник" not in joined(r) + + +async def test_drug_question_stays_with_instruction(engine: Engine) -> None: + # SPEC §7 п.5: the patient's own drug is answered only from its instruction, never from the guidelines. + r = await say(engine, "через сколько подействуют таблетки?") + text = joined(r) + assert text.startswith(load_med_qa_texts().intro) + assert "клинических рекомендациях" not in text + + +async def test_side_effect_question_goes_to_checkin(engine: Engine) -> None: + r = await say(engine, "меня тошнит от таблеток, это нормально?") + assert "клинических рекомендациях" not in joined(r) + + +async def test_acute_risk_never_reaches_knowledge_base(engine: Engine) -> None: + r = await say(engine, "зачем жить? хочу умереть") + assert any(x.kind == "crisis" for x in r) + assert "клинических рекомендациях" not in joined(r) From 25728c4b314df72ed723977cb428286de1ec5652 Mon Sep 17 00:00:00 2001 From: Lacky1234union Date: Sun, 4 Oct 2026 12:34:29 +0300 Subject: [PATCH 03/12] feat(backend): API stage 1 on real data instead of fixtures Doctor list/card/alerts/ack/connect, dialog events/outbox/history and demo controls now read the identity and clinical DBs. Rule-based discontinuation risk index, trend, adherence; every doctor read is audited. Refs #12 Co-Authored-By: Claude Opus 5.5 --- app/api/demo.py | 89 ++++++++++ app/api/doctor_views.py | 368 +++++++++++++++++++++++++++++++++++++++ app/api/routes.py | 219 ++++++++++++++++++++--- tests/test_api_dialog.py | 157 +++++++++++++++++ tests/test_api_doctor.py | 288 ++++++++++++++++++++++++++++++ tests/test_contracts.py | 11 +- 6 files changed, 1099 insertions(+), 33 deletions(-) create mode 100644 app/api/demo.py create mode 100644 app/api/doctor_views.py create mode 100644 tests/test_api_dialog.py create mode 100644 tests/test_api_doctor.py diff --git a/app/api/demo.py b/app/api/demo.py new file mode 100644 index 0000000..535e7cd --- /dev/null +++ b/app/api/demo.py @@ -0,0 +1,89 @@ +"""Demo-console helpers (#12, ADR-005): scripted personas, reset, connect links, outbox access. + +Synthetic data only. Scenario files live in `data/synthetic/.jsonl` (#18); a patient is a scripted persona +when the transliterated first word of the display name equals the slug ("Андрей С." → `andrey.jsonl`). +""" + +import inspect +import logging +from importlib import import_module, util +from pathlib import Path + +from app.config import get_settings +from app.integrations.base import MemoryOutbox, Outbox, integrations + +log = logging.getLogger(__name__) + +SEED_MODULE = "scripts.seed_demo" +CONSOLE_PATH = "/console" + +_TRANSLIT = { + "а": "a", "б": "b", "в": "v", "г": "g", "д": "d", "е": "e", "ё": "e", "ж": "zh", "з": "z", "и": "i", + "й": "y", "к": "k", "л": "l", "м": "m", "н": "n", "о": "o", "п": "p", "р": "r", "с": "s", "т": "t", + "у": "u", "ф": "f", "х": "kh", "ц": "ts", "ч": "ch", "ш": "sh", "щ": "shch", "ъ": "", "ы": "y", "ь": "", + "э": "e", "ю": "yu", "я": "ya", +} # fmt: skip + + +def persona_slug(display_name: str) -> str: + first = (display_name.split() or [""])[0].lower() + return "".join(_TRANSLIT.get(ch, ch if ch.isascii() and ch.isalnum() else "") for ch in first) + + +def scenario_files(data_dir: Path | None = None) -> dict[str, str]: + """slug → scenario path relative to the project root, e.g. {"andrey": "data/synthetic/andrey.jsonl"}.""" + root = data_dir or get_settings().data_dir + folder = root / "synthetic" + if not folder.is_dir(): + return {} + return {f.stem: f"data/synthetic/{f.name}" for f in sorted(folder.glob("*.jsonl"))} + + +async def bot_username() -> str | None: + """Username of the running Telegram bot (deep links); None when the bot is not started (no TG_BOT_TOKEN).""" + from app.channels import telegram + + bot = getattr(telegram, "_bot", None) + if bot is None: + return None + try: + me = await bot.me() # aiogram caches getMe + except Exception as exc: # noqa: BLE001 — network / auth errors: fall back to the web console link + log.warning("bot username unavailable error=%s", type(exc).__name__) + return None + return me.username or None + + +async def deep_link(code: str) -> str: + username = await bot_username() + return f"https://t.me/{username}?start={code}" if username else f"{CONSOLE_PATH}?code={code}" + + +def memory_outbox() -> MemoryOutbox | None: + """The in-memory outbox polled by the web console, also when wrapped (Telegram keeps it as `fallback`).""" + box: Outbox | None = integrations.outbox + for _ in range(5): + if isinstance(box, MemoryOutbox): + return box + box = getattr(box, "fallback", None) + return None + + +async def reset_demo() -> bool: + """Clock reset is done by the caller; re-seed via `scripts.seed_demo.reset()` (#18) when it exists.""" + box = memory_outbox() + if box is not None: + box._q.clear() + try: + spec = util.find_spec(SEED_MODULE) + except ModuleNotFoundError: # no `scripts` package on the path (e.g. installed app without scripts/) + spec = None + if spec is None: + return False + reset = getattr(import_module(SEED_MODULE), "reset", None) + if reset is None: + return False + result = reset() + if inspect.isawaitable(result): + await result + return True diff --git a/app/api/doctor_views.py b/app/api/doctor_views.py new file mode 100644 index 0000000..8d73a0a --- /dev/null +++ b/app/api/doctor_views.py @@ -0,0 +1,368 @@ +"""Doctor dashboard read models (#12): clinical DB rows → frozen API schemas (`app/api/schemas.py`). + +Everything shown to the doctor is derived from stored structured data — no LLM decides colours, trends or the +discontinuation risk (ADR-007). Time comes in as `now` (from `app.core.clock`, ADR-005). + +Identity data (guardrail #5): the API is the doctor's display boundary, so it reads exactly two identity fields — +`display_name` (minimal identification for the treating doctor) and `birth_year` (age). SNILS, phone and chat +id are never selected into a response model. +""" + +from collections.abc import Iterable, Sequence +from dataclasses import dataclass +from datetime import date, datetime, timedelta +from typing import Any, Literal, cast, get_args + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.schemas import ( + AlertOut, + Color, + DiscontinuationRisk, + DoseOut, + LevelName, + Metric, + Module, + PatientCard, + PatientListItem, + SeriesPoint, +) +from app.core.rules import color, patient_status +from app.core.types import AlertLevel +from app.db import repo +from app.db.clinical import Alert, DailyCheckin, Dose, PatientProfile, Prescription, SafetyPlan, ScaleResult +from app.db.identity import Patient +from app.integrations.base import Prescription as PrescriptionOut + +COLOR_ORDER: dict[str, int] = {"red": 0, "yellow": 1, "grey": 2, "green": 3} +_MODULES = frozenset(get_args(Module)) +_METRICS = frozenset(get_args(Metric)) +_SCALE_LABELS = {"phq9": "PHQ-9", "pcl5": "PCL-5", "gad7": "GAD-7", "auditc": "AUDIT-C", "dar5": "DAR-5"} + +# Daily check-in buckets → numbers for charts. Sleep: bucket midpoints in hours ("<4" plotted as 3, ">8" as 9). +SLEEP_HOURS = {"<4": 3.0, "4-6": 5.0, "6-8": 7.0, ">8": 9.0} +# Alcohol: 0 = none, 1 = 1–2 drinks, 2 = more (same coding as app/llm/summary.py expects). +ALCOHOL_LEVEL = {"none": 0.0, "1-2": 1.0, "more": 2.0} + +# Trend: the last two complete results of one scale. A change smaller than about half of the minimal clinically +# important difference (PHQ-9: 5 points, PCL-5: 10 points) is reported as "flat" so noise does not flip arrows. +TREND_MIN_CHANGE = {"phq9": 3, "pcl5": 5} + +CARD_DAYS = 60 # how far back the card series / dose log reach + + +# --------------------------------------------------------------------------- discontinuation risk index +# +# Rule-based index (NOT a trained model) of the risk that the patient drops the therapy, 0–100, for sorting the +# list and for the "talk about adherence" hint. Each factor is a deterministic check over the clinical DB with a +# fixed weight; the score is the capped sum. Weights reflect the known predictors of early antidepressant +# discontinuation (non-adherence, side effects in the first weeks, expressed intent to stop, low mood, alcohol): +# +# factor weight label for the doctor +# missed/skipped doses on >= 3 of the last 7 days 30 "пропуски приёма N/7 дн." +# ... on 1–2 of the last 7 days 15 "пропуски приёма N/7 дн." +# side effects reported >= 3 days in a row 20 "побочные эффекты N дн. подряд" +# ... 1–2 days in a row 10 "побочные эффекты N дн. подряд" +# intent to stop medication voiced in last 14 days 25 "хотел бросить (день N)" +# open, unacknowledged quit_intent alert +15 "не разобран сигнал «хочет бросить»" +# mood <= 3 for >= 3 days in a row 15 "настроение ≤ 3 N дн. подряд" +# alcohol on >= 3 of the last 7 days 15 "алкоголь N дн. из 7" +# ... on 1–2 of the last 7 days 5 "алкоголь N дн. из 7" +# +# Level: >= 60 high, >= 30 medium, otherwise low. "Day" = calendar day in the clinic time zone; streaks end today +# if today's check-in exists, otherwise yesterday (same anchor as app.core.rules), and a day without a check-in +# breaks a streak — we never assume a symptom the patient did not report. + +RISK_WINDOW_DAYS = 7 +QUIT_WINDOW_DAYS = 14 +MOOD_LOW = 3 +W_MISSED_MANY, W_MISSED_SOME = 30, 15 +W_SIDE_EFFECTS_LONG, W_SIDE_EFFECTS_SHORT = 20, 10 +W_QUIT_INTENT, W_QUIT_ALERT_OPEN = 25, 15 +W_MOOD_LOW = 15 +W_ALCOHOL_MANY, W_ALCOHOL_SOME = 15, 5 +STREAK_DAYS = 3 +RISK_HIGH, RISK_MEDIUM = 60, 30 + + +@dataclass +class PatientData: + """Everything the list item and the card are computed from (one patient, loaded once).""" + + profile: PatientProfile + checkins: Sequence[DailyCheckin] + results: Sequence[ScaleResult] + doses: Sequence[Dose] + prescriptions: Sequence[Prescription] + alerts: Sequence[Alert] # newest first + + +async def load_patient_data(session: AsyncSession, profile: PatientProfile) -> PatientData: + token = profile.token + doses = (await session.scalars(select(Dose).where(Dose.token == token).order_by(Dose.planned_at))).all() + rx = ( + await session.scalars(select(Prescription).where(Prescription.token == token).order_by(Prescription.id)) + ).all() + return PatientData( + profile=profile, + checkins=await repo.list_checkins(session, token), + results=await repo.list_scale_results(session, token), + doses=doses, + prescriptions=rx, + alerts=await repo.list_alerts(session, token=token), + ) + + +def _local(ts: datetime, now: datetime) -> date: + return ts.astimezone(now.tzinfo).date() + + +def _streak_days(checkins: Iterable[DailyCheckin], today: date, pred: Any) -> int: + """Length of the run of consecutive check-in days satisfying `pred`, ending at the anchor day.""" + by_day = {c.day: c for c in checkins if c.day <= today} + day = today if today in by_day else today - timedelta(days=1) + n = 0 + while (c := by_day.get(day)) is not None and pred(c): + n += 1 + day -= timedelta(days=1) + return n + + +def _therapy_day(profile: PatientProfile, day: date) -> int: + return (day - profile.therapy_start).days if profile.therapy_start else 0 + + +def _missed_days(d: PatientData, now: datetime, first: date) -> set[date]: + # Same sources as rule missed_doses_7d: dose records and the check-in answer "did not take". + days = {_local(x.planned_at, now) for x in d.doses if x.status in ("missed", "skipped") and x.planned_at <= now} + days |= {c.day for c in d.checkins if c.med_taken is False} + return {x for x in days if first <= x <= now.date()} + + +def discontinuation_risk(d: PatientData, now: datetime) -> DiscontinuationRisk: + """Deterministic discontinuation-risk index; see the weight table above.""" + today = now.date() + first = today - timedelta(days=RISK_WINDOW_DAYS - 1) + score = 0 + factors: list[str] = [] + + missed = len(_missed_days(d, now, first)) + if missed: + score += W_MISSED_MANY if missed >= STREAK_DAYS else W_MISSED_SOME + factors.append(f"пропуски приёма {missed}/{RISK_WINDOW_DAYS} дн.") + + side = _streak_days(d.checkins, today, lambda c: bool(c.side_effects)) + if side: + score += W_SIDE_EFFECTS_LONG if side >= STREAK_DAYS else W_SIDE_EFFECTS_SHORT + factors.append(f"побочные эффекты {side} дн. подряд") + + quit_since = today - timedelta(days=QUIT_WINDOW_DAYS - 1) + quit_days = [c.day for c in d.checkins if c.quit_intent and quit_since <= c.day <= today] + if quit_days: + score += W_QUIT_INTENT + factors.append(f"хотел бросить (день {_therapy_day(d.profile, max(quit_days))})") + if any(a.code == "quit_intent" and a.acked_at is None for a in d.alerts): + score += W_QUIT_ALERT_OPEN + factors.append("не разобран сигнал «хочет бросить»") + + low = _streak_days(d.checkins, today, lambda c: c.mood is not None and c.mood <= MOOD_LOW) + if low >= STREAK_DAYS: + score += W_MOOD_LOW + factors.append(f"настроение ≤ {MOOD_LOW} {low} дн. подряд") + + drinking = sum(1 for c in d.checkins if first <= c.day <= today and c.alcohol_bucket not in (None, "none")) + if drinking: + score += W_ALCOHOL_MANY if drinking >= STREAK_DAYS else W_ALCOHOL_SOME + factors.append(f"алкоголь {drinking} дн. из {RISK_WINDOW_DAYS}") + + score = min(score, 100) + level: Literal["low", "medium", "high"] = ( + "high" if score >= RISK_HIGH else "medium" if score >= RISK_MEDIUM else "low" + ) + return DiscontinuationRisk(score=score, level=level, factors=factors) + + +# --------------------------------------------------------------------------- list item + + +def _complete(d: PatientData, scale: str) -> list[ScaleResult]: + return [r for r in d.results if r.scale == scale and r.complete] # repo returns them oldest first + + +def trend(d: PatientData) -> Literal["up", "down", "flat", "unknown"]: + """Direction of symptom scores (up = worse) from the scale with the most recent pair of results.""" + pairs = [(s, rs[-2:]) for s in TREND_MIN_CHANGE if len(rs := _complete(d, s)) >= 2] + if not pairs: + return "unknown" + scale, (prev, last) = max(pairs, key=lambda p: p[1][-1].ts) + delta = last.total - prev.total + if abs(delta) < TREND_MIN_CHANGE[scale]: + return "flat" + return "up" if delta > 0 else "down" + + +def adherence_7d(d: PatientData, now: datetime) -> float | None: + """Taken / planned doses of the last 7 days; doses still `pending` (not resolved yet) are not counted.""" + since = now - timedelta(days=RISK_WINDOW_DAYS) + resolved = [x for x in d.doses if since < x.planned_at <= now and x.status != "pending"] + if not resolved: + return None + return round(sum(1 for x in resolved if x.status == "taken") / len(resolved), 2) + + +def _drug_label(rx: Prescription) -> str: + return f"{rx.drug} {rx.dose}".strip() + + +def _active_rx(d: PatientData, today: date) -> Prescription | None: + active = [ + r + for r in d.prescriptions + if (r.start_date is None or r.start_date <= today) and (r.end_date is None or r.end_date >= today) + ] + return (active or list(d.prescriptions) or [None])[-1] + + +def _last_event(d: PatientData, now: datetime) -> str: + alert = d.alerts[0] if d.alerts else None + scales = [r for r in d.results if r.complete and r.scale in _SCALE_LABELS] + scale = scales[-1] if scales else None + if scale is not None and (alert is None or scale.ts > alert.ts): + day = _therapy_day(d.profile, _local(scale.ts, now)) + return f"{_SCALE_LABELS[scale.scale]} = {scale.total} (день {day})" + return alert.reason if alert is not None else "" + + +def _age(patient: Patient | None, profile: PatientProfile, today: date) -> int: + birth_year = (patient.birth_year if patient else None) or profile.birth_year + return today.year - birth_year if birth_year else 0 + + +def list_item(d: PatientData, patient: Patient | None, now: datetime) -> PatientListItem: + p = d.profile + today = now.date() + open_alerts = [a for a in d.alerts if a.acked_at is None] + # ADR-006: the colour is derived from open alert levels (the source of truth), not from the cached column. + status = patient_status(AlertLevel[a.level.upper()] for a in open_alerts) + phq9, pcl5 = _complete(d, "phq9"), _complete(d, "pcl5") + rx = _active_rx(d, today) + return PatientListItem( + token=p.token, + display_name=patient.display_name if patient else "Пациент", + age=_age(patient, p, today), + modules=[cast(Module, m) for m in p.modules or [] if m in _MODULES], + drug=_drug_label(rx) if rx else None, + therapy_day=_therapy_day(p, today), + next_visit=p.next_visit, + status_color=status.color, + top_alert_level=cast(LevelName, status.top_level.name.lower()) if status.top_level is not None else None, + open_alerts=len(open_alerts), + phq9_last=phq9[-1].total if phq9 else None, + pcl5_last=pcl5[-1].total if pcl5 else None, + trend=trend(d), + adherence_7d=adherence_7d(d, now), + discontinuation_risk=discontinuation_risk(d, now), + last_event=_last_event(d, now), + ) + + +def sort_key(item: PatientListItem) -> tuple[int, int, str]: + """red → yellow → grey → green, then discontinuation risk (desc), then name for a stable order.""" + return (COLOR_ORDER[item.status_color], -item.discontinuation_risk.score, item.display_name) + + +# --------------------------------------------------------------------------- card + + +def alert_out(a: Alert, patient_name: str) -> AlertOut: + level = cast(LevelName, a.level) + return AlertOut( + id=a.id, + token=a.token, + patient_name=patient_name, + ts=a.ts, + level=level, + color=cast(Color, color([AlertLevel[a.level.upper()]])), + code=a.code, + reason=a.reason, + acked_by=a.acked_by, + acked_at=a.acked_at, + ) + + +def series(d: PatientData, now: datetime) -> list[SeriesPoint]: + since = now.date() - timedelta(days=CARD_DAYS) + out: list[SeriesPoint] = [] + for c in d.checkins: + if not since <= c.day <= now.date(): + continue + if c.mood is not None: + out.append(SeriesPoint(day=c.day, metric="mood", value=float(c.mood))) + if c.sleep_bucket in SLEEP_HOURS: + out.append(SeriesPoint(day=c.day, metric="sleep_hours", value=SLEEP_HOURS[c.sleep_bucket])) + if c.alcohol_bucket in ALCOHOL_LEVEL: + out.append(SeriesPoint(day=c.day, metric="alcohol", value=ALCOHOL_LEVEL[c.alcohol_bucket])) + for r in d.results: + day = _local(r.ts, now) + if r.complete and r.scale in _METRICS and since <= day and r.ts <= now: + out.append(SeriesPoint(day=day, metric=cast(Metric, r.scale), value=float(r.total), scheduled=r.scheduled)) + return sorted(out, key=lambda p: (p.day, p.metric)) + + +def doses(d: PatientData, now: datetime) -> list[DoseOut]: + labels = {r.id: _drug_label(r) for r in d.prescriptions} + since = now - timedelta(days=CARD_DAYS) + return [ + DoseOut(planned_at=x.planned_at, drug=labels.get(x.prescription_id, ""), status=x.status) # type: ignore[arg-type] + for x in d.doses + if since <= x.planned_at <= now + timedelta(days=1) + ] + + +def prescriptions(d: PatientData) -> list[PrescriptionOut]: + out = [] + for r in d.prescriptions: + schedule = dict(r.schedule or {}) + out.append( + PrescriptionOut( + drug=r.drug, + dose=r.dose, + schedule={"times": [str(t) for t in schedule.get("times", [])]}, + instructions=" ".join(str(t) for t in _as_list(schedule.get("instructions"))), + start_date=r.start_date, + end_date=r.end_date, + ) + ) + return out + + +def _as_list(value: Any) -> list[Any]: + if value is None: + return [] + return list(value) if isinstance(value, list) else [value] + + +def _plan_line(step: Any) -> str: + # Same rendering as the dialog's safety-plan reply (app/dialog/actions.py). + if isinstance(step, dict): + return ": ".join(str(v) for v in step.values() if v) + return str(step) + + +async def safety_plan(session: AsyncSession, token: str) -> list[str] | None: + row = await session.get(SafetyPlan, token) + return [_plan_line(s) for s in row.steps] if row is not None and row.steps else None + + +async def build_card(session: AsyncSession, d: PatientData, patient: Patient | None, now: datetime) -> PatientCard: + """Card without the summary (the caller adds it: the LLM call must not hold the DB session).""" + item = list_item(d, patient, now) + return PatientCard( + patient=item, + prescriptions=prescriptions(d), + series=series(d, now), + doses=doses(d, now), + alerts=[alert_out(a, item.display_name) for a in d.alerts], + safety_plan=await safety_plan(session, d.profile.token), + ) diff --git a/app/api/routes.py b/app/api/routes.py index 3c6d4e2..5f70926 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -1,11 +1,20 @@ -"""API routes `/api/v1` (#34 stubs → #12 implementation). +"""API routes `/api/v1` (#34 contract → #12 implementation). -Stubs return deterministic fixtures (`app/api/fixtures.py`) so the dashboard can be built immediately. -Lane B replaces bodies in #12 without changing paths or schemas (those are the frozen contract). +Paths and schemas are the frozen contract (`app/api/schemas.py`); bodies read the identity and clinical DBs. +Read models live in `app/api/doctor_views.py`, demo-console helpers in `app/api/demo.py`. +Every doctor access to patient data is written to `audit_log` (guardrail #6): `view_patient_list`, +`view_patient` (also a denied attempt: `view_patient_denied`), `view_alerts` (only when new alerts are +disclosed — the dashboard polls every 2–3 s), `ack_alert` (in `repo.ack_alert`), `connect_patient`. +`app/api/fixtures.py` is kept for tests and as the demo cabinet reference (doctor id). """ -from fastapi import APIRouter, HTTPException +import secrets +from fastapi import APIRouter, HTTPException +from sqlalchemy import select + +from app.api import demo as console +from app.api import doctor_views as views from app.api import fixtures from app.api.questionnaire import router as questionnaire_router from app.api.schemas import ( @@ -23,69 +32,203 @@ from app.api.schemas import ( PatientListItem, ) from app.core import clock -from app.dialog.contracts import get_engine -from app.integrations.base import MemoryOutbox, integrations +from app.core.privacy import pseudo_id +from app.db import identity_repo, repo +from app.db.audit import audit +from app.db.clinical import Alert, Message, PatientProfile +from app.db.clinical import Prescription as PrescriptionRow +from app.db.identity import Patient +from app.db.session import get_clinical_session, get_identity_session +from app.dialog.contracts import Event, get_engine +from app.integrations.base import integrations from app.scheduler import jobs as scheduler +from app.services.monitoring import refresh_status router = APIRouter(prefix="/api/v1") router.include_router(questionnaire_router) +DEFAULT_CLINIC = "clinic-demo" +CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # no 0/O/1/I: the code may be typed by hand +CODE_LEN = 8 + + +async def _patients(tokens: list[str]) -> dict[str, Patient]: + """Identity rows for display name / age only (never put SNILS or phone into a response).""" + if not tokens: + return {} + async with get_identity_session() as s: + rows = (await s.scalars(select(Patient).where(Patient.token.in_(tokens)))).all() + return {p.token: p for p in rows} + + +def _name(patients: dict[str, Patient], token: str) -> str: + p = patients.get(token) + return p.display_name if p else "Пациент" + # ------------------------------------------------------------------ doctor dashboard @router.get("/doctors/{doctor_id}/patients", response_model=list[PatientListItem], tags=["doctor"]) async def list_patients(doctor_id: str) -> list[PatientListItem]: """Doctor's patients sorted red → yellow → grey → green, then by discontinuation risk.""" - order = {"red": 0, "yellow": 1, "grey": 2, "green": 3} - return sorted(fixtures.PATIENTS, key=lambda p: (order[p.status_color], -p.discontinuation_risk.score)) + now = clock.now() + async with get_clinical_session() as s: + profiles = await repo.patients_of_doctor(s, doctor_id) + data = [await views.load_patient_data(s, p) for p in profiles] + await audit(s, actor=doctor_id, action="view_patient_list", token=None, details={"count": len(data)}, ts=now) + patients = await _patients([p.token for p in profiles]) + items = [views.list_item(d, patients.get(d.profile.token), now) for d in data] + return sorted(items, key=views.sort_key) @router.get("/patients/{token}", response_model=PatientCard, tags=["doctor"]) async def patient_card(token: str, doctor_id: str) -> PatientCard: - """Patient card. Every call is written to audit_log as `view_patient` (#12).""" - card = fixtures.card(token) - if card is None: - raise HTTPException(404, "patient not found") + """Patient card. Every call is written to audit_log as `view_patient`; only the attending doctor may read it.""" + now = clock.now() + patient = (await _patients([token])).get(token) + async with get_clinical_session() as s: + profile = await repo.get_profile(s, token) + if profile is None: + raise HTTPException(404, "patient not found") + if profile.doctor_id != doctor_id: + await audit(s, actor=doctor_id, action="view_patient_denied", token=token, ts=now) + await s.commit() + raise HTTPException(403, "not your patient") + data = await views.load_patient_data(s, profile) + card = await views.build_card(s, data, patient, now) + await audit(s, actor=doctor_id, action="view_patient", token=token, ts=now) + # Summary after the commit: the (possibly slow) LLM call must not hold the DB transaction. + from app.llm.summary import build_summary # summary imports app.api.schemas; avoid an import cycle + + card.summary = await build_summary(card, today=now.date()) return card @router.get("/doctors/{doctor_id}/alerts", response_model=list[AlertOut], tags=["doctor"]) async def list_alerts(doctor_id: str, since_id: int = -1, open_only: bool = False) -> list[AlertOut]: """Polled every 2–3 s by the dashboard: alerts with id > since_id, newest first.""" - alerts = [a for a in fixtures.all_alerts() if a.id > since_id] - return [a for a in alerts if a.acked_at is None] if open_only else alerts + async with get_clinical_session() as s: + tokens = list(await s.scalars(select(PatientProfile.token).where(PatientProfile.doctor_id == doctor_id))) + stmt = select(Alert).where(Alert.token.in_(tokens), Alert.id > since_id) + if open_only: + stmt = stmt.where(Alert.acked_at.is_(None)) + alerts = (await s.scalars(stmt.order_by(Alert.ts.desc(), Alert.id.desc()))).all() + if alerts: + await audit( + s, + actor=doctor_id, + action="view_alerts", + token=None, + details={"alert_ids": [a.id for a in alerts][:50], "count": len(alerts)}, + ts=clock.now(), + ) + patients = await _patients(sorted({a.token for a in alerts})) + return [views.alert_out(a, _name(patients, a.token)) for a in alerts] @router.post("/alerts/{alert_id}/ack", response_model=AlertOut, tags=["doctor"]) async def ack_alert(alert_id: int, body: AckIn) -> AlertOut: """Doctor acknowledges an alert (written to audit_log). Acking an acute alert lets the patient leave crisis.""" - alert = next((a for a in fixtures.all_alerts() if a.id == alert_id), None) - if alert is None: - raise HTTPException(404, "alert not found") - return alert.model_copy(update={"acked_by": body.doctor_id, "acked_at": clock.now()}) + now = clock.now() + async with get_clinical_session() as s: + alert = await s.get(Alert, alert_id) + if alert is None: + raise HTTPException(404, "alert not found") + profile = await repo.get_profile(s, alert.token) + if profile is None or profile.doctor_id != body.doctor_id: + raise HTTPException(403, "not your patient") + await repo.ack_alert(s, alert_id=alert_id, doctor_id=body.doctor_id, ts=now) + await refresh_status(s, alert.token) + open_acute = [a for a in await repo.list_alerts(s, token=alert.token, open_only=True) if a.level == "acute"] + out = views.alert_out(alert, "") + # The crisis ends only when no acute alert is left open (another acute signal keeps the patient in crisis). + if alert.level == "acute" and not open_acute: + replies = await get_engine().handle(alert.token, Event(type="system", system="doctor_ack")) + if replies: + await integrations.outbox.push(alert.token, replies) + return out.model_copy(update={"patient_name": _name(await _patients([alert.token]), alert.token)}) @router.post("/patients/connect", response_model=ConnectPatientOut, tags=["doctor"]) async def connect_patient(body: ConnectPatientIn) -> ConnectPatientOut: """Doctor connects a patient after the in-person visit (323-FZ art. 36.2): prescription → МИС mock, code/QR.""" - return ConnectPatientOut(token="tok-new", code="DEMO-0001", deep_link="https://t.me/ptsr_demo_bot?start=DEMO-0001") + now = clock.now() + try: + token = pseudo_id(body.snils) + except ValueError as exc: + raise HTTPException(422, "SNILS must contain 11 digits") from exc + code = "".join(secrets.choice(CODE_ALPHABET) for _ in range(CODE_LEN)) + async with get_identity_session() as ids: + if await identity_repo.get_patient(ids, token) is not None: + raise HTTPException(409, "patient already connected") + doctor = await identity_repo.get_doctor(ids, body.doctor_id) + await identity_repo.create_patient( + ids, + token=token, + display_name=body.display_name, + snils=body.snils, + clinic_id=doctor.clinic_id if doctor else DEFAULT_CLINIC, + doctor_id=body.doctor_id, + now=now, + birth_year=body.birth_year, + sex=body.sex, + ) + await identity_repo.create_connect_code(ids, code=code, token=token, doctor_id=body.doctor_id, now=now) + # Clinical writes nested inside the identity unit of work: if they fail, the identity row rolls back too. + async with get_clinical_session() as s: + await repo.upsert_profile( + s, + token=token, + doctor_id=body.doctor_id, + modules=list(body.modules), + therapy_start=now.date(), + next_visit=body.next_visit, + birth_year=body.birth_year, + sex=body.sex, + ) + schedule: dict[str, list[str]] = {"times": list(body.times)} + if body.instructions: + schedule["instructions"] = [body.instructions] + s.add( + PrescriptionRow(token=token, drug=body.drug, dose=body.dose, schedule=schedule, start_date=now.date()) + ) + await audit( + s, + actor=body.doctor_id, + action="connect_patient", + token=token, + details={"modules": list(body.modules), "drug": body.drug}, + ts=now, + ) + return ConnectPatientOut(token=token, code=code, deep_link=await console.deep_link(code)) # ------------------------------------------------------------------ patient dialog (web console / any channel) +async def _require_patient(token: str) -> None: + async with get_clinical_session() as s: + if await repo.get_profile(s, token) is None: + raise HTTPException(404, "patient not found") + + @router.post("/dialog/{token}/events", response_model=DialogOut, tags=["dialog"]) async def dialog_event(token: str, body: DialogIn) -> DialogOut: + await _require_patient(token) return DialogOut(replies=await get_engine().handle(token, body)) @router.get("/dialog/{token}/outbox", response_model=DialogOut, tags=["dialog"]) async def dialog_outbox(token: str) -> DialogOut: """System-initiated replies (reminders, check-ins) waiting for the web console.""" - box = integrations.outbox - return DialogOut(replies=box.drain(token) if isinstance(box, MemoryOutbox) else []) + box = console.memory_outbox() + return DialogOut(replies=box.drain(token) if box is not None else []) @router.get("/dialog/{token}/history", response_model=list[MessageOut], tags=["dialog"]) async def dialog_history(token: str, limit: int = 100) -> list[MessageOut]: - return [] + """Stored dialog (texts are scrubbed before storage, ADR-003), oldest first, the last `limit` messages.""" + async with get_clinical_session() as s: + stmt = select(Message).where(Message.token == token).order_by(Message.ts.desc(), Message.id.desc()) + rows = (await s.scalars(stmt.limit(max(1, min(limit, 1000))))).all() + return [MessageOut(ts=m.ts, direction=m.direction, text=m.text_scrubbed) for m in reversed(rows)] # type: ignore[arg-type] # ------------------------------------------------------------------ demo controls (ADR-005) @@ -108,14 +251,36 @@ async def advance_clock(body: ClockAdvanceIn) -> ClockOut: @router.post("/demo/reset", tags=["demo"]) async def demo_reset() -> dict[str, str]: + """Clock back to the wall clock, console outbox emptied, demo data re-seeded by `scripts.seed_demo` (#18).""" clock.reset() - return {"status": "ok"} + seeded = await console.reset_demo() + return {"status": "ok", "seed": "reset" if seeded else "skipped"} @router.get("/demo/personas", response_model=list[DemoPersona], tags=["demo"]) async def demo_personas() -> list[DemoPersona]: - return [ - DemoPersona( - token="tok-andrey", display_name="Андрей С.", therapy_day=13, scenario="data/synthetic/andrey.jsonl" + """Patients with a scripted scenario `data/synthetic/.jsonl`; the demo cabinet's doctor first.""" + scenarios = console.scenario_files() + if not scenarios: + return [] + async with get_identity_session() as ids: + patients = (await ids.scalars(select(Patient))).all() + scripted = [(p, scenarios[slug]) for p in patients if (slug := console.persona_slug(p.display_name)) in scenarios] + if not scripted: + return [] + today = clock.today() + async with get_clinical_session() as s: + profiles = {p.token: await repo.get_profile(s, p.token) for p, _ in scripted} + out = [] + for p, path in sorted(scripted, key=lambda x: (x[0].doctor_id != fixtures.DOCTOR_ID, x[0].display_name)): + profile = profiles.get(p.token) + start = profile.therapy_start if profile else None + out.append( + DemoPersona( + token=p.token, + display_name=p.display_name, + therapy_day=(today - start).days if start else 0, + scenario=path, + ) ) - ] + return out diff --git a/tests/test_api_dialog.py b/tests/test_api_dialog.py new file mode 100644 index 0000000..328e2d4 --- /dev/null +++ b/tests/test_api_dialog.py @@ -0,0 +1,157 @@ +"""API #12: dialog endpoints (events, outbox, history) and demo controls (clock, reset, personas).""" + +from collections.abc import AsyncIterator +from datetime import datetime, timedelta +from pathlib import Path + +import httpx +import pytest + +from app.api import demo +from app.config import Settings +from app.core import clock +from app.core.types import ScrubbedText +from app.db import identity_repo, repo, session +from app.db.bootstrap import init_db +from app.dialog.contracts import Reply +from app.integrations.base import MemoryNotifier, MemoryOutbox, integrations +from app.main import app + +MEM = "sqlite+aiosqlite:///:memory:" +TOKEN = "a" * 32 +DAY0 = datetime(2026, 10, 5, 9, 0) + + +@pytest.fixture +async def client(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[httpx.AsyncClient]: + monkeypatch.setattr(clock, "get_settings", lambda: Settings(_env_file=None, demo_clock=True)) + monkeypatch.setattr(integrations, "notifier", MemoryNotifier()) + monkeypatch.setattr(integrations, "outbox", MemoryOutbox()) + clock.set_now(DAY0) + await session.configure(identity_url=MEM, clinical_url=MEM) + await init_db() + async with session.get_identity_session() as ids: + await identity_repo.create_patient( + ids, + token=TOKEN, + display_name="Андрей С.", + snils="11122233344", + phone="+79990000001", + clinic_id="c", + doctor_id="doc-smirnova", + now=clock.now(), + birth_year=1992, + ) + async with session.get_clinical_session() as s: + await repo.upsert_profile( + s, + token=TOKEN, + doctor_id="doc-smirnova", + modules=["veteran"], + therapy_start=clock.today() - timedelta(days=13), + ) + await repo.set_dialog_state( + s, token=TOKEN, state="idle", data={"consent": True, "adult": True}, now=clock.now() + ) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c: + yield c + clock.register_persistence(None, None) + clock.reset() + await session.configure() + + +async def test_events_reach_engine_and_unknown_patient_is_404(client: httpx.AsyncClient) -> None: + r = await client.post(f"/api/v1/dialog/{TOKEN}/events", json={"type": "text", "text": "Привет"}) + assert r.status_code == 200 and r.json()["replies"] + r = await client.post("/api/v1/dialog/" + "f" * 32 + "/events", json={"type": "text", "text": "x"}) + assert r.status_code == 404 + + +async def test_outbox_drains_once(client: httpx.AsyncClient) -> None: + await integrations.outbox.push(TOKEN, [Reply(text="Пора принять лекарство")]) + first = (await client.get(f"/api/v1/dialog/{TOKEN}/outbox")).json()["replies"] + assert [x["text"] for x in first] == ["Пора принять лекарство"] + assert (await client.get(f"/api/v1/dialog/{TOKEN}/outbox")).json()["replies"] == [] + + +async def test_outbox_found_behind_wrapping_outbox(client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None: + inner = MemoryOutbox() + + class Wrapper: # like TelegramOutbox: unbound chats fall back to the console queue + fallback = inner + + async def push(self, token: str, replies: list[Reply]) -> None: + await inner.push(token, replies) + + monkeypatch.setattr(integrations, "outbox", Wrapper()) + await inner.push(TOKEN, [Reply(text="x")]) + assert len((await client.get(f"/api/v1/dialog/{TOKEN}/outbox")).json()["replies"]) == 1 + + +async def test_history_oldest_first_with_limit(client: httpx.AsyncClient) -> None: + async with session.get_clinical_session() as s: + for i in range(5): + await repo.add_message( + s, + token=TOKEN, + direction="in" if i % 2 == 0 else "out", + text=ScrubbedText(f"m{i}"), + ts=clock.now() + timedelta(minutes=i), + ) + rows = (await client.get(f"/api/v1/dialog/{TOKEN}/history", params={"limit": 3})).json() + assert [m["text"] for m in rows] == ["m2", "m3", "m4"] + assert [m["direction"] for m in rows] == ["in", "out", "in"] + + +async def test_history_stores_scrubbed_text(client: httpx.AsyncClient) -> None: + await client.post(f"/api/v1/dialog/{TOKEN}/events", json={"type": "text", "text": "мой телефон +7 999 123-45-67"}) + texts = " ".join(m["text"] for m in (await client.get(f"/api/v1/dialog/{TOKEN}/history")).json()) + assert "123-45-67" not in texts and "999" not in texts + + +async def test_clock_advance_and_reset(client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(demo, "SEED_MODULE", "scripts.no_such_seed_module") + before = (await client.get("/api/v1/demo/clock")).json() + r = (await client.post("/api/v1/demo/clock/advance", json={"hours": 2})).json() + assert r["virtual"] is True and r["offset_days"] == pytest.approx(before["offset_days"] + 2 / 24, abs=1e-3) + await integrations.outbox.push(TOKEN, [Reply(text="stale")]) + assert (await client.post("/api/v1/demo/reset")).json() == {"status": "ok", "seed": "skipped"} + assert (await client.get("/api/v1/demo/clock")).json()["offset_days"] == pytest.approx(0, abs=1e-6) + assert (await client.get(f"/api/v1/dialog/{TOKEN}/outbox")).json()["replies"] == [] + + +async def test_reset_calls_seed_module(client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None: + import sys + import types + + calls: list[str] = [] + fake = types.ModuleType("fake_seed_demo") + + async def reset() -> None: + calls.append("reset") + + fake.reset = reset # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "fake_seed_demo", fake) + monkeypatch.setattr(demo, "SEED_MODULE", "fake_seed_demo") + monkeypatch.setattr(demo.util, "find_spec", lambda name: object()) + assert (await client.post("/api/v1/demo/reset")).json()["seed"] == "reset" + assert calls == ["reset"] + + +async def test_personas_from_scenario_files( + client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr(demo, "get_settings", lambda: Settings(_env_file=None, data_dir=tmp_path)) + assert (await client.get("/api/v1/demo/personas")).json() == [] + (tmp_path / "synthetic").mkdir() + (tmp_path / "synthetic" / "andrey.jsonl").write_text('{"day": 0, "actions": []}\n', encoding="utf-8") + personas = (await client.get("/api/v1/demo/personas")).json() + assert personas == [ + {"token": TOKEN, "display_name": "Андрей С.", "therapy_day": 13, "scenario": "data/synthetic/andrey.jsonl"} + ] + + +def test_persona_slug() -> None: + assert demo.persona_slug("Андрей С.") == "andrey" + assert demo.persona_slug("Юлия Щ.") == "yuliya" + assert demo.persona_slug("") == "" diff --git a/tests/test_api_doctor.py b/tests/test_api_doctor.py new file mode 100644 index 0000000..d86e445 --- /dev/null +++ b/tests/test_api_doctor.py @@ -0,0 +1,288 @@ +"""API #12: doctor dashboard on real DB data — list, card + audit, 403, alerts, ack → crisis ends, connect, no PII.""" + +from collections.abc import AsyncIterator +from datetime import datetime, timedelta +from types import SimpleNamespace + +import httpx +import pytest +from sqlalchemy import select + +from app.api import doctor_views +from app.config import Settings +from app.core import clock +from app.core.privacy import pseudo_id +from app.core.types import AlertLevel +from app.db import identity_repo, repo, session +from app.db.audit import AuditLog +from app.db.bootstrap import init_db +from app.db.clinical import Dose, PatientProfile, Prescription, SafetyPlan +from app.integrations.base import MemoryNotifier, MemoryOutbox, integrations +from app.main import app + +MEM = "sqlite+aiosqlite:///:memory:" +DOCTOR = "doc-smirnova" +OTHER = "doc-other" +DAY0 = datetime(2026, 10, 5, 9, 0) + +# Synthetic patients: token suffix → (name, snils, phone, doctor) +ANDREY, DMITRY, OLGA, PAVEL, IRINA, STRANGER = (c * 32 for c in "abcdef") +PEOPLE = { + ANDREY: ("Андрей С.", "11122233344", "+79990000001", DOCTOR), + DMITRY: ("Дмитрий В.", "22233344455", "+79990000002", DOCTOR), + OLGA: ("Ольга К.", "33344455566", "+79990000003", DOCTOR), + PAVEL: ("Павел Н.", "44455566677", "+79990000004", DOCTOR), + IRINA: ("Ирина М.", "55566677788", "+79990000005", DOCTOR), + STRANGER: ("Чужой П.", "66677788899", "+79990000006", OTHER), +} + + +@pytest.fixture +async def client(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[httpx.AsyncClient]: + monkeypatch.setattr(clock, "get_settings", lambda: Settings(_env_file=None, demo_clock=True)) + monkeypatch.setattr(integrations, "notifier", MemoryNotifier()) + monkeypatch.setattr(integrations, "outbox", MemoryOutbox()) + clock.set_now(DAY0 + timedelta(days=13)) + await session.configure(identity_url=MEM, clinical_url=MEM) + await init_db() + await seed() + # No lifespan: the scheduler and the Telegram bot are not started. + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c: + yield c + clock.register_persistence(None, None) + clock.reset() + await session.configure() + + +async def seed() -> None: + now = clock.now() + start = (DAY0).date() + async with session.get_identity_session() as ids: + for token, (name, snils, phone, doctor) in PEOPLE.items(): + await identity_repo.create_patient( + ids, + token=token, + display_name=name, + snils=snils, + phone=phone, + clinic_id="clinic-1", + doctor_id=doctor, + now=now, + birth_year=1990, + sex="m", + ) + async with session.get_clinical_session() as s: + for token, (_, _, _, doctor) in PEOPLE.items(): + await repo.upsert_profile( + s, token=token, doctor_id=doctor, modules=["veteran"], therapy_start=start, birth_year=1990, sex="m" + ) + s.add(Prescription(token=token, drug="сертралин", dose="50 мг", schedule={"times": ["08:00"]})) + await s.flush() + rx = {r.token: r.id for r in (await s.scalars(select(Prescription))).all()} + + # Andrey: acute alert (red), worsening PHQ-9, unscheduled PHQ-9, low mood streak, alcohol, a safety plan. + await repo.add_alert(s, token=ANDREY, level=AlertLevel.ACUTE, code="phq9_item9", reason="PHQ-9 п.9", ts=now) + await repo.add_scale_result( + s, token=ANDREY, scale="phq9", total=12, severity="moderate", complete=True, ts=now - timedelta(days=7) + ) + await repo.add_scale_result( + s, + token=ANDREY, + scale="phq9", + total=18, + severity="mod_severe", + complete=True, + ts=now - timedelta(hours=2), + scheduled=False, + item9=2, + ) + await repo.add_scale_result( + s, token=ANDREY, scale="pcl5", total=44, severity="-", complete=True, ts=now - timedelta(days=13) + ) + for i, (mood, sleep, alcohol) in enumerate([(3, "<4", "more"), (2, "4-6", "1-2"), (2, "<4", "none")]): + await repo.upsert_checkin( + s, + token=ANDREY, + day=now.date() - timedelta(days=2 - i), + now=now, + mood=mood, + sleep_bucket=sleep, + alcohol_bucket=alcohol, + med_taken=True, + ) + s.add( + SafetyPlan( + token=ANDREY, steps=["Позвонить брату", {"who": "Кризисная линия", "phone": "8-800"}], updated_at=now + ) + ) + # Dmitry: high alert (yellow). Olga: info only (grey). + await repo.add_alert(s, token=DMITRY, level=AlertLevel.HIGH, code="pcl5_high", reason="PCL-5 ≥ 50", ts=now) + await repo.add_alert(s, token=OLGA, level=AlertLevel.INFO, code="no_contact_3d", reason="Молчит 3 дня", ts=now) + # Pavel and Irina: green; Pavel missed doses + voiced quit intent → higher risk, sorted first among greens. + for d in range(7): + planned = now - timedelta(days=d, hours=1) + s.add( + Dose(token=PAVEL, prescription_id=rx[PAVEL], planned_at=planned, status="missed" if d < 3 else "taken") + ) + s.add(Dose(token=IRINA, prescription_id=rx[IRINA], planned_at=planned, status="taken")) + await repo.upsert_checkin(s, token=PAVEL, day=now.date(), now=now, quit_intent=True, mood=5) + # Foreign doctor's patient with an open alert: must never show up for DOCTOR. + await repo.add_alert(s, token=STRANGER, level=AlertLevel.ACUTE, code="cssrs_high", reason="C-SSRS", ts=now) + await repo.set_dialog_state( + s, + token=ANDREY, + state="crisis", + data={"consent": True, "adult": True, "crisis": {"open": True, "opened_at": now.isoformat()}}, + now=now, + ) + + +async def audit_rows(action: str) -> list[AuditLog]: + async with session.get_clinical_session() as s: + return list((await s.scalars(select(AuditLog).where(AuditLog.action == action))).all()) + + +async def test_list_order_colours_and_metrics(client: httpx.AsyncClient) -> None: + rows = (await client.get(f"/api/v1/doctors/{DOCTOR}/patients")).json() + assert [r["token"] for r in rows] == [ANDREY, DMITRY, OLGA, PAVEL, IRINA] + assert [r["status_color"] for r in rows] == ["red", "yellow", "grey", "green", "green"] + andrey, pavel, irina = rows[0], rows[3], rows[4] + assert andrey["display_name"] == "Андрей С." and andrey["age"] == 36 + assert andrey["top_alert_level"] == "acute" and andrey["open_alerts"] == 1 + assert (andrey["phq9_last"], andrey["pcl5_last"], andrey["trend"]) == (18, 44, "up") + assert andrey["drug"] == "сертралин 50 мг" and andrey["therapy_day"] == 13 + assert andrey["last_event"] == "PHQ-9 п.9" # the alert is newer than the last scale + assert rows[1]["last_event"] == "PCL-5 ≥ 50" + factors = " ".join(andrey["discontinuation_risk"]["factors"]) + assert "настроение ≤ 3 3 дн. подряд" in factors and "алкоголь 2 дн. из 7" in factors + assert pavel["adherence_7d"] == pytest.approx(4 / 7, abs=0.01) + assert irina["adherence_7d"] == 1.0 and irina["discontinuation_risk"]["score"] == 0 + assert pavel["discontinuation_risk"]["score"] == 30 + 25 # 3 missed days + quit intent + assert pavel["discontinuation_risk"]["level"] == "medium" + assert len(await audit_rows("view_patient_list")) == 1 + + +async def test_card_writes_audit_and_has_series(client: httpx.AsyncClient) -> None: + r = await client.get(f"/api/v1/patients/{ANDREY}", params={"doctor_id": DOCTOR}) + assert r.status_code == 200 + card = r.json() + metrics = {(p["metric"], p["value"], p["scheduled"]) for p in card["series"]} + assert ("phq9", 18.0, False) in metrics and ("phq9", 12.0, True) in metrics + assert ("sleep_hours", 3.0, True) in metrics and ("sleep_hours", 5.0, True) in metrics + assert ("alcohol", 2.0, True) in metrics and ("mood", 2.0, True) in metrics + assert card["safety_plan"] == ["Позвонить брату", "Кризисная линия: 8-800"] + assert card["alerts"][0]["patient_name"] == "Андрей С." and card["alerts"][0]["color"] == "red" + assert card["prescriptions"][0]["schedule"] == {"times": ["08:00"]} + assert card["summary"] + rows = await audit_rows("view_patient") + assert len(rows) == 1 and rows[0].actor == DOCTOR and rows[0].token == ANDREY + + +async def test_card_forbidden_for_foreign_doctor(client: httpx.AsyncClient) -> None: + r = await client.get(f"/api/v1/patients/{ANDREY}", params={"doctor_id": OTHER}) + assert r.status_code == 403 + assert await audit_rows("view_patient") == [] + assert len(await audit_rows("view_patient_denied")) == 1 + assert (await client.get("/api/v1/patients/" + "0" * 32, params={"doctor_id": DOCTOR})).status_code == 404 + + +async def test_alerts_feed_since_id_and_open_only(client: httpx.AsyncClient) -> None: + alerts = (await client.get(f"/api/v1/doctors/{DOCTOR}/alerts")).json() + assert {a["token"] for a in alerts} == {ANDREY, DMITRY, OLGA} # no foreign patient + assert all(a["patient_name"] for a in alerts) + top = max(a["id"] for a in alerts) + assert (await client.get(f"/api/v1/doctors/{DOCTOR}/alerts", params={"since_id": top})).json() == [] + assert len(await audit_rows("view_alerts")) == 1 # empty poll is not a disclosure + + +async def test_ack_acute_ends_crisis(client: httpx.AsyncClient) -> None: + alerts = (await client.get(f"/api/v1/doctors/{DOCTOR}/alerts", params={"open_only": True})).json() + acute = next(a for a in alerts if a["token"] == ANDREY) + assert (await client.post(f"/api/v1/alerts/{acute['id']}/ack", json={"doctor_id": OTHER})).status_code == 403 + r = await client.post(f"/api/v1/alerts/{acute['id']}/ack", json={"doctor_id": DOCTOR}) + assert r.status_code == 200 + out = r.json() + assert out["acked_by"] == DOCTOR and out["acked_at"] and out["patient_name"] == "Андрей С." + async with session.get_clinical_session() as s: + state = await repo.get_dialog_state(s, ANDREY) + profile = await s.get(PatientProfile, ANDREY) + assert state is not None and state.state == "idle" and state.data["crisis"]["open"] is False + assert profile is not None and profile.status_color == "green" + assert isinstance(integrations.outbox, MemoryOutbox) + assert integrations.outbox.drain(ANDREY) # "crisis closed" message waits for the console + assert len(await audit_rows("ack_alert")) == 1 + rows = (await client.get(f"/api/v1/doctors/{DOCTOR}/patients")).json() + assert next(x for x in rows if x["token"] == ANDREY)["status_color"] == "green" + + +async def test_connect_creates_patient_and_redeemable_code(client: httpx.AsyncClient) -> None: + body = { + "doctor_id": DOCTOR, + "display_name": "Сергей Т.", + "birth_year": 1988, + "sex": "m", + "snils": "123-456-789 01", + "drug": "сертралин", + "dose": "50 мг", + "times": ["08:00", "20:00"], + "instructions": "после еды", + "modules": ["veteran", "depression"], + } + r = await client.post("/api/v1/patients/connect", json=body) + assert r.status_code == 200 + out = r.json() + assert out["token"] == pseudo_id("12345678901") + assert out["deep_link"] == f"/console?code={out['code']}" + assert "12345678901" not in r.text and "123-456-789" not in r.text + async with session.get_identity_session() as ids: + assert await identity_repo.redeem_connect_code(ids, code=out["code"], now=clock.now()) == out["token"] + async with session.get_clinical_session() as s: + profile = await repo.get_profile(s, out["token"]) + rx = await s.scalar(select(Prescription).where(Prescription.token == out["token"])) + assert profile is not None and profile.therapy_start == clock.today() and profile.doctor_id == DOCTOR + assert rx is not None and rx.schedule["times"] == ["08:00", "20:00"] + assert len(await audit_rows("connect_patient")) == 1 + card = (await client.get(f"/api/v1/patients/{out['token']}", params={"doctor_id": DOCTOR})).json() + assert card["prescriptions"][0]["instructions"] == "после еды" + assert card["patient"]["therapy_day"] == 0 and card["patient"]["status_color"] == "green" + assert (await client.post("/api/v1/patients/connect", json=body)).status_code == 409 + + +async def test_no_pii_in_doctor_responses(client: httpx.AsyncClient) -> None: + texts = [ + (await client.get(f"/api/v1/doctors/{DOCTOR}/patients")).text, + (await client.get(f"/api/v1/patients/{ANDREY}", params={"doctor_id": DOCTOR})).text, + (await client.get(f"/api/v1/doctors/{DOCTOR}/alerts")).text, + ] + for _, snils, phone, _ in PEOPLE.values(): + for text in texts: + assert snils not in text and phone not in text and phone[1:] not in text + + +def test_risk_index_all_factors_capped() -> None: + now = datetime(2026, 10, 18, 12, 0).astimezone() + days = [now.date() - timedelta(days=i) for i in range(4)] + checkins = [ + SimpleNamespace( + day=d, mood=2, med_taken=False, side_effects=["nausea"], quit_intent=True, alcohol_bucket="more" + ) + for d in days + ] + data = doctor_views.PatientData( + profile=SimpleNamespace(therapy_start=days[-1] - timedelta(days=10)), # type: ignore[arg-type] + checkins=checkins, # type: ignore[arg-type] + results=[], + doses=[], + prescriptions=[], + alerts=[SimpleNamespace(code="quit_intent", acked_at=None)], # type: ignore[list-item] + ) + risk = doctor_views.discontinuation_risk(data, now) + assert risk.score == 100 and risk.level == "high" + assert risk.factors == [ + "пропуски приёма 4/7 дн.", + "побочные эффекты 4 дн. подряд", + "хотел бросить (день 13)", + "не разобран сигнал «хочет бросить»", + "настроение ≤ 3 4 дн. подряд", + "алкоголь 4 дн. из 7", + ] diff --git a/tests/test_contracts.py b/tests/test_contracts.py index cae58c3..8dd8220 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -21,11 +21,10 @@ async def test_stubs_are_usable() -> None: await session.configure() -def test_api_fixture_routes() -> None: +def test_api_fixtures_and_clock() -> None: + # Routes read the DB since #12 (covered by tests/test_api_*.py); fixtures stay as schema-valid reference data. + from app.api import fixtures + + assert fixtures.card("tok-andrey") is not None and fixtures.PATIENTS[0].status_color == "red" with TestClient(app) as c: - patients = c.get("/api/v1/doctors/doc-smirnova/patients").json() - assert patients[0]["status_color"] == "red" - card = c.get("/api/v1/patients/tok-andrey", params={"doctor_id": "doc-smirnova"}).json() - assert card["safety_plan"] and card["series"] - assert c.post("/api/v1/dialog/tok-andrey/events", json={"type": "text", "text": "ok"}).status_code == 200 assert c.get("/api/v1/demo/clock").json()["virtual"] is True From 9362eb6ecca6fc12c7f34250aa57e1c0486149ea Mon Sep 17 00:00:00 2001 From: Lacky1234union Date: Sun, 4 Oct 2026 12:36:07 +0300 Subject: [PATCH 04/12] =?UTF-8?q?feat(integrations):=20demo=20adapters=20f?= =?UTF-8?q?or=20=D0=9C=D0=98=D0=A1,=20connect-code=20identity,=20=D0=93?= =?UTF-8?q?=D0=A0=D0=9B=D0=A1=20excerpts,=20doctor=20notifier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - setup() at package import (idempotent, replaces only base.py defaults): MisMock, ConnectCodeIdentity, GrlsExcerpts, CompositeNotifier; outbox stays MemoryOutbox - MisMock: clinical DB prescriptions, fallback data/meds/mis_prescriptions.json (synthetic, by token) - ConnectCodeIdentity: one-time connect code via identity_repo, 15 min grace for the double check in one connect flow (Telegram /start + engine `new` state) - GrlsExcerpts: synthetic paraphrased instruction excerpts for sertraline, paroxetine, escitalopram, fluoxetine, mirtazapine, prazosin - CompositeNotifier: in-memory feed (recent(since_id)) + Telegram doctor chat, PII-free, never raises - test_llm_med_qa: default-registry test now patches NoDrugInfo explicitly Refs #14 Co-Authored-By: Claude Opus 5.5 --- app/integrations/__init__.py | 51 +++++ app/integrations/connect_code.py | 58 ++++++ app/integrations/grls.py | 80 ++++++++ app/integrations/mis_mock.py | 97 ++++++++++ app/integrations/notify.py | 141 ++++++++++++++ data/meds/grls/escitalopram.yaml | 57 ++++++ data/meds/grls/fluoxetine.yaml | 52 +++++ data/meds/grls/mirtazapine.yaml | 50 +++++ data/meds/grls/paroxetine.yaml | 56 ++++++ data/meds/grls/prazosin.yaml | 49 +++++ data/meds/grls/sertraline.yaml | 57 ++++++ data/meds/mis_prescriptions.json | 165 ++++++++++++++++ tests/test_integrations.py | 323 +++++++++++++++++++++++++++++++ tests/test_llm_med_qa.py | 7 +- 14 files changed, 1240 insertions(+), 3 deletions(-) create mode 100644 app/integrations/connect_code.py create mode 100644 app/integrations/grls.py create mode 100644 app/integrations/mis_mock.py create mode 100644 app/integrations/notify.py create mode 100644 data/meds/grls/escitalopram.yaml create mode 100644 data/meds/grls/fluoxetine.yaml create mode 100644 data/meds/grls/mirtazapine.yaml create mode 100644 data/meds/grls/paroxetine.yaml create mode 100644 data/meds/grls/prazosin.yaml create mode 100644 data/meds/grls/sertraline.yaml create mode 100644 data/meds/mis_prescriptions.json create mode 100644 tests/test_integrations.py diff --git a/app/integrations/__init__.py b/app/integrations/__init__.py index e69de29..d3156ba 100644 --- a/app/integrations/__init__.py +++ b/app/integrations/__init__.py @@ -0,0 +1,51 @@ +"""Demo adapters behind the frozen integration Protocols (SPEC §9, #14). + +`setup()` runs when the package is first imported. Every consumer imports `app.integrations.base`, which +initialises this package first, and `app/main.py` is a frozen contract — so the registry is wired before +any request or job without a startup hook. Setup only constructs objects: data files and DB sessions are +opened lazily on the first call, so importing the package never fails on I/O. + +Registry after setup: +- `prescriptions` — `MisMock`: clinical DB `prescriptions`, then `data/meds/mis_prescriptions.json`; +- `identity` — `ConnectCodeIdentity`: one-time connect code from the doctor (ЕСИА stand-in); +- `drug_info` — `GrlsExcerpts`: `data/meds/grls/*.yaml`; +- `notifier` — `CompositeNotifier`: in-memory dashboard feed + optional Telegram doctor chat; +- `outbox` — unchanged `MemoryOutbox` (the web console drains it; the Telegram channel wraps it at start). +""" + +from app.integrations.base import ( + EmptyPrescriptions, + MemoryNotifier, + NoDrugInfo, + RejectIdentity, + integrations, +) +from app.integrations.connect_code import ConnectCodeIdentity +from app.integrations.grls import GrlsExcerpts +from app.integrations.mis_mock import MisMock +from app.integrations.notify import CompositeNotifier, NotificationFeed + +__all__ = ["CompositeNotifier", "ConnectCodeIdentity", "GrlsExcerpts", "MisMock", "NotificationFeed", "setup"] + +_done = False + + +def setup(*, force: bool = False) -> None: + """Register the demo adapters. Idempotent; never replaces an adapter someone already swapped in + (a test fake, the Telegram outbox), only the in-memory defaults from `base.py` — unless `force`.""" + global _done + if _done and not force: + return + if force or isinstance(integrations.prescriptions, EmptyPrescriptions): + integrations.prescriptions = MisMock() + if force or isinstance(integrations.identity, RejectIdentity): + integrations.identity = ConnectCodeIdentity() + if force or isinstance(integrations.drug_info, NoDrugInfo): + integrations.drug_info = GrlsExcerpts() + # Exact type check: MemoryNotifier subclasses used as test sinks must stay in place. + if force or type(integrations.notifier) is MemoryNotifier: + integrations.notifier = CompositeNotifier() + _done = True + + +setup() diff --git a/app/integrations/connect_code.py b/app/integrations/connect_code.py new file mode 100644 index 0000000..41fa9f7 --- /dev/null +++ b/app/integrations/connect_code.py @@ -0,0 +1,58 @@ +"""ЕСИА stand-in: the patient proves identity with the one-time connect code issued by the doctor. + +The doctor creates the code in «Подключить пациента» (identity DB `connect_codes`); the patient opens the +QR deep link / enters the code. In prod this is ЕСИА OAuth; the `IdentityProvider` Protocol stays the same. +""" + +import logging +from datetime import UTC, timedelta + +from sqlalchemy.exc import SQLAlchemyError + +from app.core import clock +from app.integrations.base import VerifiedPerson + +log = logging.getLogger(__name__) + +# One connect flow verifies the same code twice: the Telegram channel on `/start ` (to bind the chat) +# and then the dialog engine's `new` state (to check the code belongs to this token). A short grace window +# lets the second check pass; after it a used code is rejected like an unknown one. +REDEEM_GRACE = timedelta(minutes=15) + + +class ConnectCodeIdentity: + """`IdentityProvider` over identity-DB connect codes.""" + + def __init__(self, grace: timedelta = REDEEM_GRACE) -> None: + self.grace = grace + + async def verify(self, auth_code: str) -> VerifiedPerson | None: + code = auth_code.strip() + if not code: + return None + # Imported lazily: the integrations package is imported by clinical-side modules, which must not + # pull the identity schema into their import graph (guardrail #5). + from app.db import identity_repo + from app.db.identity import ConnectCode + from app.db.session import get_identity_session + + now = clock.now() + try: + async with get_identity_session() as s: + row = await s.get(ConnectCode, code) + if row is None: + log.info("connect code rejected: unknown") + return None + person = VerifiedPerson(token=row.token, doctor_id=row.doctor_id) + if row.used_at is not None: + used_at = row.used_at if row.used_at.tzinfo else row.used_at.replace(tzinfo=UTC) + if now - used_at <= self.grace: + return person + log.info("connect code rejected: already used") + return None + if await identity_repo.redeem_connect_code(s, code=code, now=now) is None: + return None + return person + except (OSError, SQLAlchemyError) as exc: + log.warning("connect code check failed error=%s", type(exc).__name__) + return None diff --git a/app/integrations/grls.py b/app/integrations/grls.py new file mode 100644 index 0000000..8d8e8b6 --- /dev/null +++ b/app/integrations/grls.py @@ -0,0 +1,80 @@ +"""ГРЛС stand-in: short excerpts of the official drug instruction, per section (SPEC §9, SPEC §7 п.5). + +Each `data/meds/grls/.yaml` holds one drug: + + inn: сертралин + aliases: [sertraline] + sections: + side_effects: {text: "...", source: "ГРЛС, инструкция по медицинскому применению, раздел «...»"} + +Section codes are the ones `app/llm/med_qa.py` understands (side_effects, onset, alcohol, discontinuation, +administration, interactions, overdose). The bot answers drug questions ONLY from these texts, so they are +the single place to verify against the current ГРЛС instruction (see the header of each file). +""" + +import logging +from pathlib import Path + +import yaml +from pydantic import BaseModel, Field, ValidationError + +from app.config import get_settings +from app.integrations.base import DrugSection + +log = logging.getLogger(__name__) + + +class _SectionFile(BaseModel): + text: str + source: str + + +class _DrugFile(BaseModel): + inn: str + aliases: list[str] = Field(default_factory=list) + sections: dict[str, _SectionFile] + + +def default_grls_dir() -> Path: + return get_settings().data_dir / "meds" / "grls" + + +def normalize_inn(name: str) -> str: + """Lookup key: «Сертралин 50 мг», «сертралин», «Sertraline» -> first word, lower case, ё -> е.""" + words = name.strip().lower().replace("ё", "е").split() + return words[0] if words else "" + + +class GrlsExcerpts: + """`DrugInfo` over local YAML excerpts. Files are read lazily on the first lookup and cached.""" + + def __init__(self, directory: Path | None = None) -> None: + self._dir = directory + self._index: dict[str, list[DrugSection]] | None = None + + def _load(self) -> dict[str, list[DrugSection]]: + if self._index is not None: + return self._index + directory = self._dir or default_grls_dir() + index: dict[str, list[DrugSection]] = {} + for path in sorted(directory.glob("*.yaml")): + try: + drug = _DrugFile.model_validate(yaml.safe_load(path.read_text(encoding="utf-8"))) + except (OSError, yaml.YAMLError, ValidationError) as exc: + log.warning("grls file skipped file=%s error=%s", path.name, type(exc).__name__) + continue + sections = [ + DrugSection(inn=drug.inn, section=code, text=" ".join(s.text.split()), source=s.source) + for code, s in drug.sections.items() + ] + for key in (drug.inn, *drug.aliases): + index[normalize_inn(key)] = sections + self._index = index + return index + + def known(self) -> list[str]: + """INNs with excerpts (for the dashboard / tests).""" + return sorted({s[0].inn for s in self._load().values() if s}) + + async def sections(self, inn: str) -> list[DrugSection]: + return [s.model_copy() for s in self._load().get(normalize_inn(inn), [])] diff --git a/app/integrations/mis_mock.py b/app/integrations/mis_mock.py new file mode 100644 index 0000000..8bde271 --- /dev/null +++ b/app/integrations/mis_mock.py @@ -0,0 +1,97 @@ +"""МИС / ЕГИСЗ stand-in: the patient's prescriptions (SPEC §9). + +Source order: the clinical DB table `prescriptions` (written when the doctor connects a patient or by the +demo seed), then the synthetic catalogue `data/meds/mis_prescriptions.json` keyed by token. In prod this is +a regional МИС query; the Protocol stays the same. +""" + +import json +import logging +from datetime import date +from pathlib import Path +from typing import Any + +from pydantic import BaseModel, Field, ValidationError +from sqlalchemy import select +from sqlalchemy.exc import SQLAlchemyError + +from app.config import get_settings +from app.integrations.base import Prescription + +log = logging.getLogger(__name__) + + +class _CatalogPatient(BaseModel): + persona: str + tokens: list[str] = Field(default_factory=list) + prescriptions: list[Prescription] + + +class _Catalog(BaseModel): + patients: list[_CatalogPatient] + + +def _from_row(drug: str, dose: str, schedule: dict[str, Any], start: date | None, end: date | None) -> Prescription: + # The table has no instructions column; the doctor's note («утром после еды») may ride in the schedule JSON. + times = {k: [str(x) for x in v] for k, v in schedule.items() if isinstance(v, list)} + return Prescription( + drug=drug, + dose=dose, + schedule=times or {"times": ["08:00"]}, + instructions=str(schedule.get("instructions") or ""), + start_date=start, + end_date=end, + ) + + +def default_catalog_path() -> Path: + return get_settings().data_dir / "meds" / "mis_prescriptions.json" + + +class MisMock: + """`PrescriptionSource`: clinical DB first, synthetic JSON catalogue as the fallback.""" + + def __init__(self, catalog_path: Path | None = None, *, use_db: bool = True) -> None: + self._path = catalog_path + self._use_db = use_db + self._by_token: dict[str, list[Prescription]] | None = None + + def _catalog(self) -> dict[str, list[Prescription]]: + # Lazy: the package is imported before startup, a missing/broken file must not break the import. + if self._by_token is None: + path = self._path or default_catalog_path() + index: dict[str, list[Prescription]] = {} + try: + catalog = _Catalog.model_validate(json.loads(path.read_text(encoding="utf-8"))) + except (OSError, ValueError, ValidationError) as exc: + log.warning("mis_mock catalogue not loaded error=%s", type(exc).__name__) + catalog = _Catalog(patients=[]) + for patient in catalog.patients: + for token in patient.tokens: + index[token] = patient.prescriptions + self._by_token = index + return self._by_token + + async def _from_db(self, token: str) -> list[Prescription]: + from app.db.clinical import Prescription as PrescriptionRow + from app.db.session import get_clinical_session + + async with get_clinical_session() as s: + rows = ( + await s.scalars( + select(PrescriptionRow).where(PrescriptionRow.token == token).order_by(PrescriptionRow.id) + ) + ).all() + return [_from_row(r.drug, r.dose, r.schedule or {}, r.start_date, r.end_date) for r in rows] + + async def get_prescriptions(self, token: str) -> list[Prescription]: + if self._use_db: + try: + found = await self._from_db(token) + except (OSError, SQLAlchemyError) as exc: + # DB down or not migrated: the demo still shows the catalogue prescription. + log.warning("mis_mock db lookup failed error=%s", type(exc).__name__) + found = [] + if found: + return found + return [p.model_copy(deep=True) for p in self._catalog().get(token, [])] diff --git a/app/integrations/notify.py b/app/integrations/notify.py new file mode 100644 index 0000000..29c548f --- /dev/null +++ b/app/integrations/notify.py @@ -0,0 +1,141 @@ +"""Doctor notifications (SPEC §9): in-memory feed for the dashboard + optional Telegram doctor chat. + +The alert itself is already committed to the clinical DB before `send()` is called (the dashboard reads +alerts from there via the API), so every channel here is best effort: failures are logged, never raised. +Messages carry only the level, the rule reason/code and a short pseudonymous patient id — no PII +(guardrail #8); the reason texts come from `config/rules.yaml`, not from patient messages. +""" + +import asyncio +import itertools +import logging +from collections import deque +from collections.abc import Awaitable, Callable +from datetime import datetime +from typing import Any + +from pydantic import BaseModel + +from app.config import get_settings +from app.core import clock +from app.integrations.base import AlertLevelName + +log = logging.getLogger(__name__) + +FEED_SIZE = 500 +SHORT_ID_LEN = 6 +MAX_REASON_CHARS = 200 +TELEGRAM_TIMEOUT_S = 5.0 +# Doctor-facing labels (not patient texts); acute alerts are visually distinct in the chat. +LEVEL_LABELS: dict[str, str] = { + "acute": "КРИТИЧНО", + "high": "Высокий", + "moderate": "Умеренный", + "info": "Инфо", +} + +ChatSender = Callable[[int, str], Awaitable[Any]] + + +def short_id(token: str | None) -> str: + return token[:SHORT_ID_LEN] if token else "—" + + +def format_message(level: str, text: str, token: str | None) -> str: + reason = " ".join(text.split()) + if len(reason) > MAX_REASON_CHARS: + reason = reason[: MAX_REASON_CHARS - 1] + "…" + return f"[{LEVEL_LABELS.get(level, level)}] {reason}\nПациент #{short_id(token)}" + + +class Notification(BaseModel): + id: int + ts: datetime + doctor_id: str + level: str + text: str + token: str | None = None + + +class NotificationFeed: + """Bounded in-memory feed; ids increase monotonically so a poller asks only for `recent(since_id)`.""" + + def __init__(self, size: int = FEED_SIZE) -> None: + self._items: deque[Notification] = deque(maxlen=size) + self._ids = itertools.count(1) + + def add(self, doctor_id: str, level: str, text: str, token: str | None, ts: datetime) -> Notification: + item = Notification(id=next(self._ids), ts=ts, doctor_id=doctor_id, level=level, text=text, token=token) + self._items.append(item) + return item + + def recent(self, since_id: int = 0, doctor_id: str | None = None) -> list[Notification]: + return [n for n in self._items if n.id > since_id and (doctor_id is None or n.doctor_id == doctor_id)] + + +class TelegramDoctorChat: + """Sends to the doctor chat `DOCTOR_NOTIFY_CHAT_ID` through the bot `TG_BOT_TOKEN`. + + The aiogram `Bot` is created on the first message only; with either setting empty the channel is off. + """ + + def __init__(self, chat_id: int | None = None, bot_token: str | None = None, sender: ChatSender | None = None): + settings = get_settings() + self.chat_id = chat_id if chat_id is not None else settings.doctor_notify_chat_id + self._bot_token = bot_token if bot_token is not None else settings.tg_bot_token + self._sender = sender + self._bot: Any = None + + @property + def enabled(self) -> bool: + return self.chat_id is not None and (self._sender is not None or bool(self._bot_token)) + + def _get_sender(self) -> ChatSender: + if self._sender is None: + from aiogram import Bot + + self._bot = Bot(self._bot_token) + self._sender = self._bot.send_message + return self._sender + + async def send(self, level: str, text: str, token: str | None) -> None: + if not self.enabled or self.chat_id is None: + return + try: + await asyncio.wait_for( + self._get_sender()(self.chat_id, format_message(level, text, token)), timeout=TELEGRAM_TIMEOUT_S + ) + except Exception as exc: # noqa: BLE001 — any channel error; the alert is already in the DB + err = type(exc).__name__ + log.warning("doctor chat notify failed level=%s tok=%s error=%s", level, short_id(token), err) + + async def aclose(self) -> None: + if self._bot is not None: + await self._bot.session.close() + self._bot = None + self._sender = None + + +class CompositeNotifier: + """`Notifier`: dashboard feed + Telegram doctor chat (when configured).""" + + def __init__(self, feed: NotificationFeed | None = None, chat: TelegramDoctorChat | None = None) -> None: + self.feed = feed or NotificationFeed() + self._chat = chat + + @property + def chat(self) -> TelegramDoctorChat: + # Lazy: settings are read on the first notification, not when the package is imported. + if self._chat is None: + self._chat = TelegramDoctorChat() + return self._chat + + def recent(self, since_id: int = 0, doctor_id: str | None = None) -> list[Notification]: + return self.feed.recent(since_id, doctor_id) + + async def send(self, doctor_id: str, level: AlertLevelName, text: str, token: str | None = None) -> None: + try: + self.feed.add(doctor_id, level, text, token, clock.now()) + except Exception as exc: # noqa: BLE001 — never break the caller + log.warning("notify feed failed error=%s", type(exc).__name__) + await self.chat.send(level, text, token) diff --git a/data/meds/grls/escitalopram.yaml b/data/meds/grls/escitalopram.yaml new file mode 100644 index 0000000..3815713 --- /dev/null +++ b/data/meds/grls/escitalopram.yaml @@ -0,0 +1,57 @@ +# Выдержки для прототипа, сверить с действующей инструкцией ГРЛС (grls.rosminzdrav.ru). +# Синтетический пересказ: тексты сокращены и упрощены для пациента, не являются официальной инструкцией. +# Правила: 1–4 предложения на раздел; схемы дозирования — только в administration (и без миллиграммов); +# без диагнозов и советов изменить лечение. Бот отвечает о препарате только из этих текстов (app/llm/med_qa.py). +inn: эсциталопрам +aliases: [escitalopram, ципралекс, cipralex, селектра] +sections: + side_effects: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Побочное действие»" + text: >- + Наиболее частая нежелательная реакция — тошнота; также возможны головная боль, бессонница или сонливость, + повышенная потливость, диарея, усталость. + Побочные эффекты чаще возникают в первые 1–2 недели лечения и обычно ослабевают или проходят при продолжении + приёма. + Возможны нарушения половой функции: снижение влечения, нарушение эякуляции. + О стойких или выраженных побочных эффектах нужно сообщить лечащему врачу. + onset: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Способ применения и дозы»" + text: >- + Терапевтический эффект развивается постепенно. + Улучшение обычно отмечается через 2–4 недели регулярного приёма. + Оценивает эффект и решает, нужно ли менять лечение, лечащий врач. + alcohol: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»" + text: >- + Во время лечения употребление алкоголя не рекомендуется. + Фармакодинамического взаимодействия с алкоголем не ожидается, однако, как и с другими препаратами этой группы, + их одновременный приём не рекомендован. + Алкоголь сам по себе ухудшает сон и настроение. + discontinuation: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»" + text: >- + Резкое прекращение приёма может вызвать синдром отмены: головокружение, нарушения сна, тревогу, + раздражительность, тошноту, головную боль. + Препарат не следует отменять самостоятельно: отмену проводят постепенно, в течение 1–2 недель или дольше, + по решению и под контролем врача. + Изменять схему лечения может только лечащий врач. + administration: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Способ применения и дозы»" + text: >- + Препарат принимают один раз в сутки независимо от приёма пищи. + Таблетки принимают ежедневно примерно в одно и то же время. + Дозу и схему приёма определяет лечащий врач. + interactions: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Взаимодействие с другими лекарственными средствами»" + text: >- + Одновременный приём с ингибиторами МАО и пимозидом противопоказан. + Не рекомендуется совместное применение с препаратами, удлиняющими интервал QT. + Совместное применение с другими серотонинергическими средствами (трамадол, триптаны, препараты зверобоя) + повышает риск серотонинового синдрома. + О любых других лекарствах нужно сообщить врачу. + overdose: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Передозировка»" + text: >- + Симптомы передозировки: головокружение, дрожь, возбуждение, сонливость, тошнота, рвота, учащённое + сердцебиение, нарушения сердечного ритма. + При приёме большего количества, чем назначено, нужно немедленно обратиться за медицинской помощью (103 или 112). diff --git a/data/meds/grls/fluoxetine.yaml b/data/meds/grls/fluoxetine.yaml new file mode 100644 index 0000000..10c7b65 --- /dev/null +++ b/data/meds/grls/fluoxetine.yaml @@ -0,0 +1,52 @@ +# Выдержки для прототипа, сверить с действующей инструкцией ГРЛС (grls.rosminzdrav.ru). +# Синтетический пересказ: тексты сокращены и упрощены для пациента, не являются официальной инструкцией. +# Правила: 1–4 предложения на раздел; схемы дозирования — только в administration (и без миллиграммов); +# без диагнозов и советов изменить лечение. Бот отвечает о препарате только из этих текстов (app/llm/med_qa.py). +inn: флуоксетин +aliases: [fluoxetine, прозак, prozac] +sections: + side_effects: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Побочное действие»" + text: >- + Наиболее частые нежелательные реакции: тошнота, бессонница, головная боль, нервозность и беспокойство, + снижение аппетита, повышенная потливость, диарея. + Многие из них, в том числе тошнота, выражены в начале лечения и обычно уменьшаются в течение первых недель. + Возможны нарушения половой функции: снижение влечения, нарушение эякуляции. + О стойких или выраженных побочных эффектах нужно сообщить лечащему врачу. + onset: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Способ применения и дозы»" + text: >- + Терапевтический эффект развивается постепенно. + Улучшение обычно отмечается через 2–4 недели регулярного приёма, иногда позже. + Оценивает эффект и решает, нужно ли менять лечение, лечащий врач. + alcohol: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»" + text: >- + Во время лечения употребление алкоголя не рекомендуется. + Алкоголь сам по себе ухудшает сон и настроение и может усиливать побочные эффекты. + discontinuation: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»" + text: >- + Флуоксетин выводится из организма медленно, поэтому симптомы отмены могут появиться не сразу, а через + несколько дней или недель. + При прекращении приёма возможны головокружение, нарушения сна, тревога, раздражительность, тошнота. + Препарат не следует отменять самостоятельно: решение об отмене принимает лечащий врач. + administration: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Способ применения и дозы»" + text: >- + Препарат принимают внутрь, обычно один раз в сутки утром, независимо от приёма пищи. + Капсулы проглатывают, запивая водой. + Дозу и схему приёма определяет лечащий врач. + interactions: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Взаимодействие с другими лекарственными средствами»" + text: >- + Одновременный приём с ингибиторами МАО, пимозидом и тиоридазином противопоказан; из-за медленного выведения + флуоксетина интервал перед назначением ингибиторов МАО составляет не менее 5 недель. + Совместное применение с другими серотонинергическими средствами (трамадол, триптаны, препараты зверобоя) + повышает риск серотонинового синдрома. + О любых других лекарствах нужно сообщить врачу. + overdose: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Передозировка»" + text: >- + Симптомы передозировки: тошнота, рвота, возбуждение, учащённое сердцебиение, судороги. + При приёме большего количества, чем назначено, нужно немедленно обратиться за медицинской помощью (103 или 112). diff --git a/data/meds/grls/mirtazapine.yaml b/data/meds/grls/mirtazapine.yaml new file mode 100644 index 0000000..ffd3ec4 --- /dev/null +++ b/data/meds/grls/mirtazapine.yaml @@ -0,0 +1,50 @@ +# Выдержки для прототипа, сверить с действующей инструкцией ГРЛС (grls.rosminzdrav.ru). +# Синтетический пересказ: тексты сокращены и упрощены для пациента, не являются официальной инструкцией. +# Правила: 1–4 предложения на раздел; схемы дозирования — только в administration (и без миллиграммов); +# без диагнозов и советов изменить лечение. Бот отвечает о препарате только из этих текстов (app/llm/med_qa.py). +inn: миртазапин +aliases: [mirtazapine, ремерон, remeron, каликста] +sections: + side_effects: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Побочное действие»" + text: >- + Наиболее частые нежелательные реакции: сонливость, повышение аппетита и массы тела, сухость во рту, + головокружение, головная боль, отёки. + Сонливость чаще выражена в первые недели лечения и обычно уменьшается при продолжении приёма. + О стойких или выраженных побочных эффектах нужно сообщить лечащему врачу. + onset: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Способ применения и дозы»" + text: >- + Терапевтический эффект развивается постепенно: первые признаки улучшения обычно появляются через 1–2 недели, + полный эффект — через 2–4 недели регулярного приёма. + Оценивает эффект и решает, нужно ли менять лечение, лечащий врач. + alcohol: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»" + text: >- + Миртазапин усиливает угнетающее действие алкоголя на нервную систему, поэтому во время лечения от алкоголя + следует воздержаться. + Сочетание с алкоголем усиливает сонливость и замедляет реакцию. + discontinuation: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»" + text: >- + Резкое прекращение приёма может вызвать симптомы отмены: головокружение, тревогу, возбуждение, головную боль, + тошноту. + Препарат не следует отменять самостоятельно: отмену проводят постепенно, по решению и под контролем врача. + administration: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Способ применения и дозы»" + text: >- + Препарат обычно принимают один раз в сутки вечером, перед сном, независимо от приёма пищи. + Таблетку проглатывают, не разжёвывая, запивая водой. + Дозу и схему приёма определяет лечащий врач. + interactions: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Взаимодействие с другими лекарственными средствами»" + text: >- + Одновременный приём с ингибиторами МАО противопоказан. + Миртазапин усиливает седативное действие бензодиазепинов и других успокоительных средств. + Совместное применение с другими серотонинергическими средствами повышает риск серотонинового синдрома. + О любых других лекарствах нужно сообщить врачу. + overdose: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Передозировка»" + text: >- + Симптомы передозировки: выраженная сонливость, дезориентация, учащённое сердцебиение. + При приёме большего количества, чем назначено, нужно немедленно обратиться за медицинской помощью (103 или 112). diff --git a/data/meds/grls/paroxetine.yaml b/data/meds/grls/paroxetine.yaml new file mode 100644 index 0000000..fb96e21 --- /dev/null +++ b/data/meds/grls/paroxetine.yaml @@ -0,0 +1,56 @@ +# Выдержки для прототипа, сверить с действующей инструкцией ГРЛС (grls.rosminzdrav.ru). +# Синтетический пересказ: тексты сокращены и упрощены для пациента, не являются официальной инструкцией. +# Правила: 1–4 предложения на раздел; схемы дозирования — только в administration (и без миллиграммов); +# без диагнозов и советов изменить лечение. Бот отвечает о препарате только из этих текстов (app/llm/med_qa.py). +inn: пароксетин +aliases: [paroxetine, паксил, paxil, рексетин] +sections: + side_effects: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Побочное действие»" + text: >- + Наиболее частые нежелательные реакции: тошнота, сонливость или бессонница, повышенная потливость, дрожь, + слабость, сухость во рту, запор, снижение аппетита. + Частота и выраженность многих побочных эффектов, в том числе тошноты, уменьшаются при продолжении лечения, + обычно в первые 1–2 недели. + Возможны нарушения половой функции: снижение влечения, нарушение эякуляции. + О стойких или выраженных побочных эффектах нужно сообщить лечащему врачу. + onset: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Способ применения и дозы»" + text: >- + Терапевтический эффект развивается постепенно. + Улучшение обычно отмечается через 2–4 недели регулярного приёма, а в первые дни самочувствие может не меняться. + Оценивает эффект и решает, нужно ли менять лечение, лечащий врач. + alcohol: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»" + text: >- + Во время лечения употребление алкоголя не рекомендуется. + Хотя пароксетин не усиливает вредное действие алкоголя на психомоторные функции, их одновременный приём + не рекомендован. + Алкоголь сам по себе ухудшает сон и настроение. + discontinuation: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»" + text: >- + Резкое прекращение приёма часто вызывает синдром отмены: головокружение, нарушения сна и яркие сновидения, + тревогу, раздражительность, тошноту, потливость, ощущение покалывания. + Препарат не следует отменять самостоятельно: отмену проводят постепенно, в течение нескольких недель, по решению + и под контролем врача. + Изменять схему лечения может только лечащий врач. + administration: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Способ применения и дозы»" + text: >- + Препарат принимают один раз в сутки утром, во время завтрака. + Таблетку проглатывают целиком, не разжёвывая, запивая водой. + Дозу и схему приёма определяет лечащий врач. + interactions: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Взаимодействие с другими лекарственными средствами»" + text: >- + Одновременный приём с ингибиторами МАО, тиоридазином и пимозидом противопоказан. + Совместное применение с другими серотонинергическими средствами (трамадол, триптаны, препараты зверобоя) + повышает риск серотонинового синдрома. + Пароксетин влияет на обмен ряда препаратов в печени, поэтому о любых других лекарствах нужно сообщить врачу. + overdose: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Передозировка»" + text: >- + Симптомы передозировки: тошнота, рвота, дрожь, расширение зрачков, сухость во рту, раздражительность, + сонливость. + При приёме большего количества, чем назначено, нужно немедленно обратиться за медицинской помощью (103 или 112). diff --git a/data/meds/grls/prazosin.yaml b/data/meds/grls/prazosin.yaml new file mode 100644 index 0000000..d7ad106 --- /dev/null +++ b/data/meds/grls/prazosin.yaml @@ -0,0 +1,49 @@ +# Выдержки для прототипа, сверить с действующей инструкцией ГРЛС (grls.rosminzdrav.ru). +# Синтетический пересказ: тексты сокращены и упрощены для пациента, не являются официальной инструкцией. +# Празозин зарегистрирован как средство, снижающее артериальное давление; при ночных кошмарах его назначает +# врач вне зарегистрированных показаний — сроки эффекта в инструкции для этого случая не указаны. +# Правила: 1–4 предложения на раздел; схемы дозирования — только в administration (и без миллиграммов); +# без диагнозов и советов изменить лечение. Бот отвечает о препарате только из этих текстов (app/llm/med_qa.py). +inn: празозин +aliases: [prazosin, минипресс, minipress] +sections: + side_effects: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Побочное действие»" + text: >- + Наиболее частые нежелательные реакции: головокружение, сонливость, головная боль, слабость, сердцебиение, + тошнота. + После первого приёма возможно резкое снижение давления при вставании с головокружением и даже обмороком + («эффект первой дозы»). + Головокружение обычно уменьшается при продолжении лечения; о стойких побочных эффектах нужно сообщить врачу. + onset: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Фармакологические свойства»" + text: >- + Действие на артериальное давление начинается через несколько часов после приёма. + Срок, через который ожидается эффект в вашем случае, определяет лечащий врач. + alcohol: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»" + text: >- + Во время лечения употребление алкоголя не рекомендуется. + Алкоголь усиливает снижение давления, головокружение и сонливость. + discontinuation: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»" + text: >- + Препарат не следует прекращать или возобновлять самостоятельно. + После перерыва в приёме снова возможен «эффект первой дозы», поэтому порядок возобновления определяет врач. + administration: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Способ применения и дозы»" + text: >- + Первый приём рекомендуется вечером перед сном, в положении лёжа. + Вставать с постели после приёма следует медленно, особенно ночью. + Дозу и схему приёма определяет лечащий врач. + interactions: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Взаимодействие с другими лекарственными средствами»" + text: >- + Одновременный приём с другими средствами, снижающими давление, и с препаратами для лечения эректильной + дисфункции усиливает снижение давления. + О любых других лекарствах нужно сообщить врачу. + overdose: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Передозировка»" + text: >- + Симптомы передозировки: резкое снижение давления, выраженная сонливость, слабость, обморок. + При приёме большего количества, чем назначено, нужно немедленно обратиться за медицинской помощью (103 или 112). diff --git a/data/meds/grls/sertraline.yaml b/data/meds/grls/sertraline.yaml new file mode 100644 index 0000000..5ec09d2 --- /dev/null +++ b/data/meds/grls/sertraline.yaml @@ -0,0 +1,57 @@ +# Выдержки для прототипа, сверить с действующей инструкцией ГРЛС (grls.rosminzdrav.ru). +# Синтетический пересказ: тексты сокращены и упрощены для пациента, не являются официальной инструкцией. +# Правила: 1–4 предложения на раздел; схемы дозирования — только в administration (и без миллиграммов); +# без диагнозов и советов изменить лечение. Бот отвечает о препарате только из этих текстов (app/llm/med_qa.py). +inn: сертралин +aliases: [sertraline, золофт, zoloft] +sections: + side_effects: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Побочное действие»" + text: >- + Наиболее частые нежелательные реакции в начале лечения: тошнота, диарея, сухость во рту, бессонница или + сонливость, головная боль и головокружение. + Тошнота и другие нежелательные явления начала лечения обычно ослабевают или проходят в течение 1–2 недель. + Возможны нарушения половой функции: снижение влечения, задержка эякуляции. + О стойких или выраженных побочных эффектах нужно сообщить лечащему врачу. + onset: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Способ применения и дозы»" + text: >- + Терапевтический эффект развивается постепенно. + Первые признаки улучшения могут появиться в течение первой недели, а полный эффект обычно достигается через + 2–4 недели регулярного приёма. + В первые дни, пока эффект ещё не развился, самочувствие может не улучшаться, а побочные эффекты ощущаются + сильнее. + alcohol: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»" + text: >- + Во время лечения употребление алкоголя не рекомендуется. + Хотя в исследованиях сертралин не усиливал действие алкоголя, их одновременный приём не рекомендован. + Алкоголь сам по себе ухудшает сон и настроение и может усиливать побочные эффекты, например сонливость и + головокружение. + discontinuation: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»" + text: >- + Резкое прекращение приёма может вызвать синдром отмены: головокружение, нарушения сна, тревогу, тошноту, + ощущение покалывания. + Препарат не следует отменять самостоятельно: отмену проводят постепенно, по решению и под контролем врача. + Изменять схему лечения может только лечащий врач. + administration: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Способ применения и дозы»" + text: >- + Препарат принимают внутрь один раз в сутки, утром или вечером, независимо от приёма пищи. + Таблетки принимают ежедневно примерно в одно и то же время. + Дозу и схему приёма определяет лечащий врач. + interactions: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Взаимодействие с другими лекарственными средствами»" + text: >- + Одновременный приём с ингибиторами МАО и пимозидом противопоказан. + Совместное применение с другими серотонинергическими средствами (трамадол, триптаны, препараты зверобоя) + повышает риск серотонинового синдрома. + С антикоагулянтами и обезболивающими из группы НПВП возрастает риск кровотечений. + О любых других лекарствах, включая безрецептурные, нужно сообщить врачу. + overdose: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Передозировка»" + text: >- + Симптомы передозировки: сонливость, тошнота, рвота, учащённое сердцебиение, дрожь, возбуждение, + головокружение. + При приёме большего количества, чем назначено, нужно немедленно обратиться за медицинской помощью (103 или 112). diff --git a/data/meds/mis_prescriptions.json b/data/meds/mis_prescriptions.json new file mode 100644 index 0000000..564d600 --- /dev/null +++ b/data/meds/mis_prescriptions.json @@ -0,0 +1,165 @@ +{ + "_comment": "СИНТЕТИКА. Назначения-образцы для mis_mock (заглушка МИС/ЕГИСЗ, SPEC §9). Ключ — токен пациента; источник первого уровня — таблица prescriptions клинической БД, этот файл — запасной. Персоны: docs/product/01_demo_persona.md.", + "patients": [ + { + "persona": "andrey", + "tokens": [ + "tok-andrey" + ], + "prescriptions": [ + { + "drug": "сертралин", + "dose": "50 мг", + "schedule": { + "times": [ + "08:00" + ] + }, + "instructions": "утром после еды", + "start_date": null, + "end_date": null + } + ] + }, + { + "persona": "dmitry", + "tokens": [ + "tok-dmitry" + ], + "prescriptions": [ + { + "drug": "сертралин", + "dose": "100 мг", + "schedule": { + "times": [ + "08:00" + ] + }, + "instructions": "утром после еды", + "start_date": null, + "end_date": null + } + ] + }, + { + "persona": "sergey", + "tokens": [ + "tok-sergey" + ], + "prescriptions": [ + { + "drug": "пароксетин", + "dose": "20 мг", + "schedule": { + "times": [ + "08:00" + ] + }, + "instructions": "утром во время завтрака", + "start_date": null, + "end_date": null + } + ] + }, + { + "persona": "nikolay", + "tokens": [ + "tok-nikolay" + ], + "prescriptions": [ + { + "drug": "сертралин", + "dose": "50 мг", + "schedule": { + "times": [ + "08:00" + ] + }, + "instructions": "утром после еды", + "start_date": null, + "end_date": null + } + ] + }, + { + "persona": "anna", + "tokens": [ + "tok-anna" + ], + "prescriptions": [ + { + "drug": "флуоксетин", + "dose": "20 мг", + "schedule": { + "times": [ + "08:00" + ] + }, + "instructions": "утром", + "start_date": null, + "end_date": null + } + ] + }, + { + "persona": "irina", + "tokens": [ + "tok-irina" + ], + "prescriptions": [ + { + "drug": "эсциталопрам", + "dose": "10 мг", + "schedule": { + "times": [ + "09:00" + ] + }, + "instructions": "утром", + "start_date": null, + "end_date": null + } + ] + }, + { + "persona": "olga", + "tokens": [ + "tok-olga" + ], + "prescriptions": [ + { + "drug": "сертралин", + "dose": "50 мг", + "schedule": { + "times": [ + "08:00" + ] + }, + "instructions": "утром после еды", + "start_date": null, + "end_date": null + } + ] + }, + { + "persona": "pavel", + "tokens": [ + "tok-pavel" + ], + "prescriptions": [ + { + "drug": "эсциталопрам", + "dose": "10 мг", + "schedule": { + "times": [ + "09:00" + ] + }, + "instructions": "утром", + "start_date": null, + "end_date": null + } + ] + } + ] +} diff --git a/tests/test_integrations.py b/tests/test_integrations.py new file mode 100644 index 0000000..16d132d --- /dev/null +++ b/tests/test_integrations.py @@ -0,0 +1,323 @@ +"""Demo adapters (#14): МИС mock, connect-code identity, ГРЛС excerpts, doctor notifier, registry setup.""" + +import json +from collections.abc import AsyncIterator +from datetime import UTC, date, datetime, timedelta +from pathlib import Path + +import pytest + +from app.core import clock +from app.core.types import ScrubbedText +from app.db import identity_repo, repo, session +from app.db.bootstrap import init_db +from app.db.clinical import Prescription as PrescriptionRow +from app.integrations import ( + CompositeNotifier, + ConnectCodeIdentity, + GrlsExcerpts, + MisMock, + NotificationFeed, + setup, +) +from app.integrations import notify as notify_module +from app.integrations.base import ( + EmptyPrescriptions, + MemoryNotifier, + MemoryOutbox, + NoDrugInfo, + RejectIdentity, + integrations, +) +from app.integrations.grls import normalize_inn +from app.integrations.notify import TelegramDoctorChat, format_message +from app.llm import med_qa as med_qa_module +from app.llm.gateway import LLMGateway, is_forbidden +from app.llm.med_qa import DOSING_SENTENCE, SECTIONS, med_qa +from app.llm.mock import MockTasks + +MEM = "sqlite+aiosqlite:///:memory:" +TOKEN = "a" * 32 +T0 = datetime(2026, 10, 1, 9, 0, tzinfo=UTC) +GRLS_DIR = Path(__file__).resolve().parents[1] / "data" / "meds" / "grls" +CORE_DRUGS = ("сертралин", "пароксетин", "эсциталопрам", "флуоксетин") + + +@pytest.fixture +async def dbs() -> AsyncIterator[None]: + await session.configure(identity_url=MEM, clinical_url=MEM) + await init_db() + try: + yield + finally: + await session.configure() + + +# ----------------------------------------------------------------------------------------------- setup + + +def test_setup_registers_demo_adapters() -> None: + assert isinstance(integrations.prescriptions, MisMock) + assert isinstance(integrations.identity, ConnectCodeIdentity) + assert isinstance(integrations.drug_info, GrlsExcerpts) + assert isinstance(integrations.notifier, CompositeNotifier) + # The outbox stays the in-memory queue the web console drains (Telegram wraps it at start). + assert isinstance(integrations.outbox, MemoryOutbox) + + +def test_setup_is_idempotent() -> None: + before = (integrations.prescriptions, integrations.identity, integrations.drug_info, integrations.notifier) + setup() + setup() + assert (integrations.prescriptions, integrations.identity, integrations.drug_info, integrations.notifier) == before + + +def test_setup_replaces_only_defaults(monkeypatch: pytest.MonkeyPatch) -> None: + fake_notifier = MemoryNotifier() + sink = type("Sink", (MemoryNotifier,), {})() + monkeypatch.setattr(integrations, "prescriptions", EmptyPrescriptions()) + monkeypatch.setattr(integrations, "identity", RejectIdentity()) + monkeypatch.setattr(integrations, "drug_info", NoDrugInfo()) + monkeypatch.setattr(integrations, "notifier", fake_notifier) + setup(force=True) + assert isinstance(integrations.prescriptions, MisMock) + assert isinstance(integrations.notifier, CompositeNotifier) + monkeypatch.setattr(integrations, "notifier", sink) + monkeypatch.setattr("app.integrations._done", False) + setup() + assert integrations.notifier is sink # a swapped-in test sink is kept + + +# --------------------------------------------------------------------------------------------- mis_mock + + +async def test_mis_mock_catalogue_fallback() -> None: + mis = MisMock(use_db=False) + [rx] = await mis.get_prescriptions("tok-andrey") + assert (rx.drug, rx.dose, rx.schedule["times"]) == ("сертралин", "50 мг", ["08:00"]) + assert rx.instructions == "утром после еды" + assert await mis.get_prescriptions("unknown") == [] + + +async def test_mis_mock_catalogue_drugs_have_excerpts() -> None: + raw = json.loads((GRLS_DIR.parent / "mis_prescriptions.json").read_text(encoding="utf-8")) + known = set(GrlsExcerpts().known()) + assert {rx["drug"] for p in raw["patients"] for rx in p["prescriptions"]} <= known + + +async def test_mis_mock_missing_catalogue_is_empty(tmp_path: Path) -> None: + assert await MisMock(tmp_path / "nope.json", use_db=False).get_prescriptions("tok-andrey") == [] + + +async def test_mis_mock_prefers_db(dbs: None) -> None: + async with session.get_clinical_session() as s: + s.add( + PrescriptionRow( + token="tok-andrey", + drug="эсциталопрам", + dose="10 мг", + schedule={"times": ["09:00"], "instructions": "утром"}, + start_date=date(2026, 10, 1), + ) + ) + [rx] = await MisMock().get_prescriptions("tok-andrey") + assert (rx.drug, rx.dose, rx.instructions, rx.start_date) == ("эсциталопрам", "10 мг", "утром", date(2026, 10, 1)) + # No DB rows for this token -> catalogue. + [rx] = await MisMock().get_prescriptions("tok-sergey") + assert rx.drug == "пароксетин" + + +async def test_mis_mock_inside_caller_session_keeps_its_work(dbs: None) -> None: + # The dialog calls the adapter while its own clinical session holds flushed changes. + async with session.get_clinical_session() as s: + await repo.upsert_profile(s, token=TOKEN, doctor_id="doc-smirnova") + assert await MisMock().get_prescriptions(TOKEN) == [] + async with session.get_clinical_session() as s: + assert await repo.get_profile(s, TOKEN) is not None + + +async def test_mis_mock_db_down_falls_back(tmp_path: Path) -> None: + await session.configure(clinical_url=f"sqlite+aiosqlite:///{tmp_path}/missing/dir/x.db") + try: + [rx] = await MisMock().get_prescriptions("tok-andrey") + finally: + await session.configure() + assert rx.drug == "сертралин" + + +# ------------------------------------------------------------------------------------------ connect code + + +async def _code(code: str = "DEMO-0001") -> None: + async with session.get_identity_session() as s: + await identity_repo.create_connect_code(s, code=code, token=TOKEN, doctor_id="doc-smirnova", now=T0) + + +async def test_connect_code_verifies_once(dbs: None, monkeypatch: pytest.MonkeyPatch) -> None: + await _code() + ident = ConnectCodeIdentity() + monkeypatch.setattr(clock, "now", lambda: T0) + person = await ident.verify(" DEMO-0001 ") + assert person is not None and (person.token, person.doctor_id) == (TOKEN, "doc-smirnova") + # Same connect flow (Telegram /start, then the engine's `new` state) re-checks the code: still valid. + monkeypatch.setattr(clock, "now", lambda: T0 + timedelta(minutes=1)) + assert await ident.verify("DEMO-0001") == person + # Later the used code is rejected. + monkeypatch.setattr(clock, "now", lambda: T0 + timedelta(hours=1)) + assert await ident.verify("DEMO-0001") is None + + +async def test_connect_code_unknown_or_empty(dbs: None) -> None: + assert await ConnectCodeIdentity().verify("NOPE") is None + assert await ConnectCodeIdentity().verify(" ") is None + + +async def test_connect_code_db_down(tmp_path: Path) -> None: + await session.configure(identity_url=f"sqlite+aiosqlite:///{tmp_path}/missing/dir/x.db") + try: + assert await ConnectCodeIdentity().verify("DEMO-0001") is None + finally: + await session.configure() + + +# ------------------------------------------------------------------------------------------------- ГРЛС + + +async def test_grls_core_drugs_have_all_sections() -> None: + grls = GrlsExcerpts() + for inn in CORE_DRUGS: + sections = await grls.sections(inn) + assert {s.section for s in sections} == set(SECTIONS), inn + assert all(s.inn == inn and s.source.startswith("ГРЛС, инструкция по медицинскому") for s in sections) + assert {"миртазапин", "празозин"} <= set(grls.known()) + + +async def test_grls_texts_are_short_safe_and_dose_free() -> None: + grls = GrlsExcerpts() + for inn in grls.known(): + for s in await grls.sections(inn): + sentences = [x for x in s.text.replace("! ", ". ").split(". ") if x] + assert 1 <= len(sentences) <= 4, (inn, s.section) + assert not is_forbidden(s.text), (inn, s.section) + if s.section != "administration": + assert not DOSING_SENTENCE.search(s.text), (inn, s.section) + assert "мг" not in s.text.split(), (inn, s.section) + + +def test_grls_files_carry_verification_header() -> None: + for path in GRLS_DIR.glob("*.yaml"): + assert "сверить с действующей инструкцией ГРЛС" in path.read_text(encoding="utf-8").splitlines()[0], path + + +async def test_grls_lookup_normalisation() -> None: + grls = GrlsExcerpts() + assert normalize_inn("Сертралин 50 мг") == "сертралин" + assert await grls.sections("Sertraline") == await grls.sections("сертралин 50 мг") + assert await grls.sections("аспирин") == [] + + +async def test_grls_bad_files_are_skipped(tmp_path: Path) -> None: + (tmp_path / "bad.yaml").write_text("inn: [", encoding="utf-8") + (tmp_path / "wrong.yaml").write_text("inn: x\n", encoding="utf-8") + assert GrlsExcerpts(tmp_path).known() == [] + assert await GrlsExcerpts(tmp_path / "missing").sections("сертралин") == [] + + +# --------------------------------------------------------------- med_qa end-to-end on real sertraline data + + +@pytest.fixture +def mock_gateway(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(med_qa_module, "get_gateway", lambda: LLMGateway(MockTasks())) + + +async def test_med_qa_day2_nausea(mock_gateway: None) -> None: + answer = await med_qa("сертралин", ScrubbedText("подташнивает с утра")) + assert answer is not None and answer.section == "side_effects" + assert "тошнот" in answer.text.lower() and "1–2 недель" in answer.text + assert "Побочное действие" in answer.source + + +async def test_med_qa_day5_quit(mock_gateway: None) -> None: + answer = await med_qa("сертралин", ScrubbedText("от таблеток только хуже, брошу"), topic="quit_intent") + assert answer is not None and answer.section == "onset" + assert "2–4 недели" in answer.text + + +async def test_med_qa_day10_alcohol(mock_gateway: None) -> None: + answer = await med_qa("сертралин", ScrubbedText("без пива не могу уснуть")) + assert answer is not None and answer.section == "alcohol" + assert "алкогол" in answer.text.lower() and "сон" in answer.text + + +async def test_med_qa_other_drugs_answer_core_topics(mock_gateway: None) -> None: + for inn in CORE_DRUGS: + for question in ("тошнит после таблетки", "когда начнёт действовать", "можно ли выпить пива", "хочу бросить"): + answer = await med_qa(inn, ScrubbedText(question)) + assert answer is not None, (inn, question) + assert "мг" not in answer.text + + +async def test_med_qa_dose_question_still_goes_to_doctor(mock_gateway: None) -> None: + assert await med_qa("сертралин", ScrubbedText("можно увеличить дозу?")) is None + + +# ------------------------------------------------------------------------------------------------ notify + + +class FakeChat: + def __init__(self, fail: bool = False) -> None: + self.sent: list[tuple[int, str]] = [] + self.fail = fail + + async def __call__(self, chat_id: int, text: str) -> None: + if self.fail: + raise RuntimeError("telegram down") + self.sent.append((chat_id, text)) + + +async def test_feed_ids_are_monotonic_and_filterable(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(clock, "now", lambda: T0) + notifier = CompositeNotifier(chat=TelegramDoctorChat(chat_id=None, bot_token="")) + await notifier.send("doc-smirnova", "moderate", "alcohol_3d", TOKEN) + await notifier.send("doc-other", "high", "sleep_lt4_3n", "b" * 32) + await notifier.send("doc-smirnova", "acute", "phq9_item9", TOKEN) + items = notifier.recent() + assert [n.id for n in items] == [1, 2, 3] and items[0].ts == T0 + assert [n.text for n in notifier.recent(since_id=1, doctor_id="doc-smirnova")] == ["phq9_item9"] + assert notifier.recent(since_id=3) == [] + + +def test_feed_is_bounded() -> None: + feed = NotificationFeed(size=2) + for i in range(3): + feed.add("d", "info", f"r{i}", None, T0) + assert [n.id for n in feed.recent()] == [2, 3] + + +async def test_telegram_doctor_chat_message_has_no_pii() -> None: + chat = FakeChat() + notifier = CompositeNotifier(chat=TelegramDoctorChat(chat_id=-100, bot_token="", sender=chat)) + await notifier.send("doc-smirnova", "acute", "PHQ-9 п.9 = 2 — кризисный протокол", TOKEN) + [(chat_id, text)] = chat.sent + assert chat_id == -100 + assert text == "[КРИТИЧНО] PHQ-9 п.9 = 2 — кризисный протокол\nПациент #aaaaaa" + assert TOKEN not in text + + +async def test_telegram_failure_is_swallowed() -> None: + notifier = CompositeNotifier(chat=TelegramDoctorChat(chat_id=1, bot_token="", sender=FakeChat(fail=True))) + await notifier.send("doc-smirnova", "high", "sleep_lt4_3n", TOKEN) # does not raise + assert len(notifier.recent()) == 1 + + +def test_telegram_chat_disabled_without_settings() -> None: + assert not TelegramDoctorChat(chat_id=None, bot_token="x").enabled + assert not TelegramDoctorChat(chat_id=5, bot_token="").enabled + assert TelegramDoctorChat(chat_id=5, bot_token="123:abc").enabled + + +def test_format_message_trims_long_reason() -> None: + text = format_message("info", "x" * 500, None) + assert len(text.splitlines()[0]) <= notify_module.MAX_REASON_CHARS + len("[Инфо] ") + assert text.endswith("Пациент #—") diff --git a/tests/test_llm_med_qa.py b/tests/test_llm_med_qa.py index 03b0624..1a58a98 100644 --- a/tests/test_llm_med_qa.py +++ b/tests/test_llm_med_qa.py @@ -3,7 +3,7 @@ import pytest from app.core.types import ScrubbedText -from app.integrations.base import DrugSection +from app.integrations.base import DrugSection, NoDrugInfo, integrations from app.llm import med_qa as med_qa_module from app.llm.gateway import LLMGateway, is_forbidden from app.llm.med_qa import MedAnswer, adds_content, detect_topic, fallback_text, med_qa, select_excerpt @@ -173,8 +173,9 @@ async def test_unknown_drug_and_unknown_topic(info: FakeDrugInfo, mock_gateway: assert await med_qa("сертралин", S("какая погода"), drug_info=info) is None -async def test_default_registry_has_no_data(mock_gateway: None) -> None: - # Before lane B (#14) registers real excerpts the default NoDrugInfo returns nothing. +async def test_empty_registry_has_no_data(mock_gateway: None, monkeypatch: pytest.MonkeyPatch) -> None: + # Without excerpts (base.py default NoDrugInfo; #14 registers real ones at import) nothing is answered. + monkeypatch.setattr(integrations, "drug_info", NoDrugInfo()) assert await med_qa("сертралин", S("тошнит")) is None From 260422138abee0dea49b4183587175ffdf751e09 Mon Sep 17 00:00:00 2001 From: Lacky1234union Date: Sun, 4 Oct 2026 12:46:12 +0300 Subject: [PATCH 05/12] feat(dashboard): demo console with web chat, demo clock, scenario playback and split screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pages/2_Демо-консоль.py: persona selector, virtual clock bar (+1 h, to 08:00/20:00, next day, reset with confirm), fallback patient web chat (buttons from Reply.buttons, kind styling, outbox drained after every action and on a 2 s fragment poll), scenario panel - pages/3_Сплит-экран.py: patient chat or PATIENT_WEB_URL iframe on the left, compact doctor panel (patients, live alerts, red banner on a new acute alert) on the right - console/playback.py: data/synthetic/.jsonl runner (advance_to/press/text/ expect_color/start), date chaining between days, pause on failure with retry/skip/manual - console/api.py: own HTTP client over the frozen API contract, ApiError only Refs #17 Co-Authored-By: Claude Opus 5.5 --- dashboard/console/__init__.py | 5 + dashboard/console/api.py | 158 ++++++++++ dashboard/console/chatlog.py | 81 +++++ dashboard/console/playback.py | 312 +++++++++++++++++++ dashboard/console/playback_ui.py | 106 +++++++ dashboard/console/scenario.py | 163 ++++++++++ dashboard/console/widgets.py | 412 ++++++++++++++++++++++++++ dashboard/pages/2_Демо-консоль.py | 33 +++ dashboard/pages/3_Сплит-экран.py | 58 ++++ tests/test_dashboard_console.py | 390 ++++++++++++++++++++++++ tests/test_dashboard_console_pages.py | 64 ++++ 11 files changed, 1782 insertions(+) create mode 100644 dashboard/console/__init__.py create mode 100644 dashboard/console/api.py create mode 100644 dashboard/console/chatlog.py create mode 100644 dashboard/console/playback.py create mode 100644 dashboard/console/playback_ui.py create mode 100644 dashboard/console/scenario.py create mode 100644 dashboard/console/widgets.py create mode 100644 dashboard/pages/2_Демо-консоль.py create mode 100644 dashboard/pages/3_Сплит-экран.py create mode 100644 tests/test_dashboard_console.py create mode 100644 tests/test_dashboard_console_pages.py diff --git a/dashboard/console/__init__.py b/dashboard/console/__init__.py new file mode 100644 index 0000000..3ccbeab --- /dev/null +++ b/dashboard/console/__init__.py @@ -0,0 +1,5 @@ +"""Demo console building blocks (#17): HTTP API helpers, scenario parser, playback runner, Streamlit widgets. + +Pages import this package as `console` (Streamlit puts `dashboard/` on sys.path). Modules other than +`widgets` / `chat_ui` are free of Streamlit so they can be unit-tested. +""" diff --git a/dashboard/console/api.py b/dashboard/console/api.py new file mode 100644 index 0000000..4c710a3 --- /dev/null +++ b/dashboard/console/api.py @@ -0,0 +1,158 @@ +"""Thin synchronous HTTP client for the demo console (frozen API contract, `app/api/schemas.py`). + +The console never imports backend code: it talks to `API_BASE_URL` only. Responses are kept as plain dicts — +the console reads a handful of fields and must survive a backend that still serves fixtures (#12). +Every failure is raised as `ApiError` with a short Russian message, so pages can show it instead of crashing. +""" + +from __future__ import annotations + +import os +from typing import Any, Protocol + +import httpx + +DEFAULT_DOCTOR = "doc-smirnova" +DEFAULT_TOKEN = "tok-andrey" + + +def api_base_url() -> str: + return os.environ.get("API_BASE_URL", "http://localhost:8000").rstrip("/") + + +def doctor_id() -> str: + return os.environ.get("DEMO_DOCTOR_ID", DEFAULT_DOCTOR) + + +def fallback_token() -> str: + return os.environ.get("DEMO_PERSONA_TOKEN", DEFAULT_TOKEN) + + +class ApiError(RuntimeError): + """Any API failure (network, HTTP status, bad JSON) with a message fit for the presenter.""" + + +class DemoApi(Protocol): + """What the playback runner needs; `ConsoleApi` implements it, tests use a fake.""" + + def clock(self) -> dict[str, Any]: ... + + def advance(self, days: float = 0, hours: float = 0) -> dict[str, Any]: ... + + def send_event( + self, + token: str, + type_: str, + *, + text: str | None = None, + button: str | None = None, + params: dict[str, Any] | None = None, + ) -> list[dict[str, Any]]: ... + + def outbox(self, token: str) -> list[dict[str, Any]]: ... + + def patients(self, doctor: str) -> list[dict[str, Any]]: ... + + +class ConsoleApi: + """Client for `/api/v1`. Short timeouts everywhere except clock moves, which replay the scheduler.""" + + def __init__(self, base_url: str | None = None, *, timeout: float = 5.0, client: httpx.Client | None = None): + self.base_url = (base_url or api_base_url()).rstrip("/") + self._timeout = timeout + self._client = client or httpx.Client(timeout=timeout) + + # ------------------------------------------------------------------ plumbing + def _request(self, method: str, path: str, *, timeout: float | None = None, **kwargs: Any) -> Any: + url = f"{self.base_url}{path}" + try: + resp = self._client.request(method, url, timeout=timeout or self._timeout, **kwargs) + except httpx.TimeoutException as exc: + raise ApiError(f"{method} {path}: API не ответил вовремя") from exc + except httpx.HTTPError as exc: + raise ApiError(f"{method} {path}: API недоступен ({type(exc).__name__})") from exc + if resp.status_code >= 400: + raise ApiError(f"{method} {path}: HTTP {resp.status_code}") + try: + return resp.json() + except ValueError as exc: + raise ApiError(f"{method} {path}: ответ не JSON") from exc + + def close(self) -> None: + self._client.close() + + # ------------------------------------------------------------------ service + def health(self) -> bool: + try: + return self._request("GET", "/health", timeout=2).get("status") == "ok" + except (ApiError, AttributeError): + return False + + # ------------------------------------------------------------------ demo controls (ADR-005) + def personas(self) -> list[dict[str, Any]]: + return list(self._request("GET", "/api/v1/demo/personas")) + + def clock(self) -> dict[str, Any]: + return dict(self._request("GET", "/api/v1/demo/clock")) + + def advance(self, days: float = 0, hours: float = 0) -> dict[str, Any]: + # The backend replays the scheduler up to the new time inside this call: allow it time. + body = {"days": days, "hours": hours} + return dict(self._request("POST", "/api/v1/demo/clock/advance", json=body, timeout=60)) + + def reset(self) -> dict[str, Any]: + return dict(self._request("POST", "/api/v1/demo/reset", timeout=60)) + + # ------------------------------------------------------------------ patient dialog + def send_event( + self, + token: str, + type_: str, + *, + text: str | None = None, + button: str | None = None, + params: dict[str, Any] | None = None, + ) -> list[dict[str, Any]]: + body: dict[str, Any] = {"type": type_, "params": params or {}} + if text is not None: + body["text"] = text + if button is not None: + body["button"] = button + data = self._request("POST", f"/api/v1/dialog/{token}/events", json=body, timeout=30) + return list(data.get("replies", [])) + + def outbox(self, token: str) -> list[dict[str, Any]]: + return list(self._request("GET", f"/api/v1/dialog/{token}/outbox").get("replies", [])) + + def history(self, token: str, limit: int = 100) -> list[dict[str, Any]]: + return list(self._request("GET", f"/api/v1/dialog/{token}/history", params={"limit": limit})) + + # ------------------------------------------------------------------ doctor side + def patients(self, doctor: str) -> list[dict[str, Any]]: + return list(self._request("GET", f"/api/v1/doctors/{doctor}/patients")) + + def alerts(self, doctor: str, since_id: int = -1, open_only: bool = False) -> list[dict[str, Any]]: + params = {"since_id": since_id, "open_only": str(open_only).lower()} + return list(self._request("GET", f"/api/v1/doctors/{doctor}/alerts", params=params)) + + def ack(self, alert_id: int, doctor: str) -> dict[str, Any]: + return dict(self._request("POST", f"/api/v1/alerts/{alert_id}/ack", json={"doctor_id": doctor})) + + +def personas_or_fallback(api: ConsoleApi) -> tuple[list[dict[str, Any]], str | None]: + """Demo personas from the API; on failure a single fallback persona (env token) and the error text.""" + try: + personas = [p for p in api.personas() if isinstance(p, dict) and p.get("token")] + if personas: + return personas, None + error = "API вернул пустой список персон" + except ApiError as exc: + error = str(exc) + token = fallback_token() + fallback = { + "token": token, + "display_name": "Андрей С." if token == DEFAULT_TOKEN else token, + "therapy_day": 0, + "scenario": "data/synthetic/andrey.jsonl" if token == DEFAULT_TOKEN else None, + } + return [fallback], error diff --git a/dashboard/console/chatlog.py b/dashboard/console/chatlog.py new file mode 100644 index 0000000..dcd07a2 --- /dev/null +++ b/dashboard/console/chatlog.py @@ -0,0 +1,81 @@ +"""In-memory transcript of the patient chat shown by the console (kept in Streamlit session state). + +Entries are plain dicts: `{"dir": "out", "text", "kind", "buttons"}` for bot replies (the `Reply` contract) +and `{"dir": "in", "text", "pressed": bool}` for what the patient sent. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any + +MAX_ENTRIES = 400 + + +def normalize_reply(raw: dict[str, Any]) -> dict[str, Any]: + """Coerce an API reply into a safe shape: missing fields get defaults, malformed buttons are dropped.""" + rows: list[list[dict[str, str]]] = [] + for row in raw.get("buttons") or []: + if not isinstance(row, list): + continue + clean = [ + {"label": str(b.get("label", "")), "data": str(b.get("data", ""))} + for b in row + if isinstance(b, dict) and b.get("data") is not None + ] + if clean: + rows.append(clean) + return { + "dir": "out", + "text": str(raw.get("text") or ""), + "kind": str(raw.get("kind") or "normal"), + "buttons": rows, + } + + +class ChatLog: + """Wraps a list owned by the caller (session state), so every page sees the same transcript.""" + + def __init__(self, entries: list[dict[str, Any]] | None = None): + self.entries: list[dict[str, Any]] = entries if entries is not None else [] + + def _trim(self) -> None: + if len(self.entries) > MAX_ENTRIES: + del self.entries[: len(self.entries) - MAX_ENTRIES] + + def add_in(self, text: str, *, pressed: bool = False) -> None: + self.entries.append({"dir": "in", "text": text, "pressed": pressed}) + self._trim() + + def add_replies(self, replies: Iterable[dict[str, Any]]) -> list[dict[str, Any]]: + added = [normalize_reply(r) for r in replies if isinstance(r, dict)] + self.entries.extend(added) + self._trim() + return added + + def add_history(self, messages: Iterable[dict[str, Any]]) -> None: + """Prefill from `GET /dialog/{token}/history` (MessageOut: no buttons).""" + for m in messages: + if not isinstance(m, dict): + continue + if m.get("direction") == "in": + self.entries.append({"dir": "in", "text": str(m.get("text", "")), "pressed": False}) + else: + self.entries.append(normalize_reply({"text": m.get("text"), "kind": m.get("kind")})) + self._trim() + + def recent_replies(self, n: int = 10) -> list[dict[str, Any]]: + """Last `n` bot replies, oldest first.""" + outs = [e for e in self.entries if e.get("dir") == "out"] + return outs[-n:] if n > 0 else [] + + def last_buttons_index(self) -> int | None: + """Index of the newest bot reply that carries buttons — only its keyboard is clickable in the web chat.""" + for i in range(len(self.entries) - 1, -1, -1): + e = self.entries[i] + if e.get("dir") == "out" and e.get("buttons"): + return i + return None + + def clear(self) -> None: + self.entries.clear() diff --git a/dashboard/console/playback.py b/dashboard/console/playback.py new file mode 100644 index 0000000..ddf60e7 --- /dev/null +++ b/dashboard/console/playback.py @@ -0,0 +1,312 @@ +"""Scenario playback over the HTTP API (#17): runs `data/synthetic/.jsonl` step by step. + +Time handling. A scenario day is a calendar date on the demo clock. When day N is played, its date is +derived from the last played day M: date(N) = date(M) + (N − M); with nothing played yet (or if that date +is already behind the clock, e.g. after manual «Следующий день») `advance_to HH:MM` goes to the next +occurrence of HH:MM. The clock only moves forward (`/demo/clock/advance` replays the scheduler), so a target +already in the past is skipped with a warning instead of failing. + +Failure handling. Every step is wrapped: an error stops playback, the position is kept in `PlaybackState` +and the page offers «повторить шаг» / «пропустить шаг» / «продолжить вручную». The demo never crashes. +""" + +from __future__ import annotations + +import time as _time +from collections.abc import Callable, Iterator, Sequence +from dataclasses import dataclass, field +from datetime import date, datetime, time, timedelta +from typing import Any, Literal + +from .api import DEFAULT_DOCTOR, ApiError, DemoApi +from .chatlog import ChatLog +from .scenario import Action, DayScript, Scenario, parse_hhmm + +# Virtual time keeps ticking between steps: a target a few minutes behind "now" counts as reached. +TOLERANCE = timedelta(minutes=5) +PRESS_WINDOW = 10 + +StepStatus = Literal["ok", "warn", "fail", "info"] + + +# ------------------------------------------------------------------------------------------------ pure helpers +def _norm(s: str) -> str: + return " ".join(s.casefold().replace("ё", "е").split()) + + +def resolve_press(replies: Sequence[dict[str, Any]], label: str, window: int = PRESS_WINDOW) -> tuple[str, str] | None: + """Find the button to press for a scenario `press` step. + + Searches the last `window` replies newest first; inside a reply an exact (case/ё-insensitive) label match + wins over a substring match. Returns `(data, label)` or None. Button `data` is dynamic (dose ids), so + scenarios address buttons by label. + """ + needle = _norm(label) + if not needle: + return None + for reply in reversed(list(replies)[-window:] if window > 0 else []): + buttons = [b for row in reply.get("buttons") or [] for b in row if isinstance(b, dict)] + for exact in (True, False): + for b in buttons: + text = _norm(str(b.get("label", ""))) + if (text == needle) if exact else (needle in text): + return str(b.get("data", "")), str(b.get("label", "")) + return None + + +def parse_clock_now(clock: dict[str, Any]) -> datetime: + """`ClockOut.now` (ISO, tz-aware) → datetime; raises ValueError on garbage.""" + raw = clock.get("now") + if not isinstance(raw, str): + raise ValueError("часы API не вернули поле now") + return datetime.fromisoformat(raw.replace("Z", "+00:00")) + + +def target_datetime(now: datetime, hhmm: str, day_date: date | None) -> datetime: + """Moment to move the clock to for `advance_to HH:MM` (see module docstring for the rules).""" + h, m = parse_hhmm(hhmm) + if day_date is None: + cand = now.replace(hour=h, minute=m, second=0, microsecond=0) + if cand < now - TOLERANCE: + cand += timedelta(days=1) + return cand + return datetime.combine(day_date, time(h, m), tzinfo=now.tzinfo) + + +def hours_until(now: datetime, target: datetime) -> float | None: + """Hours to advance (≥ 0), 0 within the tolerance window, None if the target is clearly in the past.""" + delta = target - now + if delta >= timedelta(0): + return delta.total_seconds() / 3600 + if delta >= -TOLERANCE: + return 0.0 + return None + + +def planned_date(dates: dict[int, date], day: int, today: date) -> date | None: + """Calendar date for scenario `day` from already played days, or None to use "next occurrence" rules.""" + if day in dates: + cand = dates[day] + else: + earlier = [d for d in dates if d < day] + if not earlier: + return None + prev = max(earlier) + cand = dates[prev] + timedelta(days=day - prev) + return cand if cand >= today else None + + +# ------------------------------------------------------------------------------------------------ runner +@dataclass +class StepLog: + day: int + index: int # 0-based step index inside the day; -1 for day-level messages + status: StepStatus + message: str + action: Action | None = None + + def render(self) -> str: + icon = {"ok": "✅", "warn": "⚠️", "fail": "❌", "info": "ℹ️"}[self.status] + step = f"шаг {self.index + 1}: " if self.index >= 0 else "" + return f"{icon} день {self.day}, {step}{self.message}" + + +@dataclass +class Resume: + day: int + step: int + until: int + + +@dataclass +class PlaybackState: + """Per-persona playback memory kept in session state; survives an interrupted Streamlit run.""" + + dates: dict[int, date] = field(default_factory=dict) # scenario day → calendar date it was played on + done: list[int] = field(default_factory=list) # fully played days + failed: Resume | None = None # where playback stopped on an error + position: Resume | None = None # step currently being executed (left set if the run was interrupted) + log: list[str] = field(default_factory=list) + + @property + def last_done(self) -> int | None: + return max(self.done) if self.done else None + + def reset(self) -> None: + self.dates.clear() + self.done.clear() + self.failed = None + self.position = None + self.log.clear() + + +class StepFailed(Exception): + pass + + +class PlaybackRunner: + def __init__( + self, + api: DemoApi, + token: str, + chat: ChatLog, + state: PlaybackState, + *, + doctor: str = DEFAULT_DOCTOR, + delay: float = 0.0, + press_retries: int = 3, + retry_wait: float = 0.7, + sleep: Callable[[float], None] = _time.sleep, + ): + self.api = api + self.token = token + self.chat = chat + self.state = state + self.doctor = doctor + self.delay = delay + self.press_retries = press_retries + self.retry_wait = retry_wait + self.sleep = sleep + + # ---------------------------------------------------------------- helpers + def drain(self) -> list[dict[str, Any]]: + """Pull system-initiated replies (reminders, check-ins) into the chat.""" + return self.chat.add_replies(self.api.outbox(self.token)) + + def _now(self) -> datetime: + return parse_clock_now(self.api.clock()) + + @staticmethod + def _preview(replies: list[dict[str, Any]]) -> str: + if not replies: + return "" + text = replies[-1].get("text", "").replace("\n", " ") + more = f" (+{len(replies) - 1})" if len(replies) > 1 else "" + return f" → «{text[:70]}{'…' if len(text) > 70 else ''}»{more}" + + def next_day(self, scenario: Scenario) -> DayScript | None: + last = self.state.last_done + return next((d for d in scenario.days if last is None or d.day > last), None) + + # ---------------------------------------------------------------- one step + def _step(self, day: DayScript, action: Action, day_date: list[date | None]) -> tuple[StepStatus, str]: + if action.kind == "advance_to": + now = self._now() + target = target_datetime(now, action.value, day_date[0]) + if day_date[0] is None: + day_date[0] = target.date() + self.state.dates[day.day] = target.date() + hours = hours_until(now, target) + if hours is None: + return "warn", f"{action.describe()}: время уже прошло ({now:%d.%m %H:%M}), пропускаю" + if hours > 0: + self.api.advance(hours=hours) + got = self.drain() + return "ok", f"{action.describe()} ({target:%d.%m %H:%M}){self._preview(got)}" + + if action.kind == "press": + found = resolve_press(self.chat.recent_replies(PRESS_WINDOW), action.value) + for _ in range(self.press_retries): + if found is not None: + break + # The reply may still be on its way (scheduler tick): wait and drain the outbox again. + self.sleep(self.retry_wait) + self.drain() + found = resolve_press(self.chat.recent_replies(PRESS_WINDOW), action.value) + if found is None: + raise StepFailed(f"кнопка «{action.value}» не найдена в последних {PRESS_WINDOW} сообщениях") + data, label = found + self.chat.add_in(label, pressed=True) + got = self.chat.add_replies(self.api.send_event(self.token, "button", button=data)) + got += self.drain() + return "ok", f"{action.describe()}{self._preview(got)}" + + if action.kind == "text": + self.chat.add_in(action.value) + got = self.chat.add_replies(self.api.send_event(self.token, "text", text=action.value)) + got += self.drain() + return "ok", f"{action.describe()}{self._preview(got)}" + + if action.kind == "start": + params = {"code": action.value} if action.value else {} + self.chat.add_in("/start", pressed=True) + got = self.chat.add_replies(self.api.send_event(self.token, "start", params=params)) + got += self.drain() + return "ok", f"{action.describe()}{self._preview(got)}" + + # expect_color is a soft check: a mismatch is shown to the presenter but never stops the demo. + try: + patients = self.api.patients(self.doctor) + except ApiError as exc: + return "warn", f"{action.describe()}: не удалось проверить ({exc})" + me = next((p for p in patients if isinstance(p, dict) and p.get("token") == self.token), None) + if me is None: + return "warn", f"{action.describe()}: пациента нет в списке врача" + actual = me.get("status_color") + if actual == action.value: + return "ok", f"{action.describe()}: совпало" + return "warn", f"{action.describe()}: у врача сейчас {actual}" + + # ---------------------------------------------------------------- days + def run(self, scenario: Scenario, from_day: int, from_step: int, until_day: int) -> Iterator[StepLog]: + """Play days `from_day..until_day` (those present in the scenario), the first one from `from_step`.""" + days = [d for d in scenario.days if from_day <= d.day <= until_day] + if not days: + yield StepLog(from_day, -1, "info", "в сценарии нет дней для проигрывания") + return + self.state.failed = None + for n, day in enumerate(days): + start = from_step if n == 0 else 0 + try: + today = self._now().date() + except (ApiError, ValueError) as exc: + self._fail(day.day, max(start, 0), until_day) + yield StepLog(day.day, -1, "fail", f"часы API недоступны: {exc}") + return + day_date: list[date | None] = [planned_date(self.state.dates, day.day, today)] + if day_date[0] is not None: + self.state.dates[day.day] = day_date[0] + yield StepLog(day.day, -1, "info", f"начинаю день {day.day}" + (f": {day.note}" if day.note else "")) + for i in range(start, len(day.actions)): + action = day.actions[i] + self.state.position = Resume(day.day, i, until_day) + try: + status, message = self._step(day, action, day_date) + except Exception as exc: # robustness first: any error pauses playback, never crashes the page + self._fail(day.day, i, until_day) + reason = str(exc) or type(exc).__name__ + yield StepLog(day.day, i, "fail", f"{action.describe()}: {reason}", action) + return + yield StepLog(day.day, i, status, message, action) + if self.delay > 0 and i < len(day.actions) - 1: + self.sleep(self.delay) + if day.day not in self.state.dates: + self.state.dates[day.day] = today + if day.day not in self.state.done: + self.state.done.append(day.day) + self.state.position = None + yield StepLog(day.day, -1, "ok", f"день {day.day} проигран") + if self.delay > 0 and n < len(days) - 1: + self.sleep(self.delay) + + def _fail(self, day: int, step: int, until: int) -> None: + self.state.failed = Resume(day, step, until) + self.state.position = None + + def play_day(self, scenario: Scenario, day: int) -> Iterator[StepLog]: + return self.run(scenario, day, 0, day) + + def scroll_to(self, scenario: Scenario, day: int) -> Iterator[StepLog]: + """Play every not yet played day up to and including `day`.""" + nxt = self.next_day(scenario) + if nxt is None or nxt.day > day: + return iter([StepLog(day, -1, "info", f"дни до {day} уже проиграны")]) + return self.run(scenario, nxt.day, 0, day) + + def resume(self, scenario: Scenario, *, skip: bool) -> Iterator[StepLog]: + """Continue after a failure (or an interrupted run): retry the failed step or skip it.""" + point = self.state.failed or self.state.position + if point is None: + return iter([StepLog(0, -1, "info", "нечего продолжать")]) + step = point.step + (1 if skip else 0) + return self.run(scenario, point.day, step, point.until) diff --git a/dashboard/console/playback_ui.py b/dashboard/console/playback_ui.py new file mode 100644 index 0000000..a678125 --- /dev/null +++ b/dashboard/console/playback_ui.py @@ -0,0 +1,106 @@ +"""Scenario playback panel of the demo console (#17): day list, «Проиграть день N», «Прокрутить до дня N».""" + +from __future__ import annotations + +from typing import Any + +import streamlit as st + +from . import widgets as w +from .playback import PlaybackState +from .scenario import Scenario + + +def _forget_upload(token: str) -> None: + st.session_state.pop(f"console_scenario_text:{token}", None) + st.session_state.pop(f"console_up:{token}", None) # resets the uploader widget too + + +def _scenario_source_controls(persona: dict[str, Any], source: str) -> None: + token = persona["token"] + with st.expander(f"Файл сценария: {source}"): + st.text_input( + "Путь в репозитории", + key=f"console_scenario_path:{token}", + placeholder=str(persona.get("scenario") or "data/synthetic/andrey.jsonl"), + ) + upload = st.file_uploader("…или загрузить .jsonl", type=["jsonl", "json", "txt"], key=f"console_up:{token}") + if upload is not None: + try: + st.session_state[f"console_scenario_text:{token}"] = upload.getvalue().decode("utf-8") + except UnicodeDecodeError: + st.error("Файл не в UTF-8") + if st.session_state.get(f"console_scenario_text:{token}"): + st.button("Забыть загруженный файл", on_click=_forget_upload, args=(token,)) + + +def _day_table(scenario: Scenario, state: PlaybackState, next_day: int | None) -> None: + rows = [] + for d in scenario.days: + mark = "✅" if d.day in state.done else ("▶️" if d.day == next_day else "") + rows.append({"": mark, "День": d.day, "Шагов": len(d.actions), "Подсказка ведущему": d.note}) + st.dataframe(rows, hide_index=True, width="stretch", height=min(38 + 35 * len(rows), 330)) + + +def _clear_failure(state: PlaybackState) -> None: + state.failed = None + state.position = None + + +def render_playback_panel(persona: dict[str, Any]) -> None: + token = persona["token"] + scenario, source = w.scenario_for(persona) + state = w.playback_state(token) + st.subheader("🎬 Сценарий демо") + _scenario_source_controls(persona, source) + if scenario.errors: + with st.expander(f"⚠️ Замечания к сценарию: {len(scenario.errors)}", expanded=not scenario.days): + for err in scenario.errors: + st.write(f"- {err}") + if not scenario.days: + st.info("Сценарий не загружен. Демо можно вести вручную: чат слева и кнопки часов сверху.") + return + + delay = st.slider("Пауза между шагами, с", 0.0, 3.0, 1.0, 0.25, key=f"console_delay:{token}") + runner = w.make_runner(persona, delay=delay) + nxt = runner.next_day(scenario) + _day_table(scenario, state, nxt.day if nxt else None) + + numbers = scenario.day_numbers + default = numbers.index(nxt.day) if nxt else len(numbers) - 1 + day = st.selectbox("День", numbers, index=default, key=f"console_day:{token}", format_func=lambda n: f"день {n}") + c1, c2, c3 = st.columns(3) + action: tuple[str, Any] | None = None + if c1.button(f"▶ Проиграть день {day}", type="primary", width="stretch"): + action = ("Проигрываю день", runner.play_day(scenario, day)) + if c2.button(f"⏭ Прокрутить до дня {day}", width="stretch", help="Быстро, без пауз: все непроигранные дни"): + runner.delay = 0.0 + action = (f"Прокрутка до дня {day}", runner.scroll_to(scenario, day)) + if c3.button( + f"▶ Следующий: день {nxt.day}" if nxt else "Все дни проиграны", + width="stretch", + disabled=nxt is None, + ): + assert nxt is not None + action = ("Проигрываю день", runner.play_day(scenario, nxt.day)) + + point = state.failed or state.position + if point is not None and action is None: + what = "остановлено на ошибке" if state.failed else "прервано" + st.warning(f"Проигрывание {what}: день {point.day}, шаг {point.step + 1}.") + r1, r2, r3 = st.columns(3) + if r1.button("🔁 Повторить шаг", width="stretch"): + action = ("Продолжаю", runner.resume(scenario, skip=False)) + if r2.button("⏩ Пропустить шаг", width="stretch"): + action = ("Продолжаю", runner.resume(scenario, skip=True)) + r3.button("✋ Продолжить вручную", width="stretch", on_click=_clear_failure, args=(state,)) + + if action is not None: + title, logs = action + w.execute(logs, state, title=title) + st.session_state["console_log_open"] = True + st.rerun() # refresh the chat and the clock with what playback produced + + if state.log: + with st.expander("Журнал последнего проигрывания", expanded=st.session_state.pop("console_log_open", False)): + st.text("\n".join(state.log[-80:])) diff --git a/dashboard/console/scenario.py b/dashboard/console/scenario.py new file mode 100644 index 0000000..454c59c --- /dev/null +++ b/dashboard/console/scenario.py @@ -0,0 +1,163 @@ +"""Demo scenario files `data/synthetic/.jsonl` (format agreed with #18). + +One JSON object per line: `{"day": , "note": "", "actions": [...]}` where an action +is exactly one of `{"advance_to": "HH:MM"}`, `{"press": "