From cdd5ad9cdec8a5f3471bad33f3081b539a3c4eec Mon Sep 17 00:00:00 2001 From: Lacky1234union Date: Sun, 4 Oct 2026 21:01:05 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20presentation=20demo=20mode=20=E2=80=94?= =?UTF-8?q?=20one-click=20private=20demo=20patient,=20screening=20on/off?= =?UTF-8?q?=20switch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/api/patient_web.py | 44 ++++++++++++++++++++++++++---- app/api/routes.py | 19 +++++++++++++ app/config.py | 2 ++ app/core/demo_settings.py | 34 +++++++++++++++++++++++ app/db/clinical.py | 9 +++++++ app/dialog/flows/core.py | 6 ++++- app/integrations/emias_record.py | 6 ++++- dashboard/console/api.py | 6 +++++ dashboard/pages/2_Демо-консоль.py | 14 ++++++++++ tests/test_demo_mode.py | 45 +++++++++++++++++++++++++++++++ web/src/api/client.ts | 2 ++ web/src/content/ru.ts | 6 +++++ web/src/screens/Welcome.tsx | 42 +++++++++++++++++++++++++++++ 13 files changed, 228 insertions(+), 7 deletions(-) create mode 100644 app/core/demo_settings.py create mode 100644 tests/test_demo_mode.py diff --git a/app/api/patient_web.py b/app/api/patient_web.py index ac02043..ed3b7b3 100644 --- a/app/api/patient_web.py +++ b/app/api/patient_web.py @@ -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. diff --git a/app/api/routes.py b/app/api/routes.py index be3feb9..b6fb171 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -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) diff --git a/app/config.py b/app/config.py index aefce77..40b62bf 100644 --- a/app/config.py +++ b/app/config.py @@ -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" diff --git a/app/core/demo_settings.py b/app/core/demo_settings.py new file mode 100644 index 0000000..90fba43 --- /dev/null +++ b/app/core/demo_settings.py @@ -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() diff --git a/app/db/clinical.py b/app/db/clinical.py index 9b9478d..d482d10 100644 --- a/app/db/clinical.py +++ b/app/db/clinical.py @@ -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) diff --git a/app/dialog/flows/core.py b/app/dialog/flows/core.py index a69a22c..ddaa57f 100644 --- a/app/dialog/flows/core.py +++ b/app/dialog/flows/core.py @@ -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. diff --git a/app/integrations/emias_record.py b/app/integrations/emias_record.py index c082946..f9f394b 100644 --- a/app/integrations/emias_record.py +++ b/app/integrations/emias_record.py @@ -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 diff --git a/dashboard/console/api.py b/dashboard/console/api.py index 1368305..ffc8092 100644 --- a/dashboard/console/api.py +++ b/dashboard/console/api.py @@ -103,6 +103,12 @@ class ConsoleApi: def reset(self) -> dict[str, Any]: return dict(self._request("POST", "/api/v1/demo/reset", timeout=60)) + def demo_settings(self) -> dict[str, Any]: + return dict(self._request("GET", "/api/v1/demo/settings")) + + def set_screening(self, enabled: bool) -> dict[str, Any]: + return dict(self._request("POST", "/api/v1/demo/settings", json={"screening_enabled": enabled})) + # ------------------------------------------------------------------ patient dialog def send_event( self, diff --git a/dashboard/pages/2_Демо-консоль.py b/dashboard/pages/2_Демо-консоль.py index 427330c..1a07365 100644 --- a/dashboard/pages/2_Демо-консоль.py +++ b/dashboard/pages/2_Демо-консоль.py @@ -33,6 +33,20 @@ w.render_connect_hero() w.show_flash() st.sidebar.caption(f"API: {w.get_api().base_url}") +# One switch for the presentation: initial questionnaires after consent on/off (off → straight to the chat). +try: + _screening = bool(w.get_api().demo_settings().get("screening_enabled", True)) + _wanted = st.sidebar.toggle( + "Начальный тест (скрининг)", + value=_screening, + help="Выключено: после согласия пациент сразу попадает в чат, без опросников.", + ) + if _wanted != _screening: + w.get_api().set_screening(_wanted) + st.sidebar.success("Начальный тест " + ("включён" if _wanted else "выключен")) +except Exception: # noqa: BLE001 — the switch must never break the console + st.sidebar.caption("Переключатель теста недоступен (API)") + with st.container(border=True, key="panel-console-clock"): w.render_clock_bar(persona, key="console-clock") diff --git a/tests/test_demo_mode.py b/tests/test_demo_mode.py new file mode 100644 index 0000000..7f2b40f --- /dev/null +++ b/tests/test_demo_mode.py @@ -0,0 +1,45 @@ +"""Presentation mode: fresh private demo patient straight into the chat; screening switch skips onboarding.""" + +from collections.abc import AsyncIterator + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.core import demo_settings +from app.db import repo, session +from app.db.bootstrap import init_db +from app.integrations.emias_record import emias_records +from app.main import app + +MEM = "sqlite+aiosqlite:///:memory:" + + +@pytest.fixture +async def client() -> AsyncIterator[AsyncClient]: + await session.configure(identity_url=MEM, clinical_url=MEM) + await init_db() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c: + yield c + await session.configure() + + +async def test_fresh_demo_patients_are_private_and_ready(client): + a = (await client.post("/api/v1/auth/demo/fresh", json={})).json()["token"] + b = (await client.post("/api/v1/auth/demo/fresh", json={})).json()["token"] + assert a != b and a.startswith("tok-andrey-") + async with session.get_clinical_session() as s: + st = await repo.get_dialog_state(s, a) + assert st.state == "idle" and st.data["consent"] is True + rec = await emias_records.get(a) + assert rec is not None and any("язвенная" in c.name.lower() for c in rec.conditions) + r = await client.post(f"/api/v1/dialog/{a}/events", json={"type": "text", "text": "привет"}) + assert r.status_code == 200 and r.json()["replies"] + + +async def test_screening_switch(client): + assert (await client.get("/api/v1/demo/settings")).json() == {"screening_enabled": True} + assert (await client.post("/api/v1/demo/settings", json={"screening_enabled": False})).json() == { + "screening_enabled": False + } + async with session.get_clinical_session() as s: + assert await demo_settings.screening_enabled(s) is False diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 1b21195..a0cb402 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -44,6 +44,8 @@ export const api = { signInEmias: (code: string) => post("/auth/emias", { code }), signInEmiasDemo: (personaToken: string) => post("/auth/emias/demo", { persona_token: personaToken }), signInAnonymous: (focus?: Focus) => post("/auth/anonymous", { focus }), + // Presentation entry: a fresh private copy of the demo patient, straight to the chat. + signInDemoFresh: () => post("/auth/demo/fresh", {}), patientCapabilities: () => request<{ primary_screening: boolean }>("/patient/capabilities"), connectDoctor: (token: string) => post<{ has_doctor: boolean }>(`/me/${encodeURIComponent(token)}/doctor/connect`), demoPersonas: () => request("/patient/demo-personas"), diff --git a/web/src/content/ru.ts b/web/src/content/ru.ts index 4078fc4..f5288dc 100644 --- a/web/src/content/ru.ts +++ b/web/src/content/ru.ts @@ -41,6 +41,12 @@ export const ru = { }, welcome: { title: "Вход", + demo: { + title: "Демо: войти как Андрей", + text: "Ветеран СВО, 34 года, сертралин. Назначения и карта ЕМИАС уже подключены — сразу в чат.", + busy: "Готовим демо…", + error: "Не удалось открыть демо. Попробуйте ещё раз.", + }, emias: { title: "Войти через ЕМИАС", perks: [ diff --git a/web/src/screens/Welcome.tsx b/web/src/screens/Welcome.tsx index 136646b..ba21bbb 100644 --- a/web/src/screens/Welcome.tsx +++ b/web/src/screens/Welcome.tsx @@ -1,5 +1,9 @@ +import { useState } from "react"; import { useNavigate } from "react-router-dom"; +import { api } from "../api/client"; +import { useSession } from "../session/SessionContext"; + import { telHref, useHelp } from "../components/HelpSheet"; import { Icon, LogoMark } from "../components/Icon"; import { ru } from "../content/ru"; @@ -7,10 +11,26 @@ import { ru } from "../content/ru"; // A1 · Вход: без сайдбара, одна карточка по центру. ЕМИАС — основной путь, анонимный — по желанию export function Welcome() { const navigate = useNavigate(); + const { signIn } = useSession(); + const [demoBusy, setDemoBusy] = useState(false); + const [demoError, setDemoError] = useState(false); const { resources, open } = useHelp(); const t = ru.welcome; const emergency = resources[0]; + // Presentation: one click → a fresh private demo patient → the chat. + const demo = async () => { + setDemoBusy(true); + setDemoError(false); + try { + signIn(await api.signInDemoFresh()); + navigate("/messages", { replace: true }); + } catch { + setDemoError(true); + setDemoBusy(false); + } + }; + return (
@@ -25,6 +45,28 @@ export function Welcome() {

{t.title}

+ + {demoError && ( +

+ {t.demo.error} +

+ )} + +
+ {t.or} +
+