feat(bot): ежедневный чек-ин ветерана, приём лекарств и реакции на правила #55
+16
-4
@@ -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":
|
||||||
intent = _MED_INFO_INTENTS.get(str(action.params.get("topic")), "default")
|
reply = await _med_info_handler(ctx, action) if _med_info_handler is not None else None
|
||||||
text = await ctx.gateway.phrase(intent, {})
|
if reply is None:
|
||||||
step = step.then(Step(replies=[Reply(text=text, kind="info")]))
|
intent = _MED_INFO_INTENTS.get(str(action.params.get("topic")), "default")
|
||||||
|
reply = Reply(text=await ctx.gateway.phrase(intent, {}), 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")
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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: "Если станет хуже — напишите мне, я передам врачу."
|
||||||
@@ -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: "Последние дни настроение ниже обычного. Давайте я задам несколько вопросов — так ваш врач лучше поймёт, как вы сейчас."
|
||||||
Reference in New Issue
Block a user