Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/api/src/services/ai_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
57 changes: 56 additions & 1 deletion apps/api/src/services/handoff.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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,
)
Expand Down
Loading