feat(bot): ежедневный чек-ин ветерана, приём лекарств и реакции на правила #55

Merged
Eleboba merged 2 commits from feat/37-veteran-daily into dev 2026-10-03 18:47:53 +03:00
7 changed files with 1821 additions and 4 deletions
+15 -3
View File
@@ -6,7 +6,7 @@ are ignored here. While a crisis is open only crisis-relevant actions (crisis, s
the patient: no medication tips or visit offers in the middle of a crisis. the patient: no medication tips or visit offers in the middle of a crisis.
""" """
from collections.abc import Iterable from collections.abc import Awaitable, Callable, Iterable
from typing import Any from typing import Any
from app.core.rules import Action from app.core.rules import Action
@@ -20,6 +20,16 @@ from app.dialog.scale_session import active_scale, add_pending
_MED_INFO_INTENTS = {"delayed_effect": "quit_intent", "alcohol_interaction": "alcohol_info", "nausea": "nausea_info"} _MED_INFO_INTENTS = {"delayed_effect": "quit_intent", "alcohol_interaction": "alcohol_info", "nausea": "nausea_info"}
_CRISIS_SAFE = {"crisis", "show_safety_plan", "schedule_scale"} _CRISIS_SAFE = {"crisis", "show_safety_plan", "schedule_scale"}
# Optional `med_info` answerer registered by a flow (`flows/meds.py`, #37): answers from the drug instruction
# (med_qa). Returning None falls back to the phrase template below.
MedInfoHandler = Callable[[Ctx, Action], Awaitable[Reply | None]]
_med_info_handler: MedInfoHandler | None = None
def register_med_info_handler(handler: MedInfoHandler | None) -> None:
global _med_info_handler
_med_info_handler = handler
def _plan_line(step: Any) -> str: def _plan_line(step: Any) -> str:
if isinstance(step, dict): if isinstance(step, dict):
@@ -57,9 +67,11 @@ async def execute(ctx: Ctx, actions: Iterable[Action]) -> Step:
pending = add_pending(data, scale_id, bool(action.params.get("scheduled", False))) pending = add_pending(data, scale_id, bool(action.params.get("scheduled", False)))
step = step.then(Step(data={"pending_scales": pending})) step = step.then(Step(data={"pending_scales": pending}))
elif action.type == "med_info": elif action.type == "med_info":
reply = await _med_info_handler(ctx, action) if _med_info_handler is not None else None
if reply is None:
intent = _MED_INFO_INTENTS.get(str(action.params.get("topic")), "default") intent = _MED_INFO_INTENTS.get(str(action.params.get("topic")), "default")
text = await ctx.gateway.phrase(intent, {}) reply = Reply(text=await ctx.gateway.phrase(intent, {}), kind="info")
step = step.then(Step(replies=[Reply(text=text, kind="info")])) step = step.then(Step(replies=[reply]))
elif action.type == "suggest_visit": elif action.type == "suggest_visit":
text = await ctx.gateway.phrase("book_visit_offer", {}) text = await ctx.gateway.phrase("book_visit_offer", {})
button = Button(label=ctx.texts.visit.button, data="menu:visit") button = Button(label=ctx.texts.visit.button, data="menu:visit")
+405
View File
@@ -0,0 +1,405 @@
"""Medication flow (#37, case scenario «управление приёмом лекарств»). Module-agnostic: every patient with a
prescription gets it, whatever the clinical module.
system:med_reminder {dose_id, drug, dose}
──▶ «Принял» → dose taken, check-in med_taken = true
──▶ «Пропущу» → dose skipped, med_taken = false ──▶ reason:
забыл / побочки (→ side effects) / решил бросить (quit_intent) / другое
──▶ «Есть побочные эффекты» → code buttons (тошнота, бессонница, …) or «Другое» → free text
→ check-in side_effects (+ dose.side_effects) → «А препарат сегодня приняли?»
menu «Вопрос о лекарстве" ──▶ meds.question (free text) ──▶ med_qa (instruction excerpt) or «уточните у врача»
rule action med_info (actions.py hook) ──▶ instruction excerpt for the rule topic + fixed notice
Everything the patient answers is written to the clinical DB (`doses`, `daily_checkins`) and the step asks the
engine to run monitoring (`notify_monitoring`): the rules (lane B, ADR-007) decide alerts and further actions
(quit intent → delayed-onset explanation, missed doses → caring contact). Nothing here decides risk.
Reminder buttons carry fixed data ("med:taken", ...) so they are plain idle triggers; the dose they refer to is
kept in `dialog_state.data["med_pending"]` ({dose_id, drug, dose, day}) — a button from an earlier day is stale.
"""
from datetime import date, timedelta
from functools import lru_cache
from pathlib import Path
from typing import Any
import yaml
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from app.config import get_settings
from app.core.privacy import scrub
from app.core.rules import Action
from app.db import repo
from app.db.clinical import DailyCheckin, Dose
from app.db.clinical import Prescription as PrescriptionRow
from app.dialog.actions import register_med_info_handler
from app.dialog.contracts import Button, Event, Reply
from app.dialog.flows.core import menu_reply
from app.dialog.fsm import Ctx, Step, get_trigger, handler_for, state, trigger
from app.integrations.base import integrations
from app.llm.med_qa import fallback_text, med_qa
QUESTION_STATE = "meds.question"
SIDE_EFFECT_TEXT_STATE = "meds.side_effect_text"
SIDE_EFFECT_CODES = ("nausea", "insomnia", "headache", "dizziness", "agitation", "other")
SKIP_REASONS = ("forgot", "side_effects", "quit", "other")
# ------------------------------------------------------------------------------------------- texts
class _Strict(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
class ReminderTexts(_Strict):
text: str
text_no_drug: str
taken: str
skip: str
side_effects: str
class SkipTexts(_Strict):
question: str
reasons: dict[str, str]
ack: dict[str, str]
class SideEffectTexts(_Strict):
question: str
options: dict[str, str]
questions: dict[str, str]
describe: str
ack: str
repeat: str
ask_dose: str
dose_yes: str
dose_no: str
class CheckinMedTexts(_Strict):
question: str
question_no_drug: str
yes: str
no: str
later: str
class QuestionTexts(_Strict):
prompt: str
no_prescription: str
class MedInfoTopic(_Strict):
section: str # med_qa section code (side_effects | onset | alcohol | ...)
question: str
lead: str = ""
fallback: str
notice: str = ""
class MedsTexts(_Strict):
status: str
reminder: ReminderTexts
taken_ack: str
stale: str
skip: SkipTexts
side_effects: SideEffectTexts
checkin: CheckinMedTexts
question: QuestionTexts
med_info: dict[str, MedInfoTopic]
def meds_texts_path() -> Path:
return get_settings().config_dir / "texts" / "meds.yaml"
@lru_cache
def load_meds_texts(path: Path | None = None) -> MedsTexts:
path = path or meds_texts_path()
return MedsTexts.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
# ------------------------------------------------------------------------------------------- data helpers
class Rx(BaseModel):
"""Active prescription as the dialog needs it (drug INN + dose exactly as the doctor wrote them)."""
id: int | None
drug: str
dose: str
async def current_prescription(ctx: Ctx) -> Rx | None:
"""Latest prescription active today: clinical DB first, then the МИС integration (demo: in-memory)."""
today = ctx.now.date()
rows = await ctx.session.scalars(
select(PrescriptionRow).where(PrescriptionRow.token == ctx.token).order_by(PrescriptionRow.id.desc())
)
for row in rows:
if (row.start_date is None or row.start_date <= today) and (row.end_date is None or row.end_date >= today):
return Rx(id=row.id, drug=row.drug, dose=row.dose)
for p in await integrations.prescriptions.get_prescriptions(ctx.token):
if (p.start_date is None or p.start_date <= today) and (p.end_date is None or p.end_date >= today):
return Rx(id=None, drug=p.drug, dose=p.dose)
return None
async def checkin_of(ctx: Ctx, day: date) -> DailyCheckin | None:
return await ctx.session.scalar(
select(DailyCheckin).where(DailyCheckin.token == ctx.token, DailyCheckin.day == day)
)
async def save_checkin(ctx: Ctx, **fields: Any) -> DailyCheckin:
return await repo.upsert_checkin(ctx.session, token=ctx.token, day=ctx.now.date(), now=ctx.now, **fields)
def pending_dose(ctx: Ctx) -> dict[str, Any] | None:
"""Today's reminder the patient is answering, or None (no reminder / reminder from an earlier day)."""
pending = ctx.data.get("med_pending")
if isinstance(pending, dict) and pending.get("day") == ctx.now.date().isoformat():
return pending
return None
async def _dose_row(ctx: Ctx, pending: dict[str, Any] | None) -> Dose | None:
if not pending or pending.get("dose_id") is None:
return None
row = await ctx.session.get(Dose, pending["dose_id"])
return row if row is not None and row.token == ctx.token else None
async def record_dose(ctx: Ctx, taken: bool, *, status: str | None = None) -> None:
"""Dose answer → `doses` row of today's reminder (if any) + `daily_checkins.med_taken` (rules read both)."""
row = await _dose_row(ctx, pending_dose(ctx))
if row is not None:
row.status = status or ("taken" if taken else "skipped")
row.answered_at = ctx.now
await save_checkin(ctx, med_taken=taken)
async def record_side_effects(ctx: Ctx, codes: list[str]) -> None:
"""Merge side-effect codes into today's check-in (rule `side_effect_3d` counts days with any code)."""
today = await checkin_of(ctx, ctx.now.date())
merged = list(dict.fromkeys([*(today.side_effects if today and today.side_effects else []), *codes]))
await save_checkin(ctx, side_effects=merged)
row = await _dose_row(ctx, pending_dose(ctx))
if row is not None:
row.side_effects = ",".join(c for c in dict.fromkeys([*(row.side_effects or "").split(","), *codes]) if c)
async def side_effect_reply(ctx: Ctx, codes: list[str]) -> Reply:
"""Reaction to newly reported side effects.
Same effect as yesterday → short support (the instruction was already shown; persistence over 3 days is the
rules' business). New effect with a known instruction question → the excerpt of the drug instruction
(med_qa, section side_effects) — never generated advice. Otherwise a plain acknowledgement.
"""
t = load_meds_texts().side_effects
yesterday = await checkin_of(ctx, ctx.now.date() - timedelta(days=1))
if yesterday is not None and set(codes) & set(yesterday.side_effects or []):
return Reply(text=t.repeat, kind="info")
rx = await current_prescription(ctx)
for code in codes:
if code in t.questions and rx is not None:
answer = await med_qa(rx.drug, scrub(t.questions[code]), topic="side_effects")
if answer is not None:
return Reply(text=answer.text, kind="info")
return Reply(text=t.ack, kind="info")
async def leave_to_idle(ctx: Ctx, event: Event, data: dict[str, Any] | None = None) -> Step:
"""Abandon the current sub-flow and let the idle state handle the event (menu button, system event...)."""
step = Step(next_state="idle", data=data or {})
idle = handler_for("idle")
return step.then(await idle(ctx, event)) if idle is not None else step
async def run_trigger_inline(ctx: Ctx, key: str, event: Event) -> Step | None:
"""Run an idle trigger from inside another flow (e.g. a reminder button pressed during the check-in)."""
handler = get_trigger(key)
return await handler(ctx, event) if handler is not None else None
# ------------------------------------------------------------------------------------------- reminder
def _reminder_buttons() -> list[list[Button]]:
t = load_meds_texts().reminder
return [
[Button(label=t.taken, data="med:taken")],
[Button(label=t.skip, data="med:skip")],
[Button(label=t.side_effects, data="med:side_effects")],
]
@trigger("system:med_reminder", replace=True)
async def _med_reminder(ctx: Ctx, event: Event) -> Step:
t = load_meds_texts().reminder
rx = await current_prescription(ctx)
drug = str(event.params.get("drug") or (rx.drug if rx else ""))
dose = str(event.params.get("dose") or (rx.dose if rx else ""))
try:
dose_id: int | None = int(event.params["dose_id"]) # type: ignore[call-overload]
except (KeyError, TypeError, ValueError):
dose_id = None
if dose_id is None and rx is not None and rx.id is not None:
# No scheduler row (demo console without #13): create the planned dose so the answer has a record.
row = Dose(token=ctx.token, prescription_id=rx.id, planned_at=ctx.now, status="pending")
ctx.session.add(row)
await ctx.session.flush()
dose_id = row.id
pending = {"dose_id": dose_id, "drug": drug, "dose": dose, "day": ctx.now.date().isoformat()}
text = t.text.format(drug=drug, dose=dose).replace(" ", " ") if drug else t.text_no_drug
return Step(replies=[Reply(text=text, buttons=_reminder_buttons(), kind="question")], data={"med_pending": pending})
def _stale(ctx: Ctx) -> Step | None:
return None if pending_dose(ctx) is not None else Step(replies=[Reply(text=load_meds_texts().stale)])
@trigger("button:med:taken")
async def _taken(ctx: Ctx, event: Event) -> Step:
if (stale := _stale(ctx)) is not None:
return stale
await record_dose(ctx, True)
return Step(replies=[Reply(text=load_meds_texts().taken_ack)], notify_monitoring=True)
@trigger("button:med:skip")
async def _skip(ctx: Ctx, event: Event) -> Step:
if (stale := _stale(ctx)) is not None:
return stale
await record_dose(ctx, False, status="skipped")
t = load_meds_texts().skip
buttons = [[Button(label=t.reasons[r], data=f"med:skip_reason:{r}")] for r in SKIP_REASONS]
return Step(replies=[Reply(text=t.question, buttons=buttons, kind="question")], notify_monitoring=True)
def _side_effect_question() -> Reply:
t = load_meds_texts().side_effects
buttons = [[Button(label=t.options[c], data=f"med:se:{c}")] for c in SIDE_EFFECT_CODES]
return Reply(text=t.question, buttons=buttons, kind="question")
def _skip_reason(reason: str) -> Any:
async def handler(ctx: Ctx, event: Event) -> Step:
if reason == "side_effects":
return Step(replies=[_side_effect_question()])
if reason == "quit":
# «Решил бросить» is the same signal as "брошу" in free text: rule `quit_intent` reacts (alert +
# delayed-onset explanation through the med_info action).
await save_checkin(ctx, quit_intent=True)
return Step(replies=[Reply(text=load_meds_texts().skip.ack["quit"])], notify_monitoring=True)
return Step(replies=[Reply(text=load_meds_texts().skip.ack[reason])])
return handler
for _reason in SKIP_REASONS:
trigger(f"button:med:skip_reason:{_reason}")(_skip_reason(_reason))
@trigger("button:med:side_effects")
async def _side_effects(ctx: Ctx, event: Event) -> Step:
return Step(replies=[_side_effect_question()])
async def _after_side_effects(ctx: Ctx, codes: list[str]) -> Step:
await record_side_effects(ctx, codes)
replies = [await side_effect_reply(ctx, codes)]
today = await checkin_of(ctx, ctx.now.date())
if (today is None or today.med_taken is None) and await current_prescription(ctx) is not None:
t = load_meds_texts().side_effects
buttons = [[Button(label=t.dose_yes, data="med:dose:yes")], [Button(label=t.dose_no, data="med:dose:no")]]
replies.append(Reply(text=t.ask_dose, buttons=buttons, kind="question"))
return Step(replies=replies, next_state="idle", notify_monitoring=True)
def _side_effect_code(code: str) -> Any:
async def handler(ctx: Ctx, event: Event) -> Step:
if code == "other":
return Step(
replies=[Reply(text=load_meds_texts().side_effects.describe)], next_state=SIDE_EFFECT_TEXT_STATE
)
return await _after_side_effects(ctx, [code])
return handler
for _code in SIDE_EFFECT_CODES:
trigger(f"button:med:se:{_code}")(_side_effect_code(_code))
@state(SIDE_EFFECT_TEXT_STATE)
async def _side_effect_text(ctx: Ctx, event: Event) -> Step:
if event.type != "text" or ctx.text is None:
return await leave_to_idle(ctx, event)
facts = await ctx.gateway.extract_checkin(ctx.text)
# Store only normalised codes, never the description itself (it stays in the scrubbed message log).
codes = [str(c) for c in facts.side_effects] if facts is not None and facts.side_effects else ["other"]
return await _after_side_effects(ctx, codes)
def _dose_answer(taken: bool) -> Any:
async def handler(ctx: Ctx, event: Event) -> Step:
await record_dose(ctx, taken, status=None if taken else "missed")
return Step(
replies=[Reply(text=load_meds_texts().taken_ack if taken else load_meds_texts().skip.ack["other"])],
notify_monitoring=True,
)
return handler
trigger("button:med:dose:yes")(_dose_answer(True))
trigger("button:med:dose:no")(_dose_answer(False))
# ------------------------------------------------------------------------------------------- question menu
@trigger("button:menu:med_question", replace=True)
async def _menu_question(ctx: Ctx, event: Event) -> Step:
t = load_meds_texts().question
if await current_prescription(ctx) is None:
return Step(replies=[menu_reply(ctx, t.no_prescription)])
return Step(replies=[Reply(text=t.prompt)], next_state=QUESTION_STATE)
async def answer_question(ctx: Ctx, rx: Rx | None) -> str:
"""Answer from the instruction excerpt only (SPEC §7 п.5); dose questions and gaps → «уточните у врача»."""
if rx is None or ctx.text is None:
return load_meds_texts().question.no_prescription
answer = await med_qa(rx.drug, ctx.text)
return answer.text if answer is not None else fallback_text()
@state(QUESTION_STATE)
async def _question(ctx: Ctx, event: Event) -> Step:
if event.type != "text" or ctx.text is None:
return await leave_to_idle(ctx, event)
text = await answer_question(ctx, await current_prescription(ctx))
return Step(replies=[menu_reply(ctx, text)], next_state="idle")
# ------------------------------------------------------------------------------------------- rule actions
async def med_info_reply(ctx: Ctx, action: Action) -> Reply | None:
"""`med_info` action of a rule (quit_intent → onset, alcohol_3d → alcohol): instruction excerpt framed by
fixed texts. The rule already decided to say it; this only picks the words. None → engine template."""
spec = load_meds_texts().med_info.get(str(action.params.get("topic")))
if spec is None:
return None
rx = await current_prescription(ctx)
answer = await med_qa(rx.drug, scrub(spec.question), topic=spec.section) if rx is not None else None
body = answer.text if answer is not None else spec.fallback
return Reply(text="\n\n".join(p for p in (spec.lead, body, spec.notice) if p), kind="info")
register_med_info_handler(med_info_reply)
+480
View File
@@ -0,0 +1,480 @@
"""Daily check-in and scheduled-scale intros (#37, ADR-002, CLINICAL_MODULES §1–2, case «мониторинг после
обращения»).
Check-in = core (mood 0–10, dose taken) + the module question (veteran: sleep hours + nightmares; alcohol for
veterans and for anyone with a positive AUDIT-C) + free text:
system:daily_checkin / menu «Как я сегодня»
──▶ daily.mood buttons 0–10 (a bare number is accepted too)
──▶ daily.med only with a prescription and no dose answer today (the reminder may have got it)
──▶ daily.sleep <4 / 4–6 / 6–8 / >8 h ┐ module veteran
──▶ daily.nightmares yes / no ┘
──▶ daily.alcohol none / 1–2 / more (veteran or AUDIT-C positive)
──▶ daily.text «Как вы? Пару слов» (skip button) → extract_checkin on the SCRUBBED text
──▶ [daily.alcohol_amount] alcohol mentioned in the text but not asked by button → amount buttons
──▶ idle upsert done, `notify_monitoring` → rules (lane B) → dialog actions
Data algorithm (medical data, jury requirement):
* Every button answer is written to `daily_checkins` immediately (`repo.upsert_checkin` merges non-None
fields), so an interrupted check-in (crisis, the patient walking away) still leaves today's mood on record —
the "mood ≤ 3 three days in a row" rule must not miss a day because the free text was never written.
* Button answers are the patient's own and always win over the LLM: from the free text the LLM may only add
side effects, the quit intent and fields that were not asked by buttons today.
* The LLM never decides anything here: alerts and follow-ups (delayed-onset explanation, unscheduled PHQ-9,
alcohol interaction, visit offer) come from the rules through `MonitoringService` (ADR-007).
* A free-text message classified POSSIBLE by the safety layer is taken over by the engine (C-SSRS). When the
screen returns to `daily.text` the message counts as the answer: the check-in is closed, not asked again.
* A check-in left unfinished is dropped on the next system event of a later day.
Also here: friendly intros for scheduled scales (pcl5, phq9, dar5, auditc) — the engine starts `scale_due`
scales without an intro, so an exact `scale.<id>` enter hook adds one and delegates to the generic scale hook.
"""
import re
from dataclasses import replace
from datetime import datetime
from functools import lru_cache
from pathlib import Path
from typing import Any
import yaml
from pydantic import BaseModel, ConfigDict
from app.config import get_settings
from app.core.intake_runtime import intake_enabled
from app.core.rules import load_rules
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.meds import (
answer_question,
checkin_of,
current_prescription,
leave_to_idle,
load_meds_texts,
record_dose,
record_side_effects,
run_trigger_inline,
save_checkin,
side_effect_reply,
)
from app.dialog.fsm import Ctx, Step, enter_hook_for, on_enter, state, trigger
from app.dialog.scale_session import PREFIX as SCALE_PREFIX
from app.dialog.scale_session import active_scale, scale_state
PREFIX = "daily"
STEPS = ("mood", "med", "sleep", "nightmares", "alcohol", "text")
ALCOHOL_AMOUNT = "alcohol_amount"
SLEEP_BUCKETS = ("<4", "4-6", "6-8", ">8")
ALCOHOL_BUCKETS = ("none", "1-2", "more")
_NUMBER = re.compile(r"^\s*(\d{1,2})\s*$")
# ------------------------------------------------------------------------------------------- texts
class _Strict(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
class DailySettings(_Strict):
sleep_modules: list[str]
alcohol_daily_modules: list[str]
intro_scales: list[str]
class Question(_Strict):
question: str
class OptionsQuestion(_Strict):
question: str
options: dict[str, str]
class YesNoQuestion(_Strict):
question: str
yes: str
no: str
class AlcoholQuestion(_Strict):
question: str
clarify: str
options: dict[str, str]
class TextQuestion(_Strict):
question: str
skip: str
class CheckinTexts(_Strict):
start: str
mood: Question
sleep: OptionsQuestion
nightmares: YesNoQuestion
alcohol: AlcoholQuestion
text: TextQuestion
buttons_hint: str
done: str
already_done: str
class VisitTexts(_Strict):
requested: str
class ScaleIntros(_Strict):
scheduled: dict[str, str]
unscheduled: dict[str, str]
class DailyTexts(_Strict):
status: str
settings: DailySettings
checkin: CheckinTexts
visit: VisitTexts
scale_intros: ScaleIntros
def daily_texts_path() -> Path:
return get_settings().config_dir / "texts" / "veteran_daily.yaml"
@lru_cache
def load_daily_texts(path: Path | None = None) -> DailyTexts:
path = path or daily_texts_path()
return DailyTexts.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
# ------------------------------------------------------------------------------------------- step logic
def _today(ctx: Ctx) -> str:
return ctx.now.date().isoformat()
def _ck(ctx: Ctx) -> dict[str, Any]:
return dict(ctx.data.get("checkin") or {})
async def _modules(ctx: Ctx) -> set[str]:
profile = await ctx.profile()
return set(profile.modules or []) if profile is not None else set()
async def _auditc_positive(ctx: Ctx) -> bool:
"""Latest AUDIT-C at or above the sex-specific cut-off of rule `auditc_positive` (one threshold, one place)."""
results = await repo.list_scale_results(ctx.session, ctx.token, "auditc")
if not results:
return False
rule = next((r for r in load_rules().rules if r.id == "auditc_positive"), None)
if rule is None:
return False
profile = await ctx.profile()
key = "threshold_f" if profile is not None and profile.sex == "f" else "threshold_m"
return results[-1].total >= int(rule.params[key])
async def _applicable(ctx: Ctx, step: str) -> bool:
settings = load_daily_texts().settings
if step in ("mood", "text"):
return True
if step == "med":
today = await checkin_of(ctx, ctx.now.date())
return (today is None or today.med_taken is None) and await current_prescription(ctx) is not None
modules = await _modules(ctx)
if step in ("sleep", "nightmares"):
return bool(modules & set(settings.sleep_modules))
if step == "alcohol":
return bool(modules & set(settings.alcohol_daily_modules)) or await _auditc_positive(ctx)
return False
async def _next_step(ctx: Ctx, after: str | None) -> str:
rest = STEPS if after is None else STEPS[STEPS.index(after) + 1 :]
for step in rest:
if await _applicable(ctx, step):
return step
return "text"
def _buttons(step: str, options: list[tuple[str, str]]) -> list[list[Button]]:
return [[Button(label=label, data=f"{PREFIX}:{step}:{value}")] for value, label in options]
async def _question(ctx: Ctx, step: str, note: str = "") -> Reply:
t = load_daily_texts().checkin
buttons: list[list[Button]]
if step == "mood":
text = t.mood.question
# Two rows (0–5, 6–10): 11 buttons in one column are hard to use on a phone.
buttons = [
[Button(label=str(v), data=f"{PREFIX}:mood:{v}") for v in range(6)],
[Button(label=str(v), data=f"{PREFIX}:mood:{v}") for v in range(6, 11)],
]
elif step == "med":
m = load_meds_texts().checkin
rx = await current_prescription(ctx)
text = m.question.format(drug=rx.drug) if rx is not None else m.question_no_drug
buttons = _buttons(step, [("yes", m.yes), ("no", m.no), ("later", m.later)])
elif step == "sleep":
text = t.sleep.question
buttons = _buttons(step, [(b, t.sleep.options[b]) for b in SLEEP_BUCKETS])
elif step == "nightmares":
text = t.nightmares.question
buttons = _buttons(step, [("yes", t.nightmares.yes), ("no", t.nightmares.no)])
elif step in ("alcohol", ALCOHOL_AMOUNT):
text = t.alcohol.question if step == "alcohol" else t.alcohol.clarify
buttons = _buttons(step, [(b, t.alcohol.options[b]) for b in ALCOHOL_BUCKETS])
else:
text = t.text.question
buttons = _buttons("text", [("skip", t.text.skip)])
return Reply(text="\n\n".join(p for p in (note, text) if p), buttons=buttons, kind="question")
async def start_checkin(ctx: Ctx, *, from_menu: bool) -> Step:
if ctx.data.get("checkin_done") == _today(ctx):
# The scheduler may fire twice; the menu button gets a friendly note instead of a second check-in.
return Step(replies=[menu_reply(ctx, load_daily_texts().checkin.already_done)]) if from_menu else Step()
first = await _next_step(ctx, None)
ck = {"day": _today(ctx), "answered": [], "started_at": ctx.now.isoformat()}
return Step(
replies=[Reply(text=load_daily_texts().checkin.start)],
next_state=f"{PREFIX}.{first}",
data={"checkin": ck},
)
def _finish(ctx: Ctx, replies: list[Reply] | None = None) -> Step:
done = Reply(text=load_daily_texts().checkin.done)
return Step(
replies=[done, *(replies or [])],
next_state="idle",
data={"checkin": None, "checkin_done": _today(ctx)},
notify_monitoring=True,
)
def _consumed_by_safety(ctx: Ctx, ck: dict[str, Any]) -> bool:
"""The free-text answer was taken over by the safety layer (C-SSRS screen) after the question was asked."""
asked = ck.get("text_asked_at")
if not asked:
return False
for event in reversed(ctx.data.get("safety_events") or []):
if event.get("state") == f"{PREFIX}.text" and datetime.fromisoformat(event["ts"]) >= datetime.fromisoformat(
asked
):
return True
return False
@on_enter(PREFIX)
async def _enter(ctx: Ctx) -> Step:
step = ctx.state.split(".", 1)[1] if "." in ctx.state else ""
ck = _ck(ctx)
if not ck or ck.get("day") != _today(ctx):
return Step(next_state="idle", data={"checkin": None})
if step == "text":
if _consumed_by_safety(ctx, ck):
return _finish(ctx)
return Step(
replies=[await _question(ctx, step)], data={"checkin": {**ck, "text_asked_at": ctx.now.isoformat()}}
)
return Step(replies=[await _question(ctx, step)])
async def _advance(ctx: Ctx, step: str, ck: dict[str, Any]) -> Step:
ck = {**ck, "answered": [*ck.get("answered", []), step]}
if step == ALCOHOL_AMOUNT:
return _finish(ctx)
nxt = await _next_step(ctx, step)
return Step(next_state=f"{PREFIX}.{nxt}", data={"checkin": ck})
async def _record_button(ctx: Ctx, step: str, value: str) -> bool:
"""Store one button answer right away. False = value not valid for this step (stale/forged button)."""
if step == "mood" and value.isdigit() and 0 <= int(value) <= 10:
await save_checkin(ctx, mood=int(value))
elif step == "med" and value in ("yes", "no", "later"):
if value != "later": # «Ещё нет» is not a missed dose: nothing is recorded yet
await record_dose(ctx, value == "yes", status=None if value == "yes" else "missed")
elif step == "sleep" and value in SLEEP_BUCKETS:
await save_checkin(ctx, sleep_bucket=value)
elif step == "nightmares" and value in ("yes", "no"):
await save_checkin(ctx, nightmares=value == "yes")
elif step in ("alcohol", ALCOHOL_AMOUNT) and value in ALCOHOL_BUCKETS:
await save_checkin(ctx, alcohol_bucket=value)
else:
return False
return True
async def _free_text(ctx: Ctx, ck: dict[str, Any]) -> Step:
assert ctx.text is not None
facts = await ctx.gateway.extract_checkin(ctx.text)
answered = set(ck.get("answered", []))
fields: dict[str, Any] = {"free_text_scrubbed": ctx.text}
replies: list[Reply] = []
clarify_alcohol = False
if facts is not None:
if facts.quit_intent:
fields["quit_intent"] = True
# Buttons win: the LLM only fills what was not asked today.
if "med" not in answered and facts.med_taken is not None:
today = await checkin_of(ctx, ctx.now.date())
if today is None or today.med_taken is None:
fields["med_taken"] = facts.med_taken
if "sleep" not in answered and facts.sleep_hours_bucket is not None:
fields["sleep_bucket"] = facts.sleep_hours_bucket
if "nightmares" not in answered and facts.nightmares is not None:
fields["nightmares"] = facts.nightmares
if "alcohol" not in answered and facts.alcohol is not None:
fields["alcohol_bucket"] = facts.alcohol # provisional; the amount buttons below refine it
clarify_alcohol = facts.alcohol != "none"
if facts.side_effects:
await record_side_effects(ctx, [str(c) for c in facts.side_effects])
# One topic per message: when the text also carries a quit intent or alcohol, the rules answer
# those (delayed onset / interaction); the side-effect excerpt would only bury that message.
if not facts.quit_intent and facts.alcohol in (None, "none"):
replies.append(await side_effect_reply(ctx, [str(c) for c in facts.side_effects]))
await save_checkin(ctx, **fields)
if clarify_alcohol:
return Step(replies=replies, next_state=f"{PREFIX}.{ALCOHOL_AMOUNT}", data={"checkin": ck})
return _finish(ctx, replies)
@state(PREFIX)
async def _handle(ctx: Ctx, event: Event) -> Step:
step = ctx.state.split(".", 1)[1] if "." in ctx.state else ""
ck = _ck(ctx)
if not ck or ck.get("day") != _today(ctx):
# Unfinished check-in from an earlier day: drop it and handle the event as idle would.
return await leave_to_idle(ctx, event, {"checkin": None})
if event.type == "button" and event.button:
parts = event.button.split(":")
if len(parts) == 3 and parts[0] == PREFIX and parts[1] == step:
if step == "text" and parts[2] == "skip":
return _finish(ctx)
if await _record_button(ctx, step, parts[2]):
return await _advance(ctx, step, ck)
if parts[0] == "med":
# A reminder button pressed mid check-in: answer it, then repeat the current question.
inline = await run_trigger_inline(ctx, f"button:{event.button}", event)
if inline is not None:
return inline if inline.next_state else inline.then(Step(replies=[await _question(ctx, step)]))
if parts[0] in (PREFIX, "scale"):
return Step(replies=[await _question(ctx, step)]) # stale button of an earlier question
return await leave_to_idle(ctx, event, {"checkin": None})
if event.type == "system" and event.system:
if event.system == "daily_checkin":
return Step(replies=[await _question(ctx, step)])
inline = await run_trigger_inline(ctx, f"system:{event.system}", event)
if inline is None:
return Step()
return inline if inline.next_state else inline.then(Step(replies=[await _question(ctx, step)]))
if event.type == "text" and ctx.text is not None:
if step == "text":
return await _free_text(ctx, ck)
number = _NUMBER.match(ctx.text)
if step == "mood" and number and await _record_button(ctx, "mood", number.group(1)):
return await _advance(ctx, step, ck)
return Step(replies=[await _question(ctx, step, note=load_daily_texts().checkin.buttons_hint)])
return Step(replies=[await _question(ctx, step)])
# ------------------------------------------------------------------------------------------- idle triggers
@trigger("system:daily_checkin", replace=True)
async def _daily_checkin(ctx: Ctx, event: Event) -> Step:
if intake_enabled(): # the intake diary (#53, feature flag) replaces this check-in when released
return await intake.checkin(ctx, event)
return await start_checkin(ctx, from_menu=False)
@trigger("button:menu:checkin", replace=True)
async def _menu_checkin(ctx: Ctx, event: Event) -> Step:
if intake_enabled():
return await intake.checkin(ctx, event)
return await start_checkin(ctx, from_menu=True)
@trigger("button:menu:visit", replace=True)
async def _visit(ctx: Ctx, event: Event) -> Step:
# «Записаться раньше» (rule action suggest_visit): no online booking yet — the request is a fact for lane B
# (#38) and the dashboard, like `contact_requests`. No alert is created here (ADR-007).
requests = [*(ctx.data.get("visit_requests") or []), {"ts": ctx.now.isoformat()}][-20:]
return Step(
replies=[Reply(text=load_daily_texts().visit.requested)],
data={"visit_requests": requests},
notify_monitoring=True,
)
@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."""
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"))])
fields: dict[str, Any] = {}
if facts.quit_intent:
fields["quit_intent"] = True
if facts.alcohol is not None and facts.alcohol != "none":
today = await checkin_of(ctx, ctx.now.date())
if today is None or today.alcohol_bucket in (None, "none"):
fields["alcohol_bucket"] = facts.alcohol
text: str | None = None
if facts.side_effects:
codes = [str(c) for c in facts.side_effects]
await record_side_effects(ctx, codes)
if not facts.quit_intent:
text = (await side_effect_reply(ctx, codes)).text
if fields:
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:
text = await ctx.gateway.phrase("support")
return Step(replies=[menu_reply(ctx, text)], notify_monitoring=bool(fields or facts.side_effects))
# ------------------------------------------------------------------------------------------- scale intros
async def _scale_intro(ctx: Ctx) -> Step:
"""Add the friendly intro of a scheduled (or rule-triggered) scale, then run the generic scale enter hook.
Onboarding sessions (#36) and sessions started with their own intro are left untouched.
"""
scale_id = active_scale(ctx.state)
sessions = dict(ctx.data.get("scales") or {})
session = sessions.get(scale_id) if scale_id else None
if (
scale_id is not None
and session is not None
and session.get("idx") == 0
and not session.get("intro_shown")
and not session.get("intro")
and ".onboarding" not in str(session.get("return_state", ""))
):
intros = load_daily_texts().scale_intros
intro = (intros.scheduled if session.get("scheduled", True) else intros.unscheduled).get(scale_id)
if intro:
sessions[scale_id] = {**session, "intro": intro}
ctx = replace(ctx, data={**ctx.data, "scales": sessions})
generic = enter_hook_for(SCALE_PREFIX)
return await generic(ctx) if generic is not None else Step()
for _scale_id in load_daily_texts().settings.intro_scales:
on_enter(scale_state(_scale_id))(_scale_intro)
+98
View File
@@ -0,0 +1,98 @@
# Тексты приёма препарата (issue #37): напоминание, пропуск, побочные эффекты, вопрос о лекарстве, ответы
# на действия правил med_info (отсроченный эффект, алкоголь, побочные эффекты).
# СТАТУС: черновик, утверждает психиатр (#8).
#
# Правила текстов: без диагнозов и названий расстройств; без советов по дозам и схеме приёма (решает врач);
# не использовать слова, которые звучат как постановка на учёт или слежка (проверяется тестом). Ответы о
# препарате берутся только из выдержки инструкции ГРЛС (app/llm/med_qa.py); тексты ниже — обрамление и
# запасные варианты, если выдержки нет.
status: "черновик, утверждает психиатр (#8)"
reminder:
# {drug}, {dose} — из назначения врача (МНН и дозировка как в назначении, без изменений).
text: "Время принять {drug} {dose}. Отметьте, пожалуйста, как получилось."
text_no_drug: "Время принять препарат, назначенный врачом. Отметьте, пожалуйста, как получилось."
taken: "Принял(а)"
skip: "Пропущу"
side_effects: "Есть побочные эффекты"
taken_ack: "Отлично, отметил приём. Спасибо!"
# Кнопка от напоминания, которое уже неактуально (например, вчерашнего).
stale: "Это напоминание уже неактуально. Если хотите отметить самочувствие — нажмите «Как я сегодня»."
skip:
question: "Понял, отметил. Подскажите, что помешало? Это поможет вашему врачу."
reasons:
forgot: "Забыл(а)"
side_effects: "Из-за побочных эффектов"
quit: "Решил(а) бросить"
other: "Другое"
ack:
forgot: "Понял, бывает. Завтра напомню в обычное время."
quit: "Спасибо, что честно сказали — это важно, и решать это лучше вместе с врачом."
other: "Понял, отметил. Если захотите рассказать подробнее — просто напишите."
side_effects:
question: "Что беспокоит? Выберите вариант или «Другое», чтобы описать своими словами."
options:
nausea: "Тошнота"
insomnia: "Бессонница"
headache: "Головная боль"
dizziness: "Головокружение"
agitation: "Беспокойство"
other: "Другое"
# Короткий вопрос к инструкции препарата (med_qa, раздел «побочные эффекты») для выбранного эффекта.
questions:
nausea: "тошнота после приёма, когда пройдёт"
insomnia: "бессонница, нарушения сна"
headache: "головная боль"
dizziness: "головокружение"
agitation: "беспокойство, тревога"
describe: "Опишите, пожалуйста, коротко, что вы заметили."
ack: "Спасибо, отметил. Ваш врач увидит это в вашей карточке."
# Тот же эффект был и вчера: инструкцию второй раз не повторяем.
repeat: "Понимаю, это неприятно. Я отмечаю это каждый день, и ваш врач увидит, если не проходит."
ask_dose: "А препарат сегодня приняли?"
dose_yes: "Да, принял(а)"
dose_no: "Нет"
# Ежедневный чек-ин: если сегодня ещё нет отметки о приёме.
checkin:
question: "Сегодня приняли {drug}?"
question_no_drug: "Сегодня приняли назначенный препарат?"
"yes": "Да"
"no": "Нет"
later: "Ещё нет"
question:
prompt: "Напишите ваш вопрос о препарате. Я отвечаю только по официальной инструкции, а если ответа в ней нет — подскажу обратиться к врачу."
no_prescription: "Я не нашёл в карточке назначенного препарата. Пожалуйста, уточните вопрос у вашего врача."
# Ответы на действие правила med_info. Ответ = lead + выдержка из инструкции (med_qa по разделу section,
# вопрос question) + notice. Если выдержки нет — lead + fallback + notice.
med_info:
delayed_effect:
section: onset
question: "когда начнёт действовать препарат, нет эффекта, только хуже"
lead: "Понимаю, что сейчас тяжело."
fallback: "Эффект таких препаратов обычно наступает не сразу, а через 2–4 недели регулярного приёма. Пожалуйста, не прекращайте приём самостоятельно."
notice: "Я сообщил вашему врачу — он свяжется с вами, чтобы обсудить, как вы себя чувствуете."
alcohol_interaction:
section: alcohol
question: "можно ли алкоголь вместе с препаратом, сон"
lead: "Спасибо, что честно рассказали."
fallback: "В инструкции к препарату есть предупреждение о совместном приёме с алкоголем, а алкоголь сам по себе ухудшает сон."
notice: "Я передал это вашему врачу."
nausea:
section: side_effects
question: "тошнота после приёма, когда пройдёт"
lead: ""
fallback: "Тошнота часто встречается в первые дни приёма и обычно проходит сама."
notice: "Если станет хуже — напишите мне, я передам врачу."
side_effects:
section: side_effects
question: "побочные эффекты в начале приёма"
lead: ""
fallback: "Спасибо, что сообщили о самочувствии. Я отмечу это для вашего врача."
notice: "Если станет хуже — напишите мне, я передам врачу."
+62
View File
@@ -0,0 +1,62 @@
# Тексты ежедневного чек-ина и плановых шкал (issue #37, ADR-002, CLINICAL_MODULES §1–2).
# Ядро (настроение + приём) задаётся всем; вопрос про сон и кошмары — модулю veteran; алкоголь — см. settings.
# СТАТУС: черновик, утверждает психиатр (#8).
#
# Правила текстов: без диагнозов и названий расстройств; без советов по дозам; пациент не видит баллов шкал;
# не использовать слова, которые звучат как постановка на учёт или слежка (проверяется тестом).
status: "черновик, утверждает психиатр (#8)"
settings:
# Модули, которым задаётся вопрос про сон и кошмары (modules.yaml: daily_question = sleep_nightmares).
sleep_modules: [veteran]
# Ежедневный вопрос об алкоголе: модули, где он задаётся всегда (у ветеранов алкоголь — частый способ
# «заглушить» бессонницу и фактор риска, CLINICAL_MODULES §1); остальным — при положительном AUDIT-C
# (порог берётся из правила auditc_positive в config/rules.yaml, ADR-002).
alcohol_daily_modules: [veteran]
# Плановые шкалы, перед которыми показывается дружелюбное вступление (см. scale_intros).
intro_scales: [pcl5, phq9, dar5, auditc]
checkin:
start: "Добрый день! Короткая отметка о самочувствии — это займёт меньше минуты."
mood:
question: "Как ваше настроение сегодня по шкале от 0 до 10? 0 — очень плохо, 10 — отлично."
sleep:
question: "Сколько часов удалось поспать прошлой ночью?"
options:
"<4": "Меньше 4 ч"
"4-6": "4–6 ч"
"6-8": "6–8 ч"
">8": "Больше 8 ч"
nightmares:
question: "Снились ли кошмары или тяжёлые сны?"
"yes": "Да"
"no": "Нет"
alcohol:
question: "Выпивали ли вы алкоголь за последние сутки?"
clarify: "Вы упомянули алкоголь. Сколько примерно выпили за последние сутки?"
options:
none: "Не пил(а)"
"1-2": "1–2 порции"
more: "Больше"
text:
question: "Как вы? Можно пару слов — что было за день, что беспокоит."
skip: "Пропустить"
buttons_hint: "Выберите, пожалуйста, вариант кнопкой."
done: "Спасибо, записал. Если что-то изменится — пишите в любое время."
already_done: "Сегодня вы уже отмечались, спасибо! Если хотите что-то добавить — просто напишите."
visit:
# Нажата кнопка «Записаться к врачу раньше» (действие правила suggest_visit). Онлайн-записи пока нет —
# просьба передаётся врачу как факт в карточке.
requested: "Передал врачу просьбу о более раннем визите. С вами свяжутся, чтобы договориться о времени. Если станет хуже до визита — пишите мне или звоните 112."
# Дружелюбное вступление перед шкалой (параметр intro у start_scale). Пациент не видит баллов.
scale_intros:
scheduled:
pcl5: "Раз в две недели я прошу ответить на вопросы о том, как беспокоят тяжёлые воспоминания и их последствия. Это займёт 3–4 минуты. Так ваш врач увидит, что меняется со временем."
phq9: "Пора для короткого опроса о настроении и силах за последние две недели — 9 вопросов. Отвечайте так, как есть, правильных ответов нет."
dar5: "Несколько вопросов о раздражительности и вспышках гнева. После службы это бывает у многих, и врачу важно видеть, как это меняется."
auditc: "Три коротких вопроса об алкоголе. Отвечайте как есть: это нужно врачу, чтобы лечение было безопасным."
unscheduled:
phq9: "Последние дни настроение ниже обычного. Давайте я задам несколько вопросов — так ваш врач лучше поймёт, как вы сейчас."
+372
View File
@@ -0,0 +1,372 @@
"""Medication flow and check-in units (#37): reminder buttons, skip reasons, side effects, medication question,
med_info rule reactions, check-in edge cases, text policy of the YAML files."""
from collections.abc import AsyncIterator
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
import pytest
import yaml
from sqlalchemy import select
from app.config import Settings, get_settings
from app.core import clock
from app.core.rules import Action
from app.db import repo, session
from app.db.bootstrap import init_db
from app.db.clinical import Dose, Prescription
from app.dialog.contracts import Event, Reply
from app.dialog.engine import Engine
from app.dialog.flows.meds import load_meds_texts
from app.dialog.flows.veteran_daily import load_daily_texts
from app.dialog.fsm import Ctx, enter_hook_for
from app.dialog.scale_session import start_scale
from app.dialog.texts import load_dialog_texts
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 fallback_text
from app.llm.mock import MockTasks
TOKEN = "m" * 32
MEM = "sqlite+aiosqlite:///:memory:"
NOW = datetime(2026, 10, 7, 8, 0)
MEDS = load_meds_texts()
DAILY = load_daily_texts()
SECTIONS = {
"side_effects": "Тошнота обычно возникает в первые дни приёма и проходит в течение 1–2 недель.",
"onset": "Улучшение обычно отмечается через 2–4 недели регулярного приёма.",
"alcohol": "Совместный приём с алкоголем не рекомендуется.",
"administration": "Препарат принимают один раз в сутки, утром или вечером, независимо от еды.",
}
class FakeDrugInfo:
async def sections(self, inn: str) -> list[DrugSection]:
return [DrugSection(inn=inn, section=k, text=v, source="синтетика") for k, v in SECTIONS.items()]
class ScriptedMonitoring:
def __init__(self) -> None:
self.calls = 0
self.actions: list[Action] = []
async def after_data_change(self, token: str) -> list[Action]:
self.calls += 1
out, self.actions = self.actions, []
return out
async def is_in_crisis(self, token: str) -> bool:
return False
@pytest.fixture
async def mon(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[ScriptedMonitoring]:
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()))
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 ScriptedMonitoring()
clock.reset()
await session.configure()
@pytest.fixture
def engine(mon: ScriptedMonitoring) -> Engine:
return Engine(gateway=LLMGateway(MockTasks()), monitoring=mon)
async def press(e: Engine, data: str) -> list[Reply]:
return await e.handle(TOKEN, Event(type="button", button=data))
async def text(e: Engine, t: str) -> list[Reply]:
return await e.handle(TOKEN, Event(type="text", text=t))
async def system(e: Engine, kind: str, **params: Any) -> list[Reply]:
return await e.handle(TOKEN, Event(type="system", system=kind, params=params)) # type: ignore[arg-type]
def buttons(replies: list[Reply]) -> list[str]:
return [b.data for r in replies for row in r.buttons for b in row]
async def get_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)
async def checkin() -> Any:
async with session.get_clinical_session() as s:
rows = await repo.list_checkins(s, TOKEN)
return rows[-1] if rows else None
async def doses() -> list[Dose]:
async with session.get_clinical_session() as s:
return list((await s.scalars(select(Dose).where(Dose.token == TOKEN).order_by(Dose.id))).all())
# ----------------------------------------------------------------------------------------------- reminder
async def test_reminder_without_dose_id_creates_dose_and_taken(engine: Engine, mon: ScriptedMonitoring) -> None:
r = await system(engine, "med_reminder")
assert r[0].text == MEDS.reminder.text.format(drug="сертралин", dose="50 мг")
assert buttons(r) == ["med:taken", "med:skip", "med:side_effects"]
[dose] = await doses()
assert dose.status == "pending"
r = await press(engine, "med:taken")
assert r[0].text == MEDS.taken_ack and mon.calls == 1
[dose] = await doses()
assert dose.status == "taken" and dose.answered_at is not None
assert (await checkin()).med_taken is True
async def test_stale_reminder_button(engine: Engine) -> None:
await system(engine, "med_reminder")
clock.advance(days=1)
r = await press(engine, "med:taken")
assert r[0].text == MEDS.stale and await checkin() is None
@pytest.mark.parametrize("reason", ["forgot", "other"])
async def test_skip_simple_reasons(engine: Engine, reason: str) -> None:
await system(engine, "med_reminder")
r = await press(engine, "med:skip")
assert buttons(r) == [f"med:skip_reason:{x}" for x in ("forgot", "side_effects", "quit", "other")]
r = await press(engine, f"med:skip_reason:{reason}")
assert r[0].text == MEDS.skip.ack[reason]
c = await checkin()
assert c.med_taken is False and not c.quit_intent
assert [d.status for d in await doses()] == ["skipped"]
async def test_skip_quit_sets_intent_and_explains(engine: Engine, mon: ScriptedMonitoring) -> None:
await system(engine, "med_reminder")
await press(engine, "med:skip")
mon.actions = [Action(type="med_info", code="quit_intent", params={"topic": "delayed_effect"})]
r = await press(engine, "med:skip_reason:quit")
assert r[0].text == MEDS.skip.ack["quit"]
assert "2–4 недели" in r[1].text and MEDS.med_info["delayed_effect"].notice in r[1].text
assert (await checkin()).quit_intent is True
async def test_skip_because_of_side_effects_asks_which(engine: Engine) -> None:
await system(engine, "med_reminder")
await press(engine, "med:skip")
r = await press(engine, "med:skip_reason:side_effects")
assert "med:se:nausea" in buttons(r) and "med:se:other" in buttons(r)
async def test_side_effect_button(engine: Engine, mon: ScriptedMonitoring) -> None:
await system(engine, "med_reminder")
r = await press(engine, "med:side_effects")
assert buttons(r) == [f"med:se:{c}" for c in ("nausea", "insomnia", "headache", "dizziness", "agitation", "other")]
r = await press(engine, "med:se:nausea")
assert "проходит в течение 1–2 недель" in r[0].text # instruction excerpt
assert r[1].text == MEDS.side_effects.ask_dose and buttons(r[1:]) == ["med:dose:yes", "med:dose:no"]
assert (await checkin()).side_effects == ["nausea"]
assert (await doses())[0].side_effects == "nausea"
r = await press(engine, "med:dose:yes")
assert (await checkin()).med_taken is True and (await doses())[0].status == "taken"
async def test_side_effect_other_free_text(engine: Engine) -> None:
await system(engine, "med_reminder")
await press(engine, "med:side_effects")
r = await press(engine, "med:se:other")
assert r[0].text == MEDS.side_effects.describe and (await get_state())[0] == "meds.side_effect_text"
r = await text(engine, "голова болит весь день")
assert (await checkin()).side_effects == ["headache"] and (await get_state())[0] == "idle"
await press(engine, "med:side_effects")
await press(engine, "med:se:other")
await text(engine, "какое-то странное ощущение")
assert (await checkin()).side_effects == ["headache", "other"]
async def test_same_side_effect_as_yesterday_gets_support(engine: Engine) -> None:
async with session.get_clinical_session() as s:
await repo.upsert_checkin(
s, token=TOKEN, day=clock.today() - timedelta(days=1), now=clock.now(), side_effects=["nausea"]
)
await system(engine, "med_reminder")
r = await press(engine, "med:se:nausea")
assert r[0].text == MEDS.side_effects.repeat
# ----------------------------------------------------------------------------------------------- question menu
async def test_med_question_menu(engine: Engine) -> None:
r = await press(engine, "menu:med_question")
assert r[0].text == MEDS.question.prompt and (await get_state())[0] == "meds.question"
r = await text(engine, "можно ли пить алкоголь?")
assert "Совместный приём с алкоголем не рекомендуется" in r[0].text and (await get_state())[0] == "idle"
await press(engine, "menu:med_question")
r = await text(engine, "можно увеличить дозу до 100 мг?")
assert r[0].text == fallback_text()
async def test_med_question_menu_left_by_button(engine: Engine) -> None:
await press(engine, "menu:med_question")
r = await press(engine, "menu:checkin")
assert r[0].text == DAILY.checkin.start and (await get_state())[0] == "daily.mood"
async def test_med_question_without_prescription(engine: Engine) -> None:
async with session.get_clinical_session() as s:
for row in await s.scalars(select(Prescription)):
await s.delete(row)
r = await press(engine, "menu:med_question")
assert r[0].text == MEDS.question.no_prescription and (await get_state())[0] == "idle"
async def test_med_info_without_excerpt_uses_fixed_text(
engine: Engine, mon: ScriptedMonitoring, monkeypatch: pytest.MonkeyPatch
) -> None:
class Empty:
async def sections(self, inn: str) -> list[DrugSection]:
return []
monkeypatch.setattr(integrations, "drug_info", Empty())
mon.actions = [Action(type="med_info", code="alcohol_3d", params={"topic": "alcohol_interaction"})]
r = await press(engine, "menu:visit") # any data-writing step runs monitoring
spec = MEDS.med_info["alcohol_interaction"]
assert r[0].text == DAILY.visit.requested
assert r[1].text == "\n\n".join([spec.lead, spec.fallback, spec.notice])
assert (await get_state())[1]["visit_requests"]
async def test_idle_free_text_med_question_and_side_effect(engine: Engine, mon: ScriptedMonitoring) -> None:
r = await text(engine, "когда подействуют таблетки?")
assert "2–4 недели" in r[0].text and "menu:checkin" in buttons(r)
r = await text(engine, "подташнивает после таблеток")
assert "проходит в течение 1–2 недель" in r[0].text and (await checkin()).side_effects == ["nausea"]
assert mon.calls == 1
# ----------------------------------------------------------------------------------------------- check-in edges
async def test_checkin_asks_dose_when_not_answered_and_typed_mood(engine: Engine) -> None:
await system(engine, "daily_checkin")
r = await text(engine, "отлично")
assert DAILY.checkin.buttons_hint in r[0].text and (await get_state())[0] == "daily.mood"
r = await text(engine, "7")
assert r[0].text == MEDS.checkin.question.format(drug="сертралин")
await press(engine, "daily:med:no")
c = await checkin()
assert (c.mood, c.med_taken) == (7, False)
assert (await get_state())[0] == "daily.sleep"
async def test_reminder_during_checkin_keeps_checkin(engine: Engine) -> None:
await system(engine, "daily_checkin")
r = await system(engine, "med_reminder")
assert buttons(r[:1]) == ["med:taken", "med:skip", "med:side_effects"] and r[-1].text == DAILY.checkin.mood.question
r = await press(engine, "med:taken")
assert r[0].text == MEDS.taken_ack and (await get_state())[0] == "daily.mood"
await press(engine, "daily:mood:5")
assert (await get_state())[0] == "daily.sleep" # dose already answered → no med question
async def test_unfinished_checkin_dropped_next_day(engine: Engine) -> None:
await system(engine, "daily_checkin")
await press(engine, "daily:mood:2")
clock.advance(days=1)
r = await system(engine, "daily_checkin")
assert r[0].text == DAILY.checkin.start and (await get_state())[0] == "daily.mood"
async def test_checkin_once_a_day(engine: Engine) -> None:
await system(engine, "daily_checkin")
for b in ("daily:mood:6", "daily:med:yes", "daily:sleep:6-8", "daily:nightmares:no", "daily:alcohol:none"):
await press(engine, b)
await press(engine, "daily:text:skip")
assert await system(engine, "daily_checkin") == []
r = await press(engine, "menu:checkin")
assert r[0].text == DAILY.checkin.already_done
async def test_alcohol_clarify_for_non_veteran(engine: Engine) -> None:
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, modules=["depression"])
await system(engine, "daily_checkin")
await press(engine, "daily:mood:5")
r = await press(engine, "daily:med:yes")
assert buttons(r) == ["daily:text:skip"] # no sleep / alcohol questions outside the veteran module
r = await text(engine, "вечером выпил пива с друзьями")
assert r[-1].text == DAILY.checkin.alcohol.clarify and (await get_state())[0] == "daily.alcohol_amount"
r = await press(engine, "daily:alcohol_amount:more")
assert r[0].text == DAILY.checkin.done and (await checkin()).alcohol_bucket == "more"
async def test_buttons_win_over_llm(engine: Engine) -> None:
await system(engine, "daily_checkin")
for b in ("daily:mood:5", "daily:med:yes", "daily:sleep:6-8", "daily:nightmares:no", "daily:alcohol:none"):
await press(engine, b)
await text(engine, "почти не спал, кошмары, брошу таблетки")
c = await checkin()
assert (c.sleep_bucket, c.nightmares, c.alcohol_bucket, c.quit_intent) == ("6-8", False, "none", True)
assert c.free_text_scrubbed
async def test_scale_intro_not_added_to_onboarding(engine: Engine, mon: ScriptedMonitoring) -> None:
await system(engine, "scale_due", scale="dar5", scheduled=True)
state, data = await get_state()
assert state == "scale.dar5" and data["scales"]["dar5"]["intro"] == DAILY.scale_intros.scheduled["dar5"]
async with session.get_clinical_session() as s:
ctx = Ctx(
token=TOKEN,
state="idle",
data={},
now=clock.now(),
session=s,
gateway=LLMGateway(MockTasks()),
monitoring=mon,
texts=load_dialog_texts(),
)
step = await start_scale(ctx, "pcl5", return_state="veteran.onboarding.phq9")
ctx.data.update(step.data)
ctx.state = "scale.pcl5"
hook = enter_hook_for("scale.pcl5")
assert hook is not None
entered = await hook(ctx)
assert DAILY.scale_intros.scheduled["pcl5"] not in entered.replies[0].text
# ----------------------------------------------------------------------------------------------- text policy
def _strings(node: Any) -> list[str]:
if isinstance(node, str):
return [node]
if isinstance(node, dict):
return [s for v in node.values() for s in _strings(v)]
if isinstance(node, list):
return [s for v in node for s in _strings(v)]
return []
@pytest.mark.parametrize("name", ["meds.yaml", "veteran_daily.yaml"])
def test_texts_policy(name: str) -> None:
path: Path = get_settings().config_dir / "texts" / name
raw = path.read_text(encoding="utf-8")
assert "черновик, утверждает психиатр (#8)" in raw.splitlines()[2]
values = " ".join(_strings(yaml.safe_load(raw))).lower()
for word in ("учёт", "учет", "контрол", "надзор", "диагноз", "птср", "депресси", "увеличьте доз", "мг"):
assert word not in values, (name, word)
+388
View File
@@ -0,0 +1,388 @@
"""Acceptance: 13 days of the veteran Andrey (docs/product/01_demo_persona.md) through the real dialog engine.
Andrey is set up post-onboarding directly in the DBs (no dependency on #36): modules veteran + depression,
therapy day 0, sertraline 50 mg at 08:00, consent. Each day: the dose reminder (as the scheduler would send it)
→ button; the daily check-in → buttons + the free text from the table.
Lane B's MonitoringService (#38) is replaced by a double that builds a `PatientSnapshot` from the clinical DB,
runs the REAL `app.core.rules.evaluate`, persists alert/crisis actions (so dedup works) and returns the dialog
actions — the same split as the frozen contract (ADR-007). Drug instruction excerpts are synthetic (FakeDrugInfo).
"""
from collections.abc import AsyncIterator
from datetime import date, datetime, timedelta
from typing import Any
import pytest
from sqlalchemy import func, select
from app.config import Settings
from app.core import clock
from app.core.rules import Action, DailyCheckin, DoseRecord, PatientSnapshot, ScalePoint, evaluate, patient_status
from app.core.scales import load_scale
from app.core.types import AlertLevel
from app.db import identity_repo, repo, session
from app.db.bootstrap import init_db
from app.db.clinical import Dose, Message, Prescription
from app.dialog.contracts import Event, Reply
from app.dialog.engine import Engine
from app.dialog.flows.meds import load_meds_texts
from app.dialog.flows.veteran_daily import load_daily_texts
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.mock import MockTasks
from app.services import contracts as monitoring_contracts
TOKEN = "e" * 32
MEM = "sqlite+aiosqlite:///:memory:"
DAY0 = datetime(2026, 10, 5, 8, 0) # visit day; reminders at 08:00, check-ins at 20:00 (Europe/Moscow)
SRC = "ГРЛС: тестовая выдержка (синтетика)"
SERTRALINE = {
"side_effects": (
"Наиболее частые нежелательные реакции в начале терапии: тошнота, диарея, сухость во рту. "
"Тошнота обычно возникает в первые дни приёма и проходит в течение 1–2 недель. "
"Для уменьшения тошноты препарат принимают после еды."
),
"onset": (
"Терапевтический эффект развивается постепенно. "
"Улучшение обычно отмечается через 2–4 недели регулярного приёма. "
"Суточная доза может увеличиваться врачом до 200 мг."
),
"alcohol": (
"Совместный приём с алкоголем не рекомендуется. "
"Алкоголь может ухудшать сон и настроение и усиливать побочные эффекты."
),
}
# Persona table: day → (dose taken, mood, sleep, nightmares, alcohol, free text). "" = skip the free text.
TABLE: dict[int, tuple[bool, int, str, bool, str, str]] = {
1: (True, 4, "4-6", True, "none", ""),
2: (True, 4, "4-6", True, "none", "подташнивает с утра"),
3: (True, 4, "4-6", True, "none", "опять тошнит"),
4: (True, 3, "4-6", False, "none", "тошнота не проходит"),
5: (False, 3, "4-6", False, "none", "от таблеток только хуже, брошу"),
6: (True, 4, "6-8", False, "none", "врач позвонил, продолжаю"),
7: (True, 4, "6-8", False, "none", ""),
8: (True, 4, "<4", True, "1-2", "снится одно и то же"),
9: (True, 4, "<4", True, "more", ""),
10: (True, 4, "<4", True, "more", "без пива не могу уснуть"),
11: (True, 3, "4-6", False, "1-2", ""),
12: (True, 3, "4-6", False, "none", ""),
13: (True, 2, "<4", True, "none", "всё бессмысленно, устал от всего"),
}
# Expected dashboard colour at the end of each day (persona: 🟢🟢🟢🟡🟡🟢🟢🟢🟢🟡🟡🟡🔴).
COLORS = ["green"] * 3 + ["yellow"] * 2 + ["green"] * 4 + ["yellow"] * 3 + ["red"]
class FakeDrugInfo:
async def sections(self, inn: str) -> list[DrugSection]:
if inn != "сертралин":
return []
return [DrugSection(inn=inn, section=k, text=v, source=SRC) for k, v in SERTRALINE.items()]
class RuleMonitoring:
"""Test double of lane B's MonitoringService: real rules over a snapshot of the clinical DB."""
def __init__(self) -> None:
self.fired: list[str] = []
async def _snapshot(self, s: Any, token: str) -> PatientSnapshot:
now = clock.now()
profile = await repo.get_profile(s, token)
assert profile is not None and profile.therapy_start is not None and profile.birth_year is not None
checkins = [
DailyCheckin(
day=c.day,
mood=c.mood,
med_taken=c.med_taken,
sleep_hours_bucket=c.sleep_bucket, # type: ignore[arg-type]
alcohol=c.alcohol_bucket, # type: ignore[arg-type]
side_effects=list(c.side_effects or []),
quit_intent=bool(c.quit_intent),
nightmares=c.nightmares,
)
for c in await repo.list_checkins(s, token)
]
results = [r for r in await repo.list_scale_results(s, token) if r.complete]
doses = (await s.scalars(select(Dose).where(Dose.token == token, Dose.status != "pending"))).all()
cssrs = [r for r in results if r.scale == "cssrs_screen"]
last_in = await s.scalar(select(func.max(Message.ts)).where(Message.token == token, Message.direction == "in"))
return PatientSnapshot(
token=token,
as_of=now,
age=now.year - profile.birth_year,
sex=profile.sex, # type: ignore[arg-type]
modules=set(profile.modules), # type: ignore[arg-type]
therapy_day=(now.date() - profile.therapy_start).days,
checkins=checkins,
scale_results=[
ScalePoint(scale=r.scale, ts=r.ts, total=r.total, item9=r.item9, scheduled=r.scheduled)
for r in results
if r.scale != "cssrs_screen"
],
doses=[DoseRecord(planned_at=d.planned_at, status=d.status) for d in doses], # type: ignore[arg-type]
cssrs_risk=cssrs[-1].severity if cssrs else None, # type: ignore[arg-type]
weapon_access=False,
last_patient_message_at=last_in,
open_alert_codes=await repo.open_alert_codes(s, token),
)
async def after_data_change(self, token: str) -> list[Action]:
async with session.get_clinical_session() as s:
actions = evaluate(await self._snapshot(s, token))
for a in actions:
if a.creates_alert:
assert a.level is not None
await repo.add_alert(
s, token=token, level=a.level, code=a.code, reason=a.reason_ru, ts=clock.now(), params=a.params
)
self.fired += [a.code for a in actions]
return [a for a in actions if a.type != "alert"]
async def is_in_crisis(self, token: str) -> bool:
async with session.get_clinical_session() as s:
alerts = await repo.list_alerts(s, token=token, open_only=True)
return any(a.level == "acute" for a in alerts)
# ----------------------------------------------------------------------------------------------- fixtures
@pytest.fixture
async def andrey(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[RuleMonitoring]:
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()))
clock.set_now(DAY0)
await session.configure(identity_url=MEM, clinical_url=MEM)
await init_db()
now = clock.now()
async with session.get_identity_session() as s:
await identity_repo.create_patient(
s,
token=TOKEN,
display_name="Андрей С. (синтетика)",
snils="000-000-000 00",
clinic_id="c1",
doctor_id="doc-smirnova",
now=now,
birth_year=1992,
sex="m",
)
await identity_repo.record_consent(s, token=TOKEN, version="v1", now=now)
async with session.get_clinical_session() as s:
await repo.upsert_profile(
s,
token=TOKEN,
doctor_id="doc-smirnova",
modules=["veteran", "depression"],
therapy_start=now.date(),
next_visit=now.date() + timedelta(days=28),
birth_year=1992,
sex="m",
)
s.add(
Prescription(
token=TOKEN, drug="сертралин", dose="50 мг", schedule={"times": ["08:00"]}, start_date=now.date()
)
)
screening = now - timedelta(days=3) # day −3 screening (persona)
for scale, total in (("pcptsd5", 4), ("pcl5", 46), ("phq9", 14), ("auditc", 3)):
await repo.add_scale_result(
s,
token=TOKEN,
scale=scale,
total=total,
severity="-",
complete=True,
ts=screening,
item9=0 if scale == "phq9" else None,
)
await repo.set_dialog_state(s, token=TOKEN, state="idle", data={"consent": True, "adult": True}, now=now)
mon = RuleMonitoring()
monitoring_contracts.get_monitoring() # import lane B's module first if it exists, then override it
previous = monitoring_contracts._factory
monitoring_contracts.register_monitoring(lambda: mon)
yield mon
monitoring_contracts.register_monitoring(previous)
clock.reset()
await session.configure()
# ----------------------------------------------------------------------------------------------- helpers
class Andrey:
def __init__(self) -> None:
self.engine = Engine(gateway=LLMGateway(MockTasks())) # monitoring resolved via register_monitoring
async def text(self, t: str) -> list[Reply]:
return await self.engine.handle(TOKEN, Event(type="text", text=t))
async def press(self, data: str) -> list[Reply]:
return await self.engine.handle(TOKEN, Event(type="button", button=data))
async def system(self, kind: str, **params: Any) -> list[Reply]:
return await self.engine.handle(TOKEN, Event(type="system", system=kind, params=params)) # type: ignore[arg-type]
async def answer_scale(self, scale_id: str, values: list[int]) -> list[Reply]:
out: list[Reply] = []
for item, value in zip(load_scale(scale_id).items, values, strict=True):
out += await self.press(f"scale:{scale_id}:{item.id}:{value}")
return out
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)
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)
async def open_alerts() -> list[Any]:
async with session.get_clinical_session() as s:
return list(await repo.list_alerts(s, token=TOKEN, open_only=True))
async def ack_all() -> None:
async with session.get_clinical_session() as s:
for a in await repo.list_alerts(s, token=TOKEN, open_only=True):
await repo.ack_alert(s, alert_id=a.id, doctor_id="doc-smirnova", ts=clock.now())
async def new_dose(day: date) -> int:
"""What the scheduler (#13) does before sending the reminder: a planned dose row."""
async with session.get_clinical_session() as s:
rx = await s.scalar(select(Prescription).where(Prescription.token == TOKEN))
assert rx is not None
row = Dose(token=TOKEN, prescription_id=rx.id, planned_at=clock.now(), status="pending")
s.add(row)
await s.flush()
return row.id
# ----------------------------------------------------------------------------------------------- the 13 days
async def test_andrey_13_days(andrey: RuleMonitoring) -> None:
a = Andrey()
meds = load_meds_texts()
daily = load_daily_texts()
log: dict[int, list[Reply]] = {}
colors: list[str] = []
for day in range(1, 14):
taken, mood, sleep, nightmares, alcohol, free_text = TABLE[day]
clock.set_now(DAY0 + timedelta(days=day))
if day == 6:
await ack_all() # the doctor called Andrey after the day-5 alerts and acknowledged them
r: list[Reply] = []
# --- 08:00 dose reminder
dose_id = await new_dose(clock.today())
reminder = await a.system("med_reminder", dose_id=dose_id, drug="сертралин", dose="50 мг")
assert "сертралин 50 мг" in reminder[0].text
assert buttons(reminder) == ["med:taken", "med:skip", "med:side_effects"]
if taken:
r += await a.press("med:taken")
else:
skip = await a.press("med:skip")
assert "med:skip_reason:quit" in buttons(skip)
r += skip + await a.press("med:skip_reason:quit")
# --- day 7: scheduled scales (PCL-5 = 44, PHQ-9 = 13, item 9 = 0) with friendly intros
if day == 7:
first = await a.system("scale_due", scale="pcl5", scheduled=True)
assert daily.scale_intros.scheduled["pcl5"] in first[0].text
await a.system("scale_due", scale="phq9", scheduled=True) # queued behind PCL-5
after_pcl = await a.answer_scale("pcl5", [2] * 16 + [3] * 4)
assert daily.scale_intros.scheduled["phq9"] in after_pcl[-1].text
await a.answer_scale("phq9", [2] * 5 + [1] * 3 + [0])
assert (await state())[0] == "idle"
# --- 20:00 daily check-in
clock.set_now(DAY0 + timedelta(days=day, hours=12))
start = await a.system("daily_checkin")
assert start[0].text == daily.checkin.start and buttons(start)[0] == "daily:mood:0"
r += await a.press(f"daily:mood:{mood}") # dose already answered by the reminder → no med question
assert "daily:sleep:<4" in buttons(r[-1:])
r += await a.press(f"daily:sleep:{sleep}")
r += await a.press(f"daily:nightmares:{'yes' if nightmares else 'no'}")
r += await a.press(f"daily:alcohol:{alcohol}")
assert buttons(r[-1:]) == ["daily:text:skip"]
r += await a.text(free_text) if free_text else await a.press("daily:text:skip")
log[day] = r
if day == 10: # «записаться раньше» offered by rule alcohol_3d
visit = await a.press("menu:visit")
assert visit[0].text == daily.visit.requested and (await state())[1]["visit_requests"]
if day == 13:
break # the crisis path is checked below
assert (await state())[0] == "idle"
colors.append(patient_status(AlertLevel[x.level.upper()] for x in await open_alerts()).color)
# Day 2: first nausea → instruction excerpt (med_qa, section side_effects), not generated advice.
assert "По инструкции к препарату" in joined(log[2]) and "проходит в течение 1–2 недель" in joined(log[2])
# Day 3: same effect again → short support, the instruction is not repeated.
assert meds.side_effects.repeat in joined(log[3]) and "По инструкции" not in joined(log[3])
# Day 5: «решил бросить» → rule quit_intent → delayed onset + do not stop on your own + doctor notified.
day5 = joined(log[5])
assert "2–4 недели" in day5 and "не прекращайте" in day5 and meds.med_info["delayed_effect"].notice in day5
assert "200 мг" not in day5 # dosing sentences of the instruction are never quoted
# Day 10: sleep < 4 h ×3 + alcohol ×3 → interaction info + «записаться раньше».
day10 = joined(log[10])
assert "Совместный приём с алкоголем не рекомендуется" in day10
assert buttons(log[10])[-1] == "menu:visit"
# Day 11: alerts still open → the rule stays silent (no repeated interaction text).
assert "Совместный приём с алкоголем" not in joined(log[11]) and "menu:visit" not in buttons(log[11])
assert colors == COLORS[:12]
# Day 13: «всё бессмысленно» → POSSIBLE → C-SSRS screen (buttons only) took over the free text.
day13 = log[13]
assert day13[-1].kind == "question" and "scale:cssrs_screen:cssrs_1:0" in buttons(day13[-1:])
r = await a.answer_scale("cssrs_screen", [0] * 6)
# Back in the check-in: the message counts as the answer → check-in closed → mood ≤ 3 three days →
# unscheduled PHQ-9 with its own intro.
assert daily.checkin.done in joined(r) and daily.scale_intros.unscheduled["phq9"] in r[-1].text
st, data = await state()
assert st == "scale.phq9" and data["scales"]["phq9"]["scheduled"] is False
r = await a.answer_scale("phq9", [2] * 8 + [2]) # total 18, item 9 = 2
assert any(x.kind == "crisis" for x in r) and any(x.kind == "safety_plan" for x in r)
st, data = await state()
assert data["crisis"]["open"] is True and st in ("crisis", "scale.cssrs_screen")
acute = [x for x in await open_alerts() if x.level == "acute"]
assert [x.code for x in acute] == ["phq9_item9"]
assert patient_status(AlertLevel[x.level.upper()] for x in await open_alerts()).color == COLORS[12]
# Stored check-ins match the persona table.
async with session.get_clinical_session() as s:
rows = {c.day: c for c in await repo.list_checkins(s, TOKEN)}
doses = (await s.scalars(select(Dose).where(Dose.token == TOKEN).order_by(Dose.id))).all()
phq = list(await repo.list_scale_results(s, TOKEN, "phq9"))
for day, (taken, mood, sleep, nightmares, alcohol, _text) in TABLE.items():
c = rows[(DAY0 + timedelta(days=day)).date()]
assert (c.med_taken, c.mood, c.sleep_bucket, c.nightmares, c.alcohol_bucket) == (
taken,
mood,
sleep,
nightmares,
alcohol,
), day
for day in (2, 3, 4):
assert "nausea" in rows[(DAY0 + timedelta(days=day)).date()].side_effects
assert rows[(DAY0 + timedelta(days=5)).date()].quit_intent is True
assert rows[(DAY0 + timedelta(days=13)).date()].free_text_scrubbed is None # taken over by the C-SSRS
assert [d.status for d in doses] == ["skipped" if d == 5 else "taken" for d in range(1, 14)]
assert [(p.total, p.item9, p.scheduled) for p in phq] == [(14, 0, True), (13, 0, True), (18, 2, False)]
for code in ("side_effect_3d", "quit_intent", "sleep_lt4_3n", "alcohol_3d", "mood_low_3d", "phq9_item9"):
assert code in andrey.fired, code