From 3f89c39c0acd7aad9390b26b7ffd5051ccd75588 Mon Sep 17 00:00:00 2001 From: Seohyunjho Date: Mon, 27 Jul 2026 18:43:38 +0900 Subject: [PATCH] =?UTF-8?q?fix(handoff,web,mobile):=20=ED=95=B8=EB=93=9C?= =?UTF-8?q?=EC=98=A4=ED=94=84=20=EB=A6=AC=ED=8F=AC=ED=8A=B8=20=ED=99=98?= =?UTF-8?q?=EC=9E=90=EB=AA=85=20=EB=A7=88=EC=8A=A4=ED=82=B9=C2=B7=EB=8C=80?= =?UTF-8?q?=EC=8B=9C=EB=B3=B4=EB=93=9C=20PDF=20=EC=9D=B8=EB=9D=BC=EC=9D=B8?= =?UTF-8?q?=C2=B7OCR=20=EB=8C=80=ED=99=94=EB=B0=98=EC=98=81=C2=B7PII=20?= =?UTF-8?q?=EC=9E=AC=EC=95=94=ED=98=B8=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 의료진 대시보드/핸드오프 리포트 관련 로컬 수정 통합: - api(handoff.py): 다세션 F5 리포트가 vp_id에 원본 DB UUID를 실어 리포트·차트· FHIR에 UUID가 노출되던 것 수정 → 비가역 표시코드(VP-XXXXXX) + 복호화 실명을 persona_name으로 전달해 마스킹 표기(예: 조서현). (_display_vp/_patient_display_name) - web(HandoffReportView): 리포트를 raw markdown 텍스트로 뿌리던 것을 editorial PDF 인라인 렌더()로 전환, 원문 markdown은 접이식으로 강등. PDF 없는 리포트는 기존 markdown 폴백. - web(layout): 브라우저 확장 속성 주입으로 인한 최상위 하이드레이션 미스매치 억제 (suppressHydrationWarning on html/body). - mobile(OCR): 처방전 OCR 확인 완료 시 요약을 대화로 흘려보내 F1이 인지·응답하고 conversation_history(→리포트)에 남게 함(pendingInjection 큐 + chat 전송 훅). - scripts/reencrypt_pii.py: ENCRYPTION_KEY 불일치로 복호화 실패하는 환자 PII를 현재 키로 재암호화(레거시 키 env 주입, dry-run 기본). 대시보드 "복호화 실패" 정리용. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/scripts/reencrypt_pii.py | 142 ++++++++++++++++++ apps/api/src/services/handoff.py | 36 ++++- apps/mobile/app/(patient)/intake/chat.tsx | 27 +++- .../app/(patient)/intake/ocr-confirm.tsx | 38 ++++- apps/mobile/state/session.ts | 17 ++- apps/web/app/layout.tsx | 8 +- apps/web/components/HandoffReportView.tsx | 55 +++++-- 7 files changed, 295 insertions(+), 28 deletions(-) create mode 100644 apps/api/scripts/reencrypt_pii.py diff --git a/apps/api/scripts/reencrypt_pii.py b/apps/api/scripts/reencrypt_pii.py new file mode 100644 index 0000000..4406fa1 --- /dev/null +++ b/apps/api/scripts/reencrypt_pii.py @@ -0,0 +1,142 @@ +"""ENCRYPTION_KEY 불일치로 복호화 실패하는 환자 PII를 현재 키로 재암호화한다. + +배경: 하나의 공유 DB에 서로 다른 ENCRYPTION_KEY로 쓰인 행이 섞이면(예: dev api와 +DGX api가 다른 키로 같은 DB에 기록), 각 인스턴스는 자기 키로 쓴 행만 복호화할 수 +있고 나머지는 AES-GCM `InvalidTag`(복호화 실패)가 난다. 의료진 대시보드에서 일부 +환자 이름이 "복호화 실패"로 뜨는 원인. + +이 스크립트는 **실행 환경의 ENCRYPTION_KEY(= 정규 키)**를 타깃으로: + 1. 각 암호화 컬럼을 정규 키로 복호화 시도 → 성공하면 이미 정상, 건너뜀. + 2. 실패하면 `LEGACY_ENCRYPTION_KEYS`(env, base64-urlsafe 32B, 쉼표구분)의 각 키로 + 복호화 시도 → 성공한 키로 평문을 얻어 **정규 키로 재암호화**해 UPDATE 대상에 담음. + 3. 어떤 키로도 못 읽으면 unrecoverable로 보고(원본 키 소실 — 삭제/재생성만 가능). + +대상: patient_profiles(name/phone/emergency_contact), messages(content). +키는 인자·git에 남기지 않는다 — 반드시 env로 주입한다. + + # DGX api 컨테이너 안(정규 키 = 컨테이너 env)에서: + LEGACY_ENCRYPTION_KEYS="<이 데이터를 쓴 다른 키(base64)>" \ + python -m scripts.reencrypt_pii # dry-run(보고만) + LEGACY_ENCRYPTION_KEYS="..." python -m scripts.reencrypt_pii --apply # 실제 UPDATE +""" + +from __future__ import annotations + +import asyncio +import base64 +import os +import sys +import uuid + +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from sqlalchemy import select + +from src.core.encryption import NONCE_BYTES, encrypt_bytes +from src.db import SessionLocal +from src.models.session import Message +from src.models.patient_profile import PatientProfile + + +def _profile_aad(user_id: uuid.UUID, column: str) -> bytes: + return f"patient_profiles.{column}:{user_id}".encode() + + +def _message_aad(session_id: uuid.UUID, message_id: uuid.UUID) -> bytes: + return f"messages.content:{session_id}:{message_id}".encode() + + +def _load_legacy_keys() -> list[bytes]: + raw = os.getenv("LEGACY_ENCRYPTION_KEYS", "").strip() + keys: list[bytes] = [] + for tok in (t.strip() for t in raw.split(",") if t.strip()): + k = base64.urlsafe_b64decode(tok + "===") + if len(k) != 32: + raise SystemExit(f"legacy key decoded to {len(k)} bytes (need 32): {tok[:8]}…") + keys.append(k) + return keys + + +def _try_decrypt_legacy(blob: bytes, aad: bytes, legacy: list[bytes]) -> bytes | None: + """레거시 키들로 복호화 시도. 성공하면 평문 bytes, 전부 실패하면 None.""" + if len(blob) <= NONCE_BYTES: + return None + nonce, ct = blob[:NONCE_BYTES], blob[NONCE_BYTES:] + for k in legacy: + try: + return AESGCM(k).decrypt(nonce, ct, aad) + except Exception: + continue + return None + + +def _canonical_ok(blob: bytes, aad: bytes) -> bool: + """정규 키(현재 settings)로 복호화되면 True.""" + from src.core.encryption import decrypt_bytes + + try: + decrypt_bytes(blob, aad=aad) + return True + except Exception: + return False + + +async def main(apply: bool) -> None: + legacy = _load_legacy_keys() + if not legacy: + print("LEGACY_ENCRYPTION_KEYS 가 비어 있습니다 — 재암호화할 원본 키를 env로 주입하세요.") + return + print(f"legacy 키 {len(legacy)}개 로드. mode={'APPLY' if apply else 'DRY-RUN'}") + + converted = already = unrecoverable = 0 + async with SessionLocal() as db: + # ── patient_profiles ── + profs = (await db.execute(select(PatientProfile))).scalars().all() + for p in profs: + for col in ("name", "phone", "emergency_contact"): + blob = getattr(p, f"{col}_encrypted") + if blob is None: + continue + aad = _profile_aad(p.user_id, col) + if _canonical_ok(blob, aad): + already += 1 + continue + pt = _try_decrypt_legacy(blob, aad, legacy) + if pt is None: + unrecoverable += 1 + print(f" UNRECOVERABLE profile {p.user_id} .{col}") + continue + if apply: + setattr(p, f"{col}_encrypted", encrypt_bytes(pt, aad=aad)) + converted += 1 + + # ── messages.content ── + msgs = (await db.execute(select(Message))).scalars().all() + for m in msgs: + blob = m.content_encrypted + if blob is None: + continue + aad = _message_aad(m.session_id, m.id) + if _canonical_ok(blob, aad): + already += 1 + continue + pt = _try_decrypt_legacy(blob, aad, legacy) + if pt is None: + unrecoverable += 1 + continue + if apply: + m.content_encrypted = encrypt_bytes(pt, aad=aad) + converted += 1 + + if apply: + await db.commit() + + print( + f"\n결과: 이미정상 {already} · 재암호화{'(적용)' if apply else '(예정)'} {converted} " + f"· 복구불가 {unrecoverable}" + ) + if not apply and converted: + print("→ --apply 를 붙여 실제 UPDATE 하세요.") + + +if __name__ == "__main__": + asyncio.run(main("--apply" in sys.argv[1:])) diff --git a/apps/api/src/services/handoff.py b/apps/api/src/services/handoff.py index ed055fc..dddc308 100644 --- a/apps/api/src/services/handoff.py +++ b/apps/api/src/services/handoff.py @@ -207,6 +207,34 @@ async def _build_request( _RISK_TO_CTRS = {"critical": 1, "high": 2, "medium": 3, "low": 4, "none": 5} +def _display_vp(patient_id: uuid.UUID) -> str: + """리포트 표시용 환자 코드. 원본 DB UUID는 개발자 정보라 노출 금지 — + editorial 렌더의 `patient.id`·F4 차트 제목·FHIR `id`가 모두 vp_id를 그대로 + 쓰므로, 여기서 비가역 축약 코드(`VP-XXXXXX`)로 바꿔 UUID 누출을 차단한다. + 템플릿의 VP-XXX 관례와도 맞아 build_report_json의 reportNo 파싱이 그대로 동작.""" + return f"VP-{patient_id.hex[:6].upper()}" + + +async def _patient_display_name( + db: AsyncSession, patient_id: uuid.UUID +) -> str | None: + """환자 실명(AES-256 복호화). F5 A0 헤더 `환자: {persona_name}`에 실려 + editorial 렌더에서 `mask()`로 마스킹 표기된다(예: 김서연→김○연). 프로필/키가 + 없으면 None → F5는 '이름 없음'으로 우아하게 degrade(UUID 노출 없음).""" + row = await db.execute( + select(PatientProfile.name_encrypted).where( + PatientProfile.user_id == patient_id + ) + ) + enc = row.scalar_one_or_none() + if enc is None: + return None + try: + return decrypt_str(enc, aad=_profile_aad(patient_id, "name")) + except Exception: + return None + + async def _build_longitudinal_sessions( db: AsyncSession, patient_id: uuid.UUID ) -> list[LongitudinalSessionEntry]: @@ -222,6 +250,10 @@ async def _build_longitudinal_sessions( ) sessions = list(srows.scalars()) + # 환자 표시 정체성(실명 마스킹용 + 비-UUID 코드) — F5 A0 헤더/차트/FHIR가 소비. + display_name = await _patient_display_name(db, patient_id) + display_vp = _display_vp(patient_id) + entries: list[LongitudinalSessionEntry] = [] idx = 0 for sess in sessions: @@ -281,6 +313,8 @@ async def _build_longitudinal_sessions( risk_assessment=risk_assessment, f3=f3, session_id=str(sess.id), + persona_name=display_name, + persona_id=display_vp, ) ) idx += 1 @@ -313,7 +347,7 @@ async def generate_report_task( if len(entries) >= 2: # 사용자 고도화 F4+F5 풀 리포트 (결정론적, PDF/FHIR/차트 포함). report_req = HandoffReportRequest( - vp_id=str(patient_id), + vp_id=_display_vp(patient_id), sessions=entries, domain_inference=DomainInferenceInput(), include_charts=True, diff --git a/apps/mobile/app/(patient)/intake/chat.tsx b/apps/mobile/app/(patient)/intake/chat.tsx index 8c7bce9..2102a3e 100644 --- a/apps/mobile/app/(patient)/intake/chat.tsx +++ b/apps/mobile/app/(patient)/intake/chat.tsx @@ -75,6 +75,8 @@ export default function ChatScreen() { const setRisk = useSession((s) => s.setRisk); const setProgress = useSession((s) => s.setProgress); const clearRisk = useSession((s) => s.clearRisk); + const pendingInjection = useSession((s) => s.pendingInjection); + const clearPendingInjection = useSession((s) => s.clearPendingInjection); // 키보드 높이를 직접 측정해 입력창을 밀어올린다(KeyboardAvoidingView가 이 // 구성에서 불안정해 결정적 방식으로 대체). 컨테이너 하단 패딩 = 키보드 높이. @@ -324,25 +326,38 @@ export default function ChatScreen() { if (status !== "open") setAwaitingAi(false); }, [status]); - const onSend = () => { - const content = draft.trim(); - if (!content || !clientRef.current) return; - if (status !== "open") return; + // 낙관적 버블 + WS 전송 공통 경로. 성공 시 true. draft 지우기는 호출측 책임. + const sendContent = (content: string): boolean => { + if (!content || !clientRef.current || status !== "open") return false; // Insert AFTER we confirm the socket is ready, to avoid orphan optimistic bubbles. const idempotencyKey = Crypto.randomUUID(); const sent = clientRef.current.sendMessage({ content, idempotencyKey }); - if (!sent) return; + if (!sent) return false; addUserMessage({ id: idempotencyKey, role: "user", content, sentAt: Date.now(), }); - setDraft(""); setMediumBanner(null); setAwaitingAi(true); // AI 응답 대기 — "입력 중…" 표시 + return true; + }; + + const onSend = () => { + if (sendContent(draft.trim())) setDraft(""); }; + // FR-048 — OCR 확인([확인 완료])이 큐잉한 요약을 소켓 open 시 사용자 메시지로 + // 흘려보낸다. F1이 인지·응답하고 conversation_history(→리포트)에 남는다. + // 소켓이 아직 닫혀 있으면 큐를 유지했다가 open되는 즉시 전송된다. + useEffect(() => { + if (!pendingInjection || status !== "open") return; + if (sendContent(pendingInjection)) clearPendingInjection(); + // sendContent는 매 렌더 새로 만들어지므로 deps에서 제외(전송은 멱등키로 1회). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pendingInjection, status]); + const statusLabel = useMemo(() => { switch (status) { case "connecting": diff --git a/apps/mobile/app/(patient)/intake/ocr-confirm.tsx b/apps/mobile/app/(patient)/intake/ocr-confirm.tsx index 0367ba5..68dbb83 100644 --- a/apps/mobile/app/(patient)/intake/ocr-confirm.tsx +++ b/apps/mobile/app/(patient)/intake/ocr-confirm.tsx @@ -2,8 +2,8 @@ * v3 FR-048 — OCR 결과 확인 화면. * * 대화에서 첨부한 처방전/진단서를 업로드→인식하고, 추출 결과를 카드로 보여준다. - * 저신뢰 항목("확인 필요")은 빨간 배지로 표시한다. [확인 완료]를 눌러야 확정되며, - * 그 시점에만 리포트에 반영된다(현재는 확인 후 대화로 복귀 — 리포트 반영은 제출 시). + * 저신뢰 항목("확인 필요")은 빨간 배지로 표시한다. [확인 완료]를 누르면 확정 요약을 + * 대화로 흘려보내(FR-048) F1이 인지·응답하고 conversation_history(→리포트)에 남는다. */ import { router, useLocalSearchParams } from "expo-router"; @@ -27,6 +27,28 @@ const DOC_LABEL: Record = { unknown: "문서", }; +/** + * 확정된 OCR 요약을 대화용 한국어 한 문장으로 조립한다(FR-048). 사용자 말풍선으로 + * 전송돼 F1이 복용약/진단을 인지하고 후속 질문에 활용한다. 추출값이 전혀 없으면 + * null → 주입을 건너뛴다(빈 첨부 안내 방지). + */ +function buildInjection(r: OCRResult): string | null { + const s = r.extractedSummary; + const docLabel = DOC_LABEL[r.documentType] ?? "문서"; + const parts: string[] = []; + if (s.diagnoses.length > 0) parts.push(`진단은 ${s.diagnoses.join(", ")}`); + if (s.medications.length > 0) { + const meds = s.medications + .map((m) => [m.name, m.dose, m.frequency].filter(Boolean).join(" ")) + .filter(Boolean) + .join(", "); + if (meds) parts.push(`처방약은 ${meds}`); + } + if (s.department) parts.push(`진료과는 ${s.department}`); + if (parts.length === 0) return null; + return `${docLabel}을 첨부했어요. ${parts.join(", ")}예요.`; +} + type Phase = "loading" | "ready" | "error"; export default function OcrConfirmScreen() { @@ -34,6 +56,7 @@ export default function OcrConfirmScreen() { const { uri, name, mime } = useLocalSearchParams<{ uri: string; name: string; mime: string }>(); const accessToken = useAuth((s) => s.accessToken); const sessionId = useSession((s) => s.sessionId); + const setPendingInjection = useSession((s) => s.setPendingInjection); const [phase, setPhase] = useState("loading"); const [result, setResult] = useState(null); @@ -155,12 +178,19 @@ export default function OcrConfirmScreen() { ) : null} - 확인한 내용만 사전 문진 리포트에 참고 자료로 반영돼요. + 확인한 내용은 대화에 반영되고 사전 문진 리포트에 참고 자료로 담겨요. -