diff --git a/apps/api/src/services/ai_client.py b/apps/api/src/services/ai_client.py index 479ca46..e230f82 100644 --- a/apps/api/src/services/ai_client.py +++ b/apps/api/src/services/ai_client.py @@ -167,6 +167,27 @@ async def domain_infer(self, payload: DomainInferRequest) -> DomainInferResponse timeout=self._settings.ai_domain_timeout_seconds, ) + async def domain_infer_artifact(self, payload: DomainInferRequest) -> dict: + """POST /ai/domain/infer, returning the FULL raw artifact dict. + + `DomainInferResponse`(공유 contract)는 `domain_candidates`/`summary`만 담아 + `ai_predicted_disease`(질환 유사도)·`department_candidates`(진료과)를 버린다 — + F5 A6/A7 리포트 섹션이 바로 이 둘을 필요로 하므로, 라우트의 full artifact + (`DomainInferRouteResponse`)를 raw dict로 받아 하위 객체를 그대로 통과시킨다 + (`DomainInferenceInput`이 dict를 받음). 실패 시 AIClientError → 호출자가 빈 + 도메인추론으로 우아하게 degrade.""" + url = f"{self._settings.ai_server_url}/ai/domain/infer" + try: + resp = await self._client.post( + url, + json=payload.model_dump(mode="json"), + timeout=self._settings.ai_domain_timeout_seconds, + ) + resp.raise_for_status() + return resp.json() + except (httpx.HTTPError, ValueError) as exc: + raise AIClientError(f"/ai/domain/infer (artifact) failed: {exc}") from exc + async def nearby_hospitals( self, *, lat: float, lng: float, radius_km: float = 5.0, num_of_rows: int = 30 ) -> NearbyHospitalsResponse: diff --git a/apps/api/src/services/handoff.py b/apps/api/src/services/handoff.py index dddc308..a819876 100644 --- a/apps/api/src/services/handoff.py +++ b/apps/api/src/services/handoff.py @@ -20,6 +20,7 @@ import uuid from datetime import UTC, datetime +from contracts.domain import DomainInferRequest, UtteranceTurn from contracts.handoff import HandoffRequest, ScaleScore, SlotData from contracts.longitudinal import ( DomainInferenceInput, @@ -322,6 +323,55 @@ async def _build_longitudinal_sessions( return entries +async def _build_domain_inference( + db: AsyncSession, session_id: uuid.UUID, sess: Session, client: AIClient +) -> DomainInferenceInput: + """제출 세션의 대화로 F2 도메인추론(`/ai/domain/infer`)을 호출해 리포트 + A6(AI 예상질환)·A7(권장 진료과) 입력을 만든다. F1이 라이브로 계산하지만 그 산출물이 + DB에 저장되지 않으므로 리포트 생성 시점에 재계산한다. 발화가 없거나 호출이 실패하면 + 빈 DomainInferenceInput → F5가 '정보 없음'으로 우아하게 degrade(기존 동작 유지).""" + mrows = await db.execute( + select(Message).where(Message.session_id == session_id).order_by(Message.created_at) + ) + turns: list[UtteranceTurn] = [] + for m in mrows.scalars().all(): + if m.role != "user": + continue + try: + content = decrypt_str(m.content_encrypted, aad=_message_aad(session_id, m.id)) + except Exception: + continue + if content.strip(): + turns.append(UtteranceTurn(turn=len(turns), patient_message=content)) + if not turns: + return DomainInferenceInput() + + slots = {k: str(v) for k, v in (sess.clinical_slots or {}).items() if v} + rrows = await db.execute( + select(RiskEvent.level).where(RiskEvent.session_id == session_id) + ) + ctrs_vals = [_RISK_TO_CTRS.get(str(lvl), 5) for lvl in rrows.scalars() if lvl] + try: + artifact = await client.domain_infer_artifact( + DomainInferRequest( + session_id=str(session_id), + final_slots=slots, + session_ctrs=min(ctrs_vals) if ctrs_vals else 3, + crisis_triggered=bool(ctrs_vals and min(ctrs_vals) <= 2), + is_first_visit=True, + turns=turns, + retrieval_mode="rag", + ) + ) + except AIClientError as exc: + logger.warning("handoff.domain_infer.failed (session=%s): %s", session_id, exc) + return DomainInferenceInput() + return DomainInferenceInput( + ai_predicted_disease=artifact.get("ai_predicted_disease"), + department_candidates=artifact.get("department_candidates") or [], + ) + + async def generate_report_task( session_id: uuid.UUID, *, ai_client: AIClient | None = None ) -> None: @@ -346,10 +396,15 @@ async def generate_report_task( if len(entries) >= 2: # 사용자 고도화 F4+F5 풀 리포트 (결정론적, PDF/FHIR/차트 포함). + # F2 도메인추론(질환·진료과)을 제출 세션 대화로 재계산해 리포트에 실음. + # 실패해도 빈 값으로 degrade하므로 리포트 생성 자체는 막지 않는다. + domain_inference = await _build_domain_inference( + db, session_id, sess, client + ) report_req = HandoffReportRequest( vp_id=_display_vp(patient_id), sessions=entries, - domain_inference=DomainInferenceInput(), + domain_inference=domain_inference, include_charts=True, include_pdf=True, )