Merge pull request 'feat(bot): ответы на вопросы о лечении из базы знаний КР (#64)' (#65) from feat/64-rag-dialog into dev
CI / ruff · pytest (push) Successful in 1m32s
CI / ruff · pytest (push) Successful in 1m32s
This commit was merged in pull request #65.
This commit is contained in:
@@ -39,6 +39,7 @@ def menu_reply(ctx: Ctx, text: str | None = None) -> Reply:
|
|||||||
buttons = [
|
buttons = [
|
||||||
[Button(label=b.checkin, data="menu:checkin")],
|
[Button(label=b.checkin, data="menu:checkin")],
|
||||||
[Button(label=b.med_question, data="menu:med_question")],
|
[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.contact_doctor, data="menu:contact_doctor")],
|
||||||
[Button(label=b.revoke, data="consent:revoke")],
|
[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.contracts import Button, Event, Reply
|
||||||
from app.dialog.flows import intake
|
from app.dialog.flows import intake
|
||||||
from app.dialog.flows.core import menu_reply
|
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 (
|
from app.dialog.flows.meds import (
|
||||||
answer_question,
|
answer_question,
|
||||||
checkin_of,
|
checkin_of,
|
||||||
@@ -423,11 +424,13 @@ async def _visit(ctx: Ctx, event: Event) -> Step:
|
|||||||
@trigger("text:idle", replace=True)
|
@trigger("text:idle", replace=True)
|
||||||
async def _idle_text(ctx: Ctx, event: Event) -> Step:
|
async def _idle_text(ctx: Ctx, event: Event) -> Step:
|
||||||
"""Free text outside the check-in: drug question → instruction excerpt; side effects / quit intent / alcohol
|
"""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
|
assert ctx.text is not None
|
||||||
facts = await ctx.gateway.extract_checkin(ctx.text)
|
facts = await ctx.gateway.extract_checkin(ctx.text)
|
||||||
if facts is None:
|
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] = {}
|
fields: dict[str, Any] = {}
|
||||||
if facts.quit_intent:
|
if facts.quit_intent:
|
||||||
fields["quit_intent"] = True
|
fields["quit_intent"] = True
|
||||||
@@ -445,6 +448,8 @@ async def _idle_text(ctx: Ctx, event: Event) -> Step:
|
|||||||
await save_checkin(ctx, **fields)
|
await save_checkin(ctx, **fields)
|
||||||
if text is None and facts.med_question and not facts.quit_intent:
|
if text is None and facts.med_question and not facts.quit_intent:
|
||||||
text = await answer_question(ctx, await current_prescription(ctx))
|
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:
|
if text is None:
|
||||||
text = await ctx.gateway.phrase("support")
|
text = await ctx.gateway.phrase("support")
|
||||||
return Step(replies=[menu_reply(ctx, text)], notify_monitoring=bool(fields or facts.side_effects))
|
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):
|
class IdleButtons(_Strict):
|
||||||
checkin: str
|
checkin: str
|
||||||
med_question: str
|
med_question: str
|
||||||
|
kb_question: str
|
||||||
contact_doctor: str
|
contact_doctor: str
|
||||||
revoke: str
|
revoke: str
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -56,6 +56,7 @@ PRESCRIBING = re.compile(
|
|||||||
)
|
)
|
||||||
# "что такое флэшбек" -> the term; its definition "Флэшбек (flashback – англ.) – ..." is preferred.
|
# "что такое флэшбек" -> the term; its definition "Флэшбек (flashback – англ.) – ..." is preferred.
|
||||||
DEFINITION_QUESTION = re.compile(r"^\s*что (?:такое|значит|означает|это за)\s+(.+?)[?.!\s]*$", re.IGNORECASE)
|
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 = re.compile(r"\s[–—-]\s(?![^(]*\))|\s[–—-]?\s?это\s") # dash outside "(… – англ.)", or "это"
|
||||||
DEFINITION_HEAD_CHARS = 60
|
DEFINITION_HEAD_CHARS = 60
|
||||||
DEFINITION_BONUS = 2.0 # multiplier for a sentence that starts with the asked term (picks it within a chunk)
|
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:
|
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
|
return set(tokens(m.group(1))) or None if m else None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ idle:
|
|||||||
buttons:
|
buttons:
|
||||||
checkin: "Как я сегодня"
|
checkin: "Как я сегодня"
|
||||||
med_question: "Вопрос о лекарстве"
|
med_question: "Вопрос о лекарстве"
|
||||||
|
kb_question: "Вопрос о лечении"
|
||||||
contact_doctor: "Связаться с врачом"
|
contact_doctor: "Связаться с врачом"
|
||||||
revoke: "Отозвать согласие"
|
revoke: "Отозвать согласие"
|
||||||
# Заглушки кнопок меню; ветки ветерана (#36/#37) и med_qa (#15) подменяют их своими триггерами.
|
# Заглушки кнопок меню; ветки ветерана (#36/#37) и med_qa (#15) подменяют их своими триггерами.
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Вопросы пациента о состоянии и лечении → база знаний по клиническим рекомендациям (#64, RAG #60).
|
||||||
|
# Зона clinical вычитывает формулировки. Сам ответ — только цитата из КР с источником (config/texts/rag.yaml
|
||||||
|
# задаёт его обрамление); здесь — тексты диалога и признаки того, что свободное сообщение — вопрос.
|
||||||
|
|
||||||
|
# Кнопка меню «Вопрос о лечении» → бот просит написать вопрос.
|
||||||
|
prompt: |-
|
||||||
|
Напишите ваш вопрос о состоянии или лечении — например, «что такое флэшбек?» или «помогает ли психотерапия?».
|
||||||
|
Я отвечу цитатой из клинических рекомендаций Минздрава и укажу источник. Вопросы о дозе и о вашем препарате решает врач.
|
||||||
|
|
||||||
|
# Вопрос о дозе (увеличить, уменьшить, полтаблетки): бот не отвечает по существу — это решение врача.
|
||||||
|
dose_redirect: "Дозу и схему приёма подбирает только ваш врач — менять их самостоятельно нельзя. Могу передать ваш вопрос врачу: нажмите «Связаться с врачом»."
|
||||||
|
|
||||||
|
# Свободное сообщение считается вопросом, если в нём есть «?» или оно начинается с одного из этих слов.
|
||||||
|
# Тогда бот ищет ответ в базе знаний; если ответа нет — отвечает обычной фразой поддержки.
|
||||||
|
question_starts:
|
||||||
|
- "что такое"
|
||||||
|
- "что значит"
|
||||||
|
- "что означает"
|
||||||
|
- "почему"
|
||||||
|
- "зачем"
|
||||||
|
- "как"
|
||||||
|
- "когда"
|
||||||
|
- "сколько"
|
||||||
|
- "можно ли"
|
||||||
|
- "нужно ли"
|
||||||
|
- "надо ли"
|
||||||
|
- "помогает ли"
|
||||||
|
- "поможет ли"
|
||||||
|
- "правда ли"
|
||||||
|
- "бывает ли"
|
||||||
|
- "что делать"
|
||||||
|
- "что будет"
|
||||||
|
- "чем"
|
||||||
|
- "где"
|
||||||
|
- "есть ли"
|
||||||
|
|
||||||
|
# Бытовые фразы — не вопросы о лечении, даже со «?»: «как дела?», «как ты?». База знаний для них не ищется
|
||||||
|
# (иначе «как дела» находит «линия делит» — у слов одна основа), бот отвечает обычной фразой поддержки.
|
||||||
|
small_talk:
|
||||||
|
- "как дела"
|
||||||
|
- "как ты"
|
||||||
|
- "как вы"
|
||||||
|
- "как жизнь"
|
||||||
|
- "как поживаете"
|
||||||
|
- "что нового"
|
||||||
|
- "кто ты"
|
||||||
|
- "ты кто"
|
||||||
|
- "ты бот"
|
||||||
|
- "ты робот"
|
||||||
|
- "привет"
|
||||||
|
- "спасибо"
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user