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/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("");
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 = {