diff --git a/app/api/patient_web.py b/app/api/patient_web.py index ed3b7b3..daa3231 100644 --- a/app/api/patient_web.py +++ b/app/api/patient_web.py @@ -148,6 +148,10 @@ async def auth_emias_demo(body: EmiasDemoIn) -> AuthOut: persona = next((p for p in fixtures.DEMO_PERSONAS if p.token == body.persona_token), None) if persona is None: raise HTTPException(404, "persona not found") + if persona.token == DEMO_SOURCE: + # Every visitor gets a private copy (never the shared account); this entry starts from consent and the + # initial questionnaires (subject to the screening switch), unlike the one-click chat demo. + return await _fresh_demo_copy(start_in_chat=False) await _seed_demo_persona(persona.token) log.info("web auth: emias demo tok=%s", persona.token[:6]) return AuthOut(token=persona.token, mode="emias", display_name=persona.display_name) @@ -158,6 +162,10 @@ DEMO_SOURCE = "tok-andrey" @router.post("/auth/demo/fresh", response_model=AuthOut) async def auth_demo_fresh() -> AuthOut: + return await _fresh_demo_copy(start_in_chat=True) + + +async def _fresh_demo_copy(*, start_in_chat: bool) -> AuthOut: """Presentation entry: a fresh private copy of veteran Andrey for every sign-in — consent given, prescription and ЕМИАС record in place, dialog resting in the chat. Nobody's crisis or half-done questionnaire leaks into another viewer's demo. Synthetic data only.""" @@ -177,12 +185,14 @@ async def auth_demo_fresh() -> AuthOut: birth_year=now.year - 34, sex="m", ) - await identity_repo.record_consent(s, token=token, version="demo", now=now) + if start_in_chat: + await identity_repo.record_consent(s, token=token, version="demo", now=now) await _seed_demo_persona(token, source=DEMO_SOURCE) async with get_clinical_session() as s: await repo.upsert_profile(s, token=token, sex="m", birth_year=now.year - 34) - consent = {"consent": True, "consent_ver": "demo", "consent_at": now.isoformat(), "revoked_at": None} - await repo.set_dialog_state(s, token=token, state="idle", data={**consent, "adult": True}, now=now) + if start_in_chat: + consent = {"consent": True, "consent_ver": "demo", "consent_at": now.isoformat(), "revoked_at": None} + await repo.set_dialog_state(s, token=token, state="idle", data={**consent, "adult": True}, now=now) log.info("web auth: demo fresh tok=%s", token[:14]) return AuthOut(token=token, mode="emias", display_name=persona.display_name) diff --git a/app/dialog/flows/veteran_daily.py b/app/dialog/flows/veteran_daily.py index 0f36020..86184f9 100644 --- a/app/dialog/flows/veteran_daily.py +++ b/app/dialog/flows/veteran_daily.py @@ -62,6 +62,7 @@ from app.dialog.flows.meds import ( ) from app.dialog.fsm import Ctx, Step, abandonable, on_enter, state, trigger from app.dialog.scale_session import register_scale_intro +from app.llm import agent PREFIX = "daily" STEPS = ("mood", "med", "sleep", "nightmares", "alcohol", "text") @@ -429,6 +430,9 @@ async def _idle_text(ctx: Ctx, event: Event) -> Step: assert ctx.text is not None facts = await ctx.gateway.extract_checkin(ctx.text) if facts is None: + smart = await agent.answer_chat(ctx.token, ctx.text) + if smart is not None: + return Step(replies=[menu_reply(ctx, agent.render(smart))]) text = await knowledge_answer(ctx, explicit=False) if looks_like_question(ctx.text) else None return Step(replies=[menu_reply(ctx, text or await ctx.gateway.phrase("support"))]) fields: dict[str, Any] = {} @@ -446,6 +450,14 @@ async def _idle_text(ctx: Ctx, event: Event) -> Step: text = (await side_effect_reply(ctx, codes)).text if fields: await save_checkin(ctx, **fields) + if not facts.quit_intent: + # Chat first: whatever the patient writes in idle gets the agent's answer; check-in signals above are + # recorded silently for the doctor. Quit intent keeps its rule reaction (explanation + doctor notified). + smart = await agent.answer_chat(ctx.token, ctx.text) + if smart is not None: + return Step( + replies=[menu_reply(ctx, agent.render(smart))], notify_monitoring=bool(fields or facts.side_effects) + ) if text is None and facts.med_question and not facts.quit_intent: text = await answer_question(ctx, await current_prescription(ctx)) # A question is answered even if it also mentioned alcohol/side effects (those are recorded above). diff --git a/app/llm/agent/prompts.py b/app/llm/agent/prompts.py index 05545f6..45722b8 100644 --- a/app/llm/agent/prompts.py +++ b/app/llm/agent/prompts.py @@ -32,6 +32,10 @@ _SYSTEM_PROMPT = """Ты — помощник пациента в сервисе - проводить психотерапию; обещать конфиденциальность сверх реальной; - выдумывать факты, которых нет в результатах инструментов. +На приветствия и болтовню («привет», «как дела?») отвечай коротко и тепло и предложи, с чем можешь помочь: +вопросы о лечении, лекарствах, побочных эффектах, самочувствии. Про замену препарата или аналоги: объясни, +что подбор и замену делает только врач, и при желании назови группу препарата по инструкции. + Стиль: по-русски, на «вы», спокойно и по-человечески, 3–6 коротких предложений, без markdown-заголовков. Если в вопросе есть признаки угрозы жизни — коротко скажи, что важно сразу позвонить 112 или на телефон доверия {phone:helpline}, и что врач будет уведомлён.""" diff --git a/tests/test_api_patient_web.py b/tests/test_api_patient_web.py index 10b941b..0205057 100644 --- a/tests/test_api_patient_web.py +++ b/tests/test_api_patient_web.py @@ -87,10 +87,14 @@ async def test_emias_rejects_unknown_code(client: AsyncClient) -> None: async def test_emias_demo_persona(client: AsyncClient) -> None: persona = fixtures.DEMO_PERSONAS[0] - res = await client.post("/api/v1/auth/emias/demo", json={"persona_token": persona.token}) - assert res.json() == {"token": persona.token, "mode": "emias", "display_name": persona.display_name} + res = (await client.post("/api/v1/auth/emias/demo", json={"persona_token": persona.token})).json() + # Every visitor gets a private copy of the persona, never the shared account. + assert res["token"].startswith(persona.token + "-") and res["mode"] == "emias" + assert res["display_name"] == persona.display_name + again = (await client.post("/api/v1/auth/emias/demo", json={"persona_token": persona.token})).json() + assert again["token"] != res["token"] async with session.get_clinical_session() as s: - assert (await repo.get_profile(s, persona.token)).doctor_id == fixtures.DOCTOR_ID + assert (await repo.get_profile(s, res["token"])).doctor_id == fixtures.DOCTOR_ID missing = await client.post("/api/v1/auth/emias/demo", json={"persona_token": "tok-nobody"}) assert missing.status_code == 404 @@ -141,8 +145,8 @@ async def test_today_links_prescription_to_reference_and_therapy_day(client: Asy async def test_demo_persona_week_mirrors_dashboard_story(client: AsyncClient) -> None: - persona = fixtures.DEMO_PERSONAS[0].token - await client.post("/api/v1/auth/emias/demo", json={"persona_token": persona}) + auth = await client.post("/api/v1/auth/emias/demo", json={"persona_token": fixtures.DEMO_PERSONAS[0].token}) + persona = auth.json()["token"] rx = (await client.get(f"/api/v1/me/{persona}/today")).json()["prescriptions"][0] assert rx["ref_id"] == "sertraline" and rx["therapy_day"] > 1 @@ -159,8 +163,8 @@ async def test_questionnaires_are_off_without_release_or_demo_flag( async def test_demo_flag_opens_intake_in_the_engine(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("INTAKE_DEMO", "1") - persona = fixtures.DEMO_PERSONAS[0].token - await client.post("/api/v1/auth/emias/demo", json={"persona_token": persona}) + auth = await client.post("/api/v1/auth/emias/demo", json={"persona_token": fixtures.DEMO_PERSONAS[0].token}) + persona = auth.json()["token"] async def press(button: str | None = None) -> list[str]: event = {"type": "button", "button": button} if button else {"type": "start"} diff --git a/tests/test_dialog_idle_agent.py b/tests/test_dialog_idle_agent.py new file mode 100644 index 0000000..cc9205a --- /dev/null +++ b/tests/test_dialog_idle_agent.py @@ -0,0 +1,24 @@ +"""In idle every message goes to the chat agent (when it is on); quit intent keeps the rule reaction.""" + +from app.llm import agent +from app.llm.agent import AgentAnswer +from tests.test_dialog_knowledge import TOKEN, engine, joined, say # noqa: F401 + + +async def test_idle_text_goes_to_agent(engine, monkeypatch): # noqa: F811 + seen = [] + + async def fake(token, text, **kw): + seen.append(str(text)) + return AgentAnswer(text="ОТВЕТ АГЕНТА", sources=[], tools=["search_guidelines"], provider="fake") + + monkeypatch.setattr(agent, "answer_chat", fake) + for msg in ("прив", "какие у меня будут ощущения и побочки от лекарств", "а какие аналоги моего лекарства есть"): + replies = await say(engine, msg) + assert "ОТВЕТ АГЕНТА" in joined(replies), msg + assert len(seen) == 3 and all(t == TOKEN for t in [TOKEN]) + + +async def test_idle_falls_back_when_agent_off(engine): # noqa: F811 + replies = await say(engine, "прив") + assert replies and "ОТВЕТ АГЕНТА" not in joined(replies) diff --git a/web/src/session/SessionContext.tsx b/web/src/session/SessionContext.tsx index a447acb..efe566e 100644 --- a/web/src/session/SessionContext.tsx +++ b/web/src/session/SessionContext.tsx @@ -13,7 +13,11 @@ interface SessionApi { const Ctx = createContext(null); export function SessionProvider({ children }: { children: ReactNode }) { - const [session, setSession] = useState(() => sessionStore.load()); + // The old shared demo account ("tok-andrey") is retired: every visitor now gets a private copy. + const [session, setSession] = useState(() => { + const s = sessionStore.load(); + return s && s.token === "tok-andrey" ? null : s; + }); const signIn = useCallback((auth: AuthResult) => { const next: Session = { token: auth.token, mode: auth.mode, displayName: auth.display_name };