CI / ruff · pytest (pull_request) Successful in 1m25s
Doctor list/card/alerts/ack/connect, dialog events/outbox/history and demo controls now read the identity and clinical DBs. Rule-based discontinuation risk index, trend, adherence; every doctor read is audited. Refs #12 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
90 lines
3.3 KiB
Python
90 lines
3.3 KiB
Python
"""Demo-console helpers (#12, ADR-005): scripted personas, reset, connect links, outbox access.
|
||
|
||
Synthetic data only. Scenario files live in `data/synthetic/<slug>.jsonl` (#18); a patient is a scripted persona
|
||
when the transliterated first word of the display name equals the slug ("Андрей С." → `andrey.jsonl`).
|
||
"""
|
||
|
||
import inspect
|
||
import logging
|
||
from importlib import import_module, util
|
||
from pathlib import Path
|
||
|
||
from app.config import get_settings
|
||
from app.integrations.base import MemoryOutbox, Outbox, integrations
|
||
|
||
log = logging.getLogger(__name__)
|
||
|
||
SEED_MODULE = "scripts.seed_demo"
|
||
CONSOLE_PATH = "/console"
|
||
|
||
_TRANSLIT = {
|
||
"а": "a", "б": "b", "в": "v", "г": "g", "д": "d", "е": "e", "ё": "e", "ж": "zh", "з": "z", "и": "i",
|
||
"й": "y", "к": "k", "л": "l", "м": "m", "н": "n", "о": "o", "п": "p", "р": "r", "с": "s", "т": "t",
|
||
"у": "u", "ф": "f", "х": "kh", "ц": "ts", "ч": "ch", "ш": "sh", "щ": "shch", "ъ": "", "ы": "y", "ь": "",
|
||
"э": "e", "ю": "yu", "я": "ya",
|
||
} # fmt: skip
|
||
|
||
|
||
def persona_slug(display_name: str) -> str:
|
||
first = (display_name.split() or [""])[0].lower()
|
||
return "".join(_TRANSLIT.get(ch, ch if ch.isascii() and ch.isalnum() else "") for ch in first)
|
||
|
||
|
||
def scenario_files(data_dir: Path | None = None) -> dict[str, str]:
|
||
"""slug → scenario path relative to the project root, e.g. {"andrey": "data/synthetic/andrey.jsonl"}."""
|
||
root = data_dir or get_settings().data_dir
|
||
folder = root / "synthetic"
|
||
if not folder.is_dir():
|
||
return {}
|
||
return {f.stem: f"data/synthetic/{f.name}" for f in sorted(folder.glob("*.jsonl"))}
|
||
|
||
|
||
async def bot_username() -> str | None:
|
||
"""Username of the running Telegram bot (deep links); None when the bot is not started (no TG_BOT_TOKEN)."""
|
||
from app.channels import telegram
|
||
|
||
bot = getattr(telegram, "_bot", None)
|
||
if bot is None:
|
||
return None
|
||
try:
|
||
me = await bot.me() # aiogram caches getMe
|
||
except Exception as exc: # noqa: BLE001 — network / auth errors: fall back to the web console link
|
||
log.warning("bot username unavailable error=%s", type(exc).__name__)
|
||
return None
|
||
return me.username or None
|
||
|
||
|
||
async def deep_link(code: str) -> str:
|
||
username = await bot_username()
|
||
return f"https://t.me/{username}?start={code}" if username else f"{CONSOLE_PATH}?code={code}"
|
||
|
||
|
||
def memory_outbox() -> MemoryOutbox | None:
|
||
"""The in-memory outbox polled by the web console, also when wrapped (Telegram keeps it as `fallback`)."""
|
||
box: Outbox | None = integrations.outbox
|
||
for _ in range(5):
|
||
if isinstance(box, MemoryOutbox):
|
||
return box
|
||
box = getattr(box, "fallback", None)
|
||
return None
|
||
|
||
|
||
async def reset_demo() -> bool:
|
||
"""Clock reset is done by the caller; re-seed via `scripts.seed_demo.reset()` (#18) when it exists."""
|
||
box = memory_outbox()
|
||
if box is not None:
|
||
box._q.clear()
|
||
try:
|
||
spec = util.find_spec(SEED_MODULE)
|
||
except ModuleNotFoundError: # no `scripts` package on the path (e.g. installed app without scripts/)
|
||
spec = None
|
||
if spec is None:
|
||
return False
|
||
reset = getattr(import_module(SEED_MODULE), "reset", None)
|
||
if reset is None:
|
||
return False
|
||
result = reset()
|
||
if inspect.isawaitable(result):
|
||
await result
|
||
return True
|