From 1a26d749bfd3a653ef4e5a03866d42f885f609ee Mon Sep 17 00:00:00 2001 From: Seohyunjho Date: Tue, 28 Jul 2026 14:24:17 +0900 Subject: [PATCH] =?UTF-8?q?feat(handoff):=20=EB=A6=AC=ED=8F=AC=ED=8A=B8?= =?UTF-8?q?=EC=97=90=20F2=20=EB=8F=84=EB=A9=94=EC=9D=B8=EC=B6=94=EB=A1=A0(?= =?UTF-8?q?AI=20=EC=98=88=EC=83=81=EC=A7=88=ED=99=98=C2=B7=EA=B6=8C?= =?UTF-8?q?=EC=9E=A5=20=EC=A7=84=EB=A3=8C=EA=B3=BC)=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 핸드오프 리포트의 A6(AI 예상질환)·A7(권장 진료과)가 항상 "정보 없음"으로 뜨던 문제 해결. F1이 라이브로 도메인추론을 하지만 그 산출물이 DB에 저장되지 않아 리포트 생성 경로(generate_report_task)가 빈 DomainInferenceInput을 넘기던 것이 원인. - ai_client.domain_infer_artifact(): `/ai/domain/infer`의 full artifact를 raw dict로 받는다. 공유 contract DomainInferResponse는 domain_candidates/summary만 담아 ai_predicted_disease·department_candidates를 버리므로, DomainInferenceInput(dict 허용) 에 하위 객체를 그대로 통과시킨다. - handoff._build_domain_inference(): 제출 세션의 환자 발화로 F2를 재계산(RAG mode)해 ai_predicted_disease + department_candidates를 채운다. 발화 없음/호출 실패 시 빈 값으로 degrade(기존 '정보 없음' 동작 보존). - generate_report_task: 다세션 리포트 요청에 위 도메인추론을 실어 A6/A7를 채운다. 검증(로컬): 도메인 아티팩트→리포트 A6(질환 유사도 후보)·A7(정신건강의학과) 정상 렌더. 비고: disease_similarity/domain_confidence 차트는 세션별 도메인 시계열이 필요해 후속. 임상 종합소견(narrative)·감성 반영도 별도 증분. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/src/services/ai_client.py | 21 +++++++++++ apps/api/src/services/handoff.py | 57 +++++++++++++++++++++++++++++- 2 files changed, 77 insertions(+), 1 deletion(-) 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, )