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
26 changes: 9 additions & 17 deletions src/components/DocumentTagCloud.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import {
type SmartDocumentTag,
type SmartDocumentTagGroup,
} from "@/lib/document-tags";
import { documentTagGroupTone } from "@/lib/document-summary-badges";
import type { DocumentLabel } from "@/lib/types";
import { clinicalBadgeToneClass } from "@/components/clinical-dashboard/clinical-badge";
import { cn } from "@/components/ui-primitives";

type DocumentTagCloudProps = {
Expand Down Expand Up @@ -40,22 +42,12 @@ const groupIcon: Record<SmartDocumentTagGroup, typeof Tag> = {
Manual: Sparkles,
};

const groupTone: Record<SmartDocumentTagGroup, string> = {
Site: "border-[color:var(--border-lux)] bg-[color:var(--surface-subtle)] text-[color:var(--text-muted)]",
Medication: "border-[color:var(--primary)]/30 bg-[color:var(--primary-soft)]/45 text-[color:var(--primary)]",
Risk: "border-[color:var(--warning)]/30 bg-[color:var(--warning-soft)]/50 text-[color:var(--warning)]",
Workflow: "border-[color:var(--info)]/30 bg-[color:var(--info-soft)]/50 text-[color:var(--info)]",
Topic: "border-[color:var(--border-lux)] bg-[color:var(--surface-raised)] text-[color:var(--text-muted)]",
Population: "border-[color:var(--success)]/25 bg-[color:var(--success-soft)]/40 text-[color:var(--success)]",
Setting: "border-[color:var(--border-lux)] bg-[color:var(--surface-subtle)] text-[color:var(--text-muted)]",
Service: "border-[color:var(--primary)]/20 bg-[color:var(--surface-raised)] text-[color:var(--primary)]",
"Document type": "border-[color:var(--border-lux)] bg-[color:var(--surface-raised)] text-[color:var(--text-muted)]",
"Clinical action": "border-[color:var(--info)]/30 bg-[color:var(--info-soft)]/50 text-[color:var(--info)]",
"Care phase": "border-[color:var(--success)]/25 bg-[color:var(--success-soft)]/40 text-[color:var(--success)]",
"Document intent": "border-[color:var(--primary)]/20 bg-[color:var(--surface-raised)] text-[color:var(--primary)]",
"Content feature": "border-[color:var(--border-lux)] bg-[color:var(--surface-subtle)] text-[color:var(--text-muted)]",
Manual: "border-[color:var(--primary)]/35 bg-[color:var(--primary-soft)] text-[color:var(--primary)]",
};
// Chip colours come from the canonical semantic-tone system (see
// /reference/colour-coding) via the shared group→tone map, so document tag
// chips read the same as every other clinical badge in the app.
function groupToneClass(group: SmartDocumentTagGroup): string {
return clinicalBadgeToneClass(documentTagGroupTone[group]);
}

function confidenceTitle(tag: SmartDocumentTag) {
return `${tag.group}: ${tag.source} tag, ${Math.round(tag.confidence * 100)}% confidence`;
Expand All @@ -76,7 +68,7 @@ function DocumentTagChip({
const tagClassName = cn(
"inline-flex max-w-full items-center gap-1 rounded-md border font-semibold shadow-[var(--shadow-inset)]",
compact ? "min-h-6 px-2 text-3xs" : "min-h-7 px-2 text-2xs",
groupTone[tag.group],
groupToneClass(tag.group),
tag.queryMatched && "ring-2 ring-[color:var(--focus)]/25",
selected && "ring-2 ring-[color:var(--primary)]/35",
onTagClick &&
Expand Down
353 changes: 233 additions & 120 deletions src/components/DocumentViewer.tsx

Large diffs are not rendered by default.

170 changes: 169 additions & 1 deletion src/lib/demo-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type {
ChunkImage,
ClinicalDocument,
DocumentBreakdown,
DocumentLabel,
DocumentSummary,
IngestionJob,
RagAnswer,
SearchResult,
Expand Down Expand Up @@ -286,6 +288,157 @@ export const demoJobs: IngestionJob[] = demoDocuments.map((document, index) => (
updated_at: now,
}));

// Labels + stored summaries let the document viewer's high-yield summary,
// badge cluster, and tag cloud render in demo mode. The lithium summary
// deliberately reproduces the stored-summary failure modes (glued header
// boilerplate, repeated passages, an inline numbered heading, and a mid-word
// truncated tail) that the display-time formatter repairs.
export const demoDocumentLabels: DocumentLabel[] = [
{
id: "88888888-8888-4888-8888-888888888801",
document_id: demoDocuments[0].id,
label: "fiona stanley hospital",
label_type: "site",
source: "generated",
confidence: 0.94,
},
{
id: "88888888-8888-4888-8888-888888888802",
document_id: demoDocuments[0].id,
label: "lithium",
label_type: "medication",
source: "generated",
confidence: 0.97,
},
{
id: "88888888-8888-4888-8888-888888888803",
document_id: demoDocuments[0].id,
label: "mood stabilisers",
label_type: "medication",
source: "generated",
confidence: 0.82,
},
{
id: "88888888-8888-4888-8888-888888888804",
document_id: demoDocuments[0].id,
label: "toxicity risk",
label_type: "risk",
source: "generated",
confidence: 0.9,
},
{
id: "88888888-8888-4888-8888-888888888805",
document_id: demoDocuments[0].id,
label: "prescribing",
label_type: "workflow",
source: "generated",
confidence: 0.86,
},
{
id: "88888888-8888-4888-8888-888888888806",
document_id: demoDocuments[0].id,
label: "contains monitoring schedule",
label_type: "content_feature",
source: "generated",
confidence: 0.8,
},
{
id: "88888888-8888-4888-8888-888888888807",
document_id: demoDocuments[1].id,
label: "clozapine",
label_type: "medication",
source: "generated",
confidence: 0.97,
},
{
id: "88888888-8888-4888-8888-888888888808",
document_id: demoDocuments[1].id,
label: "agranulocytosis risk",
label_type: "risk",
source: "generated",
confidence: 0.88,
},
{
id: "88888888-8888-4888-8888-888888888809",
document_id: demoDocuments[2].id,
label: "risk assessment",
label_type: "workflow",
source: "generated",
confidence: 0.85,
},
];

const demoProfileItem = (text: string, pages: number[]) => ({
text,
source_chunk_ids: [],
source_image_ids: [],
pages,
evidence_type: "text" as const,
support: "direct" as const,
});

export const demoDocumentSummaries: DocumentSummary[] = [
{
id: "99999999-9999-4999-8999-999999999901",
document_id: demoDocuments[0].id,
summary:
"OFFICIAL Guideline Lithium Therapy- Initiation and Continuation Reference #: FSFHG-HW-GUI-0017 Scope Site " +
"Service/Department/Unit Disciplines Fiona Stanley Hospital Hospital Wide Medical, Nursing, Pharmacy " +
"Fremantle Hospital Lithium is a high-risk medication with a narrow therapeutic index. Careful patient " +
"selection and monitoring is required to minimise the risk of lithium toxicity. 1. Introduction Hospital " +
"Wide Medical, Nursing, Pharmacy Fremantle Hospital Lithium is a high-risk medication with a narrow " +
"therapeutic index. Careful patient selection and monitoring is required to minimise the risk of lithium " +
"toxicity. 1. Introduction In this synthetic protocol, lithium levels are checked 5 to 7 days after " +
"initiation or dose change, then repeated until stable. After stability the sample schedule uses lithium " +
"levels every 3 months, renal and thyroid tests every 6 months, and calcium annually. The therapeutic " +
"effect occurs gradually and may take up to three weeks. Escalate review for vomiting, diarrhoea, " +
"dehydration, acute kidney injury, new NSAID/ACE inhibitor/diuretic exposure, tremor, confusion, or " +
"ataxia. therapeutic effect occurs gradually and may take up to three weeks. Lithium is a narro",
clinical_specifics: {},
source_chunk_ids: [],
source_image_ids: [],
model: null,
},
{
id: "99999999-9999-4999-8999-999999999902",
document_id: demoDocuments[1].id,
summary:
"Synthetic clozapine monitoring summary covering FBC/ANC checks, myocarditis screening, metabolic " +
"monitoring, and constipation prevention.",
clinical_specifics: {
profile: {
overview:
"Synthetic clozapine monitoring protocol emphasising FBC/ANC monitoring, myocarditis symptom screening, metabolic monitoring, and constipation prevention. Demo only, not clinical guidance.",
applies_to: [demoProfileItem("Adults prescribed clozapine in the synthetic shared-care demo pathway.", [1])],
key_clinical_actions: [
demoProfileItem("Record baseline FBC/ANC, weight, lipids, glucose/HbA1c, and bowel history.", [1]),
demoProfileItem("Screen for myocarditis symptoms during initiation.", [1]),
],
medication_dose_monitoring: [
demoProfileItem("Continue scheduled FBC/ANC monitoring per the synthetic protocol table.", [2]),
],
thresholds_timing: [],
escalation_risk_warnings: [
demoProfileItem(
"Urgent review for fever, chest pain, dyspnoea, tachycardia, marked sedation, seizures, or severe constipation.",
[1],
),
],
required_forms_documentation: [],
not_covered: [],
important_tables_images: [
demoProfileItem("Monitoring domains table across baseline, initiation, and ongoing care.", [2]),
],
best_questions: [],
source_quality_notes: [],
},
},
source_chunk_ids: [],
source_image_ids: [],
model: null,
},
];

export function getDemoDocument(id: string) {
return demoDocuments.find((document) => document.id === id) ?? null;
}
Expand All @@ -295,6 +448,8 @@ export function getDemoDocumentPayload(id: string, chunkId?: string | null) {
if (!document) return null;
const pages = demoPages.filter((page) => page.document_id === id);
const images = demoImages.filter((image) => image.document_id === id);
const labels = demoDocumentLabels.filter((label) => label.document_id === id);
const summary = demoDocumentSummaries.find((row) => row.document_id === id) ?? null;
const chunks = demoChunks
.filter((chunk) => chunk.document_id === id)
.filter((chunk) => !chunkId || chunk.id === chunkId)
Expand All @@ -308,7 +463,20 @@ export function getDemoDocumentPayload(id: string, chunkId?: string | null) {
image_ids: chunk.image_ids,
}));

return { document, pages, images, chunks };
// Shape mirrors the live GET /api/documents/[id] response (labels + summary
// joined onto the document, indexHealth synthesized from metadata).
return {
document: { ...document, labels, summary },
pages,
images,
chunks,
indexHealth: {
extractionQuality: syntheticMetadata.extraction_quality,
indexedAt: syntheticMetadata.indexed_at,
indexVersion: "rag-deep-memory-v1",
warnings: [],
},
};
}

const queryTerms: Record<string, string[]> = {
Expand Down
134 changes: 134 additions & 0 deletions src/lib/document-summary-badges.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// Badge derivation for the document viewer's high-yield summary card.
//
// Framework-free (mirrors medication-badges.ts): data in, ClinicalBadge-shaped
// items out. Two inputs feed the cluster — safety-relevant document labels
// (Risk / Medication / Clinical action) and phrases detected in the stored
// summary text itself ("narrow therapeutic index", "Schedule 8", …). Tone
// rules follow the badge governance in docs/clinical-badge-system-guide.md:
// danger is reserved for true contraindications; regulatory/caution signals
// (S8, high-risk, toxicity) are warnings.

import { buildSmartDocumentTags, type SmartDocumentTagGroup } from "@/lib/document-tags";
import { sortBySemanticTonePriority, type SemanticIconKey, type SemanticTone } from "@/lib/semantic-tone";
import type { DocumentLabel } from "@/lib/types";

export type DocumentSummaryBadge = {
id: string;
label: string;
tone: SemanticTone;
iconKey?: SemanticIconKey;
};

type DocumentLabelLike = Pick<DocumentLabel, "label" | "label_type" | "source" | "confidence" | "metadata">;

// Canonical tone for each smart-tag group. Shared with DocumentTagCloud so the
// tag chips and the summary badge cluster speak the same colour language.
export const documentTagGroupTone: Record<SmartDocumentTagGroup, SemanticTone> = {
Risk: "warning",
Medication: "clinical",
"Clinical action": "clinical",
Workflow: "info",
Manual: "success",
Site: "neutral",
Topic: "neutral",
Population: "neutral",
Setting: "neutral",
Service: "neutral",
"Document type": "neutral",
"Care phase": "neutral",
"Document intent": "neutral",
"Content feature": "neutral",
};

// Groups whose labels are important enough to promote into the badge cluster;
// the rest stay in the browse-by-tag cloud.
const badgeWorthyGroups = new Set<SmartDocumentTagGroup>(["Risk", "Medication", "Clinical action"]);

type SummaryPhraseRule = {
id: string;
pattern: RegExp;
label: string;
tone: SemanticTone;
iconKey?: SemanticIconKey;
};

// Phrase catalogue over the stored summary text. Every rule here is also
// registered in SEMANTIC_FLAG_CATALOGUE (document domain) so the
// /reference/colour-coding legend stays complete.
const summaryPhraseRules: SummaryPhraseRule[] = [
{ id: "summary-contraindication", pattern: /contraindicat/i, label: "Contraindications", tone: "danger" },
Comment thread
BigSimmo marked this conversation as resolved.
Comment thread
BigSimmo marked this conversation as resolved.
{
id: "summary-narrow-therapeutic-index",
pattern: /narrow therapeutic (?:index|window|range)/i,
label: "Narrow therapeutic index",
tone: "warning",
},
{
id: "summary-high-risk-medication",
pattern: /high[- ]?risk medication/i,
label: "High-risk medication",
tone: "warning",
},
{
id: "summary-controlled-drug",
pattern: /\bschedule\s*8\b|\bS8\b/i,
label: "Schedule 8",
tone: "warning",
iconKey: "controlled",
},
{ id: "summary-toxicity", pattern: /\btoxic(?:ity)?\b/i, label: "Toxicity risk", tone: "warning" },
{
id: "summary-escalation",
pattern: /\b(?:escalat\w*|urgent(?:ly)? review\w*|emergency)\b/i,
label: "Escalation criteria",
tone: "warning",
},
{
id: "summary-pregnancy",
pattern: /\b(?:pregnan\w*|lactation|breastfeed\w*)\b/i,
label: "Pregnancy & lactation",
tone: "warning",
},
{
id: "summary-monitoring",
pattern: /\b(?:monitor(?:ing|ed)?|serum levels?|blood tests?|fbc|anc|ecg|qtc)\b/i,
label: "Monitoring required",
tone: "info",
},
];

export function buildDocumentSummaryBadges({
labels,
summaryText,
limit = 8,
}: {
labels?: DocumentLabelLike[] | null;
summaryText?: string | null;
limit?: number;
}): DocumentSummaryBadge[] {
const badges: DocumentSummaryBadge[] = [];
const seenIds = new Set<string>();
const seenLabels = new Set<string>();

const push = (badge: DocumentSummaryBadge) => {
const labelKey = badge.label.toLowerCase();
if (seenIds.has(badge.id) || seenLabels.has(labelKey)) return;
seenIds.add(badge.id);
seenLabels.add(labelKey);
badges.push(badge);
};

for (const tag of buildSmartDocumentTags(labels, { includeManualGroup: false })) {
if (!badgeWorthyGroups.has(tag.group)) continue;
push({ id: `label-${tag.key}`, label: tag.label, tone: documentTagGroupTone[tag.group] });
}

if (summaryText) {
for (const rule of summaryPhraseRules) {
if (!rule.pattern.test(summaryText)) continue;
push({ id: rule.id, label: rule.label, tone: rule.tone, iconKey: rule.iconKey });
}
}

return sortBySemanticTonePriority(badges).slice(0, Math.max(0, limit));
}
Loading
Loading