feat: presentation demo mode — one-click private demo patient, screening on/off switch
CI / ruff · pytest (pull_request) Failing after 2m33s
CI / ruff · pytest (pull_request) Failing after 2m33s
- POST /auth/demo/fresh + «Демо: войти как Андрей» on the welcome screen: every sign-in gets a fresh private copy of veteran Andrey (consent, prescription, ЕМИАС record) and lands in the chat — no codes, nobody's crisis or half-done questionnaire leaks into another viewer. - Initial screening switch: SCREENING_ENABLED env default + /demo/settings + a toggle in the demo console; off → after consent straight to the chat. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+39
-5
@@ -35,12 +35,12 @@ from app.core.intake_runtime import intake_enabled, load_runtime
|
||||
from app.core.interpretation import texts as results_texts
|
||||
from app.core.meds_reference import find_drug, indication_for, load_reference
|
||||
from app.core.safety import CrisisResource, crisis_reply
|
||||
from app.db import repo
|
||||
from app.db import identity_repo, repo
|
||||
from app.db.audit import audit
|
||||
from app.db.clinical import DailyCheckin, Dose, Prescription
|
||||
from app.db.identity import TOKEN_LEN
|
||||
from app.db.intake import QuestionnaireRecord
|
||||
from app.db.session import get_clinical_session
|
||||
from app.db.session import get_clinical_session, get_identity_session
|
||||
from app.integrations.emias_mock import CODE_MAX_LEN, EmiasMock
|
||||
from app.services import anonymous_care
|
||||
|
||||
@@ -81,14 +81,14 @@ async def _attach_doctor(token: str, doctor_id: str) -> None:
|
||||
await repo.upsert_profile(s, token=token, doctor_id=doctor_id)
|
||||
|
||||
|
||||
async def _seed_demo_persona(token: str) -> None:
|
||||
async def _seed_demo_persona(token: str, source: str | None = None) -> None:
|
||||
"""Synthetic clinical start for a demo persona (no `make seed` yet): profile, prescription, today's doses.
|
||||
|
||||
Idempotent: only missing pieces are created. Data mirror the dashboard fixtures, so the patient app and the
|
||||
doctor's card tell the same story.
|
||||
"""
|
||||
card = fixtures.card(token)
|
||||
item = next((p for p in fixtures.PATIENTS if p.token == token), None)
|
||||
card = fixtures.card(source or token)
|
||||
item = next((p for p in fixtures.PATIENTS if p.token == (source or token)), None)
|
||||
if card is None or item is None:
|
||||
return
|
||||
now = clock.now()
|
||||
@@ -153,6 +153,40 @@ async def auth_emias_demo(body: EmiasDemoIn) -> AuthOut:
|
||||
return AuthOut(token=persona.token, mode="emias", display_name=persona.display_name)
|
||||
|
||||
|
||||
DEMO_SOURCE = "tok-andrey"
|
||||
|
||||
|
||||
@router.post("/auth/demo/fresh", response_model=AuthOut)
|
||||
async def auth_demo_fresh() -> AuthOut:
|
||||
"""Presentation entry: a fresh private copy of veteran Andrey for every sign-in — consent given, prescription
|
||||
and ЕМИАС record in place, dialog resting in the chat. Nobody's crisis or half-done questionnaire leaks into
|
||||
another viewer's demo. Synthetic data only."""
|
||||
token = f"{DEMO_SOURCE}-{secrets.token_hex(4)}"
|
||||
now = clock.now()
|
||||
persona = next(p for p in fixtures.DEMO_PERSONAS if p.token == DEMO_SOURCE)
|
||||
snils = f"000-{secrets.randbelow(10**3):03d}-{secrets.randbelow(10**3):03d} {secrets.randbelow(100):02d}"
|
||||
async with get_identity_session() as s:
|
||||
await identity_repo.create_patient(
|
||||
s,
|
||||
token=token,
|
||||
display_name=f"{persona.display_name} (демо)",
|
||||
snils=snils,
|
||||
clinic_id="demo",
|
||||
doctor_id=fixtures.DOCTOR_ID,
|
||||
now=now,
|
||||
birth_year=now.year - 34,
|
||||
sex="m",
|
||||
)
|
||||
await identity_repo.record_consent(s, token=token, version="demo", now=now)
|
||||
await _seed_demo_persona(token, source=DEMO_SOURCE)
|
||||
async with get_clinical_session() as s:
|
||||
await repo.upsert_profile(s, token=token, sex="m", birth_year=now.year - 34)
|
||||
consent = {"consent": True, "consent_ver": "demo", "consent_at": now.isoformat(), "revoked_at": None}
|
||||
await repo.set_dialog_state(s, token=token, state="idle", data={**consent, "adult": True}, now=now)
|
||||
log.info("web auth: demo fresh tok=%s", token[:14])
|
||||
return AuthOut(token=token, mode="emias", display_name=persona.display_name)
|
||||
|
||||
|
||||
@router.post("/auth/anonymous", response_model=AuthOut)
|
||||
async def auth_anonymous(body: AnonymousIn | None = None) -> AuthOut:
|
||||
"""Anonymous sign-in: a fresh random token; care without an identity record.
|
||||
|
||||
@@ -288,3 +288,22 @@ async def demo_personas() -> list[DemoPersona]:
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/demo/settings", tags=["demo"])
|
||||
async def get_demo_settings() -> dict[str, bool]:
|
||||
"""Presentation switches: `screening_enabled` — initial questionnaires after consent (off → straight to chat)."""
|
||||
from app.core import demo_settings
|
||||
|
||||
async with get_clinical_session() as s:
|
||||
return await demo_settings.get_all(s)
|
||||
|
||||
|
||||
@router.post("/demo/settings", tags=["demo"])
|
||||
async def set_demo_settings(body: dict[str, bool]) -> dict[str, bool]:
|
||||
from app.core import demo_settings
|
||||
|
||||
async with get_clinical_session() as s:
|
||||
if demo_settings.SCREENING in body:
|
||||
await demo_settings.set_screening(s, bool(body[demo_settings.SCREENING]))
|
||||
return await demo_settings.get_all(s)
|
||||
|
||||
@@ -78,6 +78,8 @@ class Settings(BaseSettings):
|
||||
|
||||
# Virtual clock for the demo (ADR-005).
|
||||
demo_clock: bool = True
|
||||
# Initial screening (onboarding questionnaires) on/off — the default; the demo console can flip it at runtime.
|
||||
screening_enabled: bool = True
|
||||
tz: str = "Europe/Moscow"
|
||||
|
||||
api_base_url: str = "http://localhost:8000"
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Presentation switches (demo): stored in `demo_settings`, default from settings/env.
|
||||
|
||||
SCREENING_ENABLED=false # env default
|
||||
POST /api/v1/demo/settings {"screening_enabled": false} # one button in the demo console
|
||||
|
||||
Screening off: after consent the patient goes straight to the chat — no onboarding questionnaires.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
from app.db.clinical import DemoSetting
|
||||
|
||||
SCREENING = "screening_enabled"
|
||||
|
||||
|
||||
async def get_all(session: AsyncSession) -> dict[str, Any]:
|
||||
row = await session.get(DemoSetting, SCREENING)
|
||||
return {SCREENING: bool(row.value) if row is not None else get_settings().screening_enabled}
|
||||
|
||||
|
||||
async def screening_enabled(session: AsyncSession) -> bool:
|
||||
return (await get_all(session))[SCREENING]
|
||||
|
||||
|
||||
async def set_screening(session: AsyncSession, enabled: bool) -> None:
|
||||
row = await session.get(DemoSetting, SCREENING)
|
||||
if row is None:
|
||||
session.add(DemoSetting(key=SCREENING, value=enabled))
|
||||
else:
|
||||
row.value = enabled
|
||||
await session.flush()
|
||||
@@ -192,3 +192,12 @@ class ClockState(ClinicalBase):
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
offset_seconds: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
|
||||
|
||||
class DemoSetting(ClinicalBase):
|
||||
"""Presentation switches changed at runtime from the demo console (e.g. the initial screening on/off)."""
|
||||
|
||||
__tablename__ = "demo_settings"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
value: Mapped[Any] = mapped_column(JSONType)
|
||||
|
||||
@@ -66,9 +66,13 @@ def menu_reply(ctx: Ctx, text: str | None = None) -> Reply:
|
||||
async def onboarding_entry(ctx: Ctx, after: str | None = None) -> str:
|
||||
"""Entry state of the next module onboarding: `"{module}.onboarding.start"` for the first module of the
|
||||
profile (after `after`, if given) that has such a state registered; `"idle"` when none is left."""
|
||||
from app.core.demo_settings import screening_enabled
|
||||
from app.core.intake_runtime import intake_enabled
|
||||
|
||||
if after is None and not await screening_enabled(ctx.session):
|
||||
return "idle" # demo switch: no initial questionnaires, straight to the chat
|
||||
profile = await ctx.profile()
|
||||
modules = list(profile.modules) if profile is not None and profile.modules else []
|
||||
from app.core.intake_runtime import intake_enabled
|
||||
|
||||
# Stage 1 is the same for everyone (ADR-012): it does not depend on modules, which it sets itself — an anonymous
|
||||
# person has no profile at all before it.
|
||||
|
||||
@@ -77,7 +77,11 @@ class EmiasRecordSource:
|
||||
|
||||
async def get(self, token: str) -> PatientRecord | None:
|
||||
try:
|
||||
base = _load(self._path or default_records_path()).get(token)
|
||||
records = _load(self._path or default_records_path())
|
||||
base = records.get(token)
|
||||
if base is None and "-" in token:
|
||||
# Fresh demo copies (POST /auth/demo/fresh): "tok-andrey-1a2b3c4d" → the "tok-andrey" record.
|
||||
base = records.get(token.rsplit("-", 1)[0])
|
||||
except (OSError, yaml.YAMLError, ValueError) as exc:
|
||||
log.error("emias records unavailable: %s", type(exc).__name__)
|
||||
base = None
|
||||
|
||||
Reference in New Issue
Block a user