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
57 changes: 27 additions & 30 deletions apps/web/components/HandoffReportView.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
EvidencePacket,
HandoffNarrative,
HandoffReport,
QuestionnaireScore,
Expand Down Expand Up @@ -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 (
<blockquote className="border-l-2 border-state-info pl-3 text-sm text-text-secondary">
<span className="font-medium text-text-primary">{e.source_type}</span>{" "}
({e.source_ref}): {e.content_summary}
</blockquote>
);
}

// 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 (
<dl className="flex flex-col gap-4">
<Field label="주호소" value={n.chief_complaint} />
<Field label="현병력" value={n.present_illness} />
<ListField label="주요 증상" items={n.symptoms} />
<Field label="시작 시점" value={n.onset} />
<Field label="최근 변화" value={n.recent_changes} />
<ListField label="유발 요인" items={n.triggers} />
<Field label="수면 / 식욕 / 활동" value={saaText || null} />
<Field label="과거 정신건강 이력" value={n.psych_history} />
<Field label="복용약" value={n.medications} />
<ListField label="업로드 문서 요약" items={n.documents_summary} />
<ListField label="의료진 확인 필요" items={n.clinician_attention} />
<div className="flex flex-col gap-0.5">
<dt className="text-xs font-semibold text-text-secondary">리포트</dt>
<dd className="text-text-primary whitespace-pre-wrap">{n.report_markdown}</dd>
</div>
<ListField label="누락된 항목" items={n.missing_slots} />

{n.evidence && n.evidence.length > 0 ? (
{n.evidence_packets && n.evidence_packets.length > 0 ? (
<div className="flex flex-col gap-2">
<dt className="text-[11px] font-medium uppercase tracking-wide text-faint">
원문 근거 ({n.evidence.length})
원문 근거 ({n.evidence_packets.length})
</dt>
<dd className="flex flex-col gap-2.5">
{n.evidence.map((e, i) => (
<blockquote
key={i}
className="border-l-2 border-ink2 pl-3 text-[13px] leading-relaxed text-text-secondary"
>
<span className="font-semibold text-text-primary">{e.field}</span>:{" "}
“{e.quote}”
</blockquote>
{n.evidence_packets.map((e, i) => (
<EvidenceRow key={i} e={e} />
))}
</dd>
</div>
Expand Down
32 changes: 13 additions & 19 deletions apps/web/components/ReportActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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("");
Expand Down
60 changes: 60 additions & 0 deletions apps/web/components/RiskEventCard.test.tsx
Original file line number Diff line number Diff line change
@@ -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"],
});
});
});
110 changes: 110 additions & 0 deletions apps/web/components/RiskEventCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,117 @@ const legalBasisLabel: Record<string, string> = {
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 `<details>` 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<string, string> = {
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:<tag>` 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 (
<div className="flex flex-wrap items-center gap-1" data-testid="ai-category-badges">
{evidence.categories.map((tag) => {
const emphasized = AI_CATEGORY_EMPHASIS.has(tag);
return (
<span
key={tag}
className={
emphasized
? "inline-flex items-center gap-1 px-2 py-0.5 rounded-md border-2 border-red-400 bg-red-50 text-red-800 text-xs font-bold"
: "inline-flex items-center gap-1 px-2 py-0.5 rounded-md border border-slate-300 bg-slate-50 text-slate-700 text-xs font-semibold"
}
>
{emphasized ? <span aria-hidden>⚠</span> : null}
{aiCategoryLabel[tag]}
</span>
);
})}
{evidence.source === "fallback_default" ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md border border-dashed border-slate-300 bg-transparent text-slate-500 text-xs italic">
기본값(미검출)
</span>
) : null}
</div>
);
}

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 (
<article className={`rounded-xl border border-l-4 ${c.border} ${c.bg} p-4 flex flex-col gap-2.5`}>
<header className="flex items-center justify-between gap-2">
Expand All @@ -25,6 +134,7 @@ export function RiskEventCard({ event }: { event: RiskEventOut }) {
{new Date(event.detectedAt).toLocaleString("ko-KR")}
</time>
</header>
<AiCategoryBadges evidence={aiCategoryEvidence} />
<p className="text-text-primary font-semibold tracking-tight">
{event.category ? categoryLabel[event.category] ?? event.category : ""}
</p>
Expand Down
40 changes: 20 additions & 20 deletions apps/web/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | 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 = {
Expand Down
Loading