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
2 changes: 1 addition & 1 deletion src/app/api/answer/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,7 +20,7 @@ import * as serverAuth from "@/lib/supabase/auth";
export const runtime = "nodejs";

const answerSchema = z.object({
query: z.string().trim().min(1),
query: z.string().trim().min(1).max(2000),
documentId: z.string().uuid().optional(),
documentIds: z.array(z.string().uuid()).max(25).optional(),
filters: searchScopeFiltersSchema.optional(),
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/answer/stream/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,7 +21,7 @@ import { logger } from "@/lib/logger";
export const runtime = "nodejs";

const answerSchema = z.object({
query: z.string().trim().min(1),
query: z.string().trim().min(1).max(2000),
documentId: z.string().uuid().optional(),
documentIds: z.array(z.string().uuid()).max(25).optional(),
filters: searchScopeFiltersSchema.optional(),
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/search/interaction/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,7 @@ import * as serverAuth from "@/lib/supabase/auth";
export const runtime = "nodejs";

const interactionSchema = z.object({
query: z.string().trim().min(1),
query: z.string().trim().min(1).max(2000),
documentId: z.string().uuid(),
chunkId: z.string().uuid().optional(),
fileName: z.string().trim().max(240).optional(),
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/search/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -41,7 +41,7 @@ const retrievalLogWriteMetrics: RetrievalLogWriteMetrics = {
};

const searchSchema = z.object({
query: z.string().trim().min(1),
query: z.string().trim().min(1).max(2000),
topK: z.number().int().min(1).max(20).optional(),
documentId: z.string().uuid().optional(),
documentIds: z.array(z.string().uuid()).max(25).optional(),
Expand Down
22 changes: 21 additions & 1 deletion src/components/clinical-dashboard/display-text.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,10 +50,30 @@ export function sanitizeDisplayText(value: string, options: DisplayTextSanitizeO
return looksLikeDisplayArtifact(trimmed) ? "" : trimmed;
}

// A clinical unit that should stay attached to a preceding bare number when a
// snippet is truncated (so "150 mg/day" or "1.5 ×10⁹/L" never lose their unit).
const TRUNCATION_UNIT_PATTERN =
/^(?:×10|x10|mg|mcg|microgram|micrograms|µg|μg|g|kg|ml|l|mmol|mol|umol|µmol|ng|units?|iu|hours?|hrs?|h|days?|weeks?|wk|months?|minutes?|mins?|years?|°c|mmhg|bpm|%)\b/i;
const TRUNCATION_TRAILING_CONNECTOR = /^(?:or|and|to|with|of|for|the|a|an|until|than|in|on|at|by)$/i;

function isBareNumberWord(word: string) {
return /^[<>≤≥~]?\d[\d.,–—-]*$/.test(word);
}

export function truncateWords(value: string, maxWords: number) {
const words = value.split(/\s+/).filter(Boolean);
if (words.length <= maxWords) return value;
return `${words.slice(0, maxWords).join(" ")}...`;
let end = maxWords;
// Keep a number attached to its following unit so a threshold/dose is never
// cut between the value and the unit.
if (isBareNumberWord(words[end - 1]) && words[end] && TRUNCATION_UNIT_PATTERN.test(words[end])) {
end += 1;
}
// Drop a dangling connector left at the very end ("... or", "... until").
while (end > 1 && TRUNCATION_TRAILING_CONNECTOR.test(words[end - 1])) {
end -= 1;
}
return `${words.slice(0, end).join(" ")}...`;
}

export function sourceSnippetKey(value: string) {
Expand Down
55 changes: 41 additions & 14 deletions src/lib/citations.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,23 +15,50 @@ export function citationFromResult(result: SearchResult): Citation {

export function formatCitationLabel(citation: Citation) {
const page = citation.page_number ? `p. ${citation.page_number}` : "source";
return `${citation.title || citation.file_name}, ${page}`;
const title = (citation.title || citation.file_name || "Source").trim();
return `${title}, ${page}`;
}

// Generic filler words dropped from compact citation labels so the label keeps
// the distinguishing words of the actual document title (e.g. drug/topic names)
// rather than collapsing to boilerplate.
const COMPACT_LABEL_STOPWORDS = new Set([
"the",
"a",
"an",
"and",
"or",
"of",
"for",
"to",
"in",
"on",
"with",
"guideline",
"guidelines",
"policy",
"procedure",
"document",
]);

function compactTitleWords(rawTitle: string) {
const cleaned = rawTitle
.replace(/\.(pdf|docx|xlsx|txt)$/i, "")
.replace(/\s+/g, " ")
.trim();
const words = cleaned.split(/\s+/).filter(Boolean);
const significant = words.filter((word) => !COMPACT_LABEL_STOPWORDS.has(word.toLowerCase()));
return (significant.length ? significant : words).slice(0, 3).join(" ") || "Source";
}

export function formatCompactCitationLabel(citation: Pick<Citation, "title" | "file_name" | "page_number">) {
const rawTitle = (citation.title || citation.file_name || "Source").replace(/^Synthetic\s+/i, "");
// Derive the compact label from the actual document title (first 1–2
// significant words). Do NOT special-case drug/keyword names: this chip is the
// affordance that tells a clinician which source they are opening, so
// collapsing every "...Risk..." title to "Risk", or dropping "lithium" when
// "clozapine" also appears, mislabels the cited source.
const shortTitle =
rawTitle
.replace(/\.(pdf|docx|xlsx|txt)$/i, "")
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.join(" ") || "Source";
// Derive the short label from the actual title (not a hardcoded drug whitelist)
// so real-corpus documents are labelled correctly instead of being mislabeled
// as one of a few demo drug names. Dropping filler words and keeping up to
// three significant words preserves both drugs in a multi-drug title (e.g.
// "Clozapine and lithium co-prescribing") rather than collapsing to "Clozapine and".
const rawTitle = (citation.title || citation.file_name || "Source").replace(/^Synthetic\s+/i, "").trim();
const shortTitle = compactTitleWords(rawTitle);
const page = citation.page_number ? `p.${citation.page_number}` : "source";
return `${shortTitle} ${page}`;
}
Expand Down
36 changes: 20 additions & 16 deletions src/lib/demo-data.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -346,6 +346,19 @@ export function demoSearch(query: string, topK = 8, documentId?: string, documen

export function demoAnswer(query: string, documentId?: string, documentIds?: string[]): RagAnswer {
const lowered = query.toLowerCase();
const mentionsLithium = lowered.includes("lithium") || lowered.includes("toxicity");
const mentionsClozapine =
lowered.includes("clozapine") || lowered.includes("table") || lowered.includes("image");
// The bare word "risk" is far too common to anchor a confident acute-risk
// answer (e.g. "bleeding risk with aspirin"); require genuine escalation/triage
// context so an incidental mention doesn't trigger a wrong-topic answer.
const mentionsAcuteRisk =
lowered.includes("escalat") ||
lowered.includes("senior") ||
lowered.includes("triage") ||
/\bacute risk\b/.test(lowered) ||
Comment thread
BigSimmo marked this conversation as resolved.
lowered.includes("means restriction") ||
lowered.includes("safety plan");
const broadMultiDocumentQuery =
lowered.includes("across") ||
lowered.includes("multiple") ||
Expand All@@ -355,11 +368,11 @@ export function demoAnswer(query: string, documentId?: string, documentIds?: str
broadMultiDocumentQuery || documentIds?.length
? undefined
: (documentId ??
(lowered.includes("clozapine") || lowered.includes("table") || lowered.includes("image")
(mentionsClozapine
? demoDocuments[1].id
: lowered.includes("risk") || lowered.includes("escalat") || lowered.includes("senior")
: mentionsAcuteRisk
? demoDocuments[2].id
: lowered.includes("lithium") || lowered.includes("toxicity")
: mentionsLithium
? demoDocuments[0].id
: undefined));
const sources = demoSearch(query, 6, inferredDocumentId, documentIds);
Expand All@@ -371,29 +384,20 @@ export function demoAnswer(query: string, documentId?: string, documentIds?: str
const conflictsOrGaps = detectConflictsOrGaps(sources);
const visualEvidence = buildVisualEvidence(sources);
const bestSource = selectBestSourceRecommendation(sources, quoteCards);
const supportedQuestion =
broadMultiDocumentQuery ||
lowered.includes("lithium") ||
lowered.includes("toxicity") ||
lowered.includes("clozapine") ||
lowered.includes("table") ||
lowered.includes("image") ||
lowered.includes("risk") ||
lowered.includes("escalat") ||
lowered.includes("senior");
const supportedQuestion = broadMultiDocumentQuery || mentionsLithium || mentionsClozapine || mentionsAcuteRisk;
let answer =
"These synthetic demo documents do not contain enough matching evidence to answer that question. Try one of the sample lithium, clozapine, or acute risk questions.";

if (broadMultiDocumentQuery) {
answer =
"Across the synthetic indexed documents, the high-yield clinical themes are medication monitoring and escalation triggers across Lithium, Clozapine, and acute risk workflows.";
} else if (lowered.includes("lithium") || lowered.includes("toxicity")) {
} else if (mentionsLithium) {
answer =
"In the synthetic lithium document, toxicity safety-net review should cover vomiting, diarrhoea, dehydration, acute kidney injury, new interacting medicines such as NSAIDs/ACE inhibitors/diuretics, tremor, confusion, and ataxia.";
} else if (lowered.includes("clozapine") || lowered.includes("table") || lowered.includes("image")) {
} else if (mentionsClozapine) {
answer =
"The synthetic clozapine table image highlights FBC/ANC, myocarditis, metabolic review, and constipation planning as the core monitoring domains.";
} else if (lowered.includes("risk") || lowered.includes("escalat") || lowered.includes("senior")) {
} else if (mentionsAcuteRisk) {
answer =
"The synthetic acute risk document highlights immediate safety, current intent, means restriction, protective factors, and senior review as the core escalation focus.";
}
Expand Down
7 changes: 6 additions & 1 deletion src/lib/rag-answer-text.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -130,7 +130,12 @@ export function sanitizeStructuredText(
normalized.search(answerSectionArtifactPattern) === 0
? normalized.replace(answerSectionArtifactPattern, "").trim()
: leakedKeyIndex > 0
? normalized.slice(0, leakedKeyIndex).trim()
? // Slice off the leaked JSON tail and also drop any opening brace/bracket
// left dangling just before it (e.g. "...daily. {" -> "...daily.").
normalized
.slice(0, leakedKeyIndex)
.replace(/[\s{[]+$/, "")
.trim()
: normalized;

const finalText = keepLeading ? trimmed : trimmed.trim();
Expand Down
4 changes: 2 additions & 2 deletions src/lib/rag.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -117,7 +117,7 @@
type: "string",
description:
"The first-layer response: a concise direct answer that can stand alone before structured supporting sections.",
maxLength: 1200,
maxLength: 1600,
},
grounded: {
type: "boolean",
Expand DownExpand Up@@ -153,7 +153,7 @@
type: "string",
description:
"Clinically useful section body grounded in the cited excerpts. Keep it concise, decision-oriented, and non-redundant with the answer. Do not include document codes, page labels, chunk IDs, or source metadata.",
maxLength: 420,
maxLength: 600,
},
citation_chunk_ids: {
type: "array",
Expand DownExpand Up@@ -3242,7 +3242,7 @@
);
}

function memoryCardAnswerLabel(card: DocumentMemoryCard) {

Check warning on line 3245 in src/lib/rag.ts

View workflow job for this annotation

GitHub Actions/ verify

'memoryCardAnswerLabel' is defined but never used

Check warning on line 3245 in src/lib/rag.ts

View workflow job for this annotation

GitHub Actions/ verify

'memoryCardAnswerLabel' is defined but never used
if (card.card_type === "table_row") return "Table evidence";
if (card.card_type === "threshold") return "Threshold/action";
if (card.card_type === "medication") return "Medication point";
Expand DownExpand Up@@ -3295,7 +3295,7 @@
return tokenHits * 0.08 + typeBoost + doseBoost + (card.confidence ?? 0) * 0.08 + lowValueTitlePenalty;
}

function selectDiverseMemoryCards(cards: DocumentMemoryCard[], limit: number) {

Check warning on line 3298 in src/lib/rag.ts

View workflow job for this annotation

GitHub Actions/ verify

'selectDiverseMemoryCards' is defined but never used

Check warning on line 3298 in src/lib/rag.ts

View workflow job for this annotation

GitHub Actions/ verify

'selectDiverseMemoryCards' is defined but never used
const selected: Array<{ card: DocumentMemoryCard; tokens: Set<string> }> = [];
for (const card of cards) {
const tokens = new Set(
Expand Down
22 changes: 21 additions & 1 deletion tests/citations.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,13 +47,33 @@ describe("citations", () => {
});

it("does not collapse unrelated titles to a hardcoded keyword label", () => {
// A "...Risk..." title must keep its real words, not collapse to "Risk".
expect(
formatCompactCitationLabel({
title: "Clinical Risk Assessment",
file_name: "risk.pdf",
page_number: 5,
}),
).toBe("Clinical Risk p.5");
).toBe("Clinical Risk Assessment p.5");
});

it("labels real documents from their title instead of a drug whitelist", () => {
// A real guideline keeps its distinguishing words rather than collapsing to
// a hardcoded demo drug name.
expect(
formatCompactCitationLabel({ title: "Maudsley Prescribing Guidelines", file_name: "maudsley.pdf", page_number: 5 }),
).toBe("Maudsley Prescribing p.5");
expect(
formatCompactCitationLabel({ title: "Haloperidol acute agitation protocol", file_name: "halo.pdf", page_number: 3 }),
).toBe("Haloperidol acute agitation p.3");
});

it("does not misattribute the drug when a title mentions more than one", () => {
// Previously a lithium passage inside a clozapine-titled doc was labelled
// "Clozapine"; the label now preserves both drugs.
expect(
formatCompactCitationLabel({ title: "Clozapine and lithium co-prescribing", file_name: "cl.pdf", page_number: 8 }),
).toBe("Clozapine lithium co-prescribing p.8");
});

it("links to source document, page, and chunk", () => {
Expand Down
8 changes: 8 additions & 0 deletions tests/demo-data.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -66,6 +66,14 @@ describe("demo data mode", () => {
expect(answer.bestSource).toBeTruthy();
});

it("does not give a confident acute-risk answer when 'risk' is only mentioned incidentally", () => {
const answer = demoAnswer("Is there a bleeding risk with aspirin in elderly patients?");

expect(answer.grounded).toBe(false);
expect(answer.confidence).toBe("unsupported");
expect(answer.answer).not.toContain("acute risk document");
});

it("returns document viewer payload with chunks and image captions", () => {
const clozapine = demoDocuments.find((document) => document.title.includes("clozapine"));
expect(clozapine).toBeTruthy();
Expand Down
26 changes: 25 additions & 1 deletion tests/display-text.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { sanitizeAnswerDisplayText } from "../src/components/clinical-dashboard/display-text";
import { sanitizeAnswerDisplayText, truncateWords } from "../src/components/clinical-dashboard/display-text";

describe("clinical dashboard display text", () => {
it("polishes cached generated answer prose before rendering", () => {
Expand All@@ -10,4 +10,28 @@ describe("clinical dashboard display text", () => {
"Therapy with lithium should always begin with conventional tablets (lithium carbonate 250 mg).",
);
});

describe("truncateWords", () => {
it("returns the value unchanged when within the word budget", () => {
expect(truncateWords("withhold clozapine now", 5)).toBe("withhold clozapine now");
});

it("keeps a threshold value attached to its unit when truncating", () => {
// Budget lands right after the number; the unit must come with it.
const result = truncateWords("withhold clozapine when ANC falls below 1.5 ×10⁹/L immediately", 7);
expect(result).toContain("1.5 ×10⁹/L");
expect(result).not.toMatch(/1\.5\.\.\.$/);
});

it("keeps a dose value attached to its unit when truncating", () => {
const result = truncateWords("start at 150 mg/day then review the response", 3);
expect(result).toBe("start at 150 mg/day...");
});

it("drops a dangling connector left at the truncation boundary", () => {
const result = truncateWords("monitor for 3 weeks or until symptoms resolve fully", 6);
expect(result.endsWith("or...")).toBe(false);
expect(result.endsWith("until...")).toBe(false);
});
});
});
6 changes: 6 additions & 0 deletions tests/rag-answer-text.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -33,6 +33,12 @@ describe("RAG answer text helpers", () => {
expect(sanitizeStructuredText('{"heading":"Monitoring","body":"Complete the form"}', { minTokens: 2 })).toBe("");
});

it("removes a mid-stream JSON leak after clean prose without leaving a dangling brace", () => {
expect(
sanitizeStructuredText('Withhold clozapine when ANC is low. {"answer": "ignore me"}', { minTokens: 3 }),
).toBe("Withhold clozapine when ANC is low.");
});

it("keeps clinically useful answer text with the stricter answer threshold", () => {
expect(sanitizeAnswerText("Complete baseline monitoring before clozapine initiation.")).toBe(
"Complete baseline monitoring before clozapine initiation.",
Expand Down