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
11 changes: 10 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,16 @@ OPENAI_API_KEY=replace-with-openai-api-key
OPENAI_EMBEDDING_MODEL=text-embedding-3-small
OPENAI_ANSWER_MODEL=gpt-5.5
OPENAI_FAST_ANSWER_MODEL=gpt-5.5
# Strong tier stays on the standard (non-pro) model; fast vs strong differ by reasoning effort.
OPENAI_STRONG_ANSWER_MODEL=gpt-5.5
OPENAI_MAX_OUTPUT_TOKENS=4000
OPENAI_QUERY_CACHE_SIZE=200
OPENAI_VISION_MODEL=gpt-5.5
OPENAI_VISION_IMAGE_DETAIL=auto
OPENAI_REQUEST_TIMEOUT_MS=45000
OPENAI_ANSWER_TIMEOUT_MS=12000
# Answer-generation budget. Kept generous so a strong reasoning model can finish a
# natural, model-written answer instead of timing out into stitched extractive prose.
OPENAI_ANSWER_TIMEOUT_MS=30000
OPENAI_MAX_RETRIES=2
OPENAI_GENERATION_MAX_RETRIES=0
OPENAI_PROMPT_CACHE_RETENTION=24h
Expand All @@ -41,6 +44,12 @@ OPENAI_STRONG_REASONING_EFFORT=high
OPENAI_SUMMARY_REASONING_EFFORT=medium
OPENAI_VISION_REASONING_EFFORT=low
OPENAI_TEXT_VERBOSITY=low
# Provider mode for answers/search: auto | openai | offline.
# auto (default): use OpenAI when available, automatically fall back to a clearly
# labelled source-only answer when the key is missing/invalid or the provider fails.
# openai: always attempt OpenAI (legacy behaviour).
# offline: never call OpenAI; lexical retrieval + deterministic source-only answers only.
RAG_PROVIDER_MODE=auto
RAG_ANSWER_CACHE_TTL_MS=300000
RAG_ANSWER_CACHE_SIZE=100
RAG_SEARCH_CACHE_TTL_MS=60000
Expand Down
223 changes: 223 additions & 0 deletions docs/rag-hybrid-findings-and-todo.md

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions scripts/eval-retrieval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,12 @@ const clinicalDocumentAliases: Record<string, string[]> = {
"Agitation and Arousal Pharmacological Management",
"Pharmacological Management of Acute Agitation and Arousal",
"Medication for Agitation and Arousal",
// The corpus has two legitimate agitation IM/PO guidelines. Once the full hybrid stack was
// restored, "Mental Health Pharmacological Management of Agitation and Arousal Guideline (EMHS)"
// ranks alongside/above MHSP.AgitationArousalPharmaMgt for agitation-med queries. Both are
// correct sources, so either satisfies the expectation. (Doc crowding/lexical-weighting for the
// pinned doc is tracked separately as a ranking item, not a retrieval miss.)
"Pharmacological Management of Agitation and Arousal",
],
AdmissionCommunityPts: ["Admission of Community Patients", "Admission Community Patients"],
ActiveCommunityPtED: [
Expand Down
117 changes: 117 additions & 0 deletions scripts/fixtures/rag-retrieval-golden.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,5 +93,122 @@
"expectedContentTerms": ["monitoring", "threshold", ["anc", "fbc", "wbc"]],
"topK": 12,
"expectTableEvidence": true
},
{
"id": "alcohol-withdrawal-management",
"query": "What is the recommended management of alcohol withdrawal?",
"expectedQueryClass": "broad_summary",
"expectedDocumentSubstrings": ["Alcohol withdrawal"],
"expectedContentTerms": ["alcohol", "withdrawal"],
"topK": 8,
"expectTableEvidence": false
},
{
"id": "opioid-use-disorder-management",
"query": "How is opioid use disorder managed?",
"expectedQueryClass": "broad_summary",
"expectedDocumentSubstrings": ["Opioid use disorder"],
"expectedContentTerms": ["opioid", "disorder"],
"topK": 8,
"expectTableEvidence": false
},
{
"id": "insomnia-assessment-management",
"query": "How should insomnia be assessed and managed?",
"expectedQueryClass": "broad_summary",
"expectedDocumentSubstrings": ["Insomnia"],
"expectedContentTerms": ["insomnia", "sleep"],
"topK": 8,
"expectTableEvidence": false
},
{
"id": "suicide-risk-mitigation-guidance",
"query": "Summarise strategies to reduce inpatient suicide",
"expectedQueryClass": "broad_summary",
"expectedDocumentSubstrings": ["Suicide risk mitigation"],
"expectedContentTerms": ["suicide"],
"topK": 8,
"expectTableEvidence": false
},
{
"id": "schizophrenia-overview",
"query": "Give an overview of schizophrenia assessment and treatment",
"expectedQueryClass": "broad_summary",
"expectedDocumentSubstrings": ["Schizophrenia"],
"expectedContentTerms": ["schizophrenia", ["treatment", "antipsychotic", "management"]],
"topK": 8,
"expectTableEvidence": false
},
{
"id": "bipolar-management-summary",
"query": "Summarise the management of bipolar disorder in adults",
"expectedQueryClass": "broad_summary",
"expectedDocumentSubstrings": ["Bipolar disorder in adults"],
"expectedContentTerms": ["bipolar", ["lithium", "mood", "treatment"]],
"topK": 8,
"expectTableEvidence": false
},
{
"id": "depression-adults-vs-children",
"query": "Compare depression in adults versus depression in children",
"expectedQueryClass": "comparison",
"expectedDocumentSubstrings": ["Depression in adults", "Depression in children"],
"expectedContentTerms": ["depression"],
"topK": 12,
"expectTableEvidence": false
},
{
"id": "alcohol-ciwa-scoring",
"query": "Explain the CIWA-Ar scoring used in alcohol withdrawal",
"expectedQueryClass": "broad_summary",
"expectedDocumentSubstrings": ["Alcohol withdrawal"],
"expectedContentTerms": ["alcohol", ["ciwa", "withdrawal", "score"]],
"topK": 8,
"expectTableEvidence": false
},
{
"id": "opioid-withdrawal-doses",
"query": "What medication doses are used for opioid withdrawal?",
"expectedQueryClass": "medication_dose_risk",
"expectedDocumentSubstrings": ["Opioid use disorder"],
"expectedContentTerms": ["opioid", "withdrawal", ["buprenorphine", "methadone", "dose"]],
"topK": 12,
"expectTableEvidence": false
},
{
"id": "postnatal-depression-treatment",
"query": "How is postnatal depression treated?",
"expectedQueryClass": "broad_summary",
"expectedDocumentSubstrings": ["Postnatal depression"],
"expectedContentTerms": ["postnatal", "depression"],
"topK": 8,
"expectTableEvidence": false
},
{
"id": "bipolar-vs-schizoaffective",
"query": "Compare bipolar disorder and schizoaffective disorder",
"expectedQueryClass": "comparison",
"expectedDocumentSubstrings": ["Bipolar disorder in adults", "Schizoaffective disorder"],
"expectedContentTerms": ["bipolar", "schizoaffective"],
"topK": 12,
"expectTableEvidence": false
},
{
"id": "lithium-therapy-monitoring",
"query": "What monitoring is required for lithium therapy?",
"expectedQueryClass": "medication_dose_risk",
"expectedDocumentSubstrings": [],
"expectedContentTerms": ["lithium", ["monitor", "level", "thyroid", "renal", "tsh"]],
"topK": 12,
"expectTableEvidence": false
},
{
"id": "alcohol-ciwa-threshold",
"query": "What CIWA-Ar score threshold requires drug treatment in alcohol withdrawal?",
"expectedQueryClass": "table_threshold",
"expectedDocumentSubstrings": ["Alcohol withdrawal"],
"expectedContentTerms": ["alcohol", "withdrawal", ["ciwa", "score", "threshold"]],
"topK": 12,
"expectTableEvidence": false
}
]
7 changes: 6 additions & 1 deletion src/app/api/search/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -652,7 +652,12 @@ async function buildScopedSearchPayload(
});
const resultLimit =
body.mode === "documents" ? Math.max(body.topK ?? 12, Math.min(20, body.documentLimit)) : (body.topK ?? 8);
const results = annotateSearchResults(searchFocusQuery, diversifySearchResults(search.results, resultLimit, 4, true));
// RC7: cap the search-results panel at 3 chunks per document (was 4) so one verbose document
// cannot crowd out sibling sources — the corpus has many near-duplicate guidelines (e.g. several
// "Safety Planning" / "Active Community Patients in ED" versions), and surfacing more distinct
// documents makes the panel more useful. diversifySearchResults backfills from remaining chunks
// when few documents match, so this never reduces the result count.
const results = annotateSearchResults(searchFocusQuery, diversifySearchResults(search.results, resultLimit, 3, true));

const relatedDocuments = body.includeRelatedDocuments
? await fetchRelatedDocuments({
Expand Down
16 changes: 16 additions & 0 deletions src/components/ClinicalDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -832,6 +832,7 @@ function NaturalLanguageAnswer({
sourceCount,
weakEvidence,
grounded,
sourceOnly,
bestSource,
sources,
sourceLinks,
Expand All @@ -842,6 +843,7 @@ function NaturalLanguageAnswer({
sourceCount: number;
weakEvidence: boolean;
grounded: boolean;
sourceOnly: boolean;
bestSource: BestSourceRecommendation | null;
sources: SearchResult[];
sourceLinks: SourceLink[];
Expand Down Expand Up @@ -919,6 +921,19 @@ function NaturalLanguageAnswer({
<SafeBoldText text={cleaned} />
</span>
</p>
{sourceOnly ? (
<p
data-testid="source-only-disclosure"
role="note"
className={cn(
"rounded-md border border-[color:var(--border)] bg-[color:var(--surface-subtle)] px-2.5 py-1.5 text-xs leading-5",
textMuted,
)}
>
Source-only answer — assembled from your documents without the AI model, so it may be less
complete. Verify it against the cited passages below.
</p>
) : null}
{sourceCapsuleButton}
{sourcePreviewOpen && canOpenSourcePreview && !usePreviewSheet ? (
<div
Expand Down Expand Up @@ -3828,6 +3843,7 @@ function StagedAnswerResultSurface({
sourceCount={sourceCount}
weakEvidence={weakEvidence}
grounded={answerGrounded}
sourceOnly={answer.answerQualityTier === "source_only"}
bestSource={bestSource}
sources={sources}
sourceLinks={renderModel.primarySources}
Expand Down
55 changes: 4 additions & 51 deletions src/lib/answer-ranking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,11 @@ const answerRankStrategy = "query_focused_answer_evidence_v2";
const answerBoilerplatePattern =
/\b(?:uncontrolled when printed|document control|review date|version\s+\d|page\s+\d+\s+of\s+\d+|copyright|confidential|all rights reserved|refer to the electronic version|consult your doctor|seek medical advice|this is not medical advice|intended for healthcare professionals|disclaimer)\b/i;

const queryTermExclusions = new Set([
"recommended",
"recommend",
"summary",
"summarize",
"summarise",
"overview",
"information",
"guidance",
"approach",
"therapy",
]);

// Minimal "values only" bolding: emphasise only decision-critical detail — escalation/stop
// actions, numeric doses/thresholds/timings, and rating values. Topic nouns (clozapine, FBC,
// ANC, ECG, …) and query terms are intentionally NOT bolded; bolding them everywhere produced
// robotic, keyword-highlighted prose rather than natural clinical writing.
const fixedHighYieldPatterns = [
/\b(?:clozapine|lithium|ECT|FBC|ANC|myocarditis|neutropenia|metabolic|constipation|ECG)\b/gi,
/\b(?:withhold|withholding|cease|ceased|stop|stopping|discontinue\w*|contraindicat\w*|avoid|urgent|escalat\w*|red flag\w*)\b/gi,
/\b\d+(?:\.\d+)?\s?(?:mg|mcg|g|mmol\/L|days?|weeks?|months?|hours?|minutes?|%)\b/gi,
/\brating\s+\d+(?:\s*[-–]\s*\d+)?\b/gi,
Expand Down Expand Up @@ -271,40 +261,6 @@ export function rankAnswerEvidence(
};
}

function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

function queryHighlightPatterns(query?: string) {
if (!query) return [];
const lowValueHighlightTerms = new Set([
"dose",
"dosing",
"monitor",
"monitoring",
"test",
"tests",
"result",
"results",
"baseline",
"clinical",
"patient",
]);
const tokens = uniqueQueryTokens(query).filter(
(token) => token.length >= 4 && !queryTermExclusions.has(token) && !lowValueHighlightTerms.has(token),
);
const patterns: RegExp[] = [];
const normalizedQuery = normalizeText(query);
const queryPhrase = tokens.length >= 2 ? tokens.join(" ") : "";
if (queryPhrase && normalizedQuery.includes(queryPhrase)) {
patterns.push(new RegExp(`\\b${escapeRegExp(queryPhrase).replace(/\\ /g, "\\s+")}\\b`, "gi"));
}
for (const token of tokens.slice(0, 3).sort((a, b) => b.length - a.length)) {
patterns.push(new RegExp(`\\b${escapeRegExp(token)}\\w*\\b`, "gi"));
}
return patterns;
}

function applyBoldPatternOutsideExisting(text: string, pattern: RegExp, maxMatches: number) {
let applied = 0;
const segments = text.split(/(\*\*[^*]+\*\*)/g);
Expand Down Expand Up @@ -336,9 +292,6 @@ export function boldHighYieldClinicalText(text: string, query?: string) {
if (query === undefined) return text;
if (/[{}\[\]]/.test(text) && /"?(?:answer|heading|citation_chunk_ids|chunk_id)"?\s*:/i.test(text)) return text;
let output = text;
for (const pattern of queryHighlightPatterns(query)) {
output = applyBoldPatternOutsideExisting(output, pattern, 1);
}
for (const pattern of fixedHighYieldPatterns) {
output = applyBoldPatternOutsideExisting(output, pattern, 1);
}
Expand Down
8 changes: 7 additions & 1 deletion src/lib/clinical-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,8 +304,14 @@ const comparisonPattern =
/\b(compare|compared|versus|vs|between|difference\w*|conflict\w*)\b|\bcombine\b.{0,100}\bwith\b/i;
const tableThresholdPattern =
/\b(table|chart|matrix|threshold|cut[\s-]?off|cutoff|level|range|score|scale|criteria|criterion|anc|fbc|neutrophil|white cell|when to withhold|withhold|cease|stop|maximum|minimum|baseline)\b/i;
// Note (8a): bare generic risk/workflow words (`risk`, `urgent`, `escalat*`) were removed from this
// pattern. On their own — with no medication/dose/pharmacology signal — they mis-classified topical
// queries like "suicide risk mitigation" or "urgent clinical escalation" as medication-dosing
// queries, whose retrieval plan then buried the actual guideline under dose/threshold evidence. A
// genuine medication_dose_risk query still matches via a drug name, dose/route term, or the
// medication/pharmacology/agitation vocabulary retained below.
const medicationDoseRiskPattern =
/\b(medication|medicine|pharmacolog\w*|prescrib\w*|dose|dosage|dosing|mg|mcg|titrate|route|oral|intramuscular|administer\w*|\bim\b|\bpo\b|\bprn\b|clozapine|lithium|neuroleptic|antipsychotic|benzodiazepine|injectables?|agitation|arousal|side effect\w*|adverse|toxicity|contraindicat\w*|monitor\w*|risk|urgent|escalat\w*)\b/i;
/\b(medication|medicine|pharmacolog\w*|prescrib\w*|dose|dosage|dosing|mg|mcg|titrate|route|oral|intramuscular|administer\w*|\bim\b|\bpo\b|\bprn\b|clozapine|lithium|neuroleptic|antipsychotic|benzodiazepine|injectables?|agitation|arousal|side effect\w*|adverse|toxicity|contraindicat\w*|monitor\w*)\b/i;
const documentIncludePattern =
/\b(?:what should|what must|what does|what do|which items?|requirements?|checklist|forms?)\b.{0,80}\b(?:include|contain|cover|require|required|needed|need)\b|\b(?:include|contain|cover|require|required|needed|need)\b.{0,80}\b(?:plan|form|checklist|protocol|procedure|guideline|document|file|pdf)\b/i;
const explicitDocumentLookupPattern =
Expand Down
12 changes: 12 additions & 0 deletions src/lib/deep-memory.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import { buildClinicalTextSearchQuery, classifyRagQuery, normalizedClinicalSearchTokens } from "@/lib/clinical-search";
import { logger } from "@/lib/logger";
import {
buildDocumentIndexUnitInputs,
countDocumentIndexUnitsByType,
Expand Down Expand Up @@ -810,6 +811,17 @@ export async function fetchMemoryCardsForQuery(args: {
owner_filter: args.ownerId ?? null,
});

if (error) {
// P0.1: surface the hybrid RPC failure instead of silently dropping to the lexical
// fallback below — this is the failure mode that hid the live schema drift.
logger.error("hybrid_rpc_failed", {
rpc: "match_document_memory_cards_hybrid",
code: (error as { code?: string }).code ?? "unknown",
message: error.message,
hint: (error as { hint?: string }).hint,
});
}

if (!error && data?.length) {
return (
(data ?? []) as Array<
Expand Down
22 changes: 19 additions & 3 deletions src/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ const envSchema = z.object({
EMBEDDING_DIMENSIONS: z.coerce.number().int().positive().default(1536),
OPENAI_ANSWER_MODEL: z.string().default("gpt-5.5"),
OPENAI_FAST_ANSWER_MODEL: z.string().default("gpt-5.5"),
// Strong tier intentionally stays on the standard (non-"pro") model. Fast vs strong
// is differentiated by reasoning effort (OPENAI_*_REASONING_EFFORT), not model tier.
OPENAI_STRONG_ANSWER_MODEL: z.string().default("gpt-5.5"),
// Reasoning models (gpt-5*) draw reasoning tokens from this same budget, so a
// low cap can starve the JSON answer payload and silently truncate clinical
Expand All @@ -28,9 +30,15 @@ const envSchema = z.object({
OPENAI_VISION_MODEL: z.string().default("gpt-5.5"),
OPENAI_VISION_IMAGE_DETAIL: z.enum(["auto", "low", "high"]).default("auto"),
OPENAI_REQUEST_TIMEOUT_MS: z.coerce.number().int().positive().default(45000),
// Answer generation has a source-backed fallback path, so it should fail fast
// instead of inheriting the longer provider timeout used by embeddings/vision.
OPENAI_ANSWER_TIMEOUT_MS: z.coerce.number().int().positive().default(12000),
// Answer generation has a source-backed fallback path, but a too-tight budget
// makes a strong reasoning model time out and silently degrade to stitched
// extractive prose (the "unnatural answer" failure mode). The product decision is
// to favour natural, model-written answers within ~20-30s, so this sits well above
// the old 12s default while staying under the OPENAI_REQUEST_TIMEOUT_MS ceiling.
// 30s (up from 25s) gives verbose strong-route answers margin so they finish rather
// than fail-closed; strong reasoning effort is also query-class-capped to keep the
// tail latency in budget (see strongReasoningEffortForQueryClass).
OPENAI_ANSWER_TIMEOUT_MS: z.coerce.number().int().positive().default(30000),
OPENAI_MAX_RETRIES: z.coerce.number().int().nonnegative().default(2),
OPENAI_GENERATION_MAX_RETRIES: z.coerce.number().int().nonnegative().default(0),
OPENAI_PROMPT_CACHE_RETENTION: z.enum(["off", "in_memory", "24h"]).default("24h"),
Expand All @@ -43,6 +51,14 @@ const envSchema = z.object({
OPENAI_SUMMARY_REASONING_EFFORT: z.enum(["none", "low", "medium", "high", "xhigh"]).default("medium"),
OPENAI_VISION_REASONING_EFFORT: z.enum(["none", "low", "medium", "high", "xhigh"]).default("low"),
OPENAI_TEXT_VERBOSITY: z.enum(["low", "medium", "high"]).default("low"),
// Answer/search provider mode. Controls whether OpenAI (embeddings + synthesis) is used.
// - "auto" (default): use OpenAI when a usable key is present and the call succeeds;
// automatically degrade to a source-only (embedding-free, deterministic) answer when
// the key is missing/invalid or the provider fails. The fallback is ON BY DEFAULT.
// - "openai": legacy behaviour — always attempt OpenAI; do not pre-empt with source-only.
// - "offline": never call OpenAI at all (no embeddings, no generation); lexical retrieval
// + deterministic source-only answers only. Fails closed when evidence is weak.
RAG_PROVIDER_MODE: z.enum(["auto", "openai", "offline"]).default("auto"),
RAG_ANSWER_CACHE_TTL_MS: z.coerce.number().int().nonnegative().default(300000),
RAG_ANSWER_CACHE_SIZE: z.coerce.number().int().nonnegative().default(100),
RAG_SEARCH_CACHE_TTL_MS: z.coerce.number().int().nonnegative().default(60000),
Expand Down
Loading
Loading