Merge origin/dev into feat/rag-clinical-guidelines
CI / ruff · pytest (pull_request) Successful in 2m9s

Conflict in app/api/routes.py: /demo/personas keeps the dev version (demo console, DB patients
with scenarios); the patient app gets its own /patient/demo-personas (synthetic ЕМИАС accounts).
Anonymous sign-in now always creates a doctor-less pseudonymous profile, since the dialog API
of #12 serves known profiles only.

Refs #77

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 15:22:29 +03:00
co-authored by Claude Opus 5.5
82 changed files with 9945 additions and 153 deletions
+1
View File
@@ -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")],
]
+107
View File
@@ -0,0 +1,107 @@
"""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 agent, 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
# Tool-using agent first (RAG + ЕМИАС record + drug-safety check); disabled/failed → extractive RAG below.
smart = await agent.answer_chat(ctx.token, ctx.text)
if smart is not None:
log.info("kb: agent answered provider=%s tools=%s", smart.provider, ",".join(smart.tools))
return agent.render(smart)
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")
+7 -1
View File
@@ -42,7 +42,8 @@ from app.dialog.contracts import Button, Event, Reply
from app.dialog.flows.core import menu_reply
from app.dialog.fsm import Ctx, Step, abandonable, get_trigger, handler_for, state, trigger, trigger_payload
from app.integrations.base import integrations
from app.llm.med_qa import fallback_text, med_qa
from app.llm import agent
from app.llm.med_qa import DOSE_QUESTION, fallback_text, med_qa
QUESTION_STATE = "meds.question"
SIDE_EFFECT_TEXT_STATE = "meds.side_effect_text"
@@ -441,6 +442,11 @@ async def answer_question(ctx: Ctx, rx: Rx | None) -> str:
"""Answer from the instruction excerpt only (SPEC §7 п.5); dose questions and gaps → «уточните у врача»."""
if rx is None or ctx.text is None:
return load_meds_texts().question.no_prescription
if DOSE_QUESTION.search(ctx.text.lower()) is None:
# Agent: instruction + ЕМИАС record (other drugs, conditions, intolerances) + guidelines; else excerpt only.
smart = await agent.answer_chat(ctx.token, ctx.text)
if smart is not None:
return agent.render(smart)
answer = await med_qa(rx.drug, ctx.text)
return answer.text if answer is not None else fallback_text()
+7 -2
View File
@@ -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))
+1
View File
@@ -47,6 +47,7 @@ class AgeGateTexts(_Strict):
class IdleButtons(_Strict):
checkin: str
med_question: str
kb_question: str
contact_doctor: str
revoke: str