feat(bot): answer treatment questions from the clinical-guideline knowledge base
CI / ruff · pytest (pull_request) Successful in 1m30s
CI / ruff · pytest (pull_request) Successful in 1m30s
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||
Reference in New Issue
Block a user