release: dev → main (сдача хакатона) #106
+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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -44,6 +44,8 @@ export const api = {
|
||||
signInEmias: (code: string) => post<AuthResult>("/auth/emias", { code }),
|
||||
signInEmiasDemo: (personaToken: string) => post<AuthResult>("/auth/emias/demo", { persona_token: personaToken }),
|
||||
signInAnonymous: (focus?: Focus) => post<AuthResult>("/auth/anonymous", { focus }),
|
||||
// Presentation entry: a fresh private copy of the demo patient, straight to the chat.
|
||||
signInDemoFresh: () => post<AuthResult>("/auth/demo/fresh", {}),
|
||||
patientCapabilities: () => request<{ primary_screening: boolean }>("/patient/capabilities"),
|
||||
connectDoctor: (token: string) => post<{ has_doctor: boolean }>(`/me/${encodeURIComponent(token)}/doctor/connect`),
|
||||
demoPersonas: () => request<DemoPersona[]>("/patient/demo-personas"),
|
||||
|
||||
@@ -41,6 +41,12 @@ export const ru = {
|
||||
},
|
||||
welcome: {
|
||||
title: "Вход",
|
||||
demo: {
|
||||
title: "Демо: войти как Андрей",
|
||||
text: "Ветеран СВО, 34 года, сертралин. Назначения и карта ЕМИАС уже подключены — сразу в чат.",
|
||||
busy: "Готовим демо…",
|
||||
error: "Не удалось открыть демо. Попробуйте ещё раз.",
|
||||
},
|
||||
emias: {
|
||||
title: "Войти через ЕМИАС",
|
||||
perks: [
|
||||
|
||||
@@ -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 (
|
||||
<div className="signin-page">
|
||||
<main className="signin" id="main">
|
||||
@@ -25,6 +45,28 @@ export function Welcome() {
|
||||
<section className="signin__card">
|
||||
<h1 className="h1">{t.title}</h1>
|
||||
|
||||
<button type="button" className="entry entry--emias" onClick={demo} disabled={demoBusy}>
|
||||
<span className="entry__top">
|
||||
<span className="tile-icon tile-icon--teal">
|
||||
<Icon name="check" size={26} />
|
||||
</span>
|
||||
<span className="entry__title">
|
||||
{demoBusy ? t.demo.busy : t.demo.title}
|
||||
<Icon name="chevronRight" />
|
||||
</span>
|
||||
</span>
|
||||
<span className="muted small">{t.demo.text}</span>
|
||||
</button>
|
||||
{demoError && (
|
||||
<p className="muted small center" role="alert">
|
||||
{t.demo.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="divider" role="separator">
|
||||
<span>{t.or}</span>
|
||||
</div>
|
||||
|
||||
<button type="button" className="entry entry--emias" onClick={() => navigate("/emias")}>
|
||||
<span className="entry__top">
|
||||
<span className="tile-icon tile-icon--teal">
|
||||
|
||||
Reference in New Issue
Block a user