diff --git a/src/app/api/answer/route.ts b/src/app/api/answer/route.ts index c1eb1bec37..13601fe0aa 100644 --- a/src/app/api/answer/route.ts +++ b/src/app/api/answer/route.ts @@ -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(), diff --git a/src/app/api/answer/stream/route.ts b/src/app/api/answer/stream/route.ts index a54203f5f3..56bb5c3aa8 100644 --- a/src/app/api/answer/stream/route.ts +++ b/src/app/api/answer/stream/route.ts @@ -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(), diff --git a/src/app/api/search/interaction/route.ts b/src/app/api/search/interaction/route.ts index 7c283d1508..f9d5b8dec7 100644 --- a/src/app/api/search/interaction/route.ts +++ b/src/app/api/search/interaction/route.ts @@ -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(), diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 11d975fa39..794ebb8ee6 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -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(), diff --git a/src/components/clinical-dashboard/display-text.ts b/src/components/clinical-dashboard/display-text.ts index 11a8af2bf3..5cbab49a81 100644 --- a/src/components/clinical-dashboard/display-text.ts +++ b/src/components/clinical-dashboard/display-text.ts @@ -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) { diff --git a/src/lib/citations.ts b/src/lib/citations.ts index cc7e3470e7..325f80f42c 100644 --- a/src/lib/citations.ts +++ b/src/lib/citations.ts @@ -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) { - 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}`; } diff --git a/src/lib/demo-data.ts b/src/lib/demo-data.ts index 12dd3874c8..a8706a3004 100644 --- a/src/lib/demo-data.ts +++ b/src/lib/demo-data.ts @@ -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) || + lowered.includes("means restriction") || + lowered.includes("safety plan"); const broadMultiDocumentQuery = lowered.includes("across") || lowered.includes("multiple") || @@ -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); @@ -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."; } diff --git a/src/lib/rag-answer-text.ts b/src/lib/rag-answer-text.ts index 2791e8665f..c21b380f60 100644 --- a/src/lib/rag-answer-text.ts +++ b/src/lib/rag-answer-text.ts @@ -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(); diff --git a/src/lib/rag.ts b/src/lib/rag.ts index dfdf7cdb42..179cd9e336 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -117,7 +117,7 @@ const answerJsonOutputSchema = { 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", @@ -153,7 +153,7 @@ const answerJsonOutputSchema = { 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", diff --git a/tests/citations.test.ts b/tests/citations.test.ts index c807a325f7..b9f371cf58 100644 --- a/tests/citations.test.ts +++ b/tests/citations.test.ts @@ -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", () => { diff --git a/tests/demo-data.test.ts b/tests/demo-data.test.ts index 53fe00b5a1..a932c58232 100644 --- a/tests/demo-data.test.ts +++ b/tests/demo-data.test.ts @@ -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(); diff --git a/tests/display-text.test.ts b/tests/display-text.test.ts index 6bb387b282..34b1f3bc9c 100644 --- a/tests/display-text.test.ts +++ b/tests/display-text.test.ts @@ -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", () => { @@ -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); + }); + }); }); diff --git a/tests/rag-answer-text.test.ts b/tests/rag-answer-text.test.ts index f26f33c28f..5e70706f6a 100644 --- a/tests/rag-answer-text.test.ts +++ b/tests/rag-answer-text.test.ts @@ -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.",