Compare commits

...
Author SHA1 Message Date
Pavel Eliseev f5987c72f0 fix(web): offer anonymous screening and doctor contact without identity
CI / ruff · pytest (pull_request) Successful in 2m30s
Refs #95, #96
2026-10-04 16:48:03 +03:00
Pavel Eliseev 0743184f3d fix(api): provide pseudonymous patients access to doctor care 2026-10-04 16:47:06 +03:00
Arxip222 079c3003aa Merge pull request 'fix(clinical): красные флаги, одно правило кризиса, звонок после кризиса, одна таблица номеров' (#94) from fix/clinical-review-safety into dev
CI / ruff · pytest (push) Successful in 2m26s
2026-10-04 16:28:28 +03:00
Eleboba dff8f316bb Merge pull request 'chore(devops): HTTPS для meditron.tenvex.ru' (#93) from chore/nginx-https-prod into dev
CI / ruff · pytest (push) Successful in 2m14s
2026-10-04 16:25:26 +03:00
ElebobaandClaude Opus 5.5 f14efac7ac chore(devops): HTTPS for meditron.tenvex.ru too
CI / ruff · pytest (pull_request) Successful in 2m20s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:22:49 +03:00
Eleboba 766c2a2e4b Merge pull request 'chore(devops): HTTPS для dev.meditron.tenvex.ru' (#91) from chore/nginx-https into dev
CI / ruff · pytest (push) Successful in 2m16s
2026-10-04 16:21:49 +03:00
Arxip222 37b612b8c4 Merge remote-tracking branch 'origin/dev' into fix/clinical-review-safety
CI / ruff · pytest (pull_request) Successful in 2m22s
2026-10-04 16:21:24 +03:00
Arxip222andClaude Opus 5.5 44814b7293 fix(clinical): red flags, one crisis rule, callback after a crisis, one table of numbers
Clinical review of the questionnaires (ADR-013):
- Red-flag block first in stage 1 (config/instruments/red_flags.yaml); any «Да» stops the
  questionnaire with an acute alert and the help screen of its category.
- One crisis rule: the crisis screen opens at a HIGH C-SSRS risk only; moderate stops and goes
  to a psychiatrist (24 h as a guide), low is a flag with the safety plan. PHQ-9 item 9 with a
  negative screener is a discrepancy flag; without a completed screener it asks the C-SSRS at once.
  config/questionnaire.yaml: cssrs_positive_policy graded (C01).
- Every crisis leaves a callback task for the doctor on duty (post_crisis_call, one per patient)
  and the app asks how the person is the next day, the safety plan one tap away.
- Routing: risk and flags first, scales second; PCL-5 >= 50 no longer means a priority
  appointment (pcl5_severe is moderate on the dashboard too).
- Crisis numbers live only in config/texts/crisis.yaml; texts and the agent prompt refer to them
  via {phone:<key>}. Fixes the children's helpline offered to adults by the chat agent.

Refs #92

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:21:16 +03:00
Eleboba c09620e6d3 Merge pull request 'feat(ui): единый дизайн всех страниц по образцу кабинета врача' (#88) from feat/85-unified-design into dev
CI / ruff · pytest (push) Successful in 2m26s
Reviewed-on: #88
2026-10-04 16:20:10 +03:00
ElebobaandClaude Opus 5.5 a3180ad232 chore(devops): HTTPS for dev.meditron.tenvex.ru, meditron.tenvex.ru on HTTP until its DNS record
CI / ruff · pytest (pull_request) Successful in 2m20s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:19:19 +03:00
Soyavo 07652d044c Merge pull request 'feat(bot): prepare MAX notification adapter and setup handoff' (#89) from feat/83-max-bot into dev
CI / ruff · pytest (push) Successful in 2m21s
2026-10-04 16:17:18 +03:00
Soyavo ae4c76aa62 Merge pull request '[contract] Redact input values in settings validation errors' (#90) from fix/87-settings-privacy into dev
CI / ruff · pytest (push) Successful in 2m19s
2026-10-04 16:17:03 +03:00
Pavel Eliseev a4ee3d25f0 fix(web): allow returning home from questionnaires
CI / ruff · pytest (pull_request) Successful in 2m28s
Keep the shared dialog and submitted answers while leaving the focus screen. Remove the history trap and wire the text-step home action.

Refs #85
2026-10-04 16:14:37 +03:00
Pavel Eliseev 2a2fdb171e feat(ui): extend minimal doctor design across all screens
CI / ruff · pytest (pull_request) Successful in 2m16s
Refs #85
2026-10-04 16:09:57 +03:00
56 changed files with 1194 additions and 334 deletions
+6 -6
View File
@@ -1,15 +1,15 @@
# Dashboard-only Streamlit settings (doctor dashboard + demo console). # Dashboard-only Streamlit settings (doctor dashboard + demo console).
[theme] [theme]
base = "light" base = "light"
primaryColor = "#2A78D6" primaryColor = "#285E4E"
backgroundColor = "#F6F7F9" backgroundColor = "#F6F7F3"
secondaryBackgroundColor = "#FFFFFF" secondaryBackgroundColor = "#FFFFFF"
textColor = "#1B1F24" textColor = "#213B38"
borderColor = "#E3E6EA" borderColor = "#E1E7E3"
baseRadius = "medium" baseRadius = "medium"
# Falls back to the system sans-serif when offline (ADR-004). # Falls back to the system sans-serif when offline (ADR-004).
font = "Inter:https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap, sans-serif" font = "sans-serif"
headingFontWeights = 700 headingFontWeights = 600
[client] [client]
toolbarMode = "minimal" toolbarMode = "minimal"
+11 -1
View File
@@ -34,6 +34,7 @@ from app.db import repo
from app.db.clinical import Alert, DailyCheckin, Dose, PatientProfile, Prescription, SafetyPlan, ScaleResult from app.db.clinical import Alert, DailyCheckin, Dose, PatientProfile, Prescription, SafetyPlan, ScaleResult
from app.db.identity import Patient from app.db.identity import Patient
from app.integrations.base import Prescription as PrescriptionOut from app.integrations.base import Prescription as PrescriptionOut
from app.services.anonymous_care import display_name as anonymous_name
COLOR_ORDER: dict[str, int] = {"red": 0, "yellow": 1, "grey": 2, "green": 3} COLOR_ORDER: dict[str, int] = {"red": 0, "yellow": 1, "grey": 2, "green": 3}
_MODULES = frozenset(get_args(Module)) _MODULES = frozenset(get_args(Module))
@@ -96,6 +97,7 @@ class PatientData:
doses: Sequence[Dose] doses: Sequence[Dose]
prescriptions: Sequence[Prescription] prescriptions: Sequence[Prescription]
alerts: Sequence[Alert] # newest first alerts: Sequence[Alert] # newest first
contact_requested_at: datetime | None = None
async def load_patient_data(session: AsyncSession, profile: PatientProfile) -> PatientData: async def load_patient_data(session: AsyncSession, profile: PatientProfile) -> PatientData:
@@ -104,6 +106,9 @@ async def load_patient_data(session: AsyncSession, profile: PatientProfile) -> P
rx = ( rx = (
await session.scalars(select(Prescription).where(Prescription.token == token).order_by(Prescription.id)) await session.scalars(select(Prescription).where(Prescription.token == token).order_by(Prescription.id))
).all() ).all()
dialog = await repo.get_dialog_state(session, token)
requests = (dialog.data.get("contact_requests") or []) if dialog else []
contact_at = datetime.fromisoformat(requests[-1]["ts"]) if requests else None
return PatientData( return PatientData(
profile=profile, profile=profile,
checkins=await repo.list_checkins(session, token), checkins=await repo.list_checkins(session, token),
@@ -111,6 +116,7 @@ async def load_patient_data(session: AsyncSession, profile: PatientProfile) -> P
doses=doses, doses=doses,
prescriptions=rx, prescriptions=rx,
alerts=await repo.list_alerts(session, token=token), alerts=await repo.list_alerts(session, token=token),
contact_requested_at=contact_at,
) )
@@ -228,6 +234,10 @@ def _last_event(d: PatientData, now: datetime) -> str:
alert = d.alerts[0] if d.alerts else None alert = d.alerts[0] if d.alerts else None
scales = [r for r in d.results if r.complete and r.scale in _SCALE_LABELS] scales = [r for r in d.results if r.complete and r.scale in _SCALE_LABELS]
scale = scales[-1] if scales else None scale = scales[-1] if scales else None
if d.contact_requested_at is not None and all(
d.contact_requested_at >= ts for ts in [x.ts for x in (alert, scale) if x is not None]
):
return "Пациент просит врача связаться"
if scale is not None and (alert is None or scale.ts > alert.ts): if scale is not None and (alert is None or scale.ts > alert.ts):
day = _therapy_day(d.profile, _local(scale.ts, now)) day = _therapy_day(d.profile, _local(scale.ts, now))
return f"{_SCALE_LABELS[scale.scale]} = {scale.total} (день {day})" return f"{_SCALE_LABELS[scale.scale]} = {scale.total} (день {day})"
@@ -249,7 +259,7 @@ def list_item(d: PatientData, patient: Patient | None, now: datetime) -> Patient
rx = _active_rx(d, today) rx = _active_rx(d, today)
return PatientListItem( return PatientListItem(
token=p.token, token=p.token,
display_name=patient.display_name if patient else "Пациент", display_name=patient.display_name if patient else anonymous_name(p.token),
age=_age(patient, p, today), age=_age(patient, p, today),
modules=[cast(Module, m) for m in p.modules or [] if m in _MODULES], modules=[cast(Module, m) for m in p.modules or [] if m in _MODULES],
drug=_drug_label(rx) if rx else None, drug=_drug_label(rx) if rx else None,
+2 -1
View File
@@ -16,6 +16,7 @@ from app.api.schemas import (
PatientListItem, PatientListItem,
SeriesPoint, SeriesPoint,
) )
from app.core.safety import fill_phones
from app.integrations.base import Prescription from app.integrations.base import Prescription
TZ = ZoneInfo("Europe/Moscow") TZ = ZoneInfo("Europe/Moscow")
@@ -262,7 +263,7 @@ def andrey_card() -> PatientCard:
"Что помогает: прогулка с собакой, душ", "Что помогает: прогулка с собакой, душ",
"Люди и места: брат Олег, спортзал", "Люди и места: брат Олег, спортзал",
"Кому звонить: жена, сослуживец Миша", "Кому звонить: жена, сослуживец Миша",
"Специалисты: д-р Смирнова, 8-800-2000-122, 112", fill_phones("Специалисты: д-р Смирнова, {phone:helpline}, 112"),
"Безопасная среда: ружьё хранится у брата", "Безопасная среда: ружьё хранится у брата",
], ],
summary="За 13 дней: приверженность 12/13, эпизод намерения бросить на 5-й день (снят после звонка врача). " summary="За 13 дней: приверженность 12/13, эпизод намерения бросить на 5-й день (снят после звонка врача). "
+30 -7
View File
@@ -7,9 +7,9 @@ exactly like the web console, and consent, onboarding, scales and check-ins come
Two ways in: Two ways in:
- **ЕМИАС** (demo): the doctor's one-time connect code via `EmiasMock`, or a synthetic demo persona. The - **ЕМИАС** (demo): the doctor's one-time connect code via `EmiasMock`, or a synthetic demo persona. The
patient is linked to the attending doctor, who sees the data on the dashboard. patient is linked to the attending doctor, who sees the data on the dashboard.
- **Anonymous**: a random pseudonymous token and nothing else — no identity record, no doctor. The engine - **Anonymous**: a random pseudonymous token, no identity record, and access to the prototype care team.
supports tokens without an identity record (consent is kept in the dialog state), so no personal data is Clinical answers are stored on the server and visible to the assigned doctor under a pseudonym.
stored at all. Crisis safety still works: the fixed crisis script does not depend on the doctor. Consent is kept in the dialog state. Existing doctor-less profiles require explicit care opt-in.
The token returned here is the session credential of the prototype. Production needs short-lived sessions The token returned here is the session credential of the prototype. Production needs short-lived sessions
issued by the server instead of handing out the pseudonymous token itself. issued by the server instead of handing out the pseudonymous token itself.
@@ -36,11 +36,13 @@ 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 repo
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
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
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -153,19 +155,40 @@ async def auth_emias_demo(body: EmiasDemoIn) -> AuthOut:
@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; no identity record, no doctor. """Anonymous sign-in: a fresh random token; care without an identity record.
The clinical side gets a doctor-less pseudonymous profile (the dialog API serves known profiles only). It holds The clinical side gets a pseudonymous profile assigned to the prototype care team. It holds
no personal data — only the module of the chosen `focus`; the stage 1 screening then sets the modules itself. no identity fields. Legacy clients may supply `focus`; the common stage 1 screening sets modules itself.
""" """
token = secrets.token_hex(TOKEN_LEN // 2) token = secrets.token_hex(TOKEN_LEN // 2)
focus = body.focus if body is not None else None focus = body.focus if body is not None else None
async with get_clinical_session() as s: async with get_clinical_session() as s:
await repo.upsert_profile(s, token=token, doctor_id="", modules=[_FOCUS_MODULE[focus]] if focus else []) await repo.upsert_profile(
s, token=token, doctor_id=anonymous_care.doctor_id(), modules=[_FOCUS_MODULE[focus]] if focus else []
)
log.info("web auth: anonymous tok=%s", token[:6]) log.info("web auth: anonymous tok=%s", token[:6])
return AuthOut(token=token, mode="anonymous") return AuthOut(token=token, mode="anonymous")
@router.get("/patient/capabilities")
async def patient_capabilities() -> dict[str, bool]:
"""Expose availability without leaking clinical policy or server configuration."""
return {"primary_screening": intake_enabled()}
@router.post("/me/{token}/doctor/connect")
async def connect_anonymous_doctor(token: str) -> dict[str, bool]:
"""Explicit opt-in for old doctor-less profiles; never reassign an existing doctor."""
async with get_clinical_session() as s:
profile = await repo.get_profile(s, token)
if profile is None:
raise HTTPException(404, "patient not found")
if not profile.doctor_id:
profile.doctor_id = anonymous_care.doctor_id()
await audit(s, actor=token, action="anonymous_care_opt_in", token=token, ts=clock.now())
return {"has_doctor": True}
class DoseItem(BaseModel): class DoseItem(BaseModel):
id: int id: int
drug: str drug: str
+2 -1
View File
@@ -43,6 +43,7 @@ from app.db.session import get_clinical_session, get_identity_session
from app.dialog.contracts import Event, get_engine from app.dialog.contracts import Event, get_engine
from app.integrations.base import integrations from app.integrations.base import integrations
from app.scheduler import jobs as scheduler from app.scheduler import jobs as scheduler
from app.services.anonymous_care import display_name as anonymous_name
from app.services.monitoring import refresh_status from app.services.monitoring import refresh_status
router = APIRouter(prefix="/api/v1") router = APIRouter(prefix="/api/v1")
@@ -66,7 +67,7 @@ async def _patients(tokens: list[str]) -> dict[str, Patient]:
def _name(patients: dict[str, Patient], token: str) -> str: def _name(patients: dict[str, Patient], token: str) -> str:
p = patients.get(token) p = patients.get(token)
return p.display_name if p else "Пациент" return p.display_name if p else anonymous_name(token)
# ------------------------------------------------------------------ doctor dashboard # ------------------------------------------------------------------ doctor dashboard
+4 -1
View File
@@ -13,7 +13,7 @@ from pydantic import BaseModel
from app.config import get_settings from app.config import get_settings
from app.core.cssrs_content import load_cssrs_russian from app.core.cssrs_content import load_cssrs_russian
from app.core.intake_questions import DIARY_PARTS, content, diary_config, texts from app.core.intake_questions import DIARY_PARTS, content, diary_config, red_flags, texts
from app.core.scales import load_scale from app.core.scales import load_scale
Section = Literal["safety", "mood", "trauma", "alcohol", "about", "diary", "scale"] Section = Literal["safety", "mood", "trauma", "alcohol", "about", "diary", "scale"]
@@ -92,6 +92,9 @@ def catalog() -> Catalog:
), ),
) )
flags = red_flags()
add(FormInfo(id="red_flags", title="Вопросы о безопасности", section="safety"), [i["id"] for i in flags["items"]])
cssrs = load_cssrs_russian() cssrs = load_cssrs_russian()
add(FormInfo(id="cssrs", title=cssrs.title, section="safety"), ["cssrs_" + i.id for i in cssrs.items]) add(FormInfo(id="cssrs", title=cssrs.title, section="safety"), ["cssrs_" + i.id for i in cssrs.items])
+18 -4
View File
@@ -53,11 +53,25 @@ def cssrs_answers(answers: dict) -> dict[str, int]:
return {k.removeprefix("cssrs_"): v for k, v in answers.items() if k.startswith("cssrs_")} return {k.removeprefix("cssrs_"): v for k, v in answers.items() if k.startswith("cssrs_")}
def red_flags() -> dict:
return yaml.safe_load((get_settings().config_dir / "instruments/red_flags.yaml").read_text(encoding="utf-8"))
def red_flag_category(item_id: str) -> str | None:
return next((i["category"] for i in red_flags()["items"] if i["id"] == item_id), None)
def next_intake(answers: dict, policy: IntakePolicy, sex: str | None) -> Question | None: def next_intake(answers: dict, policy: IntakePolicy, sex: str | None) -> Question | None:
"""Stage 1 in the fixed order of «Опросники для MVP»: PHQ-2, trauma filter → TSQ, RUS-AUDIT-S, C-SSRS screener, """Stage 1: the red-flag block first (config/instruments/red_flags.yaml), then the fixed order of «Опросники
then the context questions; stage 2 (rest of PHQ-9, PCL-5, full RUS-AUDIT) only where stage 1 is positive. для MVP»: PHQ-2, trauma filter → TSQ, RUS-AUDIT-S, C-SSRS screener, the context questions; stage 2 (rest of
Items inside a scale keep the order of the source. The risk stop rules run after every answer (flows/intake.py), PHQ-9, PCL-5, full RUS-AUDIT) only where stage 1 is positive. Items inside a scale keep the order of the source.
so a positive answer interrupts the questionnaire wherever it comes.""" The risk stop rules run after every answer (flows/intake.py), so a positive answer interrupts the questionnaire
wherever it comes."""
flags = red_flags()
for n, item in enumerate(flags["items"]):
if item["id"] not in answers:
text = (flags["intro"] + "\n\n" if n == 0 else "") + item["text"]
return Question(item["id"], text, tuple((o["value"], o["label"]) for o in flags["options"]), "now")
for item in ("phq9_1", "phq9_2"): for item in ("phq9_1", "phq9_2"):
if item not in answers: if item not in answers:
return scale_question("phq9", item) return scale_question("phq9", item)
+2 -1
View File
@@ -12,6 +12,7 @@ import yaml
from app.config import get_settings from app.config import get_settings
from app.core.intake_questions import content from app.core.intake_questions import content
from app.core.questionnaire import IntakeDecision, IntakePolicy from app.core.questionnaire import IntakeDecision, IntakePolicy
from app.core.safety import fill_phones
# Stage 1 finding → diary module of stage 2 (PTSD is the «veteran» module of the profile). # Stage 1 finding → diary module of stage 2 (PTSD is the «veteran» module of the profile).
FINDING_MODULE = {"depression_suspected": "depression", "ptsd_suspected": "veteran"} FINDING_MODULE = {"depression_suspected": "depression", "ptsd_suspected": "veteran"}
@@ -23,7 +24,7 @@ PCL5_IMPROVED_BELOW = 28
def texts() -> dict[str, Any]: def texts() -> dict[str, Any]:
return yaml.safe_load((get_settings().config_dir / "texts/results.yaml").read_text(encoding="utf-8")) return yaml.safe_load(fill_phones((get_settings().config_dir / "texts/results.yaml").read_text(encoding="utf-8")))
def render(interpretation: dict[str, Any]) -> str: def render(interpretation: dict[str, Any]) -> str:
+6 -5
View File
@@ -50,7 +50,6 @@ class IntakePolicy(BaseModel):
phq9_specialist_at: int = Field(ge=0, le=27, strict=True) phq9_specialist_at: int = Field(ge=0, le=27, strict=True)
phq9_priority_at: int = Field(ge=0, le=27, strict=True) phq9_priority_at: int = Field(ge=0, le=27, strict=True)
pcl5_specialist_at: int = Field(ge=0, le=80, strict=True) pcl5_specialist_at: int = Field(ge=0, le=80, strict=True)
pcl5_priority_at: int = Field(ge=0, le=80, strict=True)
pcl5_behavior_flag_at: int = Field(ge=0, le=4, strict=True) pcl5_behavior_flag_at: int = Field(ge=0, le=4, strict=True)
@model_validator(mode="after") @model_validator(mode="after")
@@ -65,8 +64,6 @@ class IntakePolicy(BaseModel):
raise ValueError("choose a total threshold OR a zone for alcohol expansion") raise ValueError("choose a total threshold OR a zone for alcohol expansion")
if not self.phq9_psychologist_at < self.phq9_specialist_at < self.phq9_priority_at: if not self.phq9_psychologist_at < self.phq9_specialist_at < self.phq9_priority_at:
raise ValueError("PHQ-9 thresholds must be ordered") raise ValueError("PHQ-9 thresholds must be ordered")
if self.pcl5_specialist_at >= self.pcl5_priority_at:
raise ValueError("PCL-5 thresholds must be ordered")
return self return self
@@ -276,7 +273,9 @@ def evaluate_intake(
if c.positive: if c.positive:
flags.append("cssrs_positive") flags.append("cssrs_positive")
flags.extend(c.conflicts) flags.extend(c.conflicts)
if item9_positive or c.risk_lower_bound == "high": # One crisis rule (ADR-013): a high C-SSRS risk. Item 9 alone is a crisis only while the screener is not complete;
# with a negative / low screener it is the «discrepancy» flag of the routing table, not the crisis screen.
if c.risk_lower_bound == "high" or (item9_positive and not c.complete):
return IntakeDecision(policy.version, "crisis", "crisis", (), tuple(flags), c.missing) return IntakeDecision(policy.version, "crisis", "crisis", (), tuple(flags), c.missing)
if c.positive and policy.approval and policy.cssrs_positive_policy == "stop_any": if c.positive and policy.approval and policy.cssrs_positive_policy == "stop_any":
return IntakeDecision(policy.version, "crisis", "crisis", (), tuple(flags), c.missing) return IntakeDecision(policy.version, "crisis", "crisis", (), tuple(flags), c.missing)
@@ -327,9 +326,11 @@ def evaluate_intake(
# PHQ-2-negative does not invent a PHQ-9 total; it simply does not trigger expansion. # PHQ-2-negative does not invent a PHQ-9 total; it simply does not trigger expansion.
phq_total = phq.total if phq.complete else 0 phq_total = phq.total if phq.complete else 0
pcl_total = pcl.total if pcl.complete else 0 pcl_total = pcl.total if pcl.complete else 0
# Risk and flags first, scales second (clinical review 2026-10-04). A high PCL-5 alone is not a reason for a
# priority appointment: it goes to the planned specialist route.
if c.risk == "moderate": if c.risk == "moderate":
primary = "urgent_psychiatrist" primary = "urgent_psychiatrist"
elif phq_total >= policy.phq9_priority_at or pcl_total >= policy.pcl5_priority_at: elif phq_total >= policy.phq9_priority_at:
primary = "priority_psychiatrist" primary = "priority_psychiatrist"
elif phq_total >= policy.phq9_specialist_at or pcl_total >= policy.pcl5_specialist_at: elif phq_total >= policy.phq9_specialist_at or pcl_total >= policy.pcl5_specialist_at:
primary = "psychiatrist_or_psychotherapist" primary = "psychiatrist_or_psychotherapist"
+19
View File
@@ -395,6 +395,25 @@ def _crisis_cfg(path: Path = CRISIS_PATH) -> _CrisisCfg:
return _CrisisCfg.model_validate(yaml.safe_load(path.read_text(encoding="utf-8"))) return _CrisisCfg.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
_PHONE_REF = re.compile(r"\{phone:([a-z_]+)\}")
def fill_phones(text: str, path: Path = CRISIS_PATH) -> str:
"""Replace `{phone:<resource>}` with the number from the single table in config/texts/crisis.yaml.
Every patient text and prompt refers to the table instead of repeating a number, so a number is changed in one
place. An unknown resource fails loudly: a wrong number on the crisis screen must never pass silently.
"""
resources = _crisis_cfg(path).resources
def number(m: re.Match[str]) -> str:
if m.group(1) not in resources:
raise ValueError(f"unknown crisis resource {m.group(1)!r}")
return resources[m.group(1)].phone
return _PHONE_REF.sub(number, text)
def _render(body: str, resources: list[CrisisResource], doctor_notified: str, grounding: str) -> str: def _render(body: str, resources: list[CrisisResource], doctor_notified: str, grounding: str) -> str:
lines = [body.strip(), ""] lines = [body.strip(), ""]
for r in resources: for r in resources:
+66 -6
View File
@@ -14,6 +14,7 @@ from app.core.intake_questions import (
diary_questions, diary_questions,
group_answers, group_answers,
next_intake, next_intake,
red_flag_category,
scale_question, scale_question,
texts, texts,
) )
@@ -27,6 +28,7 @@ from app.dialog.contracts import Button, Event, Reply
from app.dialog.crisis import crisis_open, open_crisis from app.dialog.crisis import crisis_open, open_crisis
from app.dialog.fsm import Step, on_enter, state, trigger from app.dialog.fsm import Step, on_enter, state, trigger
from app.services import questionnaire as service from app.services import questionnaire as service
from app.services.followup import schedule_post_crisis_call
def allowed(ctx): def allowed(ctx):
@@ -105,6 +107,7 @@ async def urgent(ctx, row=None, *, code="help_now", categories=None):
level=AlertLevel.ACUTE, level=AlertLevel.ACUTE,
params={"record_id": row.id if row else None, "flags": flags}, params={"record_id": row.id if row else None, "flags": flags},
) )
await schedule_post_crisis_call(ctx.session, ctx.token, ctx.now, source="questionnaire:" + code)
step = open_crisis(ctx, categories or [], source="questionnaire:" + code) step = open_crisis(ctx, categories or [], source="questionnaire:" + code)
step.data["questionnaire_record"] = None step.data["questionnaire_record"] = None
step.data["intake_review_required"] = True step.data["intake_review_required"] = True
@@ -113,6 +116,41 @@ async def urgent(ctx, row=None, *, code="help_now", categories=None):
return step return step
async def item9_discrepancy(ctx, row):
profile = await ctx.profile()
if profile:
profile.enhanced_monitoring = True
await service.alert(
ctx.session,
ctx.token,
row.id + ":phq9_cssrs_discrepancy",
"phq9_cssrs_discrepancy",
ctx.now,
level=AlertLevel.HIGH,
params={"record_id": row.id},
)
row.details = {**row.details, "item9_flagged": True}
async def item9_assessment(ctx, row):
"""Thoughts of death in PHQ-9 item 9 → the C-SSRS screener at once; the screener's level decides (graded)."""
await service.alert(
ctx.session,
ctx.token,
row.id + ":item9_assessment",
"phq9_item9_cssrs",
ctx.now,
level=AlertLevel.HIGH,
params={"record_id": row.id},
)
await service.finish(ctx, row, "complete", extra={"stop_reason": "phq9_item9_cssrs"})
profile = await ctx.profile()
if profile:
profile.enhanced_monitoring = True
task = await service.queue_task(ctx.session, ctx.token, "cssrs_since_contact", row.id + ":cssrs", ctx.now)
return await start(ctx, "cssrs_since_contact", task)
async def manual_review(ctx, row, code): async def manual_review(ctx, row, code):
await service.finish(ctx, row, "review_required", extra={"stop_reason": code}) await service.finish(ctx, row, "review_required", extra={"stop_reason": code})
await service.alert( await service.alert(
@@ -135,9 +173,24 @@ async def advance(ctx, row):
a = row.answers a = row.answers
c = score_cssrs_recent(cssrs_answers(a)) c = score_cssrs_recent(cssrs_answers(a))
policy = load_policy() policy = load_policy()
# PHQ-9 item 9 ≥1 — in the full scale or in the depression module of the diary — stops at once (stage 1 rule). # Red flags (asked first): any «Да» stops the questionnaire with the help screen of its category.
if a.get("phq9_9", 0) > 0 or a.get("dphq_9", 0) > 0 or c.risk_lower_bound == "high": for item_id, value in a.items():
if item_id.startswith("rf_") and value == 1:
category = red_flag_category(item_id)
return await urgent(ctx, row, code="red_flag_" + item_id[3:], categories=[category] if category else [])
# One crisis rule for the whole app (ADR-013): the crisis screen opens at a HIGH C-SSRS risk only. Moderate →
# stop + psychiatrist (about 24 h); low → flag, safety plan, continue.
if c.risk_lower_bound == "high":
return await urgent(ctx, row, code="suicide_signal") return await urgent(ctx, row, code="suicide_signal")
if a.get("dphq_9", 0) > 0 and row.kind in DIARY_KINDS:
# Diary depression module, item 9 ≥1 → the C-SSRS right away («Этап 2»); its risk level decides.
return await item9_assessment(ctx, row)
if a.get("phq9_9", 0) > 0 and not c.complete:
# Item 9 without a completed screener (only in a scheduled PHQ-9): assess the risk right away.
return await item9_assessment(ctx, row)
if a.get("phq9_9", 0) > 0 and not row.details.get("item9_flagged"):
# Item 9 ≥1 with a negative / low C-SSRS = discrepancy (routing table): flag to the doctor, safety plan.
await item9_discrepancy(ctx, row)
if c.positive and policy.cssrs_positive_policy == "stop_any": if c.positive and policy.cssrs_positive_policy == "stop_any":
return await urgent(ctx, row, code="cssrs_positive") return await urgent(ctx, row, code="cssrs_positive")
if c.conflicts: if c.conflicts:
@@ -165,7 +218,7 @@ async def advance(ctx, row):
return await manual_review(ctx, row, "answer_conflict") return await manual_review(ctx, row, "answer_conflict")
if q: if q:
replies = [question_reply(row, q)] replies = [question_reply(row, q)]
if c.complete and c.risk == "low" and not row.details.get("plan_shown"): if ((c.complete and c.risk == "low") or row.details.get("item9_flagged")) and not row.details.get("plan_shown"):
from app.dialog.actions import safety_plan_reply from app.dialog.actions import safety_plan_reply
replies.insert(0, await safety_plan_reply(ctx)) replies.insert(0, await safety_plan_reply(ctx))
@@ -225,11 +278,12 @@ async def advance(ctx, row):
if row.kind in ("phq9", "pcl5"): if row.kind in ("phq9", "pcl5"):
sid = row.kind sid = row.kind
total = extra["totals"][sid]["total"] total = extra["totals"][sid]["total"]
priority = getattr(policy, sid + "_priority_at") # Only PHQ-9 has a priority threshold; PCL-5 alone never makes an appointment a priority.
priority = getattr(policy, sid + "_priority_at", None)
specialist = getattr(policy, sid + "_specialist_at") specialist = getattr(policy, sid + "_specialist_at")
extra["route"] = ( extra["route"] = (
"priority_psychiatrist" "priority_psychiatrist"
if total >= priority if priority is not None and total >= priority
else ("psychiatrist_or_psychotherapist" if total >= specialist else "review_with_treating_clinician") else ("psychiatrist_or_psychotherapist" if total >= specialist else "review_with_treating_clinician")
) )
earlier = [ earlier = [
@@ -284,7 +338,13 @@ async def advance(ctx, row):
return step return step
from app.dialog.flows.core import menu_reply from app.dialog.flows.core import menu_reply
return Step(replies=[menu_reply(ctx, shown)], next_state="idle", data={"questionnaire_record": None}) replies = [menu_reply(ctx, shown)]
if ((c.complete and c.risk == "low") or row.details.get("item9_flagged")) and not row.details.get("plan_shown"):
# The safety plan is never skipped because the risk signal came with the last question.
from app.dialog.actions import safety_plan_reply
replies.insert(0, await safety_plan_reply(ctx))
return Step(replies=replies, next_state="idle", data={"questionnaire_record": None})
# Diary flags that bring a full scale forward («Этап 2», p.4). # Diary flags that bring a full scale forward («Этап 2», p.4).
+3 -1
View File
@@ -7,6 +7,7 @@ import yaml
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from app.config import get_settings from app.config import get_settings
from app.core.safety import fill_phones
class _Strict(BaseModel): class _Strict(BaseModel):
@@ -91,6 +92,7 @@ class ReachOutTexts(_Strict):
silence: str silence: str
missed_doses: str missed_doses: str
default: str default: str
after_crisis: str
class VisitTexts(_Strict): class VisitTexts(_Strict):
@@ -120,4 +122,4 @@ def dialog_texts_path() -> Path:
@lru_cache @lru_cache
def load_dialog_texts(path: Path | None = None) -> DialogTexts: def load_dialog_texts(path: Path | None = None) -> DialogTexts:
path = path or dialog_texts_path() path = path or dialog_texts_path()
return DialogTexts.model_validate(yaml.safe_load(path.read_text(encoding="utf-8"))) return DialogTexts.model_validate(yaml.safe_load(fill_phones(path.read_text(encoding="utf-8"))))
+7 -2
View File
@@ -1,6 +1,8 @@
"""System prompt of the chat agent (Russian). Prohibitions follow SPEC §7; grounding: answer only from tools.""" """System prompt of the chat agent (Russian). Prohibitions follow SPEC §7; grounding: answer only from tools."""
SYSTEM_PROMPT = """Ты — помощник пациента в сервисе сопровождения лечения между визитами к врачу. Пациент лечится у from app.core.safety import fill_phones
_SYSTEM_PROMPT = """Ты — помощник пациента в сервисе сопровождения лечения между визитами к врачу. Пациент лечится у
психиатра; ты отвечаешь на его вопросы о состоянии, лечении и лекарствах. психиатра; ты отвечаешь на его вопросы о состоянии, лечении и лекарствах.
Как работать: Как работать:
@@ -32,4 +34,7 @@ SYSTEM_PROMPT = """Ты — помощник пациента в сервисе
Стиль: по-русски, на «вы», спокойно и по-человечески, 3–6 коротких предложений, без markdown-заголовков. Стиль: по-русски, на «вы», спокойно и по-человечески, 3–6 коротких предложений, без markdown-заголовков.
Если в вопросе есть признаки угрозы жизни — коротко скажи, что важно сразу позвонить 112 или на телефон Если в вопросе есть признаки угрозы жизни — коротко скажи, что важно сразу позвонить 112 или на телефон
доверия 8-800-2000-122, и что врач будет уведомлён.""" доверия {phone:helpline}, и что врач будет уведомлён."""
# Numbers come from the single table config/texts/crisis.yaml.
SYSTEM_PROMPT = fill_phones(_SYSTEM_PROMPT)
+12 -1
View File
@@ -22,11 +22,13 @@ from app.db import repo
from app.db.clinical import PatientProfile from app.db.clinical import PatientProfile
from app.db.intake import QuestionnaireTask from app.db.intake import QuestionnaireTask
from app.db.session import get_clinical_session from app.db.session import get_clinical_session
from app.dialog.contracts import Event, Reply, get_engine from app.dialog.contracts import Button, Event, Reply, get_engine
from app.dialog.texts import load_dialog_texts
from app.integrations.base import integrations from app.integrations.base import integrations
from app.scheduler import demo from app.scheduler import demo
from app.services import monitoring from app.services import monitoring
from app.services import questionnaire as service from app.services import questionnaire as service
from app.services.followup import next_day_check_due
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
_worker = None _worker = None
@@ -43,6 +45,15 @@ async def plan():
state = await repo.get_dialog_state(session, profile.token) state = await repo.get_dialog_state(session, profile.token)
if not state or state.data.get("consent") is not True or state.data.get("adult") is not True: if not state or state.data.get("consent") is not True or state.data.get("adult") is not True:
continue continue
crisis = state.data.get("crisis")
if next_day_check_due(crisis, now):
# The day after a crisis the app asks how the person is, the safety plan one tap away.
texts = load_dialog_texts()
plan_button = Button(label=texts.safety_plan.title.rstrip(":"), data="safety_plan")
await integrations.outbox.push(
profile.token, [Reply(text=texts.reach_out.after_crisis, buttons=[[plan_button]], kind="question")]
)
state.data = {**state.data, "crisis": {**crisis, "followup_sent": now.isoformat()}}
rows = await service.records(session, profile.token) rows = await service.records(session, profile.token)
initial = next((r for r in reversed(rows) if r.kind == "intake" and r.status == "complete"), None) initial = next((r for r in reversed(rows) if r.kind == "intake" and r.status == "complete"), None)
if initial is None: if initial is None:
+24
View File
@@ -0,0 +1,24 @@
"""Pseudonymous care routing; no identity fields or changes to clinical release gates."""
from hashlib import sha256
import yaml
from pydantic import BaseModel, ConfigDict, Field
from app.config import get_settings
class AnonymousCare(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
doctor_id: str = Field(min_length=1, max_length=64)
def doctor_id() -> str:
path = get_settings().config_dir / "anonymous_care.yaml"
return AnonymousCare.model_validate(yaml.safe_load(path.read_text())).doctor_id
def display_name(token: str) -> str:
# Do not use a piece of the bearer token itself as the public patient label.
label = sha256(("anonymous-patient:" + token).encode()).hexdigest()[:10].upper()
return f"Пациент {label}"
+43
View File
@@ -0,0 +1,43 @@
"""Follow-up after a crisis (clinical review 2026-10-04: «план безопасности и звонок после кризиса — обязательно»).
Every crisis — from the safety lexicon, a rule (PHQ-9 item 9, C-SSRS high) or the questionnaire (red flags, high
risk) — leaves a task for the doctor on duty: call the patient back, about 24 hours as a guide. The task is an alert
on the dashboard; «позвонили» is its acknowledgement (`repo.ack_alert`, audited). One open task per patient: a
repeated crisis signal does not pile up callbacks. On the next day the app itself asks how the person is, with the
safety plan at hand (`app/scheduler/jobs.py`).
"""
from datetime import datetime, timedelta
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.types import AlertLevel
from app.db import repo
from app.db.clinical import Alert
POST_CRISIS_CALL = "post_crisis_call"
ON_DUTY = "on-duty"
CALL_WITHIN = timedelta(hours=24) # a guide, not a guarantee
NEXT_DAY_CHECK_AFTER = timedelta(hours=20)
async def schedule_post_crisis_call(session: AsyncSession, token: str, now: datetime, *, source: str) -> Alert | None:
if POST_CRISIS_CALL in await repo.open_alert_codes(session, token):
return None
return await repo.add_alert(
session,
token=token,
level=AlertLevel.HIGH,
code=POST_CRISIS_CALL,
reason="Перезвонить пациенту после кризиса — дежурный специалист, ориентир 24 ч. Отметка «позвонили» — "
"подтверждение этого сигнала.",
ts=now,
params={"due_at": (now + CALL_WITHIN).isoformat(), "assignee": ON_DUTY, "source": source},
)
def next_day_check_due(crisis: dict | None, now: datetime) -> bool:
"""The day after a crisis the app asks how the person is — once per crisis episode."""
if not crisis or not crisis.get("opened_at") or crisis.get("followup_sent"):
return False
return now - datetime.fromisoformat(crisis["opened_at"]) >= NEXT_DAY_CHECK_AFTER
+2
View File
@@ -30,6 +30,7 @@ from app.db.audit import audit
from app.db.session import get_clinical_session from app.db.session import get_clinical_session
from app.integrations.base import integrations from app.integrations.base import integrations
from app.services.contracts import register_monitoring from app.services.contracts import register_monitoring
from app.services.followup import schedule_post_crisis_call
from app.services.snapshot import build_snapshot from app.services.snapshot import build_snapshot
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -93,6 +94,7 @@ class RuleMonitoring:
ts=now, ts=now,
) )
notify.append((ON_DUTY_DOCTOR, level, action.reason_ru)) notify.append((ON_DUTY_DOCTOR, level, action.reason_ru))
await schedule_post_crisis_call(s, token, now, source=f"rule:{action.code}")
await refresh_status(s, token) await refresh_status(s, token)
log.info("monitoring token=%s actions=%s", token, [a.code for a in actions]) log.info("monitoring token=%s actions=%s", token, [a.code for a in actions])
for doctor_id, level, text in notify: for doctor_id, level, text in notify:
+3
View File
@@ -0,0 +1,3 @@
# Routing for the current prototype: anonymous patients use the same doctor workspace,
# without an identity record. Set this to the responsible clinic's doctor when deploying.
doctor_id: doc-smirnova
+25
View File
@@ -0,0 +1,25 @@
# Red flags — asked first in stage 1, before any scale (clinical review 2026-10-04: «обязательно»).
# Any «Да» stops the questionnaire: an acute alert to the doctor, a callback task for the doctor on duty and the
# help screen (crisis script variant by `category`, config/texts/crisis.yaml). Wording — team draft, the doctor
# approves it (#8). These are not a scale: there is no score, only a stop rule.
version: red-flags-v1-2026-10-04
intro: "Сначала несколько коротких вопросов о безопасности. Отвечайте так, как есть сейчас."
options:
- {value: 1, label: Да}
- {value: 0, label: Нет}
items:
- id: rf_psychosis
text: Слышите ли Вы сейчас голоса или видите то, чего не видят и не слышат другие люди?
category: null
- id: rf_harm_others
text: Есть ли у Вас сейчас мысли причинить вред другому человеку?
category: harm_others
- id: rf_weapons
text: Есть ли у Вас дома или рядом доступ к оружию?
category: null
- id: rf_violence
text: Угрожает ли Вам сейчас кто-то дома или применяет к Вам насилие?
category: domestic_violence
- id: rf_intoxication
text: Находитесь ли Вы сейчас в состоянии сильного опьянения или тяжёлой отмены алкоголя либо других веществ?
category: null
+2 -2
View File
@@ -1,7 +1,8 @@
# Review candidate from the supplied MVP PDF, not a signed clinical protocol. # Review candidate from the supplied MVP PDF, not a signed clinical protocol.
version: mvp-v2-review-2026-10-03 version: mvp-v2-review-2026-10-03
approval: null approval: null
cssrs_positive_policy: pending # C01 decided by the team after the clinical review (2026-10-04, ADR-013): crisis screen at HIGH risk only.
cssrs_positive_policy: graded
rus_audit_s_expand_from_zone: null rus_audit_s_expand_from_zone: null
phq2_expand_at: 2 phq2_expand_at: 2
tsq_expand_at: 6 tsq_expand_at: 6
@@ -9,5 +10,4 @@ phq9_psychologist_at: 5
phq9_specialist_at: 10 phq9_specialist_at: 10
phq9_priority_at: 20 phq9_priority_at: 20
pcl5_specialist_at: 31 pcl5_specialist_at: 31
pcl5_priority_at: 50
pcl5_behavior_flag_at: 3 pcl5_behavior_flag_at: 3
-1
View File
@@ -17,5 +17,4 @@ phq9_psychologist_at: 5
phq9_specialist_at: 10 phq9_specialist_at: 10
phq9_priority_at: 20 phq9_priority_at: 20
pcl5_specialist_at: 31 pcl5_specialist_at: 31
pcl5_priority_at: 50
pcl5_behavior_flag_at: 3 pcl5_behavior_flag_at: 3
+5 -3
View File
@@ -62,7 +62,7 @@ rules:
params: {} params: {}
actions: actions:
- {type: alert, level: high} - {type: alert, level: high}
reason_ru: "C-SSRS: умеренный риск, врачу в течение 24 ч" reason_ru: "C-SSRS: умеренный риск, психиатр — ориентир 24 ч"
# ---------------------------------------------------------------- scale thresholds (yellow) # ---------------------------------------------------------------- scale thresholds (yellow)
- id: phq9_severe - id: phq9_severe
@@ -73,10 +73,12 @@ rules:
reason_ru: "PHQ-9 = {total} (тяжёлая депрессия, порог {threshold})" reason_ru: "PHQ-9 = {total} (тяжёлая депрессия, порог {threshold})"
- id: pcl5_severe - id: pcl5_severe
description: Latest PCL-5 total >= 50 → doctor within 24 h. description: >
Latest PCL-5 total >= 50 → planned specialist review. Not a priority appointment by itself: risk and flags
come first, scales second (clinical review 2026-10-04).
params: {threshold: 50} params: {threshold: 50}
actions: actions:
- {type: alert, level: high} - {type: alert, level: moderate}
reason_ru: "PCL-5 = {total} (порог {threshold})" reason_ru: "PCL-5 = {total} (порог {threshold})"
- id: phq9_rise - id: phq9_rise
+7
View File
@@ -1,4 +1,6 @@
# Кризисный скрипт (guardrail №2, issue #4). Отправляется ДОСЛОВНО при safety = acute; LLM не вызывается. # Кризисный скрипт (guardrail №2, issue #4). Отправляется ДОСЛОВНО при safety = acute; LLM не вызывается.
# `resources` — ЕДИНСТВЕННАЯ таблица телефонов приложения: остальные тексты и промпты ссылаются на неё через
# `{phone:<ключ>}` (app/core/safety.py::fill_phones). Источники и проверка — docs/clinical/crisis-contacts.md.
# СТАТУС: черновик, утверждает психиатр (#8). # СТАТУС: черновик, утверждает психиатр (#8).
# #
# Выбор варианта (app/core/safety.py::crisis_reply): `<audience>.<категория>` по порядку category_priority, # Выбор варианта (app/core/safety.py::crisis_reply): `<audience>.<категория>` по порядку category_priority,
@@ -17,6 +19,11 @@ resources:
name: "Психологическая помощь МЧС России" name: "Психологическая помощь МЧС России"
phone: "+7 (495) 989-50-50" phone: "+7 (495) 989-50-50"
note: "круглосуточно; стоимость звонка по тарифу оператора" note: "круглосуточно; стоимость звонка по тарифу оператора"
# For minors only (age gate stop text); not a general adult crisis line (docs/clinical/crisis-contacts.md).
child_helpline:
name: "Детский телефон доверия"
phone: "8-800-2000-122"
note: "для детей, подростков и родителей; бесплатно, анонимно"
police: police:
name: "Полиция" name: "Полиция"
phone: "102" phone: "102"
+7 -5
View File
@@ -53,7 +53,7 @@ age_gate:
unclear: "Не удалось понять ответ. Нажмите кнопку или напишите год рождения четырьмя цифрами, например 1990." unclear: "Не удалось понять ответ. Нажмите кнопку или напишите год рождения четырьмя цифрами, например 1990."
minor_stop: |- minor_stop: |-
Спасибо, что ответили. Этот помощник рассчитан на взрослых, поэтому дальше я не смогу с вами работать и не сохраняю ваши ответы. Спасибо, что ответили. Этот помощник рассчитан на взрослых, поэтому дальше я не смогу с вами работать и не сохраняю ваши ответы.
Если вам тяжело или нужна поддержка — позвоните на Детский телефон доверия 8-800-2000-122 (бесплатно, анонимно, круглосуточно) или расскажите взрослому, которому доверяете: родителям, учителю, школьному психологу. Если вам тяжело или нужна поддержка — позвоните на Детский телефон доверия {phone:child_helpline} (бесплатно, анонимно) или расскажите взрослому, которому доверяете: родителям, учителю, школьному психологу.
Если есть угроза жизни прямо сейчас — звоните 112. Если есть угроза жизни прямо сейчас — звоните 112.
idle: idle:
@@ -79,9 +79,9 @@ revoked:
crisis: crisis:
# Повторный острый сигнал в уже открытом кризисе: скрипт второй раз не шлём, только это + ресурсы. # Повторный острый сигнал в уже открытом кризисе: скрипт второй раз не шлём, только это + ресурсы.
repeat: "Я рядом. Ваш при угрозе жизни звоните 112. Если вам угрожает опасность прямо сейчас — позвоните:" repeat: "Я рядом. Если вам угрожает опасность прямо сейчас — позвоните:"
# Любое другое сообщение, пока кризис открыт (до подтверждения врача). # Любое другое сообщение, пока кризис открыт (до подтверждения врача).
hold: "Я на связи и читаю вас. Не ждите ответа врача, если помощь нужна сейчас. Если станет хуже — звоните 112 или на телефон доверия +7 (495) 989-50-50." hold: "Я на связи и читаю вас. Не ждите ответа врача, если помощь нужна сейчас. Если станет хуже — звоните 112 или на телефон доверия {phone:helpline}."
# Врач подтвердил кризисный алерт (doctor_ack): выходим из кризиса. # Врач подтвердил кризисный алерт (doctor_ack): выходим из кризиса.
closed: "Ваш врач получил сообщение и в курсе вашей ситуации. Спасибо, что остаётесь на связи. Если снова станет тяжело — напишите мне в любой момент." closed: "Ваш врач получил сообщение и в курсе вашей ситуации. Спасибо, что остаётесь на связи. Если снова станет тяжело — напишите мне в любой момент."
@@ -101,7 +101,7 @@ safety_plan:
- "Что помогает мне самому: прогулка, душ, дыхание, любимое дело." - "Что помогает мне самому: прогулка, душ, дыхание, любимое дело."
- "Люди и места, которые отвлекают: к кому можно пойти." - "Люди и места, которые отвлекают: к кому можно пойти."
- "Кому позвонить, если тяжело: близкий человек, сослуживец." - "Кому позвонить, если тяжело: близкий человек, сослуживец."
- "Специалисты: ваш врач, телефон доверия +7 (495) 989-50-50, экстренные службы 112." - "Специалисты: ваш врач, телефон доверия {phone:helpline}, экстренные службы 112."
- "Безопасная среда: оружие и лишние лекарства — на хранение близкому человеку." - "Безопасная среда: оружие и лишние лекарства — на хранение близкому человеку."
means_restriction: "Если дома есть оружие или запас лекарств — попросите близкого человека временно хранить их у себя." means_restriction: "Если дома есть оружие или запас лекарств — попросите близкого человека временно хранить их у себя."
@@ -109,10 +109,12 @@ reach_out:
silence: "Давно не было вестей от вас. Как вы? Можно ответить одним словом — мне важно знать, что с вами всё в порядке." silence: "Давно не было вестей от вас. Как вы? Можно ответить одним словом — мне важно знать, что с вами всё в порядке."
missed_doses: "Заметил, что несколько приёмов препарата пропущено. Всё ли в порядке? Если что-то мешает — расскажите, я передам врачу." missed_doses: "Заметил, что несколько приёмов препарата пропущено. Всё ли в порядке? Если что-то мешает — расскажите, я передам врачу."
default: "Хотел узнать, как вы. Напишите пару слов, когда будет удобно." default: "Хотел узнать, как вы. Напишите пару слов, когда будет удобно."
# The day after a crisis (app/services/followup.py); the doctor on duty also calls back.
after_crisis: "Как вы сегодня? Вчера было очень тяжело — мне важно знать, что вы в порядке. Ваш план безопасности под кнопкой ниже. С вами также свяжется дежурный специалист. Если станет хуже — звоните 112 или на телефон доверия {phone:helpline}."
visit: visit:
button: "Записаться к врачу раньше" button: "Записаться к врачу раньше"
unavailable: |- unavailable: |-
Сейчас у меня технические неполадки, и я не могу сохранить ваше сообщение. Пожалуйста, напишите чуть позже. Сейчас у меня технические неполадки, и я не могу сохранить ваше сообщение. Пожалуйста, напишите чуть позже.
Если вам плохо прямо сейчас — звоните 112 или на телефон доверия +7 (495) 989-50-50. Если вам плохо прямо сейчас — звоните 112 или на телефон доверия {phone:helpline}.
+2 -2
View File
@@ -32,13 +32,13 @@ findings:
text: "Рекомендуем консультацию нарколога — параллельно с основным маршрутом." text: "Рекомендуем консультацию нарколога — параллельно с основным маршрутом."
suicide_low: suicide_low:
title: "Тяжёлые мысли" title: "Тяжёлые мысли"
text: "Вы отметили мысли о смерти. Мы добавили план безопасности и будем каждый день коротко спрашивать о самочувствии. Если станет тяжело — звоните 112 или на телефон доверия." text: "Вы отметили мысли о смерти. Мы добавили план безопасности и будем каждый день коротко спрашивать о самочувствии. Если станет тяжело — звоните 112 или на телефон доверия {phone:helpline}."
tbi: tbi:
title: "Контузия или ЧМТ" title: "Контузия или ЧМТ"
text: "Рекомендуем консультацию невролога." text: "Рекомендуем консультацию невролога."
routes: routes:
urgent_psychiatrist: "Срочно свяжитесь с психиатром — сегодня или завтра." urgent_psychiatrist: "Срочно свяжитесь с психиатром — ориентир: в течение 24 часов."
priority_psychiatrist: "Рекомендуем приоритетную запись к психиатру в ближайшие дни." priority_psychiatrist: "Рекомендуем приоритетную запись к психиатру в ближайшие дни."
psychiatrist_or_psychotherapist: "Рекомендуем плановую запись к психиатру или психотерапевту." psychiatrist_or_psychotherapist: "Рекомендуем плановую запись к психиатру или психотерапевту."
psychologist: "Рекомендуем консультацию психолога." psychologist: "Рекомендуем консультацию психолога."
+11 -8
View File
@@ -42,7 +42,10 @@ def dose_strip_html(grid: h.DoseGrid, start: date | None) -> str:
f'<span><i style="border:1.5px dashed var(--grey)"></i>ожидается · {c["pending"]}</span>' f'<span><i style="border:1.5px dashed var(--grey)"></i>ожидается · {c["pending"]}</span>'
"</div>" "</div>"
) )
return f'<div class="mt-doses" style="grid-template-columns:{cols}">{"".join(cells)}</div>{legend}' return (
f'<div class="mt-dose-scroll"><div class="mt-doses" style="grid-template-columns:{cols}">'
f"{''.join(cells)}</div></div>{legend}"
)
def _tile(label: str, value: str, hint: str = "", tone: str = "blue") -> str: def _tile(label: str, value: str, hint: str = "", tone: str = "blue") -> str:
@@ -181,7 +184,7 @@ def render(card: PatientCard, today: date | None) -> None:
main, side = st.columns([1.75, 1], gap="large") main, side = st.columns([1.75, 1], gap="large")
with main: with main:
with st.container(border=True): with st.container(border=True, key="panel-scales"):
c1, c2 = st.columns(2) c1, c2 = st.columns(2)
for col, scale_id, title in ((c1, "phq9", "PHQ-9"), (c2, "pcl5", "PCL-5")): for col, scale_id, title in ((c1, "phq9", "PHQ-9"), (c2, "pcl5", "PCL-5")):
fig = charts.scale_figure(card.series, scale_id, title, start) fig = charts.scale_figure(card.series, scale_id, title, start)
@@ -191,22 +194,22 @@ def render(card: PatientCard, today: date | None) -> None:
else: else:
st.plotly_chart(fig, config=theme.PLOTLY_CONFIG, key=f"chart-{scale_id}") st.plotly_chart(fig, config=theme.PLOTLY_CONFIG, key=f"chart-{scale_id}")
st.caption("◇ — внеплановое заполнение (по триггеру правил). Полосы — градации тяжести из config/scales.") st.caption("◇ — внеплановое заполнение (по триггеру правил). Полосы — градации тяжести из config/scales.")
with st.container(border=True): with st.container(border=True, key="panel-wellbeing"):
fig = charts.wellbeing_figure(card.series, start) fig = charts.wellbeing_figure(card.series, start)
if fig is None: if fig is None:
st.caption("Ежедневных чек-инов пока нет") st.caption("Ежедневных чек-инов пока нет")
else: else:
st.plotly_chart(fig, config=theme.PLOTLY_CONFIG, key="chart-wellbeing") st.plotly_chart(fig, config=theme.PLOTLY_CONFIG, key="chart-wellbeing")
st.caption("Красная зона настроения ≤ 3 (3 дня → внеплановый PHQ-9); пунктир — сон 4 ч.") st.caption("Красная зона настроения ≤ 3 (3 дня → внеплановый PHQ-9); пунктир — сон 4 ч.")
with st.container(border=True): with st.container(border=True, key="panel-doses"):
st.html('<div class="mt-panel-title">Календарь приёма</div>') st.html('<div class="mt-panel-title">Календарь приёма</div>')
st.html(dose_strip_html(grid, start)) st.html(dose_strip_html(grid, start))
with side: with side:
with st.container(border=True): with st.container(border=True, key="panel-alerts"):
_alerts_feed(card.alerts, today) _alerts_feed(card.alerts, today)
with st.container(border=True): with st.container(border=True, key="panel-summary"):
_summary(card.summary) _summary(card.summary)
with st.container(border=True): with st.container(border=True, key="panel-safety"):
_safety_plan(card.safety_plan) _safety_plan(card.safety_plan)
with st.container(border=True): with st.container(border=True, key="panel-prescriptions"):
_prescriptions(card) _prescriptions(card)
+1 -1
View File
@@ -51,7 +51,7 @@ def render_playback_panel(persona: dict[str, Any]) -> None:
token = persona["token"] token = persona["token"]
scenario, source = w.scenario_for(persona) scenario, source = w.scenario_for(persona)
state = w.playback_state(token) state = w.playback_state(token)
st.subheader("🎬 Сценарий демо") st.subheader("Сценарий демо")
_scenario_source_controls(persona, source) _scenario_source_controls(persona, source)
if scenario.errors: if scenario.errors:
with st.expander(f"⚠️ Замечания к сценарию: {len(scenario.errors)}", expanded=not scenario.days): with st.expander(f"⚠️ Замечания к сценарию: {len(scenario.errors)}", expanded=not scenario.days):
+2 -3
View File
@@ -368,10 +368,9 @@ def render_doctor_panel(highlight_token: str | None, *, key: str = "doc") -> Non
] ]
for a in fresh_acute[:1]: for a in fresh_acute[:1]:
st.markdown( st.markdown(
"<div style='background:#c62828;color:#fff;padding:18px 20px;border-radius:12px;" "<div class='mt-banner t-red mt-console-alert'><div>"
"font-size:1.35rem;font-weight:700;margin-bottom:8px'>"
f"🚨 ОСТРЫЙ АЛЕРТ · {_escape(a.get('patient_name', ''))} · {_fmt_ts(a.get('ts'))}<br>" f"🚨 ОСТРЫЙ АЛЕРТ · {_escape(a.get('patient_name', ''))} · {_fmt_ts(a.get('ts'))}<br>"
f"<span style='font-size:1.05rem;font-weight:500'>{_escape(a.get('reason', ''))}</span></div>", f"<div class='mt-sub'>{_escape(a.get('reason', ''))}</div></div></div>",
unsafe_allow_html=True, unsafe_allow_html=True,
) )
c1, c2 = st.columns(2) c1, c2 = st.columns(2)
@@ -68,7 +68,7 @@ def _reset() -> None:
def _result(res: ConnectPatientOut, name: str) -> None: def _result(res: ConnectPatientOut, name: str) -> None:
link = h.absolute_link(res.deep_link, os.environ.get("DASHBOARD_PUBLIC_URL", "http://localhost:8501")) link = h.absolute_link(res.deep_link, os.environ.get("DASHBOARD_PUBLIC_URL", "http://localhost:8501"))
with st.container(border=True): with st.container(border=True, key="panel-connect-result"):
st.html( st.html(
f'<div class="mt-kicker">Пациент подключён · {escape(name)}</div>' f'<div class="mt-kicker">Пациент подключён · {escape(name)}</div>'
'<div class="mt-sub" style="margin:4px 0 10px">Покажите QR пациенту или продиктуйте код — бот спросит ' '<div class="mt-sub" style="margin:4px 0 10px">Покажите QR пациенту или продиктуйте код — бот спросит '
@@ -108,7 +108,7 @@ def main() -> None:
st.button("Заполнить демо-данными (Андрей)", icon=":material/auto_fix_high:", on_click=_fill_demo, args=(today,)) st.button("Заполнить демо-данными (Андрей)", icon=":material/auto_fix_high:", on_click=_fill_demo, args=(today,))
left, right = st.columns(2, gap="large") left, right = st.columns(2, gap="large")
with left, st.container(border=True): with left, st.container(border=True, key="panel-connect-person"):
st.html('<div class="mt-panel-title">Пациент</div>') st.html('<div class="mt-panel-title">Пациент</div>')
st.text_input("Имя и инициал фамилии", key="cp_name", placeholder="Андрей С.", help="Без полного ФИО") st.text_input("Имя и инициал фамилии", key="cp_name", placeholder="Андрей С.", help="Без полного ФИО")
a, b = st.columns(2) a, b = st.columns(2)
@@ -128,7 +128,7 @@ def main() -> None:
key="cp_modules", key="cp_modules",
format_func=lambda m: h.MODULE_LABELS[m], format_func=lambda m: h.MODULE_LABELS[m],
) )
with right, st.container(border=True): with right, st.container(border=True, key="panel-connect-prescription"):
st.html('<div class="mt-panel-title">Назначение</div>') st.html('<div class="mt-panel-title">Назначение</div>')
d1, d2 = st.columns([1.4, 1]) d1, d2 = st.columns([1.4, 1])
with d1: with d1:
+16 -6
View File
@@ -14,21 +14,31 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from console import widgets as w # noqa: E402 from console import widgets as w # noqa: E402
from console.playback_ui import render_playback_panel # noqa: E402 from console.playback_ui import render_playback_panel # noqa: E402
from dashboard import theme # noqa: E402
from dashboard.components import ui # noqa: E402
st.set_page_config(page_title="Демо-консоль", page_icon="🎬", layout="wide") st.set_page_config(page_title="Демо-консоль", page_icon="🎬", layout="wide")
st.html(theme.CSS)
st.html(
ui.page_header(
"Демо-консоль",
"Чат пациента и сценарий сопровождения. Прототип на синтетических данных.",
kicker="Демонстрация",
)
)
persona = w.select_persona() persona = w.select_persona()
w.render_connect_hero() 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}")
st.title("Демо-консоль") with st.container(border=True, key="panel-console-clock"):
st.caption("Прототип на синтетических данных. Время — виртуальные демо-часы (ADR-005).")
w.render_clock_bar(persona, key="console-clock") w.render_clock_bar(persona, key="console-clock")
st.divider()
chat_col, play_col = st.columns([1.05, 1], gap="large") chat_col, play_col = st.columns([1.05, 1], gap="large")
with chat_col: with chat_col, st.container(border=True, key="panel-console-chat"):
st.subheader(f"💬 Чат пациента — {persona.get('display_name') or persona['token']}") st.subheader(f"Чат пациента — {persona.get('display_name') or persona['token']}")
w.render_chat(persona["token"], height=560, key="console-chat") w.render_chat(persona["token"], height=560, key="console-chat")
with play_col: with play_col, st.container(border=True, key="panel-console-scenario"):
render_playback_panel(persona) render_playback_panel(persona)
+10 -10
View File
@@ -15,12 +15,12 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from console import widgets as w # noqa: E402 from console import widgets as w # noqa: E402
from dashboard import theme # noqa: E402
from dashboard.components import ui # noqa: E402
st.set_page_config(page_title="Сплит-экран", page_icon="🖥️", layout="wide", initial_sidebar_state="collapsed") st.set_page_config(page_title="Сплит-экран", page_icon="🖥️", layout="wide", initial_sidebar_state="collapsed")
st.markdown( st.html(theme.CSS)
"<style>.block-container{padding-top:1.2rem;max-width:100%} " st.html(ui.page_header("Сплит-экран", "Пациент и врач: сопровождение в реальном времени.", kicker="Демонстрация"))
"[data-testid='stMarkdownContainer'] p, [data-testid='stMarkdownContainer'] li{font-size:1.08rem}</style>",
unsafe_allow_html=True,
)
persona = w.select_persona() persona = w.select_persona()
w.show_flash() w.show_flash()
@@ -44,15 +44,15 @@ if clicked and nxt is not None:
st.rerun() st.rerun()
left, right = st.columns([1.1, 1], gap="large") left, right = st.columns([1.1, 1], gap="large")
with left: with left, st.container(border=True, key="panel-split-patient"):
url = w.patient_web_url() url = w.patient_web_url()
if url: if url:
st.markdown(f"#### 📱 Пациент — {persona.get('display_name') or token}") st.markdown(f"#### Пациент — {persona.get('display_name') or token}")
st.iframe(url, height=760) st.iframe(url, height=760)
st.caption(f"Веб-приложение пациента: {url}") st.caption(f"Веб-приложение пациента: {url}")
else: else:
st.markdown(f"#### 💬 Пациент — {persona.get('display_name') or token}") st.markdown(f"#### Пациент — {persona.get('display_name') or token}")
w.render_chat(token, height=600, key="split-chat") w.render_chat(token, height=600, key="split-chat")
with right: with right, st.container(border=True, key="panel-split-doctor"):
st.markdown("#### 🩺 Кабинет врача — д-р Смирнова Е. А.") st.markdown("#### Кабинет врача — д-р Смирнова Е. А.")
w.polled_doctor_panel(token, key="split-doc") w.polled_doctor_panel(token, key="split-doc")
+23 -6
View File
@@ -138,8 +138,8 @@ div[class*="st-key-prow-"] {{ border-top:1px solid var(--line); padding:18px 0 4
.mt-empty p {{ font-size:.85rem; margin:8px 0 0; }} .mt-empty p {{ font-size:.85rem; margin:8px 0 0; }}
/* ---------- chips, badges, bars ---------- */ /* ---------- chips, badges, bars ---------- */
.mt-chip {{ display:inline-block; font-size:.72rem; padding: 1px 8px; border-radius: 999px; margin-right: 4px; .mt-chip {{ display:inline-block; font-size:.72rem; padding: 1px 8px; border-radius: 5px; margin-right: 4px;
background: var(--blue-tint); color: var(--blue-ink); font-weight: 500; }} background: #edf3ee; color: #38634b; font-weight: 500; }}
.mt-badge {{ display:inline-flex; align-items:center; gap:6px; font-size:.7rem; font-weight:550; padding: 5px 8px; .mt-badge {{ display:inline-flex; align-items:center; gap:6px; font-size:.7rem; font-weight:550; padding: 5px 8px;
border-radius: 5px; white-space: normal; }} border-radius: 5px; white-space: normal; }}
.mt-badge.t-red {{ background: var(--red-tint); color: var(--red-ink); }} .mt-badge.t-red {{ background: var(--red-tint); color: var(--red-ink); }}
@@ -166,7 +166,7 @@ div[class*="st-key-prow-"] {{ border-top:1px solid var(--line); padding:18px 0 4
.mt-banner b {{ color: inherit; }} .mt-banner b {{ color: inherit; }}
.mt-pulse {{ width: 12px; height: 12px; border-radius: 50%; background: var(--red); margin-top: 5px; flex: none; .mt-pulse {{ width: 12px; height: 12px; border-radius: 50%; background: var(--red); margin-top: 5px; flex: none;
box-shadow: 0 0 0 4px #faeeee; }} box-shadow: 0 0 0 4px #faeeee; }}
.mt-panel-title {{ font-size: .8rem; text-transform: uppercase; letter-spacing: .07em; font-weight: 650; .mt-panel-title {{ font-size: .82rem; text-transform: uppercase; letter-spacing: .03em; font-weight: 550;
color: var(--ink-3); margin: 2px 0 8px; }} color: var(--ink-3); margin: 2px 0 8px; }}
.mt-alert {{ border-left: 4px solid var(--grey); padding: 4px 0 4px 12px; }} .mt-alert {{ border-left: 4px solid var(--grey); padding: 4px 0 4px 12px; }}
.mt-alert.t-red {{ border-left-color: var(--red); }} .mt-alert.t-red {{ border-left-color: var(--red); }}
@@ -175,9 +175,9 @@ div[class*="st-key-prow-"] {{ border-top:1px solid var(--line); padding:18px 0 4
.mt-alert .when {{ color: var(--ink-3); font-size: .78rem; }} .mt-alert .when {{ color: var(--ink-3); font-size: .78rem; }}
.mt-alert .why {{ color: var(--ink); font-weight: 600; font-size: .92rem; margin: 2px 0; }} .mt-alert .why {{ color: var(--ink); font-weight: 600; font-size: .92rem; margin: 2px 0; }}
.mt-alert.acked .why {{ color: var(--ink-3); font-weight: 500; }} .mt-alert.acked .why {{ color: var(--ink-3); font-weight: 500; }}
.mt-summary {{ background: var(--blue-tint); border-radius: 12px; padding: 14px 16px; color: var(--ink); .mt-summary {{ background: #f4f6f2; border:1px solid var(--line); border-radius: 12px; padding: 14px 16px;
line-height: 1.5; font-size: .93rem; }} color: var(--ink); line-height: 1.5; font-size: .93rem; }}
.mt-summary .disc {{ color: var(--blue-ink); font-size: .76rem; margin-top: 8px; }} .mt-summary .disc {{ color: var(--ink-2); font-size: .76rem; margin-top: 8px; }}
.mt-plan ol {{ margin: 0; padding-left: 1.2rem; }} .mt-plan ol {{ margin: 0; padding-left: 1.2rem; }}
.mt-plan li {{ margin: 3px 0; color: var(--ink); font-size: .9rem; }} .mt-plan li {{ margin: 3px 0; color: var(--ink); font-size: .9rem; }}
@@ -195,6 +195,21 @@ div[class*="st-key-prow-"] {{ border-top:1px solid var(--line); padding:18px 0 4
.mt-legend i {{ display:inline-block; width: 12px; height: 12px; border-radius: 3px; margin-right: 5px; .mt-legend i {{ display:inline-block; width: 12px; height: 12px; border-radius: 3px; margin-right: 5px;
vertical-align: -1px; }} vertical-align: -1px; }}
/* ---------- internal pages ---------- */
div[class*="st-key-panel-"] {{ background:var(--surface); border:1px solid var(--line); border-radius:12px;
padding:20px; }}
[data-testid="stExpander"] {{ background:var(--surface); border-color:var(--line); border-radius:12px; }}
[data-testid="stChatMessage"] {{ background:#f4f6f2; border:1px solid var(--line); border-radius:12px; }}
[data-testid="stChatInput"] {{ background:var(--surface); border:1px solid var(--line); border-radius:8px; }}
[data-testid="stNumberInputContainer"], [data-testid="stDateInput"] input {{
background:var(--surface); color:var(--ink); border-color:var(--line); border-radius:8px; }}
[data-testid="stApp"] [data-variant="pills"][data-selected] {{ background:#edf3ee; color:#285e4e;
border-color:#b4c6ba; }}
.mt-console-alert {{ font-size:1.1rem; font-weight:600; }}
.mt-console-alert .mt-sub {{ color:var(--red-ink); font-weight:400; }}
.mt-dose-scroll {{ overflow-x:auto; padding-bottom:4px; }}
.mt-doses {{ min-width:max-content; }}
/* ---------- connect result ---------- */ /* ---------- connect result ---------- */
.mt-code {{ font-size: 2.4rem; font-weight: 700; letter-spacing: .12em; color: var(--ink); .mt-code {{ font-size: 2.4rem; font-weight: 700; letter-spacing: .12em; color: var(--ink);
font-variant-numeric: tabular-nums; }} font-variant-numeric: tabular-nums; }}
@@ -222,6 +237,8 @@ div[class*="st-key-prow-"] {{ border-top:1px solid var(--line); padding:18px 0 4
.mt-mobile-label {{ display:block; color:var(--ink-3); font-size:.68rem; margin-bottom:5px; }} .mt-mobile-label {{ display:block; color:var(--ink-3); font-size:.68rem; margin-bottom:5px; }}
.mt-row-details {{ grid-template-columns:1fr; gap:12px; }} .mt-row-details {{ grid-template-columns:1fr; gap:12px; }}
.mt-list-footer {{ flex-wrap:wrap; }} .mt-list-footer {{ flex-wrap:wrap; }}
.mt-head .mt-side {{ text-align:left; }}
div[class*="st-key-panel-"] {{ padding:16px; }}
.mt-tip:hover::after, .mt-tip:focus-visible::after {{ display:none; }} .mt-tip:hover::after, .mt-tip:focus-visible::after {{ display:none; }}
}} }}
@media (prefers-reduced-motion: reduce) {{ @media (prefers-reduced-motion: reduce) {{
+11
View File
@@ -3,6 +3,17 @@
> Живой журнал: **что** решили и **почему**. Новые записи — сверху. Одна запись = одно решение. > Живой журнал: **что** решили и **почему**. Новые записи — сверху. Одна запись = одно решение.
> При конфликте с [`docs/info/SPEC.md`](info/SPEC.md) действует ADR. > При конфликте с [`docs/info/SPEC.md`](info/SPEC.md) действует ADR.
## 2026-10-04 — ADR-013: Правки по клиническому ревью — красные флаги, одно правило кризиса, звонок после кризиса, одна таблица номеров
**Контекст:** ревью опросников: обязательны блок красных флагов и план безопасности со звонком после кризиса; маршрутизация должна сначала смотреть на риск и флаги, потом на шкалы; в документе два противоречащих правила кризисного экрана («любое Да» против «только высокий риск»); кризисные номера разбросаны по текстам (и чат-агент давал взрослым детский телефон доверия).
**Решение:**
1. Красные флаги (`config/instruments/red_flags.yaml`) идут первыми в этапе 1: голоса/видения, мысли причинить вред другому, доступ к оружию, насилие дома, сильное опьянение или отмена. Любое «Да» останавливает опрос: острый алерт врачу и экран помощи нужного варианта (`harm_others`, `domestic_violence`).
2. Одно правило кризиса во всём приложении: кризисный экран открывается только при **высоком** риске C-SSRS. Умеренный риск — стоп опроса и психиатр, «ориентир 24 ч»; низкий — флаг врачу, план безопасности, опрос продолжается. Пункт 9 PHQ-9 при отрицательном C-SSRS — флаг «расхождение» и план безопасности; без пройденного C-SSRS (плановый PHQ-9, дневник) — сразу C-SSRS, решает его уровень. `config/questionnaire.yaml`: `cssrs_positive_policy: graded` (C01).
3. После любого кризиса дежурному создаётся задача «перезвонить пациенту» (алерт `post_crisis_call`, ориентир 24 ч; «позвонили» — подтверждение алерта), одна на пациента, пока открыта. На следующий день приложение само спрашивает «как вы» с кнопкой плана безопасности (`app/services/followup.py`).
4. Маршрут: риск и флаги → шкалы. PCL-5 ≥50 убран из приоритетной записи (и в правилах дашборда `pcl5_severe` — уровень moderate): высокий PCL-5 ведёт к плановой записи к специалисту.
5. Все телефоны — только в `config/texts/crisis.yaml`; остальные тексты и промпт агента ссылаются через `{phone:<ключ>}`, неизвестный ключ роняет загрузку, тест не пускает «голые» номера в код и тексты.
**Причина:** врач назвал пункты 1–2 обязательными; одно правило кризиса исключает расхождение демо с документом; неверный номер на кризисном экране недопустим.
**Последствия:** формулировки красных флагов — черновик команды, утверждает врач (#8). Старое правило дашборда `phq9_item9` (ADR-006, сценарий без опросника) по-прежнему поднимает кризис по п.9 — свести при следующем ревью правил.
## 2026-10-04 — ADR-012: Два этапа опросов — общий скрининг, предварительный результат пациенту, дневник по результату ## 2026-10-04 — ADR-012: Два этапа опросов — общий скрининг, предварительный результат пациенту, дневник по результату
**Контекст:** PDF «Опросники для MVP» (этап 1) и «Этап 2: наблюдение после диагноза». Пациент должен видеть, что у него всё в порядке или есть признаки депрессии/ПТСР, и дальше вести дневник под свой результат. Клиническая политика (`config/questionnaire.yaml`) закрыта до решений врача C01–C09, поэтому в демо опрос всегда заканчивался остановкой «нужна оценка специалиста». **Контекст:** PDF «Опросники для MVP» (этап 1) и «Этап 2: наблюдение после диагноза». Пациент должен видеть, что у него всё в порядке или есть признаки депрессии/ПТСР, и дальше вести дневник под свой результат. Клиническая политика (`config/questionnaire.yaml`) закрыта до решений врача C01–C09, поэтому в демо опрос всегда заканчивался остановкой «нужна оценка специалиста».
**Решение:** этап 1 общий для всех и идёт в фиксированном порядке документа: PHQ-2 → фильтр травмы/TSQ → RUS-AUDIT-S → C-SSRS → общие вопросы, затем этап 2 только там, где этап 1 положительный. Пункты внутри шкал не переставляются. Стоп-правила риска срабатывают после каждого ответа. После этапа 1 правила (`app/core/interpretation.py`, тексты в `config/texts/results.yaml`) показывают пациенту предварительный результат: «не выявлено», «предположение депрессии» (PHQ-9 ≥10), «склонность к ПТСР» (PCL-5 ≥31), алкоголь, ЧМТ, маршрут. Это всегда «результат скрининга, не диагноз». Результат включает модули дневника: `depression` и `veteran` (ПТСР). Без врача их определяет скрининг, модули врача сохраняются и дополняются. Дневник этапа 2 состоит из двух частей: утро (часы сна, качество сна, порции алкоголя) и вечер (настроение, лекарства + модули: 3 пункта PHQ-9 в день по кругу, короткая PCL-5 из 4 пунктов). C-SSRS «с последнего контакта» задаётся каждый вечер только при суицидальном риске. Флаги считаются по недельным средним против личной нормы за первые 14 дней и выносят полную шкалу раньше срока. Каждая запись хранит интерпретацию, показанную при завершении; журнал отдаёт `/me/{token}/results`. Демо (`INTAKE_DEMO=1` + демо-часы) читает отдельный `config/questionnaire_demo.yaml`, помеченный «не клиническое согласование», а реальная политика не меняется. **Решение:** этап 1 общий для всех и идёт в фиксированном порядке документа: PHQ-2 → фильтр травмы/TSQ → RUS-AUDIT-S → C-SSRS → общие вопросы, затем этап 2 только там, где этап 1 положительный. Пункты внутри шкал не переставляются. Стоп-правила риска срабатывают после каждого ответа. После этапа 1 правила (`app/core/interpretation.py`, тексты в `config/texts/results.yaml`) показывают пациенту предварительный результат: «не выявлено», «предположение депрессии» (PHQ-9 ≥10), «склонность к ПТСР» (PCL-5 ≥31), алкоголь, ЧМТ, маршрут. Это всегда «результат скрининга, не диагноз». Результат включает модули дневника: `depression` и `veteran` (ПТСР). Без врача их определяет скрининг, модули врача сохраняются и дополняются. Дневник этапа 2 состоит из двух частей: утро (часы сна, качество сна, порции алкоголя) и вечер (настроение, лекарства + модули: 3 пункта PHQ-9 в день по кругу, короткая PCL-5 из 4 пунктов). C-SSRS «с последнего контакта» задаётся каждый вечер только при суицидальном риске. Флаги считаются по недельным средним против личной нормы за первые 14 дней и выносят полную шкалу раньше срока. Каждая запись хранит интерпретацию, показанную при завершении; журнал отдаёт `/me/{token}/results`. Демо (`INTAKE_DEMO=1` + демо-часы) читает отдельный `config/questionnaire_demo.yaml`, помеченный «не клиническое согласование», а реальная политика не меняется.
+1 -1
View File
@@ -160,7 +160,7 @@ q6 за всю жизнь всем; при положительном q6 уто
| ID | Решение, которое нужно зафиксировать | Статус | | ID | Решение, которое нужно зафиксировать | Статус |
|---|---|---| |---|---|---|
| C01 | Любой «Да» останавливает обычный опрос или разрешено продолжение после низкого риска? Различить обычный опрос, уточнение риска и кризисную помощь. Новый PDF с.1 против с.12; в старом с.1 против с.11. Скриншот поддерживает остановку. | pending | | C01 | Любой «Да» останавливает обычный опрос или разрешено продолжение после низкого риска? Различить обычный опрос, уточнение риска и кризисную помощь. Новый PDF с.1 против с.12; в старом с.1 против с.11. Скриншот поддерживает остановку. | **решено 2026-10-04 (ADR-013):** кризисный экран только при высоком риске; умеренный — стоп и психиатр (ориентир 24 ч); низкий — флаг, план безопасности, продолжение. `cssrs_positive_policy: graded`. Подпись врача — в #8. |
| C02 | Использовать сумму ≥1 или согласованную границу зоны для расширения RUS-AUDIT-S? Не переносить порог AUDIT-C ≥3/≥4. | pending | | C02 | Использовать сумму ≥1 или согласованную границу зоны для расширения RUS-AUDIT-S? Не переносить порог AUDIT-C ≥3/≥4. | pending |
| C03 | Самозаполнение полного RUS-AUDIT, таблица стандартных порций, порядок дополнения и повторные замеры. | pending | | C03 | Самозаполнение полного RUS-AUDIT, таблица стандартных порций, порядок дополнения и повторные замеры. | pending |
| C04 | Новая спецификация: TSQ ≥6 вместо PC-PTSD-5 ≥3; получить приложение Г1, согласовать применение и PCL-5 ≥31 для популяции. TSQ из приложения Г1 редакции 2023 года уже добавлен; актуальность редакции и применение требуют подтверждения. | pending | | C04 | Новая спецификация: TSQ ≥6 вместо PC-PTSD-5 ≥3; получить приложение Г1, согласовать применение и PCL-5 ≥31 для популяции. TSQ из приложения Г1 редакции 2023 года уже добавлен; актуальность редакции и применение требуют подтверждения. | pending |
+3
View File
@@ -1,5 +1,8 @@
# Проверка контактов: 3 октября 2026 # Проверка контактов: 3 октября 2026
> **Единая таблица номеров приложения — `config/texts/crisis.yaml` (`resources`).** Остальные тексты и промпт
> чат-агента ссылаются на неё через `{phone:<ключ>}` (ADR-013); номер меняется в одном месте.
Проверены официальные страницы, **не телефонное соединение**. Никаких звонков Проверены официальные страницы, **не телефонное соединение**. Никаких звонков
или уведомлений службам/врачу не выполнялось. или уведомлений службам/врачу не выполнялось.
+1 -1
View File
@@ -23,7 +23,7 @@ services:
- --browser.gatherUsageStats=false - --browser.gatherUsageStats=false
environment: environment:
API_BASE_URL: http://app:8000 API_BASE_URL: http://app:8000
DASHBOARD_PUBLIC_URL: ${PUBLIC_URL:-http://localhost}/doctor DASHBOARD_PUBLIC_URL: ${PUBLIC_URL:-https://dev.meditron.tenvex.ru}/doctor
ports: !override ports: !override
- "127.0.0.1:8511:8501" - "127.0.0.1:8511:8501"
restart: unless-stopped restart: unless-stopped
+69 -2
View File
@@ -1,8 +1,9 @@
# meditron.tenvex.ru — PTSRaiAgent prototype (synthetic data only). # PTSRaiAgent prototype (synthetic data only) — shared host with vobot.
# / patient web app (static, web/dist) # / patient web app (static, web/dist)
# /api/ FastAPI (127.0.0.1:8010) # /api/ FastAPI (127.0.0.1:8010)
# /doctor/ Streamlit doctor dashboard + demo console (127.0.0.1:8511, baseUrlPath=doctor) # /doctor/ Streamlit doctor dashboard + demo console (127.0.0.1:8511, baseUrlPath=doctor)
# TLS: after the DNS A-record exists — certbot --nginx -d meditron.tenvex.ru # dev.meditron.tenvex.ru — HTTPS (Let's Encrypt, certbot certonly --nginx, auto-renew).
# meditron.tenvex.ru — HTTPS (same stack for now; will serve the main branch after promotion).
map $http_upgrade $ptsr_connection_upgrade { map $http_upgrade $ptsr_connection_upgrade {
default upgrade; default upgrade;
@@ -12,7 +13,73 @@ map $http_upgrade $ptsr_connection_upgrade {
server { server {
listen 80; listen 80;
listen [::]:80; listen [::]:80;
server_name dev.meditron.tenvex.ru;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name dev.meditron.tenvex.ru;
ssl_certificate /etc/letsencrypt/live/dev.meditron.tenvex.ru/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/dev.meditron.tenvex.ru/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
access_log /var/log/nginx/ptsr-access.log;
client_max_body_size 5m;
root /srv/ptsr/app/web/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://127.0.0.1:8010;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s; # chat agent + scheduler replay on clock advance
}
location ~ ^/(docs|openapi.json|health)$ {
proxy_pass http://127.0.0.1:8010;
proxy_set_header Host $host;
}
location /doctor/ {
proxy_pass http://127.0.0.1:8511;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $ptsr_connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400s;
proxy_buffering off;
}
}
server {
listen 80;
listen [::]:80;
server_name meditron.tenvex.ru; server_name meditron.tenvex.ru;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name meditron.tenvex.ru;
ssl_certificate /etc/letsencrypt/live/meditron.tenvex.ru/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/meditron.tenvex.ru/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
access_log /var/log/nginx/ptsr-access.log; access_log /var/log/nginx/ptsr-access.log;
client_max_body_size 5m; client_max_body_size 5m;
+74 -9
View File
@@ -12,6 +12,7 @@ from app.db import identity_repo, repo, session
from app.db.bootstrap import init_db from app.db.bootstrap import init_db
from app.db.clinical import Dose, Prescription from app.db.clinical import Dose, Prescription
from app.main import app from app.main import app
from app.services.anonymous_care import doctor_id as anonymous_doctor
MEM = "sqlite+aiosqlite:///:memory:" MEM = "sqlite+aiosqlite:///:memory:"
TOKEN = "b" * 32 TOKEN = "b" * 32
@@ -48,17 +49,17 @@ async def test_anonymous_tokens_are_fresh_and_store_no_personal_data(client: Asy
async with session.get_identity_session() as s: async with session.get_identity_session() as s:
assert await identity_repo.get_patient(s, first["token"]) is None assert await identity_repo.get_patient(s, first["token"]) is None
async with session.get_clinical_session() as s: async with session.get_clinical_session() as s:
# Only a doctor-less pseudonymous profile: the dialog API serves known profiles only. # The care team sees a pseudonymous profile without an identity record.
profile = await repo.get_profile(s, first["token"]) profile = await repo.get_profile(s, first["token"])
assert profile.doctor_id == "" and profile.modules == [] assert profile.doctor_id == anonymous_doctor() and profile.modules == []
async def test_anonymous_focus_picks_the_module_without_a_doctor(client: AsyncClient) -> None: async def test_anonymous_focus_does_not_remove_access_to_doctor(client: AsyncClient) -> None:
token = (await client.post("/api/v1/auth/anonymous", json={"focus": "ptsd"})).json()["token"] token = (await client.post("/api/v1/auth/anonymous", json={"focus": "ptsd"})).json()["token"]
async with session.get_clinical_session() as s: async with session.get_clinical_session() as s:
profile = await repo.get_profile(s, token) profile = await repo.get_profile(s, token)
assert profile.modules == ["veteran"] and not profile.doctor_id assert profile.modules == ["veteran"] and profile.doctor_id == anonymous_doctor()
assert (await client.get(f"/api/v1/me/{token}/today")).json()["has_doctor"] is False assert (await client.get(f"/api/v1/me/{token}/today")).json()["has_doctor"] is True
assert (await client.post("/api/v1/auth/anonymous", json={"focus": "flu"})).status_code == 422 assert (await client.post("/api/v1/auth/anonymous", json={"focus": "flu"})).status_code == 422
@@ -122,7 +123,7 @@ async def test_today_plan_and_week_marks(client: AsyncClient) -> None:
async def test_today_for_anonymous_is_empty(client: AsyncClient) -> None: async def test_today_for_anonymous_is_empty(client: AsyncClient) -> None:
token = (await client.post("/api/v1/auth/anonymous")).json()["token"] token = (await client.post("/api/v1/auth/anonymous")).json()["token"]
body = (await client.get(f"/api/v1/me/{token}/today")).json() body = (await client.get(f"/api/v1/me/{token}/today")).json()
assert body["has_doctor"] is False and body["doses"] == [] and body["prescriptions"] == [] assert body["has_doctor"] is True and body["doses"] == [] and body["prescriptions"] == []
async def test_crisis_resources_come_from_config(client: AsyncClient) -> None: async def test_crisis_resources_come_from_config(client: AsyncClient) -> None:
@@ -169,8 +170,8 @@ async def test_demo_flag_opens_intake_in_the_engine(client: AsyncClient, monkeyp
await press() await press()
await press("consent:yes") await press("consent:yes")
buttons = await press("age:adult") buttons = await press("age:adult")
# Stage 1 starts with PHQ-2, in the order of the MVP questionnaire. # Stage 1 starts with the red-flag block, then the order of the MVP questionnaire.
assert any(b.startswith("iq:") and ":phq9_1:" in b for b in buttons) assert any(b.startswith("iq:") and ":rf_psychosis:" in b for b in buttons)
assert "intake:help" in buttons and "intake:pause" in buttons assert "intake:help" in buttons and "intake:pause" in buttons
q = (await client.get(f"/api/v1/me/{persona}/today")).json()["questionnaires"] q = (await client.get(f"/api/v1/me/{persona}/today")).json()["questionnaires"]
assert q["enabled"] and q["intake"] == "active" assert q["enabled"] and q["intake"] == "active"
@@ -214,9 +215,73 @@ async def test_stage_one_starts_without_modules(client: AsyncClient, monkeypatch
assert (await client.get(f"/api/v1/me/{token}/today")).json()["questionnaires"]["enabled"] assert (await client.get(f"/api/v1/me/{token}/today")).json()["questionnaires"]["enabled"]
await press() await press()
await press("consent:yes") await press("consent:yes")
assert any(":phq9_1:" in b for b in await press("age:adult")) assert any(":rf_psychosis:" in b for b in await press("age:adult"))
async def test_patient_app_has_its_own_demo_personas(client: AsyncClient) -> None: async def test_patient_app_has_its_own_demo_personas(client: AsyncClient) -> None:
body = (await client.get("/api/v1/patient/demo-personas")).json() body = (await client.get("/api/v1/patient/demo-personas")).json()
assert [p["token"] for p in body] == [p.token for p in fixtures.DEMO_PERSONAS] assert [p["token"] for p in body] == [p.token for p in fixtures.DEMO_PERSONAS]
async def test_anonymous_is_visible_only_to_assigned_doctor(client: AsyncClient) -> None:
first = (await client.post("/api/v1/auth/anonymous")).json()["token"]
second = (await client.post("/api/v1/auth/anonymous")).json()["token"]
items = (await client.get(f"/api/v1/doctors/{anonymous_doctor()}/patients")).json()
names = {p["token"]: p["display_name"] for p in items}
assert names[first].startswith("Пациент ") and names[first] != names[second]
assert first not in names[first]
own = await client.get(f"/api/v1/patients/{first}", params={"doctor_id": anonymous_doctor()})
assert own.status_code == 200 and own.json()["patient"]["display_name"] == names[first]
denied = await client.get(f"/api/v1/patients/{first}", params={"doctor_id": "another-doctor"})
assert denied.status_code == 403
assert (await client.get("/api/v1/doctors/another-doctor/patients")).json() == []
async def test_old_anonymous_profile_requires_explicit_care_opt_in(client: AsyncClient) -> None:
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, doctor_id="", modules=["veteran"])
# Merely opening Today must not reveal an old private profile to a doctor.
assert not (await client.get(f"/api/v1/me/{TOKEN}/today")).json()["has_doctor"]
assert (await client.get(f"/api/v1/doctors/{anonymous_doctor()}/patients")).json() == []
for _ in range(2):
assert (await client.post(f"/api/v1/me/{TOKEN}/doctor/connect")).json() == {"has_doctor": True}
async with session.get_clinical_session() as s:
p = await repo.get_profile(s, TOKEN)
assert p.doctor_id == anonymous_doctor() and p.modules == ["veteran"]
async with session.get_identity_session() as s:
assert await identity_repo.get_patient(s, TOKEN) is None
async def test_connect_care_does_not_reassign_or_create_patients(client: AsyncClient) -> None:
assert (await client.post(f"/api/v1/me/{TOKEN}/doctor/connect")).status_code == 404
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, doctor_id="own-doctor")
assert (await client.post(f"/api/v1/me/{TOKEN}/doctor/connect")).status_code == 200
async with session.get_clinical_session() as s:
assert (await repo.get_profile(s, TOKEN)).doctor_id == "own-doctor"
async def test_screening_capability_respects_release_gate(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("INTAKE_DEMO", "0")
assert (await client.get("/api/v1/patient/capabilities")).json() == {"primary_screening": False}
monkeypatch.setenv("INTAKE_DEMO", "1")
assert (await client.get("/api/v1/patient/capabilities")).json() == {"primary_screening": True}
async def test_anonymous_contact_request_reaches_doctor(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("INTAKE_DEMO", "0")
token = (await client.post("/api/v1/auth/anonymous")).json()["token"]
for event in [
{"type": "start"},
{"type": "button", "button": "consent:yes"},
{"type": "button", "button": "age:adult"},
{"type": "button", "button": "menu:contact_doctor"},
]:
response = await client.post(f"/api/v1/dialog/{token}/events", json=event)
assert response.status_code == 200
items = (await client.get(f"/api/v1/doctors/{anonymous_doctor()}/patients")).json()
assert items[0]["last_event"] == "Пациент просит врача связаться"
card = (await client.get(f"/api/v1/patients/{token}", params={"doctor_id": anonymous_doctor()})).json()
assert card["patient"]["last_event"] == items[0]["last_event"]
async with session.get_identity_session() as s:
assert await identity_repo.get_patient(s, token) is None
+31
View File
@@ -0,0 +1,31 @@
"""Crisis numbers live in one table (config/texts/crisis.yaml); every other text refers to it by `{phone:<key>}`."""
import re
from pathlib import Path
import pytest
from app.core.safety import CRISIS_PATH, fill_phones
from app.llm.agent.prompts import SYSTEM_PROMPT
ROOT = Path(__file__).resolve().parents[1]
# Any phone number except the universal short emergency numbers 112 / 101–103.
NUMBER = re.compile(r"(\+7|\b8)[\s(-]*\d{3}[\s)-]*\d{3}[\s-]*\d{2}[\s-]*\d{2}")
def test_no_number_outside_the_crisis_table():
texts = [p for p in (ROOT / "config" / "texts").glob("*.yaml") if p != CRISIS_PATH]
code = [*(ROOT / "app").rglob("*.py"), *(ROOT / "web" / "src").rglob("*.ts*")]
offenders = [
str(p.relative_to(ROOT))
for p in [*texts, *code]
if p.name != "privacy.py" # the PII scrubber documents the phone format with an example
and NUMBER.search("\n".join(line for line in p.read_text(encoding="utf-8").splitlines() if "#" not in line))
]
assert offenders == []
def test_references_resolve_and_unknown_keys_fail():
assert "989-50-50" in fill_phones("{phone:helpline}") and "989-50-50" in SYSTEM_PROMPT
with pytest.raises(ValueError):
fill_phones("{phone:no_such_line}")
+3 -1
View File
@@ -482,4 +482,6 @@ def test_dialog_texts_avoid_forbidden_words() -> None:
body = "\n".join(line for line in raw.splitlines() if not line.lstrip().startswith("#")) body = "\n".join(line for line in raw.splitlines() if not line.lstrip().startswith("#"))
for word in ("учёт", "учет", "контрол", "надзор", "диагноз:"): for word in ("учёт", "учет", "контрол", "надзор", "диагноз:"):
assert word not in body assert word not in body
assert "152-фз" in body and "8-800-2000-122" in body assert "152-фз" in body
# Numbers live in one table (config/texts/crisis.yaml); the minors' stop text gets the children's line from it.
assert "8-800-2000-122" not in body and "8-800-2000-122" in load_dialog_texts().age_gate.minor_stop
+68 -11
View File
@@ -13,6 +13,7 @@ from app.core import clock
from app.core.scales import clear_cache from app.core.scales import clear_cache
from app.db import repo, session from app.db import repo, session
from app.db.bootstrap import init_db from app.db.bootstrap import init_db
from app.db.clinical import Alert
from app.db.intake import QuestionnaireNotice, QuestionnaireRecord, QuestionnaireTask from app.db.intake import QuestionnaireNotice, QuestionnaireRecord, QuestionnaireTask
from app.dialog.contracts import Event from app.dialog.contracts import Event
from app.dialog.engine import Engine from app.dialog.engine import Engine
@@ -76,7 +77,8 @@ def answer_button(replies, choices=None):
for b in line: for b in line:
if b.data.startswith("iq:"): if b.data.startswith("iq:"):
item = b.data.split(":")[2] item = b.data.split(":")[2]
index = choices.get(item, 1 if item.startswith("cssrs_") else 0) # «Нет» by default for the safety items (C-SSRS, red flags), the first option otherwise.
index = choices.get(item, 1 if item.startswith(("cssrs_", "rf_")) else 0)
for line2 in reply.buttons: for line2 in reply.buttons:
for other in line2: for other in line2:
if other.data.endswith(":" + str(index)) and other.data.startswith("iq:"): if other.data.endswith(":" + str(index)) and other.data.startswith("iq:"):
@@ -122,17 +124,17 @@ async def test_pause_restart_and_duplicate_callbacks(released):
old = answer_button(replies) old = answer_button(replies)
await asyncio.gather(press(released, old), press(released, old)) await asyncio.gather(press(released, old), press(released, old))
(row,) = await rows() (row,) = await rows()
assert row.answers == {"phq9_1": 0} assert row.answers == {"rf_psychosis": 0}
await press(released, "intake:pause") await press(released, "intake:pause")
restarted = Engine(gateway=LLMGateway(MockTasks())) restarted = Engine(gateway=LLMGateway(MockTasks()))
replies = await press(restarted, "intake:resume") replies = await press(restarted, "intake:resume")
assert "phq9_2" in answer_button(replies) assert "rf_harm_others" in answer_button(replies)
await press(restarted, old) await press(restarted, old)
assert (await rows())[0].answers == {"phq9_1": 0} assert (await rows())[0].answers == {"rf_psychosis": 0}
assert all(len(b.data.encode()) <= 64 for r in replies for line in r.buttons for b in line) assert all(len(b.data.encode()) <= 64 for r in replies for line in r.buttons for b in line)
@pytest.mark.parametrize("signal", ["help", "phq", "cssrs"]) @pytest.mark.parametrize("signal", ["help", "red_flag", "cssrs"])
async def test_immediate_stop_preserves_partial_answers(released, signal): async def test_immediate_stop_preserves_partial_answers(released, signal):
replies = await press(released, "intake:start") replies = await press(released, "intake:start")
if signal == "help": if signal == "help":
@@ -142,13 +144,45 @@ async def test_immediate_stop_preserves_partial_answers(released, signal):
for item in ("cssrs_q1", "cssrs_q2", "cssrs_q3", "cssrs_q4"): for item in ("cssrs_q1", "cssrs_q2", "cssrs_q3", "cssrs_q4"):
replies = await press(released, answer_button(replies, {item: 0})) replies = await press(released, answer_button(replies, {item: 0}))
else: else:
replies = await complete_existing(released, replies, {"trauma": 1, "phq9_1": 2, "phq9_9": 1}) replies = await press(released, answer_button(replies, {"rf_psychosis": 0}))
assert any(r.kind == "crisis" for r in replies) assert any(r.kind == "crisis" for r in replies)
(row,) = await rows() (row,) = await rows()
assert row.status == "interrupted" and row.completed_at is None assert row.status == "interrupted" and row.completed_at is None
notices = await rows(QuestionnaireNotice) notices = await rows(QuestionnaireNotice)
assert len(notices) == 1 and notices[0].status == "test_sink" assert len(notices) == 1 and notices[0].status == "test_sink"
assert "уведомлён" not in replies[0].text assert "уведомлён" not in replies[0].text
# Every crisis leaves a callback task for the doctor on duty (one per patient while it is open).
calls = [a for a in await rows(Alert) if a.code == "post_crisis_call"]
assert len(calls) == 1 and calls[0].params["assignee"] == "on-duty"
async def test_red_flag_violence_uses_its_crisis_variant(released):
replies = await press(released, "intake:start")
for _ in range(3): # psychosis, harm to others, weapons: «Нет»
replies = await press(released, answer_button(replies))
replies = await press(released, answer_button(replies, {"rf_violence": 0}))
crisis = next(r for r in replies if r.kind == "crisis")
assert "насили" in crisis.text.lower()
(row,) = await rows()
assert row.details["stop_reason"] == "red_flag_violence"
async def test_item9_with_a_negative_screener_is_a_flag_not_a_crisis(released):
# One crisis rule (ADR-013): C-SSRS was negative, so PHQ-9 item 9 is a discrepancy for the doctor.
choices = {f"phq9_{i}": 2 for i in range(1, 9)}
choices.update(trauma=1, tbi=1, psychotropics=1, phq9_9=1)
replies = await press(released, "intake:start")
for _ in range(60):
button = answer_button(replies, choices)
if button is None or any(r.kind == "safety_plan" for r in replies):
break
replies = await press(released, button)
assert not any(r.kind == "crisis" for r in replies)
assert any(r.kind == "safety_plan" for r in replies)
codes = {a.code for a in await rows(Alert)}
assert "phq9_cssrs_discrepancy" in codes and "post_crisis_call" not in codes
(row,) = await rows()
assert row.status == "complete" and "phq9_cssrs_discrepancy" in row.details["decision"]["flags"]
async def complete_existing(e, replies, choices): async def complete_existing(e, replies, choices):
@@ -350,16 +384,24 @@ async def test_diary_morning_then_evening_with_the_ptsd_module(released):
assert any("уже заполнен" in r.text for r in replies) assert any("уже заполнен" in r.text for r in replies)
async def test_depression_module_item9_stops_with_crisis(released, monkeypatch): async def test_depression_module_item9_asks_the_screener_at_once(released, monkeypatch):
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1}) await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
async with session.get_clinical_session() as s: async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, modules=["depression"]) await repo.upsert_profile(s, token=TOKEN, modules=["depression"])
monkeypatch.setattr(clock, "now", lambda: NOW + timedelta(days=2)) monkeypatch.setattr(clock, "now", lambda: NOW + timedelta(days=2))
await run_diary(released, await press(released, "menu:checkin")) await run_diary(released, await press(released, "menu:checkin"))
replies = await run_diary(released, await press(released, "menu:checkin"), {"medication": 2, "dphq_9": 1}) replies = await press(released, "menu:checkin")
assert any(r.kind == "crisis" for r in replies) for _ in range(10):
button = answer_button(replies, {"medication": 2, "dphq_9": 1})
replies = await press(released, button)
if ":cssrs_" in (answer_button(replies) or ""):
break
# Item 9 → the C-SSRS right away («Этап 2»): no crisis screen before the screener says the risk is high.
assert not any(r.kind == "crisis" for r in replies) and any("последнего ответа" in r.text for r in replies)
evening = next(r for r in await rows() if r.kind == "diary_evening") evening = next(r for r in await rows() if r.kind == "diary_evening")
assert evening.answers["dphq_9"] == 1 and evening.status == "interrupted" assert evening.answers["dphq_9"] == 1 and evening.details["stop_reason"] == "phq9_item9_cssrs"
replies = await complete_existing(released, replies, {"cssrs_q1": 0, "cssrs_q2": 0, "cssrs_q3": 0, "cssrs_q4": 0})
assert any(r.kind == "crisis" for r in replies)
async def test_unknown_sex_goes_to_review_without_a_zone(released): async def test_unknown_sex_goes_to_review_without_a_zone(released):
@@ -380,7 +422,7 @@ async def test_version_change_blocks_resume_and_preserves_answers(released):
path.write_text(yaml.safe_dump(data, allow_unicode=True)) path.write_text(yaml.safe_dump(data, allow_unicode=True))
await press(released, "intake:resume") await press(released, "intake:resume")
(row,) = await rows() (row,) = await rows()
assert row.status == "review_required" and row.answers == {"phq9_1": 0} assert row.status == "review_required" and row.answers == {"rf_psychosis": 0}
async def test_followup_delta_uses_comparable_complete_baseline(released): async def test_followup_delta_uses_comparable_complete_baseline(released):
@@ -434,6 +476,8 @@ async def test_diary_schedule_uses_moscow_date(released, monkeypatch):
async def test_resumed_answer_keeps_original_period(released, monkeypatch): async def test_resumed_answer_keeps_original_period(released, monkeypatch):
replies = await press(released, "intake:start") replies = await press(released, "intake:start")
for _ in range(5): # the red-flag block
replies = await press(released, answer_button(replies))
await press(released, answer_button(replies)) await press(released, answer_button(replies))
await press(released, "intake:pause") await press(released, "intake:pause")
monkeypatch.setattr(clock, "now", lambda: NOW + timedelta(days=1)) monkeypatch.setattr(clock, "now", lambda: NOW + timedelta(days=1))
@@ -472,3 +516,16 @@ async def test_without_a_doctor_the_screening_defines_the_modules(released):
assert any("не выявлено" in r.text for r in replies) assert any("не выявлено" in r.text for r in replies)
async with session.get_clinical_session() as s: async with session.get_clinical_session() as s:
assert (await repo.get_profile(s, TOKEN)).modules == [] assert (await repo.get_profile(s, TOKEN)).modules == []
async def test_next_day_after_a_crisis_the_app_asks_once(released, monkeypatch):
replies = await press(released, "intake:start")
await press(released, answer_button(replies, {"rf_psychosis": 0})) # red flag → crisis
outbox = MemoryOutbox()
monkeypatch.setattr(integrations, "outbox", outbox)
monkeypatch.setattr(clock, "now", lambda: NOW + timedelta(hours=21))
await jobs.plan()
await jobs.plan()
sent = outbox.drain(TOKEN)
assert len(sent) == 1 and "Как вы сегодня" in sent[0].text
assert any(b.data == "safety_plan" for line in sent[0].buttons for b in line)
+8 -3
View File
@@ -172,12 +172,16 @@ def test_unapproved_policy_never_continues_even_a_negative_screen():
def test_immediate_safety_preempts_unfinished_or_invalid_unrelated_scales(): def test_immediate_safety_preempts_unfinished_or_invalid_unrelated_scales():
for overrides in [ for overrides in [
dict(help_now=True, cssrs={"broken": "data"}), dict(help_now=True, cssrs={"broken": "data"}),
dict(phq9={"phq9_9": 1}), dict(phq9={"phq9_9": 1}, cssrs={}), # item 9 before the screener is complete
dict(cssrs={"q4": 1}, alcohol={"bad": 50}), dict(cssrs={"q4": 1}, alcohol={"bad": 50}),
]: ]:
assert intake(policy=load_policy(), **overrides).status == "crisis" assert intake(policy=load_policy(), **overrides).status == "crisis"
def test_item9_with_a_negative_screener_is_a_discrepancy_flag_not_a_crisis():
# One crisis rule (ADR-013): the crisis screen opens at a high C-SSRS risk only.
r = intake(phq9={"phq9_9": 1}) r = intake(phq9={"phq9_9": 1})
assert "phq9_cssrs_discrepancy" in r.flags assert r.status != "crisis" and {"phq9_item9_positive", "phq9_cssrs_discrepancy"} <= set(r.flags)
def test_physician_must_choose_between_stop_and_graded(): def test_physician_must_choose_between_stop_and_graded():
@@ -208,7 +212,8 @@ def test_phq2_and_trauma_expansion_do_not_turn_missing_into_negative():
(30, "self_help"), (30, "self_help"),
(31, "psychiatrist_or_psychotherapist"), (31, "psychiatrist_or_psychotherapist"),
(49, "psychiatrist_or_psychotherapist"), (49, "psychiatrist_or_psychotherapist"),
(50, "priority_psychiatrist"), (50, "psychiatrist_or_psychotherapist"),
(80, "psychiatrist_or_psychotherapist"),
], ],
) )
def test_pdf_pcl_boundaries_are_distinct_from_legacy_33(total, route): def test_pdf_pcl_boundaries_are_distinct_from_legacy_33(total, route):
+3 -1
View File
@@ -155,7 +155,9 @@ def test_severe_totals(scale: str, total: int, code: str, fires: bool) -> None:
acts = evaluate(s) acts = evaluate(s)
assert (code in codes(acts)) is fires assert (code in codes(acts)) is fires
if fires: if fires:
assert next(a for a in acts if a.code == code).level == AlertLevel.HIGH # PCL-5 alone is a planned specialist review, not a priority (clinical review 2026-10-04).
expected = AlertLevel.MODERATE if scale == "pcl5" else AlertLevel.HIGH
assert next(a for a in acts if a.code == code).level == expected
@pytest.mark.parametrize( @pytest.mark.parametrize(
+2 -1
View File
@@ -178,6 +178,7 @@ async def test_andrey_13_days(db: MemoryNotifier) -> None:
"sleep_lt4_3n", "sleep_lt4_3n",
"alcohol_3d", "alcohol_3d",
"phq9_item9", # same evaluation: YAML order "phq9_item9", # same evaluation: YAML order
"post_crisis_call", # the crisis leaves a callback task for the doctor on duty
"phq9_rise", "phq9_rise",
] ]
assert len(crisis_audit) == 1 and crisis_audit[0].token == TOKEN and crisis_audit[0].actor == "system" assert len(crisis_audit) == 1 and crisis_audit[0].token == TOKEN and crisis_audit[0].actor == "system"
@@ -260,7 +261,7 @@ async def test_notifier_failure_keeps_alert(db: MemoryNotifier, monkeypatch: pyt
await scale("pcl5", 55) await scale("pcl5", 55)
await monitoring.RuleMonitoring().after_data_change(TOKEN) await monitoring.RuleMonitoring().after_data_change(TOKEN)
async with session.get_clinical_session() as s: async with session.get_clinical_session() as s:
assert [(a.code, a.level) for a in await repo.list_alerts(s, token=TOKEN)] == [("pcl5_severe", "high")] assert [(a.code, a.level) for a in await repo.list_alerts(s, token=TOKEN)] == [("pcl5_severe", "moderate")]
assert await color() == "yellow" assert await color() == "yellow"
+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#0a4da2"/><path d="M13 7h6v6h6v6h-6v6h-6v-6H7v-6h6z" fill="#fff"/></svg> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="9" fill="#285e4e"/><path d="M16 8v16M8 16h16" stroke="#fff" stroke-width="2" stroke-linecap="round"/></svg>

Before

Width:  |  Height:  |  Size: 175 B

After

Width:  |  Height:  |  Size: 201 B

+3 -1
View File
@@ -43,7 +43,9 @@ const post = <T>(path: string, body?: unknown) =>
export const api = { 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 }),
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"), demoPersonas: () => request<DemoPersona[]>("/patient/demo-personas"),
crisisResources: () => request<CrisisResource[]>("/crisis/resources"), crisisResources: () => request<CrisisResource[]>("/crisis/resources"),
catalog: () => request<Catalog>("/instruments/catalog"), catalog: () => request<Catalog>("/instruments/catalog"),
+27 -2
View File
@@ -3,13 +3,14 @@ import { Link, NavLink } from "react-router-dom";
import { ru } from "../content/ru"; import { ru } from "../content/ru";
import { useDialog } from "../dialog/DialogContext"; import { useDialog } from "../dialog/DialogContext";
import { useSession } from "../session/SessionContext";
import { HotlineCard, useHelp } from "./HelpSheet"; import { HotlineCard, useHelp } from "./HelpSheet";
import { Icon, LogoMark, type IconName } from "./Icon"; import { Icon, LogoMark, type IconName } from "./Icon";
/* /*
* Layout from docs/mock (series 2). * Shared patient workspace, following the approved doctor dashboard.
* phone: content → «Экстренная помощь» block → bottom tab bar (signed in) * phone: content → «Экстренная помощь» block → bottom tab bar (signed in)
* desktop: navy sidebar | content | right rail (optional `aside` + emergency panel) * desktop: white sidebar | workspace header | content + emergency panel
* bare: one centred column on every width (questionnaires) * bare: one centred column on every width (questionnaires)
*/ */
@@ -87,6 +88,10 @@ function Sidebar({ signedIn }: { signedIn: boolean }) {
</li> </li>
</ul> </ul>
)} )}
<div className="sidebar__note">
<strong>{ru.product.supportTitle}</strong>
<span>{ru.product.supportText}</span>
</div>
</nav> </nav>
); );
} }
@@ -122,6 +127,24 @@ function Rail({ aside }: { aside?: ReactNode }) {
); );
} }
function WorkspaceHeader({ signedIn, bare = false }: { signedIn: boolean; bare?: boolean }) {
const { session } = useSession();
return (
<header className={`workspace${bare ? " workspace--bare" : ""}`}>
<div className="workspace__brand">
<LogoMark size={30} />
<span>{bare ? ru.product.name : signedIn ? ru.product.workspace : ru.nav.signIn}</span>
</div>
{signedIn && !bare ? (
<Link className="workspace__profile" to="/profile">
<span className="avatar"><Icon name="user" size={18} /></span>
<span>{session?.displayName || ru.nav.profile}</span>
</Link>
) : <span className="workspace__tag">{ru.product.tagline}</span>}
</header>
);
}
export function AppShell({ export function AppShell({
children, children,
signedIn = true, signedIn = true,
@@ -144,6 +167,7 @@ export function AppShell({
return ( return (
<div className="app app--bare"> <div className="app app--bare">
<div className="app__main"> <div className="app__main">
<WorkspaceHeader signedIn={signedIn} bare />
<main className="screen" id="main"> <main className="screen" id="main">
{children} {children}
{sos && <EmergencyBlock />} {sos && <EmergencyBlock />}
@@ -155,6 +179,7 @@ export function AppShell({
<div className={`app${signedIn && !focus ? " app--tabs" : ""}`}> <div className={`app${signedIn && !focus ? " app--tabs" : ""}`}>
<Sidebar signedIn={signedIn} /> <Sidebar signedIn={signedIn} />
<div className="app__main"> <div className="app__main">
<WorkspaceHeader signedIn={signedIn} />
<main className="screen" id="main"> <main className="screen" id="main">
{children} {children}
{sos && <EmergencyBlock />} {sos && <EmergencyBlock />}
+2 -2
View File
@@ -163,8 +163,8 @@ export function Icon({ name, size = 22, label }: { name: IconName; size?: number
export function LogoMark({ size = 32 }: { size?: number }) { export function LogoMark({ size = 32 }: { size?: number }) {
return ( return (
<svg width={size} height={size} viewBox="0 0 32 32" aria-hidden="true" focusable="false"> <svg width={size} height={size} viewBox="0 0 32 32" aria-hidden="true" focusable="false">
<rect width="32" height="32" rx="9" fill="var(--c-teal)" /> <rect width="32" height="32" rx="9" fill="var(--c-primary)" />
<path d="M13 7h6v6h6v6h-6v6h-6v-6H7v-6h6z" fill="#fff" /> <path d="M16 8v16M8 16h16" stroke="#fff" strokeWidth="2" strokeLinecap="round" />
</svg> </svg>
); );
} }
+21 -15
View File
@@ -6,6 +6,9 @@ export const ru = {
product: { product: {
name: "Сопровождение пациента", name: "Сопровождение пациента",
tagline: "между визитами к врачу", tagline: "между визитами к врачу",
workspace: "Личный кабинет",
supportTitle: "Рядом между визитами",
supportText: "Наблюдение за самочувствием и поддержка назначенного лечения.",
}, },
nav: { nav: {
today: "Сегодня", today: "Сегодня",
@@ -49,7 +52,7 @@ export const ru = {
or: "или", or: "или",
anonymous: { anonymous: {
title: "Войти анонимно", title: "Войти анонимно",
text: "Без имени и полиса. Врач не видит ответы, история хранится только на этом устройстве.", text: "Без имени, СНИЛС и полиса. Первичный опрос и связь с врачом — по обезличенному профилю.",
}, },
disclaimer: "Сервис не ставит диагнозы и не назначает лечение — решения принимает врач.", disclaimer: "Сервис не ставит диагнозы и не назначает лечение — решения принимает врач.",
}, },
@@ -68,12 +71,12 @@ export const ru = {
demoDay: (day: number) => `${day}-й день терапии`, demoDay: (day: number) => `${day}-й день терапии`,
}, },
anonymous: { anonymous: {
title: "Что вы хотите проверить?", title: "Помощь без личных данных",
note: "Анонимно: врач не видит ответы, история хранится только на этом устройстве.", note: "Врач видит ваши ответы и результаты под условным номером, без имени, СНИЛС и полиса. Ответы хранятся на сервере, ключ доступа к профилю — на этом устройстве.",
focus: { start: "Начать первичный опрос",
depression: { title: "Депрессия", text: "Подавленность, потеря интереса, усталость" }, continue: "Продолжить без личных данных",
ptsd: { title: "ПТСР", text: "Последствия травмы: флешбэки, кошмары, тревога" }, lead: "Пройдите единый первичный опрос: заранее выбирать предполагаемый диагноз не нужно.",
}, unavailable: "Первичный опрос сейчас отключён на сервере. Кабинет и подключение врача доступны без личных данных.",
submitting: "Создаём профиль…", submitting: "Создаём профиль…",
}, },
today: { today: {
@@ -93,11 +96,11 @@ export const ru = {
visit: "Следующий визит к врачу", visit: "Следующий визит к врачу",
medQuestion: "Вопрос о лекарстве", medQuestion: "Вопрос о лекарстве",
medQuestionHint: "Ответ по официальной инструкции", medQuestionHint: "Ответ по официальной инструкции",
messageDoctor: "Сообщение врачу", messageDoctor: "Связаться с врачом",
messageDoctorHint: "Врач ответит в рабочее время", messageDoctorHint: "Запрос появится в кабинете врача",
week: "Приём на этой неделе", week: "Приём на этой неделе",
weekTaken: (taken: number, total: number) => (total ? `Принято ${taken} из ${total}` : "Отметок пока нет"), weekTaken: (taken: number, total: number) => (total ? `Принято ${taken} из ${total}` : "Отметок пока нет"),
noDoctor: "Врач не подключён. Подключите ЕМИАС в профиле, чтобы назначения и визиты появились здесь.", noDoctor: "Врач пока не подключён. Подключите его без личных данных, чтобы передавать ответы и получать сопровождение.",
newMessage: "Новое сообщение", newMessage: "Новое сообщение",
open: "Открыть", open: "Открыть",
continue: "Продолжить", continue: "Продолжить",
@@ -124,7 +127,7 @@ export const ru = {
tablet: "1 приём", tablet: "1 приём",
week: ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"], week: ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"],
onlyDoctor: "Схему меняет только врач. Не изменяйте дозировку самостоятельно.", onlyDoctor: "Схему меняет только врач. Не изменяйте дозировку самостоятельно.",
empty: "Назначения появятся здесь после входа через ЕМИАС.", empty: "Здесь появятся назначения вашего врача.",
question: "Вопрос о лекарстве", question: "Вопрос о лекарстве",
reminderTitle: "Время принять препарат", reminderTitle: "Время принять препарат",
reminderLead: "По назначению врача", reminderLead: "По назначению врача",
@@ -163,11 +166,13 @@ export const ru = {
visit: "Следующий визит", visit: "Следующий визит",
visitNone: "Дата следующего визита пока не назначена", visitNone: "Дата следующего визита пока не назначена",
attending: "Ваш лечащий врач", attending: "Ваш лечащий врач",
write: "Написать врачу", connect: "Подключить врача без личных данных",
writeNote: "Обычно врач отвечает в рабочее время. Если срочно — 112.", connecting: "Подключаем врача…",
write: "Попросить врача связаться",
writeNote: "Запрос на связь появится в кабинете врача. Если срочно — 112.",
earlier: "Записаться к врачу раньше", earlier: "Записаться к врачу раньше",
plan: "Мой план безопасности", plan: "Мой план безопасности",
noDoctor: "Врач не подключён. Подключите ЕМИАС в профиле, чтобы связаться с лечащим врачом.", noDoctor: "Врач пока не подключён. Можно подключить его без имени, СНИЛС и полиса: он увидит обезличенные ответы и результаты.",
}, },
profile: { profile: {
title: "Профиль", title: "Профиль",
@@ -182,7 +187,7 @@ export const ru = {
revokeConfirm: "Да, отозвать", revokeConfirm: "Да, отозвать",
cancel: "Отмена", cancel: "Отмена",
wipe: "Удалить данные с устройства", wipe: "Удалить данные с устройства",
wipeNote: "Для анонимного режима: история на этом устройстве будет удалена.", wipeNote: "Удалятся ключ доступа и локальная копия переписки. Обезличенные ответы останутся на сервере; без ключа восстановить доступ к этому профилю не получится.",
logout: "Выйти", logout: "Выйти",
messages: "Сообщения", messages: "Сообщения",
result: "Предварительный результат", result: "Предварительный результат",
@@ -201,6 +206,7 @@ export const ru = {
toIntake: "Пройти первичный опрос", toIntake: "Пройти первичный опрос",
}, },
flow: { flow: {
home: "На главный экран",
question: (n: number, total: number) => `Вопрос ${n} из ${total}`, question: (n: number, total: number) => `Вопрос ${n} из ${total}`,
period: "За последний месяц", period: "За последний месяц",
textPlaceholder: "Напишите несколько слов", textPlaceholder: "Напишите несколько слов",
-4
View File
@@ -1,7 +1,3 @@
import "@fontsource/golos-text/400.css";
import "@fontsource/golos-text/500.css";
import "@fontsource/golos-text/600.css";
import "@fontsource/golos-text/700.css";
import "./theme/tokens.css"; import "./theme/tokens.css";
import "./theme/global.css"; import "./theme/global.css";
+23 -30
View File
@@ -1,31 +1,35 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { api } from "../api/client"; import { api } from "../api/client";
import type { Focus } from "../api/types";
import { AppShell, Notice, TopBar } from "../components/AppShell"; import { AppShell, Notice, TopBar } from "../components/AppShell";
import { Icon, type IconName } from "../components/Icon"; import { Icon } from "../components/Icon";
import { ru } from "../content/ru"; import { ru } from "../content/ru";
import { useSession } from "../session/SessionContext"; import { useSession } from "../session/SessionContext";
const FOCUS: { id: Focus; icon: IconName }[] = [ // Anonymous means no identifying data, with the same screening and care path.
{ id: "depression", icon: "smile" },
{ id: "ptsd", icon: "heart" },
];
// A3 · Анонимный вход: человек выбирает, что хочет проверить — от этого зависит стартовый опрос
export function AnonymousSignIn() { export function AnonymousSignIn() {
const t = ru.anonymous; const t = ru.anonymous;
const navigate = useNavigate(); const navigate = useNavigate();
const { signIn } = useSession(); const { signIn } = useSession();
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [screening, setScreening] = useState<boolean | null>(null);
const start = async (focus: Focus) => { useEffect(() => {
let alive = true;
api.patientCapabilities()
.then((v) => { if (alive) setScreening(v.primary_screening); })
.catch(() => { if (alive) setError(true); });
return () => { alive = false; };
}, []);
const start = async () => {
setBusy(true); setBusy(true);
setError(false); setError(false);
try { try {
signIn(await api.signInAnonymous(focus)); // No self-selected diagnosis: the common intake determines the follow-up modules.
signIn(await api.signInAnonymous());
navigate("/flow", { replace: true }); navigate("/flow", { replace: true });
} catch { } catch {
setError(true); setError(true);
@@ -38,27 +42,16 @@ export function AnonymousSignIn() {
<div className="focus"> <div className="focus">
<TopBar onBack={() => navigate("/")} /> <TopBar onBack={() => navigate("/")} />
<div className="stack signin"> <div className="stack signin">
<span className="tile-icon tile-icon--teal"><Icon name="lock" size={26} /></span>
<h1 className="h1">{t.title}</h1> <h1 className="h1">{t.title}</h1>
<p className="lead">{t.lead}</p>
<div className="entry-grid"> <Notice>{t.note}</Notice>
{FOCUS.map(({ id, icon }) => ( {screening === false && <Notice>{t.unavailable}</Notice>}
<button key={id} type="button" className="entry" onClick={() => start(id)} disabled={busy}>
<span className="entry__top">
<span className="tile-icon">
<Icon name={icon} size={26} />
</span>
</span>
<span className="entry__title">
{t.focus[id].title}
<Icon name="chevronRight" />
</span>
<span className="entry__text">{t.focus[id].text}</span>
</button>
))}
</div>
{error && <Notice tone="danger">{ru.common.networkError}</Notice>} {error && <Notice tone="danger">{ru.common.networkError}</Notice>}
<p className="muted small">{busy ? t.submitting : t.note}</p> <button type="button" className="btn btn--primary" disabled={busy} onClick={() => void start()}>
{busy ? t.submitting : screening ? t.start : t.continue}
<Icon name="chevronRight" size={20} />
</button>
</div> </div>
</div> </div>
</AppShell> </AppShell>
+26 -2
View File
@@ -1,3 +1,6 @@
import { useState } from "react";
import { api } from "../api/client";
import { useSession } from "../session/SessionContext";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { AppShell, Notice } from "../components/AppShell"; import { AppShell, Notice } from "../components/AppShell";
@@ -11,7 +14,22 @@ export function Doctor() {
const t = ru.doctor; const t = ru.doctor;
const navigate = useNavigate(); const navigate = useNavigate();
const dialog = useDialog(); const dialog = useDialog();
const { plan } = useToday(); const { plan, failed } = useToday();
const { session } = useSession();
const [connecting, setConnecting] = useState(false);
const [connectFailed, setConnectFailed] = useState(false);
const connect = async () => {
if (!session || connecting) return;
setConnecting(true);
setConnectFailed(false);
try {
await api.connectDoctor(session.token);
navigate("/today");
} catch {
setConnectFailed(true);
setConnecting(false);
}
};
const go = (button: string, label: string) => const go = (button: string, label: string) =>
void dialog.send({ type: "button", button }, label).then((ok) => ok && navigate("/flow")); void dialog.send({ type: "button", button }, label).then((ok) => ok && navigate("/flow"));
@@ -29,8 +47,14 @@ export function Doctor() {
<h1 className="h1">{t.title}</h1> <h1 className="h1">{t.title}</h1>
</header> </header>
{plan && !plan.has_doctor ? ( {(failed || connectFailed) && <Notice tone="danger">{ru.common.networkError}</Notice>}
{!plan ? (!failed && <div className="loader" aria-label="Загрузка" />) : !plan.has_doctor ? (
<div className="stack stack--sm">
<Notice>{t.noDoctor}</Notice> <Notice>{t.noDoctor}</Notice>
<button type="button" className="btn btn--primary" disabled={connecting} onClick={() => void connect()}>
{connecting ? t.connecting : t.connect}
</button>
</div>
) : ( ) : (
<div className="cols"> <div className="cols">
<section className="card-list" aria-label={t.visit}> <section className="card-list" aria-label={t.visit}>
+14 -15
View File
@@ -265,7 +265,7 @@ function FreeText({
{skip.label} {skip.label}
</button> </button>
) : ( ) : (
<button type="button" className="btn btn--ghost" onClick={onExit}> <button type="button" className="btn btn--ghost" disabled={busy} onClick={onExit}>
{ru.flow.exit} {ru.flow.exit}
</button> </button>
)} )}
@@ -362,6 +362,7 @@ function SurveyAct({
press, press,
say, say,
onPause, onPause,
onHome,
}: { }: {
step: Survey; step: Survey;
stepKey: string; stepKey: string;
@@ -369,6 +370,7 @@ function SurveyAct({
press: (b: DialogButton) => void; press: (b: DialogButton) => void;
say: (text: string) => void; say: (text: string) => void;
onPause: (b: DialogButton) => void; onPause: (b: DialogButton) => void;
onHome: () => void;
}) { }) {
let answer: ReactNode; let answer: ReactNode;
switch (step.input) { switch (step.input) {
@@ -381,7 +383,7 @@ function SurveyAct({
break; break;
case "text": case "text":
answer = ( answer = (
<FreeText key={stepKey} skip={step.skip} busy={busy} onSend={say} onSkip={press} onExit={() => undefined} /> <FreeText key={stepKey} skip={step.skip} busy={busy} onSend={say} onSkip={press} onExit={onHome} />
); );
break; break;
default: default:
@@ -522,17 +524,6 @@ export function Flow() {
navigate("/today", { replace: true }); navigate("/today", { replace: true });
}, [step, stepKey, navigate, toast]); }, [step, stepKey, navigate, toast]);
// Questionnaire: answers are saved on the server per question, and there is no going back to change one.
// The browser «back» is absorbed by a duplicate history entry of this same screen.
const testing = step?.kind === "survey" || step?.kind === "choice" || step?.kind === "consent";
useEffect(() => {
if (!testing) return;
window.history.pushState(null, "", window.location.href);
const stay = () => window.history.pushState(null, "", window.location.href);
window.addEventListener("popstate", stay);
return () => window.removeEventListener("popstate", stay);
}, [testing]);
const press = (b: DialogButton) => void dialog.send({ type: "button", button: b.data }, b.label); const press = (b: DialogButton) => void dialog.send({ type: "button", button: b.data }, b.label);
// «Продолжить позже»: answers are already saved on the server — pause and go straight home, no confirmation. // «Продолжить позже»: answers are already saved on the server — pause and go straight home, no confirmation.
const pause = (b: DialogButton) => const pause = (b: DialogButton) =>
@@ -588,7 +579,7 @@ export function Flow() {
) : item ? ( ) : item ? (
<Progress n={item.index} total={item.total} adaptive={item.adaptive} /> <Progress n={item.index} total={item.total} adaptive={item.adaptive} />
) : null; ) : null;
const top = <TopBar onBack={testing ? undefined : home}>{progress}</TopBar>; const top = progress ? <TopBar>{progress}</TopBar> : null;
// Phone: one column. Desktop: question and context on the left, answers and actions on the right. // Phone: one column. Desktop: question and context on the left, answers and actions on the right.
let lead: ReactNode; let lead: ReactNode;
let act: ReactNode; let act: ReactNode;
@@ -702,7 +693,7 @@ export function Flow() {
break; break;
case "survey": case "survey":
lead = <SurveyLead step={step} catalog={catalog} />; lead = <SurveyLead step={step} catalog={catalog} />;
act = <SurveyAct step={step} stepKey={stepKey} busy={dialog.busy} press={press} say={say} onPause={pause} />; act = <SurveyAct step={step} stepKey={stepKey} busy={dialog.busy} press={press} say={say} onPause={pause} onHome={home} />;
break; break;
case "choice": case "choice":
lead = <Head title={step.title} lead={step.lead} />; lead = <Head title={step.title} lead={step.lead} />;
@@ -725,6 +716,14 @@ export function Flow() {
return ( return (
<AppShell focus bare sos={step?.kind !== "crisis"}> <AppShell focus bare sos={step?.kind !== "crisis"}>
<nav className="flow-nav" aria-label={ru.flow.home}>
{/* Leaving does not send an answer or clear pending: Today can reopen this same question.
Wait for an in-flight answer before leaving so its result has reached the shared dialog. */}
<button type="button" className="btn btn--ghost btn--auto" disabled={dialog.busy} onClick={home}>
<Icon name="home" size={20} />
{ru.flow.home}
</button>
</nav>
{content} {content}
</AppShell> </AppShell>
); );
+6 -3
View File
@@ -7,7 +7,6 @@ import { useCatalog } from "../content/reference";
import { ru } from "../content/ru"; import { ru } from "../content/ru";
import { useDialog } from "../dialog/DialogContext"; import { useDialog } from "../dialog/DialogContext";
import { classify } from "../dialog/step"; import { classify } from "../dialog/step";
import { useSession } from "../session/SessionContext";
import { WeekDots } from "./Meds"; import { WeekDots } from "./Meds";
import { humanDate, useToday } from "./useToday"; import { humanDate, useToday } from "./useToday";
@@ -84,7 +83,6 @@ function WeekCard({ rx, onOpen }: { rx: TodayPrescription; onOpen: () => void })
export function Today() { export function Today() {
const t = ru.today; const t = ru.today;
const navigate = useNavigate(); const navigate = useNavigate();
const { session } = useSession();
const dialog = useDialog(); const dialog = useDialog();
const { plan, failed } = useToday(); const { plan, failed } = useToday();
const catalog = useCatalog(); const catalog = useCatalog();
@@ -119,7 +117,7 @@ export function Today() {
!diaryDue && !diaryDue &&
q?.intake !== "review"; q?.intake !== "review";
const nextDose = plan?.prescriptions.flatMap((p) => p.times).sort()[0]; const nextDose = plan?.prescriptions.flatMap((p) => p.times).sort()[0];
const noDoctor = session?.mode === "anonymous" || (plan !== null && !plan.has_doctor); const noDoctor = plan !== null && !plan.has_doctor;
const now = plan?.now ?? "00:00"; const now = plan?.now ?? "00:00";
const openDose = (d: TodayDose) => navigate(`/dose/${d.id}`); const openDose = (d: TodayDose) => navigate(`/dose/${d.id}`);
@@ -292,7 +290,12 @@ export function Today() {
{plan?.prescriptions[0] && <WeekCard rx={plan.prescriptions[0]} onOpen={() => navigate("/meds")} />} {plan?.prescriptions[0] && <WeekCard rx={plan.prescriptions[0]} onOpen={() => navigate("/meds")} />}
{noDoctor ? ( {noDoctor ? (
<div className="stack stack--sm">
<Notice>{t.noDoctor}</Notice> <Notice>{t.noDoctor}</Notice>
<button type="button" className="btn btn--primary" onClick={() => navigate("/doctor")}>
{ru.doctor.connect}
</button>
</div>
) : ( ) : (
<div className="quick"> <div className="quick">
<button <button
+387 -106
View File
File diff suppressed because it is too large Load Diff
+33 -33
View File
@@ -1,45 +1,45 @@
/* /*
* Design tokens — single source of colours, type and spacing (docs/mock, series 2). * Shared visual language with dashboard/theme.py and the approved doctor workspace.
* Capacitor wraps this same bundle; a React Native port maps these 1:1 to a theme object. * Capacitor wraps this same bundle; a React Native port maps these 1:1 to a theme object.
*/ */
:root { :root {
/* Ink & surfaces */ /* Ink & surfaces */
--c-navy: #0f2747; /* primary buttons, headings, desktop sidebar */ --c-primary: #285e4e; /* primary buttons and active navigation */
--c-navy-hover: #0a1d37; --c-primary-hover: #204b3e;
--c-navy-soft: #e8eef6; --c-primary-soft: #edf3ee;
--c-text: #13294b; --c-text: #213b38;
--c-text-2: #4d5d73; --c-text-2: #526660;
--c-text-3: #7a889b; --c-text-3: #63736f;
--c-page: #ffffff; --c-page: #f6f7f3;
--c-page-desk: #f4f7fb; /* desktop canvas behind white panels */ --c-page-desk: #f6f7f3; /* desktop canvas behind white panels */
--c-surface: #ffffff; --c-surface: #ffffff;
--c-tile: #f3f7fb; /* soft card fill */ --c-tile: #f4f6f2; /* soft card fill */
--c-border: #e2e9f1; --c-border: #e1e7e3;
--c-border-strong: #c9d4e1; --c-border-strong: #b4c6ba;
/* Selection & positive: teal */ /* Selection & positive */
--c-teal: #17a08f; --c-teal: #527f61;
--c-teal-strong: #0e7e71; --c-teal-strong: #38634b;
--c-teal-soft: #e3f5f2; --c-teal-soft: #eef4ee;
/* Info: blue */ /* Informational surfaces */
--c-blue: #2f7fdb; --c-blue: #38634b;
--c-blue-soft: #eaf3fd; --c-blue-soft: #edf3ee;
/* Help & crisis: red */ /* Help & crisis: red */
--c-red: #e2383b; --c-red: #a83236;
--c-red-strong: #c62b2e; --c-red-strong: #a83236;
--c-red-soft: #fdeded; --c-red-soft: #faeeee;
--c-red-line: #f7d3d3; --c-red-line: #e7cbcb;
/* Warning */ /* Warning */
--c-amber: #e69a12; --c-amber: #805714;
--c-amber-soft: #fff4dd; --c-amber-soft: #fbf3e4;
--c-focus: #2f7fdb; --c-focus: #598d7a;
/* Type: Golos Text (OFL), bundled locally — works offline. */ /* Same offline system stack as the doctor workspace. */
--font: "Golos Text", system-ui, -apple-system, "Segoe UI", sans-serif; --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;
--fs-12: 0.75rem; --fs-12: 0.75rem;
--fs-13: 0.8125rem; --fs-13: 0.8125rem;
--fs-14: 0.875rem; --fs-14: 0.875rem;
@@ -63,14 +63,14 @@
--s-12: 48px; --s-12: 48px;
/* Radii: tiles > buttons > chips */ /* Radii: tiles > buttons > chips */
--r-tile: 16px; --r-tile: 12px;
--r-btn: 12px; --r-btn: 8px;
--r-chip: 999px; --r-chip: 5px;
--tap: 52px; --tap: 52px;
--nav-h: 64px; --nav-h: 64px;
--sidebar-w: 232px; --sidebar-w: 228px;
--rail-w: 300px; --rail-w: 264px;
--safe-top: env(safe-area-inset-top, 0px); --safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px); --safe-bottom: env(safe-area-inset-bottom, 0px);