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: 2 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
- [ ] `npm run verify:ui` when UI, routing, styling, browser behavior, reduced-motion, or forced-colors behavior changed
- [ ] `npm run verify:release` before release or handoff confidence claims
- [ ] `npm run format:check`
- [ ] **`npm run eval:retrieval:quality` (must stay 23/23) when retrieval, ranking, selection, chunking, or scoring behavior changed** — CI cannot run it (needs live keys), so run it locally and paste the summary. A metadata/governance-weighting change once buried correct docs (recall 1.0→0.76) and only this eval caught it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update the retrieval gate to require all 24 cases

The checked-in scripts/fixtures/rag-retrieval-golden.json currently has 24 cases, and this PR's verification summary reports eval:retrieval:quality as 24/24, but the new PR checkbox tells reviewers the gate only needs to stay 23/23. For retrieval/ranking PRs this can make a one-case regression look acceptable; require zero failures or the current 24/24 count instead of the stale 23/23 number.

Useful? React with 👍 / 👎.

- [ ] `npm run eval:rag -- --limit 15` + `npm run eval:quality -- --rag-only` when answer generation, the synthesis prompt, or answer post-processing changed (grounded-supported must not drop; citation-failure 0)
- [ ] `npm run check:production-readiness` when clinical workflow, privacy, environment, Supabase, source governance, or deployment behavior changed
- [ ] `npm run check:deployment-readiness` when deployment startup, hosting, or rollout behavior changed

Expand Down
7 changes: 7 additions & 0 deletions docs/process-hardening.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,10 @@ All approved render-surface modules are extracted. `ClinicalDashboard.tsx` went
- **Root cause (confirmed live):** the soft-tail branch of `shouldShortCircuitUnsupportedSearch` (`src/lib/rag.ts`) fires on `analysis.queryClass === "unsupported_or_general"`, and that class is set by `analyzeQueryWithClassifierFallback`, which calls a **generative LLM classifier** (6s timeout, `reasoningEffort:"low"`, uncached) for low-confidence queries. That call is nondeterministic: it reclassifies "bipolar disorder" to a supported class on some runs (→ answered) and declines/times-out on others (→ short-circuited to 0). Reproduced with a same-process ×N probe.
- **Why a clean Phase-1 fix does NOT exist:** the deterministic analyzer carries **no signal** distinguishing in-corpus topics from out-of-corpus (bipolar, anorexia, gout, DKA all produce identical `unsupported_or_general`, confidence ≈ 0.40, `canonicalTerms` = query tokens). Only the corpus can tell them apart. Removing the soft-tail short-circuit fixes the valid topics but **regresses `unsupported_correct_rate` 1.0 → 0.79** on `eval:quality --rag-only`: a lexical distinctive-term relevance gate in `chooseAnswerRoute` either over-refuses legitimate semantic/vector matches (whose exact term is not in the retrieved text — e.g. the `rag-routing.test.ts` "admission" fixtures) or under-refuses invented terms ("florbizone syndrome") that score strongly on generic scaffolding words ("syndrome"/"management"). The corpus is broad (gout 13 / crohn 49 / appendicitis 30 / angioplasty 32 / bipolar 719 chunks), so almost no common medical term is truly absent — grounded low-confidence answers, not fabrication, are the realistic worst case for real terms; only genuinely invented terms should refuse.
- **Decision (2026-07-03):** the risky change (soft-tail removal + relevance gate + invented-term eval controls) was **reverted**; only a safe, independent hardening was kept — `fetchEnabledRagAliases` no longer caches `[]` on a transient `rag_aliases` read error (which would suppress alias expansion for the whole TTL). Finding #11 is **re-scoped into RAG optimisation Phase 2** ("fit retrieval to content"), where it should be fixed with corpus-grounded relevance — IDF/corpus-frequency weighting of query terms and/or the semantic relevance model, plus the data-driven query-understanding vocabulary (RC6) so the deterministic classifier recognises in-corpus topics up front. Any gate must keep `eval:quality --rag-only` `unsupported_correct_rate` at 1.0 (add invented-term controls like "florbizone syndrome management" / "quxbyria disorder treatment" once it can pass them) while letting valid bare topics answer. A cheaper interim option worth measuring first: **classifier-verdict memoization** to at least make the current behaviour deterministic per query.

## Retrieval changes must pass the golden eval before merge (2026-07-03)

- **Any PR that touches retrieval, ranking, selection, chunking, or scoring MUST run `npm run eval:retrieval:quality` (23/23) locally before merge** and paste the summary in the PR. CI cannot run it (it needs live Supabase + OpenAI keys), so it is a manual gate — now a checkbox in the PR template.
- **Why (measured):** PR #118 caught a main-side change (uncapped candidate score + blanket source-governance metadata weighting in `retrieval-selection.ts`) that regressed the golden set 23/23 → 16/23 (doc-recall@5 1.0 → 0.76) on the partially-enriched corpus. `verify:cheap` was green throughout — only the golden retrieval eval surfaced it. Unit tests do not exercise live ranking, so they cannot substitute.
- **Standing constraint (do not relearn):** source-governance metadata (`document_status`/`clinical_validation_status`/`extraction_quality`) must NOT weight retrieval **selection ordering**, and candidate relevance scores must stay clamped. Live scores saturate at 1.0 and the corpus is only partially enriched (unenriched → unknown/unverified), so metadata weighting buries correct documents. Governance belongs in ranking penalties and the answer/source-governance layer. See [[no-governance-weighting-in-retrieval-selection]] and `docs/rag-hybrid-findings-and-todo.md` (RC8).
- **Answer-generation changes** (synthesis prompt, post-processing) additionally run `eval:rag --limit 15` + `eval:quality --rag-only` (grounded-supported must not drop; citation-failure 0). A new opt-in `npm run eval:answer-quality` reports a structural per-intent **targeting** metric (informational) for measuring how precisely answers hit the asked question.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"supabase:recovery-status": "tsx scripts/supabase-recovery-status.ts",
"promote:query-misses": "tsx scripts/promote-query-misses.ts",
"eval:rag": "node scripts/run-eval-safe.mjs scripts/eval-rag.ts",
"eval:answer-quality": "node scripts/run-eval-safe.mjs scripts/eval-answer-quality.ts",
"eval:quality": "node scripts/run-eval-safe.mjs scripts/eval-quality.ts",
"eval:quality:release": "npm run eval:quality -- --fail-on-threshold --source-metadata-debt docs/release-source-metadata-debt-2026-06-30.json",
"eval:retrieval": "node scripts/run-eval-safe.mjs scripts/eval-retrieval.ts",
Expand Down
166 changes: 166 additions & 0 deletions scripts/eval-answer-quality.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// Answer-quality / targeting eval (P3). Runs the 30-case `answerQualityEvalCases` fixture through
// the live answer path and reports the five answer-quality metrics PLUS a structural per-intent
// `targeting` metric (dose→figure, red-result→withhold action, monitoring→schedule, etc.).
//
// This is a SEPARATE, opt-in script (npm run eval:answer-quality) — it does not touch eval:quality
// or add spend to any existing run. All metrics are reported informationally; the script exits 0
// unless --fail-on-threshold is passed with an explicit floor, so it can be calibrated safely.
import { loadEnvConfig } from "@next/env";
import {
answerQualityEvalCases,
answerQualityMetricLabels,
answerTargetingMetricLabel,
scoreAnswerQualityEvalCase,
scoreAnswerTargeting,
type AnswerQualityEvalCase,
type AnswerQualityMetric,
} from "@/lib/rag-eval-cases";
import type { RagAnswer } from "@/lib/types";
import { findOwnerIdByEmail, loadAdminClient, withProviderBackoff } from "./eval-utils";

loadEnvConfig(process.cwd());

type Args = {
ownerEmail?: string;
ownerId?: string;
limit?: number;
intent?: string;
json: boolean;
targetingFloor?: number;
};

function parseArgs(argv: string[]): Args {
const args: Args = {
ownerEmail: process.env.RAG_EVAL_OWNER_EMAIL,
ownerId: process.env.RAG_EVAL_OWNER_ID ?? process.env.LOCAL_NO_AUTH_OWNER_ID,
json: false,
};
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index];
if (!token.startsWith("--")) continue;
if (token === "--json") {
args.json = true;
continue;
}
const value = argv[index + 1];
if (token === "--owner-id") args.ownerId = value;
if (token === "--owner-email") args.ownerEmail = value;
if (token === "--intent") args.intent = value;
if (token === "--limit") args.limit = Number.parseInt(value, 10);
if (token === "--targeting-floor") args.targetingFloor = Number.parseFloat(value);
Comment on lines +49 to +50

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate numeric eval CLI options

When --limit or --targeting-floor is omitted, typoed, or provided in an unsupported form like --limit=5, these assignments store NaN. The limit is then ignored by the truthy check below, so the script runs all 30 live answer calls, and a NaN floor silently disables the requested gate; reject missing/non-positive numeric values before calling the provider.

Useful? React with 👍 / 👎.

}
return args;
}

const METRICS: AnswerQualityMetric[] = ["relevance", "readability", "artifact_leaks", "intent_coverage", "fail_closed"];

async function main() {
const args = parseArgs(process.argv.slice(2));
const [{ requireOpenAIEnv, requireServerEnv }, { answerQuestionWithScope }, supabase] = await Promise.all([
import("@/lib/env"),
import("@/lib/rag"),
loadAdminClient(),
]);
requireServerEnv();
requireOpenAIEnv();

const ownerId = args.ownerId ?? (args.ownerEmail ? await findOwnerIdByEmail(supabase, args.ownerEmail) : undefined);
let cases: AnswerQualityEvalCase[] = answerQualityEvalCases;
if (args.intent) cases = cases.filter((testCase) => testCase.expectedIntent === args.intent);
if (args.limit) cases = cases.slice(0, args.limit);

const metricTotals: Record<AnswerQualityMetric, number> = {
relevance: 0,
readability: 0,
artifact_leaks: 0,
intent_coverage: 0,
fail_closed: 0,
};
const targetingByIntent = new Map<string, { applicable: number; hit: number }>();
let targetingApplicable = 0;
let targetingHit = 0;
const targetingMisses: Array<{ id: string; intent: string; reason: string; answer: string }> = [];

for (const testCase of cases) {
const answer = (await withProviderBackoff(`answer-quality:${testCase.id}`, () =>
answerQuestionWithScope({ query: testCase.question, ownerId, logQuery: false, skipCache: true }),
)) as RagAnswer;

for (const score of scoreAnswerQualityEvalCase(testCase, answer)) {
metricTotals[score.metric] += score.score;
}

const targeting = scoreAnswerTargeting(testCase, answer);
const bucket = targetingByIntent.get(testCase.expectedIntent) ?? { applicable: 0, hit: 0 };
if (targeting.applicable) {
bucket.applicable += 1;
targetingApplicable += 1;
if (targeting.score === 1) {
bucket.hit += 1;
targetingHit += 1;
} else {
targetingMisses.push({
id: testCase.id,
intent: testCase.expectedIntent,
reason: targeting.reason,
answer: (answer.answer ?? "").replace(/\s+/g, " ").slice(0, 160),
});
}
}
targetingByIntent.set(testCase.expectedIntent, bucket);
}

const caseCount = cases.length;
const metricRates = Object.fromEntries(
METRICS.map((metric) => [metric, caseCount ? Number((metricTotals[metric] / caseCount).toFixed(4)) : 0]),
) as Record<AnswerQualityMetric, number>;
const targetingRate = targetingApplicable ? Number((targetingHit / targetingApplicable).toFixed(4)) : null;
const targetingByIntentRates = Object.fromEntries(
[...targetingByIntent.entries()].map(([intent, { applicable, hit }]) => [
intent,
{ applicable, hit, rate: applicable ? Number((hit / applicable).toFixed(4)) : null },
]),
);

const summary = {
case_count: caseCount,
metric_labels: answerQualityMetricLabels,
metric_rates: metricRates,
targeting_label: answerTargetingMetricLabel,
targeting_applicable: targetingApplicable,
targeting_rate: targetingRate,
targeting_by_intent: targetingByIntentRates,
targeting_misses: targetingMisses,
};

if (args.json) {
console.log(JSON.stringify(summary, null, 2));
} else {
console.log(`Answer-quality eval: ${caseCount} case(s).`);
console.log(" metric_rates:");
for (const metric of METRICS) console.log(` ${metric}=${metricRates[metric]}`);
console.log(` targeting_rate=${targetingRate} (applicable=${targetingApplicable})`);
console.log(" targeting_by_intent:");
for (const [intent, value] of Object.entries(targetingByIntentRates)) {
console.log(` ${intent}=${value.rate} (${value.hit}/${value.applicable})`);
}
if (targetingMisses.length) {
console.log(" targeting_misses:");
for (const miss of targetingMisses) {
console.log(` [${miss.intent}] ${miss.id}: ${miss.reason} :: "${miss.answer}"`);
}
}
}

if (args.targetingFloor !== undefined && targetingRate !== null && targetingRate < args.targetingFloor) {
console.error(`FAIL: targeting_rate ${targetingRate} < floor ${args.targetingFloor}`);
process.exit(1);
}
}

main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
112 changes: 3 additions & 109 deletions scripts/eval-utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { expectedFileCoverage } from "@/lib/eval-document-matching";
import type { RagEvalCase } from "@/lib/rag-eval-cases";
import type { RagAnswer, SearchResult, VisualEvidenceCard } from "@/lib/types";

export { expectedFileCoverage, type ExpectedFileCoverage } from "@/lib/eval-document-matching";

export type SupabaseAdmin = Awaited<ReturnType<typeof loadAdminClient>>;

export async function loadAdminClient() {
Expand Down Expand Up @@ -79,115 +82,6 @@ export function percentile(values: number[], percentileValue: number) {
return sorted[index];
}

export type ExpectedFileCoverage = {
expectedFiles: string[];
matchedFiles: string[];
missingFiles: string[];
anyHit: boolean;
allHit: boolean;
};

function normalizedDocumentName(value: string) {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, " ")
.replace(/\s+/g, " ")
.trim();
}

const clinicalDocumentAliases: Record<string, string[]> = {
ActiveCommunityPtED: [
"Active Community Patients in the Emergency Department",
"Active Community Patients Emergency Department",
],
AdmissionCommunityPts: ["Admission of Community Patients", "Admission Community Patients"],
AgitationArousalPharmaMgt: [
"Agitation and Arousal Pharmacological Management",
"Pharmacological Management of Acute Agitation and Arousal",
"Medication for Agitation and Arousal",
"Mental Health Pharmacological Management of Agitation and Arousal",
],
AssessmentDocumentation: ["Assessment Documentation", "Clinical Assessment", "Mental Health Assessment"],
BestPracticePrescription: ["Best Practice Prescription", "Best Practice Prescribing", "Prescription"],
ClozapinePresAdminMonitor: [
"Clozapine Prescribing Administration Monitoring",
"Clozapine Prescribing Administration and Monitoring",
"Clozapine Prescribing Administering Monitoring",
"Clozapine Prescribing Administering Monitoring and Capillary Sampling",
"Clozapine Prescribing",
"Clozapine Prescribing NMHS",
"Clozapine GP Shared Care",
"Clozapine Management by GP",
"Clozapine Therapy",
],
CommunityHomeVisit: ["Community Home Visit", "Home Visit", "Community Visits"],
Discharge: [
"Admission to Discharge for Mental Health Inpatients",
"Admission to Discharge for Community Mental Health",
"Referral Admission and Discharge Mental Health Hospital in the Home",
"Mental Health Hospital in the Home",
"Mental Health Medically Cleared for Discharge",
"Mental Health Inpatient Triage to Discharge",
"ACMHS and OACMHS Triage to Discharge",
// The synthetic MHSP.Discharge.pdf was superseded by real named discharge documents;
// "Discharge Planning for Community Patients / Inpatients" is the discharge counterpart
// that ranks for the admission-vs-discharge comparison, so recognize it as a real hit.
"Discharge Planning",
],
Duress: ["Duress", "Duress Procedure", "Duress Response"],
ECTProcedure: ["ECT Procedure", "Electroconvulsive Therapy", "Electroconvulsive Therapy ECT"],
IllegalSubstances: ["Illegal Substances", "Substances", "Contraband"],
LongActingInjectable: [
"Long Acting Injectable",
"Long-Acting Injectable",
"Depot",
"Olanzapine LAI",
"Long Acting Injectable Antipsychotic",
],
MetabolicScreening: ["Metabolic Screening", "Metabolic Monitoring", "Physical Health Monitoring"],
MHATMHCTTreatmentTeamProcess: ["Mental Health Treatment Team Process", "Treatment Team Process", "MHAT", "MHCT"],
NeurolepticSideEffect: ["Neuroleptic Side Effects", "Neuroleptic Side Effect", "Neuroleptic Effects"],
NOCC: ["NOCC", "National Outcomes and Casemix Collection", "Outcome Measures Completion"],
PtSafetyPlan: ["Patient Safety Plan", "Safety Planning", "Safety Plan"],
};

function documentExpectationAlternatives(expectation: string) {
const normalizedExpectation = normalizedDocumentName(expectation);
const compactExpectation = normalizedExpectation.replace(/\s+/g, "");
const aliasValues = Object.entries(clinicalDocumentAliases).flatMap(([key, values]) => {
const normalizedKey = normalizedDocumentName(key);
const compactKey = normalizedKey.replace(/\s+/g, "");
if (!compactExpectation.includes(compactKey) && !normalizedExpectation.includes(normalizedKey)) return [];
return values;
});
return Array.from(new Set([expectation, ...aliasValues].map(normalizedDocumentName).filter(Boolean)));
}

function resultDocumentText(source: Pick<SearchResult, "file_name" | "title">) {
return normalizedDocumentName(`${source.title} ${source.file_name}`);
}

export function expectedFileCoverage(
expectedFiles: string[],
sources: Array<Pick<SearchResult, "file_name" | "title">>,
limit = 3,
): ExpectedFileCoverage {
const topFiles = sources.slice(0, limit).map(resultDocumentText);
const matchedFiles = expectedFiles.filter((expected) =>
documentExpectationAlternatives(expected).some((alternative) =>
topFiles.some((file) => file.includes(alternative)),
),
);

return {
expectedFiles,
matchedFiles,
missingFiles: expectedFiles.filter((expected) => !matchedFiles.includes(expected)),
anyHit: matchedFiles.length > 0,
allHit: expectedFiles.length > 0 && matchedFiles.length === expectedFiles.length,
};
}

export function expectedFileHit(
expectedFiles: string[],
sources: Array<Pick<SearchResult, "file_name" | "title">>,
Expand Down
10 changes: 9 additions & 1 deletion src/components/clinical-dashboard/evidence-panels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -585,7 +585,7 @@
return "Note";
}

function ClinicalNoteDetailCard({ row }: { row: ClinicalNotesRow }) {

Check warning on line 588 in src/components/clinical-dashboard/evidence-panels.tsx

View workflow job for this annotation

GitHub Actions / verify

'ClinicalNoteDetailCard' is defined but never used
const detail = sentenceCaseClinicalNoteDetail(row.detail);
return (
<div className="mt-2 rounded-md border border-[color:var(--border)] bg-[color:var(--surface-subtle)] px-2.5 py-2 shadow-[var(--shadow-inset)]">
Expand Down Expand Up @@ -1889,6 +1889,11 @@
<blockquote className={cn(proseMeasure, "text-[15px] font-medium leading-6 text-[color:var(--text)]")}>
&ldquo;{quoteText}&rdquo;
</blockquote>
{quote.isTruncated ? (
<p className="mt-1 text-xs italic leading-5 text-[color:var(--text-muted)]">
Quote truncated for length — open the source to read the full passage.
</p>
Comment thread
BigSimmo marked this conversation as resolved.
) : null}
<div
className={cn(
"mt-3 flex flex-wrap items-center justify-between gap-2 pt-3 sm:mt-4 sm:gap-3",
Expand Down Expand Up @@ -1928,9 +1933,12 @@
// Clean the copied text the same way the card displays it, so clipboard
// output never contains internal image-data blocks or glyph artifacts.
`${index + 1}. "${sourceTextForVerbatimQuote(quote.quote)}"`,
quote.isTruncated ? "Warning: quote truncated for length; open the source to read the full passage." : null,
`Source: ${formatCitationLabel(quote)}`,
`Link: ${documentCitationHref(quote)}`,
].join("\n"),
]
.filter(Boolean)
.join("\n"),
)
.join("\n\n");
}
Loading
Loading