feat(bot): агент отвечает на любое сообщение + личная копия на любой демо-вход #104
@@ -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)
|
persona = next((p for p in fixtures.DEMO_PERSONAS if p.token == body.persona_token), None)
|
||||||
if persona is None:
|
if persona is None:
|
||||||
raise HTTPException(404, "persona not found")
|
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)
|
await _seed_demo_persona(persona.token)
|
||||||
log.info("web auth: emias demo tok=%s", persona.token[:6])
|
log.info("web auth: emias demo tok=%s", persona.token[:6])
|
||||||
return AuthOut(token=persona.token, mode="emias", display_name=persona.display_name)
|
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)
|
@router.post("/auth/demo/fresh", response_model=AuthOut)
|
||||||
async def auth_demo_fresh() -> 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
|
"""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
|
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."""
|
another viewer's demo. Synthetic data only."""
|
||||||
@@ -177,10 +185,12 @@ async def auth_demo_fresh() -> AuthOut:
|
|||||||
birth_year=now.year - 34,
|
birth_year=now.year - 34,
|
||||||
sex="m",
|
sex="m",
|
||||||
)
|
)
|
||||||
|
if start_in_chat:
|
||||||
await identity_repo.record_consent(s, token=token, version="demo", now=now)
|
await identity_repo.record_consent(s, token=token, version="demo", now=now)
|
||||||
await _seed_demo_persona(token, source=DEMO_SOURCE)
|
await _seed_demo_persona(token, source=DEMO_SOURCE)
|
||||||
async with get_clinical_session() as s:
|
async with get_clinical_session() as s:
|
||||||
await repo.upsert_profile(s, token=token, sex="m", birth_year=now.year - 34)
|
await repo.upsert_profile(s, token=token, sex="m", birth_year=now.year - 34)
|
||||||
|
if start_in_chat:
|
||||||
consent = {"consent": True, "consent_ver": "demo", "consent_at": now.isoformat(), "revoked_at": None}
|
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)
|
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])
|
log.info("web auth: demo fresh tok=%s", token[:14])
|
||||||
|
|||||||
@@ -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.fsm import Ctx, Step, abandonable, on_enter, state, trigger
|
||||||
from app.dialog.scale_session import register_scale_intro
|
from app.dialog.scale_session import register_scale_intro
|
||||||
|
from app.llm import agent
|
||||||
|
|
||||||
PREFIX = "daily"
|
PREFIX = "daily"
|
||||||
STEPS = ("mood", "med", "sleep", "nightmares", "alcohol", "text")
|
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
|
assert ctx.text is not None
|
||||||
facts = await ctx.gateway.extract_checkin(ctx.text)
|
facts = await ctx.gateway.extract_checkin(ctx.text)
|
||||||
if facts is None:
|
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
|
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"))])
|
return Step(replies=[menu_reply(ctx, text or await ctx.gateway.phrase("support"))])
|
||||||
fields: dict[str, Any] = {}
|
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
|
text = (await side_effect_reply(ctx, codes)).text
|
||||||
if fields:
|
if fields:
|
||||||
await save_checkin(ctx, **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:
|
if text is None and facts.med_question and not facts.quit_intent:
|
||||||
text = await answer_question(ctx, await current_prescription(ctx))
|
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).
|
# A question is answered even if it also mentioned alcohol/side effects (those are recorded above).
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ _SYSTEM_PROMPT = """Ты — помощник пациента в сервисе
|
|||||||
- проводить психотерапию; обещать конфиденциальность сверх реальной;
|
- проводить психотерапию; обещать конфиденциальность сверх реальной;
|
||||||
- выдумывать факты, которых нет в результатах инструментов.
|
- выдумывать факты, которых нет в результатах инструментов.
|
||||||
|
|
||||||
|
На приветствия и болтовню («привет», «как дела?») отвечай коротко и тепло и предложи, с чем можешь помочь:
|
||||||
|
вопросы о лечении, лекарствах, побочных эффектах, самочувствии. Про замену препарата или аналоги: объясни,
|
||||||
|
что подбор и замену делает только врач, и при желании назови группу препарата по инструкции.
|
||||||
|
|
||||||
Стиль: по-русски, на «вы», спокойно и по-человечески, 3–6 коротких предложений, без markdown-заголовков.
|
Стиль: по-русски, на «вы», спокойно и по-человечески, 3–6 коротких предложений, без markdown-заголовков.
|
||||||
Если в вопросе есть признаки угрозы жизни — коротко скажи, что важно сразу позвонить 112 или на телефон
|
Если в вопросе есть признаки угрозы жизни — коротко скажи, что важно сразу позвонить 112 или на телефон
|
||||||
доверия {phone:helpline}, и что врач будет уведомлён."""
|
доверия {phone:helpline}, и что врач будет уведомлён."""
|
||||||
|
|||||||
@@ -87,10 +87,14 @@ async def test_emias_rejects_unknown_code(client: AsyncClient) -> None:
|
|||||||
|
|
||||||
async def test_emias_demo_persona(client: AsyncClient) -> None:
|
async def test_emias_demo_persona(client: AsyncClient) -> None:
|
||||||
persona = fixtures.DEMO_PERSONAS[0]
|
persona = fixtures.DEMO_PERSONAS[0]
|
||||||
res = await client.post("/api/v1/auth/emias/demo", json={"persona_token": persona.token})
|
res = (await client.post("/api/v1/auth/emias/demo", json={"persona_token": persona.token})).json()
|
||||||
assert res.json() == {"token": persona.token, "mode": "emias", "display_name": persona.display_name}
|
# 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:
|
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"})
|
missing = await client.post("/api/v1/auth/emias/demo", json={"persona_token": "tok-nobody"})
|
||||||
assert missing.status_code == 404
|
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:
|
async def test_demo_persona_week_mirrors_dashboard_story(client: AsyncClient) -> None:
|
||||||
persona = fixtures.DEMO_PERSONAS[0].token
|
auth = await client.post("/api/v1/auth/emias/demo", json={"persona_token": fixtures.DEMO_PERSONAS[0].token})
|
||||||
await client.post("/api/v1/auth/emias/demo", json={"persona_token": persona})
|
persona = auth.json()["token"]
|
||||||
rx = (await client.get(f"/api/v1/me/{persona}/today")).json()["prescriptions"][0]
|
rx = (await client.get(f"/api/v1/me/{persona}/today")).json()["prescriptions"][0]
|
||||||
assert rx["ref_id"] == "sertraline" and rx["therapy_day"] > 1
|
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:
|
async def test_demo_flag_opens_intake_in_the_engine(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setenv("INTAKE_DEMO", "1")
|
monkeypatch.setenv("INTAKE_DEMO", "1")
|
||||||
persona = fixtures.DEMO_PERSONAS[0].token
|
auth = await client.post("/api/v1/auth/emias/demo", json={"persona_token": fixtures.DEMO_PERSONAS[0].token})
|
||||||
await client.post("/api/v1/auth/emias/demo", json={"persona_token": persona})
|
persona = auth.json()["token"]
|
||||||
|
|
||||||
async def press(button: str | None = None) -> list[str]:
|
async def press(button: str | None = None) -> list[str]:
|
||||||
event = {"type": "button", "button": button} if button else {"type": "start"}
|
event = {"type": "button", "button": button} if button else {"type": "start"}
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -13,7 +13,11 @@ interface SessionApi {
|
|||||||
const Ctx = createContext<SessionApi | null>(null);
|
const Ctx = createContext<SessionApi | null>(null);
|
||||||
|
|
||||||
export function SessionProvider({ children }: { children: ReactNode }) {
|
export function SessionProvider({ children }: { children: ReactNode }) {
|
||||||
const [session, setSession] = useState<Session | null>(() => sessionStore.load());
|
// The old shared demo account ("tok-andrey") is retired: every visitor now gets a private copy.
|
||||||
|
const [session, setSession] = useState<Session | null>(() => {
|
||||||
|
const s = sessionStore.load();
|
||||||
|
return s && s.token === "tok-andrey" ? null : s;
|
||||||
|
});
|
||||||
|
|
||||||
const signIn = useCallback((auth: AuthResult) => {
|
const signIn = useCallback((auth: AuthResult) => {
|
||||||
const next: Session = { token: auth.token, mode: auth.mode, displayName: auth.display_name };
|
const next: Session = { token: auth.token, mode: auth.mode, displayName: auth.display_name };
|
||||||
|
|||||||
Reference in New Issue
Block a user