From b72af71e661d88fe7278cd2f3bf2369ee1f9df0e Mon Sep 17 00:00:00 2001 From: Lacky1234union Date: Sun, 4 Oct 2026 11:27:42 +0300 Subject: [PATCH] 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) -- 2.54.0