diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 47239abc8..059c75989 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -488,3 +488,4 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD | 2026-07-14 | PR #655 / codex/release-blocker-remediation | 1ece1b891ed2d7f9577a060ae481e33cd4925ea4 + reviewed follow-up diff | offline release latency follow-up | The offline quality release gate reproducibly isolated one live timeout: a generic agitation table-lookup question was expanded into an unnecessary ten-term dose/route AND query, making lexical retrieval take about 39 seconds. Dose/route expansion now requires an actual dosing/route signal; the same case retrieves its expected source and four citations in 1.27 seconds without a model. | Focused clinical-search/retrieval Vitest 111/111; scoped ESLint; Prettier; `git diff --check`; live provider-free single-case quality eval passed with zero model/request/token/cost/generation evidence. | | 2026-07-14 | PR #655 / codex/release-blocker-remediation | 3ed3a7a2df37d7d15143ab7606e5748ac7ecca09 + reviewed follow-up diff | offline dose-route latency follow-up | The next isolated timeout was a short IM/PO agitation question receiving the same blanket ten-term AND expansion. Agitation dose/route retrieval now keeps only the dose and route signals present in the question; the exact case retrieves its expected source and five citations in 1.54 seconds without a model. All remaining RAG cases 23–44 passed individually, so no further deadline crash remains. | Focused clinical-search/retrieval Vitest 111/111; scoped ESLint; Prettier; `git diff --check`; live provider-free case 22 passed; live provider-free cases 23–44 passed individually. | | 2026-07-14 | PR #655 / codex/release-blocker-remediation | a3f3a89676015cd5f018c07e8c3ad9483f91cef6 + reviewed follow-up diff | offline adversarial-latency follow-up | The final blocking offline-quality failure was an adversarial secret-exfiltration query that correctly refused but first spent about 25 seconds in lexical retrieval. Adversarial manipulation now short-circuits at the search boundary before provider-client creation, cache access, classification, aliases, or Supabase work, and is never cached. | Focused Vitest 2/2; scoped ESLint; Prettier; full TypeScript; `git diff --check`; live provider-free adversarial case completed in 100 ms with 0 ms RPC time; `eval:quality:release:offline` passed with zero blocking failures and zero model, request-ID, token, cost, or generation-latency evidence. Flaky local browser/composite suites intentionally not repeated; hosted CI remains authoritative. | +| 2026-07-14 | PR #666 / codex/release-blocker-remediation | 9b56eebe4b23ab783207445fb827c317c8d59be8 + reviewed follow-up diff | review-followup | One late P2 retrieval-contract gap was confirmed: the optimized agitation query retained IM/PO but could drop other already-supported amount, route, and frequency aliases. Medication evidence intent is now shared with retrieval selection, and focused agitation queries preserve requested numeric units, SC, SL, PRN, and frequency signals without restoring the broad ten-term expansion. | GitHub review-thread inspection; focused clinical-search/retrieval Vitest 112/112; scoped ESLint; Prettier; `git diff --check`. Hosted final-head TypeScript/build/CI and exact-head staging evidence remain required after push. | diff --git a/src/lib/clinical-search.ts b/src/lib/clinical-search.ts index 5e5840e34..270739d53 100644 --- a/src/lib/clinical-search.ts +++ b/src/lib/clinical-search.ts @@ -1199,6 +1199,32 @@ export function medicationDoseEvidenceQueryIntent(query: string) { }; } +/** Canonical text-search terms for the medication evidence attributes explicitly requested. */ +export function medicationDoseEvidenceSearchTerms(query: string) { + const terms: string[] = []; + const intent = medicationDoseEvidenceQueryIntent(query); + if (intent.asksAmount) terms.push("dose"); + + for (const match of query.matchAll( + /\b(\d+(?:\.\d+)?)\s*(mg|mcg|micrograms?|milligrams?|ug)\b|\b(\d+(?:\.\d+)?)\s*([µμ]g)/gi, + )) { + const amount = match[1] ?? match[3]; + const unit = match[2] ?? match[4]; + if (!amount || !unit) continue; + terms.push(amount, /^(?:mcg|microgram|ug|[µμ]g)/i.test(unit) ? "mcg" : "mg"); + } + + if (/\broutes?\b/i.test(query)) terms.push("route"); + if (/\b(?:intramuscular|intramuscularly|im)\b/i.test(query)) terms.push("im"); + if (/\b(?:oral|orally|po|by\s+mouth)\b/i.test(query)) terms.push("po"); + if (/\b(?:subcutaneous|subcutaneously|subcut|sc)\b/i.test(query)) terms.push("sc"); + if (/\b(?:sublingual|sublingually|sl)\b/i.test(query)) terms.push("sl"); + if (/\bprn\b/i.test(query)) terms.push("prn"); + else if (intent.asksFrequency) terms.push("frequency"); + + return [...new Set(terms.filter(Boolean))]; +} + /** Whether the query explicitly asks for dose, route, or frequency evidence. */ export function isMedicationDoseEvidenceQuery(query: string) { const intent = medicationDoseEvidenceQueryIntent(query); @@ -1245,15 +1271,16 @@ export function buildClinicalTextSearchQuery(query: string) { const hasAgitationArousalContext = (hasAgitationArousalTypo && /\bagitation\b/i.test(correctedQueryText) && /\barousal\b/i.test(correctedQueryText)) || /\bagitation\b/i.test(correctedQueryText); - // Dose/route alias pattern aligned with buildRetrievalIntent in retrieval-selection.ts - const DOSE_ROUTE_ALIAS_PATTERN = - /\b(?:dose|doses|dosage|dosages|dosing|route|oral|intramuscular|subcutaneous|subcut|sublingual|im|po|sc|sl|frequency|mg|mcg|prn)\b/i; + const agitationMedicationIntent = medicationDoseEvidenceQueryIntent(query); + const wantsAgitationMedicationChart = + hasAgitationArousalContext && + (agitationMedicationIntent.asksAmount || + agitationMedicationIntent.asksRoute || + agitationMedicationIntent.asksFrequency); const wantsAgitationArousal = hasAgitationArousalContext && - /\b(?:dose|doses|dosage|dosages|dosing|guidance|inpatient|psychiatric|route|oral|intramuscular|subcutaneous|subcut|sublingual|\bim\b|\bpo\b|sc|sl|frequency|mg|mcg|prn|chart|table|pharmacolog)/i.test( - correctedQueryText, - ); - const wantsAgitationMedicationChart = wantsAgitationArousal && DOSE_ROUTE_ALIAS_PATTERN.test(correctedQueryText); + (wantsAgitationMedicationChart || + /\b(?:guidance|inpatient|psychiatric|chart|table|pharmacolog)/i.test(correctedQueryText)); if (wantsClozapineMissedDose) { normalizedTokens.splice(0, normalizedTokens.length, "clozapine", "missed", "dose", "monitoring", "table"); @@ -1266,17 +1293,7 @@ export function buildClinicalTextSearchQuery(query: string) { } else if (wantsClozapineBloodMonitoring) { normalizedTokens.splice(0, normalizedTokens.length, "clozapine", "monitoring"); } else if (wantsAgitationMedicationChart) { - const requestedDoseRouteTerms = [ - /\b(?:dose|doses|dosage|dosages|dosing)\b/i.test(correctedQueryText) ? "dose" : null, - /\broute\b/i.test(correctedQueryText) ? "route" : null, - /\b(?:intramuscular|im)\b/i.test(correctedQueryText) ? "im" : null, - /\b(?:oral|po)\b/i.test(correctedQueryText) ? "po" : null, - /\b(?:subcutaneous|subcut|sc)\b/i.test(correctedQueryText) ? "sc" : null, - /\b(?:sublingual|sl)\b/i.test(correctedQueryText) ? "sl" : null, - /\b(?:mg|mcg)\b/i.test(correctedQueryText) ? "mg" : null, - /\bfrequency\b/i.test(correctedQueryText) ? "frequency" : null, - /\bprn\b/i.test(correctedQueryText) ? "prn" : null, - ].filter((term): term is string => Boolean(term)); + const requestedDoseRouteTerms = medicationDoseEvidenceSearchTerms(query); normalizedTokens.splice(0, normalizedTokens.length, "agitation", "arousal", ...requestedDoseRouteTerms); } else if (wantsAgitationArousal) { normalizedTokens.splice(0, normalizedTokens.length, "agitation", "arousal", "pharmacological", "management"); diff --git a/src/lib/retrieval-selection.ts b/src/lib/retrieval-selection.ts index a8f0c93e4..7742d2f9b 100644 --- a/src/lib/retrieval-selection.ts +++ b/src/lib/retrieval-selection.ts @@ -1,5 +1,9 @@ import { citationFromResult, documentCitationHref } from "@/lib/citations"; -import { medicationDoseQueryContext, medicationDoseQuerySubjectTokens } from "@/lib/clinical-search"; +import { + medicationDoseEvidenceQueryIntent, + medicationDoseQueryContext, + medicationDoseQuerySubjectTokens, +} from "@/lib/clinical-search"; import type { RagQueryClass, RetrievalCandidate, @@ -350,13 +354,10 @@ function resultBoost(args: { intent: RetrievalIntent; candidate: RetrievalCandid export function buildRetrievalIntent(query: string, queryClass: RagQueryClass): RetrievalIntent { const normalizedQuery = normalize(query); + const medicationEvidenceIntent = medicationDoseEvidenceQueryIntent(normalizedQuery); const asksDoseRoute = - /\b(?:dose|doses|dosage|dosages|dosing|route|oral|intramuscular|subcutaneous|subcut|sublingual|im|po|sc|sl|frequency|mg|mcg|prn)\b/.test( - normalizedQuery, - ); - const asksDoseAmount = /\b(?:dose|doses|dosage|dosages|dosing|mg|mcg|microgram|maximum|minimum)\b/.test( - normalizedQuery, - ); + medicationEvidenceIntent.asksAmount || medicationEvidenceIntent.asksRoute || medicationEvidenceIntent.asksFrequency; + const asksDoseAmount = medicationEvidenceIntent.asksAmount; const asksTable = /\b(?:table|chart|matrix|threshold|cutoff|cut off|range|criteria|row)\b/.test(normalizedQuery); const asksSourceImage = /\b(?:source|show|open|view|display|see)\b.*\b(?:image|figure|visual|table|chart|matrix)\b/.test(normalizedQuery) || @@ -398,8 +399,7 @@ export function buildRetrievalIntent(query: string, queryClass: RagQueryClass): } if (asksDoseRoute) { if (asksDoseAmount) requiredTermSignals.push("dose_amount"); - if (/\b(?:route|oral|intramuscular|subcutaneous|subcut|sublingual|im|po|sc|sl)\b/.test(normalizedQuery)) - requiredTermSignals.push("route"); + if (medicationEvidenceIntent.asksRoute) requiredTermSignals.push("route"); if (queryClass === "medication_dose_risk" && medicationDoseQuerySubjectTokens(query).length > 0) { requiredTermSignals.push("clinical_subject"); } diff --git a/tests/clinical-search.test.ts b/tests/clinical-search.test.ts index f06fc36b2..b6e41b3ee 100644 --- a/tests/clinical-search.test.ts +++ b/tests/clinical-search.test.ts @@ -218,6 +218,21 @@ describe("clinical search query normalization", () => { ).toBe("agitation arousal dose"); }); + it("preserves supported agitation amount, route, and frequency signals without broad expansion", () => { + expect(buildClinicalTextSearchQuery("What 5 mg option is listed for agitation?")).toBe( + "agitation arousal dose 5 mg", + ); + expect(buildClinicalTextSearchQuery("Which subcutaneous dose is listed for agitation?")).toBe( + "agitation arousal dose sc", + ); + expect(buildClinicalTextSearchQuery("Which sublingual PRN option is listed for agitation?")).toBe( + "agitation arousal sl prn", + ); + expect(buildClinicalTextSearchQuery("What dosing frequency is listed for agitation?")).toBe( + "agitation arousal dose frequency", + ); + }); + it("anchors clozapine blood-monitoring paraphrases to clozapine FBC evidence", () => { expect( buildClinicalTextSearchQuery(