feat(llm): RAG knowledge base over clinical guidelines with cited extractive answers
CI / ruff · pytest (pull_request) Successful in 1m30s

- docs/source/rag: PDF sources (KR PTSD 2023, KR depression 2024, meds module spec),
  pdf->txt converter (tables as markdown, page markers, hand-transcribed figures),
  word-level check against pdftotext; fixes HAM-D/MADRS fake headers and split rows
- config/rag: corpus txt + sources.yaml (documents, audiences, retrieval params, synonyms)
- app/llm/rag: Snowball stemmer, section-aware chunker with page citations,
  BM25 + synonym expansion + densest-passage reranking, extractive answer()
  with coverage gate, patient dose/prescribing filter, optional LLM rephrasing
  rejected when it adds content; CLI python -m app.llm.rag
- tests: gold retrieval set (hit@3 >= 15/18), off-topic, safety, faithfulness
- ADR-011

Refs #60

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 04:11:06 +03:00
co-authored by Claude Opus 5.5
parent 66986255e2
commit 6a77831603
26 changed files with 7776 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
"""Сверка txt с PDF: какие слова из pdftotext не попали в txt (с учётом количества)."""
import re
import subprocess
import sys
from collections import Counter
from pathlib import Path
WORD = re.compile(r"[\wё]+", re.I)
def words(text: str) -> Counter[str]:
text = re.sub(r"(\w)-\s*\n\s*(\w)", r"\1\2", text) # переносы
return Counter(w.lower() for w in WORD.findall(text))
args = sys.argv[1:]
out = None
if "--out" in args:
i = args.index("--out")
out = Path(args[i + 1])
del args[i : i + 2]
for arg in args:
pdf = Path(arg)
ref = words(subprocess.run(["pdftotext", str(pdf), "-"], capture_output=True, text=True).stdout)
got = words(((out / pdf.name) if out else pdf).with_suffix(".txt").read_text())
lost = ref - got
total = sum(ref.values())
print(f"{pdf.name}: слов {total}, потеряно {sum(lost.values())} ({sum(lost.values()) / total:.2%})")
print(" ", lost.most_common(40))
@@ -0,0 +1,5 @@
Рисунок 1 «Схема течения ДЭ и РДР и этапов терапии» (модель D.J. Kupfer, 1991). Описание графика:
- Горизонтальная пунктирная линия делит уровни «Симптомы» (выше) и «Синдром» (ниже). Кривая состояния опускается ниже линии — «Прогрессирование заболевания» до уровня синдрома.
- «Эффективное лечение» (стрелка) — кривая поднимается обратно выше линии и выходит на плато: точка «Ремиссия».
- После ремиссии на плато отмечена точка «Обострение» (пунктирные ветви вниз — возможный возврат симптомов), далее точка «Выздоровление», затем точка «Рецидив» (пунктирная ветвь вниз).
- Подписи этапов терапии под графиком: «Купирующая» (от начала эффективного лечения до ремиссии), «Стабилизирующая» (от ремиссии до выздоровления; в этот период возможно обострение), «Противорецидивная» (после выздоровления; в этот период возможен рецидив).
@@ -0,0 +1,62 @@
Б1. Блок-схема «Этапы ведения пациента (взрослые) с ДР или РДР». Пошаговая запись. Ссылки «Стр. N, раздел X» стоят на схеме мелким шрифтом рядом с блоками и указывают на страницы исходного документа. Текст блоков сверен с официальной версией КР с рубрикатора Минздрава (КР 301, 2024 г.).
1. «Пациент с подозрением на ДЭ или РДР» → шаг 2.
2. «Диагностика пациента с подозрением на ДЭ или РДР (подсхема Б.2)» (Стр. 17, раздел 2.1–2.5) → вопрос 3.
3. «Установлен ли диагноз ДЭ или РДР?»
- Нет → «Диагностика другого психического расстройства».
- Да → шаг 4.
4. «Купирующая терапия (подсхема Б.3 или Б.4)» (Стр. 21, раздел 3.1–3.4) → вопрос 5.
5. «Достигнута ли ремиссия?»
- Нет → возврат к шагу 4 (купирующая терапия).
- Да → «Стабилизирующая терапия» (Стр. 40, раздел 3.5) → вопрос 6.
6. «Сохраняется ли ремиссия не менее 6 месяцев?»
- Нет → возврат к шагу 4 (купирующая терапия).
- Да → вопрос 7.
7. «Необходима ли противорецидивная терапия?»
- Нет → «Окончание терапии» (Стр. 44, раздел 3.6).
- Да → «Противорецидивная терапия, психообразование» (Стр. 41, раздел 3.6) → вопрос 8.
8. «Возник ли рецидив ДЭ?»
- Да → возврат к шагу 4 (купирующая терапия).
- Нет → «Окончание терапии».
Б2. Подсхема «Диагностика пациента (взрослые) с ДР или РДР» (нижняя схема на стр. 52). На этой странице схема в низком разрешении, поэтому текст блоков сверен с той же схемой в официальной версии КР с рубрикатора Минздрава (КР 301, 2024 г.). Пошаговая запись:
1. «Диагностика пациента с подозрением на ДЭ или РДР» → «Провести психиатрическое и медицинское обследование, определить клинический статус пациента» → вопрос 2.
2. «Соответствует ли состояние пациента общим критериям ДЭ?»
- Нет → «Перейти к клиническим рекомендациям, наиболее соответствующим состоянию пациента».
- Да → вопрос 3.
3. «Имеются ли сведения о маниакальном, гипоманиакальном или смешанном эпизоде в анамнезе?»
- Да → «Перейти к клиническим рекомендациям "Биполярное аффективное расстройство (F31)"».
- Нет → вопрос 4.
4. «Является ли текущий эпизод повторным?»
- Нет → «Диагностировать ДЭ (F32.-)» → вопрос 5.
- Да → «Диагностировать РДР, текущий эпизод (F33.-)» → вопрос 5.
5. «Присутствуют ли в состоянии пациента психотические симптомы?»
- Да → «Диагностировать ДЭ тяжелой степени с психотическими симптомами (F3x.3)» → вопрос 9.
- Нет → вопрос 6.
6. «Соответствует ли состояние пациента критериям ДЭ тяжелой степени без психотических симптомов?»
- Да → «Диагностировать ДЭ тяжелой степени без психотических симптомов (F3x.2)» → вопрос 10.
- Нет → вопрос 7.
7. «Соответствует ли состояние пациента критериям ДЭ средней степени?»
- Да → «Диагностировать ДЭ средней степени (F3x.1)» → вопрос 10.
- Нет → вопрос 8.
8. «Соответствует ли состояние пациента критериям ДЭ легкой степени?»
- Да → «Диагностировать ДЭ легкой степени (F3x.0)» → вопрос 10.
9. (ДЭ с психотическими симптомами) «Может ли дать и дает ли пациент согласие на госпитализацию?»
- Да → «Проводить купирующую терапию ДЭ тяжелой степени с психотическими симптомами в условиях стационара».
- Нет → «Принять решение о недобровольной госпитализации» → «Проводить купирующую терапию ДЭ тяжелой степени с психотическими симптомами в условиях стационара».
10. (ДЭ тяжелой степени без психотических симптомов, средней или легкой степени) «Существует ли суицидальный риск?»
- Да → вопрос 11.
- Нет → см. примечание ниже.
11. «Нуждается ли пациент в стационарном лечении?»
- Нет → «Проводить купирующую терапию ДЭ в амбулаторных условиях».
- Да → вопрос 12.
12. «Может ли дать и дает ли пациент согласие на госпитализацию?»
- Да → «Проводить купирующую терапию ДЭ без психотических симптомов в условиях стационара».
- Нет → вопрос 13.
13. «Есть ли показания для недобровольной госпитализации?»
- Да → «Принять решение о недобровольной госпитализации» → «Проводить купирующую терапию ДЭ без психотических симптомов в условиях стационара».
- Нет → см. примечание ниже.
Примечание: на схеме линии «Нет» от вопроса 10 («Существует ли суицидальный риск?») и от вопроса 13 («Есть ли показания для недобровольной госпитализации?») соединены друг с другом. Стрелкой ни к одному блоку они не доведены, так что куда они ведут, по схеме не определить.
@@ -0,0 +1,101 @@
Б3. Подсхема «Купирующая терапия ДЭ без психотических симптомов» (верхняя схема на стр. 53). На этой странице схема в низком разрешении, поэтому текст блоков сверен с той же схемой в официальной версии КР с рубрикатора Минздрава (cr.minzdrav.gov.ru, КР 301, 2024 г.), где она векторная. Пошаговая запись:
ЭТАП I
1. «Пациент с диагностированным ДЭ без психотических симптомов (единичный либо в рамках РДР)» → шаг 2.
2. «Назначить СИОЗС (A1), СИОЗСН (A1), другие АД нового поколения (A1), ТЦА (A1) в течение 3-4 недель. Принять решение о назначении психотерапии (A1)» → вопрос 3.
3. «Есть ли клинический эффект и хорошая переносимость?»
- Да → вопрос 4.
- Нет → «Назначить АД с другим механизмом действия (A1)» → этап II, вопрос 5.
4. «Достаточен ли клинический эффект?»
- Да → «Продолжить терапию до достижения ремиссии».
- Нет → «Повысить дозу до максимальной и продолжить терапию еще 3-4 недели (A1)» → этап II, вопрос 5.
ЭТАП II
5. «Есть ли клинический эффект и хорошая переносимость?»
- Да → «Продолжить терапию до достижения ремиссии».
- Нет → шаг 6.
6. «Уточнить диагноз, диагностировать соматическую патологию, коморбидные психические расстройства, органическое поражение ЦНС, неразрешенные стрессовые факторы, выявить некомплаентность» → вопрос 7. (От этого блока на схеме идёт и линия обратно к вопросу 5.)
7. «Обнаружена ли какая-либо причина?»
- Да → «Принять соответствующие меры» → возврат к шагу 6.
- Нет → этап III, шаг 8.
ЭТАП III
8. «Регистрировать относительную резистентность и перейти к любой из опций III этапа». Опции проверяются по цепочке:
8а. «Присоединен ли к АД #лития карбонат (B1), или #ламотриджин (A1), или #карбамазепин (B2)?» Да → вопрос 9; Нет → 8б.
8б. «Присоединен ли к АД арипипразол, или кветиапин**, или оланзапин**, или #рисперидон**, или #карипразин**, или #брекспипразол, или #зипрасидон, или #амисульприд (B1)?» Да → вопрос 9; Нет → 8в.
8в. «Присоединен ли к АД сульпирид** или флупентиксол** (C4)?» Да → вопрос 9; Нет → возврат к шагу 8.
9. «Есть ли клинический эффект и хорошая переносимость?»
- Да → «Продолжить терапию до достижения ремиссии».
- Нет → шаг 10.
10. «Верифицировать диагноз, исключить сопутствующую патологию и лекарственные взаимодействия» → вопрос 11. (От этого блока на схеме идёт и линия обратно к вопросу 9.)
11. «Обнаружена ли какая-либо причина?»
- Да → «Принять соответствующие меры» → возврат к шагу 10.
- Нет → этап IV, шаг 12.
ЭТАП IV
12. «Перейти к любой из опций IV этапа». Опции проверяются по цепочке, каждая «Нет» ведёт к следующей:
12а. «Использованы ли другие варианты комбинированной терапии?¹»
12б. «Назначены ли другие лекарственные методы аугментации АД?²»
12в. «Назначена ли ЭСТ (B2)?»
12г. «Назначены ли другие методы электростимуляции мозга?³»
12д. «Назначена ли психотерапия (A1)?»
12е. «Назначены ли другие нелекарственные методы?⁴»
12ж. «Проведена ли одномоментная отмена терапии (C5)?» — Нет → возврат к шагу 12.
Любое «Да» → этап V, вопрос 13.
ЭТАП V
13. «Есть ли клинический эффект?»
- Да → «Продолжить терапию до достижения ремиссии».
- Нет → «Регистрировать абсолютную резистентность» → возврат к шагу 12 (опции IV этапа).
Сноски к схеме:
¹ сочетания двух АД (A1), АД с АВП (B1), с АПП (C4).
² # лития карбонат (B1), #ламотриджин (A1), АД + #карбамазепин** (B2), #буспирон (B2).
³ ТМС (A1), МКТ (C4), ТПЭСМ (B2), СБН (A2), ГСМ (B3).
⁴ светотерапия (B1), депривация сна (B2), физические упражнения (B1), рефлексотерапия (C2), РДТ (C5), ВЛОК (C5), ультрафиолетовое облучение крови (C5), нормобарическая гипоксия (C5), тренировка с биологической обратной связью (B2), плазмаферез (C5), ксенонотерапия (C5).
Б4. Подсхема «Купирующая терапия ДЭ со смешанными чертами» (средняя схема на стр. 53; текст сверен с официальной версией КР с рубрикатора Минздрава). Пошаговая запись:
1. «Диагностирован депрессивный эпизод с симптомами субсиндромальной гипомании (депрессивный эпизод со смешанными чертами)» → вопрос 2.
2. «Получает ли пациент АД?»
- Да → вопрос 3.
- Нет → ветка Б (шаг 8).
Ветка А (пациент получает АД):
3. «АД эффективен?»
- Да → вопрос 4.
- Нет → шаг 5.
4. «Усиливает ли АД симптомы гипомании?»
- Да → шаг 5.
- Нет → ИТОГ.
5. «Отменить АД (C4), назначить #зипрасидон (B2), или #луразидон (A2), или #карипразин** (B2)» → «Есть эффект?»
- Да → ИТОГ.
- Нет → шаг 6.
6. «Добавить или перевести на нормотимик (B2) или сменить АВП (#зипрасидон (B2), #луразидон (A2), кветиапин** (B2), #карипразин** (B2), оланзапин** (B2))» → «Есть эффект?»
- Да → ИТОГ.
- Нет → «ЭСТ (C4)».
Ветка Б (пациент не получает АД):
8. «Назначить #зипрасидон (B2), или #луразидон (A2), или #карипразин** (B2)» → «Есть эффект?» Да → ИТОГ; Нет → шаг 9.
9. «Сменить АВП первой линии или назначить оланзапин** (B2)» → «Есть эффект?» Да → ИТОГ; Нет → шаг 10.
10. «Добавить или перевести на нормотимик (B2)» → «Есть эффект?» Да → ИТОГ; Нет → шаг 11.
11. «Добавить АД или использовать оланзапин**+флуоксетин** (C5)» → «Есть эффект?» Да → ИТОГ; Нет → «ЭСТ (C4)».
ИТОГ: «Эффективную терапию продолжать до достижения ремиссии и переходить к этапу поддерживающей терапии (C4)».
Приложение Б5. «Алгоритм профилактики и ведения пациентов с синдромом отмены приема антидепрессантов (СОПА)» (нижняя схема на стр. 53; текст сверен с официальной версией КР с рубрикатора Минздрава). Пошаговая запись:
Боковые пометки на схеме: «Проинформировать пациента о СОПА до лечения АД» (до начала лечения) и «Повторно проинформировать пациента о СОПА при отмене АД» (на этапе отмены).
1. «Отмена АД» → вопрос 2.
2. «Пациент принимает высокие дозы АД / испытывал симптомы СОПА после пропуска приема АД / испытывал симптомы СОПА в прошлом при отмене АД / принимает АД с высоким риском развития СОПА»
- да → «Высокий риск СОПА» → «Максимально медленное снижение дозы АД» → шаг 3.
- нет → «Постепенное снижение дозы АД» → шаг 3.
3. «Симптомы СОПА» — по выраженности:
- «Легкая выраженность симптомов» → «Необходимо успокоить пациента, объяснив временный характер симптомов».
- «Умеренная выраженность симптомов» → «Симптоматическая терапия».
- «Тяжелая выраженность симптомов» → два варианта:
а) «При отмене СИОЗС или СИОЗСН можно перейти на флуоксетин; при отмене АД из других групп можно перейти на фармакодинамически сходный АД с более длительным периодом полувыведения с прекращением приема исходного АД после нескольких недель лечения новым АД и постепенной отменой последнего в последующем»;
б) «Можно возобновить АД в минимально эффективной дозе или в той дозе, при которой не было симптомов отмены, после чего отменить его более дробным и медленным титрованием».
+30
View File
@@ -0,0 +1,30 @@
Блок-схема «Алгоритм ведения пациента с посттравматическим стрессовым расстройством» (Приложение Б). Пошаговая запись:
1. Точка входа А: «Обращение к врачу общей практики, медицинскому психологу» → вопрос 2.
2. «Было в течение последних шести месяцев выраженное травматическое событие?»
- нет → «Дальнейший диагностический поиск».
- да → вопрос 3.
3. «Имеются предположительные данные за ПТСР или сомнения в оценке состояния?»
- нет → «Дальнейший диагностический поиск».
- да → «Обращение к врачу-психотерапевту, врачу-психиатру» (шаг 4).
4. Точка входа Б: «Обращение к врачу-психотерапевту, врачу-психиатру» → вопрос 5.
5. «Имеются диагностические критерии ПТСР?»
- нет → «Дальнейший диагностический поиск».
- да → «Диагноз ПТСР» → шаг 6.
6. «Оценка суицидального риска, тяжести состояния, коморбидной патологии, наличия социальной поддержки» → вопрос 7.
7. «Имеются показания для госпитализации (суицидальный риск, наличие выраженной психической или соматической патологии, отсутствие социальной поддержки)?»
- нет → «Оказание помощи в первичном звене здравоохранения (психотерапевтический кабинет, ПНД, психотерапевтический центр и др.) амбулаторно или в условиях дневного стационара» → «Проведение курса лечения ПТСР (психотерапия и фармакотерапия)» → «Оценка состояния» (шаг 10).
- да → вопрос 8.
8. «Есть актуальная соматическая патология, требующая неотложной госпитализации в соматический стационар?»
- да → «Госпитализация и лечение в соматическом стационаре» → «Оценка состояния» (шаг 10).
- нет → «Госпитализация и лечение в психиатрическом стационаре» → вопрос 9.
9. «Есть показания для помещения в психиатрическое отделение с интенсивным наблюдением (суицидальный риск, психотическая симптоматика)?»
- нет → «Госпитализация и лечение в отделении пограничных состояний (отделение неврозов)» → «Оценка состояния» (шаг 10).
- да → «Госпитализация и лечение в отделении интенсивного наблюдения» → «Оценка состояния» (шаг 10).
10. «Оценка состояния» → вопрос 11.
11. «Есть необходимость продолжения лечения?»
- да → возврат к шагу 6 («Оценка суицидального риска, тяжести состояния, коморбидной патологии, наличия социальной поддержки»).
- нет → вопрос 12.
12. «Есть необходимость проведения реабилитационных мероприятий?»
- нет → «Завершение лечения».
- да → «Направление на реабилитацию».
+18
View File
@@ -0,0 +1,18 @@
Блок-схема «Алгоритм терапии ПТСР» (Приложение Б1). Пошаговая запись. Цифры после названий (1, 2,3,4,5,6, 8, 9, 10, 11) стоят на схеме как сноски; их расшифровки на странице нет. Написание «велафаксином» сохранено как в оригинале (имеется в виду венлафаксин).
1. «ДИАГНОЗ ПТСР» → вопрос 2.
2. «Психотерапевтическое лечение возможно и целесообразно? 1»
- да → «Проведение психотерапии» → вопрос 3.
- нет → «Монотерапия сертралином/пароксетином/флуоксетином/велафаксином 2,3,4,5,6» → вопрос 3.
3. «Лечение эффективно?»
- да → «Поддерживающее лечение, реабилитационные мероприятия».
- нет → «5,6» → «Смена антидепрессанта на другой из 1-й линии / Совместное применение психотерапии и психофармакотерапии 8» → вопрос 4.
4. «Лечение эффективно?»
- да → «Поддерживающее лечение, реабилитационные мероприятия».
- нет → «Присоединение к терапии рисперидона, кветиапина, оланзапина 9» → вопрос 5.
5. «Лечение эффективно?»
- да → «Поддерживающее лечение, реабилитационные мероприятия».
- нет → «Замена антидепрессанта на амитриптилин, имипрамин, миртазапин 10» → вопрос 6.
6. «Лечение эффективно?»
- да → «Поддерживающее лечение, реабилитационные мероприятия».
- нет → «Переход к стратегиям преодоления резистентности 11».
Binary file not shown.
Binary file not shown.
+184
View File
@@ -0,0 +1,184 @@
# Модуль «Лекарства»: спецификация для разработки
Источник: meds_module_spec.pdf, страниц: 10. Документ команды от Oct 4, 2026, автор @Andrey. Текст перенесён вручную и сверен с PDF: автоматическое извлечение ломает вёрстку выносок и таблиц. Символ «|» внутри ячеек заменён на «/».
--- стр. 1 ---
Модуль следит не за абстрактными «лекарствами», а за конкретным препаратом пациента. Он напоминает о приёме, узнаёт причины пропусков, спрашивает о побочных эффектах этого препарата и работает по 10 правилам (M1–M10). Данные взяты из двух клинических рекомендаций Минздрава: по депрессии (2024) и по ПТСР (2023).
Что передаём:
- этот документ — логика модуля;
- файл meds_reference.json (прилагается к ссылке) — справочник на 47 препаратов, тексты для пациента и параметры правил;
- вкладка «Справочник препаратов» — тот же справочник в виде таблицы, для проверки медиком.
Как модуль встраивается в этап 2. Вечерний вопрос о лекарствах, опрос о побочных эффектах на 7-й, 14-й и 28-й день и флаги «меньше 80% дней» и «пропуск 3 дня подряд» не меняются. Модуль добавляет к ним данные о препарате и новые правила.
| В MVP | После хакатона |
|---|---|
| Справочник из JSON | Тексты инструкций из ГРЛС в карточке препарата |
| Назначение: ввод в кабинете врача или из имитации ЕМИАС | Назначения из ЕМИАС (электронные рецепты) |
| Напоминание о приёме и отметка в дневнике | Схема постепенной отмены препарата |
| Опрос о побочных эффектах по профилю препарата | Ввод результатов анализов |
| Правила M1–M10 и блок в сводке врачу | Проверка взаимодействий для врача |
--- стр. 2 ---
Границы: бот следит, но не лечит
Препарат и дозу всегда выбирает врач. Если приложение начнёт давать советы о лечении, его могут счесть медицинским изделием, а к ним требования строже.
| Бот делает | Бот не делает никогда |
|---|---|
| Хранит назначение врача: препарат, дозу, время приёма | Не предлагает препарат, дозу, смену или отмену |
| Напоминает о приёме и отмечает, принят ли препарат | На вопросы «можно ли бросить» или «можно ли увеличить дозу» отвечает только «обсудите с врачом» и ставит флаг |
| Спрашивает о побочных эффектах, характерных для этого препарата | Не исправляет и не блокирует дозу вне диапазона — только просит проверить ввод (M10) |
| Показывает карточку препарата из проверенных источников | Не проверяет для пациента взаимодействия лекарств |
| Сигналит врачу по правилам M1–M10 | Не ставит диагноз и не оценивает, «работает ли» лечение, — это делает врач по шкалам |
Модель данных
Шесть сущностей. Drug читается из JSON и в приложении не редактируется, остальные создаёт приложение.
| Сущность | Поля | Откуда берётся |
|---|---|---|
| Drug | id, name_ru, class, indications.{depression/ptsd}.{min_mg, max_mg, start_mg, off_label}, diary_items[], red_flags[], monitoring[], withdrawal_risk, overdose_danger, max_course_days | meds_reference.json |
--- стр. 3 ---
| Сущность | Поля | Откуда берётся |
|---|---|---|
| Prescription | id, patient_id, drug_id, indication (F32/F33/F43.1), dose_mg_per_day, times[] (например, "09:00"), start_date, source (emias / doctor / patient), status (active / stopped), history[] (дата, старая и новая доза) | имитация ЕМИАС или кабинет врача; ввод пациентом — с пометкой «со слов пациента» |
| DoseLog | prescription_id, date, taken (bool), reason (forgot / side_effects / decided_not_to / ran_out), logged_at | вечерний дневник |
| SideEffectReport | date, trigger (scheduled / missed_dose / button), fibser.{freq, intensity, burden}, items[]: {item_id, severity: mild/severe}, red_flags[] | опрос о побочных эффектах |
| MedEvent | prescription_id, type (start / dose_change / stop), date, baseline_scale_ids[] | создаётся при изменении Prescription |
| MedFlag | rule_id (M1…M10), patient_id, prescription_id, created_at, recipient, due_at, status (open / acknowledged), evidence (ответы, по которым сработало правило) | движок правил |
Связь с расписанием этапа 2. Любой MedEvent включает строку «Начало или смена лекарств»: полные шкалы и опрос о побочных эффектах на 7-й, 14-й, 28-й день, далее раз в 4 недели. Исходный замер для M5 — последняя полная шкала за 14 дней до события. Если её нет, шкалу проводят в день события.
Дневник и опросы
В обычный день добавляется только отметка по каждому препарату. Подробно спрашиваем по расписанию или после пропуска.
--- стр. 4 ---
1. Напоминание. Push-уведомление во время из Prescription.times: «Время принять {препарат} {доза}» и кнопка «Принял(а)». Нажатие сразу создаёт DoseLog с taken = true, и вечером этот препарат уже не спрашиваем.
2. Вечерний вопрос (уже есть в ядре этапа 2), теперь по каждому препарату:
1. «Приняли ли Вы сегодня лекарства, назначенные врачом?» — да, все / не все / нет.
2. Если «не все» — выбрать, какие не приняты.
3. По каждому непринятому — причина: забыл(а) · побочные эффекты · решил(а) не принимать · закончились.
Причина напрямую запускает правила: «побочные эффекты» → внеплановый опрос (п. 3) и M8, «решил(а) не принимать» → M2, «закончились» → M3.
3. Опрос о побочных эффектах. Проводится в трёх случаях: по расписанию этапа 2 (7-й, 14-й, 28-й день, далее раз в 4 недели), после пропуска «из-за побочных эффектов» и по кнопке «Сообщить о побочном эффекте», которая доступна всегда.
1. Три вопроса FIBSER: как часто, насколько сильно, насколько мешают жить (из этапа 2).
2. Если частота > 0 — «Что именно беспокоит?»: список из diary_items всех активных препаратов без повторов, плюс «другое» с текстом. У каждого пункта два ответа: немного / сильно мешает.
3. Вопросы о срочных симптомах — только из red_flags активных препаратов, с ответом да / нет. Ответ «да» → M7.
Список пунктов для препарата формируется так. Для 16 антидепрессантов берём эффекты с оценкой «++» и «+++» из таблицы 3 КР по депрессии. Для остальных препаратов — эффекты, которые названы в тексте КР. В JSON список уже собран: например, у сертралина это тошнота, бессонница, сексуальные проблемы и головная боль.
4. Карточки о сроках эффекта. Текст expect_effect_depression показываем в 1-й и 14-й день после начала, expect_effect_ptsd — в 1-й и 7-й. Тексты лежат в JSON, в разделе texts.
--- стр. 5 ---
Контрольные точки после начала лечения
[Изображение: текст перенесён вручную]
Схема «Ответ оценивают к 4–6 неделе, лечение длится месяцы». Подзаголовок: «Фазы после начала или смены препарата: что делает модуль и что говорят КР». Таблица по фазам:
| Строка | Недели 1–2 | Недели 3–6 | Месяцы и годы |
|---|---|---|---|
| Модуль (что делает бот) | Дни 7 и 14: шкалы и опрос о побочных. Карточка «когда ждать эффекта» | День 28: шкалы, побочные и проверка ответа — M5. Бензодиазепин дольше допустимого — M4 | Шкалы и опрос о побочных раз в 4 недели до конца курса |
| Депрессия (КР 2024) | Раннее улучшение (1–2 недели) — благоприятный признак | Эффект через 3–4 недели, у СИОЗС — до 6–8 недель; снижение на 25–50% — ответ недостаточен | Тот же препарат и доза 4–6 мес. после улучшения; при РДР — 3–5 лет |
| ПТСР (КР 2023) | СИОЗС: в 1-ю неделю тревога может усилиться; следить за суицидальным риском | Эффект оценивают через 4–6 недель; бензодиазепины — не дольше 3–4 недель | Курс 6–12 месяцев после стабилизации |
Подпись под схемой: «Фазы после начала лечения · модуль, депрессия, ПТСР».
M5 проверяет ответ на 28-й день при обоих диагнозах. Для ПТСР это ранняя граница окна из КР: окончательно эффект оценивают до 6-й недели, поэтому флаг — повод для врача посмотреть, а не сигнал тревоги.
Правила сигналов M1–M10
К флагам этапа 2 по лекарствам (меньше 80% дней с приёмом, пропуск 3 дня подряд, тяжёлый день алкоголя при приёме лекарств) добавляются десять правил. Пороги и сроки хранятся в rules[].params в JSON и меняются без правки кода. Строки с пометкой «черновик» нужно согласовать с психиатром.
--- стр. 6 ---
| ID | Условие | Что делает приложение | Кто получает, срок | Основание |
|---|---|---|---|---|
| M1 | У препарата withdrawal_risk = high (венлафаксин, пароксетин, ТЦА, бензодиазепины) и он пропущен 2 дня подряд | Текст withdrawal_missed и вопрос «Стало ли хуже после пропуска?» | Лечащий психиатр: ответ «хуже» — 24 ч, иначе — до 3 рабочих дней. Черновик | КР депрессия 3.6; КР ПТСР 3.2 |
| M2 | Причина пропуска «решил(а) не принимать» | Тексты dont_stop_alone (+ why_continue_depression при F32/F33), флаг | Лечащий психиатр, до 3 рабочих дней. Черновик | КР депрессия, прил. В; КР ПТСР 6.1 |
| M3 | Причина «закончились» | Текст ran_out; 2 дня подряд → флаг | Лечащий врач, до 3 рабочих дней. Черновик | команда |
| M4 | Бензодиазепин принимается дольше max_course_days | Отметка в кабинете и сводке | Лечащий психиатр, к следующему визиту | КР ПТСР 3.2 |
| M5 | 28-й день после MedEvent: PHQ-9 снизился меньше чем на 50% (F32/F33) или PCL-5 — меньше чем на 10 баллов (F43.1) от исходного | Флаг «нет ответа к 4-й неделе» | Лечащий психиатр, до 3 рабочих дней. Черновик | КР депрессия 3.1–3.2; КР ПТСР 3.2; пороги ответа — этап 2 |
--- стр. 7 ---
| ID | Условие | Что делает приложение | Кто получает, срок | Основание |
|---|---|---|---|---|
| M6 | Сработал сигнал C-SSRS (умеренный или высокий риск), а у пациента активен препарат с overdose_danger | Пометка «препарат опасен при передозировке» в этом же сигнале, новый сигнал не создаётся | Как у сигнала C-SSRS | КР депрессия 3.1.3 |
| M7 | Пациент отметил срочный симптом из red_flags | Текст red_flag (врач сегодня; при тяжёлом состоянии 103/112), флаг | Лечащий психиатр, 24 ч. Черновик | КР депрессия 3.1, 3.3; КР ПТСР 3.2 |
| M8 | FIBSER «насколько мешают» ≥ 5 из 6 или пропуск «из-за побочных» в 2 из 7 дней | Текст side_effects_general, флаг | Лечащий психиатр, до 3 рабочих дней. Черновик | КР депрессия, прил. В |
| M9 | У активного препарата есть monitoring | Строка «Контроль по КР: …» в сводке врачу, это не сигнал | Лечащий врач, в сводке | КР депрессия 3.1, 3.3; КР ПТСР 3.2 |
| M10 | При сохранении назначения доза вне min_mg – max_mg для этого диагноза | «Проверьте ввод» тому, кто вводит; пометка в сводке; сохранение не блокируется | Тот, кто вводит, сразу | Приложения А3 обеих КР |
Все флаги попадают в кабинет врача с датой, правилом и ответами, по которым правило сработало, как в этапе 2.
Карточка препарата, сводка врачу, демо
Карточка препарата построена по списку «что сообщить пациенту» из КР по депрессии (приложение В). Поля с источником «инструкция» в MVP показывают «Уточните у врача или в инструкции». Для 2–3 демо-препаратов их можно заполнить вручную.
--- стр. 8 ---
| Поле | Источник | Пример: сертралин при ПТСР |
|---|---|---|
| Название, доза, время приёма | Prescription | Сертралин, 50 мг в сутки, в 09:00 |
| Когда ждать эффекта | indications.*.expected_response | Эффект оценивают через 4–6 недель |
| Сколько принимать | indications.*.course + решение врача | Обычно 6–12 месяцев после стабилизации |
| Частые побочные эффекты | diary_items | Тошнота, бессонница, сексуальные проблемы, головная боль |
| Когда срочно к врачу | red_flags | Необычные кровотечения или синяки |
| Не прекращать самостоятельно | texts.dont_stop_alone | общий текст |
| Как принимать, связь с едой, что делать при пропуске | инструкция (ГРЛС) | — |
| Алкоголь, вождение, беременность | инструкция (ГРЛС) | — |
Блок «Лекарства» в сводке врачу (дополняет сводку этапа 2), по каждому препарату:
- препарат, доза, дата начала и неделя приёма рядом с ожидаемым сроком ответа (например, «неделя 3 из 4–6»);
- доля дней с приёмом за 4 недели и причины пропусков;
- побочные эффекты: FIBSER и пункты «что именно» по замерам, срочные симптомы;
- ответ на 28-й день (M5), контроль по КР (M9), пометки M4 и M10.
Демо: ветеран Иван, ПТСР. Этот сценарий стыкуется с демо этапа 2: там ухудшение на 3-й неделе, здесь видна его причина.
--- стр. 9 ---
1. День 0. Врач в кабинете назначает сертралин 50 мг в сутки (препарат 1-й линии по КР ПТСР). Включается расписание «Начало или смена лекарств», пациент получает карточку expect_effect_ptsd.
2. День 3. Отметка «не принял — побочные эффекты». Внеплановый опрос: тошнота «немного», бессонница «сильно мешает».
3. День 7. Плановые шкалы и опрос о побочных эффектах.
4. Дни 17–19. Три дня подряд «решил не принимать». На 17-й день срабатывает M2 (карточка и флаг врачу), на 19-й — флаг этапа 2 «пропуск 3 дня».
5. Неделя 3. Ухудшение по дневнику → внеплановый PCL-5 → повторный триаж (сценарий этапа 2). В сводке врач видит, что ухудшение совпало с прекращением приёма из-за бессонницы.
Для питча: КР по ПТСР (раздел 3.2) прямо рекомендуют для соблюдения режима «ведение дневника приёма лекарств или телефонное приложение с напоминанием». В разделе 6.1 сказано, что пациенты часто бросают лекарства после улучшения и состояние ухудшается.
Черновые параметры — согласовать с психиатрами
Код можно писать сейчас: все числа ниже лежат в JSON и заменяются без правки логики. Вопросы к врачам по этапу 2 собраны в документе «Этап 2: вопросы к психиатрам».
| Параметр | Черновое значение | Основание |
|---|---|---|
| M1: сколько дней пропуска препарата с риском отмены | 2 дня подряд | риск — КР; число — команда |
| M4: предел курса бензодиазепинов | 21 день; диазепам 28; алпразолам 90 | КР ПТСР 3.2 |
| M5: день проверки ответа | 28-й день для обоих диагнозов | КР: депрессия 3–4 недели, ПТСР 4–6 недель |
| M5: исходный замер | последняя полная шкала за 14 дней до старта, иначе в день старта | команда |
| M8: «сильно мешают» | FIBSER-бремя ≥ 5 из 6 или 2 пропуска из-за побочных за 7 дней | порог FIBSER сверить с оригиналом шкалы |
--- стр. 10 ---
| Параметр | Черновое значение | Основание |
|---|---|---|
| M7: список срочных симптомов и текст пациенту | 5 симптомов из JSON, текст red_flag | симптомы — КР; текст — команда |
| Сроки реакции | 24 ч / до 3 рабочих дней / к визиту | как в этапе 2 |
| Ввод назначения пациентом | разрешён, с пометкой «со слов пациента» | команда |
| Все тексты для пациента из texts | черновики | вычитать врачу |
Источники
- Клинические рекомендации «Депрессивный эпизод, рекуррентное депрессивное расстройство», Минздрав РФ, 2024, Российское общество психиатров. Использованы разделы 3.1–3.6 и приложения А3 (табл. 2 — дозы, табл. 3 — побочные эффекты), Б5 (синдром отмены), В (информация для пациента).
- Клинические рекомендации «Посттравматическое стрессовое расстройство», Минздрав РФ, 2023, ID 753, Российское общество психиатров. Использованы разделы 3.2 и 6.1, приложения А3 (дозы), Б1 (алгоритм терапии), В. Срок пересмотра — не позднее 2025 года: проверить, не вышла ли новая версия.
- «Этап 2: наблюдение после диагноза» — наш документ о расписании, флагах и сроках реакции.
Расхождения внутри КР отмечены в JSON в поле note. Пример: хлорпротиксен в тексте КР по ПТСР — 15–100 мг, а в таблице — 25–150 мг.
Binary file not shown.
+278
View File
@@ -0,0 +1,278 @@
"""PDF -> TXT для RAG.
Запуск из корня репо (нужен poppler-utils для pdftotext):
uv run --with pdfplumber python docs/source/rag/pdf_to_txt.py docs/source/rag/*.pdf --out config/rag/corpus
Сверка результата с PDF:
python docs/source/rag/check_txt.py docs/source/rag/*.pdf --out config/rag/corpus
- текст вне таблиц, строки склеены в абзацы (в том числе через границу страницы);
- таблицы -> markdown; у таблицы-продолжения на новой странице повторяется шапка,
строка, разорванная границей страницы, склеивается;
- колонтитулы (повторяющиеся строки вверху/внизу страниц) вырезаются;
- маркеры страниц `--- стр. N ---` для ссылок на источник;
- текст с картинок (блок-схемы и т.п.) берётся из figures/<имя pdf>.p<N>.txt
(ручная транскрипция) и вставляется в конец своей страницы;
- если есть manual/<имя pdf>.txt, берётся он (документы, которые автоматом
не разбираются, например вёрстка Chromium с выносками).
"""
from __future__ import annotations
import math
import re
import subprocess
import sys
from collections import Counter
from pathlib import Path
from typing import Any
import pdfplumber
FIGURES_DIR = Path(__file__).parent / "figures"
MANUAL_DIR = Path(__file__).parent / "manual" # вручную выверенные txt: берутся как есть
EDGE_LINES = 4 # сколько строк сверху/снизу страницы проверять на колонтитул
SENTENCE_END = tuple(".:;!?»)")
GRADE_TAIL = re.compile(r"^([ABCАВС]|\(уровень\b.*)$") # "Уровень убедительности рекомендаций\nA\n(уровень ...)"
BULLET = re.compile(r"^([●•▪◦\-–—*]|\d+[.)]|[а-яa-z][.)]|[IVXА-Я]{1,4}[.)])\s")
HYPHEN = "\x00" # временная метка переноса "сло-\nво", снимается в resolve_hyphens
SOFT = "\x01" # временная метка разрыва строки внутри слова в узкой ячейке: "Соверше\nнно"
DEFINITION = re.compile(r"^\S+ [–—] ") # "рТМС – ..." в списке сокращений
ITEM = re.compile(r"^(\d+\s*[.)\-–]|[А-ЯA-Z]$)") # "1. СНИЖЕННОЕ НАСТРОЕНИЕ", "И" — пункт, не шапка
OPTION = re.compile(r"^0 □") # варианты ответа пункта шкалы: "0 □ Отсутствует 1 □ ..."
def norm_edge(line: str) -> str:
return re.sub(r"\d+", "#", line.strip())
def strip_junk(line: str, junk: set[str]) -> str:
"""Убирает колонтитул: целую строку, удлинённый вариант (на альбомных
страницах он шире) и прилипший к концу строки ("...на 7Страница 24 из 117")."""
n = norm_edge(line)
if n in junk or any(len(j) >= 30 and (n.startswith(j) or j.startswith(n) and len(n) >= 30) for j in junk):
return ""
for j in junk:
if len(j) >= 10 and "#" in j:
line = re.sub(re.escape(j).replace("\\#", r"\d+") + r"\s*$", "", line)
return line
def clean_cell(cell: str | None) -> str:
if not cell:
return ""
cell = re.sub(r"(\w)-\s*\n\s*(?=[а-яёa-z])", r"\1" + HYPHEN, cell) # перенос в узкой ячейке
cell = re.sub(r"([а-яё])\n(?=[а-яё])", r"\1" + SOFT, cell)
return re.sub(r"\s*\n\s*", " ", cell).replace("|", "/").strip()
def is_header(row: list[str]) -> bool:
"""Шапка — короткие подписи колонок; у таблиц-опросников её нет и первая
строка — уже пункт шкалы (его нельзя повторять на страницах-продолжениях)."""
return not ITEM.match(row[0]) and all(len(c) <= 100 for c in row)
def merge_split_rows(rows: list[list[str]]) -> list[list[str]]:
"""Пункт шкалы, разрезанный на "N. НАЗВАНИЕ" и строку с вариантами "0 □ ...",
склеивается обратно в одну строку."""
out: list[list[str]] = []
for row in rows:
if out and OPTION.match(row[0]) and "□" not in out[-1][0]:
out[-1] = [f"{a} {c}".strip() for a, c in zip(out[-1], row)]
else:
out.append(row)
return out
def table_to_md(header: list[str], body: list[list[str]]) -> str:
out = ["| " + " | ".join(header) + " |", "|" + "---|" * len(header)]
out += ["| " + " | ".join(r) + " |" for r in body]
return "\n".join(out)
def reflow(lines: list[str]) -> str:
"""Склеивает строки, разорванные вёрсткой, обратно в абзацы."""
paras: list[str] = []
for raw in lines:
line = raw.strip()
if not line:
continue
prev = paras[-1] if paras else ""
if prev and GRADE_TAIL.match(line):
paras[-1] = prev + " " + line
continue
joinable = (
prev
and line[0].islower()
and not BULLET.match(line)
and not DEFINITION.match(line)
)
if joinable and prev.endswith("-") and not prev.endswith(" -"):
paras[-1] = prev[:-1] + HYPHEN + line # перенос или составное слово
elif joinable:
paras[-1] = prev + " " + line
else:
paras.append(line)
return "\n".join(paras)
def region_text(pdf_path: Path, num: int, page: Any, top: float, bottom: float) -> str:
"""Текст горизонтальной полосы страницы. pdftotext надёжнее pdfplumber на
буквах с диакритикой (у pdfplumber выходит "K o z a r i ć")."""
y = math.floor(top)
h = math.ceil(bottom) - y
if h <= 0:
return ""
cmd = ["pdftotext", "-nopgbrk", "-f", str(num), "-l", str(num), "-x", "0", "-y", str(y),
"-W", str(math.ceil(page.width)), "-H", str(h), str(pdf_path), "-"]
return subprocess.run(cmd, capture_output=True, text=True, check=True).stdout
def word_bag(text: str) -> Counter[str]:
return Counter(w.lower() for w in re.findall(r"\w+", text.replace(HYPHEN, "")))
def page_blocks(pdf_path: Path, num: int, page: Any) -> list[tuple[str, Any]]:
"""Текстовые полосы и таблицы страницы сверху вниз."""
# у таблицы, разорванной страницей, нет нижней (или верхней) рамки — без неё
# pdfplumber теряет крайнюю строку; дорисовываем линии по концам вертикалей
ends = {round(y) for e in page.vertical_edges if e["height"] > 5 for y in (e["top"], e["bottom"])}
settings = {"explicit_horizontal_lines": sorted(ends)}
tables = []
for t in sorted(page.find_tables(settings), key=lambda t: t.bbox[1]):
rows = [[clean_cell(c) for c in row] for row in t.extract()]
# рамка, внутри которой текст лежит не в ячейках, — не таблица: иначе текст пропадёт
band = word_bag(region_text(pdf_path, num, page, t.bbox[1], t.bbox[3]))
if band - word_bag(" ".join(" ".join(r) for r in rows)):
continue
tables.append((t.bbox[1], t.bbox[3], rows))
blocks: list[tuple[str, Any]] = []
top = 0.0
for t_top, t_bottom, rows in tables:
if t_top < top: # вложенная/перекрывающаяся таблица
continue
blocks.append(("text", region_text(pdf_path, num, page, top, t_top)))
blocks.append(("table", rows))
top = t_bottom
blocks.append(("text", region_text(pdf_path, num, page, top, page.height)))
return blocks
def resolve_hyphens(text: str) -> str:
""""клинико-\nпсихопатологические" — составное слово, "антихо-\nлинерги-\nческие" —
перенос. Склеиваем без дефиса, если склейка встречается в документе целиком или
хотя бы одна половинка — не слово (обрывок слога); иначе оставляем дефис."""
vocab = {w.lower() for w in re.findall(r"\w+", re.sub(rf"\w*[{HYPHEN}{SOFT}]\w*", " ", text))}
def fix(m: re.Match[str]) -> str:
a, b = m.group(1), m.group(2)
whole = (a + b).lower() in vocab
compound = a.lower() in vocab and b.lower() in vocab
return a + b if whole or not compound else f"{a}-{b}"
pattern = re.compile(rf"(\w+){HYPHEN}(\w+)")
while HYPHEN in text:
new = pattern.sub(fix, text, count=0)
if new == text:
break
text = new
text = text.replace(HYPHEN, "-")
def fix_soft(m: re.Match[str]) -> str:
a, b = m.group(1), m.group(2)
broken = (a + b).lower() in vocab and not (a.lower() in vocab and b.lower() in vocab)
return a + b if broken else f"{a} {b}"
return re.sub(rf"(\w+){SOFT}(\w+)", fix_soft, text).replace(SOFT, " ")
def convert(pdf_path: Path) -> str:
manual = MANUAL_DIR / f"{pdf_path.stem}.txt"
if manual.exists():
return manual.read_text()
with pdfplumber.open(pdf_path) as pdf:
pages = [page_blocks(pdf_path, n, p) for n, p in enumerate(pdf.pages, 1)]
# колонтитулы: строки у края страницы, повторяющиеся на >= 40% страниц
edge: Counter[str] = Counter()
for page in pages:
lines = [l for kind, b in page if kind == "text" for l in b.splitlines() if l.strip()]
edge.update({norm_edge(l) for l in lines[:EDGE_LINES] + lines[-EDGE_LINES:]})
junk = {k for k, n in edge.items() if n >= max(3, 0.4 * len(pages))}
# blocks: ["page", N] | ["text", str] | ["table", (header, body)] | ["figure", str]
blocks: list[list[Any]] = []
for num, page in enumerate(pages, 1):
blocks.append(["page", num])
first_on_page = True
for kind, b in page:
prev = next((x for x in reversed(blocks) if x[0] != "page"), None)
if kind == "text":
text = reflow([strip_junk(l, junk) for l in b.splitlines()])
if not text:
continue
if first_on_page and prev and prev[0] == "text" and text[0].islower():
# абзац перетёк на новую страницу: дописываем его хвост к началу
head, _, rest = text.partition("\n")
prev[1] = prev[1] + " " + head
if rest:
blocks.append(["text", rest])
else:
blocks.append(["text", text])
else:
rows = [r for r in b if any(r)]
if not rows:
continue
continues = (
first_on_page
and prev is not None
and prev[0] == "table"
and len(prev[1][0]) == len(rows[0])
and rows[0] != prev[1][0]
)
if continues:
header, body = prev[1]
tail = body if body else [header]
if not rows[0][0] or rows[0][0][:1].islower() or (
OPTION.match(rows[0][0]) and "□" not in tail[-1][0]
):
# строка таблицы разорвана границей страницы
tail[-1] = [f"{a} {c}".strip() for a, c in zip(tail[-1], rows[0])]
if not body:
prev[1] = (tail[0], body)
rows = rows[1:]
if rows and is_header(header):
blocks.append(["table", (header, rows)])
elif rows: # шапки нет — не дублируем первый пункт
blocks.append(["table", (rows[0], rows[1:])])
else:
blocks.append(["table", (rows[0], rows[1:])])
first_on_page = False
fig = FIGURES_DIR / f"{pdf_path.stem}.p{num}.txt"
if fig.exists():
blocks.append(["figure", fig.read_text().strip()])
out = [f"# {pdf_path.stem}", f"Источник: {pdf_path.name}, страниц: {len(pages)}"]
for kind, b in blocks:
if kind == "page":
out.append(f"--- стр. {b} ---")
elif kind == "text":
out.append(b)
elif kind == "table":
header, *body = merge_split_rows([b[0], *b[1]])
out.append(table_to_md(header, body))
else:
out.append("[Изображение: текст перенесён вручную]\n" + b)
return resolve_hyphens("\n\n".join(out)) + "\n"
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("pdfs", nargs="+", type=Path)
parser.add_argument("--out", type=Path, help="папка для txt (по умолчанию рядом с pdf)")
args = parser.parse_args()
for src in args.pdfs:
dst = (args.out / src.name if args.out else src).with_suffix(".txt")
dst.write_text(convert(src))
print(f"{src.name} -> {dst}")