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:
@@ -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")],
|
||||
]
|
||||
|
||||
@@ -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")
|
||||
@@ -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))
|
||||
|
||||
@@ -47,6 +47,7 @@ class AgeGateTexts(_Strict):
|
||||
class IdleButtons(_Strict):
|
||||
checkin: str
|
||||
med_question: str
|
||||
kb_question: str
|
||||
contact_doctor: str
|
||||
revoke: str
|
||||
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user