release: dev → main (сдача хакатона) #106

Merged
Eleboba merged 128 commits from dev into main 2026-10-04 23:00:56 +03:00
8 changed files with 353 additions and 3 deletions
Showing only changes of commit 53818ac76c - Show all commits
+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")],
]
+102
View File
@@ -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")
+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
+2 -1
View File
@@ -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
+1
View File
@@ -63,6 +63,7 @@ idle:
buttons:
checkin: "Как я сегодня"
med_question: "Вопрос о лекарстве"
kb_question: "Вопрос о лечении"
contact_doctor: "Связаться с врачом"
revoke: "Отозвать согласие"
# Заглушки кнопок меню; ветки ветерана (#36/#37) и med_qa (#15) подменяют их своими триггерами.
+51
View File
@@ -0,0 +1,51 @@
# Вопросы пациента о состоянии и лечении → база знаний по клиническим рекомендациям (#64, RAG #60).
# Зона clinical вычитывает формулировки. Сам ответ — только цитата из КР с источником (config/texts/rag.yaml
# задаёт его обрамление); здесь — тексты диалога и признаки того, что свободное сообщение — вопрос.
# Кнопка меню «Вопрос о лечении» → бот просит написать вопрос.
prompt: |-
Напишите ваш вопрос о состоянии или лечении — например, «что такое флэшбек?» или «помогает ли психотерапия?».
Я отвечу цитатой из клинических рекомендаций Минздрава и укажу источник. Вопросы о дозе и о вашем препарате решает врач.
# Вопрос о дозе (увеличить, уменьшить, полтаблетки): бот не отвечает по существу — это решение врача.
dose_redirect: "Дозу и схему приёма подбирает только ваш врач — менять их самостоятельно нельзя. Могу передать ваш вопрос врачу: нажмите «Связаться с врачом»."
# Свободное сообщение считается вопросом, если в нём есть «?» или оно начинается с одного из этих слов.
# Тогда бот ищет ответ в базе знаний; если ответа нет — отвечает обычной фразой поддержки.
question_starts:
- "что такое"
- "что значит"
- "что означает"
- "почему"
- "зачем"
- "как"
- "когда"
- "сколько"
- "можно ли"
- "нужно ли"
- "надо ли"
- "помогает ли"
- "поможет ли"
- "правда ли"
- "бывает ли"
- "что делать"
- "что будет"
- "чем"
- "где"
- "есть ли"
# Бытовые фразы — не вопросы о лечении, даже со «?»: «как дела?», «как ты?». База знаний для них не ищется
# (иначе «как дела» находит «линия делит» — у слов одна основа), бот отвечает обычной фразой поддержки.
small_talk:
- "как дела"
- "как ты"
- "как вы"
- "как жизнь"
- "как поживаете"
- "что нового"
- "кто ты"
- "ты кто"
- "ты бот"
- "ты робот"
- "привет"
- "спасибо"
+188
View File
@@ -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)