diff --git a/apps/api/alembic/versions/0001_initial_auth_schema.py b/apps/api/alembic/versions/0001_initial_auth_schema.py index 2f110c1..37700d7 100644 --- a/apps/api/alembic/versions/0001_initial_auth_schema.py +++ b/apps/api/alembic/versions/0001_initial_auth_schema.py @@ -70,15 +70,12 @@ def upgrade() -> None: ), sa.Column("name_encrypted", sa.LargeBinary(), nullable=False), sa.Column("birth_year", sa.Integer(), nullable=False), - sa.Column( - "is_minor", - sa.Boolean(), - sa.Computed( - "(EXTRACT(YEAR FROM CURRENT_DATE)::int - birth_year) < 14", - persisted=True, - ), - nullable=False, - ), + # BUG-065 fix: plain column, not `GENERATED ALWAYS AS ... STORED` — + # PG16 rejects CURRENT_DATE (volatile) inside a generated expression, + # so a fresh `alembic upgrade head` never completed past this table. + # The application now sets the value explicitly at INSERT time + # (`api/v1/auth.py::_is_minor`, `models/patient_profile.py`). + sa.Column("is_minor", sa.Boolean(), nullable=False), sa.Column("gender", sa.Text()), sa.Column("phone_encrypted", sa.LargeBinary()), sa.Column("region", sa.Text()), diff --git a/apps/api/alembic/versions/0013_f1f3_backend_integration_schema.py b/apps/api/alembic/versions/0013_f1f3_backend_integration_schema.py new file mode 100644 index 0000000..05be9c2 --- /dev/null +++ b/apps/api/alembic/versions/0013_f1f3_backend_integration_schema.py @@ -0,0 +1,77 @@ +"""F1~F3 backend-api integration schema catch-up (PR #79 canonical merge). + +`fix/backend-api-integration`'s model-source changes (Phase 1 ADR-046 #2 +round-trip, BUG-063/064 role+status widening) were authored on a branch that +forked before Master's alembic chain reached `0011`/`0012` and never carried +its own migrations for these columns/constraints — this migration is the +catch-up so a fresh `alembic upgrade head` matches `src/models/session.py` +exactly (closes the same class of source/DB divergence BUG-065 fixed for +`patient_profiles.is_minor`). + +Adds: +- `sessions.session_state` (JSONB, nullable) — ADR-046 #2 WS-reconnect + round-trip carrier for the prior turn's `ChatResponse.session_state`. +- `sessions.clinical_escalation_required` (bool NOT NULL default false) — + ADR-044 4th backstop field, queryable independent of `session_state`. +- `risk_events.status` widened `varchar(16)` -> `varchar(32)` + CHECK + extended with `'pending_reclassify'` (BUG-063, M-1 conservative fallback + when the safety classifier is unavailable). +- `messages` CHECK `ck_messages_role` extended with `'assistant'` (BUG-064 + additive step; storage keeps writing `'ai'`, `services/chat.py::respond`'s + outbound seam-map is the interim `'ai'`->`'assistant'` translation). + +Revision ID: 0013 +Revises: 0012 +Create Date: 2026-07-25 +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "0013" +down_revision: str | None = "0012" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS session_state JSONB") + op.execute( + "ALTER TABLE sessions ADD COLUMN IF NOT EXISTS clinical_escalation_required " + "BOOLEAN NOT NULL DEFAULT false" + ) + + op.execute("ALTER TABLE risk_events ALTER COLUMN status TYPE VARCHAR(32)") + op.execute("ALTER TABLE risk_events DROP CONSTRAINT IF EXISTS ck_risk_events_status") + op.execute( + "ALTER TABLE risk_events ADD CONSTRAINT ck_risk_events_status " + "CHECK (status IN ('detected','acknowledged','resolved','dismissed'," + "'pending_reclassify'))" + ) + + op.execute("ALTER TABLE messages DROP CONSTRAINT IF EXISTS ck_messages_role") + op.execute( + "ALTER TABLE messages ADD CONSTRAINT ck_messages_role " + "CHECK (role IN ('user','ai','system','assistant'))" + ) + + +def downgrade() -> None: + op.execute("ALTER TABLE messages DROP CONSTRAINT IF EXISTS ck_messages_role") + op.execute( + "ALTER TABLE messages ADD CONSTRAINT ck_messages_role " + "CHECK (role IN ('user','ai','system'))" + ) + + op.execute("ALTER TABLE risk_events DROP CONSTRAINT IF EXISTS ck_risk_events_status") + op.execute( + "ALTER TABLE risk_events ADD CONSTRAINT ck_risk_events_status " + "CHECK (status IN ('detected','acknowledged','resolved','dismissed'))" + ) + op.execute("ALTER TABLE risk_events ALTER COLUMN status TYPE VARCHAR(16)") + + op.execute( + "ALTER TABLE sessions DROP COLUMN IF EXISTS clinical_escalation_required" + ) + op.execute("ALTER TABLE sessions DROP COLUMN IF EXISTS session_state") diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml index 2c63884..ef94240 100644 --- a/apps/api/pyproject.toml +++ b/apps/api/pyproject.toml @@ -57,3 +57,10 @@ known-first-party = ["src"] pythonpath = ["."] testpaths = ["tests"] asyncio_mode = "auto" +# pytest-asyncio 1.4.0 defaults to a function-scoped event loop per test; our +# session-scoped `engine` fixture (asyncpg pool) must share one loop with the +# tests that use it, or asyncpg raises "Task got Future attached to a +# different loop" (BUG-057 root cause 2). Session-scope both fixtures and +# tests so there is exactly one loop for the whole suite. +asyncio_default_fixture_loop_scope = "session" +asyncio_default_test_loop_scope = "session" diff --git a/apps/api/scripts/seed_demo.py b/apps/api/scripts/seed_demo.py index 6ec9841..2853c8a 100644 --- a/apps/api/scripts/seed_demo.py +++ b/apps/api/scripts/seed_demo.py @@ -42,6 +42,66 @@ def _message_aad(session_id: uuid.UUID, message_id: uuid.UUID) -> bytes: return f"messages.content:{session_id}:{message_id}".encode() +def _is_minor(birth_year: int, settings: Settings) -> bool: + """BUG-065 fix: `is_minor` is no longer a DB-computed GENERATED column + (see `models/patient_profile.py`) — this demo seed sets it explicitly, + mirroring `api/v1/auth.py::_is_minor` (kept local here rather than + imported to avoid a `scripts/` -> `api/v1/` import across module + boundaries the rest of this file doesn't otherwise take).""" + return (datetime.now(tz=UTC).year - birth_year) < settings.minor_age_cutoff + + +def _narrative_to_report_response(n: dict) -> dict: + """BUG-066 fix: `HandoffReport.content` must now match the realigned + `contracts.handoff.HandoffResponse` shape (`report_markdown`-primary) + instead of the pre-fix invented `chief_complaint`/`present_illness`/... + fields ai-server never actually produced — this demo seed data predates + that fix. Folds the old narrative dict into one Markdown report so the + clinician dashboard demo still renders real-looking content.""" + saa = n.get("sleep_appetite_activity", {}) + lines = [ + f"## 주호소\n{n.get('chief_complaint', '')}", + f"## 현병력\n{n.get('present_illness', '')}", + ] + if n.get("symptoms"): + lines.append("## 주요 증상\n" + ", ".join(n["symptoms"])) + if n.get("onset"): + lines.append(f"## 시작 시점\n{n['onset']}") + if n.get("recent_changes"): + lines.append(f"## 최근 변화\n{n['recent_changes']}") + if n.get("triggers"): + lines.append("## 유발 요인\n" + ", ".join(n["triggers"])) + saa_text = " · ".join( + filter( + None, + [ + f"수면: {saa.get('sleep')}" if saa.get("sleep") else None, + f"식욕: {saa.get('appetite')}" if saa.get("appetite") else None, + f"활동: {saa.get('activity')}" if saa.get("activity") else None, + ], + ) + ) + if saa_text: + lines.append(f"## 수면 / 식욕 / 활동\n{saa_text}") + if n.get("psych_history"): + lines.append(f"## 과거 정신건강 이력\n{n['psych_history']}") + if n.get("medications"): + lines.append(f"## 복용약\n{n['medications']}") + if n.get("clinician_attention"): + lines.append("## 의료진 확인 필요\n" + ", ".join(n["clinician_attention"])) + + return { + "report_markdown": "\n\n".join(lines), + "report_json": None, + "report_pdf_base64": None, + "trend_plot_base64": None, + "evidence_packets": [], + "missing_slots": [], + "risk_level": "none", + "requires_human_review": False, + } + + def _severity_phq9(score: int) -> str: return ( "minimal" if score <= 4 else "mild" if score <= 9 else "moderate" @@ -233,6 +293,7 @@ async def _make_persona(db, settings: Settings, p: dict) -> None: p["name"], aad=_profile_aad(user.id, "name"), settings=settings ), birth_year=p["birth_year"], + is_minor=_is_minor(p["birth_year"], settings), gender=p["gender"], phone_encrypted=encrypt_str( p["phone"], aad=_profile_aad(user.id, "phone"), settings=settings @@ -313,7 +374,7 @@ async def _make_persona(db, settings: Settings, p: dict) -> None: HandoffReport( session_id=sess.id, status="ready", - content=p["narrative"], + content=_narrative_to_report_response(p["narrative"]), generated_at=datetime.now(UTC) - timedelta(hours=1), # v3 §6-B — 시드 리포트는 이미 전달된 상태로 둔다(의료진 대시보드 노출). delivered_at=datetime.now(UTC) - timedelta(minutes=50), diff --git a/apps/api/src/api/v1/auth.py b/apps/api/src/api/v1/auth.py index 765c9be..c1d39f8 100644 --- a/apps/api/src/api/v1/auth.py +++ b/apps/api/src/api/v1/auth.py @@ -222,6 +222,12 @@ async def register( payload.name, aad=_profile_aad(user.id, "name"), settings=settings ), birth_year=payload.birth_year, + # BUG-065 fix: `is_minor` is no longer a DB-computed GENERATED + # column (see `models/patient_profile.py`/`alembic/0001` docstrings) + # — set explicitly here via the SAME `_is_minor()` helper + # `_check_guardian` above already used, so there is exactly one + # source of truth for this policy. + is_minor=_is_minor(payload.birth_year, settings), gender=payload.gender, phone_encrypted=encrypt_str( payload.phone, aad=_profile_aad(user.id, "phone"), settings=settings diff --git a/apps/api/src/api/v1/sessions.py b/apps/api/src/api/v1/sessions.py index 573126b..06cdb7f 100644 --- a/apps/api/src/api/v1/sessions.py +++ b/apps/api/src/api/v1/sessions.py @@ -37,6 +37,7 @@ import anyio from contracts.safety import SafetyRequest +from contracts.survey_plan import SurveyPlanRequest from contracts.temporal import TemporalSummarizeRequest from fastapi import ( APIRouter, @@ -71,6 +72,7 @@ from src.schemas.handoff import SubmitAccepted from src.schemas.questionnaire import QuestionnaireResultOut, QuestionnaireSubmit from src.schemas.session import ( + SessionListItemOut, SessionOut, WSAuthConnect, WSUserMessage, @@ -80,17 +82,18 @@ # ISS-022: shared org-access rule (handoff.py도 동일 임포트) from src.services.clinician import _can_access_patient -from src.services.domain_routing import infer_instrument +from src.services.domain_routing import infer_instrument_with_caveat from src.services.handoff import ( build_report_response, create_pending_report, generate_report_task, ) -from src.services.questionnaire import QuestionnaireError, upsert_result +from src.services.questionnaire import AI_SCALE_NAME, QuestionnaireError, upsert_result from src.services.safety import ( handle_safety_result, handle_unavailable_classifier, latest_consent_snapshot, + to_safety_assessment, ) logger = logging.getLogger(__name__) @@ -131,6 +134,53 @@ async def create_session( } +SESSIONS_LIST_MAX = 100 + + +@router.get("") +async def list_sessions( + patient: Annotated[User, Depends(require_role("patient"))], + db: Annotated[AsyncSession, Depends(get_session)], +) -> dict: + """S09 records (기록 조회) — the patient's own session history. + + Own-data-only by construction (`Session.patient_id == patient.id` in the + query itself, not a post-filter) — never leaks another patient's + sessions. `has_report` is a plain existence check against + `handoff_reports`, independent of report `status` (a `generating`/ + `failed` report still counts as "exists" for this list view; the client + resolves the actual phase via the existing `/report/status` route when + the user opens a record). + """ + rows = await db.execute( + select(Session) + .where(Session.patient_id == patient.id) + .order_by(Session.created_at.desc()) + .limit(SESSIONS_LIST_MAX) + ) + sessions = list(rows.scalars().all()) + if not sessions: + return {"success": True, "data": {"sessions": []}} + + session_ids = [s.id for s in sessions] + report_rows = await db.execute( + select(HandoffReport.session_id).where(HandoffReport.session_id.in_(session_ids)) + ) + session_ids_with_report = {row[0] for row in report_rows.all()} + + items = [ + SessionListItemOut( + session_id=s.id, + status=s.status, + created_at=s.created_at, + progress_ratio=s.progress_ratio, + has_report=s.id in session_ids_with_report, + ).model_dump(by_alias=True, mode="json") + for s in sessions + ] + return {"success": True, "data": {"sessions": items}} + + async def _owned_in_progress_session( db: AsyncSession, *, session_id: UUID, patient_id: UUID ) -> Session: @@ -277,13 +327,70 @@ async def infer_domain( session_id, ) - instrument = await infer_instrument( + routing = await infer_instrument_with_caveat( ai_client=ai_client, session_id=session_id, turns=turns, clinical_slots=sess.clinical_slots or {}, + # BUG-059: previously never threaded, so `DomainInferRequest. + # crisis_triggered` sent to ai-server was always False. Same value + # already forwarded for the survey/plan `crisis_triggered` below + # (`sess.clinical_escalation_required`, the ADR-044 screening + # backstop signal) — ai-server's live domain route does not + # reproject this field or use it as selection evidence, so this is + # additive/inert today, not a behavior change. + crisis_triggered=bool(sess.clinical_escalation_required), ) - return {"success": True, "data": {"instrument": instrument}} + instrument = routing.instrument + + # PLAN-2026-W30-INTEG P3-1(a)/(c) — F2 routing -> F3 plan. Best-effort: a + # plan-call failure never blocks the patient-facing instrument response + # (same "라우팅은 실패하지 않는다" posture as infer_instrument itself). + plan_data: dict[str, Any] | None = None + try: + plan_response = await ai_client.survey_plan( + SurveyPlanRequest( + recommended_questionnaire=AI_SCALE_NAME.get(instrument), + recommendation_caveat=routing.caveat, + crisis_triggered=bool(sess.clinical_escalation_required), + ) + ) + except AIClientError as exc: + logger.warning( + "survey plan call failed, patient response stays instrument-only (session=%s): %s", + session_id, + exc, + ) + else: + if routing.caveat: + # Clinician/audit-facing only (P3-0 consult safety constraint) — + # persisted to audit_logs, never returned in this endpoint's + # patient-facing response body below. + db.add( + AuditLog( + actor_id=patient.id, + actor_role="patient", + action="survey.plan.proxy_caveat", + resource_type="session", + resource_id=session_id, + audit_metadata={"scale": plan_response.scale, "caveat": routing.caveat}, + ) + ) + await db.commit() + logger.info( + "survey plan: proxy caveat recorded for clinician/audit review " + "(session=%s, scale=%s)", + session_id, + plan_response.scale, + ) + plan_data = plan_response.model_dump(mode="json") + # Never forward the caveat itself to the patient survey UI (safety constraint). + plan_data.pop("recommendation_caveat", None) + + data: dict[str, Any] = {"instrument": instrument} + if plan_data is not None: + data["plan"] = plan_data + return {"success": True, "data": data} @router.post("/{session_id}/documents/ocr", status_code=status.HTTP_201_CREATED) @@ -780,12 +887,22 @@ async def session_chat( return user_id, role = auth - # Authorize session — only the owner can open this WS. + # Authorize session — only the owner can open this WS. Also reload the + # PRIOR turn's `session_state` (Phase 1, ADR-046 #2 wiring) so a + # reconnect resumes from the persisted round-trip state rather than + # always starting `None` again (`services/chat.py::respond` persists it + # each turn via `Session.session_state`). async with SessionLocal() as db: owner_row = await db.execute( - select(Session.patient_id).where(Session.id == session_id) + select(Session.patient_id, Session.session_state).where( + Session.id == session_id + ) + ) + owner_row_result = owner_row.one_or_none() + owner = owner_row_result[0] if owner_row_result is not None else None + persisted_session_state: dict[str, Any] | None = ( + owner_row_result[1] if owner_row_result is not None else None ) - owner = owner_row.scalar_one_or_none() if owner is None or owner != user_id: await _send_error_and_close(ws, 1008, "SESSION_NOT_AUTHORIZED") return @@ -812,6 +929,16 @@ async def session_chat( # user:message can fan out to multiple frames (ack + ai:complete). idem_cache: OrderedDict[str, list[dict[str, Any]]] = OrderedDict() + # ADR-046 #2 round-trip channel (contract1, `services.chat.respond`'s own + # `session_state` param docstring): seeded from the DB-persisted prior + # turn's `Session.session_state` (Phase 1 fix — previously always + # started `None` on a reconnect since no DB column existed yet, PRD + # §5.1 Option B note). Every `user:message` frame on this WS threads the + # PRIOR turn's `ChatResponse.session_state` back into the next + # `/ai/chat/respond` call; `services/chat.py::respond` persists the + # latest value back to `Session.session_state` after each turn. + session_state: dict[str, Any] | None = persisted_session_state + try: while True: try: @@ -860,12 +987,13 @@ async def session_chat( await ws.send_json(frame_out) continue - frames = await _handle_message( + frames, session_state = await _handle_message( settings=settings, ai_client=ai_client, user_id=user_id, session_id=session_id, frame=frame, + session_state=session_state, ) idem_cache[frame.payload.idempotency_key] = frames @@ -903,7 +1031,13 @@ async def _handle_message( user_id: UUID, session_id: UUID, frame: WSUserMessage, -) -> list[dict[str, Any]]: + session_state: dict[str, Any] | None = None, +) -> tuple[list[dict[str, Any]], dict[str, Any] | None]: + """Returns (frames, next_session_state) — `next_session_state` is the + ADR-046 #2 round-trip value the caller must thread into the NEXT call's + `session_state` param (unchanged from the input when this turn never + reached the AI dialogue call, e.g. a risk-interrupt or safety-classifier + failure short-circuit below).""" started = time.perf_counter() async with SessionLocal() as db: @@ -922,9 +1056,19 @@ async def _handle_message( safety_unavailable = False safety = None try: - safety = await ai_client.safety_classify( - SafetyRequest(message=frame.payload.content) + # BUG-062 fix: `SafetyRequest` now mirrors ai-server's real + # `SafetyInput` (session_id/user_message/conversation_history) — the + # pre-gate call has no multi-turn context of its own (single-message + # classification), so `conversation_history` stays empty, matching + # this call site's pre-fix behavior (the old `prev_context` field was + # never populated either). Translate the wire response into the + # platform's internal `SafetyAssessment` via the single-source + # `to_safety_assessment` (relocated from `ai_client.py`'s interim + # adapter into `services/safety.py`'s domain layer). + wire_safety = await ai_client.safety_classify( + SafetyRequest(session_id=str(session_id), user_message=frame.payload.content) ) + safety = to_safety_assessment(wire_safety) except AIClientError as exc: logger.warning("safety.unavailable", extra={"error": str(exc)}) safety_unavailable = True @@ -961,7 +1105,7 @@ async def _handle_message( # HIGH/CRITICAL — the dialogue is interrupted (PRD §5.1): risk only, no AI reply. if payload is not None: - return [{"type": "risk:detected", "payload": payload}] + return [{"type": "risk:detected", "payload": payload}], session_state safety_level_str = safety.level.value if safety is not None else "unknown" frames: list[dict[str, Any]] = [ @@ -980,13 +1124,36 @@ async def _handle_message( # swallowed inside chat_turn so the chat keeps flowing. async with SessionLocal() as db: ai_payload = await chat_turn( - db, ai_client=ai_client, session_id=session_id, settings=settings + db, + ai_client=ai_client, + session_id=session_id, + settings=settings, + session_state=session_state, ) await db.commit() + next_session_state = session_state if ai_payload is not None: + # CVR-051 fix: ai-server's own orchestrator-internal safety gate + # (conversation-history-aware, distinct from the pre-gate call + # above) fired a crisis on this turn — `chat_turn` (`services/ + # chat.py::respond`) already ran the SAME `handle_safety_result` + # escalation the pre-gate path uses and threaded the resulting + # payload through as `riskDetected`. Emit it as its own + # `risk:detected` frame (identical shape/semantics to the + # pre-gate emission above) BEFORE `ai:complete`, then strip the + # key so the wire-contract-typed `ai:complete` payload is + # unchanged from before this fix. + risk_detected = ai_payload.pop("riskDetected", None) + if risk_detected is not None: + frames.append({"type": "risk:detected", "payload": risk_detected}) frames.append({"type": "ai:complete", "payload": ai_payload}) + # ADR-046 #2 round-trip: only advance the carried state when this + # turn actually reached the AI dialogue call and got one back — + # a failed/unavailable turn (ai_payload is None) keeps the last + # good state instead of clobbering it with None. + next_session_state = ai_payload.get("sessionState", session_state) - return frames + return frames, next_session_state __all__ = ["router"] diff --git a/apps/api/src/core/config.py b/apps/api/src/core/config.py index 3eec6be..c424d11 100644 --- a/apps/api/src/core/config.py +++ b/apps/api/src/core/config.py @@ -83,9 +83,43 @@ class Settings(BaseSettings): # AI server (apps/ai-server) location ai_server_url: str = Field(default="http://localhost:8001") # Handoff generation budget — PRD §4.1 p95 < 30s, allow margin. - ai_handoff_timeout_seconds: float = Field(default=45.0) - # Chat reply budget — first token < 800ms; full non-streaming reply margin. - ai_chat_timeout_seconds: float = Field(default=10.0) + # BUG-068 (2026-07-23, live): the evidence-verifier's own worst-case + # 3-attempt regenerate loop (ai-server `routes/handoff.py`, + # `_MAX_REGENERATE_ATTEMPTS = 2`) was observed at 48.1s live, exceeding + # the prior 45.0s client budget so apps/api gave up right as ai-server + # was about to return a (degraded but content-bearing) 200. This call is + # driven by `generate_report_task`, an async background task polled via + # `/report/status` — no interactive user is blocked waiting on this + # client call — so a generous margin over the observed worst case costs + # nothing but a slightly later status flip. 90s ~= 1.9x the 48.1s + # observed worst case. + ai_handoff_timeout_seconds: float = Field(default=90.0) + # Chat reply budget — first token < 800ms for an ORDINARY turn; the + # budget itself must cover the rare turn where it does not stay + # ordinary. BUG-081 (2026-07-25, live): when a turn crosses the + # slot-coverage/risk-grounded threshold, ai-server's orchestrator runs + # its ENTIRE post-dialogue pipeline (slot_extraction + handoff_ + # generation + the evidence-verifier's up-to-3-attempt regenerate loop + # — the SAME chain BUG-068 measured at 48.1s worst case for the + # regenerate loop alone) synchronously, inside this ONE + # `/ai/chat/respond` call — confirmed by source read + # (`OrchestratorAgent._execute_pipeline` awaits + # `_run_post_dialogue_pipeline` directly; there is no background-task + # split on the ai-server side for this pipeline, unlike apps/api's own + # `services/chat.py::_extract_slots_bg`, which IS backgrounded). A live + # instance ran ~30s and was still in progress when the prior 10.0s + # budget gave up; the turn (including a genuine, already-computed SI + # grounding) was silently dropped. 90.0s mirrors `ai_handoff_timeout_ + # seconds`'s margin (~1.9x the 48.1s BUG-068 worst case) since this is + # structurally the same chain plus one extra LLM call (slot + # extraction) — this only affects the tail latency of the rare + # threshold-crossing turn; ordinary turns return long before this + # budget is ever approached. Splitting the post-dialogue pipeline out + # of the synchronous request/response cycle (background task + a + # distinct WS frame) remains the preferred longer-term fix (see + # BUG-081's fix direction) — out of scope for this timeout-alignment + # pass. + ai_chat_timeout_seconds: float = Field(default=90.0) # STT budget — PRD §4.1 SLA < 2,000ms, allow margin for the vendor chain. ai_stt_timeout_seconds: float = Field(default=8.0) # v3 FR-039 — 도메인 추정. 모바일 '분석 중' 상한이 5초(NFR v3-3)라 그 안에서 @@ -109,6 +143,18 @@ class Settings(BaseSettings): # FR-048/028 — 대화 중 첨부(처방전) 업로드 상한. Upstage 50MB보다 보수적. ocr_max_bytes: int = Field(default=20 * 1024 * 1024) # 20MB (FR-028) + # Safety classify budget — BUG-080 (2026-07-25, live): `safety_classify` + # previously used the bare `httpx.AsyncClient(timeout=2.0)` constructor + # default (no dedicated setting, unlike every other AIClient method). + # ai-server's `/ai/safety/classify` unconditionally makes a real Upstage + # LLM call (`_llm_classify`, unless the rule-level is already >= high); + # observed live latency was 952-4373ms across 2 sessions/9 turns. A 2.0s + # budget caused 6/9 turns (67%) to trip the client-side timeout and + # fail-open into `classifier_unavailable` full-block on ordinary, + # non-crisis turns. 6.0s gives >1.4x margin over the observed 4.373s + # worst case. + ai_safety_timeout_seconds: float = Field(default=6.0) + # STT audio (FR-033/036). S3 SSE-KMS is Phase 2; demo writes to local disk. audio_storage_dir: str = Field(default=".audio_store") audio_max_bytes: int = Field(default=2 * 1024 * 1024) # 2MB (PRD §5.1) diff --git a/apps/api/src/models/patient_profile.py b/apps/api/src/models/patient_profile.py index 7e56a11..35aa4e8 100644 --- a/apps/api/src/models/patient_profile.py +++ b/apps/api/src/models/patient_profile.py @@ -6,7 +6,7 @@ from datetime import datetime from sqlalchemy import ( - Computed, + Boolean, DateTime, ForeignKey, Integer, @@ -23,8 +23,16 @@ class PatientProfile(Base): """PRD §5.2 patient_profiles. name/phone/emergency_contact are AES-256 encrypted blobs (BYTEA). - is_minor uses Postgres GENERATED ALWAYS AS STORED — FR-027 enforcement - in app layer also (since GENERATED requires postgres). + + BUG-065 fix: `is_minor` was a Postgres `GENERATED ALWAYS AS STORED` + column using the non-immutable `CURRENT_DATE` — PG16 rejects this + (fresh-DB `alembic upgrade head` could never complete), see + `alembic/versions/0001_initial_auth_schema.py`'s docstring for the full + rationale. Now a plain column: the app sets it explicitly at INSERT time + (`api/v1/auth.py::register`, reusing `_is_minor()` — the SAME helper + already used independently for the FR-027 guardian-consent gate, now the + single source of truth instead of an app function plus a DB-computed + shadow of it). """ __tablename__ = "patient_profiles" @@ -36,13 +44,7 @@ class PatientProfile(Base): ) name_encrypted: Mapped[bytes] = mapped_column(LargeBinary, nullable=False) birth_year: Mapped[int] = mapped_column(Integer, nullable=False) - is_minor: Mapped[bool] = mapped_column( - Computed( - "(EXTRACT(YEAR FROM CURRENT_DATE)::int - birth_year) < 14", - persisted=True, - ), - nullable=False, - ) + is_minor: Mapped[bool] = mapped_column(Boolean, nullable=False) gender: Mapped[str | None] = mapped_column(Text) phone_encrypted: Mapped[bytes | None] = mapped_column(LargeBinary) region: Mapped[str | None] = mapped_column(Text) diff --git a/apps/api/src/models/session.py b/apps/api/src/models/session.py index 060199d..cc84cbd 100644 --- a/apps/api/src/models/session.py +++ b/apps/api/src/models/session.py @@ -12,6 +12,7 @@ from sqlalchemy import ( ARRAY, + Boolean, CheckConstraint, DateTime, Float, @@ -64,6 +65,23 @@ class Session(Base): # 주의: rag.session_insights.slots는 VP 시뮬레이션 코퍼스 테이블이므로 # 실환자 슬롯은 반드시 이 플랫폼 컬럼에만 쓴다 (코퍼스 오염 방지). clinical_slots: Mapped[dict[str, Any] | None] = mapped_column(JSONB) + # ADR-046 #2 round-trip channel (PRD Phase 1, contract1 wiring): verbatim + # persistence of the PRIOR turn's `ChatResponse.session_state` (opaque, + # ai-server-owned `schemas/orchestrator.py::SessionState.model_dump()` + # shape) — carries the ADR-044 backstop fields (`asked_slot_counts`/ + # `risk_screening_incomplete`/`handoff_delivered`) nested by their own + # key names. Lets the WS gateway reload state across reconnects instead + # of only connection-scoped in-memory (`services/chat.py::respond`). + session_state: Mapped[dict[str, Any] | None] = mapped_column(JSONB) + # ADR-044 4th field — also a distinctly-named top-level field on + # `ChatResponse`/`DialogueOutput` (mirrors `OrchestratorTurnResult. + # clinical_escalation_required`). Extracted into its own column (not + # left to a JSONB reach-through into `session_state`) so it is + # queryable/observable as the minimal consumer CVR-047 recommendation 3 + # calls for. Full counselor notification (F5) is a follow-up PRD's scope. + clinical_escalation_required: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False, server_default="false" + ) messages = relationship( "Message", back_populates="session", cascade="all, delete-orphan" @@ -73,8 +91,14 @@ class Session(Base): class Message(Base): __tablename__ = "messages" __table_args__ = ( + # BUG-064 fix (additive step, migration 0012): 'assistant' is now + # also a valid stored value, matching the widened DB CHECK — storage + # still writes 'ai' (see services/chat.py's outbound seam-map + # docstring); the full rename to canonicalize storage on + # 'assistant' is an explicit, deferred follow-up (fix_wave_design.md + # §(c)), not part of this migration. CheckConstraint( - "role IN ('user','ai','system')", name="ck_messages_role" + "role IN ('user','ai','system','assistant')", name="ck_messages_role" ), CheckConstraint( "input_modality IN ('text','voice')", name="ck_messages_modality" @@ -109,8 +133,12 @@ class RiskEvent(Base): CheckConstraint( "level IN ('low','medium','high','critical')", name="ck_risk_events_level" ), + # BUG-063 fix (migration 0012): widened to include 'pending_reclassify' + # (17 chars — see the column's String(32) below), the value + # `services/safety.py::handle_unavailable_classifier` unconditionally + # writes on every classifier-unavailable turn. CheckConstraint( - "status IN ('detected','acknowledged','resolved','dismissed')", + "status IN ('detected','acknowledged','resolved','dismissed','pending_reclassify')", name="ck_risk_events_status", ), CheckConstraint( @@ -138,7 +166,9 @@ class RiskEvent(Base): ARRAY(PG_UUID(as_uuid=True)) ) ai_evidence: Mapped[dict[str, Any] | None] = mapped_column(JSONB) - status: Mapped[str] = mapped_column(String(16), nullable=False, default="detected") + # BUG-063 fix: was String(16) — too short for 'pending_reclassify' (17 + # chars), source/DB divergence fixed alongside migration 0012. + status: Mapped[str] = mapped_column(String(32), nullable=False, default="detected") notified_to: Mapped[dict[str, Any] | None] = mapped_column(JSONB) legal_basis: Mapped[str | None] = mapped_column(Text) consent_snapshot_id: Mapped[uuid.UUID | None] = mapped_column( diff --git a/apps/api/src/schemas/session.py b/apps/api/src/schemas/session.py index 8161fbd..747fe5c 100644 --- a/apps/api/src/schemas/session.py +++ b/apps/api/src/schemas/session.py @@ -16,6 +16,30 @@ class SessionOut(BaseModel): model_config = ConfigDict(populate_by_name=True, extra="forbid") +class SessionListItemOut(BaseModel): + """S09 records — one row of the patient's own session history + (`GET /api/v1/sessions`). Deliberately thin: full transcript/report + content is NOT included here (existing `/{session_id}/report` route + already gates that clinician-only per screen-spec §S-12 elsewhere; the + patient-facing report body is fetched separately if/when needed).""" + + session_id: UUID = Field(alias="sessionId") + status: str + created_at: datetime = Field(alias="createdAt") + # FR-004 intake completeness, same source of truth as the WS progress + # bar (`Session.progress_ratio`). + progress_ratio: float = Field(alias="progressRatio") + has_report: bool = Field(alias="hasReport") + + model_config = ConfigDict(populate_by_name=True, extra="forbid") + + +class SessionListOut(BaseModel): + sessions: list[SessionListItemOut] + + model_config = ConfigDict(populate_by_name=True, extra="forbid") + + # ────────── WebSocket frames ────────── diff --git a/apps/api/src/services/ai_client.py b/apps/api/src/services/ai_client.py index 4d1cfbe..1ca8a00 100644 --- a/apps/api/src/services/ai_client.py +++ b/apps/api/src/services/ai_client.py @@ -24,6 +24,7 @@ from contracts.slots import SlotsExtractRequest, SlotsExtractResponse from contracts.stt import STTRequest, STTResponse from contracts.survey import SurveyScoreRequest, SurveyScoreResponse +from contracts.survey_plan import SurveyPlanRequest, SurveyPlanResponse from contracts.temporal import TemporalSummarizeRequest, TemporalSummarizeResponse from pydantic import BaseModel, ValidationError @@ -75,7 +76,25 @@ async def _post( raise AIClientError(f"{path} failed: {exc}") from exc async def safety_classify(self, payload: SafetyRequest) -> SafetyResponse: - return await self._post("/ai/safety/classify", SafetyResponse, payload) + """POST /ai/safety/classify. + + BUG-062 fix (EXP-031 fix_wave_design.md step 4, retiring the interim + hand-built adapter that used to live here): `SafetyRequest`/ + `SafetyResponse` are now field-identical to ai-server's real + `SafetyInput`/`SafetyOutput` wire shape, so this is the same generic + `_post` path every other endpoint uses — no more manual dict-building + or response re-mapping. The category-priority reduction that used to + happen here (`_SAFETY_CATEGORY_MAP`/`_SAFETY_CATEGORY_PRIORITY`) now + lives in `services/safety.py::to_safety_assessment` (domain-layer + translation, single-sourced with the CVR-051 orchestrator-crisis + category map — see that module). + """ + return await self._post( + "/ai/safety/classify", + SafetyResponse, + payload, + timeout=self._settings.ai_safety_timeout_seconds, + ) async def chat_respond(self, payload: ChatRequest) -> ChatResponse: """POST /ai/chat/respond. Non-streaming dialogue turn; its own timeout @@ -113,6 +132,11 @@ async def survey_score(self, payload: SurveyScoreRequest) -> SurveyScoreResponse """POST /ai/survey/score. 결정론적 채점(LLM 없음) — 빠른 기본 타임아웃.""" return await self._post("/ai/survey/score", SurveyScoreResponse, payload) + async def survey_plan(self, payload: SurveyPlanRequest) -> SurveyPlanResponse: + """POST /ai/survey/plan. F3 문진 시행 계획 — 결정론적(LLM 없음), + `survey_score`와 같은 기본 타임아웃(PLAN-2026-W30-INTEG P3-1(a)).""" + return await self._post("/ai/survey/plan", SurveyPlanResponse, payload) + async def domain_infer(self, payload: DomainInferRequest) -> DomainInferResponse: """POST /ai/domain/infer. LLM 호출이라 chat과 같은 예산을 쓴다. diff --git a/apps/api/src/services/chat.py b/apps/api/src/services/chat.py index d52b640..06ad6cd 100644 --- a/apps/api/src/services/chat.py +++ b/apps/api/src/services/chat.py @@ -2,14 +2,23 @@ Called by the WebSocket gateway after the safety check clears (LOW/MEDIUM). It: 1. builds the conversation context (decrypted, oldest-first) for the AI server, -2. calls POST /ai/chat/respond, +2. calls POST /ai/chat/respond (ADR-046 #2 shape — `ChatRequest.user_message`/ + `conversation_history`/`filled_slots`/`session_state`), 3. persists the assistant reply (encrypted, AAD-bound like user messages), -4. updates the session's intake progress (ratio + collected items), -5. returns the `ai:complete` payload (camelCase) for the client. +4. updates the session's intake progress (ratio + collected items) when + ai-server supplies it this turn, +5. returns the `ai:complete` payload (camelCase) for the client, including the + round-tripped `session_state` for the WS caller to thread into the next turn. Best-effort by design: ANY failure returns None so the chat keeps flowing — the safety pipeline has already done its job and the dialogue is non-critical. Token streaming (`ai:token`) is deferred until the AI server exposes SSE. + +NOTE (ADR-046 #2 / PRD §5.1 Option A): the previous `grounding`-injection field +on `ChatRequest` is gone — it never had a matching field on ai-server's own +`DialogueInput` (this route's real wire contract) and `_build_grounding` below +always resolved to `None` in practice anyway (RAG lives in ai-server's own +`src.rag`, not here). Dropped rather than carried as dead code. """ from __future__ import annotations @@ -19,7 +28,8 @@ import uuid from typing import Any -from contracts.chat import ChatMessage, ChatRequest, Grounding +from contracts.chat import ChatMessage, ChatRequest +from contracts.safety import RiskCategory, RiskLevel, SafetyAssessment, SafetyEvidence from contracts.slots import SlotsExtractRequest from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -29,16 +39,83 @@ from src.db import SessionLocal from src.models.session import Message, Session from src.services.ai_client import AIClient, AIClientError +from src.services.safety import ( + RiskDetectedPayload, + handle_safety_result, + latest_consent_snapshot, + map_crisis_category, +) logger = logging.getLogger(__name__) CHAT_CONTEXT_TURNS = 20 +# BUG-075 fix: ai-server's `DialogueOutput`/`ChatResponse` has no `progress` +# field at all (`result.progress` is always `None` on every real call — see +# BUG-075's own reproduction) — the progress bar was structurally frozen at +# 0%/its DB defaults forever. Fix direction (b) from that entry: compute +# `progress_ratio`/`collected_items` on THIS side from the round-tripped +# `session_state` dict every `ChatResponse` already carries (ai-server's own +# `SessionState.model_dump()` shape — `slot_coverage`/`filled_slots` keys are +# always present), rather than waiting on an ai-server schema change. This +# is a single source of truth: no second, independently-computed coverage +# number. +# +# `_INTAKE_TOTAL_ITEMS` mirrors ai-server's own coverage denominator +# (`src.agents.clinical_slot.PATIENT_FILLABLE_SLOTS`, 7 of 12 canonical +# slots — excludes risk_assessment/encounter_metadata/clinical_assessment/ +# treatment_plan/mental_status_exam) so `ratio` (already computed over that +# denominator server-side, threaded as `session_state["slot_coverage"]`) +# and `total_items` stay fraction-consistent (closes the previous "13 +# hardcoded, but ratio computed over a different denominator" drift). This +# is a plain int, not an import — apps/api has no dependency on ai-server's +# own package — kept in sync manually; a mismatch would only ever under/ +# over-state the progress bar's item COUNT display, never the trusted +# `ratio` itself (services/chat.py never recomputes ratio from this count). +_INTAKE_TOTAL_ITEMS = 7 + + +def _progress_fields_from_session_state( + session_state: dict[str, Any] | None, +) -> tuple[float | None, list[str] | None]: + """BUG-075: pull `(slot_coverage, filled_slots)` out of the round- + tripped `session_state` dict (ai-server's `SessionState.model_dump()` + shape) — the source of truth this module now derives the progress bar + from, instead of the never-populated `ChatResponse.progress`. Returns + `(None, None)` for a missing/malformed `session_state` (first turn, or + any shape apps/api does not recognize) — never raises.""" + state = session_state or {} + slot_coverage = state.get("slot_coverage") + filled_slots = state.get("filled_slots") + return ( + slot_coverage if isinstance(slot_coverage, (int, float)) else None, + filled_slots if isinstance(filled_slots, list) else None, + ) + def _message_aad(session_id: uuid.UUID, message_id: uuid.UUID) -> bytes: return f"messages.content:{session_id}:{message_id}".encode() +def _to_llm_role(role: str) -> str: + """Map the platform's DB-native message role onto the LLM-facing role. + + Platform stores assistant turns as role='ai' (messages CHECK); LLM APIs + (chat, slots-extract, and any future sibling call) only accept + 'assistant'. BUG-064 fixed this at `respond`'s call site only; BUG-067 + found `_extract_slots` sending the raw, unmapped role and 400ing at + Upstage from the 2nd turn onward. Every outbound conversation_history + builder must go through this one seam so a third sibling can't drift + again. + """ + return "assistant" if role == "ai" else role + + +def _to_llm_history(context: list[ChatMessage]) -> list[dict[str, str]]: + """Build an LLM-facing conversation_history, mapping roles via `_to_llm_role`.""" + return [{"role": _to_llm_role(m.role), "content": m.content} for m in context] + + async def _recent_messages( db: AsyncSession, session_id: uuid.UUID, *, limit: int ) -> list[ChatMessage]: @@ -62,30 +139,171 @@ async def _recent_messages( return out -async def _build_grounding( - db: AsyncSession, session_id: uuid.UUID, context: list[ChatMessage] -) -> Grounding | None: - """pgvector RAG grounding (best-effort — 검색 실패해도 대화는 계속 진행). +async def _recent_message_ids( + db: AsyncSession, session_id: uuid.UUID, *, limit: int +) -> list[uuid.UUID]: + """Mirrors `api.v1.sessions._recent_message_ids` (private there, this + module can't import across the services/router boundary) — used only + to give the CVR-051 escalation path (below) the same audit-trail + `context_message_ids` shape `handle_safety_result` already expects + from the pre-gate caller.""" + rows = await db.execute( + select(Message.id) + .where(Message.session_id == session_id) + .order_by(Message.created_at.desc()) + .limit(limit) + ) + ids = list(rows.scalars().all()) + ids.reverse() # oldest-first, matches the pre-gate caller's convention + return ids + + +# CVR-051: ai-server's `risk_level` string values ("high"/"critical") match +# `contracts.safety.RiskLevel`'s member values 1:1 (both lowercase); this +# maps the wire string back to the enum `handle_safety_result` requires. +# Defaults to HIGH (never silently drops the escalation) for any value the +# safety-gate contract does not otherwise use as a crisis level. +_CRISIS_RISK_LEVEL_MAP: dict[str, RiskLevel] = { + "critical": RiskLevel.CRITICAL, + "high": RiskLevel.HIGH, +} + +# BUG-060/CVR-051 follow-up, relocated by the BUG-062 fix wave: the +# category-tag -> `RiskCategory` priority map is now a SINGLE source +# (`services/safety.py::CRISIS_CATEGORY_PRIORITY`/`map_crisis_category`), +# shared with the pre-gate `/ai/safety/classify` translation +# (`services/safety.py::to_safety_assessment`). This wrapper keeps the +# orchestrator-crisis-specific fallback (`SELF_HARM`, not `NONE` —see +# docstring below) as the only thing still local to this module. + + +def _map_crisis_category(categories: list[str]) -> RiskCategory: + """Map ai-server's merged category tags to the correct `RiskCategory`, + via the single-source `services/safety.py::map_crisis_category`. + + BUG-060/CVR-051 follow-up: replaces the previous hardcoded + `RiskCategory.OTHER_HARM` placeholder (wrong for this pathway's actual + self-harm/suicide-oriented trigger, `_CRISIS_MESSAGES`). Falls back to + `RiskCategory.SELF_HARM` — not `OTHER_HARM`/`NONE` — when ai-server + sends no category tag for this turn, because the crisis-bypass CTRS + band this pathway fires on is self-harm/suicide-oriented by + construction (see `agents/orchestrator.py::_CRISIS_MESSAGES`, 109 + 자살예방상담전화); an empty category list is a safe-default case, not + evidence of harm to others. This is why this pathway passes its own + `default=RiskCategory.SELF_HARM` rather than using + `map_crisis_category`'s own default (`NONE`) — the pre-gate safety- + classify path (`services/safety.py::to_safety_assessment`) has no such + self-harm-oriented prior and correctly keeps `NONE` as its default. + + NOTE: this collapses a co-occurring multi-category turn (e.g. + suicidal_ideation + harm_to_others) down to ONE priority-ranked + `RiskCategory` — required for triage sorting/routing, which is + single-value by contract. The full tag list is NOT lost: see + `_crisis_evidence_keywords` below, which threads it onto the + clinician-facing `SafetyEvidence.matched_keywords` so it survives + into `RiskEvent.ai_evidence` (CVR-051 RM-8 follow-up finding 1). + """ + return map_crisis_category(categories, default=RiskCategory.SELF_HARM) + + +# CVR-051 RM-8 follow-up (finding 1 + 2): `matched_keywords` was hardcoded +# `[]` for this pathway, so (a) a co-occurring category dropped by +# `_map_crisis_category`'s single-value collapse (e.g. `harm_to_others` +# alongside a `suicidal_ideation`-triggered `RiskCategory.SUICIDE`) was +# unrecoverable from any clinician-facing artifact, and (b) the +# `RiskCategory.SELF_HARM` empty-list fallback was indistinguishable from +# a genuinely detected `self_harm` tag on the dashboard. Both encoded here +# as sentinel strings inside the EXISTING `matched_keywords: list[str]` +# field (no `SafetyEvidence`/`RiskEvent` schema change, no migration) — +# `handle_safety_result` copies this list verbatim into the JSONB +# `RiskEvent.ai_evidence` (`services/safety.py`), which is queryable and +# already documented as "Surfaced in audit + clinician UI" +# (`contracts/safety.py::SafetyEvidence`). Patient-facing surfaces +# (`ai:complete`/`risk:detected`, built from `RiskDetectedPayload` / +# `_payload_for` in `services/safety.py`) never read `matched_keywords` — +# this stays clinician/audit-only (NFR v3-2). +_EVIDENCE_CATEGORY_PREFIX = "ai_category:" +_EVIDENCE_SOURCE_DETECTED = "category_source:detected" +_EVIDENCE_SOURCE_FALLBACK_DEFAULT = "category_source:fallback_default" + + +def _crisis_evidence_keywords(categories: list[str]) -> list[str]: + """Full ai-server category list + fallback/detected source, as evidence. + + - Non-empty `categories`: every tag ai-server sent this turn, prefixed + `ai_category:`, plus `category_source:detected` — so a clinician can + recover a co-occurring `harm_to_others` even when `_map_crisis_category` + picked `SUICIDE` as the single-value primary `category`. + - Empty `categories` (ai-server sent no tag this turn): just + `category_source:fallback_default`, marking that `_map_crisis_category`'s + `RiskCategory.SELF_HARM` return is the safe-default placeholder, not an + actually-detected self_harm tag. + """ + if not categories: + return [_EVIDENCE_SOURCE_FALLBACK_DEFAULT] + return [f"{_EVIDENCE_CATEGORY_PREFIX}{tag}" for tag in categories] + [ + _EVIDENCE_SOURCE_DETECTED + ] - rag 의존성/스키마/임베딩이 아직 없으면 조용히 None (대화 비크리티컬). - NOTE: RAG 코드(src.rag)가 ai-server로 이전됨 → api에는 더 이상 없으므로 - 아래 `from src.rag.retrieval import ...`가 ImportError → grounding은 항상 None. - 즉 현재 api 라이브챗은 grounding 없이 동작(degrade). RAG는 ai-server에서만 구동.""" - last_user = next((m.content for m in reversed(context) if m.role == "user"), None) - if not last_user: - return None - try: - from src.rag.retrieval import retrieve_grounding - - sess = ( - await db.execute(select(Session).where(Session.id == session_id)) - ).scalar_one_or_none() - patient_id = sess.patient_id if sess is not None else None - return await retrieve_grounding(db, last_user, patient_id=patient_id) - except Exception: # noqa: BLE001 — grounding is best-effort - logger.info("chat.grounding.skipped", exc_info=True) +async def _handle_orchestrator_crisis( + db: AsyncSession, + *, + session_id: uuid.UUID, + patient_id: uuid.UUID, + ai_risk_level: str, + ai_risk_categories: list[str] | None = None, +) -> RiskDetectedPayload | None: + """CVR-051 fix: mirror the pre-gate escalation path for a crisis + detected by ai-server's OWN conversation-history-aware safety gate + inside the orchestrator (`ChatResponse.crisis_triggered`), so this + second gate produces the SAME structured RiskEvent + `risk:detected` + payload the pre-gate `handle_safety_result` call already produces for + `apps/api`'s own pre-gate classification — rather than only the crisis + message text landing as an ordinary chat bubble (CVR-051's finding). + + Reuses `handle_safety_result` directly (not a parallel mechanism) by + constructing the `SafetyAssessment` it expects from the fields ai-server + already returned this turn. `category` is derived from `ai_risk_ + categories` (`ChatResponse.risk_categories`, BUG-060/CVR-051 follow-up) + via `_map_crisis_category` — no longer the hardcoded `OTHER_HARM` + placeholder this function used before the follow-up fix (that value is + literally "harm to others"/'타해' and was wrong for this pathway's + actual self-harm/suicide-oriented trigger, `_CRISIS_MESSAGES`). + + RM-8 follow-up (findings 1/2): `evidence.matched_keywords` is no longer + hardcoded `[]` — see `_crisis_evidence_keywords` for what it now carries + (full category list + fallback/detected source marker), clinician-facing + only. + """ + trigger_message_id = await _recent_message_ids(db, session_id, limit=1) + if not trigger_message_id: + logger.warning( + "chat.crisis.no_trigger_message session_id=%s", session_id + ) return None + context_message_ids = await _recent_message_ids(db, session_id, limit=CHAT_CONTEXT_TURNS) + consent = await latest_consent_snapshot(db, patient_id) + + safety = SafetyAssessment( + level=_CRISIS_RISK_LEVEL_MAP.get(ai_risk_level, RiskLevel.HIGH), + category=_map_crisis_category(ai_risk_categories or []), + evidence=SafetyEvidence( + matched_keywords=_crisis_evidence_keywords(ai_risk_categories or []), + classifier="ai-server-orchestrator-crisis-gate", + confidence=1.0, + ), + latency_ms=0, + ) + return await handle_safety_result( + db, + patient_id=patient_id, + session_id=session_id, + trigger_message_id=trigger_message_id[-1], + context_message_ids=context_message_ids, + safety=safety, + consent=consent, + ) # 파이어-앤-포겟 태스크 GC 방지용 강한 참조. @@ -98,11 +316,26 @@ def _spawn(coro) -> None: task.add_done_callback(_bg_tasks.discard) +def _dialogue_target_slot(session_state: dict[str, Any] | None) -> str | None: + """BUG-072/073: pull ai-server's single-source-of-truth "what is + dialogue asking about this turn" field (`SessionState. + dialogue_target_slot`) out of the round-tripped `session_state` dict, + so `/ai/slots/extract` gets the same ask-evidence hint the HTTP route + (`routes/slots.py`) and the in-process post-dialogue extraction + (`agents/orchestrator.py`) both now use. `None` on any shape mismatch + (first turn, stale/foreign session_state) — never raises.""" + if not session_state: + return None + value = session_state.get("dialogue_target_slot") + return value if isinstance(value, str) and value else None + + async def _extract_slots_bg( *, session_id: uuid.UUID, context: list[ChatMessage], ai_client: AIClient, + session_state: dict[str, Any] | None = None, ) -> None: """응답 경로 밖에서 슬롯을 추출·저장한다 (별도 DB 세션). @@ -119,6 +352,7 @@ async def _extract_slots_bg( session_id=session_id, context=context, current=sess.clinical_slots or {}, + session_state=session_state, ) await bg_db.commit() except Exception: # noqa: BLE001 — 배경 작업이라 어떤 실패도 대화에 영향 없음 @@ -131,6 +365,7 @@ async def _extract_slots( session_id: uuid.UUID, context: list[ChatMessage], current: dict[str, Any], + session_state: dict[str, Any] | None = None, ) -> dict[str, Any]: """F1 임상 슬롯 증분 추출 (best-effort). @@ -145,10 +380,9 @@ async def _extract_slots( result = await ai_client.slots_extract( SlotsExtractRequest( session_id=str(session_id), - conversation_history=[ - {"role": m.role, "content": m.content} for m in context - ], + conversation_history=_to_llm_history(context), current_slots=current, + dialogue_target_slot=_dialogue_target_slot(session_state), ) ) except AIClientError as exc: @@ -168,14 +402,50 @@ async def respond( ai_client: AIClient, session_id: uuid.UUID, settings: Settings, + session_state: dict[str, Any] | None = None, ) -> dict[str, Any] | None: """Generate + persist one assistant turn. Returns the ai:complete payload - or None if generation failed (caller simply omits the AI reply).""" + or None if generation failed (caller simply omits the AI reply). + + `session_state`: ADR-046 #2 round-trip channel — the PRIOR turn's + `ChatResponse.session_state` (opaque dict), `None` on the session's first + turn (or when the caller has not yet reloaded it from the DB, e.g. a + fresh reconnect before `api/v1/sessions.py` seeds it from + `Session.session_state`). The caller (WS gateway) holds this + connection-scoped, in-memory across turns within one connection; the + returned payload's `sessionState` key is what the caller must feed back + in as this param on the NEXT call. + + Phase 1 (PRD §5.1 Option A wiring): this call ALSO persists the turn's + `result.session_state` and `result.clinical_escalation_required` onto + `Session` (committed by the caller's surrounding `db.commit()`), so the + round-trip survives a WS reconnect and not just a single open + connection. + """ try: context = await _recent_messages(db, session_id, limit=CHAT_CONTEXT_TURNS) - grounding = await _build_grounding(db, session_id, context) + if not context or context[-1].role != "user": + # DialogueInput.user_message requires the CURRENT user turn — + # nothing to respond to without one (e.g. called out of order). + logger.warning("chat.respond.no_user_message session_id=%s", session_id) + return None + user_message = context[-1].content + # See `_to_llm_role`/`_to_llm_history` — one shared seam-map for + # every outbound conversation_history builder (BUG-064, BUG-067). + conversation_history = _to_llm_history(context[:-1]) + + sess_row = await db.execute(select(Session).where(Session.id == session_id)) + sess = sess_row.scalar_one_or_none() + filled_slots: dict[str, str] = dict(sess.clinical_slots or {}) if sess is not None else {} + result = await ai_client.chat_respond( - ChatRequest(session_id=session_id, messages=context, grounding=grounding) + ChatRequest( + session_id=session_id, + user_message=user_message, + conversation_history=conversation_history, + filled_slots=filled_slots, + session_state=session_state, + ) ) except AIClientError as exc: logger.info("chat.respond.unavailable", extra={"error": str(exc)}) @@ -184,6 +454,30 @@ async def respond( logger.warning("chat.respond.failed", exc_info=True) return None + # CVR-051 fix: ai-server's own conversation-history-aware safety gate + # (inside its orchestrator) fired on THIS turn — mirror the pre-gate + # `handle_safety_result` escalation (RiskEvent + `risk:detected` + # payload) so this second gate's crisis signal is not silently dropped + # (previously only `result.assistant_response`'s crisis message text + # reached the client, as an ordinary chat bubble). Best-effort: any + # failure here must not block the assistant reply already generated. + risk_detected: dict[str, Any] | None = None + if result.crisis_triggered and sess is not None: + try: + payload = await _handle_orchestrator_crisis( + db, + session_id=session_id, + patient_id=sess.patient_id, + ai_risk_level=result.risk_level, + ai_risk_categories=result.risk_categories, + ) + if payload is not None: + risk_detected = dict(payload) + except Exception: # noqa: BLE001 — escalation failure must not drop the reply + logger.warning( + "chat.crisis.escalation_failed session_id=%s", session_id, exc_info=True + ) + # Persist the assistant reply (AAD bound to its own row id). ai_message_id = uuid.uuid4() db.add( @@ -192,7 +486,7 @@ async def respond( session_id=session_id, role="ai", content_encrypted=encrypt_str( - result.reply, + result.assistant_response, aad=_message_aad(session_id, ai_message_id), settings=settings, ), @@ -200,26 +494,102 @@ async def respond( ) ) - # Persist the latest progress snapshot on the session. - ratio = max(0.0, min(1.0, result.progress.ratio)) - sess_row = await db.execute(select(Session).where(Session.id == session_id)) - sess = sess_row.scalar_one_or_none() + # BUG-075 fix: `result.progress` is structurally always `None` (ai- + # server's `DialogueOutput` has no `progress` field to populate it from + # — see this module's own `_INTAKE_TOTAL_ITEMS` docstring), so deriving + # progress from it froze every session's progress bar at its DB + # defaults forever. Compute directly from the round-tripped + # `session_state` dict instead — ai-server's own `SessionState. + # model_dump()` shape always carries `slot_coverage`/`filled_slots`, + # computed server-side every turn (`OrchestratorAgent. + # _update_slot_coverage`) — single source of truth, no second + # independently-computed ratio. Keeps the session's last-known values + # (never resets to 0) only when `session_state` itself is absent this + # turn (e.g. an error path that short-circuited before the orchestrator + # ran). + state_slot_coverage, state_filled_slots = _progress_fields_from_session_state( + result.session_state + ) if sess is not None: - sess.progress_ratio = ratio - sess.collected_items = result.progress.collected_items + if state_slot_coverage is not None: + sess.progress_ratio = max(0.0, min(1.0, float(state_slot_coverage))) + if state_filled_slots is not None: + sess.collected_items = state_filled_slots + collected_items = sess.collected_items + total_items = _INTAKE_TOTAL_ITEMS + ratio = sess.progress_ratio + + # Phase 1 (ADR-046 #2 wiring) — persist the round-trip channel + the + # ADR-044 4th field so a reconnect can reload the prior turn's state + # (`api/v1/sessions.py` WS-open path) instead of only ever seeing + # `None` again. `result.session_state` already carries + # `asked_slot_counts`/`risk_screening_incomplete`/`handoff_delivered` + # nested under their own key names (ai-server's + # `SessionState.model_dump()` shape) — stored verbatim, not + # re-derived or flattened. + sess.session_state = result.session_state + sess.clinical_escalation_required = result.clinical_escalation_required + + # Minimal consumer (CVR-047 recommendation 3): DB persistence of the + # flag alone is not a clinical action, so surface it as a distinctly + # named, structured (alert-greppable) log event in addition to the + # column above — this is the Phase 1 floor, not the full counselor + # notification pipeline (F5 is a follow-up PRD's scope; that would + # consume this log/column, not replace it). + if result.clinical_escalation_required: + logger.warning( + "chat.clinical_escalation.flagged", + extra={ + "session_id": str(session_id), + "risk_level": result.risk_level, + "requires_human_review": result.requires_human_review, + }, + ) + else: + collected_items = state_filled_slots if state_filled_slots is not None else [] + total_items = _INTAKE_TOTAL_ITEMS + ratio = ( + max(0.0, min(1.0, float(state_slot_coverage))) + if state_slot_coverage is not None else 0.0 + ) # F1 — 임상 슬롯 추출은 두 번째 AI 왕복이라, 응답 경로에서 await하면 답변이 # 그만큼 늦어진다(최악 chat+slots 예산 합). 응답을 먼저 돌려주고 슬롯은 별도 # 태스크 + 별도 DB 세션에서 누적한다. 실패해도 대화엔 영향 없다. - _spawn(_extract_slots_bg(session_id=session_id, context=context, ai_client=ai_client)) + # BUG-072/073: thread this turn's round-tripped `session_state` too, so + # the background extraction call can populate `dialogue_target_slot` + # (the ask-evidence hint) — see `_dialogue_target_slot`. + _spawn( + _extract_slots_bg( + session_id=session_id, + context=context, + ai_client=ai_client, + session_state=result.session_state, + ) + ) return { "messageId": str(ai_message_id), - "content": result.reply, + "content": result.assistant_response, "modelUsed": result.model_used, "progress": { - "collectedItems": result.progress.collected_items, - "totalItems": result.progress.total_items, + "collectedItems": collected_items, + "totalItems": total_items, "ratio": ratio, }, + # ADR-046 #2 round-trip fields — caller threads `sessionState` back + # into the next turn's `respond(session_state=...)` call. + "sessionState": result.session_state, + "riskLevel": result.risk_level, + "requiresHumanReview": result.requires_human_review, + "handoffReady": result.handoff_ready, + "handoffReport": result.handoff_report, + "clinicalEscalationRequired": result.clinical_escalation_required, + # CVR-051 fix: present only when ai-server's own orchestrator- + # internal safety gate fired a crisis on this turn — the caller + # (`api/v1/sessions.py::_handle_message`) pops this key and emits + # it as a separate `risk:detected` WS frame (the same payload + # shape the pre-gate `handle_safety_result` path already emits), + # then ships the rest of this dict as `ai:complete` unchanged. + "riskDetected": risk_detected, } diff --git a/apps/api/src/services/domain_routing.py b/apps/api/src/services/domain_routing.py index 03e8032..ae6ad4c 100644 --- a/apps/api/src/services/domain_routing.py +++ b/apps/api/src/services/domain_routing.py @@ -15,6 +15,7 @@ import logging import uuid +from dataclasses import dataclass from contracts.domain import DomainInferRequest, UtteranceTurn @@ -27,6 +28,19 @@ "depression": "PHQ9", "anxiety": "GAD7", "alcohol": "AUDITC", + # ADR-046 #2 / contract4 fix: mirrors ai-server's canonical + # DOMAIN_TO_SCALE["panic"] == "GAD-7" (rag/questionnaire_mapping.py) — + # anxiety-family precedent (DSM-5: Panic Disorder is an anxiety + # disorder); see also golden_labels_f1f2.md VP-004's panic->anxiety + # mapping. Without this entry a "panic" top1 candidate silently fell + # to FALLBACK_INSTRUMENT below instead of routing to GAD7. + "panic": "GAD7", + # PLAN-2026-W30-INTEG P3-1(b) / CVR-049 register #3: mirrors ai-server's + # canonical DOMAIN_TO_SCALE["substance"] == "AUDIT-C" + # (rag/questionnaire_mapping.py:349). Without this entry a "substance" + # top1 candidate silently fell to FALLBACK_INSTRUMENT (PHQ4) instead of + # routing to AUDITC — same class of gap as the panic fix above. + "substance": "AUDITC", } # 미결 #4 — 후보 없음·저신뢰 시 기본 문진. 정책 확정 시 이 상수만 바꾼다. @@ -35,27 +49,68 @@ # 이 미만이면 저신뢰로 보고 폴백한다. MIN_CONFIDENCE = 0.35 +# PLAN-2026-W30-INTEG P3-0 (clinical-validator caveat-design consult) / P3-1(c): +# proxy-scale caveats for routings where the administered instrument is not a +# native scale for the inferred domain. Keyed by domain (module-internal only +# — never returned bare; always attached to the `InstrumentRouting.caveat` +# alongside the instrument ID, never the domain key itself). Verbatim wording +# from the consult; do not paraphrase. +_PROXY_CAVEATS: dict[str, str] = { + "panic": ( + "GAD-7은 범불안(generalized anxiety) 척도로, panic 특이 증상(발작 빈도, " + "상황성/자발성 구분, 죽을 것 같은 공포·비현실감, 광장공포 회피)을 직접 " + "반영하지 않습니다. 이 결과는 panic 중증도의 proxy로만 해석하고, 점수와 " + "무관하게 임상의 확인이 필요합니다." + ), + "substance": ( + "AUDIT-C는 알코올(음주) 소비·빈도 전용 스크리너입니다. 이 세션의 substance " + "후보가 알코올이 아닌 물질(예: 각성제·아편유사제·대마 등)을 포함하는 경우, " + "AUDIT-C 문항은 해당 물질 사용을 전혀 묻지 않으므로 이 결과가 실제 물질사용 " + "양상을 반영하지 못할 수 있습니다. 알코올 특이적 스크리닝 결과로만 해석하고, " + "비알코올 물질 사용 여부는 별도의 임상 확인이 필요합니다." + ), +} -async def infer_instrument( + +@dataclass(frozen=True) +class InstrumentRouting: + """CVR-049 finding 2 "infer_instrument marker" direction — carries the + resolved instrument PLUS, when the routing is a proxy (panic->GAD7, + substance->AUDITC), the clinician/audit-facing caveat text. + + `caveat` is `None` for native routings (depression/anxiety/alcohol) and + for the FALLBACK_INSTRUMENT path — never populated for a routing that + is not an actual proxy. This struct itself carries no domain KEY (only + instrument + prose caveat), so returning it does not regress the "only + PHQ9|GAD7|AUDITC|PHQ4 leave this module" invariant for the *bare* + `infer_instrument` API, which remains a thin wrapper returning only + `.instrument` unchanged. + """ + + instrument: str + caveat: str | None = None + + +async def _resolve_routing( *, ai_client: AIClient, session_id: uuid.UUID, turns: list[tuple[int, str]], clinical_slots: dict[str, str] | None = None, crisis_triggered: bool = False, -) -> str: - """top1 도메인에 해당하는 문진 도구 ID를 반환한다. +) -> InstrumentRouting: + """top1 도메인에 해당하는 문진 도구 + (proxy인 경우) caveat를 반환한다. Args: turns: [(turn_no, patient_message)] — 근거 소스. llm_only 모드에서는 이것이 유일한 근거이므로 비어 있으면 곧장 폴백한다. Returns: - "PHQ9" | "GAD7" | "AUDITC" | "PHQ4" — 절대 예외를 던지지 않는다. + `InstrumentRouting` — 절대 예외를 던지지 않는다. """ if not turns: logger.info("domain routing: no utterances, using fallback (session=%s)", session_id) - return FALLBACK_INSTRUMENT + return InstrumentRouting(instrument=FALLBACK_INSTRUMENT) # "라우팅은 실패하지 않는다" — 요청 조립(중첩 슬롯값 등으로 ValidationError # 가능)부터 호출까지 통째로 감싸고, 어떤 예외든 폴백으로 떨어뜨린다. @@ -72,7 +127,7 @@ async def infer_instrument( result = await ai_client.domain_infer(payload) except Exception as exc: # noqa: BLE001 — 라우팅은 어떤 실패에서도 폴백한다 logger.warning("domain routing fell back (session=%s): %s", session_id, exc) - return FALLBACK_INSTRUMENT + return InstrumentRouting(instrument=FALLBACK_INSTRUMENT) candidates = [c for c in result.domain_candidates if c.confidence >= MIN_CONFIDENCE] if not candidates: @@ -81,23 +136,67 @@ async def infer_instrument( MIN_CONFIDENCE, session_id, ) - return FALLBACK_INSTRUMENT + return InstrumentRouting(instrument=FALLBACK_INSTRUMENT) top = max(candidates, key=lambda c: c.confidence) instrument = _DOMAIN_TO_INSTRUMENT.get(top.domain) if instrument is None: - # trauma/sleep/psychosis/substance/other — 전용 척도가 아직 없다. + # trauma/sleep/psychosis/other — 전용 척도가 아직 없다. logger.info( "domain routing: top1 has no mapped instrument, using fallback (session=%s)", session_id, ) - return FALLBACK_INSTRUMENT + return InstrumentRouting(instrument=FALLBACK_INSTRUMENT) - # 로그에도 병명을 남기지 않는다 (NFR v3-2). + # 로그에도 병명을 남기지 않는다 (NFR v3-2) — instrument ID만 남긴다. logger.info( "domain routing: session=%s instrument=%s confidence=%.2f", session_id, instrument, top.confidence, ) - return instrument + return InstrumentRouting(instrument=instrument, caveat=_PROXY_CAVEATS.get(top.domain)) + + +async def infer_instrument( + *, + ai_client: AIClient, + session_id: uuid.UUID, + turns: list[tuple[int, str]], + clinical_slots: dict[str, str] | None = None, + crisis_triggered: bool = False, +) -> str: + """top1 도메인에 해당하는 문진 도구 ID를 반환한다 (bare-string API, 하위호환 불변). + + Returns: + "PHQ9" | "GAD7" | "AUDITC" | "PHQ4" — 절대 예외를 던지지 않는다. + """ + routing = await _resolve_routing( + ai_client=ai_client, + session_id=session_id, + turns=turns, + clinical_slots=clinical_slots, + crisis_triggered=crisis_triggered, + ) + return routing.instrument + + +async def infer_instrument_with_caveat( + *, + ai_client: AIClient, + session_id: uuid.UUID, + turns: list[tuple[int, str]], + clinical_slots: dict[str, str] | None = None, + crisis_triggered: bool = False, +) -> InstrumentRouting: + """PLAN-2026-W30-INTEG P3-1(c) — `infer_instrument`와 동일 로직이나 proxy + caveat(clinician/audit-facing)도 함께 반환한다. `/ai/survey/plan` 배선 + (F3 plan 채널)에서만 쓰인다 — 환자 응답 경로(`infer_instrument`)는 여전히 + bare instrument 문자열만 쓴다.""" + return await _resolve_routing( + ai_client=ai_client, + session_id=session_id, + turns=turns, + clinical_slots=clinical_slots, + crisis_triggered=crisis_triggered, + ) diff --git a/apps/api/src/services/handoff.py b/apps/api/src/services/handoff.py index 6d71ec4..44a4538 100644 --- a/apps/api/src/services/handoff.py +++ b/apps/api/src/services/handoff.py @@ -20,12 +20,7 @@ import uuid from datetime import UTC, datetime -from contracts.handoff import ( - HandoffMessage, - HandoffQuestionnaire, - HandoffRequest, - HandoffRiskSignal, -) +from contracts.handoff import HandoffRequest, ScaleScore, SlotData from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -80,15 +75,72 @@ async def create_pending_report( return report +async def _build_slots(db: AsyncSession, session_id: uuid.UUID) -> SlotData: + """BUG-066 fix — resolves the fix_wave_design.md UNVERIFIED open item: + apps/api DOES persist per-session slot data, in `Session.clinical_slots` + (JSONB, populated in the background by `services/chat.py:: + _extract_slots_bg` — a flat dict keyed by ClinicalSlotAgent's + canonical-12 slot names, per BUG-050). `_build_request` never read this + column before this fix — every handoff request silently shipped + `slots=SlotData()` (all-`None` defaults), which is why BUG-066's live + probe found a vacuous 12-section report despite real filled slots + existing in the DB. Unknown/stale dict keys (defensive — `clinical_slots` + is caller-controlled JSONB, not schema-enforced) are filtered against + `SlotData.model_fields` rather than passed through, so a stray key can + never trip `SlotData`'s own `extra="forbid"`.""" + row = await db.execute(select(Session.clinical_slots).where(Session.id == session_id)) + raw = row.scalar_one_or_none() or {} + filtered = {k: v for k, v in raw.items() if k in SlotData.model_fields} + return SlotData(**filtered) + + +async def _build_patient_metadata( + db: AsyncSession, session_id: uuid.UUID +) -> tuple[str | None, str | None, str | None]: + """BUG-069 follow-up (F5 metadata enrichment, 2026-07-25): resolves the + real `patient_gender`/`session_started_at`/`session_ended_at` values + `HandoffRequest` gained additively — previously nothing threaded these + into the handoff pipeline at all (v4.3's "기록 없음"/"미수집" fallback + was the only rendering, by design, given that absence). Returns `(None, + None, None)` per-field wherever the underlying row/column is missing — + never guesses; `handoff_generator.py::_build_user_content` keeps its + existing grounded fallback text for any `None` here.""" + sess_row = await db.execute( + select(Session.created_at, Session.submitted_at, Session.patient_id).where( + Session.id == session_id + ) + ) + sess = sess_row.first() + if sess is None: + return None, None, None + created_at, submitted_at, patient_id = sess + + gender_row = await db.execute( + select(PatientProfile.gender).where(PatientProfile.user_id == patient_id) + ) + gender = gender_row.scalar_one_or_none() + + return ( + gender, + created_at.isoformat() if created_at is not None else None, + submitted_at.isoformat() if submitted_at is not None else None, + ) + + async def _build_request( db: AsyncSession, session_id: uuid.UUID ) -> HandoffRequest: + """BUG-066 fix: assembles the REAL `HandoffInput`-shaped request + (`conversation_history`/`scale_scores`/`risk_events`/`slots`) instead of + the pre-fix invented `HandoffMessage`/`HandoffQuestionnaire`/ + `HandoffRiskSignal` shape ai-server's `extra`-permissive (pre-fix) + `HandoffInput` silently dropped in full.""" msg_rows = await db.execute( select(Message) .where(Message.session_id == session_id) .order_by(Message.created_at) ) - messages: list[HandoffMessage] = [] + conversation_history: list[dict[str, str]] = [] for m in msg_rows.scalars().all(): try: content = decrypt_str( @@ -96,39 +148,44 @@ async def _build_request( ) except Exception: content = "" - messages.append( - HandoffMessage(message_id=m.id, role=m.role, content=content) - ) + conversation_history.append({"role": m.role, "content": content}) q_rows = await db.execute( select(QuestionnaireResult).where( QuestionnaireResult.session_id == session_id ) ) - questionnaires = [ - HandoffQuestionnaire( - type=q.type, total_score=q.total_score, severity=q.severity - ) + scale_scores = [ + ScaleScore(scale_name=q.type, total_score=q.total_score, severity=q.severity) for q in q_rows.scalars().all() ] r_rows = await db.execute( select(RiskEvent).where(RiskEvent.session_id == session_id) ) - risk_signals = [ - HandoffRiskSignal( - level=r.level, - category=r.category, - source_message_id=r.trigger_message_id, - ) + risk_events: list[dict[str, str]] = [ + { + "level": r.level, + "category": r.category or "", + "source_message_id": str(r.trigger_message_id) if r.trigger_message_id else "", + } for r in r_rows.scalars().all() ] + slots = await _build_slots(db, session_id) + patient_gender, session_started_at, session_ended_at = await _build_patient_metadata( + db, session_id + ) + return HandoffRequest( - session_id=session_id, - messages=messages, - questionnaires=questionnaires, - risk_signals=risk_signals, + session_id=str(session_id), + slots=slots, + conversation_history=conversation_history, + scale_scores=scale_scores, + risk_events=risk_events, + patient_gender=patient_gender, + session_started_at=session_started_at, + session_ended_at=session_ended_at, ) diff --git a/apps/api/src/services/questionnaire.py b/apps/api/src/services/questionnaire.py index 3b83df3..5278a7a 100644 --- a/apps/api/src/services/questionnaire.py +++ b/apps/api/src/services/questionnaire.py @@ -22,7 +22,7 @@ import logging import uuid -from contracts.survey import SurveyScoreRequest +from contracts.survey import SurveyItemResponse, SurveyScoreRequest from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -149,7 +149,14 @@ async def score_with_ai( SurveyScoreRequest( session_id=str(session_id), scale_name=AI_SCALE_NAME[qtype], - responses=answers, + # ADR-046 #2 (contract3 fix): ai-server's SurveyScoreInput + # expects 1-based index/value pairs, not a flat int list + # (`contracts.survey.SurveyItemResponse`, mirrors + # `schemas.survey.SurveyItemResponse` 1:1). + responses=[ + SurveyItemResponse(index=i + 1, value=v) + for i, v in enumerate(answers) + ], patient_sex=patient_sex, ) ) diff --git a/apps/api/src/services/safety.py b/apps/api/src/services/safety.py index 4230efe..9500927 100644 --- a/apps/api/src/services/safety.py +++ b/apps/api/src/services/safety.py @@ -22,7 +22,13 @@ import uuid from typing import Literal, TypedDict -from contracts.safety import RiskCategory, RiskLevel, SafetyEvidence, SafetyResponse +from contracts.safety import ( + RiskCategory, + RiskLevel, + SafetyAssessment, + SafetyEvidence, + SafetyResponse, +) from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -33,6 +39,79 @@ logger = logging.getLogger(__name__) +# BUG-062 fix — single source of truth for ai-server's safety-classifier +# category vocabulary -> platform `RiskCategory`, shared by BOTH: +# (1) `to_safety_assessment` below (pre-gate `/ai/safety/classify` wire +# response translation, formerly `ai_client.py`'s inline +# `_SAFETY_CATEGORY_MAP`/`_SAFETY_CATEGORY_PRIORITY`), and +# (2) `services/chat.py::_map_crisis_category` (the CVR-051 +# orchestrator-crisis-gate translation) — that function now imports +# `CRISIS_CATEGORY_PRIORITY` from here instead of keeping its own copy, +# per the fix wave's "single source, not two parallel maps" directive. +# +# Priority order (most clinically specific/urgent first) matters because a +# turn can carry multiple co-occurring category tags; first match wins for +# the single-value `RiskCategory` field (the full tag list is preserved +# separately as clinician-facing evidence — see `chat.py:: +# _crisis_evidence_keywords`, unaffected by this relocation). +CRISIS_CATEGORY_PRIORITY: tuple[tuple[str, RiskCategory], ...] = ( + ("suicidal_ideation", RiskCategory.SUICIDE), + ("suicide", RiskCategory.SUICIDE), + ("self_harm", RiskCategory.SELF_HARM), + ("self_harm_overdose", RiskCategory.SELF_HARM), + ("harm_to_others", RiskCategory.OTHER_HARM), + ("distress", RiskCategory.ACUTE_DISTRESS), + ("despair", RiskCategory.ACUTE_DISTRESS), +) + + +def map_crisis_category( + categories: list[str], *, default: RiskCategory = RiskCategory.NONE +) -> RiskCategory: + """Map ai-server's merged category tags to the platform `RiskCategory`, + using `CRISIS_CATEGORY_PRIORITY`'s priority order. `default` lets each + caller pick its own safe fallback for an empty/unmatched tag list (the + pre-gate safety-classify path defaults to `NONE`; the CVR-051 + orchestrator-crisis path in `chat.py` defaults to `SELF_HARM` — see + that call site for why).""" + for tag, category in CRISIS_CATEGORY_PRIORITY: + if tag in categories: + return category + return default + + +_WIRE_RISK_LEVEL_MAP: dict[str, RiskLevel] = { + "none": RiskLevel.LOW, + "low": RiskLevel.LOW, + "medium": RiskLevel.MEDIUM, + "high": RiskLevel.HIGH, + "critical": RiskLevel.CRITICAL, +} + + +def to_safety_assessment(wire: SafetyResponse) -> SafetyAssessment: + """BUG-062 fix — translate ai-server's real `/ai/safety/classify` wire + response (`contracts.safety.SafetyResponse`, field-identical to + `SafetyOutput`) into the platform's internal `SafetyAssessment` + (`RiskLevel`/`RiskCategory` enums `handle_safety_result`/ + `handle_unavailable_classifier` consume). Replaces the manual mapping + previously hand-built inline in `ai_client.py::AIClient.safety_classify` + (the interim BUG-062 mitigation) — now the ONLY place this translation + happens, per the fix wave's single-source-of-truth directive.""" + level = _WIRE_RISK_LEVEL_MAP.get(wire.risk_level, RiskLevel.LOW) + category = map_crisis_category(wire.categories, default=RiskCategory.NONE) + return SafetyAssessment( + level=level, + category=category, + evidence=SafetyEvidence( + matched_keywords=list(wire.flagged_phrases), + classifier=f"ai-server:ctrs{wire.ctrs_level}", + confidence=float(wire.confidence), + ), + latency_ms=0, + ) + + # v3 FR-044 — 2024-01부터 자살예방상담전화(1393)·정신건강상담전화(1577-0199)는 # 자살예방 통합번호 109로 통합됐다. 모바일(MOCK_HOTLINES)과 동일하게 맞춘다. HOTLINES = [ @@ -81,9 +160,9 @@ def _is_blocking(level: RiskLevel) -> bool: return level in (RiskLevel.HIGH, RiskLevel.CRITICAL) -def _build_unavailable_response() -> SafetyResponse: +def _build_unavailable_response() -> SafetyAssessment: """M-1: conservative MEDIUM when classifier unavailable.""" - return SafetyResponse( + return SafetyAssessment( level=RiskLevel.MEDIUM, category=RiskCategory.OTHER_HARM, # closest placeholder; reclassify in Phase 2 evidence=SafetyEvidence( @@ -123,7 +202,7 @@ async def handle_safety_result( session_id: uuid.UUID, trigger_message_id: uuid.UUID, context_message_ids: list[uuid.UUID], - safety: SafetyResponse, + safety: SafetyAssessment, consent: ConsentSnapshotRef | None, ) -> RiskDetectedPayload | None: """Persist a RiskEvent if needed and return the client-facing payload. @@ -143,8 +222,57 @@ async def handle_safety_result( "classifier": safety.evidence.classifier, }, ) - # No client routing — the chat continues. RiskEvent skipped for MEDIUM - # to keep the table focused on actionable events. + # BUG-076 fix (CVR-055/056 "옵션 A"): MEDIUM + a suicide/self-harm + # -family category still persists a RiskEvent (status= + # "pending_reclassify", the same status already used for the + # classifier-unavailable path — CHECK constraint already allows it, + # migration 0012) so a passive-SI/CTRS-3-shaped disclosure leaves a + # clinician-reviewable trace instead of only a log line no one reads + # (CVR-055 finding: "log severity only... under-triage double + # failure" — slot contamination + zero structured signal). Client- + # facing routing/notification policy is UNCHANGED — no `routeTo`, + # no hotlines payload, no consent/notification dispatch; this is a + # storage-only addition, not a new alert tier. Plain MEDIUM without + # an SI-family category (e.g. generic "despair"/"distress" tags) + # keeps the pre-existing log-only behavior — narrowing the new + # persistence to the categories BUG-076's live repro was actually + # about, not every MEDIUM hit. + if safety.category in (RiskCategory.SUICIDE, RiskCategory.SELF_HARM): + risk_event = RiskEvent( + patient_id=patient_id, + session_id=session_id, + level=safety.level.value, + category=safety.category.value, + trigger_message_id=trigger_message_id, + context_message_ids=context_message_ids or None, + ai_evidence={ + "matched_keywords": safety.evidence.matched_keywords, + "classifier": safety.evidence.classifier, + "confidence": safety.evidence.confidence, + "latency_ms": safety.latency_ms, + }, + status="pending_reclassify", + notified_to=None, + legal_basis=None, + consent_snapshot_id=consent["id"] if consent else None, + ) + db.add(risk_event) + await db.flush() + db.add( + AuditLog( + actor_id=patient_id, + actor_role="patient", + action="safety.medium_si_logged", + resource_type="risk_event", + resource_id=risk_event.id, + audit_metadata={ + "level": safety.level.value, + "category": safety.category.value, + "classifier": safety.evidence.classifier, + }, + ) + ) + # No client routing regardless — the chat continues unchanged. return None # HIGH / CRITICAL path. @@ -179,6 +307,16 @@ async def handle_safety_result( consent_snapshot_id=consent_id, ) db.add(risk_event) + # CVR-051 self-check finding (RM-1): `risk_event.id` is a Python-side + # `default=uuid.uuid4` — SQLAlchemy only evaluates it during flush, and + # `SessionLocal`/every caller's AsyncSession here runs `autoflush=False` + # (`src/db.py`). Reading `.id` below without this flush returns `None` + # — both the `AuditLog.resource_id` FK and the `riskEventId` field + # `_payload_for` puts on the wire (FR-011/022's PATCH target) would + # silently ship the literal string `"None"`. Flushing (not committing — + # the caller still owns the transaction boundary) makes the DB assign + # the real id before either read. + await db.flush() db.add( AuditLog( @@ -220,6 +358,13 @@ async def handle_unavailable_classifier( A `pending_reclassify` RiskEvent is persisted so the Phase 2 replay job can re-evaluate without losing the message. The client always gets a self-hotline payload (consent-aware) — silence is unacceptable. + + CVR-053 fix: category is `RiskCategory.NONE`, not `OTHER_HARM` (타해). + A classifier-infrastructure outage is not a harm-to-others detection — + labeling it `OTHER_HARM` overstates the (unknown) clinical content of + the flagged message, the same category-fidelity defect class CVR-051 + fixed elsewhere in this file (see `map_crisis_category`/ + `to_safety_assessment` above, now this path's single source too). """ consent_opted_in = bool(consent and consent["risk_notification"]) consent_id = consent["id"] if consent else None @@ -228,7 +373,7 @@ async def handle_unavailable_classifier( patient_id=patient_id, session_id=session_id, level=RiskLevel.MEDIUM.value, - category=RiskCategory.OTHER_HARM.value, + category=RiskCategory.NONE.value, trigger_message_id=trigger_message_id, context_message_ids=context_message_ids or None, ai_evidence={ @@ -245,6 +390,9 @@ async def handle_unavailable_classifier( consent_snapshot_id=consent_id, ) db.add(risk_event) + # CVR-051 self-check finding (RM-1) — same flush-before-read fix as + # `handle_safety_result` above; see that call site's comment. + await db.flush() db.add( AuditLog( actor_id=patient_id, @@ -258,7 +406,7 @@ async def handle_unavailable_classifier( return _payload_for( level=RiskLevel.MEDIUM, - category=RiskCategory.OTHER_HARM, + category=RiskCategory.NONE, risk_event_id=risk_event.id, trigger_message_id=trigger_message_id, consent_opted_in=consent_opted_in, diff --git a/apps/api/tests/conftest.py b/apps/api/tests/conftest.py index db5c10b..28f787e 100644 --- a/apps/api/tests/conftest.py +++ b/apps/api/tests/conftest.py @@ -13,7 +13,7 @@ from __future__ import annotations -import asyncio +import functools import os from collections.abc import AsyncGenerator from urllib.parse import urlparse, urlunparse @@ -32,16 +32,19 @@ from src.db import Base, get_session from src.main import create_app -# ── Pre-existing bug workaround (TEST-ONLY) ───────────────────────────────── -# patient_profiles.is_minor is a Postgres GENERATED column whose expression uses -# CURRENT_DATE (non-immutable). PG16 rejects that at CREATE TABLE ("generation -# expression is not immutable"), which breaks Base.metadata.create_all for the -# whole suite. Drop the generated expression for TEST schema creation only — -# production models/DDL are untouched. (Model fix tracked as a separate issue.) +# ── TEST-ONLY convenience default ─────────────────────────────────────────── +# BUG-065 fix: `patient_profiles.is_minor` is no longer a Postgres GENERATED +# column (it used to use the non-immutable `CURRENT_DATE`, which PG16 +# rejected at CREATE TABLE — that was the original reason this module +# patched the column at import time). It is now a plain, app-set +# `Boolean NOT NULL` column (see `models/patient_profile.py`). This +# `server_default` patch is kept — TEST-ONLY, production DDL is untouched — +# purely so pre-existing tests that construct `PatientProfile(...)` without +# passing `is_minor` (most of the suite, since this field is orthogonal to +# what they're testing) keep working without a mass test-file edit. from src.models.patient_profile import PatientProfile as _PatientProfile # noqa: E402 _is_minor_col = _PatientProfile.__table__.c.is_minor -_is_minor_col.computed = None _is_minor_col.server_default = text("false") # Skip the whole test module if no Postgres is reachable — keeps unit tests @@ -60,13 +63,6 @@ def _swap_db_name(url: str, new_name: str) -> str: TEST_URL = _swap_db_name(SOURCE_URL, TEST_DB_NAME) -@pytest.fixture(scope="session") -def event_loop(): - loop = asyncio.new_event_loop() - yield loop - loop.close() - - async def _ensure_test_database() -> bool: """Create the test database if it doesn't exist. Return True on success.""" admin_url = _swap_db_name(SOURCE_URL, "postgres") @@ -135,7 +131,14 @@ def test_settings() -> Settings: @pytest_asyncio.fixture async def client(db_session: AsyncSession, test_settings: Settings) -> TestClient: app = create_app() - app.dependency_overrides[get_session] = lambda: _yield(db_session) + # BUG-057 root cause 1: a lambda wrapping an already-invoked call + # (`lambda: _yield(db_session)`) is not itself an async-generator + # *function* — FastAPI's dependency resolution inspects the override + # callable, not its return value, so it injects the raw un-iterated + # async-generator object instead of iterating it. functools.partial + # preserves `_yield`'s async-generator-function identity so FastAPI + # correctly resolves and iterates it. + app.dependency_overrides[get_session] = functools.partial(_yield, db_session) app.dependency_overrides[get_settings] = lambda: test_settings return TestClient(app) diff --git a/apps/api/tests/repro/test_bug_062_safety_contract_roundtrip.py b/apps/api/tests/repro/test_bug_062_safety_contract_roundtrip.py new file mode 100644 index 0000000..9ddac5f --- /dev/null +++ b/apps/api/tests/repro/test_bug_062_safety_contract_roundtrip.py @@ -0,0 +1,149 @@ +"""Regression test for BUG-062 (fixed this pass, EXP-031 fix_wave_design.md +Option B). No live HTTP — pure contract/translation-layer test. + +Pre-fix: `contracts.safety.SafetyRequest`/`SafetyResponse` (apps/api's +shared-contract vocabulary) shared no field names with ai-server's real +`/ai/safety/classify` schema (`SafetyInput`: session_id/user_message/ +conversation_history; `SafetyOutput`: risk_level/categories/flagged_phrases/ +confidence/ctrs_level/requires_human_review/crisis_protocol_activated) — a +raw `SafetyRequest`-shaped body 422'd immediately. `ai_client.py`'s hand- +built adapter mitigated live traffic but left the shared contract itself +wrong (its own docstring's "single source of truth" claim was false). + +Fix (this pass): `SafetyRequest`/`SafetyResponse` are now field-identical to +`SafetyInput`/`SafetyOutput`. `AIClient.safety_classify` is a plain generic +`_post` call (no more hand-built dict). The category-priority mapping that +used to live inline in `ai_client.py` is now `services/safety.py:: +to_safety_assessment` (translating the wire response into the platform's +internal `SafetyAssessment`), single-sourced with the CVR-051 orchestrator- +crisis category map (`services/safety.py::map_crisis_category`/ +`CRISIS_CATEGORY_PRIORITY`, now imported by `services/chat.py` instead of +keeping its own copy).""" + +from __future__ import annotations + +import inspect + +import pytest +from contracts.safety import RiskCategory, RiskLevel, SafetyRequest, SafetyResponse +from pydantic import ValidationError + + +def test_safety_request_matches_ai_server_safety_input_field_names(): + """`SafetyRequest.model_dump()`'s field names must intersect FULLY with + ai-server's real `SafetyInput` wire fields (`session_id`, `user_message`, + `conversation_history` — plus the shared `AgentInput` base's + `request_id`/`extra`, which `SafetyRequest` deliberately omits since + apps/api never needs to set them — this is fine because `SafetyInput` + does NOT require them: both have defaults).""" + req = SafetyRequest( + session_id="s-1", user_message="hello", conversation_history=[] + ) + dumped = req.model_dump(mode="json") + ai_server_safety_input_fields = {"session_id", "user_message", "conversation_history"} + assert ai_server_safety_input_fields.issubset(dumped.keys()), ( + f"SafetyRequest field names {set(dumped.keys())!r} do not cover " + f"ai-server's real SafetyInput fields {ai_server_safety_input_fields!r} " + "— BUG-062 regression (this is what silently 422'd pre-fix)" + ) + # extra=forbid on the wire — no stray platform-only fields would survive + # a real ai-server round trip either (defensive, mirrors the 422 ai- + # server itself would now give if this contract drifted again). + with pytest.raises(ValidationError): + SafetyRequest(session_id="s-1", user_message="hello", message="pre-fix-field") + + +def test_safety_output_shaped_dict_validates_against_safety_response(): + """A `SafetyOutput`-shaped dict (ai-server's real response, including the + shared `AgentOutput` base fields it also carries) must validate against + `SafetyResponse` without raising — this is what 422'd/mismatched + pre-fix (the old `SafetyResponse` required `level`/`category`/ + `evidence`/`latency_ms`, none of which ai-server's real output has).""" + ai_server_safety_output = { + # AgentOutput base fields ai-server's real response also carries — + # SafetyResponse must NOT reject these (extra="ignore", not forbid). + "model_used": "solar-pro3", + "prompt_version": "v1", + "latency_ms": 42.0, + "reason_summary": "no risk detected", + "prompts_degraded": False, + # SafetyOutput's own fields. + "risk_level": "high", + "categories": ["suicidal_ideation"], + "flagged_phrases": ["죽고 싶다"], + "confidence": 0.91, + "rule_triggered": True, + "llm_risk_level": "high", + "rule_risk_level": "high", + "ctrs_level": 2, + "requires_human_review": True, + "crisis_protocol_activated": True, + } + wire = SafetyResponse.model_validate(ai_server_safety_output) + assert wire.risk_level == "high" + assert wire.categories == ["suicidal_ideation"] + assert wire.ctrs_level == 2 + assert wire.requires_human_review is True + + +def test_to_safety_assessment_translates_wire_into_platform_vocabulary(): + from src.services.safety import to_safety_assessment + + wire = SafetyResponse( + risk_level="high", + categories=["suicidal_ideation"], + flagged_phrases=["죽고 싶다"], + confidence=0.9, + ctrs_level=2, + requires_human_review=True, + crisis_protocol_activated=True, + ) + assessment = to_safety_assessment(wire) + assert assessment.level == RiskLevel.HIGH + assert assessment.category == RiskCategory.SUICIDE + assert assessment.evidence.matched_keywords == ["죽고 싶다"] + + +def test_to_safety_assessment_maps_none_risk_level_to_low(): + """ai-server's `RiskLevel` has a `"none"` tier `contracts.safety. + RiskLevel` does not — must collapse to LOW, not raise/KeyError.""" + from src.services.safety import to_safety_assessment + + wire = SafetyResponse(risk_level="none", categories=[]) + assessment = to_safety_assessment(wire) + assert assessment.level == RiskLevel.LOW + assert assessment.category == RiskCategory.NONE + + +def test_ai_client_safety_classify_is_generic_post_not_hand_built(): + """BUG-062 interim-mitigation retirement check: `AIClient.safety_classify` + must no longer hand-build the ai-server request body / hand-map the + response — it should be the same one-line generic `_post` pattern every + other endpoint uses.""" + from src.services.ai_client import AIClient + + source = inspect.getsource(AIClient.safety_classify) + assert "self._post(" in source, ( + "safety_classify no longer uses the generic _post path — interim " + "adapter retirement regression" + ) + assert '"session_id": "platform-safety-gate"' not in source, ( + "safety_classify still hand-builds the request body — the interim " + "BUG-062 adapter was not actually retired" + ) + + +def test_category_priority_single_sourced_between_safety_and_chat(): + """BUG-062 fix wave directive: the category-priority map must be a + SINGLE source, shared by the pre-gate safety-classify translation and + the CVR-051 orchestrator-crisis path (`services/chat.py`) — not two + independently-maintained copies.""" + from src.services import chat as chat_module + from src.services import safety as safety_module + + # chat.py must import the shared map, not declare its own. + assert not hasattr(chat_module, "_CRISIS_CATEGORY_PRIORITY"), ( + "services/chat.py still declares its own _CRISIS_CATEGORY_PRIORITY " + "— single-source relocation regression" + ) + assert chat_module.map_crisis_category is safety_module.map_crisis_category diff --git a/apps/api/tests/repro/test_bug_063_risk_events_status_length.py b/apps/api/tests/repro/test_bug_063_risk_events_status_length.py new file mode 100644 index 0000000..5a05613 --- /dev/null +++ b/apps/api/tests/repro/test_bug_063_risk_events_status_length.py @@ -0,0 +1,120 @@ +"""Regression test for BUG-063 (fixed this pass, EXP-031 fix_wave_design.md). + +Pre-fix: `src/models/session.py`'s `RiskEvent.status` was `String(16)` with a +CHECK excluding `'pending_reclassify'` (17 chars > 16) — the exact value +`services/safety.py::handle_unavailable_classifier` unconditionally writes +on every classifier-unavailable turn. The live demo DB only avoided +crashing because it was hand-patched (outside any migration) to +`varchar(32)` + an updated CHECK. + +Fix (this pass): model source is `String(32)` + migration `0012` widens the +column and CHECK to match (additive, no data risk). This test checks BOTH +the model source (offline, no DB) and a live migrated fresh DB's actual +constraint text (so a source/DB divergence, BUG-063's exact defect, cannot +recur silently). +""" + +from __future__ import annotations + +import os +import tempfile + +import pytest + +pgserver = pytest.importorskip( + "pgserver", + reason="test-only throwaway-Postgres helper not installed; skipping gracefully.", +) + +for _key in ( + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "UPSTAGE_API_KEY", + "HIRA_API_KEY", + "KAKAO_API_KEY", + "NS_RAG_API_KEY", +): + os.environ.setdefault(_key, "") + os.environ[_key] = "" + + +def test_model_source_status_length_and_check(): + """Offline (no DB): model source itself must declare a length that fits + 'pending_reclassify' (17 chars) and the CHECK clause must include it — + BUG-063's defect was specifically source/DB divergence, so the source + itself (not just a live DB) must be correct.""" + from src.models.session import RiskEvent + + status_col = RiskEvent.__table__.c.status + assert status_col.type.length >= 32, ( + f"RiskEvent.status length={status_col.type.length} — too short for " + "'pending_reclassify' (17 chars); BUG-063 regression" + ) + + check_clauses = [ + str(c.sqltext) for c in RiskEvent.__table__.constraints if hasattr(c, "sqltext") + ] + assert any("pending_reclassify" in c for c in check_clauses), ( + f"no CHECK constraint on RiskEvent.status includes 'pending_reclassify': " + f"{check_clauses!r}" + ) + + +def test_fresh_migrated_db_status_column_and_check_match_model(): + """Live (throwaway pgserver, `alembic upgrade head`): the ACTUAL DB + column length + CHECK clause after migration 0012 must match what + `handle_unavailable_classifier` needs — closes the exact BUG-063 gap + (model/migration source vs. what was only ever true on a hand-patched + DB).""" + import subprocess + import sys + from pathlib import Path + + apps_api_root = Path(__file__).resolve().parents[2] + pgdata = tempfile.mkdtemp(prefix="bug063_pgdata_") + srv = pgserver.get_server(pgdata, cleanup_mode="delete") + try: + srv.psql("CREATE DATABASE neurosync_bug063;") + database_url = ( + srv.get_uri() + .replace("postgresql://", "postgresql+asyncpg://") + .replace("/postgres?", "/neurosync_bug063?") + ) + env = dict(os.environ) + env["DATABASE_URL"] = database_url + result = subprocess.run( + [sys.executable, "-m", "alembic", "upgrade", "head"], + cwd=str(apps_api_root), + env=env, + capture_output=True, + text=True, + timeout=120, + ) + assert result.returncode == 0, ( + f"migration chain failed\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) + + sync_url = ( + srv.get_uri().replace("/postgres?", "/neurosync_bug063?") + ) + import psycopg + + with psycopg.connect(sync_url.replace("postgresql://", "postgresql://")) as conn: + with conn.cursor() as cur: + cur.execute( + "SELECT character_maximum_length FROM information_schema.columns " + "WHERE table_name='risk_events' AND column_name='status'" + ) + (length,) = cur.fetchone() + assert length == 32, f"expected varchar(32), found varchar({length})" + + cur.execute( + "SELECT pg_get_constraintdef(oid) FROM pg_constraint " + "WHERE conrelid='risk_events'::regclass AND conname='ck_risk_events_status'" + ) + (constraint_def,) = cur.fetchone() + assert "pending_reclassify" in constraint_def, ( + f"CHECK missing 'pending_reclassify': {constraint_def!r}" + ) + finally: + srv.cleanup() diff --git a/apps/api/tests/repro/test_bug_064_role_ai_seam_map.py b/apps/api/tests/repro/test_bug_064_role_ai_seam_map.py new file mode 100644 index 0000000..19c7781 --- /dev/null +++ b/apps/api/tests/repro/test_bug_064_role_ai_seam_map.py @@ -0,0 +1,131 @@ +"""Regression test for BUG-064 (additive step fixed this pass, per +fix_wave_design.md §(c) — full role-rename explicitly deferred/out-of-wave). + +Pre-fix: ai-server's Upstage-backed chat API rejects the platform's +DB-native `role='ai'` from the 2nd conversational turn onward. The interim +mitigation (`services/chat.py::respond`, `'ai'`->`'assistant'` outbound-only +map) stays in place — storage still writes `'ai'`. This wave's fix is the +additive DB/model-source step only: `messages.role`'s CHECK now ALSO allows +`'assistant'` (migration 0012 + `models/session.py`), closing the +schema-level obstacle without doing the full storage-rename backfill (an +explicit, deferred follow-up per the design doc). + +This test checks: (1) the model source CHECK includes 'assistant' (offline), +(2) the outbound seam-map in `services/chat.py::respond` still maps every +'ai' -> 'assistant' before it leaves the process (guards the interim +mitigation against regression, per BUG-064's own recommended repro), and (3) +a fresh migrated DB's live CHECK matches the model source (closing BUG-064's +actual defect: source/DB divergence).""" + +from __future__ import annotations + +import os +import tempfile + +import pytest + +pgserver = pytest.importorskip( + "pgserver", + reason="test-only throwaway-Postgres helper not installed; skipping gracefully.", +) + +for _key in ( + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "UPSTAGE_API_KEY", + "HIRA_API_KEY", + "KAKAO_API_KEY", + "NS_RAG_API_KEY", +): + os.environ.setdefault(_key, "") + os.environ[_key] = "" + + +def test_model_source_role_check_includes_assistant(): + from src.models.session import Message + + check_clauses = [ + str(c.sqltext) for c in Message.__table__.constraints if hasattr(c, "sqltext") + ] + role_checks = [c for c in check_clauses if "role" in c] + assert role_checks, "no CHECK constraint found on messages.role" + assert any("'assistant'" in c for c in role_checks), ( + f"messages.role CHECK does not include 'assistant': {role_checks!r}" + ) + # Additive-only per the design: 'ai' must still be valid too (storage + # still writes it — the full rename is an explicit, deferred follow-up). + assert any("'ai'" in c for c in role_checks) + + +def test_outbound_seam_map_still_maps_ai_to_assistant(): + """BUG-064's own recommended repro: the interim mitigation (still in + place — full rename is deferred) must keep mapping every outbound + context message's role='ai' to 'assistant' before it reaches ai-server, + or a live Upstage call would 400 again from the 2nd turn on. + + BUG-067 factored this seam-map into a single shared helper + (`_to_llm_role`/`_to_llm_history`) so every outbound conversation_history + builder (not just `respond`'s) goes through it. This test now checks the + helper directly, plus that `respond` actually calls it, rather than + grepping for an inline literal that no longer exists post-refactor. + """ + import inspect + + from src.services.chat import _to_llm_role, respond + + assert _to_llm_role("ai") == "assistant" + assert _to_llm_role("user") == "user" + assert _to_llm_role("system") == "system" + + source = inspect.getsource(respond) + assert "_to_llm_history(" in source, ( + "services/chat.py::respond no longer routes its outbound " + "conversation_history through the shared _to_llm_history seam-map — " + "BUG-064 regression risk (unmapped 'ai' 400s at Upstage from the 2nd " + "turn on)" + ) + + +def test_fresh_migrated_db_role_check_matches_model(): + import subprocess + import sys + from pathlib import Path + + apps_api_root = Path(__file__).resolve().parents[2] + pgdata = tempfile.mkdtemp(prefix="bug064_pgdata_") + srv = pgserver.get_server(pgdata, cleanup_mode="delete") + try: + srv.psql("CREATE DATABASE neurosync_bug064;") + database_url = ( + srv.get_uri() + .replace("postgresql://", "postgresql+asyncpg://") + .replace("/postgres?", "/neurosync_bug064?") + ) + env = dict(os.environ) + env["DATABASE_URL"] = database_url + result = subprocess.run( + [sys.executable, "-m", "alembic", "upgrade", "head"], + cwd=str(apps_api_root), + env=env, + capture_output=True, + text=True, + timeout=120, + ) + assert result.returncode == 0, ( + f"migration chain failed\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) + + sync_url = srv.get_uri().replace("/postgres?", "/neurosync_bug064?") + import psycopg + + with psycopg.connect(sync_url) as conn: + with conn.cursor() as cur: + cur.execute( + "SELECT pg_get_constraintdef(oid) FROM pg_constraint " + "WHERE conrelid='messages'::regclass AND conname='ck_messages_role'" + ) + (constraint_def,) = cur.fetchone() + assert "assistant" in constraint_def + assert "'ai'" in constraint_def + finally: + srv.cleanup() diff --git a/apps/api/tests/repro/test_bug_065_migration_0001_immutable_generated_column.py b/apps/api/tests/repro/test_bug_065_migration_0001_immutable_generated_column.py new file mode 100644 index 0000000..9a05323 --- /dev/null +++ b/apps/api/tests/repro/test_bug_065_migration_0001_immutable_generated_column.py @@ -0,0 +1,100 @@ +"""Regression test for BUG-065 (fixed this pass, EXP-031 fix_wave_design.md). + +Pre-fix: `alembic/versions/0001_initial_auth_schema.py`'s `patient_profiles. +is_minor` column used `sa.Computed("(EXTRACT(YEAR FROM CURRENT_DATE)::int - +birth_year) < 14", persisted=True)` — PG16 rejects `CURRENT_DATE` (volatile) +inside a `GENERATED ALWAYS AS ... STORED` expression, so a genuinely fresh +`alembic upgrade head` could never complete past `0001`. + +Fix (this pass): `is_minor` is now a plain `Boolean NOT NULL` column with no +DB-side computation (`0001`'s source edited in place — see its docstring); +the application sets the value explicitly at INSERT time +(`api/v1/auth.py::register`, via `_is_minor()`). + +This test is the exact repro BUG-065 describes: spin up a THROWAWAY +`pgserver` Postgres (no DGX/demo DB touched), run the full `alembic upgrade +head` chain via subprocess (the real CLI entrypoint, not +`Base.metadata.create_all` — that would not have caught this bug, since the +test harness's own `create_all`-based fixture patches around exactly this +column), and assert it completes without raising. Completion criterion per +PLAN-2026-W30-FIXWAVE: "fresh throwaway PG에서 0001→head 전체 체인 무패치 통과." +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +import pytest + +pgserver = pytest.importorskip( + "pgserver", + reason="test-only throwaway-Postgres helper not installed; skipping gracefully.", +) + +# All live keys forced empty before any src.* import touches Settings() +# (BUG-052: unset alone does not gate live-only paths; only "" does). +for _key in ( + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "UPSTAGE_API_KEY", + "HIRA_API_KEY", + "KAKAO_API_KEY", + "NS_RAG_API_KEY", +): + os.environ.setdefault(_key, "") + os.environ[_key] = "" + +_APPS_API_ROOT = Path(__file__).resolve().parents[2] + + +def test_fresh_pgserver_alembic_upgrade_head_no_hand_patch(): + """BUG-065 completion criterion: fresh throwaway PG, `alembic upgrade + head` from an EMPTY database, zero hand-patching, must not raise.""" + pgdata = tempfile.mkdtemp(prefix="bug065_pgdata_") + srv = pgserver.get_server(pgdata, cleanup_mode="delete") + try: + srv.psql("CREATE DATABASE neurosync_bug065;") + database_url = ( + srv.get_uri() + .replace("postgresql://", "postgresql+asyncpg://") + .replace("/postgres?", "/neurosync_bug065?") + ) + + env = dict(os.environ) + env["DATABASE_URL"] = database_url + + result = subprocess.run( + [sys.executable, "-m", "alembic", "upgrade", "head"], + cwd=str(_APPS_API_ROOT), + env=env, + capture_output=True, + text=True, + timeout=120, + ) + + assert result.returncode == 0, ( + "`alembic upgrade head` failed against a fresh throwaway PG — " + f"BUG-065 regression.\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) + assert "InvalidObjectDefinitionError" not in result.stderr + assert "generation expression is not immutable" not in result.stderr + finally: + srv.cleanup() + + +def test_is_minor_is_no_longer_a_generated_column(): + """Confirms the actual fix, not just the migration's success — the + `patient_profiles.is_minor` column must be a plain column with no + Postgres GENERATED expression, matching `models/patient_profile.py`.""" + from src.models.patient_profile import PatientProfile + + col = PatientProfile.__table__.c.is_minor + assert col.computed is None, ( + "is_minor must not be a GENERATED/Computed column post-BUG-065-fix " + f"(found: {col.computed!r})" + ) + assert col.nullable is False diff --git a/apps/api/tests/repro/test_bug_066_handoff_contract_drift.py b/apps/api/tests/repro/test_bug_066_handoff_contract_drift.py new file mode 100644 index 0000000..3deafd5 --- /dev/null +++ b/apps/api/tests/repro/test_bug_066_handoff_contract_drift.py @@ -0,0 +1,178 @@ +"""Regression test for BUG-066 (fixed this pass, EXP-031 fix_wave_design.md). + +Pre-fix: `contracts.handoff.HandoffRequest`/`HandoffResponse` shared ZERO +field names with ai-server's real `/ai/handoff/generate` schema on EITHER +direction — request fields were silently dropped (no `extra="forbid"` on +ai-server's `HandoffInput`), so the LLM narrated from an empty session, and +the (vacuous but 200) response then failed apps/api's strict-required-field +`HandoffResponse` validation (`chief_complaint`/`present_illness` ai-server +never returns) — every live handoff report ended `status="failed"`. + +Fix (this pass): `HandoffRequest`/`HandoffResponse` are now field-identical +to ai-server's real `HandoffInput`/`HandoffOutput` on both directions. +`services/handoff.py::_build_request` assembles the REAL shape +(conversation_history/scale_scores/risk_events/slots) instead of the +pre-fix invented `HandoffMessage`/`HandoffQuestionnaire`/`HandoffRiskSignal`. +`slots` is now built from `Session.clinical_slots` (resolves the design +doc's UNVERIFIED open item — apps/api DOES persist per-session slot data, +in that JSONB column, populated by `services/chat.py::_extract_slots_bg`; +the pre-fix `_build_request` simply never read it).""" + +from __future__ import annotations + +import uuid + +import pytest +from contracts.handoff import HandoffRequest, HandoffResponse, SlotData +from pydantic import ValidationError + + +def test_handoff_request_field_names_intersect_ai_server_handoff_input(): + """`HandoffRequest.model_dump()`'s field names must cover ai-server's + real `HandoffInput` fields — zero overlap pre-fix (BUG-066's exact + finding).""" + req = HandoffRequest(session_id=str(uuid.uuid4())) + dumped = req.model_dump(mode="json") + ai_server_handoff_input_fields = { + "session_id", + "slots", + "conversation_history", + "scale_scores", + "risk_events", + "ocr_documents", + "prior_handoff", + "is_first_visit", + } + assert ai_server_handoff_input_fields.issubset(dumped.keys()), ( + f"HandoffRequest field names {set(dumped.keys())!r} do not cover " + f"ai-server's real HandoffInput fields {ai_server_handoff_input_fields!r} " + "— BUG-066 regression" + ) + # extra="forbid" scoped to HandoffInput on ai-server's side means any + # stray pre-fix-vocabulary field (messages/questionnaires/doc_texts/ + # risk_signals) would now 422 immediately rather than silently drop — + # confirm the CONTRACT side also rejects them (bounded blast radius: + # this is HandoffRequest, not the shared AgentInput base). + with pytest.raises(ValidationError): + HandoffRequest(session_id=str(uuid.uuid4()), messages=[]) + + +def test_handoff_output_shaped_dict_validates_against_handoff_response(): + """A `HandoffOutput`-shaped dict (ai-server's real response, including + the shared `AgentOutput` base fields it also carries) must validate + against `HandoffResponse` without raising — this is what raised + `pydantic.ValidationError` pre-fix (the old `HandoffResponse` required + non-optional `chief_complaint`/`present_illness`, neither of which + ai-server's real output has).""" + ai_server_handoff_output = { + "model_used": "solar-pro3", + "prompt_version": "v1", + "latency_ms": 6200.0, + "reason_summary": "report generated", + "prompts_degraded": False, + "report_markdown": "## 주호소\n업무 스트레스로 인한 수면 곤란", + "report_json": None, + "report_pdf_base64": None, + "trend_plot_base64": None, + "evidence_packets": [ + { + "evidence_id": "ev_msg_001", + "source_type": "message", + "source_ref": "msg-1", + "content_summary": "수면 곤란 호소", + } + ], + "missing_slots": ["family_history"], + "risk_level": "low", + "requires_human_review": False, + } + resp = HandoffResponse.model_validate(ai_server_handoff_output) + assert resp.report_markdown.startswith("## 주호소") + assert resp.missing_slots == ["family_history"] + assert resp.evidence_packets[0].source_type == "message" + + +def test_slots_filtered_against_slotdata_fields_no_extra_forbid_trip(): + """`services/handoff.py::_build_slots` filters `Session.clinical_slots` + (caller-controlled JSONB) against `SlotData.model_fields` before + construction — a stray/legacy key must never trip `SlotData`'s + `extra='forbid'`.""" + raw = { + "chief_complaint": "수면 곤란", + "some_future_unknown_key": "should be filtered out", + } + filtered = {k: v for k, v in raw.items() if k in SlotData.model_fields} + slots = SlotData(**filtered) # must not raise + assert slots.chief_complaint == "수면 곤란" + + +@pytest.mark.asyncio +async def test_build_request_assembles_real_shape_including_slots(monkeypatch): + """End-to-end (DB-mocked) check of `services/handoff.py::_build_request`: + conversation_history/scale_scores/risk_events/slots are all populated + from real session data — the pre-fix version silently shipped + `slots=SlotData()` (all-None defaults) because `_build_request` never + read `Session.clinical_slots` at all.""" + import src.services.handoff as handoff_module + + session_id = uuid.uuid4() + + class _FakeScalarResult: + def __init__(self, value): + self._value = value + + def scalar_one_or_none(self): + return self._value + + def scalars(self): + return self + + def all(self): + return self._value or [] + + def first(self): + return self._value + + class _FakeMessage: + def __init__(self, id_, role, content): + self.id = id_ + self.role = role + self.content_encrypted = content.encode() + + class _FakeDB: + def __init__(self): + self._call = 0 + + async def execute(self, query): + self._call += 1 + # Order in _build_request: messages, questionnaires, risk_events, + # _build_slots' Session.clinical_slots select, then (BUG-069 + # follow-up) _build_patient_metadata's Session row (.first()) + # and PatientProfile.gender (.scalar_one_or_none()). + if self._call == 1: + return _FakeScalarResult([]) + if self._call == 2: + return _FakeScalarResult([]) + if self._call == 3: + return _FakeScalarResult([]) + if self._call == 4: + return _FakeScalarResult({"chief_complaint": "수면 곤란"}) + if self._call == 5: + return _FakeScalarResult(None) # no Session row found -> (None, None, None) + return _FakeScalarResult(None) + + monkeypatch.setattr( + handoff_module, "decrypt_str", lambda enc, aad: enc.decode() + ) + + req = await handoff_module._build_request(_FakeDB(), session_id) + assert req.slots.chief_complaint == "수면 곤란", ( + "slots were not assembled from Session.clinical_slots — BUG-066 " + "regression (design doc's UNVERIFIED open item unresolved)" + ) + # BUG-069 follow-up: metadata fields degrade to None gracefully when the + # underlying Session row isn't found by this mock (not this test's + # focus — asserted for completeness, must not raise). + assert req.patient_gender is None + assert req.session_started_at is None + assert req.session_ended_at is None diff --git a/apps/api/tests/repro/test_bug_067_slots_extract_role_map.py b/apps/api/tests/repro/test_bug_067_slots_extract_role_map.py new file mode 100644 index 0000000..0e83d14 --- /dev/null +++ b/apps/api/tests/repro/test_bug_067_slots_extract_role_map.py @@ -0,0 +1,101 @@ +"""Regression test for BUG-067 (fixed this pass, EXP-031 live re-verification). + +Pre-fix: `services/chat.py::_extract_slots`'s `conversation_history` builder +sent the raw, unmapped `role='ai'` to `/ai/slots/extract` — the SAME defect +BUG-064 fixed at the `respond` call site, but never applied here. From the +2nd extraction call onward (any conversation with a prior assistant turn), +Upstage 400'd (`"role" value 'ai' must be one of +['system','assistant','user','tool']`), and `_extract_slots` silently fell +back to the caller's existing (near-empty) slots. `Session.clinical_slots` +never accumulated past the first turn's fields no matter how long the +conversation ran — starving BUG-066's `_build_request`/`_build_slots` (the +structured evidence base for F5 handoff generation). + +Fix (this pass): the role map is factored into one shared seam +(`_to_llm_role`/`_to_llm_history`, `services/chat.py`) used by BOTH +`_extract_slots` and `respond`, so a third sibling call site can't drift +again.""" + +from __future__ import annotations + +import inspect +import uuid +from types import SimpleNamespace + +import pytest + + +def test_to_llm_role_maps_ai_to_assistant_and_passes_through_others(): + from src.services.chat import _to_llm_role + + assert _to_llm_role("ai") == "assistant" + assert _to_llm_role("user") == "user" + assert _to_llm_role("system") == "system" + + +def test_to_llm_history_never_emits_raw_role_ai(): + from src.services.chat import _to_llm_history + + context = [ + SimpleNamespace(role="user", content="잠을 잘 못 자고..."), + SimpleNamespace(role="ai", content="언제부터 그러셨나요?"), + SimpleNamespace(role="user", content="식욕도 없고..."), + ] + history = _to_llm_history(context) + assert all(entry["role"] != "ai" for entry in history), ( + f"raw role='ai' leaked into outbound conversation_history: {history!r}" + ) + assert [entry["role"] for entry in history] == ["user", "assistant", "user"] + + +def test_extract_slots_source_uses_shared_seam_map(): + """`_extract_slots`'s conversation_history builder must route through + the shared `_to_llm_history` helper, not build its own unmapped + `{"role": m.role, ...}` list (BUG-067's exact defect).""" + from src.services.chat import _extract_slots + + source = inspect.getsource(_extract_slots) + assert "_to_llm_history(context)" in source, ( + "_extract_slots no longer routes its outbound conversation_history " + "through the shared _to_llm_history seam-map — BUG-067 regression " + "risk (unmapped role='ai' 400s at Upstage from the 2nd extraction " + "call onward)" + ) + assert '"role": m.role' not in source, ( + "_extract_slots appears to build conversation_history with a raw, " + "unmapped role again — BUG-067 regression" + ) + + +@pytest.mark.asyncio +async def test_extract_slots_calls_ai_client_with_mapped_roles(monkeypatch): + """End-to-end (ai_client-mocked) check: whatever `_extract_slots` sends + over the wire to `ai_client.slots_extract` never contains a raw + `role='ai'` entry, even though the stored context does.""" + from src.services.chat import _extract_slots + + captured: dict = {} + + class _FakeAIClient: + async def slots_extract(self, payload): + captured["conversation_history"] = payload.conversation_history + return SimpleNamespace(extracted_slots={"chief_complaint": "수면 곤란"}) + + context = [ + SimpleNamespace(role="user", content="잠을 잘 못 자고..."), + SimpleNamespace(role="ai", content="언제부터 그러셨나요?"), + SimpleNamespace(role="user", content="식욕도 없고..."), + ] + + result = await _extract_slots( + ai_client=_FakeAIClient(), + session_id=uuid.uuid4(), + context=context, + current={}, + ) + + sent_history = captured["conversation_history"] + assert all(entry["role"] != "ai" for entry in sent_history), ( + f"_extract_slots sent an unmapped role='ai' entry to ai-server: {sent_history!r}" + ) + assert result["chief_complaint"] == "수면 곤란" diff --git a/apps/api/tests/repro/test_bug_068_handoff_timeout_budget.py b/apps/api/tests/repro/test_bug_068_handoff_timeout_budget.py new file mode 100644 index 0000000..b148faa --- /dev/null +++ b/apps/api/tests/repro/test_bug_068_handoff_timeout_budget.py @@ -0,0 +1,57 @@ +"""Regression test for BUG-068 (fixed this pass, EXP-031 live re-verification). + +Pre-fix: `ai_handoff_timeout_seconds` defaulted to 45.0s, but ai-server's +evidence-verifier worst case (3 regenerate attempts, +`routes/handoff.py::_MAX_REGENERATE_ATTEMPTS = 2`) was observed live at +48.1s total (attempt latencies 19:23:38.549 -> 19:24:26.656). ai-server +itself did NOT fail — it returned a 200 (degraded, `requires_human_review`) +result — but apps/api's httpx client had already given up, so +`generate_report_task` marked the report `status="failed", +failureReason="ai_server_unavailable"` for a session ai-server was about to +successfully (if degraded) complete. + +`generate_report_task` is an async background task polled via +`GET /report/status` — no interactive request is blocked on this client +call — so there is no UX cost to a generous timeout margin. + +Fix (this pass): raised `ai_handoff_timeout_seconds` default to 90.0s, comfortably +above the observed 48.1s worst case.""" + +from __future__ import annotations + +# The exact worst-case wall-clock observed live for ai-server's 3-attempt +# evidence-verifier regenerate loop (error.md BUG-068 reproduction, EXP-031 +# rerun 2026-07-23: 19:23:38.549 -> 19:24:26.656). +_OBSERVED_WORST_CASE_SECONDS = 48.1 + + +def test_handoff_timeout_exceeds_observed_verifier_worst_case(): + from src.core.config import Settings + + settings = Settings() + assert settings.ai_handoff_timeout_seconds > _OBSERVED_WORST_CASE_SECONDS, ( + f"ai_handoff_timeout_seconds={settings.ai_handoff_timeout_seconds} does " + f"not exceed the observed worst-case verifier loop " + f"({_OBSERVED_WORST_CASE_SECONDS}s) — BUG-068 regression: apps/api " + "would give up right as ai-server was about to return a result." + ) + # Guard against an over-correction that silently masks a truly hung + # ai-server for an unreasonable duration — keep a sane upper bound too. + assert settings.ai_handoff_timeout_seconds <= 300.0 + + +def test_handoff_generate_uses_the_configured_timeout(monkeypatch): + """`AIClient.handoff_generate` must pass `ai_handoff_timeout_seconds` + through unchanged to the underlying POST — the fix is a config value, + not a code path; this guards against the call site being refactored to + hardcode a different budget.""" + import inspect + + from src.services.ai_client import AIClient + + source = inspect.getsource(AIClient.handoff_generate) + assert "self._settings.ai_handoff_timeout_seconds" in source, ( + "AIClient.handoff_generate no longer reads its timeout from " + "settings.ai_handoff_timeout_seconds — BUG-068 regression risk (a " + "hardcoded value would silently stop tracking config changes)" + ) diff --git a/apps/api/tests/repro/test_bug_069_followup_handoff_metadata_enrichment.py b/apps/api/tests/repro/test_bug_069_followup_handoff_metadata_enrichment.py new file mode 100644 index 0000000..3fb699c --- /dev/null +++ b/apps/api/tests/repro/test_bug_069_followup_handoff_metadata_enrichment.py @@ -0,0 +1,195 @@ +"""BUG-069 follow-up (F5 metadata enrichment, 2026-07-25) regression — +`services/handoff.py::_build_request` now threads the REAL +`PatientProfile.gender`/`Session.created_at`/`Session.submitted_at` values +into `HandoffRequest.patient_gender`/`session_started_at`/ +`session_ended_at` (previously always `None`, forcing ai-server's v4.3+ +"기록 없음"/"미수집" fallback even when the platform actually had the +data). + +Pattern mirrors `test_bug_076_medium_si_riskevent.py`/ +`test_cvr051_crisis_escalation_wire.py`: own throwaway `pgserver` Postgres, +`Base.metadata.create_all` schema, no live LLM/HTTP. +""" + +from __future__ import annotations + +import os +import tempfile +import uuid +from collections.abc import AsyncGenerator +from datetime import UTC, datetime +from typing import Any + +import pytest +import pytest_asyncio + +pgserver = pytest.importorskip( + "pgserver", + reason=( + "test-only throwaway-Postgres helper not declared in " + "apps/api/pyproject.toml/uv.lock yet; skipping gracefully." + ), +) + +for _key in ("OPENAI_API_KEY", "ANTHROPIC_API_KEY", "UPSTAGE_API_KEY", "SKT_A_X_API_KEY"): + os.environ[_key] = "" + + +@pytest.fixture(scope="module") +def _throwaway_postgres_url() -> Any: + pgdata = tempfile.mkdtemp(prefix="qa_bug069fu_pgdata_") + srv = pgserver.get_server(pgdata, cleanup_mode="delete") + srv.psql("CREATE DATABASE neurosync_qa_bug069fu;") + uri = ( + srv.get_uri() + .replace("postgresql://", "postgresql+asyncpg://") + .replace("/postgres?", "/neurosync_qa_bug069fu?") + ) + os.environ["DATABASE_URL"] = uri + from src.core.config import get_settings + + get_settings.cache_clear() + try: + yield uri + finally: + get_settings.cache_clear() + srv.cleanup() + + +@pytest.fixture(scope="module") +def _migrated_engine(_throwaway_postgres_url: str): + import asyncio + + from sqlalchemy.ext.asyncio import create_async_engine + + import src.models # noqa: F401 — register all tables on Base.metadata + from src.db import Base + from src.models.patient_profile import PatientProfile + + # `tests/conftest.py` (auto-loaded for the whole session regardless of + # which test file is running) sets `is_minor.server_default = text( + # "false")` as a TEST-ONLY convenience for callers that omit + # `is_minor=`. That `TextClause` object crashes SQLAlchemy's ORM + # `_insert_cols_as_none` mapper-cache computation (`bool(TextClause(...))` + # raises) the FIRST time any INSERT is emitted against this table via a + # fresh `Base.metadata.create_all`-schema'd engine (this file's own + # throwaway-Postgres fixture, independent of `conftest.py`'s own + # DB) — every `PatientProfile(...)` construction below supplies + # `is_minor=` explicitly, so the default is never actually needed; + # clearing it avoids tripping that SQLAlchemy quirk for this file's + # engine. + PatientProfile.__table__.c.is_minor.server_default = None + + async def _create_schema() -> None: + engine = create_async_engine(_throwaway_postgres_url, echo=False) + try: + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + finally: + await engine.dispose() + + asyncio.run(_create_schema()) + return _throwaway_postgres_url + + +@pytest_asyncio.fixture +async def db_session(_migrated_engine: str) -> AsyncGenerator[Any, None]: + from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + from sqlalchemy.pool import NullPool + + engine = create_async_engine(_migrated_engine, echo=False, poolclass=NullPool) + session_factory = async_sessionmaker( + bind=engine, class_=AsyncSession, autoflush=False, expire_on_commit=False + ) + try: + async with session_factory() as session: + yield session + await session.rollback() + finally: + await engine.dispose() + + +async def _seed_patient_and_session( + db_session, *, gender: str | None, submitted: bool +) -> uuid.UUID: + from src.core.encryption import encrypt_str + from src.models.patient_profile import PatientProfile + from src.models.session import Session + from src.models.user import User + + user_id = uuid.uuid4() + user = User( + id=user_id, + email=f"qa-bug069fu-{uuid.uuid4()}@example.test", + password_hash="not-a-real-hash", + role="patient", + ) + db_session.add(user) + await db_session.flush() + + profile = PatientProfile( + user_id=user_id, + name_encrypted=encrypt_str( + "홍길동", aad=f"patient_profiles.name:{user_id}".encode() + ), + birth_year=1990, + is_minor=False, + gender=gender, + ) + db_session.add(profile) + + sess = Session( + id=uuid.uuid4(), + patient_id=user_id, + status="submitted" if submitted else "in_progress", + submitted_at=datetime.now(UTC) if submitted else None, + ) + db_session.add(sess) + await db_session.commit() + return sess.id + + +@pytest.mark.asyncio +async def test_build_patient_metadata_returns_real_gender_and_timestamps(db_session): + from src.services.handoff import _build_patient_metadata + + session_id = await _seed_patient_and_session(db_session, gender="female", submitted=True) + + gender, started_at, ended_at = await _build_patient_metadata(db_session, session_id) + + assert gender == "female" + assert started_at is not None, "Session.created_at (server_default=now()) must be populated" + assert ended_at is not None, "submitted session must carry a real submitted_at" + # ISO-8601 round-trips. + datetime.fromisoformat(started_at) + datetime.fromisoformat(ended_at) + + +@pytest.mark.asyncio +async def test_build_patient_metadata_none_when_not_submitted_or_no_gender(db_session): + """A session still `in_progress` (never submitted) has no + `submitted_at`; a patient with no `gender` on file returns `None` for + that field — both must surface as `None`, not an invented placeholder, + so ai-server's existing fallback keeps firing correctly for them.""" + from src.services.handoff import _build_patient_metadata + + session_id = await _seed_patient_and_session(db_session, gender=None, submitted=False) + + gender, started_at, ended_at = await _build_patient_metadata(db_session, session_id) + + assert gender is None + assert started_at is not None # created_at is always set + assert ended_at is None + + +@pytest.mark.asyncio +async def test_build_request_threads_metadata_into_handoff_request(db_session): + from src.services.handoff import _build_request + + session_id = await _seed_patient_and_session(db_session, gender="male", submitted=True) + + req = await _build_request(db_session, session_id) + + assert req.patient_gender == "male" + assert req.session_started_at is not None + assert req.session_ended_at is not None diff --git a/apps/api/tests/repro/test_bug_075_progress_from_session_state.py b/apps/api/tests/repro/test_bug_075_progress_from_session_state.py new file mode 100644 index 0000000..53a006c --- /dev/null +++ b/apps/api/tests/repro/test_bug_075_progress_from_session_state.py @@ -0,0 +1,71 @@ +"""Regression test for BUG-075 (fixed this pass). + +Pre-fix: `services/chat.py::respond` derived the progress bar exclusively +from `result.progress` (`ChatResponse.progress`) — a field ai-server's real +`DialogueOutput` never populates (see BUG-075's own reproduction: `grep -rln +progress apps/ai-server/src/` finds no populate call in `routes/chat.py`). +Every real call therefore had `result.progress is None`, freezing +`progress_ratio`/`collected_items` at their DB defaults forever regardless +of turn count. + +Fix (this pass, option (b) from BUG-075's own fix direction): derive +progress directly from the round-tripped `session_state` dict every +`ChatResponse` already carries (ai-server's own `SessionState. +model_dump()` shape — `slot_coverage`/`filled_slots` keys are always +present, computed server-side every turn) via the new +`_progress_fields_from_session_state` helper — no ai-server change +required, single source of truth (never a second, independently-computed +ratio). + +No DB/AIClient/httpx call in this module — `_progress_fields_from_session_ +state` is a pure function; a mocked `ChatResponse.progress=None` shape is +constructed inline (a plain dict `session_state`, exactly what apps/api +actually receives on the wire) to prove the derivation no longer depends +on `.progress` at all. +""" + +from __future__ import annotations + +from src.services.chat import _INTAKE_TOTAL_ITEMS, _progress_fields_from_session_state + + +def test_progress_derived_from_session_state_slot_coverage_and_filled_slots(): + """Mirrors a real ai-server `ChatResponse` where `.progress` is `None` + (the structural BUG-075 shape) but `.session_state` carries the real + `slot_coverage`/`filled_slots` — the fix's whole point.""" + session_state = { + "slot_coverage": 0.42, + "filled_slots": ["chief_complaint", "risk_assessment"], + # Other SessionState keys are present on the real wire but + # irrelevant to progress derivation — included to prove the + # helper reads only the two keys it needs, not the whole shape. + "turn_count": 5, + "dialogue_target_slot": "family_history", + } + + ratio, collected_items = _progress_fields_from_session_state(session_state) + + assert ratio == 0.42 + assert collected_items == ["chief_complaint", "risk_assessment"] + + +def test_progress_is_none_none_when_session_state_absent(): + """First turn (or any error path with no session_state yet) — the + caller must keep the session's LAST-KNOWN values, never fabricate 0.""" + assert _progress_fields_from_session_state(None) == (None, None) + assert _progress_fields_from_session_state({}) == (None, None) + + +def test_progress_ignores_malformed_shapes_defensively(): + assert _progress_fields_from_session_state( + {"slot_coverage": "not-a-number", "filled_slots": "not-a-list"} + ) == (None, None) + + +def test_intake_total_items_matches_ai_server_patient_fillable_denominator(): + """BUG-075: `total_items` must match the SAME denominator ai-server's + own `slot_coverage` ratio is computed over (`PATIENT_FILLABLE_SLOTS`, + 7 of 12 canonical slots) — not the old hardcoded `13`, which produced + a ratio/fraction mismatch (dashboard-trust risk, same class as the + VP-010 `slot_coverage` vs `grounded_coverage` split noted elsewhere).""" + assert _INTAKE_TOTAL_ITEMS == 7 diff --git a/apps/api/tests/repro/test_bug_076_medium_si_riskevent.py b/apps/api/tests/repro/test_bug_076_medium_si_riskevent.py new file mode 100644 index 0000000..50f192d --- /dev/null +++ b/apps/api/tests/repro/test_bug_076_medium_si_riskevent.py @@ -0,0 +1,266 @@ +"""BUG-076 regression (option A, CVR-055/056-supported) — a MEDIUM-level +safety result carrying a suicide/self-harm-family category now persists a +`RiskEvent` (status="pending_reclassify") instead of being logged-only. + +CVR-055's finding: this session's ONLY structured safety signal was a +(previously mis-scored) `risk_assessment` slot text — with `handle_safety_ +result` discarding every MEDIUM result outright ("RiskEvent skipped...to +keep the table focused on actionable events"), a passive-SI/CTRS-3-shaped +disclosure could leave ZERO clinician-reviewable trace in `risk_events` +even when correctly scored. CVR-055 recommended "옵션 A" (MEDIUM + SI +signal -> persist a RiskEvent) over "옵션 B" (log-severity only, invisible +to a clinician who doesn't open the raw session). + +This fix is deliberately narrow and additive: + - Client-facing routing/notification policy is UNCHANGED (`handle_safety_ + result` still returns `None` for MEDIUM — no `routeTo`, no hotlines + payload, no consent/notification dispatch). + - Only MEDIUM + `RiskCategory.SUICIDE`/`RiskCategory.SELF_HARM` persists; + a plain MEDIUM with a non-SI category (e.g. `ACUTE_DISTRESS`) keeps the + pre-existing log-only behavior (negative control below) — this is not a + blanket "persist every MEDIUM" change. + +Pattern mirrors `tests/test_cvr051_crisis_escalation_wire.py`: own +throwaway `pgserver` Postgres, `Base.metadata.create_all` schema, no live +LLM/HTTP. +""" + +from __future__ import annotations + +import os +import tempfile +import uuid +from collections.abc import AsyncGenerator +from typing import Any + +import pytest +import pytest_asyncio + +pgserver = pytest.importorskip( + "pgserver", + reason=( + "test-only throwaway-Postgres helper not declared in " + "apps/api/pyproject.toml/uv.lock yet; skipping gracefully." + ), +) + +for _key in ("OPENAI_API_KEY", "ANTHROPIC_API_KEY", "UPSTAGE_API_KEY", "SKT_A_X_API_KEY"): + os.environ[_key] = "" + + +@pytest.fixture(scope="module") +def _throwaway_postgres_url() -> Any: + pgdata = tempfile.mkdtemp(prefix="qa_bug076_pgdata_") + srv = pgserver.get_server(pgdata, cleanup_mode="delete") + srv.psql("CREATE DATABASE neurosync_qa_bug076;") + uri = ( + srv.get_uri() + .replace("postgresql://", "postgresql+asyncpg://") + .replace("/postgres?", "/neurosync_qa_bug076?") + ) + os.environ["DATABASE_URL"] = uri + from src.core.config import get_settings + + get_settings.cache_clear() + try: + yield uri + finally: + get_settings.cache_clear() + srv.cleanup() + + +@pytest.fixture(scope="module") +def _migrated_engine(_throwaway_postgres_url: str): + import asyncio + + from sqlalchemy.ext.asyncio import create_async_engine + + import src.models # noqa: F401 — register all tables on Base.metadata + from src.db import Base + + async def _create_schema() -> None: + engine = create_async_engine(_throwaway_postgres_url, echo=False) + try: + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + finally: + await engine.dispose() + + asyncio.run(_create_schema()) + return _throwaway_postgres_url + + +@pytest_asyncio.fixture +async def db_session(_migrated_engine: str) -> AsyncGenerator[Any, None]: + from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + from sqlalchemy.pool import NullPool + + engine = create_async_engine(_migrated_engine, echo=False, poolclass=NullPool) + session_factory = async_sessionmaker( + bind=engine, class_=AsyncSession, autoflush=False, expire_on_commit=False + ) + try: + async with session_factory() as session: + yield session + await session.rollback() + finally: + await engine.dispose() + + +@pytest_asyncio.fixture +async def seeded_session(db_session) -> Any: + from src.models.session import Session + from src.models.user import User + + patient = User( + id=uuid.uuid4(), + email=f"qa-bug076-{uuid.uuid4()}@example.test", + password_hash="not-a-real-hash", + role="patient", + ) + db_session.add(patient) + await db_session.flush() + sess = Session(id=uuid.uuid4(), patient_id=patient.id, status="in_progress") + db_session.add(sess) + await db_session.commit() + return patient.id, sess.id + + +async def _seed_message(db_session, session_id: uuid.UUID) -> uuid.UUID: + """RiskEvent.trigger_message_id is a real FK to `messages` — insert a + minimal real Message row rather than an arbitrary UUID for the + persistence-path test (the non-SI/LOW negative controls below never + reach the INSERT, so they use a bare `uuid.uuid4()` safely).""" + from src.core.encryption import encrypt_str + from src.models.session import Message + + message_id = uuid.uuid4() + db_session.add( + Message( + id=message_id, + session_id=session_id, + role="user", + content_encrypted=encrypt_str( + "아침에 눈을 안 떴으면 좋겠어", + aad=f"messages.content:{session_id}:{message_id}".encode(), + ), + input_modality="text", + ) + ) + await db_session.flush() + return message_id + + +def _medium_assessment(category: str): + from contracts.safety import RiskCategory, RiskLevel, SafetyAssessment, SafetyEvidence + + return SafetyAssessment( + level=RiskLevel.MEDIUM, + category=RiskCategory(category), + evidence=SafetyEvidence( + matched_keywords=["눈을 안 떴으면"], classifier="test", confidence=0.6 + ), + latency_ms=5, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("category", ["suicide", "self_harm"]) +async def test_medium_si_category_persists_risk_event(db_session, seeded_session, category): + """BUG-076 fix: MEDIUM + suicide/self_harm category persists a RiskEvent + (status=pending_reclassify), and the client-facing return value is still + None (no routing/notification change).""" + from sqlalchemy import select + + from src.models.session import RiskEvent + from src.services.safety import handle_safety_result + + patient_id, session_id = seeded_session + trigger_message_id = await _seed_message(db_session, session_id) + + payload = await handle_safety_result( + db_session, + patient_id=patient_id, + session_id=session_id, + trigger_message_id=trigger_message_id, + context_message_ids=[], + safety=_medium_assessment(category), + consent=None, + ) + await db_session.commit() + + assert payload is None, "MEDIUM must still produce no client-facing routing payload" + + rows = await db_session.execute(select(RiskEvent).where(RiskEvent.session_id == session_id)) + events = rows.scalars().all() + assert len(events) == 1, f"expected exactly one RiskEvent for category={category!r}" + event = events[0] + assert event.level == "medium" + assert event.category == category + assert event.status == "pending_reclassify" + assert event.trigger_message_id == trigger_message_id + assert event.notified_to is None + + +@pytest.mark.asyncio +async def test_medium_non_si_category_stays_log_only(db_session, seeded_session): + """Negative control: a plain MEDIUM with a non-SI category (e.g. + acute_distress) must NOT persist a RiskEvent — the pre-existing + log-only behavior is unchanged for this case, this fix is scoped to + SI-family categories only.""" + from sqlalchemy import select + + from src.models.session import RiskEvent + from src.services.safety import handle_safety_result + + patient_id, session_id = seeded_session + + payload = await handle_safety_result( + db_session, + patient_id=patient_id, + session_id=session_id, + trigger_message_id=uuid.uuid4(), + context_message_ids=[], + safety=_medium_assessment("acute_distress"), + consent=None, + ) + await db_session.commit() + + assert payload is None + + rows = await db_session.execute(select(RiskEvent).where(RiskEvent.session_id == session_id)) + assert rows.scalars().all() == [] + + +@pytest.mark.asyncio +async def test_low_level_unaffected(db_session, seeded_session): + """Regression guard: LOW level (even with an SI category, which + shouldn't happen in practice but isolates the level-gate) still returns + None with no RiskEvent — this fix only touches the MEDIUM branch.""" + from contracts.safety import RiskCategory, RiskLevel, SafetyAssessment, SafetyEvidence + from sqlalchemy import select + + from src.models.session import RiskEvent + from src.services.safety import handle_safety_result + + patient_id, session_id = seeded_session + + payload = await handle_safety_result( + db_session, + patient_id=patient_id, + session_id=session_id, + trigger_message_id=uuid.uuid4(), + context_message_ids=[], + safety=SafetyAssessment( + level=RiskLevel.LOW, + category=RiskCategory.SUICIDE, + evidence=SafetyEvidence(matched_keywords=[], classifier="test", confidence=0.1), + latency_ms=1, + ), + consent=None, + ) + await db_session.commit() + + assert payload is None + rows = await db_session.execute(select(RiskEvent).where(RiskEvent.session_id == session_id)) + assert rows.scalars().all() == [] diff --git a/apps/api/tests/repro/test_bug_080_safety_classify_timeout_budget.py b/apps/api/tests/repro/test_bug_080_safety_classify_timeout_budget.py new file mode 100644 index 0000000..e76d9d6 --- /dev/null +++ b/apps/api/tests/repro/test_bug_080_safety_classify_timeout_budget.py @@ -0,0 +1,149 @@ +"""Regression test for BUG-080 (fixed this pass, EXP-032 live re-verification). + +Pre-fix: `AIClient.safety_classify` was the only LLM-backed `AIClient` +method with no dedicated `timeout=` kwarg at its `_post` call site — it +silently fell back to the bare `httpx.AsyncClient(timeout=2.0)` constructor +default. ai-server's `/ai/safety/classify` unconditionally makes a real +Upstage LLM call (`_llm_classify`, unless the rule level is already +`>= high`) whose live latency was observed at 952-4373ms across 2 sessions/ +9 turns (`error.md` BUG-080 reproduction table). Every latency `>2000ms` +tripped the client-side timeout — 4/6 turns (67%) in one session, 2/3 in +another — and fell open into `classifier_unavailable`, full-blocking +ordinary, non-crisis chat turns. + +Fix (this pass): added a dedicated `ai_safety_timeout_seconds` setting +(default 6.0s, mirroring the pattern every other `AIClient` method already +uses) and threaded it through `safety_classify`'s `_post` call. +""" + +from __future__ import annotations + +import asyncio +import inspect + +import httpx +import pytest + +# The exact worst-case latency observed live for a real Upstage +# `/ai/safety/classify` call (error.md BUG-080 reproduction, EXP-032 +# 2026-07-25: session `fb5380bb...` turn latencies 1807/1481/3265/952/3008/ +# 2456ms). +_OBSERVED_WORST_CASE_SECONDS = 4.373 + + +def test_safety_timeout_exceeds_observed_worst_case(): + from src.core.config import Settings + + settings = Settings() + assert settings.ai_safety_timeout_seconds > _OBSERVED_WORST_CASE_SECONDS, ( + f"ai_safety_timeout_seconds={settings.ai_safety_timeout_seconds} does " + f"not exceed the observed worst-case classify latency " + f"({_OBSERVED_WORST_CASE_SECONDS}s) — BUG-080 regression: an " + "ordinary, benign turn would trip the client-side timeout and " + "fail-open into classifier_unavailable full-block." + ) + # Sane upper bound — a safety-gate call sitting in the interactive + # pre-gate path should not be allowed to silently balloon. + assert settings.ai_safety_timeout_seconds <= 30.0 + + +def test_safety_classify_no_longer_uses_the_bare_2s_client_default(): + """The constructor-level `httpx.AsyncClient(timeout=2.0)` default + still exists (other legacy call sites may rely on it), but + `safety_classify`'s OWN call site must pass an explicit `timeout=` + kwarg through `_post` — source-level guard against the call site being + refactored back to the implicit default.""" + from src.services.ai_client import AIClient + + source = inspect.getsource(AIClient.safety_classify) + assert "timeout=self._settings.ai_safety_timeout_seconds" in source, ( + "AIClient.safety_classify no longer passes an explicit timeout — " + "BUG-080 regression risk (falls back to the bare 2.0s httpx " + "constructor default)." + ) + + +class _SlowTransport(httpx.AsyncBaseTransport): + """Mock transport that sleeps past a *narrow* timeout before replying + — proves `safety_classify` fails fast under a tight budget (pre-fix + behavior) and succeeds once the budget is widened (post-fix behavior), + without depending on ai-server or real Upstage latency. + + A bare custom `AsyncBaseTransport` does not enforce httpx's per-request + `timeout=` kwarg on its own (only the real `HTTPTransport`/httpcore + stack does) — this transport reads the read-timeout httpx already + stashed on `request.extensions["timeout"]` and races its own delay + against it via `asyncio.wait_for`, so the mock genuinely reproduces a + client-side timeout rather than always succeeding regardless of the + configured budget.""" + + def __init__(self, delay_seconds: float) -> None: + self._delay = delay_seconds + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + timeout_cfg = request.extensions.get("timeout") or {} + read_timeout = timeout_cfg.get("read") + if read_timeout is not None: + try: + await asyncio.wait_for(asyncio.sleep(self._delay), timeout=read_timeout) + except TimeoutError as exc: + raise httpx.ReadTimeout("mock read timeout", request=request) from exc + else: + await asyncio.sleep(self._delay) + return httpx.Response( + 200, + json={ + "risk_level": "low", + "categories": [], + "flagged_phrases": [], + "confidence": 0.9, + "ctrs_level": 5, + "requires_human_review": False, + "crisis_protocol_activated": False, + "model_used": "stub", + "prompt_version": "v1", + "latency_ms": self._delay * 1000, + "reason_summary": "stub", + }, + ) + + +def _make_safety_request(): + from contracts.safety import SafetyRequest + + return SafetyRequest( + session_id="test-session-bug080", + user_message="오늘 기분이 좀 나아요.", + conversation_history=[], + ) + + +def test_safety_classify_fails_fast_under_a_tight_client_timeout(): + """Mirrors the pre-fix shape: a slow-but-successful ai-server response + (3s, inside the 952-4373ms observed live range) against a 2.0s budget + must raise AIClientError — reproduces the fail-open trigger.""" + from src.core.config import Settings + from src.services.ai_client import AIClient, AIClientError + + client = httpx.AsyncClient(transport=_SlowTransport(3.0)) + settings = Settings(ai_safety_timeout_seconds=2.0) + ai_client = AIClient(client=client, settings=settings) + + with pytest.raises(AIClientError): + asyncio.run(ai_client.safety_classify(_make_safety_request())) + + +def test_safety_classify_succeeds_within_the_widened_default_budget(): + """Same 3s-slow mock response against the fixed 6.0s default budget — + must succeed (BUG-080's whole point: the budget now exceeds real + observed latency).""" + from src.core.config import Settings + from src.services.ai_client import AIClient + + client = httpx.AsyncClient(transport=_SlowTransport(3.0)) + settings = Settings() + assert settings.ai_safety_timeout_seconds == 6.0 + ai_client = AIClient(client=client, settings=settings) + + result = asyncio.run(ai_client.safety_classify(_make_safety_request())) + assert result.risk_level == "low" diff --git a/apps/api/tests/repro/test_bug_081_chat_extraction_timeout_budget.py b/apps/api/tests/repro/test_bug_081_chat_extraction_timeout_budget.py new file mode 100644 index 0000000..dd33d6f --- /dev/null +++ b/apps/api/tests/repro/test_bug_081_chat_extraction_timeout_budget.py @@ -0,0 +1,69 @@ +"""Regression test for BUG-081 (fixed this pass, EXP-032 live re-verification). + +Pre-fix: `ai_chat_timeout_seconds` defaulted to 10.0s, but ai-server's +`OrchestratorAgent._execute_pipeline` runs its ENTIRE post-dialogue +pipeline (slot_extraction + handoff_generation + the evidence-verifier's +up-to-3-attempt regenerate loop — the SAME chain BUG-068 measured at 48.1s +worst case for the regenerate loop alone) synchronously, inside the SAME +`/ai/chat/respond` call, whenever a turn crosses the slot-coverage/risk- +grounded threshold (confirmed by source read: `_execute_pipeline` awaits +`_run_post_dialogue_pipeline` directly — no background-task split on the +ai-server side, unlike apps/api's own `services/chat.py:: +_extract_slots_bg`). A live instance ran ~30s and was still running when +the prior 10.0s client budget gave up — the turn (including a genuine, +already-computed SI grounding) was silently dropped (`respond()` returns +`None`, caller emits no `ai:complete`/`risk:detected` frame at all). + +Fix (this pass): raised `ai_chat_timeout_seconds` default to 90.0s, +mirroring `ai_handoff_timeout_seconds`'s own BUG-068 margin, since this is +structurally the same regenerate-loop chain plus one extra LLM call (slot +extraction).""" + +from __future__ import annotations + +import inspect + +# The exact worst-case wall-clock observed live for ai-server's 3-attempt +# evidence-verifier regenerate loop alone (error.md BUG-068 reproduction, +# EXP-031 rerun 2026-07-23: 19:23:38.549 -> 19:24:26.656). BUG-081's own +# chain additionally prepends a slot-extraction LLM call before this loop +# even starts, so this is a conservative (not inflated) floor. +_OBSERVED_REGENERATE_LOOP_WORST_CASE_SECONDS = 48.1 + +# The BUG-081 live repro's own observed elapsed time before apps/api's +# prior 10.0s budget gave up (extraction chain was still running). +_OBSERVED_BUG_081_LIVE_ELAPSED_SECONDS = 30.0 + + +def test_chat_timeout_exceeds_both_observed_worst_cases(): + from src.core.config import Settings + + settings = Settings() + for observed in ( + _OBSERVED_REGENERATE_LOOP_WORST_CASE_SECONDS, + _OBSERVED_BUG_081_LIVE_ELAPSED_SECONDS, + ): + assert settings.ai_chat_timeout_seconds > observed, ( + f"ai_chat_timeout_seconds={settings.ai_chat_timeout_seconds} does " + f"not exceed an observed worst case ({observed}s) — BUG-081 " + "regression: apps/api would give up on a turn ai-server is " + "still genuinely computing, silently dropping it (including a " + "safety-relevant grounding, per the live repro)." + ) + # Sane upper bound — an interactive chat turn's budget should not be + # allowed to silently balloon past the handoff-generate budget it now + # mirrors. + assert settings.ai_chat_timeout_seconds <= 120.0 + + +def test_chat_respond_uses_the_configured_timeout(): + """`AIClient.chat_respond` must pass `ai_chat_timeout_seconds` through + unchanged to the underlying POST — guards against the call site being + refactored to hardcode a different budget.""" + from src.services.ai_client import AIClient + + source = inspect.getsource(AIClient.chat_respond) + assert "self._settings.ai_chat_timeout_seconds" in source, ( + "AIClient.chat_respond no longer reads its timeout from " + "settings.ai_chat_timeout_seconds — BUG-081 regression risk." + ) diff --git a/apps/api/tests/repro/test_bug_apps_api_dep_override.py b/apps/api/tests/repro/test_bug_apps_api_dep_override.py new file mode 100644 index 0000000..864b5d6 --- /dev/null +++ b/apps/api/tests/repro/test_bug_apps_api_dep_override.py @@ -0,0 +1,94 @@ +"""Minimal repro for the pinned-fastapi/starlette dependency-override +regression that breaks `tests/conftest.py`'s `client` fixture. + +No DB / network required — isolates the exact pattern used by +`tests/conftest.py:138` (`app.dependency_overrides[get_session] = lambda: _yield(db_session)`) +against a bare FastAPI app + async-generator dependency, on the repo's own +pinned `fastapi==0.136.3` / `starlette==1.2.1` (see `apps/api/uv.lock`). + +Filed against: BUG (qa, this pass) — "apps/api pytest client fixture +AttributeError: 'async_generator' object has no attribute ''" across +~44 tests in test_auth_login.py / test_auth_register.py / test_clinician.py / +test_intake_flow.py / test_stt_endpoint.py / test_websocket_chat.py whenever +the suite is actually run with `uv sync` deps installed (previously masked +by "apps/api/.venv lacks pydantic"). +""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient + + +class _Resource: + def __init__(self, tag: str) -> None: + self.tag = tag + + +async def _get_resource() -> _Resource: # pragma: no cover - real dependency stub + raise RuntimeError("not overridden") + + +async def _yield(value: _Resource): + yield value + + +def _build_app() -> FastAPI: + app = FastAPI() + + @app.get("/probe") + async def probe(res: Annotated[_Resource, Depends(_get_resource)]) -> dict: + return {"tag": res.tag} + + return app + + +def test_conftest_style_override_is_broken_on_pinned_fastapi(): + """Reproduces `tests/conftest.py:138`'s exact override pattern. + + `lambda: _yield(value)` is a plain callable that returns an + already-constructed async-generator *instance* — it is NOT itself an + async-generator *function*, so FastAPI's dependency resolution + (`fastapi.dependencies.utils.is_async_gen_callable`, which inspects the + override *callable*, not its return value) does not special-case it. + The raw async-generator object is injected as the dependency VALUE + instead of being iterated for its yielded item. + """ + app = _build_app() + resource = _Resource(tag="expected") + app.dependency_overrides[_get_resource] = lambda: _yield(resource) + + # `raise_server_exceptions=False` mirrors what a real deployed server + # would do (500 response) rather than TestClient's default of + # re-raising in-process — the underlying defect is identical either + # way (`tests/conftest.py`'s own `client` fixture hits this same + # AttributeError verbatim when pytest calls the actual test suite). + client = TestClient(app, raise_server_exceptions=False) + resp = client.get("/probe") + + assert resp.status_code == 500, ( + f"expected the known-broken 500 (AttributeError on async_generator), " + f"got {resp.status_code}: {resp.text} — if this now passes, the " + f"fastapi/starlette pin or the override pattern has changed; re-verify " + f"tests/conftest.py's `client` fixture directly and update/close this bug." + ) + + +def test_correct_override_pattern_works(): + """The fix: override with the async-generator FUNCTION itself (optionally + via functools.partial), not a lambda wrapping an already-invoked call — + this is what makes FastAPI recognize and iterate it as a generator dep. + """ + import functools + + app = _build_app() + resource = _Resource(tag="expected") + app.dependency_overrides[_get_resource] = functools.partial(_yield, resource) + + client = TestClient(app) + resp = client.get("/probe") + + assert resp.status_code == 200 + assert resp.json() == {"tag": "expected"} diff --git a/apps/api/tests/test_cvr051_crisis_escalation_wire.py b/apps/api/tests/test_cvr051_crisis_escalation_wire.py new file mode 100644 index 0000000..9f909e4 --- /dev/null +++ b/apps/api/tests/test_cvr051_crisis_escalation_wire.py @@ -0,0 +1,570 @@ +"""Committed regression test for CVR-051 (clinical-validator) — the +`/ai/chat/respond` crisis-signal wire-drop. + +CVR-051's finding: ai-server runs its OWN, second, conversation-history- +aware `SafetyClassifierAgent` invocation inside its orchestrator +(`apps/ai-server/src/agents/orchestrator.py`). When that gate fires a +genuine crisis (`orch_result.crisis_triggered=True`), the signal was +dropped end-to-end — no `RiskEvent`, no `risk:detected` WS emission, no +escalation flag; only the crisis message text (with hotline numbers) +reached the client as an ordinary chat bubble. + +The fix (this pass, RM-1): `ChatResponse.crisis_triggered` (new field, +mirrors `DialogueOutput.crisis_triggered`, itself new) is now consumed by +`src/services/chat.py::respond()`, which reuses the SAME +`handle_safety_result` escalation mechanism the pre-gate safety path +already uses to persist a `RiskEvent` and produce the `risk:detected` +payload — returned here as `respond()`'s `riskDetected` key (popped and +re-emitted as its own WS frame by `src/api/v1/sessions.py::_handle_message`, +verified separately by inspection, not re-tested here since it requires a +live WebSocket harness). + +Deliberately independent of `tests/conftest.py` (BUG-057) — same pattern +as `test_phase1_session_state_persistence.py`: own throwaway `pgserver` +Postgres, own migrated engine, `_StubAIClient` (no `httpx`, no live LLM). + +Hard constraints: + - all live API keys forced to "" (BUG-052 — unset alone does not gate). + - zero live LLM / outbound HTTP: `httpx.AsyncClient.post` monkeypatched + to raise if reached at all. + - no DGX Postgres touched — `pgserver` throwaway instance only. +""" + +from __future__ import annotations + +import os +import tempfile +import uuid +from collections.abc import AsyncGenerator +from typing import Any + +import pytest +import pytest_asyncio + +pgserver = pytest.importorskip( + "pgserver", + reason=( + "test-only throwaway-Postgres helper not declared in " + "apps/api/pyproject.toml/uv.lock yet (qa does not edit the " + "dependency manifest); skipping gracefully rather than erroring." + ), +) + +# All live keys forced empty BEFORE any src.* import touches Settings() +# (BUG-052: unset alone does not gate live-only paths; only "" does). +for _key in ( + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "UPSTAGE_API_KEY", + "SKT_A_X_API_KEY", +): + os.environ[_key] = "" + + +@pytest.fixture(scope="module") +def _throwaway_postgres_url() -> Any: + pgdata = tempfile.mkdtemp(prefix="qa_cvr051_pgdata_") + srv = pgserver.get_server(pgdata, cleanup_mode="delete") + srv.psql("CREATE DATABASE neurosync_qa_cvr051;") + uri = ( + srv.get_uri() + .replace("postgresql://", "postgresql+asyncpg://") + .replace("/postgres?", "/neurosync_qa_cvr051?") + ) + os.environ["DATABASE_URL"] = uri + from src.core.config import get_settings + + get_settings.cache_clear() + try: + yield uri + finally: + get_settings.cache_clear() + srv.cleanup() + + +@pytest.fixture(scope="module") +def _migrated_engine(_throwaway_postgres_url: str): + """Schema via `Base.metadata.create_all` (same TEST-ONLY workaround + `tests/conftest.py` uses — `patient_profiles.is_minor` is a Postgres + GENERATED column whose expression uses `CURRENT_DATE`, which Postgres + rejects as "not immutable" for a STORED generated column outside a + real `alembic` migration's raw SQL). `RiskEvent`'s table (this fix's + only DB dependency, per the RM-1 brief's no-migration-needed + constraint) is created here identically to how it exists at head — + this file makes no claim about any specific migration, only that the + fix's own read/write against `RiskEvent` behaves as expected.""" + import asyncio + + from sqlalchemy import text + from sqlalchemy.ext.asyncio import create_async_engine + + import src.models # noqa: F401 — register all tables on Base.metadata + from src.db import Base + from src.models.patient_profile import PatientProfile + + is_minor_col = PatientProfile.__table__.c.is_minor + is_minor_col.computed = None + is_minor_col.server_default = text("false") + + async def _create_schema() -> None: + engine = create_async_engine(_throwaway_postgres_url, echo=False) + try: + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + finally: + await engine.dispose() + + asyncio.run(_create_schema()) + return _throwaway_postgres_url + + +@pytest_asyncio.fixture +async def db_session(_migrated_engine: str) -> AsyncGenerator[Any, None]: + from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + from sqlalchemy.pool import NullPool + + engine = create_async_engine(_migrated_engine, echo=False, poolclass=NullPool) + session_factory = async_sessionmaker( + bind=engine, class_=AsyncSession, autoflush=False, expire_on_commit=False + ) + try: + async with session_factory() as session: + yield session + await session.rollback() + finally: + await engine.dispose() + + +@pytest_asyncio.fixture +async def seeded_session(db_session) -> Any: + from src.models.session import Session + from src.models.user import User + + patient = User( + id=uuid.uuid4(), + email=f"qa-cvr051-{uuid.uuid4()}@example.test", + password_hash="not-a-real-hash", + role="patient", + ) + db_session.add(patient) + await db_session.flush() + sess = Session(id=uuid.uuid4(), patient_id=patient.id, status="in_progress") + db_session.add(sess) + await db_session.commit() + return sess.id + + +class _StubAIClient: + """No `httpx`, no live LLM — scripted `ChatResponse` per call.""" + + def __init__(self, responses: list[Any]) -> None: + self._responses = list(responses) + self.calls: list[Any] = [] + + async def chat_respond(self, payload: Any) -> Any: + self.calls.append(payload) + return self._responses.pop(0) + + async def slots_extract(self, payload: Any) -> Any: + from contracts.slots import SlotsExtractResponse + + return SlotsExtractResponse(extracted_slots={}) + + +def _crisis_response( + *, risk_level: str = "critical", risk_categories: list[str] | None = None +) -> Any: + from contracts.chat import ChatResponse + + return ChatResponse( + assistant_response="지금 많이 힘드시군요. 자살예방상담전화 1393으로 연락해보세요.", + session_state={ + "asked_slot_counts": {}, + "risk_screening_incomplete": True, + "clinical_escalation_required": False, + "handoff_delivered": False, + }, + risk_level=risk_level, + requires_human_review=True, + crisis_triggered=True, + risk_categories=risk_categories or [], + ) + + +def _normal_response() -> Any: + from contracts.chat import ChatResponse + + return ChatResponse( + assistant_response="네, 조금 더 말씀해주시겠어요?", + session_state={ + "asked_slot_counts": {"chief_complaint": 1}, + "risk_screening_incomplete": True, + "clinical_escalation_required": False, + "handoff_delivered": False, + }, + risk_level="low", + requires_human_review=False, + crisis_triggered=False, + ) + + +@pytest.fixture(autouse=True) +def _forbid_live_http(monkeypatch: pytest.MonkeyPatch) -> None: + import httpx + + async def _forbidden(*args: Any, **kwargs: Any) -> Any: + raise AssertionError( + "live HTTP call attempted — this test must only use _StubAIClient" + ) + + monkeypatch.setattr(httpx.AsyncClient, "post", _forbidden) + + +async def _post_user_turn(db_session, session_id, settings, text: str) -> None: + from src.core.encryption import encrypt_str + from src.models.session import Message + + message_id = uuid.uuid4() + db_session.add( + Message( + id=message_id, + session_id=session_id, + role="user", + content_encrypted=encrypt_str( + text, + aad=f"messages.content:{session_id}:{message_id}".encode(), + settings=settings, + ), + input_modality="text", + ) + ) + await db_session.flush() + + +@pytest.mark.asyncio +async def test_orchestrator_crisis_produces_risk_event_and_wire_payload( + db_session, seeded_session +): + """CVR-051 core fix: `crisis_triggered=True` on the ai-server response + must produce a persisted `RiskEvent` (HIGH/CRITICAL, same table the + pre-gate path writes) and a `riskDetected` payload shaped exactly like + `handle_safety_result`'s own `RiskDetectedPayload` — not just the + chat-bubble text.""" + from sqlalchemy import select + + from src.core.config import Settings + from src.models.session import RiskEvent + from src.services.chat import respond as chat_respond + + session_id = seeded_session + settings = Settings() + + await _post_user_turn(db_session, session_id, settings, "다 끝내고 싶어요") + ai_client = _StubAIClient([_crisis_response(risk_level="critical")]) + + payload = await chat_respond( + db_session, + ai_client=ai_client, + session_id=session_id, + settings=settings, + session_state=None, + ) + await db_session.commit() + + assert payload is not None + # The crisis message text itself is unchanged/unaffected by this fix. + assert "1393" in payload["content"] + + risk_detected = payload["riskDetected"] + assert risk_detected is not None, ( + "CVR-051 regression: crisis_triggered=True produced no riskDetected " + "payload — the signal was dropped again" + ) + assert risk_detected["level"] == "critical" + assert risk_detected["routeTo"] in ("/emergency", "/self_hotline") + assert risk_detected["hotlines"] + assert uuid.UUID(risk_detected["riskEventId"]) + + rows = await db_session.execute( + select(RiskEvent).where(RiskEvent.session_id == session_id) + ) + events = rows.scalars().all() + assert len(events) == 1, "exactly one RiskEvent must be persisted for this crisis turn" + assert events[0].level == "critical" + assert events[0].status == "detected" + + +@pytest.mark.asyncio +async def test_non_crisis_turn_produces_no_risk_event(db_session, seeded_session): + """Negative control: an ordinary (non-crisis) turn must NOT create a + RiskEvent or a riskDetected payload — this fix is additive, not a + blanket escalation on every turn.""" + from sqlalchemy import select + + from src.core.config import Settings + from src.models.session import RiskEvent + from src.services.chat import respond as chat_respond + + session_id = seeded_session + settings = Settings() + + await _post_user_turn(db_session, session_id, settings, "요즘 잠을 잘 못 자요") + ai_client = _StubAIClient([_normal_response()]) + + payload = await chat_respond( + db_session, + ai_client=ai_client, + session_id=session_id, + settings=settings, + session_state=None, + ) + await db_session.commit() + + assert payload is not None + assert payload["riskDetected"] is None + + rows = await db_session.execute( + select(RiskEvent).where(RiskEvent.session_id == session_id) + ) + assert rows.scalars().all() == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("risk_categories", "expected_category"), + [ + pytest.param(["suicidal_ideation"], "suicide", id="suicidal_ideation-suicide"), + pytest.param(["harm_to_others"], "other_harm", id="harm_to_others-other_harm"), + pytest.param(["self_harm"], "self_harm", id="self_harm-self_harm"), + pytest.param( + [], + "self_harm", + id="empty-fallback-self_harm-NOT-other_harm", + ), + pytest.param( + ["distress", "harm_to_others", "suicidal_ideation"], + "suicide", + id="multi-tag-priority-resolves-to-suicide", + ), + ], +) +async def test_orchestrator_crisis_category_threads_to_riskevent( + db_session, seeded_session, risk_categories, expected_category +): + """BUG-059/CVR-051 category-fidelity follow-up: `ChatResponse. + risk_categories` (ai-server's real category signal, threaded through + `SafetyStatus.categories` -> `DialogueOutput.risk_categories`) must + resolve to the CORRECT `RiskCategory` on the persisted `RiskEvent` and + the wire `riskDetected.category`, per `_CRISIS_CATEGORY_PRIORITY`'s + documented priority order — not the pre-fix hardcoded `OTHER_HARM` + placeholder. The empty-list case is the fail-closed/no-signal case and + must fall back to `self_harm` (the crisis-bypass pathway's own + self-harm/suicide-oriented trigger), never silently default to + `other_harm` again.""" + from sqlalchemy import select + + from src.core.config import Settings + from src.models.session import RiskEvent + from src.services.chat import respond as chat_respond + + session_id = seeded_session + settings = Settings() + + await _post_user_turn(db_session, session_id, settings, "다 끝내고 싶어요") + ai_client = _StubAIClient( + [_crisis_response(risk_level="critical", risk_categories=risk_categories)] + ) + + payload = await chat_respond( + db_session, + ai_client=ai_client, + session_id=session_id, + settings=settings, + session_state=None, + ) + await db_session.commit() + + assert payload is not None + risk_detected = payload["riskDetected"] + assert risk_detected is not None + assert risk_detected["category"] == expected_category, ( + f"risk_categories={risk_categories!r} should map to " + f"{expected_category!r}, wire payload got " + f"{risk_detected['category']!r}" + ) + + rows = await db_session.execute( + select(RiskEvent).where(RiskEvent.session_id == session_id) + ) + events = rows.scalars().all() + assert len(events) == 1 + assert events[0].category == expected_category, ( + "persisted RiskEvent.category must match the mapped value, not the " + "old hardcoded OTHER_HARM placeholder" + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("risk_categories", "expected_category", "expected_evidence_subset", "forbidden_evidence"), + [ + pytest.param( + ["suicidal_ideation", "harm_to_others"], + "suicide", + [ + "ai_category:suicidal_ideation", + "ai_category:harm_to_others", + "category_source:detected", + ], + ["category_source:fallback_default"], + id="cooccurring-suicide-plus-harm_to_others-both-tags-survive", + ), + pytest.param( + [], + "self_harm", + ["category_source:fallback_default"], + ["ai_category:", "category_source:detected"], + id="empty-fallback-marked-as-fallback-not-detected", + ), + pytest.param( + ["self_harm"], + "self_harm", + ["ai_category:self_harm", "category_source:detected"], + ["category_source:fallback_default"], + id="detected-only-self_harm-marked-detected", + ), + ], +) +async def test_crisis_evidence_keywords_persist_on_riskevent( + db_session, + seeded_session, + risk_categories, + expected_category, + expected_evidence_subset, + forbidden_evidence, +): + """CVR-051 RM-8 follow-up (findings 1+2), verified at the persisted + `RiskEvent` level (not just the `_crisis_evidence_keywords` helper in + isolation): the JSONB `RiskEvent.ai_evidence["matched_keywords"]` + actually written to the DB must carry the FULL category tag list (a + co-occurring `harm_to_others` alongside a `suicidal_ideation`-derived + `SUICIDE` primary category must be recoverable) and must distinguish a + genuinely-detected category list from the empty-list safe-default + fallback. `RiskEvent.category` (the single-value priority-mapped + primary) must be unaffected by this evidence-only change.""" + from sqlalchemy import select + + from src.core.config import Settings + from src.models.session import RiskEvent + from src.services.chat import respond as chat_respond + + session_id = seeded_session + settings = Settings() + + await _post_user_turn(db_session, session_id, settings, "다 끝내고 싶어요") + ai_client = _StubAIClient( + [_crisis_response(risk_level="critical", risk_categories=risk_categories)] + ) + + payload = await chat_respond( + db_session, + ai_client=ai_client, + session_id=session_id, + settings=settings, + session_state=None, + ) + await db_session.commit() + + assert payload is not None + risk_detected = payload["riskDetected"] + assert risk_detected is not None + assert risk_detected["category"] == expected_category + + rows = await db_session.execute( + select(RiskEvent).where(RiskEvent.session_id == session_id) + ) + events = rows.scalars().all() + assert len(events) == 1, "exactly one RiskEvent must be persisted for this crisis turn" + persisted = events[0] + assert persisted.category == expected_category, ( + "single-value primary category mapping must be unchanged by the " + "evidence-only fix" + ) + + ai_evidence = persisted.ai_evidence + assert isinstance(ai_evidence, dict), "ai_evidence must be the JSONB dict, not None/omitted" + matched_keywords = ai_evidence["matched_keywords"] + assert isinstance(matched_keywords, list) + for expected in expected_evidence_subset: + assert expected in matched_keywords, ( + f"persisted RiskEvent.ai_evidence['matched_keywords']={matched_keywords!r} " + f"missing expected tag {expected!r} for risk_categories={risk_categories!r}" + ) + for forbidden in forbidden_evidence: + assert not any(kw.startswith(forbidden) or kw == forbidden for kw in matched_keywords), ( + f"persisted RiskEvent.ai_evidence['matched_keywords']={matched_keywords!r} " + f"unexpectedly contains {forbidden!r} for risk_categories={risk_categories!r}" + ) + + +@pytest.mark.asyncio +async def test_patient_facing_surfaces_carry_no_evidence_or_category_tags( + db_session, seeded_session +): + """NFR v3-2: `matched_keywords`/`ai_category:*`/evidence must never reach + the patient-facing wire. Checks BOTH the `riskDetected` payload dict AND + the full `ai:complete` dict (`payload` itself, minus the `riskDetected` + key the WS gateway pops off per `chat.py`'s own docstring) for any + evidence-shaped key or `ai_category:`-prefixed string value, recursively + — not just an exact-keys check, so a nested leak would also be caught.""" + import json + + from src.core.config import Settings + from src.services.chat import respond as chat_respond + + session_id = seeded_session + settings = Settings() + + await _post_user_turn(db_session, session_id, settings, "다 끝내고 싶어요") + ai_client = _StubAIClient( + [ + _crisis_response( + risk_level="critical", + risk_categories=["suicidal_ideation", "harm_to_others"], + ) + ] + ) + + payload = await chat_respond( + db_session, + ai_client=ai_client, + session_id=session_id, + settings=settings, + session_state=None, + ) + await db_session.commit() + + assert payload is not None + risk_detected = payload["riskDetected"] + assert risk_detected is not None + + # riskDetected: exact key set is the documented RiskDetectedPayload shape — + # no evidence/matched_keywords/ai_category key was added to it. + assert set(risk_detected.keys()) == { + "level", + "category", + "riskEventId", + "triggerMessageId", + "routeTo", + "hotlines", + "reason", + } + + # Full ai:complete dict (everything `respond()` returns, patient-facing + # once the WS gateway pops `riskDetected` back out into its own frame) — + # serialize and scan for any leaked evidence marker string anywhere, + # including nested values. + serialized = json.dumps(payload, default=str) + assert "matched_keywords" not in serialized + assert "ai_category:" not in serialized + assert "category_source:" not in serialized diff --git a/apps/api/tests/test_integration_rev_coupling.py b/apps/api/tests/test_integration_rev_coupling.py new file mode 100644 index 0000000..39a2599 --- /dev/null +++ b/apps/api/tests/test_integration_rev_coupling.py @@ -0,0 +1,579 @@ +"""PLAN-2026-W30-INTEG-REV (R1, qa) — single-session-lifecycle cross-phase +coupling verification. + +The F1-F3 integration was built in phases (7c1e7f8 -> b513379 -> 5e7b503 -> +37c38b2), each with its own gate and its own isolated-fixture test suite. +Nothing before this file has run ONE session through chat roundtrip -> slots +-> domain infer (3 cases) -> survey plan -> score -> WS-reconnect seed as a +SINGLE coupled lifecycle. This file exists to explicitly probe the three +named cross-phase coupling hypotheses (verbatim from the mission brief): + + H1: P1's session_state persistence interferes with P2/P3 routes in the + SAME session (stale/overwritten session_state, WS-reconnect seed + dropping fields). + H2: `clinical_escalation_required` (chat path) correctly flows into + survey/plan's `crisis_triggered` (`sessions.py:289`) and drives + `si_supplement` end-to-end, not just in isolation. + H3: the survey-plan path's caveat-strip (`sessions.py:320-322` pop) does + NOT accidentally strip anything from the chat response path, and no + proxy-caveat text leaks to any patient-facing response. + +Deliberately independent of `tests/conftest.py` (BUG-058) — same pattern as +test_phase1_session_state_persistence.py / test_phase2_domain_routing.py / +test_phase3_survey_plan.py: own throwaway `pgserver`, own `alembic +stamp 0010 -> upgrade head` (0011 upgrade() runs for real), own ASGITransport +client, own hard httpx.AsyncClient.post guard. + +Hard constraints: + - all live API keys forced to "" (BUG-052/058 — unset alone does not gate). + - zero live LLM / outbound HTTP — ai-server responses are stubbed at the + `AIClient`/`get_ai_client` boundary; only in-process ASGITransport calls + against this repo's own app are allowed through the guard. + - no DGX Postgres touched — throwaway pgserver only. +""" + +from __future__ import annotations + +import logging +import os +import tempfile +import uuid +from collections.abc import AsyncGenerator +from typing import Any + +import pytest +import pytest_asyncio + +pgserver = pytest.importorskip( + "pgserver", + reason=( + "test-only throwaway-Postgres helper not declared in " + "apps/api/pyproject.toml/uv.lock yet — skipping gracefully rather " + "than erroring the suite (mirrors test_phase1/2/3's own pattern)." + ), +) + +for _key in ( + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "UPSTAGE_API_KEY", + "HIRA_API_KEY", + "KAKAO_API_KEY", + "NS_RAG_API_KEY", + "SKT_A_X_API_KEY", +): + os.environ[_key] = "" + + +@pytest.fixture(autouse=True) +def _forbid_live_http(monkeypatch: pytest.MonkeyPatch) -> None: + """Hard guard: only in-process ASGITransport calls against our own app + are allowed through; any real network attempt fails the test loudly.""" + import httpx + + _real_post = httpx.AsyncClient.post + + async def _guarded_post(self: httpx.AsyncClient, *args: Any, **kwargs: Any) -> Any: + transport = getattr(self, "_transport", None) + if isinstance(transport, httpx.ASGITransport): + return await _real_post(self, *args, **kwargs) + raise AssertionError( + "live HTTP call attempted — this test must only use a stub " + "AIClient / dependency override, or an in-process ASGITransport" + ) + + monkeypatch.setattr(httpx.AsyncClient, "post", _guarded_post) + + +# ─────────────────────── throwaway Postgres + alembic 0010->head ────────── + + +@pytest.fixture(scope="module") +def _throwaway_postgres_url() -> Any: + pgdata = tempfile.mkdtemp(prefix="qa_integ_rev_pgdata_") + srv = pgserver.get_server(pgdata, cleanup_mode="delete") + srv.psql("CREATE DATABASE neurosync_qa_integ_rev;") + uri = ( + srv.get_uri() + .replace("postgresql://", "postgresql+asyncpg://") + .replace("/postgres?", "/neurosync_qa_integ_rev?") + ) + os.environ["DATABASE_URL"] = uri + from src.core.config import get_settings + + get_settings.cache_clear() + try: + yield uri + finally: + get_settings.cache_clear() + srv.cleanup() + + +@pytest.fixture(scope="module") +def _migrated_engine(_throwaway_postgres_url: str) -> str: + """Bootstrap to pre-0011 baseline via `Base.metadata.create_all`, stamp + 0010, then run the REAL `alembic upgrade head` (0011's own `upgrade()` + executes here) — mirrors test_phase1/2/3 verbatim. 0009a/0010 are already + baked into `Base.metadata` (models reflect head), so the create_all + + drop-then-alembic-add pattern exercises exactly the 0010->0011 seam this + mission's own success criterion names.""" + import asyncio + + import alembic.command + from alembic.config import Config + from sqlalchemy import text + from sqlalchemy.ext.asyncio import create_async_engine + + import src.models # noqa: F401 — register all tables on Base.metadata + from src.db import Base + from src.models.patient_profile import PatientProfile + + is_minor_col = PatientProfile.__table__.c.is_minor + is_minor_col.computed = None + is_minor_col.server_default = text("false") + + async def _create_pre_0011_baseline() -> None: + baseline_engine = create_async_engine(_throwaway_postgres_url, echo=False) + try: + async with baseline_engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + await conn.execute(text("ALTER TABLE sessions DROP COLUMN session_state")) + await conn.execute( + text("ALTER TABLE sessions DROP COLUMN clinical_escalation_required") + ) + finally: + await baseline_engine.dispose() + + asyncio.run(_create_pre_0011_baseline()) + + alembic_dir = os.path.join(os.path.dirname(__file__), "..", "alembic") + cfg = Config() + cfg.set_main_option("script_location", os.path.abspath(alembic_dir)) + cfg.set_main_option("sqlalchemy.url", _throwaway_postgres_url) + alembic.command.stamp(cfg, "0010") + alembic.command.upgrade(cfg, "head") # <- real 0011 upgrade() executes here + + async def _verify_columns() -> set[str]: + verify_engine = create_async_engine(_throwaway_postgres_url, echo=False) + try: + async with verify_engine.connect() as conn: + cols = await conn.execute( + text( + "SELECT column_name FROM information_schema.columns " + "WHERE table_name='sessions' " + "AND column_name IN " + "('session_state','clinical_escalation_required')" + ) + ) + return {row[0] for row in cols.fetchall()} + finally: + await verify_engine.dispose() + + found = asyncio.run(_verify_columns()) + assert found == {"session_state", "clinical_escalation_required"}, ( + f"alembic 0010->head did not add the expected Phase 1 columns: {found}" + ) + + return _throwaway_postgres_url + + +@pytest_asyncio.fixture +async def db_session(_migrated_engine: str) -> AsyncGenerator[Any, None]: + from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + from sqlalchemy.pool import NullPool + + engine = create_async_engine(_migrated_engine, echo=False, poolclass=NullPool) + session_factory = async_sessionmaker( + bind=engine, class_=AsyncSession, autoflush=False, expire_on_commit=False + ) + try: + async with session_factory() as session: + yield session + await session.rollback() + finally: + await engine.dispose() + + +@pytest_asyncio.fixture +async def seeded_session(db_session) -> Any: + """ONE patient + ONE in_progress session — the single session_id threaded + through the entire lifecycle in the test below.""" + from src.models.session import Session + from src.models.user import User + + patient = User( + id=uuid.uuid4(), + email=f"qa-integ-rev-{uuid.uuid4()}@example.test", + password_hash="not-a-real-hash", + role="patient", + ) + db_session.add(patient) + await db_session.flush() + sess = Session(id=uuid.uuid4(), patient_id=patient.id, status="in_progress") + db_session.add(sess) + await db_session.commit() + return patient, sess.id + + +# ─────────────────────────────── stubs ───────────────────────────────────── + + +class _StubChatAIClient: + """Stand-in for `AIClient.chat_respond`/`slots_extract` — no httpx.""" + + def __init__(self, responses: list[Any]) -> None: + self._responses = list(responses) + self.calls: list[Any] = [] + + async def chat_respond(self, payload: Any) -> Any: + self.calls.append(payload) + return self._responses.pop(0) + + async def slots_extract(self, payload: Any) -> Any: + from contracts.slots import SlotsExtractResponse + + return SlotsExtractResponse(extracted_slots={}) + + +def _chat_response(*, session_state: dict[str, Any], escalation: bool) -> Any: + from contracts.chat import ChatResponse + + return ChatResponse( + assistant_response="ack", + session_state=session_state, + clinical_escalation_required=escalation, + risk_level="high" if escalation else "low", + requires_human_review=escalation, + ) + + +async def _post_user_turn(db_session, session_id, settings, text: str) -> None: + from src.core.encryption import encrypt_str + from src.models.session import Message + + message_id = uuid.uuid4() + db_session.add( + Message( + id=message_id, + session_id=session_id, + role="user", + content_encrypted=encrypt_str( + text, + aad=f"messages.content:{session_id}:{message_id}".encode(), + settings=settings, + ), + input_modality="text", + ) + ) + await db_session.flush() + + +def _make_app_with_overrides(db_session, patient, ai_client_stub: Any) -> Any: + from src.core.deps import get_current_user + from src.db import get_session + from src.main import create_app + from src.services.ai_client import get_ai_client + + async def _override_get_session(): + yield db_session + + async def _override_get_current_user(): + return patient + + def _override_get_ai_client(): + return ai_client_stub + + app = create_app() + app.dependency_overrides[get_session] = _override_get_session + app.dependency_overrides[get_current_user] = _override_get_current_user + app.dependency_overrides[get_ai_client] = _override_get_ai_client + return app + + +class _RecordingDomainAIClient: + """Route-level `get_ai_client` override exposing `domain_infer` + + `survey_plan` — records every `survey_plan` request so the test can + assert `crisis_triggered` actually arrived (H2), and mirrors real + ai-server's own `si_supplement` = f(crisis_triggered) resolution + (`apps/ai-server/src/f3.py::resolve_si_supplement_needed`, simplified: + a crisis-triggered proxy-scale routing supplements SI) so the coupling + is exercised, not just echoed.""" + + def __init__(self, domain_response: Any) -> None: + self._domain_response = domain_response + self.domain_infer_calls: list[Any] = [] + self.survey_plan_calls: list[Any] = [] + + async def domain_infer(self, payload: Any) -> Any: + self.domain_infer_calls.append(payload) + return self._domain_response + + async def survey_plan(self, payload: Any) -> Any: + from contracts.survey_plan import SurveyPlanResponse + + self.survey_plan_calls.append(payload) + # Simplified mirror of ai-server's real si_supplement logic: a + # crisis_triggered session administering a PROXY (caveat-bearing) + # scale gets the SI-supplement item; native scales / non-crisis + # sessions do not. This is intentionally NOT a tautology against + # `payload.crisis_triggered` alone — it also requires a caveat + # (proxy-scale) to be present, matching CVR-030's real condition. + si_needed = bool(payload.crisis_triggered) and payload.recommendation_caveat is not None + return SurveyPlanResponse( + scale=payload.recommended_questionnaire, + administration_mode="si_supplement-plan" if si_needed else "natural", + si_supplement=si_needed, + recommendation_caveat=payload.recommendation_caveat, + ) + + +class _StubScoreAIClient: + """Stand-in for `AIClient.survey_score` — always the AI-success path, + with values structurally unreachable by the local-fallback path + (mirrors test_phase3_survey_score_smoke.py's non-tautology design).""" + + def __init__(self, *, total_score: int, max_score: int, severity: str, critical: bool) -> None: + self._total_score = total_score + self._max_score = max_score + self._severity = severity + self._critical = critical + self.called = False + + async def survey_score(self, payload: Any) -> Any: + from contracts.survey import SurveyScoreResponse + + self.called = True + return SurveyScoreResponse( + scale_name=payload.scale_name, + total_score=self._total_score, + max_score=self._max_score, + severity=self._severity, + critical_item_positive=self._critical, + ) + + +# ───────────────────────── the single lifecycle test ─────────────────────── + + +@pytest.mark.asyncio +async def test_full_session_lifecycle_cross_phase_coupling( + db_session, seeded_session, caplog +) -> None: + from sqlalchemy import select + + from src.core.config import Settings + from src.models.audit_log import AuditLog + from src.models.session import Session + from src.services.chat import respond as chat_respond + from src.services.questionnaire import score_with_ai + + patient, session_id = seeded_session + settings = Settings() + + # ── Step 1: chat turn 1 — no escalation, session_state seeded ───────── + await _post_user_turn(db_session, session_id, settings, "안녕하세요") + turn1_state = { + "asked_slot_counts": {"chief_complaint": 1}, + "risk_screening_incomplete": True, + "clinical_escalation_required": False, + "handoff_delivered": False, + } + ai1 = _StubChatAIClient([_chat_response(session_state=turn1_state, escalation=False)]) + payload1 = await chat_respond( + db_session, ai_client=ai1, session_id=session_id, settings=settings, session_state=None + ) + await db_session.commit() + assert payload1["sessionState"] == turn1_state + + # ── Step 2: chat turn 2 — escalation fires (ADR-044 4 fields) ────────── + await _post_user_turn(db_session, session_id, settings, "죽고 싶다는 생각이 계속 들어요") + turn2_state = { + "asked_slot_counts": {"chief_complaint": 1, "onset": 1}, + "risk_screening_incomplete": True, + "clinical_escalation_required": True, + "handoff_delivered": False, + } + ai2 = _StubChatAIClient([_chat_response(session_state=turn2_state, escalation=True)]) + payload2 = await chat_respond( + db_session, + ai_client=ai2, + session_id=session_id, + settings=settings, + session_state=payload1["sessionState"], + ) + await db_session.commit() + assert payload2["clinicalEscalationRequired"] is True + + row = (await db_session.execute(select(Session).where(Session.id == session_id))).scalar_one() + assert row.session_state == turn2_state + assert row.clinical_escalation_required is True + for field in ( + "asked_slot_counts", + "risk_screening_incomplete", + "clinical_escalation_required", + "handoff_delivered", + ): + assert field in row.session_state, f"ADR-044 field {field!r} missing after persist" + + # H3 (partial, chat side): the chat response payload never carries any + # caveat-shaped key — the strip logic in the survey-plan path has + # nothing to accidentally strip here because caveats never touch chat. + assert not any("caveat" in k.lower() for k in payload2), ( + "chat response payload unexpectedly carries a caveat-shaped field " + f"— got keys {list(payload2)}" + ) + + # ── Step 3: domain infer — panic (proxy, crisis session) ─────────────── + from contracts.domain import DomainCandidate, DomainInferResponse + from httpx import ASGITransport, AsyncClient + + panic_stub = _RecordingDomainAIClient( + DomainInferResponse(domain_candidates=[DomainCandidate(domain="panic", confidence=0.8)]) + ) + app = _make_app_with_overrides(db_session, patient, panic_stub) + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp_panic = await client.post( + f"/api/v1/sessions/{session_id}/domain/infer", + headers={"Authorization": "Bearer irrelevant-override-bypasses-decode"}, + ) + assert resp_panic.status_code == 200, resp_panic.text + body_panic = resp_panic.json() + assert body_panic["data"]["instrument"] == "GAD7" + plan_panic = body_panic["data"]["plan"] + assert "recommendation_caveat" not in plan_panic, ( + "H3 violated: proxy caveat leaked into the patient-facing plan response" + ) + + # H2: escalation (True, from Step 2) must have flowed into the + # survey/plan request's crisis_triggered, and driven si_supplement. + assert len(panic_stub.survey_plan_calls) == 1 + sent = panic_stub.survey_plan_calls[0] + assert sent.crisis_triggered is True, ( + "H2 violated: clinical_escalation_required=True on the session did " + "not flow into SurveyPlanRequest.crisis_triggered" + ) + assert plan_panic["si_supplement"] is True, ( + "H2 violated: crisis_triggered=True + proxy-scale routing did not " + "drive si_supplement=True end-to-end" + ) + + # caveat persisted to audit_logs (clinician-facing), never patient response. + audit_rows = ( + await db_session.execute(select(AuditLog).where(AuditLog.resource_id == session_id)) + ).scalars().all() + caveat_rows = [r for r in audit_rows if r.action == "survey.plan.proxy_caveat"] + assert len(caveat_rows) == 1 + caveat_metadata = caveat_rows[0].audit_metadata + assert "GAD-7" in caveat_metadata["caveat"] or caveat_metadata["scale"] == "GAD-7" + + # H1: domain/infer must NOT have touched session_state at all. + row_after_panic = ( + await db_session.execute(select(Session).where(Session.id == session_id)) + ).scalar_one() + assert row_after_panic.session_state == turn2_state, ( + "H1 violated: domain/infer route interfered with the chat-persisted " + "session_state" + ) + + # ── Step 4: domain infer — substance (proxy, second proxy check) ────── + substance_stub = _RecordingDomainAIClient( + DomainInferResponse( + domain_candidates=[DomainCandidate(domain="substance", confidence=0.75)] + ) + ) + app2 = _make_app_with_overrides(db_session, patient, substance_stub) + transport2 = ASGITransport(app=app2) + async with AsyncClient(transport=transport2, base_url="http://test") as client: + resp_sub = await client.post( + f"/api/v1/sessions/{session_id}/domain/infer", + headers={"Authorization": "Bearer irrelevant-override-bypasses-decode"}, + ) + assert resp_sub.status_code == 200, resp_sub.text + body_sub = resp_sub.json() + assert body_sub["data"]["instrument"] == "AUDITC" + plan_sub = body_sub["data"]["plan"] + assert "recommendation_caveat" not in plan_sub + assert substance_stub.survey_plan_calls[0].crisis_triggered is True + audit_rows_sub = ( + await db_session.execute(select(AuditLog).where(AuditLog.resource_id == session_id)) + ).scalars().all() + caveat_rows_sub = [r for r in audit_rows_sub if r.action == "survey.plan.proxy_caveat"] + assert len(caveat_rows_sub) == 2, "expected a SECOND independent proxy-caveat audit row" + + # ── Step 5: domain infer — general/non-proxy (depression -> PHQ9, no caveat) ── + general_stub = _RecordingDomainAIClient( + DomainInferResponse( + domain_candidates=[DomainCandidate(domain="depression", confidence=0.7)] + ) + ) + app3 = _make_app_with_overrides(db_session, patient, general_stub) + transport3 = ASGITransport(app=app3) + async with AsyncClient(transport=transport3, base_url="http://test") as client: + resp_gen = await client.post( + f"/api/v1/sessions/{session_id}/domain/infer", + headers={"Authorization": "Bearer irrelevant-override-bypasses-decode"}, + ) + assert resp_gen.status_code == 200, resp_gen.text + body_gen = resp_gen.json() + assert body_gen["data"]["instrument"] == "PHQ9" + plan_gen = body_gen["data"]["plan"] + assert plan_gen["si_supplement"] is False, ( + "native (non-proxy) routing must not get si_supplement even under " + "crisis_triggered=True — CVR-030's own precondition (proxy AND crisis)" + ) + audit_rows_gen = ( + await db_session.execute(select(AuditLog).where(AuditLog.resource_id == session_id)) + ).scalars().all() + caveat_rows_gen = [r for r in audit_rows_gen if r.action == "survey.plan.proxy_caveat"] + assert len(caveat_rows_gen) == 2, ( + "native (non-proxy) domain infer must NOT add a proxy_caveat audit row" + ) + + # H1 (again, after 3 domain/infer round trips): session_state still intact. + row_final = ( + await db_session.execute(select(Session).where(Session.id == session_id)) + ).scalar_one() + assert row_final.session_state == turn2_state, ( + "H1 violated: repeated domain/infer calls interfered with the " + "chat-persisted session_state across the session's lifecycle" + ) + + # ── Step 6: score — canonical ai-server result used, zero local fallback ── + with caplog.at_level(logging.WARNING, logger="src.services.questionnaire"): + score_stub = _StubScoreAIClient( + total_score=15, max_score=21, severity="severe", critical=True + ) + total, severity, critical = await score_with_ai( + "GAD7", [3, 3, 3, 3, 1, 1, 1], ai_client=score_stub, session_id=session_id + ) + assert score_stub.called is True + assert (total, severity, critical) == (15, "severe", True), ( + "score_with_ai did not return the ai-server canonical result" + ) + fallback_logs = [ + r for r in caplog.records if "fell back to local cutoffs" in r.message + ] + assert len(fallback_logs) == 0, "unexpected local-cutoff fallback fired for an AI-success call" + + # ── Step 7: WS-reconnect reseed — verify DB-persisted session_state + # (ADR-044 4 fields intact) re-seeds a fresh connection exactly. ──────── + reconnect_row = await db_session.execute( + select(Session.patient_id, Session.session_state).where(Session.id == session_id) + ) + reconnect_result = reconnect_row.one_or_none() + assert reconnect_result is not None + reloaded_state = reconnect_result[1] + assert reloaded_state == turn2_state, ( + "WS-reconnect reseed query did not reload the exact last-persisted " + "session_state — reconnect would silently drop fields or restart None" + ) + for field in ( + "asked_slot_counts", + "risk_screening_incomplete", + "clinical_escalation_required", + "handoff_delivered", + ): + assert field in reloaded_state, ( + f"WS-reconnect reseed dropped ADR-044 field {field!r}" + ) diff --git a/apps/api/tests/test_phase1_session_state_persistence.py b/apps/api/tests/test_phase1_session_state_persistence.py new file mode 100644 index 0000000..8add7bb --- /dev/null +++ b/apps/api/tests/test_phase1_session_state_persistence.py @@ -0,0 +1,511 @@ +"""Committed regression test for Phase 1 session_state / clinical_escalation +persistence — REV-008 (critic) resolution #1. + +REV-008's major finding: the Phase 1 pass (session_state turn-to-turn + +WS-reconnect persistence, `clinical_escalation_required` consumer) was +verified against an *ephemeral* throwaway Postgres with no committed +artifact — nothing in the repo could reproduce that claim. This file is +the durable, re-runnable replacement. + +Deliberately independent of `tests/conftest.py`: BUG-057 documents two +defects in that file's fixtures (the `lambda: _yield(db_session)` +dependency-override pattern, and `event_loop` session-scoping vs +`pytest-asyncio==1.4.0`) that are unrelated to `src/` and out of this +test's scope to fix. This module does not import or request any +`conftest.py` fixture — it builds its own throwaway Postgres +(`pgserver`, embeddable, no-root, DGX never touched) and its own engine +directly, then exercises `src/services/chat.py::respond` (the actual +Phase 1 production function) with a stub `AIClient` (no `httpx`, no +live LLM call) plus the exact reconnect-reload query +`src/api/v1/sessions.py::session_chat` uses. + +Covers PRD Phase 1 completion criteria (`docs/ai/integration_prd_f1f3_hospital.md` +§4) via code path, not claim: + 1. real `alembic` 0010 -> head (0011) migration applied to a throwaway DB + (`alembic/versions/0011_session_state_persistence.py`'s own + `upgrade()`, invoked through `alembic.command`, not re-implemented). + 2. `session_state` (carrying the ADR-044 4 fields nested under their own + keys) persists turn-to-turn on `Session.session_state` + (`chat.py:243-244`). + 3. the WS-reconnect reseed query in `sessions.py:551-561` + (`select(Session.patient_id, Session.session_state)...`) reloads the + last-persisted value — reproduced verbatim here rather than only + asserting the column value. + 4. `clinical_escalation_required=True` fires both the DB column + (`chat.py:244`) and the structured log consumer + (`chat.py:252-260`, `chat.clinical_escalation.flagged`). + +Hard constraints (unchanged from the ephemeral pass this replaces): + - all live API keys forced to "" (BUG-052 — unset alone does not gate). + - zero live LLM / outbound HTTP: `AIClient.chat_respond` is replaced by an + in-process stub; `httpx.AsyncClient.post` is monkeypatched to raise if + anything ever reaches it, as a hard guard rather than an assumption. + - no DGX Postgres (223.194.33.26:28881) touched — `pgserver` throwaway + instance only, torn down at test end. + +Test-only dependency note: `pgserver` is NOT declared in +`apps/api/pyproject.toml` / `uv.lock` (qa scope excludes editing the +dependency manifest — filemanager's call). If it is not importable in the +environment this runs in, the whole module is skipped rather than erroring +(`pytest.importorskip` below) — see this file's regression-test entry in +error.md BUG-057 resolution / REV-008 for the "needs declaring" note. +""" + +from __future__ import annotations + +import os +import tempfile +import uuid +from collections.abc import AsyncGenerator +from typing import Any + +import pytest +import pytest_asyncio + +pgserver = pytest.importorskip( + "pgserver", + reason=( + "test-only throwaway-Postgres helper not declared in " + "apps/api/pyproject.toml/uv.lock yet (qa does not edit the " + "dependency manifest — see REV-008 resolution note in error.md); " + "skipping gracefully rather than erroring the suite." + ), +) + +# All live keys forced empty BEFORE any src.* import touches Settings() +# (BUG-052: unset alone does not gate live-only paths; only "" does). +for _key in ( + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "UPSTAGE_API_KEY", + "SKT_A_X_API_KEY", +): + os.environ[_key] = "" + + +@pytest.fixture(scope="module") +def _throwaway_postgres_url() -> Any: + """Self-contained throwaway Postgres — no conftest, no DGX. + + `cleanup_mode="delete"` removes the pgdata dir on `.cleanup()`; the + tmpdir itself is also independently removed to leave zero artifacts. + """ + pgdata = tempfile.mkdtemp(prefix="qa_phase1_pgdata_") + srv = pgserver.get_server(pgdata, cleanup_mode="delete") + srv.psql("CREATE DATABASE neurosync_qa_phase1;") + uri = ( + srv.get_uri() + .replace("postgresql://", "postgresql+asyncpg://") + .replace("/postgres?", "/neurosync_qa_phase1?") + ) + os.environ["DATABASE_URL"] = uri + # `src.core.config.get_settings` is `@lru_cache`d — some other test + # module's collection-time import may have already memoized a + # `Settings()` built before this env var was set (e.g. the default + # `localhost:5432` URL). Clear it here so `alembic/env.py`'s own + # `get_settings().database_url` read picks up THIS throwaway DB + # instead of silently reusing a stale cached instance. + from src.core.config import get_settings + + get_settings.cache_clear() + try: + yield uri + finally: + get_settings.cache_clear() + srv.cleanup() + + +@pytest.fixture(scope="module") +def _migrated_engine(_throwaway_postgres_url: str): + """Bootstrap schema up to (equivalent of) revision 0010, then run the + REAL `alembic upgrade head` — i.e. actually execute + `0011_session_state_persistence.py`'s `upgrade()` against Postgres, + not a re-implementation of it. + + Deliberately a *synchronous* fixture: `alembic/env.py` calls + `asyncio.run(...)` internally, which raises `RuntimeError: asyncio.run() + cannot be called from a running event loop` if invoked from inside an + `async def` fixture under pytest-asyncio. Running the baseline-schema + setup and the alembic commands here (outside any event loop) sidesteps + that entirely — this file makes zero use of `conftest.py`'s + session-scoped `event_loop` fixture (BUG-057 root cause 2). + + `Base.metadata.create_all` (rather than replaying migrations 0001-0010 + one at a time) is used only to reach the pre-0011 baseline quickly; + this mirrors the same TEST-ONLY workaround `tests/conftest.py` already + uses for the *unrelated*, pre-existing `patient_profiles.is_minor` + generated-column-immutability defect (migration 0001, PG rejects + CURRENT_DATE in a STORED generated column) — not something this pass + introduces or is scoped to fix. The two Phase 1 columns are then + dropped to reproduce the exact pre-0011 shape before alembic adds them + back for real. + """ + import asyncio + + import alembic.command + from alembic.config import Config + from sqlalchemy import text + from sqlalchemy.ext.asyncio import create_async_engine + + import src.models # noqa: F401 — register all tables on Base.metadata + from src.db import Base + from src.models.patient_profile import PatientProfile + + is_minor_col = PatientProfile.__table__.c.is_minor + is_minor_col.computed = None + is_minor_col.server_default = text("false") + + async def _create_pre_0011_baseline() -> None: + baseline_engine = create_async_engine(_throwaway_postgres_url, echo=False) + try: + async with baseline_engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + await conn.execute( + text("ALTER TABLE sessions DROP COLUMN session_state") + ) + await conn.execute( + text( + "ALTER TABLE sessions DROP COLUMN clinical_escalation_required" + ) + ) + finally: + await baseline_engine.dispose() + + asyncio.run(_create_pre_0011_baseline()) + + alembic_dir = os.path.join(os.path.dirname(__file__), "..", "alembic") + cfg = Config() + cfg.set_main_option("script_location", os.path.abspath(alembic_dir)) + cfg.set_main_option("sqlalchemy.url", _throwaway_postgres_url) + alembic.command.stamp(cfg, "0010") + alembic.command.upgrade(cfg, "head") # <- real 0011 upgrade() executes here + + async def _verify_columns() -> set[str]: + verify_engine = create_async_engine(_throwaway_postgres_url, echo=False) + try: + async with verify_engine.connect() as conn: + cols = await conn.execute( + text( + "SELECT column_name FROM information_schema.columns " + "WHERE table_name='sessions' " + "AND column_name IN " + "('session_state','clinical_escalation_required')" + ) + ) + return {row[0] for row in cols.fetchall()} + finally: + await verify_engine.dispose() + + found = asyncio.run(_verify_columns()) + assert found == {"session_state", "clinical_escalation_required"}, ( + f"alembic 0010->head did not add the expected Phase 1 columns: {found}" + ) + + # Yield the URL, not a bound `AsyncEngine` — pytest-asyncio's DEFAULT + # loop scope is per-TEST-FUNCTION, so a single module-scoped engine's + # pooled asyncpg connections get created inside test A's event loop and + # then handed to test B's *different* event loop, producing exactly + # the `InterfaceError: another operation is in progress` cross-loop + # corruption BUG-057 documents for `conftest.py`'s own session-scoped + # `event_loop` fixture. `db_session` below builds a fresh, `NullPool` + # engine per test instead (no connection survives across a test + # boundary), sidestepping that class of bug entirely rather than + # relying on the same pattern that broke `conftest.py`. + return _throwaway_postgres_url + + +@pytest_asyncio.fixture +async def db_session(_migrated_engine: str) -> AsyncGenerator[Any, None]: + from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + from sqlalchemy.pool import NullPool + + # `NullPool`: a brand-new asyncpg connection per checkout, discarded + # (not pooled) at checkin — guarantees no connection outlives this + # test's own event loop. + engine = create_async_engine(_migrated_engine, echo=False, poolclass=NullPool) + session_factory = async_sessionmaker( + bind=engine, class_=AsyncSession, autoflush=False, expire_on_commit=False + ) + try: + async with session_factory() as session: + yield session + await session.rollback() + finally: + await engine.dispose() + + +@pytest_asyncio.fixture +async def seeded_session(db_session) -> Any: + """A minimal patient + in-progress session row — the FK graph + `Session.patient_id -> users.id` requires a real user row.""" + from src.models.session import Session + from src.models.user import User + + patient = User( + id=uuid.uuid4(), + email=f"qa-phase1-{uuid.uuid4()}@example.test", + password_hash="not-a-real-hash", + role="patient", + ) + db_session.add(patient) + await db_session.flush() # patient row must exist before the FK-dependent insert + sess = Session(id=uuid.uuid4(), patient_id=patient.id, status="in_progress") + db_session.add(sess) + await db_session.commit() + return sess.id + + +class _StubAIClient: + """In-process stand-in for `src.services.ai_client.AIClient` — no + `httpx`, no live LLM. `chat_respond` returns a scripted `ChatResponse` + per call so each simulated turn can carry its own + `clinical_escalation_required`/`session_state`.""" + + def __init__(self, responses: list[Any]) -> None: + self._responses = list(responses) + self.calls: list[Any] = [] + + async def chat_respond(self, payload: Any) -> Any: + self.calls.append(payload) + return self._responses.pop(0) + + async def slots_extract(self, payload: Any) -> Any: + from contracts.slots import SlotsExtractResponse + + return SlotsExtractResponse(extracted_slots={}) + + +def _chat_response(*, session_state: dict[str, Any], escalation: bool) -> Any: + from contracts.chat import ChatResponse + + return ChatResponse( + assistant_response="ack", + session_state=session_state, + clinical_escalation_required=escalation, + risk_level="high" if escalation else "low", + requires_human_review=escalation, + ) + + +@pytest.fixture(autouse=True) +def _forbid_live_http(monkeypatch: pytest.MonkeyPatch) -> None: + """Hard guard, not an assumption: any attempt to actually reach the + network via httpx (i.e. the real `AIClient`) fails the test loudly + instead of silently succeeding against a live/mocked server.""" + import httpx + + async def _forbidden(*args: Any, **kwargs: Any) -> Any: + raise AssertionError( + "live HTTP call attempted — this test must only use _StubAIClient" + ) + + monkeypatch.setattr(httpx.AsyncClient, "post", _forbidden) + + +async def _post_user_turn(db_session, session_id, settings, text: str) -> None: + """Mirrors `chat.py::_message_aad` exactly — the real code decrypts with + `aad=f"messages.content:{session_id}:{message_id}".encode()` + (`chat.py:47-48`); a mismatched AAD here would make `_recent_messages` + silently skip the row as undecryptable (its own documented best-effort + behavior) and falsely look like a Phase 1 wiring bug.""" + from src.core.encryption import encrypt_str + from src.models.session import Message + + message_id = uuid.uuid4() + db_session.add( + Message( + id=message_id, + session_id=session_id, + role="user", + content_encrypted=encrypt_str( + text, + aad=f"messages.content:{session_id}:{message_id}".encode(), + settings=settings, + ), + input_modality="text", + ) + ) + await db_session.flush() + + +@pytest.mark.asyncio +async def test_session_state_persists_turn_to_turn(db_session, seeded_session): + """PRD §4 Phase1 / ADR-046 #2: `Session.session_state` round-trips and + accumulates across two consecutive turns via `chat.py::respond` — the + actual production function, not a re-implementation.""" + from src.core.config import Settings + from src.services.chat import respond as chat_respond + + session_id = seeded_session + settings = Settings() + + # Turn 1 — no prior session_state (None on first turn, per docstring). + await _post_user_turn(db_session, session_id, settings, "hello") + turn1_state = { + "asked_slot_counts": {"chief_complaint": 1}, + "risk_screening_incomplete": True, + "clinical_escalation_required": False, + "handoff_delivered": False, + } + ai_client = _StubAIClient( + [_chat_response(session_state=turn1_state, escalation=False)] + ) + payload1 = await chat_respond( + db_session, + ai_client=ai_client, + session_id=session_id, + settings=settings, + session_state=None, + ) + await db_session.commit() + + assert payload1 is not None + assert payload1["sessionState"] == turn1_state + + from sqlalchemy import select + + from src.models.session import Session + + row = await db_session.execute(select(Session).where(Session.id == session_id)) + sess = row.scalar_one() + assert sess.session_state == turn1_state + assert sess.clinical_escalation_required is False + + # Turn 2 — caller threads turn 1's returned sessionState back in + # (mirrors `sessions.py::_handle_message`'s `next_session_state`). + await _post_user_turn(db_session, session_id, settings, "still here") + turn2_state = { + "asked_slot_counts": {"chief_complaint": 1, "onset": 1}, + "risk_screening_incomplete": False, + "clinical_escalation_required": False, + "handoff_delivered": False, + } + ai_client2 = _StubAIClient( + [_chat_response(session_state=turn2_state, escalation=False)] + ) + payload2 = await chat_respond( + db_session, + ai_client=ai_client2, + session_id=session_id, + settings=settings, + session_state=payload1["sessionState"], + ) + await db_session.commit() + + assert payload2 is not None + # The turn-2 request carried turn-1's state forward (round-trip channel). + assert ai_client2.calls[0].session_state == turn1_state + + row2 = await db_session.execute(select(Session).where(Session.id == session_id)) + sess2 = row2.scalar_one() + assert sess2.session_state == turn2_state, ( + "session_state did not persist/update turn-to-turn on Session.session_state" + ) + + +@pytest.mark.asyncio +async def test_session_state_survives_ws_reconnect_reseed_query( + db_session, seeded_session +): + """Reproduces `src/api/v1/sessions.py::session_chat`'s exact reconnect + reseed query (`select(Session.patient_id, Session.session_state)`, + sessions.py:551-561) against a session whose state was persisted by a + PRIOR (now-closed) connection, proving the round-trip survives a + reconnect rather than only a single open WS connection.""" + from sqlalchemy import select + + from src.models.session import Session + + session_id = seeded_session + persisted_state = { + "asked_slot_counts": {"chief_complaint": 2}, + "risk_screening_incomplete": False, + "clinical_escalation_required": False, + "handoff_delivered": True, + } + + # Simulate the FIRST connection's last-persisted write (what + # chat.py::respond would have committed before this connection closed). + row = await db_session.execute(select(Session).where(Session.id == session_id)) + sess = row.scalar_one() + sess.session_state = persisted_state + await db_session.commit() + + # Simulate a WS reconnect: a fresh query, exactly as + # `sessions.py::session_chat` runs on `ws.accept()`, with no in-memory + # state carried over from the prior connection. + reconnect_row = await db_session.execute( + select(Session.patient_id, Session.session_state).where( + Session.id == session_id + ) + ) + reconnect_result = reconnect_row.one_or_none() + assert reconnect_result is not None + reloaded_session_state = reconnect_result[1] + + assert reloaded_session_state == persisted_state, ( + "WS-reconnect reseed query did not reload the last-persisted " + "session_state — reconnect would incorrectly restart from None" + ) + + +@pytest.mark.asyncio +async def test_clinical_escalation_required_fires_log_and_db_column( + db_session, seeded_session, caplog +): + """CVR-047 recommendation 3 minimal consumer: `clinical_escalation_required + == True` must (a) persist on `Session.clinical_escalation_required` and + (b) emit the structured `chat.clinical_escalation.flagged` warning log + (`chat.py:252-260`) — both from one real `respond()` call, not asserted + separately/aspirationally.""" + import logging + + from sqlalchemy import select + + from src.core.config import Settings + from src.models.session import Session + from src.services.chat import respond as chat_respond + + session_id = seeded_session + settings = Settings() + + await _post_user_turn(db_session, session_id, settings, "I want to hurt myself") + + escalation_state = { + "asked_slot_counts": {}, + "risk_screening_incomplete": True, + "clinical_escalation_required": True, + "handoff_delivered": False, + } + ai_client = _StubAIClient( + [_chat_response(session_state=escalation_state, escalation=True)] + ) + + with caplog.at_level(logging.WARNING, logger="src.services.chat"): + payload = await chat_respond( + db_session, + ai_client=ai_client, + session_id=session_id, + settings=settings, + session_state=None, + ) + await db_session.commit() + + assert payload is not None + assert payload["clinicalEscalationRequired"] is True + + row = await db_session.execute(select(Session).where(Session.id == session_id)) + sess = row.scalar_one() + assert sess.clinical_escalation_required is True, ( + "clinical_escalation_required=True from ai-server did not persist " + "onto Session.clinical_escalation_required" + ) + + flagged = [r for r in caplog.records if r.message == "chat.clinical_escalation.flagged"] + assert len(flagged) == 1, ( + "expected exactly one chat.clinical_escalation.flagged log record " + f"when clinical_escalation_required=True, got {len(flagged)}" + ) + assert flagged[0].session_id == str(session_id) + assert flagged[0].risk_level == "high" + assert flagged[0].requires_human_review is True diff --git a/apps/api/tests/test_phase2_domain_routing.py b/apps/api/tests/test_phase2_domain_routing.py new file mode 100644 index 0000000..a0fa3ec --- /dev/null +++ b/apps/api/tests/test_phase2_domain_routing.py @@ -0,0 +1,476 @@ +"""Committed regression test for Phase 2 domain routing — PRD §4/§5.4 +(`docs/ai/integration_prd_f1f3_hospital.md`) completion criteria, and the +panic->GAD7 fix (`_DOMAIN_TO_INSTRUMENT["panic"] = "GAD7"`, +ADR-046 #2 / contract4 fix). + +Three layers, offline/mocked throughout: + 1. Unit decision surface — `src.services.domain_routing.infer_instrument` + exercised directly for all 9 `DomainName` values plus the fallback + edge cases (below-confidence, no-candidate, empty-turns, ai-server + exception). + 2. Integration path — the real `POST /sessions/{id}/domain/infer` route + (`src/api/v1/sessions.py::infer_domain`), wired through `create_app()`, + with `get_ai_client` mocked (no live HTTP) and DB/auth dependencies + overridden against a throwaway Postgres. + 3. VP-004-style panic scenario reproduction — a panic-shaped top1 + candidate (as VP-004's `fluctuating_panic_recurrence` arc would + naturally elicit) routed end-to-end through the same integration path, + confirming it lands on GAD7 and not the pre-fix PHQ4 fallback. + +Deliberately independent of `tests/conftest.py` (BUG-058: the sync +`starlette.TestClient` + anyio-portal cross-loop defect in that fixture set +makes HTTP-surface tests unreliable there) — this module builds its own +throwaway Postgres (`pgserver`, no-root, DGX never touched) and uses +`httpx.AsyncClient(transport=ASGITransport(app=app))` for the one test that +needs to hit the real ASGI route, exactly the pattern +`test_phase1_session_state_persistence.py` established. + +Hard constraints: + - all live API keys forced to "" (BUG-052/058 — unset alone does not gate; + only "" reliably disables live-only paths). + - zero live LLM / outbound HTTP: `AIClient` is replaced everywhere by an + in-process stub (unit layer) or a `get_ai_client` dependency override + returning a stub (integration layer); `httpx.AsyncClient.post` is + monkeypatched to raise if anything ever reaches it, as a hard guard. + - no DGX Postgres (223.194.33.26:28881) touched — `pgserver` throwaway + instance only, torn down at test end. +""" + +from __future__ import annotations + +import os +import tempfile +import uuid +from collections.abc import AsyncGenerator +from typing import Any + +import pytest +import pytest_asyncio + +pgserver = pytest.importorskip( + "pgserver", + reason=( + "test-only throwaway-Postgres helper not declared in " + "apps/api/pyproject.toml/uv.lock yet (qa does not edit the " + "dependency manifest — see REV-008 resolution note in error.md); " + "skipping gracefully rather than erroring the suite." + ), +) + +# All live keys forced empty BEFORE any src.* import touches Settings() +# (BUG-052/058: unset alone does not gate live-only paths; only "" does). +for _key in ( + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "UPSTAGE_API_KEY", + "HIRA_API_KEY", + "KAKAO_API_KEY", + "NS_RAG_API_KEY", + "SKT_A_X_API_KEY", +): + os.environ[_key] = "" + + +@pytest.fixture(autouse=True) +def _forbid_live_http(monkeypatch: pytest.MonkeyPatch) -> None: + """Hard guard: any attempt to actually reach the network via httpx (the + real `AIClient`'s transport, which defaults to a real socket transport) + fails the test loudly instead of silently succeeding against a + live/mocked server. In-process ASGI-transport calls (the httpx test + client driving `ASGITransport(app=app)` against our own app object, used + by the integration-layer tests below) are explicitly allowed through — + those never leave the process.""" + import httpx + + _real_post = httpx.AsyncClient.post + + async def _guarded_post(self: httpx.AsyncClient, *args: Any, **kwargs: Any) -> Any: + transport = getattr(self, "_transport", None) + if isinstance(transport, httpx.ASGITransport): + return await _real_post(self, *args, **kwargs) + raise AssertionError( + "live HTTP call attempted — this test must only use a stub " + "AIClient / dependency override, or an in-process ASGITransport" + ) + + monkeypatch.setattr(httpx.AsyncClient, "post", _guarded_post) + + +# ─────────────────────────── Layer 1: unit decision surface ─────────────── + +# The 9-domain decision table (PRD §4/§5.4 P2-2). Domains with no dedicated +# scale (substance/trauma/sleep/psychosis/other) fall to FALLBACK_INSTRUMENT. +DOMAIN_TO_EXPECTED_INSTRUMENT: dict[str, str] = { + "anxiety": "GAD7", + "depression": "PHQ9", + "alcohol": "AUDITC", + # PLAN-2026-W30-INTEG P3-1(b): mirrors ai-server canonical + # DOMAIN_TO_SCALE["substance"] == "AUDIT-C" (previously PHQ4 fallback gap, + # CVR-049 register #3). + "substance": "AUDITC", + "trauma": "PHQ4", + "sleep": "PHQ4", + "psychosis": "PHQ4", + "other": "PHQ4", + "panic": "GAD7", # ADR-046 #2 fix under regression test here +} + + +class _StubDomainAIClient: + """Duck-typed stand-in for `AIClient` exposing only `domain_infer` — no + `httpx`, no live call.""" + + def __init__(self, *, response: Any = None, exc: Exception | None = None) -> None: + self._response = response + self._exc = exc + self.called = False + + async def domain_infer(self, payload: Any) -> Any: + self.called = True + if self._exc is not None: + raise self._exc + return self._response + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "domain,expected_instrument", + sorted(DOMAIN_TO_EXPECTED_INSTRUMENT.items()), +) +async def test_domain_decision_table(domain: str, expected_instrument: str) -> None: + """PRD §5.4 P2-2 — 9-domain top1 candidate -> resolved instrument.""" + from contracts.domain import DomainCandidate, DomainInferResponse + + from src.services.domain_routing import infer_instrument + + response = DomainInferResponse( + domain_candidates=[DomainCandidate(domain=domain, confidence=0.9)] + ) + client = _StubDomainAIClient(response=response) + + result = await infer_instrument( + ai_client=client, + session_id=uuid.uuid4(), + turns=[(1, "관련 발화")], + ) + + assert result == expected_instrument, ( + f"domain={domain!r} expected -> {expected_instrument}, got {result}" + ) + assert client.called, "ai_client.domain_infer was not invoked" + + +@pytest.mark.asyncio +async def test_below_confidence_falls_back_to_phq4() -> None: + """Candidate present but below MIN_CONFIDENCE (0.35) -> PHQ4.""" + from contracts.domain import DomainCandidate, DomainInferResponse + + from src.services.domain_routing import infer_instrument + + response = DomainInferResponse( + domain_candidates=[DomainCandidate(domain="panic", confidence=0.10)] + ) + client = _StubDomainAIClient(response=response) + + result = await infer_instrument( + ai_client=client, session_id=uuid.uuid4(), turns=[(1, "hi")] + ) + assert result == "PHQ4" + + +@pytest.mark.asyncio +async def test_no_candidate_falls_back_to_phq4() -> None: + """Empty domain_candidates list -> PHQ4.""" + from contracts.domain import DomainInferResponse + + from src.services.domain_routing import infer_instrument + + client = _StubDomainAIClient(response=DomainInferResponse(domain_candidates=[])) + + result = await infer_instrument( + ai_client=client, session_id=uuid.uuid4(), turns=[(1, "hi")] + ) + assert result == "PHQ4" + + +@pytest.mark.asyncio +async def test_empty_turns_falls_back_to_phq4_without_calling_ai_client() -> None: + """No utterances -> immediate PHQ4 fallback, ai_client never touched.""" + from src.services.domain_routing import infer_instrument + + client = _StubDomainAIClient(exc=AssertionError("must not be called")) + + result = await infer_instrument(ai_client=client, session_id=uuid.uuid4(), turns=[]) + assert result == "PHQ4" + assert client.called is False + + +@pytest.mark.asyncio +async def test_ai_server_exception_falls_back_to_phq4() -> None: + """Any exception from the ai-server call (timeout/5xx/validation) -> PHQ4, + never raised out of `infer_instrument` ("라우팅은 실패하지 않는다").""" + from src.services.domain_routing import infer_instrument + + client = _StubDomainAIClient(exc=RuntimeError("simulated ai-server 5xx")) + + result = await infer_instrument( + ai_client=client, session_id=uuid.uuid4(), turns=[(1, "hi")] + ) + assert result == "PHQ4" + + +# ─────────────────────── Layer 2 & 3: integration path ──────────────────── + + +@pytest.fixture(scope="module") +def _throwaway_postgres_url() -> Any: + """Self-contained throwaway Postgres — no conftest, no DGX (mirrors + test_phase1_session_state_persistence.py's fixture verbatim).""" + pgdata = tempfile.mkdtemp(prefix="qa_phase2_pgdata_") + srv = pgserver.get_server(pgdata, cleanup_mode="delete") + srv.psql("CREATE DATABASE neurosync_qa_phase2;") + uri = ( + srv.get_uri() + .replace("postgresql://", "postgresql+asyncpg://") + .replace("/postgres?", "/neurosync_qa_phase2?") + ) + os.environ["DATABASE_URL"] = uri + from src.core.config import get_settings + + get_settings.cache_clear() + try: + yield uri + finally: + get_settings.cache_clear() + srv.cleanup() + + +@pytest.fixture(scope="module") +def _migrated_engine(_throwaway_postgres_url: str) -> str: + """Same alembic-0010->head bootstrap as test_phase1 — brings the schema + fully to head (including the tables `domain/infer`'s route touches: + sessions, messages, users).""" + import asyncio + + import alembic.command + from alembic.config import Config + from sqlalchemy import text + from sqlalchemy.ext.asyncio import create_async_engine + + import src.models # noqa: F401 — register all tables on Base.metadata + from src.db import Base + from src.models.patient_profile import PatientProfile + + is_minor_col = PatientProfile.__table__.c.is_minor + is_minor_col.computed = None + is_minor_col.server_default = text("false") + + async def _create_pre_0011_baseline() -> None: + baseline_engine = create_async_engine(_throwaway_postgres_url, echo=False) + try: + async with baseline_engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + await conn.execute( + text("ALTER TABLE sessions DROP COLUMN session_state") + ) + await conn.execute( + text( + "ALTER TABLE sessions DROP COLUMN clinical_escalation_required" + ) + ) + finally: + await baseline_engine.dispose() + + asyncio.run(_create_pre_0011_baseline()) + + alembic_dir = os.path.join(os.path.dirname(__file__), "..", "alembic") + cfg = Config() + cfg.set_main_option("script_location", os.path.abspath(alembic_dir)) + cfg.set_main_option("sqlalchemy.url", _throwaway_postgres_url) + alembic.command.stamp(cfg, "0010") + alembic.command.upgrade(cfg, "head") + + return _throwaway_postgres_url + + +@pytest_asyncio.fixture +async def db_session(_migrated_engine: str) -> AsyncGenerator[Any, None]: + from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + from sqlalchemy.pool import NullPool + + engine = create_async_engine(_migrated_engine, echo=False, poolclass=NullPool) + session_factory = async_sessionmaker( + bind=engine, class_=AsyncSession, autoflush=False, expire_on_commit=False + ) + try: + async with session_factory() as session: + yield session + await session.rollback() + finally: + await engine.dispose() + + +@pytest_asyncio.fixture +async def seeded_session_with_turn(db_session) -> Any: + """A patient + an in_progress session with one decryptable user message + — the route's `turns` source and the FK graph `domain/infer` needs.""" + from src.core.config import Settings + from src.core.encryption import encrypt_str + from src.models.session import Message, Session + from src.models.user import User + + settings = Settings() + patient = User( + id=uuid.uuid4(), + email=f"qa-phase2-{uuid.uuid4()}@example.test", + password_hash="not-a-real-hash", + role="patient", + ) + db_session.add(patient) + await db_session.flush() + + sess = Session(id=uuid.uuid4(), patient_id=patient.id, status="in_progress") + db_session.add(sess) + await db_session.flush() + + message_id = uuid.uuid4() + aad = f"messages.content:{sess.id}:{message_id}".encode() + db_session.add( + Message( + id=message_id, + session_id=sess.id, + role="user", + content_encrypted=encrypt_str( + "요즘 갑자기 숨이 막히고 심장이 두근거려요", # panic-shaped utterance + aad=aad, + settings=settings, + ), + input_modality="text", + ) + ) + await db_session.commit() + return patient, sess.id + + +def _make_app_with_overrides(db_session, patient, ai_client_stub: Any) -> Any: + from src.core.deps import get_current_user + from src.db import get_session + from src.main import create_app + from src.services.ai_client import get_ai_client + + async def _override_get_session(): + yield db_session + + async def _override_get_current_user(): + return patient + + def _override_get_ai_client(): + return ai_client_stub + + app = create_app() + app.dependency_overrides[get_session] = _override_get_session + app.dependency_overrides[get_current_user] = _override_get_current_user + app.dependency_overrides[get_ai_client] = _override_get_ai_client + return app + + +class _StubRouteAIClient: + """`get_ai_client`-override stand-in exposing `domain_infer` + + `survey_plan` — PLAN-2026-W30-INTEG P3-1(a) added a `survey_plan` call to + the same route, so the double needs both methods now. `survey_plan` + returns a minimal echo response (mirrors real ai-server's own + pass-through of `recommendation_caveat`).""" + + def __init__(self, response: Any) -> None: + self._response = response + self.calls: list[Any] = [] + + async def domain_infer(self, payload: Any) -> Any: + self.calls.append(payload) + return self._response + + async def survey_plan(self, payload: Any) -> Any: + from contracts.survey_plan import SurveyPlanResponse + + return SurveyPlanResponse( + scale=payload.recommended_questionnaire, + administration_mode="natural", + si_supplement=False, + recommendation_caveat=payload.recommendation_caveat, + ) + + +@pytest.mark.asyncio +async def test_domain_infer_route_resolves_panic_to_gad7( + db_session, seeded_session_with_turn +) -> None: + """Integration path: `POST /sessions/{id}/domain/infer` wired through the + real ASGI app (`create_app()`), with `get_ai_client` mocked returning a + panic top1 candidate — confirms the wire-level response resolves to + GAD7, offline.""" + from contracts.domain import DomainCandidate, DomainInferResponse + from httpx import ASGITransport, AsyncClient + + patient, session_id = seeded_session_with_turn + ai_response = DomainInferResponse( + domain_candidates=[DomainCandidate(domain="panic", confidence=0.82)] + ) + ai_stub = _StubRouteAIClient(ai_response) + app = _make_app_with_overrides(db_session, patient, ai_stub) + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.post( + f"/api/v1/sessions/{session_id}/domain/infer", + headers={"Authorization": "Bearer irrelevant-override-bypasses-decode"}, + ) + + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["success"] is True + assert body["data"]["instrument"] == "GAD7", ( + f"panic top1 candidate did not resolve to GAD7 at the wire level: {body}" + ) + assert len(ai_stub.calls) == 1, "route did not call ai_client.domain_infer exactly once" + # NFR v3-2: no domain/disease string ever leaves the module in the response. + assert "panic" not in resp.text.lower() + + +@pytest.mark.asyncio +async def test_vp004_style_panic_scenario_reproduces_gad7_not_phq4_fallback( + db_session, seeded_session_with_turn +) -> None: + """PRD §4/§7 'VP-004류 panic 시나리오 재현' — a panic-shaped candidate as + VP-004's `fluctuating_panic_recurrence` arc would elicit (recurrent + panic-attack-pattern top1, moderate-high confidence, alongside a lower + -confidence secondary anxiety candidate reflecting the DSM-5 comorbidity + the scenario models) must route to GAD7 end-to-end, not the pre-fix + PHQ4 fallback that `_DOMAIN_TO_INSTRUMENT` lacked before ADR-046 #2.""" + from contracts.domain import DomainCandidate, DomainInferResponse + from httpx import ASGITransport, AsyncClient + + patient, session_id = seeded_session_with_turn + vp004_style_response = DomainInferResponse( + domain_candidates=[ + DomainCandidate(domain="panic", confidence=0.77), + DomainCandidate(domain="anxiety", confidence=0.41), + ], + summary="recurrent fluctuating panic pattern (VP-004-style)", + ) + ai_stub = _StubRouteAIClient(vp004_style_response) + app = _make_app_with_overrides(db_session, patient, ai_stub) + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.post( + f"/api/v1/sessions/{session_id}/domain/infer", + headers={"Authorization": "Bearer irrelevant-override-bypasses-decode"}, + ) + + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["data"]["instrument"] == "GAD7", ( + "VP-004-style panic scenario regressed to the pre-fix PHQ4 fallback " + f"instead of routing panic top1 -> GAD7: {body}" + ) + assert body["data"]["instrument"] != "PHQ4" diff --git a/apps/api/tests/test_phase3_survey_plan.py b/apps/api/tests/test_phase3_survey_plan.py new file mode 100644 index 0000000..f9264ac --- /dev/null +++ b/apps/api/tests/test_phase3_survey_plan.py @@ -0,0 +1,450 @@ +"""Dev smoke — PLAN-2026-W30-INTEG Phase 3 (P3-1) `/ai/survey/plan` wiring + +substance->AUDITC mirror + proxy-scale caveat channel. + +Not the durable qa suite (P3-2 owns that) — a developer smoke exercising the +new code paths end to end, offline/mocked throughout, before handoff. + +Layers: + 1. Unit — `_DOMAIN_TO_INSTRUMENT["substance"]` mirror + `infer_instrument`/ + `infer_instrument_with_caveat` caveat resolution (native vs proxy). + 2. Unit — `AIClient.survey_plan` round-trips against a mock ai-server + (`httpx.MockTransport`), no network. + 3. Integration — `POST /sessions/{id}/domain/infer` wired through the real + ASGI app, `get_ai_client` overridden with a stub whose `survey_plan` + returns a crafted `SurveyPlanResponse` — confirms `data.plan` carries + scale/administration_mode/si_supplement/items, `recommendation_caveat` + is stripped from the patient-facing response, and the caveat instead + lands in a persisted `audit_logs` row (clinician/audit-reachable, not + patient UI). + +Hard constraints (mirrors test_phase2_domain_routing.py): + - all live API keys forced to "" (BUG-052/058). + - zero live LLM / outbound HTTP — guarded the same way. + - throwaway `pgserver` only, no DGX Postgres touched. +""" + +from __future__ import annotations + +import os +import tempfile +import uuid +from collections.abc import AsyncGenerator +from typing import Any + +import pytest +import pytest_asyncio + +pgserver = pytest.importorskip( + "pgserver", + reason=( + "test-only throwaway-Postgres helper not declared in " + "apps/api/pyproject.toml/uv.lock yet — skipping gracefully rather " + "than erroring the suite (mirrors test_phase2_domain_routing.py)." + ), +) + +for _key in ( + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "UPSTAGE_API_KEY", + "HIRA_API_KEY", + "KAKAO_API_KEY", + "NS_RAG_API_KEY", + "SKT_A_X_API_KEY", +): + os.environ[_key] = "" + + +@pytest.fixture(autouse=True) +def _forbid_live_http(monkeypatch: pytest.MonkeyPatch) -> None: + import httpx + + _real_post = httpx.AsyncClient.post + + async def _guarded_post(self: httpx.AsyncClient, *args: Any, **kwargs: Any) -> Any: + transport = getattr(self, "_transport", None) + if isinstance(transport, httpx.ASGITransport | httpx.MockTransport): + return await _real_post(self, *args, **kwargs) + raise AssertionError( + "live HTTP call attempted — this test must only use a stub " + "AIClient / dependency override, an in-process ASGITransport, or " + "an httpx.MockTransport" + ) + + monkeypatch.setattr(httpx.AsyncClient, "post", _guarded_post) + + +# ─────────────────────── Layer 1: unit decision surface ─────────────────── + + +def test_substance_mirrors_ai_server_canonical_scale() -> None: + """(b) — mirrors ai-server's DOMAIN_TO_SCALE["substance"] == "AUDIT-C" + (questionnaire_mapping.py:349); pre-fix this key was absent -> PHQ4.""" + from src.services.domain_routing import _DOMAIN_TO_INSTRUMENT + + assert _DOMAIN_TO_INSTRUMENT["substance"] == "AUDITC" + + +class _StubDomainAIClient: + def __init__(self, *, response: Any) -> None: + self._response = response + + async def domain_infer(self, payload: Any) -> Any: + return self._response + + +@pytest.mark.asyncio +async def test_substance_top1_routes_to_auditc_not_phq4() -> None: + from contracts.domain import DomainCandidate, DomainInferResponse + + from src.services.domain_routing import infer_instrument + + client = _StubDomainAIClient( + response=DomainInferResponse( + domain_candidates=[DomainCandidate(domain="substance", confidence=0.9)] + ) + ) + result = await infer_instrument(ai_client=client, session_id=uuid.uuid4(), turns=[(1, "x")]) + assert result == "AUDITC" + assert result != "PHQ4" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "domain,expect_caveat", + [ + ("panic", True), + ("substance", True), + ("depression", False), + ("anxiety", False), + ("alcohol", False), + ], +) +async def test_infer_instrument_with_caveat_only_for_proxy_routings( + domain: str, expect_caveat: bool +) -> None: + """(c) — proxy routings (panic->GAD7, substance->AUDITC) carry a + non-None caveat; native routings (depression/anxiety/alcohol) do not.""" + from contracts.domain import DomainCandidate, DomainInferResponse + + from src.services.domain_routing import _PROXY_CAVEATS, infer_instrument_with_caveat + + client = _StubDomainAIClient( + response=DomainInferResponse( + domain_candidates=[DomainCandidate(domain=domain, confidence=0.9)] + ) + ) + routing = await infer_instrument_with_caveat( + ai_client=client, session_id=uuid.uuid4(), turns=[(1, "x")] + ) + if expect_caveat: + assert routing.caveat == _PROXY_CAVEATS[domain] + assert routing.caveat is not None + else: + assert routing.caveat is None + + +@pytest.mark.asyncio +async def test_infer_instrument_bare_api_unaffected_by_caveat_refactor() -> None: + """`infer_instrument` (bare-string API, used by existing durable tests) + stays a thin string-returning wrapper after the refactor.""" + from contracts.domain import DomainCandidate, DomainInferResponse + + from src.services.domain_routing import infer_instrument + + client = _StubDomainAIClient( + response=DomainInferResponse( + domain_candidates=[DomainCandidate(domain="panic", confidence=0.9)] + ) + ) + result = await infer_instrument(ai_client=client, session_id=uuid.uuid4(), turns=[(1, "x")]) + assert result == "GAD7" + assert isinstance(result, str) + + +# ─────────────────── Layer 2: AIClient.survey_plan mock round-trip ──────── + + +@pytest.mark.asyncio +async def test_ai_client_survey_plan_round_trips_against_mock_ai_server() -> None: + """(a) — `AIClient.survey_plan` POSTs to `/ai/survey/plan` and parses a + `SurveyPlanResponse` carrying scale/administration_mode/si_supplement/ + items, against a mock ai-server (`httpx.MockTransport`), no network.""" + import httpx + from contracts.survey_plan import SurveyPlanRequest + + from src.services.ai_client import AIClient + + def _handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/ai/survey/plan" + return httpx.Response( + 200, + json={ + "scale": "AUDIT-C", + "administration_mode": "natural", + "item_bank_version": "v1", + "item_bank_provenance": "test-fixture", + "instruction_ko": "다음 문항에 답해 주세요.", + "items": [ + { + "index": 1, + "text_ko": "문항1", + "response_min": 0, + "response_max": 4, + } + ], + "si_supplement": False, + "si_supplement_item": None, + "not_administrable_reason": None, + "si_positive_action_ko": None, + "si_positive_threshold": 1, + "recommendation_caveat": "AUDIT-C는 알코올 전용 스크리너입니다.", + "duplicate_administration": False, + "refusal_guidance_ko": "거부 시 강요하지 마세요.", + }, + ) + + transport = httpx.MockTransport(_handler) + client = AIClient(client=httpx.AsyncClient(transport=transport, base_url="http://ai-server.test")) + response = await client.survey_plan( + SurveyPlanRequest(recommended_questionnaire="AUDIT-C", recommendation_caveat=None) + ) + assert response.scale == "AUDIT-C" + assert response.administration_mode == "natural" + assert response.si_supplement is False + assert len(response.items) == 1 + assert response.recommendation_caveat == "AUDIT-C는 알코올 전용 스크리너입니다." + + +# ─────────────────────── Layer 3: integration path ──────────────────────── + + +@pytest.fixture(scope="module") +def _throwaway_postgres_url() -> Any: + pgdata = tempfile.mkdtemp(prefix="dev_phase3_pgdata_") + srv = pgserver.get_server(pgdata, cleanup_mode="delete") + srv.psql("CREATE DATABASE neurosync_dev_phase3;") + uri = ( + srv.get_uri() + .replace("postgresql://", "postgresql+asyncpg://") + .replace("/postgres?", "/neurosync_dev_phase3?") + ) + os.environ["DATABASE_URL"] = uri + from src.core.config import get_settings + + get_settings.cache_clear() + try: + yield uri + finally: + get_settings.cache_clear() + srv.cleanup() + + +@pytest.fixture(scope="module") +def _migrated_engine(_throwaway_postgres_url: str) -> str: + import asyncio + + import alembic.command + from alembic.config import Config + from sqlalchemy import text + from sqlalchemy.ext.asyncio import create_async_engine + + import src.models # noqa: F401 + from src.db import Base + from src.models.patient_profile import PatientProfile + + is_minor_col = PatientProfile.__table__.c.is_minor + is_minor_col.computed = None + is_minor_col.server_default = text("false") + + async def _create_pre_0011_baseline() -> None: + baseline_engine = create_async_engine(_throwaway_postgres_url, echo=False) + try: + async with baseline_engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + await conn.execute(text("ALTER TABLE sessions DROP COLUMN session_state")) + await conn.execute( + text("ALTER TABLE sessions DROP COLUMN clinical_escalation_required") + ) + finally: + await baseline_engine.dispose() + + asyncio.run(_create_pre_0011_baseline()) + + alembic_dir = os.path.join(os.path.dirname(__file__), "..", "alembic") + cfg = Config() + cfg.set_main_option("script_location", os.path.abspath(alembic_dir)) + cfg.set_main_option("sqlalchemy.url", _throwaway_postgres_url) + alembic.command.stamp(cfg, "0010") + alembic.command.upgrade(cfg, "head") + + return _throwaway_postgres_url + + +@pytest_asyncio.fixture +async def db_session(_migrated_engine: str) -> AsyncGenerator[Any, None]: + from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + from sqlalchemy.pool import NullPool + + engine = create_async_engine(_migrated_engine, echo=False, poolclass=NullPool) + session_factory = async_sessionmaker( + bind=engine, class_=AsyncSession, autoflush=False, expire_on_commit=False + ) + try: + async with session_factory() as session: + yield session + await session.rollback() + finally: + await engine.dispose() + + +@pytest_asyncio.fixture +async def seeded_session_with_turn(db_session) -> Any: + from src.core.config import Settings + from src.core.encryption import encrypt_str + from src.models.session import Message, Session + from src.models.user import User + + settings = Settings() + patient = User( + id=uuid.uuid4(), + email=f"dev-phase3-{uuid.uuid4()}@example.test", + password_hash="not-a-real-hash", + role="patient", + ) + db_session.add(patient) + await db_session.flush() + + sess = Session(id=uuid.uuid4(), patient_id=patient.id, status="in_progress") + db_session.add(sess) + await db_session.flush() + + message_id = uuid.uuid4() + aad = f"messages.content:{sess.id}:{message_id}".encode() + db_session.add( + Message( + id=message_id, + session_id=sess.id, + role="user", + content_encrypted=encrypt_str( + "요즘 술이 아니라 다른 걸 자꾸 찾게 돼요", # substance-shaped utterance + aad=aad, + settings=settings, + ), + input_modality="text", + ) + ) + await db_session.commit() + return patient, sess.id + + +def _make_app_with_overrides(db_session, patient, ai_client_stub: Any) -> Any: + from src.core.deps import get_current_user + from src.db import get_session + from src.main import create_app + from src.services.ai_client import get_ai_client + + async def _override_get_session(): + yield db_session + + async def _override_get_current_user(): + return patient + + def _override_get_ai_client(): + return ai_client_stub + + app = create_app() + app.dependency_overrides[get_session] = _override_get_session + app.dependency_overrides[get_current_user] = _override_get_current_user + app.dependency_overrides[get_ai_client] = _override_get_ai_client + return app + + +class _StubRouteAIClient: + """Stands in for `AIClient` at the route layer — `domain_infer` returns a + substance top1 candidate, `survey_plan` returns a crafted plan echoing + the request's `recommendation_caveat` (mirrors real ai-server's own + pass-through echo, `apps/ai-server/src/routes/survey.py:205`).""" + + def __init__(self, domain_response: Any) -> None: + self._domain_response = domain_response + self.survey_plan_calls: list[Any] = [] + + async def domain_infer(self, payload: Any) -> Any: + return self._domain_response + + async def survey_plan(self, payload: Any) -> Any: + from contracts.survey_plan import ItemBankItemPlan, SurveyPlanResponse + + self.survey_plan_calls.append(payload) + return SurveyPlanResponse( + scale=payload.recommended_questionnaire, + administration_mode="natural", + item_bank_version="v1", + instruction_ko="다음 문항에 답해 주세요.", + items=[ + ItemBankItemPlan(index=1, text_ko="문항1", response_min=0, response_max=4) + ], + si_supplement=False, + recommendation_caveat=payload.recommendation_caveat, + ) + + +@pytest.mark.asyncio +async def test_domain_infer_route_wires_survey_plan_and_strips_caveat_from_patient_response( + db_session, seeded_session_with_turn +) -> None: + """(a)+(c) — the real `POST /sessions/{id}/domain/infer` route now calls + `/ai/survey/plan` (mocked), returns `data.plan` with scale/ + administration_mode/si_supplement/items, NEVER forwards + `recommendation_caveat` to the patient response, and persists the proxy + caveat to `audit_logs` (clinician/audit-reachable) instead.""" + from contracts.domain import DomainCandidate, DomainInferResponse + from httpx import ASGITransport, AsyncClient + from sqlalchemy import select + + from src.models.audit_log import AuditLog + + patient, session_id = seeded_session_with_turn + ai_response = DomainInferResponse( + domain_candidates=[DomainCandidate(domain="substance", confidence=0.82)] + ) + ai_stub = _StubRouteAIClient(ai_response) + app = _make_app_with_overrides(db_session, patient, ai_stub) + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.post( + f"/api/v1/sessions/{session_id}/domain/infer", + headers={"Authorization": "Bearer irrelevant-override-bypasses-decode"}, + ) + + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["data"]["instrument"] == "AUDITC" + + plan = body["data"]["plan"] + assert plan["scale"] == "AUDIT-C" + assert plan["administration_mode"] == "natural" + assert plan["si_supplement"] is False + assert len(plan["items"]) == 1 + assert "recommendation_caveat" not in plan, ( + "proxy caveat must never reach the patient-facing survey plan response" + ) + + # the plan request DID carry the caveat (clinician/audit channel populated). + assert len(ai_stub.survey_plan_calls) == 1 + sent_request = ai_stub.survey_plan_calls[0] + assert sent_request.recommendation_caveat is not None + assert "AUDIT-C" in sent_request.recommendation_caveat + + # persisted, clinician/audit-reachable — never the patient response. + rows = ( + await db_session.execute( + select(AuditLog).where(AuditLog.resource_id == session_id) + ) + ).scalars().all() + assert len(rows) == 1 + assert rows[0].action == "survey.plan.proxy_caveat" + assert rows[0].audit_metadata["scale"] == "AUDIT-C" + assert "AUDIT-C" in rows[0].audit_metadata["caveat"] diff --git a/apps/api/tests/test_phase3_survey_score_smoke.py b/apps/api/tests/test_phase3_survey_score_smoke.py new file mode 100644 index 0000000..1a368f7 --- /dev/null +++ b/apps/api/tests/test_phase3_survey_score_smoke.py @@ -0,0 +1,231 @@ +"""PRD §4/§7 Phase 3 criterion 2 smoke — REV-010 issue 1 remediation. + +REV-010 (critic, 2026-07-22, blocking) found that the only pre-existing test +touching `score_with_ai` (`test_questionnaire_scoring_ai.py`, "PR#74 M4", +Phase-0-era) exercises the AI-success (non-fallback) path for **PHQ9 only**; +GAD-7 and PHQ-4 had zero AI-success coverage and AUDIT-C had only a +deliberate-fallback test. The PRD's own named verification method for +criterion 2 (`docs/ai/integration_prd_f1f3_hospital.md:155,157`) is: + + "4종 문진(PHQ9/GAD7/AUDITC/PHQ4) 각 1건씩 실제 응답 세트로 스코어링 + 스모크" + "로그에 local fallback 카운트 0 확인" + +This file adds that missing coverage directly against `score_with_ai` +(`src/services/questionnaire.py`) — no `apps/api/tests/conftest.py` fixture +and no DB are needed, since `score_with_ai` takes its `AIClient` as a plain +argument. This sidesteps BUG-058 (broken `client` fixture) entirely rather +than working around it. + +Non-tautology design (the previous test `test_uses_ai_server_result_when_available` +was flagged as weak because its AI-mocked value could coincide with what local +scoring would independently produce for the same input): every scale's fixture +below is deliberately constructed so the mocked ai-server result is one a +correct local fallback computation of the *same input* could never produce — +either the `total_score`/`severity` diverge from `severity_for()`'s own cutoff +math, or (mirroring the PRD's named PHQ-9 item9 SI-flag / AUDIT-C Korean-cutoff +specifics) `critical_item_positive=True` is asserted on non-PHQ9 scales, which +`critical_item_positive()` can structurally never return (it hard-codes +`False` unless `qtype == "PHQ9"`) — so a passing assertion is only possible if +the ai-server branch, not the local-fallback branch, actually executed. +""" + +from __future__ import annotations + +import logging +import os +import uuid +from typing import Any + +import pytest +from contracts.survey import SurveyScoreResponse + +from src.services.ai_client import AIClientError +from src.services.questionnaire import critical_item_positive, score_questionnaire, score_with_ai + +# ── BUG-052/058 hard constraint: force-empty, never unset, all live keys ── +for _key in ( + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "UPSTAGE_API_KEY", + "HIRA_API_KEY", + "KAKAO_API_KEY", + "NS_RAG_API_KEY", + "SKT_A_X_API_KEY", +): + os.environ[_key] = "" + + +@pytest.fixture(autouse=True) +def _forbid_live_http(monkeypatch: pytest.MonkeyPatch) -> None: + """Defense-in-depth: this suite never touches `httpx` directly (the mock + ai-server is a plain fake object satisfying `AIClient`'s duck-typed + `survey_score` method), but guard the real transport anyway in case a + future edit routes through the real `AIClient`.""" + import httpx + + _real_post = httpx.AsyncClient.post + + async def _guarded_post(self: httpx.AsyncClient, *args: Any, **kwargs: Any) -> Any: + transport = getattr(self, "_transport", None) + if isinstance(transport, httpx.ASGITransport | httpx.MockTransport): + return await _real_post(self, *args, **kwargs) + raise AssertionError( + "live HTTP call attempted — this suite must only use the " + "mock-ai-server fake client, never a live AIClient transport" + ) + + monkeypatch.setattr(httpx.AsyncClient, "post", _guarded_post) + + +class _MockAIServer: + """Stands in for `AIClient` at the exact boundary `score_with_ai` calls — + equivalent to a mocked `/ai/survey/score` response, no network.""" + + def __init__(self, result: SurveyScoreResponse) -> None: + self._result = result + self.calls: list[Any] = [] + + async def survey_score(self, payload: Any) -> SurveyScoreResponse: + self.calls.append(payload) + return self._result + + +# One real response set + one non-tautological ai-server mock per scale type. +# `answers` are chosen so `score_questionnaire()`'s own local math would land +# on a materially different (total, severity) than the mocked ai-server value, +# and (for non-PHQ9) with `critical_item_positive` a local fallback could +# structurally never produce. +CASES: dict[str, dict[str, Any]] = { + "PHQ9": { + # sum=9 -> local "mild" (5..9 band); item9=0 -> local critical False. + "answers": [1, 1, 1, 1, 1, 1, 1, 1, 0], + "ai_total": 27, + "ai_severity": "severe", + "ai_critical": True, # PRD-named: PHQ-9 item9 SI flag + }, + "GAD7": { + # sum=7 -> local "mild" (5..9 band). + "answers": [1, 1, 1, 1, 1, 1, 1], + "ai_total": 21, + "ai_severity": "severe", + "ai_critical": True, # local fallback can never set this (qtype != PHQ9) + }, + "AUDITC": { + # sum=3 -> local "minimal" (<=3 band, approximate — PRD-named: Korean + # cutoff is ai-server's single source of truth per questionnaire.py docstring). + "answers": [1, 1, 1], + "ai_total": 12, + "ai_severity": "severe", + "ai_critical": True, + }, + "PHQ4": { + # sum=4 -> local "mild" (3..5 band). + "answers": [1, 1, 1, 1], + "ai_total": 12, + "ai_severity": "severe", + "ai_critical": True, + }, +} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("qtype", ["PHQ9", "GAD7", "AUDITC", "PHQ4"]) +async def test_score_with_ai_uses_canonical_ai_result_not_local_fallback( + qtype: str, caplog: pytest.LogCaptureFixture +) -> None: + case = CASES[qtype] + answers = case["answers"] + + # sanity: confirm the mocked ai-server value genuinely diverges from what + # local scoring of the SAME input would independently compute — otherwise + # the assertion below would be tautological (could pass via either branch). + local_total, local_severity = score_questionnaire(qtype, answers) + local_critical = critical_item_positive(qtype, answers) + assert (local_total, local_severity) != (case["ai_total"], case["ai_severity"]), ( + f"{qtype}: fixture is tautological — local and mocked-ai values coincide" + ) + if qtype != "PHQ9": + assert local_critical is False, ( + f"{qtype}: local fallback critical must be False for the " + "non-tautology guarantee to hold" + ) + assert case["ai_critical"] != local_critical or (local_total, local_severity) != ( + case["ai_total"], + case["ai_severity"], + ) + + mock_server = _MockAIServer( + SurveyScoreResponse( + scale_name=qtype, + total_score=case["ai_total"], + max_score=case["ai_total"], + severity=case["ai_severity"], + critical_item_positive=case["ai_critical"], + ) + ) + + with caplog.at_level(logging.WARNING, logger="src.services.questionnaire"): + total, severity, critical = await score_with_ai( + qtype, + answers, + ai_client=mock_server, + session_id=uuid.uuid4(), + patient_sex="unknown", + ) + + # 1) the mocked ai-server was actually invoked exactly once. + assert len(mock_server.calls) == 1 + + # 2) the returned triple is the AI value, not the local one — this is the + # PRD's "200 canonical scoring" bar, made non-tautological by construction + # above (the two branches cannot agree on this input). + assert (total, severity, critical) == ( + case["ai_total"], + case["ai_severity"], + case["ai_critical"], + ) + + # 3) "local fallback count 0" — the exact PRD-named log signal + # (`questionnaire.py:165-166`, "survey scoring fell back to local + # cutoffs") never fired. + fallback_records = [ + r for r in caplog.records if "fell back to local cutoffs" in r.getMessage() + ] + assert fallback_records == [], ( + f"{qtype}: local-fallback log fired even though the mock ai-server " + f"succeeded: {[r.getMessage() for r in fallback_records]}" + ) + + +@pytest.mark.asyncio +async def test_score_with_ai_still_falls_back_on_genuine_ai_failure() -> None: + """Negative control for the smoke above: confirms the fallback log DOES + fire (and IS the mechanism the positive cases prove absent) when the + ai-server genuinely fails — guards against the positive assertions above + passing merely because the log filter is broken/always empty.""" + + class _FailingServer: + async def survey_score(self, payload: Any) -> SurveyScoreResponse: + raise AIClientError("mock ai-server outage") + + caplog_logger = logging.getLogger("src.services.questionnaire") + caplog_logger.propagate = True + + handler = logging.Handler() + records: list[logging.LogRecord] = [] + handler.emit = records.append # type: ignore[assignment] + caplog_logger.addHandler(handler) + try: + total, severity, critical = await score_with_ai( + "PHQ9", + [1] * 9, + ai_client=_FailingServer(), + session_id=uuid.uuid4(), + ) + finally: + caplog_logger.removeHandler(handler) + + fallback_records = [r for r in records if "fell back to local cutoffs" in r.getMessage()] + assert len(fallback_records) == 1 + # PHQ9 [1]*9 -> local total=9 "mild", item9=1>0 -> critical True. + assert (total, severity, critical) == (9, "mild", True) diff --git a/apps/api/tests/test_sessions.py b/apps/api/tests/test_sessions.py index 25fbbd1..9472f09 100644 --- a/apps/api/tests/test_sessions.py +++ b/apps/api/tests/test_sessions.py @@ -12,8 +12,8 @@ from contracts.safety import ( RiskCategory, RiskLevel, + SafetyAssessment, SafetyEvidence, - SafetyResponse, ) from src.services.safety import ( @@ -24,8 +24,8 @@ ) -def _make_response(level: RiskLevel, category: RiskCategory) -> SafetyResponse: - return SafetyResponse( +def _make_response(level: RiskLevel, category: RiskCategory) -> SafetyAssessment: + return SafetyAssessment( level=level, category=category, evidence=SafetyEvidence( @@ -50,6 +50,15 @@ def __init__(self) -> None: def add(self, obj) -> None: self.added.append(obj) + async def flush(self) -> None: + """CVR-051 self-check fix (RM-1): `handle_safety_result`/ + `handle_unavailable_classifier` now flush before reading + `risk_event.id` (a real AsyncSession assigns the Python-side + `default=uuid.uuid4` at flush time). This fake has no real DB, so + `.id` stays whatever the RiskEvent constructor left it as (None, + since no test here asserts on `riskEventId`'s value) — the no-op + just satisfies the call so these DB-less unit tests keep working.""" + # ────────── handle_safety_result ────────── @@ -221,3 +230,31 @@ async def test_unavailable_classifier_respects_opt_out(): assert payload["routeTo"] == "/self_hotline" risk_event = db.added[0] assert risk_event.legal_basis == "self_hotline_only" + + +@pytest.mark.asyncio +async def test_unavailable_classifier_category_is_honest_not_other_harm(): + """CVR-053 regression: a classifier-infrastructure outage is not a + harm-to-others (타해) detection. `handle_unavailable_classifier` was the + one path BUG-062's category-mapping consolidation missed — it kept + hardcoding `RiskCategory.OTHER_HARM` for every outage. Both the + persisted `RiskEvent.category` and the wire payload's `category` must + be the honest `RiskCategory.NONE`, not `OTHER_HARM`.""" + db = _FakeSession() + payload = await handle_unavailable_classifier( + db, # type: ignore[arg-type] + patient_id=uuid.uuid4(), + session_id=uuid.uuid4(), + trigger_message_id=uuid.uuid4(), + context_message_ids=[], + consent=_consent(True), + ) + risk_event = db.added[0] + assert risk_event.category == RiskCategory.NONE.value + assert risk_event.category != RiskCategory.OTHER_HARM.value + assert payload["category"] == RiskCategory.NONE.value + # Level stays MEDIUM and escalation still fires — CVR-053 scored the + # fail-safe turn-block clinically correct; only the category label + # was wrong, and only that changes here. + assert risk_event.level == RiskLevel.MEDIUM.value + assert payload["routeTo"] == "/emergency" # opted in — still escalates diff --git a/apps/api/tests/test_sessions_list.py b/apps/api/tests/test_sessions_list.py new file mode 100644 index 0000000..64fce73 --- /dev/null +++ b/apps/api/tests/test_sessions_list.py @@ -0,0 +1,118 @@ +"""GET /api/v1/sessions — S09 records (기록 조회). + +DB-backed (auto-skips without Postgres, same convention as +`test_intake_flow.py`). Covers: own-sessions-only scoping, `hasReport` +existence flag (independent of a `generating`/`failed` report status), +`progressRatio` round-trip, and newest-first ordering. +""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime + +import pytest + + +def _register_patient(client, email: str | None = None) -> dict: + email = email or f"pt-{uuid.uuid4().hex[:8]}@example.com" + res = client.post( + "/api/v1/auth/register", + json={ + "email": email, + "password": "Hunter2-Strong!Password", + "name": "환자", + "birthYear": datetime.now(tz=UTC).year - 30, + "gender": "male", + "phone": "010-1111-2222", + "region": "서울", + "emergencyContact": "010-3333-4444", + "consents": { + "tos": True, + "privacy": True, + "sensitive": True, + "riskNotification": True, + }, + }, + ) + assert res.status_code == 201, res.text + return res.json()["data"] + + +def _create_session(client, access: str) -> str: + res = client.post( + "/api/v1/sessions", headers={"Authorization": f"Bearer {access}"} + ) + assert res.status_code == 201, res.text + return res.json()["data"]["sessionId"] + + +@pytest.mark.asyncio +async def test_list_returns_only_own_sessions_newest_first(client): + owner = _register_patient(client) + owner_access = owner["accessToken"] + sid1 = _create_session(client, owner_access) + sid2 = _create_session(client, owner_access) + + other = _register_patient(client) + _create_session(client, other["accessToken"]) + + res = client.get( + "/api/v1/sessions", headers={"Authorization": f"Bearer {owner_access}"} + ) + assert res.status_code == 200, res.text + sessions = res.json()["data"]["sessions"] + ids = [s["sessionId"] for s in sessions] + + assert set(ids) == {sid1, sid2} + # newest-first: sid2 was created after sid1 + assert ids.index(sid2) < ids.index(sid1) + for s in sessions: + assert s["hasReport"] is False + assert s["progressRatio"] == 0.0 + assert s["status"] == "in_progress" + + +@pytest.mark.asyncio +async def test_list_empty_for_new_patient(client): + patient = _register_patient(client) + res = client.get( + "/api/v1/sessions", headers={"Authorization": f"Bearer {patient['accessToken']}"} + ) + assert res.status_code == 200, res.text + assert res.json()["data"]["sessions"] == [] + + +@pytest.mark.asyncio +async def test_list_requires_auth(client): + res = client.get("/api/v1/sessions") + assert res.status_code == 401 + + +@pytest.mark.asyncio +async def test_list_reflects_progress_and_report_existence(client, db_session): + """`progressRatio` mirrors `Session.progress_ratio`, and `hasReport` + flips true purely on row existence in `handoff_reports` — a + `generating`/not-yet-`ready` report still counts (the client resolves + the actual phase separately via `/report/status`).""" + from sqlalchemy import select + + from src.models.handoff import HandoffReport + from src.models.session import Session + + patient = _register_patient(client) + access = patient["accessToken"] + sid = _create_session(client, access) + + row = await db_session.execute(select(Session).where(Session.id == uuid.UUID(sid))) + sess = row.scalar_one() + sess.progress_ratio = 0.42 + db_session.add(HandoffReport(session_id=sess.id, status="generating")) + await db_session.commit() + + res = client.get("/api/v1/sessions", headers={"Authorization": f"Bearer {access}"}) + assert res.status_code == 200, res.text + entry = res.json()["data"]["sessions"][0] + assert entry["sessionId"] == sid + assert entry["progressRatio"] == pytest.approx(0.42) + assert entry["hasReport"] is True diff --git a/infra/deploy/docker-compose.dgx.yml b/infra/deploy/docker-compose.dgx.yml new file mode 100644 index 0000000..cb75f8d --- /dev/null +++ b/infra/deploy/docker-compose.dgx.yml @@ -0,0 +1,111 @@ +# DGX Spark production deployment — ai-server + api. +# +# ⚠️ STANDALONE file, NOT an override of docker-compose.yml — run with ONLY +# `-f infra/deploy/docker-compose.dgx.yml`. Never combine the two files. +# +# - The DB server already runs on this DGX as a separately managed container +# (external 28881 → postgres 5432). This file deliberately defines NO +# postgres service — never start a second DB from here. Both ai-server and +# api reference the same standing external DB via DATABASE_URL in their +# respective .env files (env_file below); neither service owns the DB +# container. +# - Distinct compose project name + container name → zero state collision +# with any compose project or container already on the DGX. +# - External port 24855 is the allocated ai-server port. Backend base URL: +# http://223.194.33.26:24855 (container listens on 8001, Dockerfile CMD). +# - External port 24856 for api is a WORKING ASSUMPTION (ADR-046 decision 5, +# 2026-07-22) — no port has been formally allocated by the operator yet. +# Co-locating api on this same DGX + compose file is itself a working +# assumption (PRD §9 decision #6 default: co-locate with DB + ai-server, +# no separate-host instruction received). If the operator assigns a +# different port or a separate host, only this file's `ports:`/service +# block needs to change — no code change required. +# - All secrets come from each service's own .env via env_file at RUN time; +# the images never contain .env (root .dockerignore excludes it). The +# operator places both apps/ai-server/.env and apps/api/.env on the DGX +# before `up`. ENCRYPTION_KEY MUST be byte-identical across the two .env +# files — see docs/ai/deployment_integration_plan.md §6b (ENCRYPTION_KEY +# SOP) before first boot. +# +# Run from repo root on the DGX: +# docker compose -f infra/deploy/docker-compose.dgx.yml up -d --build +# curl -s localhost:24855/health # ai-server +# curl -s localhost:24856/health # api +# +# DB reachability: DATABASE_URL in each .env points at 223.194.33.26:28881. If +# a container cannot hairpin through the host's external IP, change only the +# DSN host to host.docker.internal:28881 (mapped via extra_hosts below) — an +# .env edit, no code change. +# +# api-server startup order (manual, not compose-orchestrated — see runbook +# docs/ai/deployment_integration_plan.md §6a): apps/api requires +# `alembic upgrade head` to be run manually once against the target DB before +# (or on) first boot of the api container. This file does NOT run migrations +# automatically (no init container / entrypoint hook) — see runbook for why. + +name: neuro-sync-ai-dgx + +services: + ai-server: + build: + context: ../.. + dockerfile: apps/ai-server/Dockerfile + container_name: ns-ai-server-dgx + # No `command:` override and no src/tests volume mounts — the baked image + # is the deployment artifact; the Dockerfile's production CMD runs + # (no --reload). + env_file: + - ../../apps/ai-server/.env + environment: + LOG_LEVEL: info + # `environment:` outranks env_file in compose precedence. The dev .env + # carries the workstation-relative PROMPTS_BASE_DIR=docs/ai/prompts, + # which does not exist inside the container — pin the baked-in copy + # (Dockerfile: COPY docs/ai/prompts /app/prompts) so env_file injection + # can never break prompt loading. + PROMPTS_BASE_DIR: /app/prompts + ports: + - "24855:8001" + extra_hosts: + - "host.docker.internal:host-gateway" + restart: unless-stopped + + api: + # ADR-046 decision 5 (2026-07-22): apps/api DGX compose service = working + # assumption (co-locate with the standing DB + ai-server on this DGX, no + # separate-host instruction received). Replaceable by the operator without + # touching apps/api code — swap this block for a remote-host runbook if + # instructed otherwise. + build: + context: ../.. + dockerfile: apps/api/Dockerfile + container_name: ns-api-dgx + # No `command:` override — Dockerfile's production CMD runs (no --reload). + env_file: + - ../../apps/api/.env + environment: + # `environment:` outranks env_file. DATABASE_URL is intentionally NOT + # overridden here — apps/api/.env (the real operator-managed file, not + # .env.example) must already carry the production DSN pointed at the + # same standing external postgres ai-server uses (223.194.33.26:28881, + # asyncpg driver per apps/api/pyproject.toml). This file defines no + # postgres service; DATABASE_URL is a reference to that existing + # container, never a new one. If the shipped .env.example's dev-local + # value (localhost:5432) leaks into a DGX .env by mistake, the api + # container fails DB connectivity at boot — visible immediately via the + # /health check below, not a silent misroute. + # + # AI_SERVER_URL is overridden here (same precedence rule) because both + # services share this compose project's default network — resolving + # the ai-server service name directly avoids a hairpin round-trip + # through the external IP:port for same-host, same-project traffic. If + # api is later moved to a separate host/compose project, change only + # this one value to http://223.194.33.26:24855 (no code change). + AI_SERVER_URL: http://ai-server:8001 + ports: + - "24856:8000" + extra_hosts: + - "host.docker.internal:host-gateway" + depends_on: + - ai-server + restart: unless-stopped diff --git a/infra/deploy/docker-compose.yml b/infra/deploy/docker-compose.yml index e9b5297..c0de3c7 100644 --- a/infra/deploy/docker-compose.yml +++ b/infra/deploy/docker-compose.yml @@ -65,13 +65,7 @@ services: # PROMPTS_BASE_DIR specifically falls through to the Dockerfile's # in-container ENV default (/app/prompts). No value is invented here. HIRA_SERVICE_KEY: - HIRA_HOSPITAL_SERVICE_URL: - HIRA_PHARMACY_SERVICE_URL: - HIRA_MADM_DTL_SERVICE_URL: HIRA_DRUG_EFFICACY_SERVICE_URL: - KAKAO_REST_KEY_ENCODED: - KAKAO_JS_KEY_ENCODED: - KAKAO_LOCAL_REST_BASE_URL: LLM_CLIENT_TIMEOUT_S: PROMPTS_BASE_DIR: ports: