release: dev → main (сдача хакатона) #106

Merged
Eleboba merged 128 commits from dev into main 2026-10-04 23:00:56 +03:00
13 changed files with 228 additions and 7 deletions
Showing only changes of commit cdd5ad9cde - Show all commits
+39 -5
View File
@@ -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.interpretation import texts as results_texts
from app.core.meds_reference import find_drug, indication_for, load_reference from app.core.meds_reference import find_drug, indication_for, load_reference
from app.core.safety import CrisisResource, crisis_reply 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.audit import audit
from app.db.clinical import DailyCheckin, Dose, Prescription from app.db.clinical import DailyCheckin, Dose, Prescription
from app.db.identity import TOKEN_LEN from app.db.identity import TOKEN_LEN
from app.db.intake import QuestionnaireRecord 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.integrations.emias_mock import CODE_MAX_LEN, EmiasMock
from app.services import anonymous_care 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) 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. """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 Idempotent: only missing pieces are created. Data mirror the dashboard fixtures, so the patient app and the
doctor's card tell the same story. doctor's card tell the same story.
""" """
card = fixtures.card(token) card = fixtures.card(source or token)
item = next((p for p in fixtures.PATIENTS if p.token == token), None) item = next((p for p in fixtures.PATIENTS if p.token == (source or token)), None)
if card is None or item is None: if card is None or item is None:
return return
now = clock.now() 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) 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) @router.post("/auth/anonymous", response_model=AuthOut)
async def auth_anonymous(body: AnonymousIn | None = None) -> AuthOut: async def auth_anonymous(body: AnonymousIn | None = None) -> AuthOut:
"""Anonymous sign-in: a fresh random token; care without an identity record. """Anonymous sign-in: a fresh random token; care without an identity record.
+19
View File
@@ -288,3 +288,22 @@ async def demo_personas() -> list[DemoPersona]:
) )
) )
return out 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)
+2
View File
@@ -78,6 +78,8 @@ class Settings(BaseSettings):
# Virtual clock for the demo (ADR-005). # Virtual clock for the demo (ADR-005).
demo_clock: bool = True 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" tz: str = "Europe/Moscow"
api_base_url: str = "http://localhost:8000" api_base_url: str = "http://localhost:8000"
+34
View File
@@ -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()
+9
View File
@@ -192,3 +192,12 @@ class ClockState(ClinicalBase):
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
offset_seconds: Mapped[float] = mapped_column(Float, default=0.0) 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)
+5 -1
View File
@@ -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: 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 """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.""" 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() profile = await ctx.profile()
modules = list(profile.modules) if profile is not None and profile.modules else [] 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 # 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. # person has no profile at all before it.
+5 -1
View File
@@ -77,7 +77,11 @@ class EmiasRecordSource:
async def get(self, token: str) -> PatientRecord | None: async def get(self, token: str) -> PatientRecord | None:
try: 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: except (OSError, yaml.YAMLError, ValueError) as exc:
log.error("emias records unavailable: %s", type(exc).__name__) log.error("emias records unavailable: %s", type(exc).__name__)
base = None base = None
+6
View File
@@ -103,6 +103,12 @@ class ConsoleApi:
def reset(self) -> dict[str, Any]: def reset(self) -> dict[str, Any]:
return dict(self._request("POST", "/api/v1/demo/reset", timeout=60)) 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 # ------------------------------------------------------------------ patient dialog
def send_event( def send_event(
self, self,
@@ -33,6 +33,20 @@ w.render_connect_hero()
w.show_flash() w.show_flash()
st.sidebar.caption(f"API: {w.get_api().base_url}") 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"): with st.container(border=True, key="panel-console-clock"):
w.render_clock_bar(persona, key="console-clock") w.render_clock_bar(persona, key="console-clock")
+45
View File
@@ -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
+2
View File
@@ -44,6 +44,8 @@ export const api = {
signInEmias: (code: string) => post<AuthResult>("/auth/emias", { code }), signInEmias: (code: string) => post<AuthResult>("/auth/emias", { code }),
signInEmiasDemo: (personaToken: string) => post<AuthResult>("/auth/emias/demo", { persona_token: personaToken }), signInEmiasDemo: (personaToken: string) => post<AuthResult>("/auth/emias/demo", { persona_token: personaToken }),
signInAnonymous: (focus?: Focus) => post<AuthResult>("/auth/anonymous", { focus }), 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"), patientCapabilities: () => request<{ primary_screening: boolean }>("/patient/capabilities"),
connectDoctor: (token: string) => post<{ has_doctor: boolean }>(`/me/${encodeURIComponent(token)}/doctor/connect`), connectDoctor: (token: string) => post<{ has_doctor: boolean }>(`/me/${encodeURIComponent(token)}/doctor/connect`),
demoPersonas: () => request<DemoPersona[]>("/patient/demo-personas"), demoPersonas: () => request<DemoPersona[]>("/patient/demo-personas"),
+6
View File
@@ -41,6 +41,12 @@ export const ru = {
}, },
welcome: { welcome: {
title: "Вход", title: "Вход",
demo: {
title: "Демо: войти как Андрей",
text: "Ветеран СВО, 34 года, сертралин. Назначения и карта ЕМИАС уже подключены — сразу в чат.",
busy: "Готовим демо…",
error: "Не удалось открыть демо. Попробуйте ещё раз.",
},
emias: { emias: {
title: "Войти через ЕМИАС", title: "Войти через ЕМИАС",
perks: [ perks: [
+42
View File
@@ -1,5 +1,9 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { api } from "../api/client";
import { useSession } from "../session/SessionContext";
import { telHref, useHelp } from "../components/HelpSheet"; import { telHref, useHelp } from "../components/HelpSheet";
import { Icon, LogoMark } from "../components/Icon"; import { Icon, LogoMark } from "../components/Icon";
import { ru } from "../content/ru"; import { ru } from "../content/ru";
@@ -7,10 +11,26 @@ import { ru } from "../content/ru";
// A1 · Вход: без сайдбара, одна карточка по центру. ЕМИАС — основной путь, анонимный — по желанию // A1 · Вход: без сайдбара, одна карточка по центру. ЕМИАС — основной путь, анонимный — по желанию
export function Welcome() { export function Welcome() {
const navigate = useNavigate(); const navigate = useNavigate();
const { signIn } = useSession();
const [demoBusy, setDemoBusy] = useState(false);
const [demoError, setDemoError] = useState(false);
const { resources, open } = useHelp(); const { resources, open } = useHelp();
const t = ru.welcome; const t = ru.welcome;
const emergency = resources[0]; 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 ( return (
<div className="signin-page"> <div className="signin-page">
<main className="signin" id="main"> <main className="signin" id="main">
@@ -25,6 +45,28 @@ export function Welcome() {
<section className="signin__card"> <section className="signin__card">
<h1 className="h1">{t.title}</h1> <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")}> <button type="button" className="entry entry--emias" onClick={() => navigate("/emias")}>
<span className="entry__top"> <span className="entry__top">
<span className="tile-icon tile-icon--teal"> <span className="tile-icon tile-icon--teal">