+
Open document
- {evidence ? (
-
- Open evidence
-
- ) : null}
);
@@ -590,13 +550,6 @@ function evidenceTone(type: EvidenceType): "teal" | "amber" | "blue" | "violet"
return "violet";
}
-function EvidenceTypeIcon({ type, className }: { type: EvidenceType; className?: string }) {
- if (type === "table") return
;
- if (type === "quote") return
;
- if (type === "image") return
;
- return
;
-}
-
function MonitoringRowCards({ compact = false }: { compact?: boolean }) {
return (
@@ -628,22 +581,194 @@ function MonitoringRowCards({ compact = false }: { compact?: boolean }) {
);
}
+// --- Live document search wiring -------------------------------------------------
+// The command centre queries the real retrieval pipeline via POST /api/search
+// (mode: "documents") rather than filtering the in-file fixtures above. Only the
+// results table + mobile cards are data-bound; the reader/evidence views below still
+// render fixtures.
+
+type SearchResultChunk = {
+ document_id: string;
+ section_heading?: string | null;
+ page_number?: number | null;
+ content?: string | null;
+ source_metadata?: {
+ source_title?: string | null;
+ publisher?: string | null;
+ version?: string | null;
+ review_date?: string | null;
+ publication_date?: string | null;
+ document_status?: string | null;
+ indexed_at?: string | null;
+ } | null;
+};
+
+type SearchApiResponse = {
+ documentMatches?: DocumentMatch[];
+ results?: SearchResultChunk[];
+};
+
+type DocumentSearchRow = {
+ documentId: string;
+ title: string;
+ metaLine: string;
+ version: string | null;
+ statusLabel: string | null;
+ statusIsCurrent: boolean;
+ reviewLine: string | null;
+ updatedLine: string | null;
+ relevance: number;
+ page: number | null;
+ snippet: string;
+ imageCount: number;
+ tableCount: number;
+ href: string;
+};
+
+// document_status enum -> display label. Unknown/absent statuses render no pill rather
+// than a misleading one.
+const documentStatusLabels: Record = {
+ current: "Current",
+ active: "Current",
+ published: "Current",
+ approved: "Current",
+ under_review: "Review due",
+ review_due: "Review due",
+ in_review: "Review due",
+ draft: "Draft",
+ superseded: "Superseded",
+ archived: "Archived",
+ expired: "Expired",
+ retired: "Retired",
+ unknown: "",
+};
+
+function humanizeStatus(status: string) {
+ return status
+ .split(/[_\s]+/)
+ .filter(Boolean)
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
+ .join(" ");
+}
+
+// Exported for unit testing. Formats in UTC on purpose: a date-only ISO value like
+// "2026-07-10" parses to UTC midnight, so formatting in local time would roll back to
+// the previous calendar day in negative-offset zones (e.g. America/New_York). en-GB
+// day-month-year matches this Australian clinical KB's existing date style.
+export function formatDateish(value: string | null | undefined): string | null {
+ const trimmed = value?.trim();
+ if (!trimmed) return null;
+ // Bare year or free text (e.g. "2026"): render verbatim.
+ if (!/\d{4}-\d{2}/.test(trimmed)) return trimmed;
+ const date = new Date(trimmed);
+ if (Number.isNaN(date.getTime())) return trimmed;
+ return date.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric", timeZone: "UTC" });
+}
+
+function documentViewerHref(documentId: string, page: number | null, chunkId: string | null): string {
+ const params = new URLSearchParams();
+ if (page && page >= 1) params.set("page", String(page));
+ if (chunkId) params.set("chunk", chunkId);
+ const suffix = params.toString();
+ return suffix ? `/documents/${documentId}?${suffix}` : `/documents/${documentId}`;
+}
+
+function toDocumentSearchRow(
+ match: DocumentMatch,
+ metadataByDocument: Map,
+): DocumentSearchRow {
+ const chunk = metadataByDocument.get(match.document_id);
+ const metadata = chunk?.source_metadata ?? null;
+ const rawStatus = metadata?.document_status?.trim().toLowerCase() ?? "";
+ const mappedStatus = rawStatus ? (documentStatusLabels[rawStatus] ?? humanizeStatus(rawStatus)) : "";
+ const statusLabel = mappedStatus || null;
+ const page = match.bestPages.find((value) => Number.isFinite(value) && value >= 1) ?? null;
+ const chunkId = match.bestChunkIds[0] ?? null;
+ const snippet =
+ match.summarySnippet?.trim() || chunk?.content?.trim() || match.matchReason?.trim() || "Matched indexed content.";
+ const source = metadata?.publisher?.trim() || metadata?.source_title?.trim() || null;
+ const metaLine = [source, match.file_name]
+ .filter((value): value is string => Boolean(value && value.trim()))
+ .join(" · ");
+ const review = formatDateish(metadata?.review_date);
+ return {
+ documentId: match.document_id,
+ title: match.title,
+ metaLine: metaLine || match.file_name,
+ version: metadata?.version?.trim() || null,
+ statusLabel,
+ statusIsCurrent: statusLabel === "Current",
+ reviewLine: review ? `Review ${review}` : null,
+ updatedLine: formatDateish(metadata?.indexed_at) ?? formatDateish(metadata?.publication_date),
+ relevance: documentRelevancePercent({ relevance: match.relevance, score: match.score }),
+ page,
+ snippet,
+ imageCount: match.imageCount,
+ tableCount: match.tableCount,
+ href: documentViewerHref(match.document_id, page, chunkId),
+ };
+}
+
+// Sources/Tables/Images filter the real evidence signals we have; the API doesn't tag
+// quote/related evidence per document, so those lenses show the full match list.
+function rowMatchesEvidenceType(row: DocumentSearchRow, type: "all" | EvidenceType): boolean {
+ if (type === "table") return row.tableCount > 0;
+ if (type === "image") return row.imageCount > 0;
+ return true;
+}
+
export function MasterDocumentSearch() {
const searchParams = useSearchParams();
const query = searchParams.get("q")?.trim() || defaultQuery;
+ const { authorizationHeader } = useAuthSession();
const [type, setType] = useState<"all" | EvidenceType>("all");
+ const [rows, setRows] = useState([]);
+ const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
+
+ useEffect(() => {
+ const controller = new AbortController();
+ // Defer all state writes out of the synchronous effect body (React would otherwise
+ // cascade an extra render); a 0ms timer also coalesces rapid query changes.
+ const timer = window.setTimeout(() => {
+ if (query.length < 2) {
+ setRows([]);
+ setStatus("ready");
+ return;
+ }
+ setStatus("loading");
+ fetch("/api/search", {
+ method: "POST",
+ headers: { "Content-Type": "application/json", ...authorizationHeader },
+ body: JSON.stringify({ query, mode: "documents", documentLimit: 24, topK: 20, includeRelatedDocuments: true }),
+ signal: controller.signal,
+ })
+ .then(async (response) => {
+ if (!response.ok) throw new Error(`Search failed (${response.status})`);
+ return (await response.json()) as SearchApiResponse;
+ })
+ .then((payload) => {
+ const metadataByDocument = new Map();
+ for (const chunk of payload.results ?? []) {
+ if (!metadataByDocument.has(chunk.document_id)) metadataByDocument.set(chunk.document_id, chunk);
+ }
+ setRows((payload.documentMatches ?? []).map((match) => toDocumentSearchRow(match, metadataByDocument)));
+ setStatus("ready");
+ })
+ .catch((error: unknown) => {
+ if (error instanceof DOMException && error.name === "AbortError") return;
+ setRows([]);
+ setStatus("error");
+ });
+ }, 0);
+ return () => {
+ window.clearTimeout(timer);
+ controller.abort();
+ };
+ }, [query, authorizationHeader]);
- const filtered = useMemo(() => {
- const lowered = query.toLowerCase();
- return documents.filter((document) => {
- const matchesQuery =
- document.title.toLowerCase().includes(lowered) ||
- document.snippet.toLowerCase().includes(lowered) ||
- document.terms.some((term) => lowered.includes(term.toLowerCase()) || term.toLowerCase().includes(lowered));
- const matchesType = type === "all" || document.evidence.some((item) => item.type === type);
- return matchesQuery && matchesType;
- });
- }, [query, type]);
+ const filtered = useMemo(() => rows.filter((row) => rowMatchesEvidenceType(row, type)), [rows, type]);
+ const loading = status === "loading";
+ const errored = status === "error";
return (
@@ -741,7 +866,9 @@ export function MasterDocumentSearch() {
- {filtered.length === 0 ? (
+ {loading ? (
+
+
+ Searching “{query}”…
+
+ ) : errored ? (
+
+
+ Search is unavailable right now
+
+
Retry in a moment.
+
+ ) : filtered.length === 0 ? (
No documents match “{query}”
@@ -790,12 +929,10 @@ export function MasterDocumentSearch() {
) : null}
- ) : null}
- {filtered.map((document, index) => {
- const rowEvidence = evidenceForType(document, type);
- return (
+ ) : (
+ filtered.map((row, index) => (
Best match : Relevant}
- {document.kind} · {document.version} · {document.source}
+ {row.metaLine}
+ {row.version ? ` · ${row.version}` : ""}