From 5eda7d7a159cf919eff2be8bae50c562ff77e6d2 Mon Sep 17 00:00:00 2001 From: Dae Hyeon Kim Date: Sat, 25 Jul 2026 21:00:31 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(web):=20clinician=20RiskEventCard=20?= =?UTF-8?q?=EB=8F=99=EC=8B=9C=EB=B0=9C=EC=83=9D=20category=20=EB=B0=B0?= =?UTF-8?q?=EC=A7=80=20=EB=A0=8C=EB=8D=94=20+=20=EA=B3=84=EC=95=BD=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CVR-051/REV-015 RiskEventCard 동시발생 category 배지 렌더 수정 + 회귀 테스트(RiskEventCard.test.tsx), HandoffReportView/lib/api.ts 클라이언트 계약 정렬(BUG-062~066 후속). --- apps/web/components/HandoffReportView.tsx | 57 +++++------ apps/web/components/RiskEventCard.test.tsx | 60 +++++++++++ apps/web/components/RiskEventCard.tsx | 110 +++++++++++++++++++++ apps/web/lib/api.ts | 40 ++++---- 4 files changed, 217 insertions(+), 50 deletions(-) create mode 100644 apps/web/components/RiskEventCard.test.tsx diff --git a/apps/web/components/HandoffReportView.tsx b/apps/web/components/HandoffReportView.tsx index f30e143..20535c8 100644 --- a/apps/web/components/HandoffReportView.tsx +++ b/apps/web/components/HandoffReportView.tsx @@ -1,4 +1,5 @@ import type { + EvidencePacket, HandoffNarrative, HandoffReport, QuestionnaireScore, @@ -75,44 +76,40 @@ function ListField({ label, items }: { label: string; items: string[] }) { ); } -function Narrative({ n }: { n: HandoffNarrative }) { - const saa = n.sleep_appetite_activity; - const saaText = [ - saa.sleep ? `수면: ${saa.sleep}` : null, - saa.appetite ? `식욕: ${saa.appetite}` : null, - saa.activity ? `활동: ${saa.activity}` : null, - ] - .filter(Boolean) - .join(" · "); +function EvidenceRow({ e }: { e: EvidencePacket }) { + return ( +
+ {e.source_type}{" "} + ({e.source_ref}): {e.content_summary} +
+ ); +} +// BUG-066 fix: `HandoffResponse` (contracts.handoff, apps/api<->ai-server) +// was realigned to ai-server's real `HandoffOutput` shape — the previous +// chief_complaint/present_illness/... fields this component read were never +// actually produced by ai-server (BUG-066's root cause: silent request-side +// field drop + response-side ValidationError, "status":"failed" every time +// live). `report_markdown` is the primary rendering surface going forward; +// `report_json`'s internal shape is UNVERIFIED (ai-server does not appear to +// populate it) so it is not parsed here. +function Narrative({ n }: { n: HandoffNarrative }) { return (
- - - - - - - - - - - +
+
리포트
+
{n.report_markdown}
+
+ - {n.evidence && n.evidence.length > 0 ? ( + {n.evidence_packets && n.evidence_packets.length > 0 ? (
- 원문 근거 ({n.evidence.length}) + 원문 근거 ({n.evidence_packets.length})
- {n.evidence.map((e, i) => ( -
- {e.field}:{" "} - “{e.quote}” -
+ {n.evidence_packets.map((e, i) => ( + ))}
diff --git a/apps/web/components/RiskEventCard.test.tsx b/apps/web/components/RiskEventCard.test.tsx new file mode 100644 index 0000000..8bebab8 --- /dev/null +++ b/apps/web/components/RiskEventCard.test.tsx @@ -0,0 +1,60 @@ +import { parseAiCategoryEvidence } from "./RiskEventCard"; + +// Regression test for CVR-051 / RM-16 fix (BRIEF: PLAN-2026-W30-INTEG-REV4, qa RM-17). +// `parseAiCategoryEvidence` is pure and side-effect free; these cases mirror the +// scenarios independently re-executed via plain Node during the RM-17 gate because +// jest could not run in the sandbox (pre-existing Bun/node shim `TypeError: +// Attempted to assign to readonly property`, reproduces identically on the +// untouched RiskBadge.test.tsx). +describe("parseAiCategoryEvidence", () => { + it("recovers co-occurring categories and the detected source", () => { + expect( + parseAiCategoryEvidence([ + "ai_category:suicidal_ideation", + "ai_category:harm_to_others", + "category_source:detected", + ]) + ).toEqual({ + categories: ["suicidal_ideation", "harm_to_others"], + source: "detected", + unrecognized: [], + }); + }); + + it("reports fallback_default with no recognized category", () => { + expect(parseAiCategoryEvidence(["category_source:fallback_default"])).toEqual({ + categories: [], + source: "fallback_default", + unrecognized: [], + }); + }); + + it("routes unknown or malformed entries to unrecognized only", () => { + expect(parseAiCategoryEvidence(["unknown_tag", 123, null])).toEqual({ + categories: [], + source: null, + unrecognized: ["unknown_tag", "123", "null"], + }); + }); + + it("returns empty result for undefined or empty input", () => { + expect(parseAiCategoryEvidence(undefined)).toEqual({ + categories: [], + source: null, + unrecognized: [], + }); + expect(parseAiCategoryEvidence([])).toEqual({ + categories: [], + source: null, + unrecognized: [], + }); + }); + + it("treats an unmapped ai_category:* tag as unrecognized, not a category", () => { + expect(parseAiCategoryEvidence(["ai_category:not_a_real_tag"])).toEqual({ + categories: [], + source: null, + unrecognized: ["ai_category:not_a_real_tag"], + }); + }); +}); diff --git a/apps/web/components/RiskEventCard.tsx b/apps/web/components/RiskEventCard.tsx index 36d9944..5a91611 100644 --- a/apps/web/components/RiskEventCard.tsx +++ b/apps/web/components/RiskEventCard.tsx @@ -15,8 +15,117 @@ const legalBasisLabel: Record = { self_hotline_only: "옵트아웃 (본인용 안내)", }; +// CVR-051 (RM-13/RM-16 follow-up): `RiskEvent.ai_evidence.matched_keywords` +// carries the FULL ai-server category list as sentinel strings (see +// `apps/api/src/services/chat.py::_crisis_evidence_keywords`) so a +// co-occurring category (e.g. `harm_to_others` alongside a `SUICIDE` +// primary) survives `_map_crisis_category`'s single-value collapse. Prior +// to this fix that survived list was only visible as raw JSON in a +// collapsed `
` block — invisible at a glance. This map + parser +// surface it as structured Korean badges at the top of the card instead. +// +// NOTE: this is a DIFFERENT tag namespace than `categoryLabel` above +// (`RiskEventOut.category`, the single collapsed value) — these are the +// raw ai-server category tags. Korean wording here is risk-TYPE phrasing +// (not disease names) per NFR v3-2; exact wording pending clinical- +// validator confirmation (CVR-051 RM-18 follow-up). +const AI_CATEGORY_PREFIX = "ai_category:"; +const SOURCE_DETECTED = "category_source:detected"; +const SOURCE_FALLBACK_DEFAULT = "category_source:fallback_default"; + +const aiCategoryLabel: Record = { + suicidal_ideation: "자살 사고", + self_harm: "자해", + self_harm_overdose: "자해(과다복용)", + harm_to_others: "타해 위험", + distress: "정서적 고통", + despair: "절망감", +}; + +// Categories rendered with a visually distinct (stronger) badge style so a +// co-occurring duty-to-warn-relevant tag isn't lost behind the primary +// `RiskBadge`. Currently just `harm_to_others` per CVR-051's finding; +// extend here if clinical-validator flags another tag as needing the same +// treatment. +const AI_CATEGORY_EMPHASIS = new Set(["harm_to_others"]); + +export type ParsedAiCategoryEvidence = { + /** Recognized `ai_category:` tags, in the order ai-server sent them. */ + categories: string[]; + /** `detected` | `fallback_default` | null (no recognized source tag present). */ + source: "detected" | "fallback_default" | null; + /** Anything that didn't match a known sentinel shape — triggers the raw-JSON fallback. */ + unrecognized: string[]; +}; + +/** + * Parse the `ai_category:*` / `category_source:*` sentinel strings out of + * `RiskEvent.ai_evidence.matched_keywords`. Pure and side-effect free so it + * can be unit-tested without a running frontend. + */ +export function parseAiCategoryEvidence(matchedKeywords: unknown): ParsedAiCategoryEvidence { + const result: ParsedAiCategoryEvidence = { categories: [], source: null, unrecognized: [] }; + if (!Array.isArray(matchedKeywords)) { + return result; + } + for (const raw of matchedKeywords) { + if (typeof raw !== "string") { + result.unrecognized.push(String(raw)); + continue; + } + if (raw === SOURCE_DETECTED) { + result.source = "detected"; + } else if (raw === SOURCE_FALLBACK_DEFAULT) { + result.source = "fallback_default"; + } else if (raw.startsWith(AI_CATEGORY_PREFIX)) { + const tag = raw.slice(AI_CATEGORY_PREFIX.length); + if (tag && aiCategoryLabel[tag]) { + result.categories.push(tag); + } else { + result.unrecognized.push(raw); + } + } else { + result.unrecognized.push(raw); + } + } + return result; +} + +function AiCategoryBadges({ evidence }: { evidence: ParsedAiCategoryEvidence }) { + if (evidence.categories.length === 0 && evidence.source === null) { + return null; + } + return ( +
+ {evidence.categories.map((tag) => { + const emphasized = AI_CATEGORY_EMPHASIS.has(tag); + return ( + + {emphasized ? ⚠ : null} + {aiCategoryLabel[tag]} + + ); + })} + {evidence.source === "fallback_default" ? ( + + 기본값(미검출) + + ) : null} +
+ ); +} + export function RiskEventCard({ event }: { event: RiskEventOut }) { const c = riskColor[event.level]; + const matchedKeywords = event.aiEvidence ? (event.aiEvidence as { matched_keywords?: unknown }).matched_keywords : undefined; + const aiCategoryEvidence = parseAiCategoryEvidence(matchedKeywords); return (
@@ -25,6 +134,7 @@ export function RiskEventCard({ event }: { event: RiskEventOut }) { {new Date(event.detectedAt).toLocaleString("ko-KR")}
+

{event.category ? categoryLabel[event.category] ?? event.category : ""}

diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index 9e9f003..d709d11 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -213,29 +213,29 @@ export type ReportPatient = { gender: string | null; }; -export type Citation = { - field: string; - source_message_id: string; - quote: string; +// BUG-066 fix (EXP-031 fix_wave_design.md): `HandoffNarrative` now mirrors +// the realigned `contracts.handoff.HandoffResponse` (report_markdown-primary) +// instead of the pre-fix invented chief_complaint/present_illness shape +// ai-server never produced. `report_json` is `UNVERIFIED`/usually `None` +// (no `report_json=` assignment found in ai-server's `handoff_generator.py` +// as of this fix) — `HandoffReportView` renders `report_markdown` as the +// primary surface and does not assume `report_json`'s internal shape. +export type EvidencePacket = { + evidence_id: string; + source_type: string; + source_ref: string; + content_summary: string; }; export type HandoffNarrative = { - chief_complaint: string; - present_illness: string; - symptoms: string[]; - onset: string | null; - recent_changes: string | null; - triggers: string[]; - sleep_appetite_activity: { - sleep: string | null; - appetite: string | null; - activity: string | null; - }; - psych_history: string | null; - medications: string | null; - documents_summary: string[]; - clinician_attention: string[]; - evidence: Citation[]; + report_markdown: string; + report_json: Record | null; + report_pdf_base64: string | null; + trend_plot_base64: string | null; + evidence_packets: EvidencePacket[]; + missing_slots: string[]; + risk_level: string; + requires_human_review: boolean; }; export type HandoffReport = { From e8fc03fefecf877a5399e6cc96c9fb82a733c0e7 Mon Sep 17 00:00:00 2001 From: Dae Hyeon Kim Date: Sat, 25 Jul 2026 23:02:31 +0900 Subject: [PATCH 2/2] fix(web): ReportActions EMR text mirrors post-BUG-066 HandoffNarrative shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit toEmrText still read the pre-fix chief_complaint/present_illness/... fields (never produced by ai-server) after HandoffReportView.tsx moved to the report_markdown/evidence_packets shape — broke `tsc --noEmit` on rebase onto Master c0622ce. Mirrors the same fields HandoffReportView renders. --- apps/web/components/ReportActions.tsx | 32 +++++++++++---------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/apps/web/components/ReportActions.tsx b/apps/web/components/ReportActions.tsx index bd95f9d..fdd17e0 100644 --- a/apps/web/components/ReportActions.tsx +++ b/apps/web/components/ReportActions.tsx @@ -41,30 +41,24 @@ function toEmrText(report: HandoffReport, patientName: string): string { } L.push(""); } + // BUG-066 fix (see HandoffReportView.tsx): `HandoffNarrative` mirrors + // ai-server's real `report_markdown`-primary shape — the previous + // chief_complaint/present_illness/... fields here were never actually + // produced by ai-server. Mirror the same fields HandoffReportView renders. const n = report.narrative; if (n) { L.push("■ 요약"); - if (n.chief_complaint) L.push(`주호소: ${n.chief_complaint}`); - if (n.present_illness) L.push(`현병력: ${n.present_illness}`); - if (n.symptoms?.length) L.push(`주요 증상: ${n.symptoms.join(", ")}`); - if (n.onset) L.push(`시작 시점: ${n.onset}`); - if (n.recent_changes) L.push(`최근 변화: ${n.recent_changes}`); - const saa = n.sleep_appetite_activity; - const saaText = [ - saa?.sleep ? `수면 ${saa.sleep}` : null, - saa?.appetite ? `식욕 ${saa.appetite}` : null, - saa?.activity ? `활동 ${saa.activity}` : null, - ] - .filter(Boolean) - .join(" · "); - if (saaText) L.push(`수면/식욕/활동: ${saaText}`); - if (n.psych_history) L.push(`과거 정신건강 이력: ${n.psych_history}`); - if (n.medications) L.push(`복용약: ${n.medications}`); - if (n.clinician_attention?.length) L.push(`의료진 확인 필요: ${n.clinician_attention.join(", ")}`); - if (n.evidence?.length) { + L.push(n.report_markdown); + if (n.missing_slots?.length) { + L.push(""); + L.push(`누락된 항목: ${n.missing_slots.join(", ")}`); + } + if (n.evidence_packets?.length) { L.push(""); L.push("■ 원문 근거"); - for (const e of n.evidence) L.push(`- ${e.field}: "${e.quote}"`); + for (const e of n.evidence_packets) { + L.push(`- ${e.source_type} (${e.source_ref}): ${e.content_summary}`); + } } } L.push("");