"""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")