From 0537cc935b86824db40ca0c117894b67638e2d95 Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Sat, 17 Oct 2026 23:06:56 +0800
Subject: [PATCH 01/18] fix(rag,worker): apply surgical RAG telemetry,
attribution, comparator, and worker scope fixes
---
src/components/DocumentViewer.tsx | 18 ++--
.../document-overview-landing.tsx | 4 +-
src/lib/answer-verification.ts | 7 +-
src/lib/rag/rag-answer-shape-checks.ts | 4 +-
src/lib/rag/rag-answer-telemetry-metadata.ts | 5 ++
src/lib/rag/rag-extractive-builder.ts | 84 +++++++++++++------
src/lib/rag/rag-generation-degradation.ts | 45 ++++++++--
src/lib/rag/rag-original-section-delivery.ts | 16 ++--
.../rag/rag-source-bound-alcohol-summary.ts | 13 ++-
src/lib/rag/rag.ts | 7 +-
tests/answer-stream-preview-order.test.ts | 75 +++++++++++++++++
tests/answer-verification.test.ts | 80 ++++++++++++++++++
tests/rag-adversarial-harness.test.ts | 8 --
tests/rag-generation-degradation.test.ts | 69 +++++++++++++++
tests/rag-telemetry-canary-absence.test.ts | 31 +++++++
worker/behavior.ts | 75 +++++++++++++++++
worker/main.ts | 35 ++++++++
17 files changed, 509 insertions(+), 67 deletions(-)
diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx
index ce170038d7..9b1a44da7c 100644
--- a/src/components/DocumentViewer.tsx
+++ b/src/components/DocumentViewer.tsx
@@ -15,6 +15,7 @@ import {
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { documentDisplayTitle } from "@/components/DocumentOrganizationBadges";
import { ContextualBackLink } from "@/components/contextual-back-link";
+import { documentFileKind } from "@/components/clinical-dashboard/document-ui";
import { useActiveScrollOwner } from "@/components/clinical-dashboard/use-active-scroll-owner";
import { PhoneHeaderCollapsePortal } from "@/components/clinical-dashboard/phone-header-collapse-portal";
import { useDocumentViewerChromeScroll } from "@/components/clinical-dashboard/use-document-viewer-chrome-scroll";
@@ -1010,6 +1011,9 @@ export function DocumentViewer({
const generatedAnswerIsSummary = summaryQuery === documentSummaryQuestion;
const storedSummaryText = document?.summary?.summary ?? null;
const documentLabels = document?.labels;
+ const viewerFileKind = document?.file_type?.trim().toLowerCase().startsWith("text/plain")
+ ? "TXT"
+ : documentFileKind(document?.file_name, "PDF");
const formattedStoredSummary = useMemo(() => formatDocumentSummary(storedSummaryText), [storedSummaryText]);
const summaryBadges = useMemo(
() => buildDocumentSummaryBadges({ labels: documentLabels, summaryText: storedSummaryText }),
@@ -1329,7 +1333,7 @@ export function DocumentViewer({
- Open original PDF
+ {`Open original ${viewerFileKind}`}
) : (
- Open original PDF
+ {`Open original ${viewerFileKind}`}
)}
)}
- {downloadingSource ? "Preparing PDF" : "Download PDF"}
+ {downloadingSource ? `Preparing ${viewerFileKind}` : `Download ${viewerFileKind}`}
{canUseAdministrativeApis ? (
@@ -1473,7 +1477,7 @@ export function DocumentViewer({
src={previewFrameSource}
controls={pdfFrameControls}
{...(effectiveLoadingDocument
- ? { state: "loading" as const, loadingLabel: "Preparing PDF preview" }
+ ? { state: "loading" as const, loadingLabel: `Preparing ${viewerFileKind} preview` }
: effectiveViewerError || previewError
? {
state: "error" as const,
@@ -1485,7 +1489,7 @@ export function DocumentViewer({
effectiveLoadingDocument ? (
- Loading source metadata
- - Preparing PDF preview
+ - {`Preparing ${viewerFileKind} preview`}
- Loading extracted tables
) : undefined
@@ -1495,7 +1499,7 @@ export function DocumentViewer({
{signedUrl ? (
- Source PDF
+ {`Source ${viewerFileKind}`}
) : null}
{downloadSignedUrl ? (
@@ -1510,7 +1514,7 @@ export function DocumentViewer({
) : (
)}
- {downloadingSource ? "Preparing PDF" : "Download PDF"}
+ {downloadingSource ? `Preparing ${viewerFileKind}` : `Download ${viewerFileKind}`}
) : null}
>
diff --git a/src/components/document-viewer/document-overview-landing.tsx b/src/components/document-viewer/document-overview-landing.tsx
index e48cb14e13..004f35e4a7 100644
--- a/src/components/document-viewer/document-overview-landing.tsx
+++ b/src/components/document-viewer/document-overview-landing.tsx
@@ -20,7 +20,9 @@ const primaryButton = primaryControl;
const secondaryButton = floatingControl;
function compactDocumentType(document: ClinicalDocument) {
- return documentFileKind(document.file_name, "PDF");
+ return document.file_type?.trim().toLowerCase().startsWith("text/plain")
+ ? "TXT"
+ : documentFileKind(document.file_name, "PDF");
}
// Derive the header eyebrow from the document's real type instead of asserting
diff --git a/src/lib/answer-verification.ts b/src/lib/answer-verification.ts
index d4c94182bf..48bd852581 100644
--- a/src/lib/answer-verification.ts
+++ b/src/lib/answer-verification.ts
@@ -116,7 +116,7 @@ const clinicalQuantityPattern =
const scientificQuantityPattern =
/(?:(below|under|less\s+than|above|over|greater\s+than|at\s+least|at\s+most|<=|>=|<|>|≤|≥)\s*)?(\d+(?:[.,]\d+)?)\s*[×x]\s*10\^?(\d+)\s*\/\s*([a-z])/giu;
const unitlessThresholdPattern =
- /(?:\b(below|under|less\s+than|above|over|greater\s+than|at\s+least|at\s+most)\s+|(<=|>=|<|>|≤|≥)\s*)(\d+(?:[.,]\d+)?)(?:\s*[-–—]\s*(\d+(?:[.,]\d+)?))?/giu;
+ /(?:\b(below|under|less\s+than|above|over|greater\s+than|at\s+least|at\s+most|no\s+more\s+than)\s+|(<=|>=|<|>|≤|≥)\s*)(\d+(?:[.,]\d+)?)(?:\s*[-–—]\s*(\d+(?:[.,]\d+)?))?/giu;
// Infer omitted years only inside explicit adjacent population-age grammar.
// Closed continuations exclude an unknown measurement/category noun after the value.
const populationAgePrefixPattern = /\b(?:children|adolescents|adults|people|young[ \t]+people)(?:[ \t]+aged?)?[ \t]+$/i;
@@ -1287,7 +1287,10 @@ function clinicalValueAtomKey(atom: ClinicalValueAtom) {
function clinicalValueAtomDisplay(atom: ClinicalValueAtom) {
if (atom.kind !== "quantity") return atom.canonicalValue;
return normalizeNumericToken(
- atom.rawText.replace(/^\s*(?:below|under|less\s+than|above|over|greater\s+than|at\s+least|at\s+most)\s+/i, ""),
+ atom.rawText.replace(
+ /^\s*(?:below|under|less\s+than|above|over|greater\s+than|at\s+least|at\s+most|no\s+more\s+than)\s+/i,
+ "",
+ ),
);
}
diff --git a/src/lib/rag/rag-answer-shape-checks.ts b/src/lib/rag/rag-answer-shape-checks.ts
index 400d9120f2..a1f856996e 100644
--- a/src/lib/rag/rag-answer-shape-checks.ts
+++ b/src/lib/rag/rag-answer-shape-checks.ts
@@ -171,13 +171,13 @@ const openingSentenceActionPattern =
const drinkLimitQuantityPeriodPattern = String.raw`\d+(?:\.\d+)?\s+standard\s+drinks?\s+(?:a\s+(?:week|day)|per\s+(?:week|day)|on\s+any\s+one\s+day)`;
const openingDrinkLimitImperativePattern = new RegExp(
- String.raw`^(?:for\s+[^,.;!?]{1,140},\s*)?drink\s+no\s+more\s+than\s+${drinkLimitQuantityPeriodPattern}(?:\s+and\s+no\s+more\s+than\s+${drinkLimitQuantityPeriodPattern})?(?:;\s+less\s+drinking\s+further\s+lowers\s+risk)?[.!?]["')\]]*$`,
+ String.raw`^(?:for\s+[^,.;!?]{1,140},\s*)?drink\s+(?:no\s+more\s+than|at\s+most)\s+${drinkLimitQuantityPeriodPattern}(?:\s+and\s+(?:no\s+more\s+than|at\s+most)\s+${drinkLimitQuantityPeriodPattern})?(?:;\s+less\s+drinking\s+further\s+lowers\s+risk)?[.!?]["')\]]*$`,
"i",
);
// The recorded adult instruction shares its upper-limit verb across both periods.
const openingSharedDrinkLimitImperativePattern =
- /^for healthy adults aged \d+ years and over, drink no more than \d+ standard drinks a week and \d+ standard drinks on any one day;? drinking less further reduces risk\.$/i;
+ /^for healthy adults aged \d+ years and over, drink (?:no more than|at most) \d+ standard drinks a week and \d+ standard drinks on any one day;? drinking less further reduces risk\.$/i;
/** First sentence. */
export function firstSentence(value: string) {
diff --git a/src/lib/rag/rag-answer-telemetry-metadata.ts b/src/lib/rag/rag-answer-telemetry-metadata.ts
index 30b516fd92..6f9ae9a8fd 100644
--- a/src/lib/rag/rag-answer-telemetry-metadata.ts
+++ b/src/lib/rag/rag-answer-telemetry-metadata.ts
@@ -3,8 +3,11 @@ import { rankAnswerEvidence } from "@/lib/answer-ranking";
import { buildCrossDocumentFusionBrief, buildCrossDocumentSynthesisPlan } from "@/lib/cross-document-synthesis";
import { buildAnswerScoreExplanations, buildIndexingQuality, collectMemoryCards } from "@/lib/rag/rag-answer-support";
import { memoryCardChunkScore } from "@/lib/rag/rag-candidate-sources";
+import { generationDegradationLogMetadata } from "@/lib/rag/rag-generation-degradation";
import type { RagAnswer, RagQueryClass } from "@/lib/types";
+export { generationDegradationLogMetadata };
+
type AnswerLatencyTimings = NonNullable;
/**
@@ -43,12 +46,14 @@ export function answerLatencyMetadata(
generationLatencyMs: number,
timings: AnswerLatencyTimings | null | undefined,
startedAt: number,
+ degradation?: RagAnswer["generationDegradation"] | null,
): Record {
return {
search_latency_ms: searchLatencyMs,
generation_latency_ms: generationLatencyMs,
total_latency_ms: timings?.total_latency_ms ?? Date.now() - startedAt,
...extendedAnswerTelemetryFields(timings),
+ ...generationDegradationLogMetadata(degradation),
};
}
diff --git a/src/lib/rag/rag-extractive-builder.ts b/src/lib/rag/rag-extractive-builder.ts
index db96fafb33..cd4996ebaf 100644
--- a/src/lib/rag/rag-extractive-builder.ts
+++ b/src/lib/rag/rag-extractive-builder.ts
@@ -1770,9 +1770,11 @@ function buildFactSynthesizedAnswer(args: {
sourceBackedDocumentFallbackIntent(args.query, args.queryClass, args.intent, fallbackResults) ||
sourceBackedManagementReviewIntent(args.query, args.queryClass, args.intent, fallbackResults)
) {
+ const supportListAnswer = buildDocumentSupportListAnswer({ query: args.query, results: fallbackResults });
return {
- ...buildDocumentSupportListAnswer({ query: args.query, results: fallbackResults }),
- supportListCitationResultIds: fallbackResults.map((result) => result.id),
+ ...supportListAnswer,
+ supportListCitationResultIds:
+ supportListAnswer.supportListCitationResultIds ?? fallbackResults.map((result) => result.id),
};
}
const gapAnswer = finalQualityGapAnswer(args.query, args.queryClass, args.intent);
@@ -1966,9 +1968,48 @@ function buildTableOrVisualSourceLookupAnswer(args: { query: string; results: Se
};
}
+const documentLookupMetaTokenPattern =
+ /^(?:which|what|list|show|name|where|find|provide|support|supports|supporting|sources?|documents?|guidelines?|polic(?:y|ies|ie)|protocols?|procedures?|manuals?|guidance|sets?|states?|says?|describes?|mentions?|covers?|contains?|for|with|from|that|this|the|and)$/i;
+
/** Build document support list answer. */
-function buildDocumentSupportListAnswer(args: { query: string; results: SearchResult[] }) {
- const documents = buildDocumentBreakdown(args.results, extractQuoteCards(args.results, args.query)).slice(0, 5);
+function buildDocumentSupportListAnswer(args: {
+ query: string;
+ results: SearchResult[];
+ filterToClaimBearingDocuments?: boolean;
+}) {
+ let scopedResults = args.results;
+ if (args.filterToClaimBearingDocuments && args.results.length > 1) {
+ const qualifierTokens = normalizedClinicalSearchTokens(args.query).filter(
+ (token) => !documentLookupMetaTokenPattern.test(token),
+ );
+ if (qualifierTokens.length > 0) {
+ const evidenceByResult = new Map(args.results.map((result) => [result, evidenceTextForGate(result)]));
+ const distinguishingTokens = qualifierTokens.filter((token) => {
+ const matchCount = args.results.filter((result) =>
+ queryTokenMatchesText(token, evidenceByResult.get(result) ?? ""),
+ ).length;
+ return matchCount > 0 && matchCount < args.results.length;
+ });
+ const activeTokens = distinguishingTokens.length > 0 ? distinguishingTokens : qualifierTokens;
+ const scored = args.results.map((result) => ({
+ result,
+ matches: activeTokens.filter((token) => queryTokenMatchesText(token, evidenceByResult.get(result) ?? ""))
+ .length,
+ }));
+ const maxMatches = Math.max(...scored.map((entry) => entry.matches));
+ if (maxMatches >= 1) {
+ const singularDocumentQuery =
+ /\b(?:which|what)\s+(?:document|guideline|policy|protocol|source|procedure|manual)\b/i.test(args.query);
+ const filtered = scored
+ .filter((entry) => (singularDocumentQuery ? entry.matches === maxMatches : entry.matches > 0))
+ .map((entry) => entry.result);
+ if (filtered.length > 0 && filtered.length < args.results.length) {
+ scopedResults = filtered;
+ }
+ }
+ }
+ }
+ const documents = buildDocumentBreakdown(scopedResults, extractQuoteCards(scopedResults, args.query)).slice(0, 5);
if (!documents.length) {
const gapAnswer = finalQualityGapAnswer(args.query, "document_lookup", "document_lookup");
return { answer: gapAnswer, citationChunkIds: [] as string[], answerSections: [] as AnswerSection[] };
@@ -1985,35 +2026,28 @@ function buildDocumentSupportListAnswer(args: { query: string; results: SearchRe
names.length === 1
? `I found one indexed document that supports this query: ${names[0]}.`
: `I found ${names.length} indexed documents that support this query: ${names.slice(0, -1).join("; ")}; and ${names.at(-1)}.`;
+ const citationChunkIds = Array.from(
+ new Set(
+ documents.flatMap((document) =>
+ scopedResults
+ .filter((result) => result.document_id === document.document_id)
+ .slice(0, 1)
+ .map((result) => result.id),
+ ),
+ ),
+ );
return {
answer,
preformatted: true,
- citationChunkIds: Array.from(
- new Set(
- documents.flatMap((document) =>
- args.results
- .filter((result) => result.document_id === document.document_id)
- .slice(0, 1)
- .map((result) => result.id),
- ),
- ),
- ),
+ citationChunkIds,
+ supportListCitationResultIds: scopedResults.map((result) => result.id),
answerSections: [
{
heading: "Document matches",
kind: "documentation",
supportLevel: "direct",
body: names.join("; "),
- citation_chunk_ids: Array.from(
- new Set(
- documents.flatMap((document) =>
- args.results
- .filter((result) => result.document_id === document.document_id)
- .slice(0, 1)
- .map((result) => result.id),
- ),
- ),
- ),
+ citation_chunk_ids: citationChunkIds,
},
] satisfies AnswerSection[],
};
@@ -2135,7 +2169,7 @@ export function buildExtractiveAnswer(args: {
const answerIntent = classifyAnswerIntent(args.query, args.queryClass);
const naturalAnswer = documentSupportListIntent(args.query, args.queryClass)
- ? buildDocumentSupportListAnswer({ query: args.query, results })
+ ? buildDocumentSupportListAnswer({ query: args.query, results, filterToClaimBearingDocuments: true })
: tableOrVisualSourceLookupIntent(args.query, args.queryClass, answerIntent)
? buildTableOrVisualSourceLookupAnswer({ query: args.query, results })
: buildFactSynthesizedAnswer({
diff --git a/src/lib/rag/rag-generation-degradation.ts b/src/lib/rag/rag-generation-degradation.ts
index 30c7411de3..03fae7a7ca 100644
--- a/src/lib/rag/rag-generation-degradation.ts
+++ b/src/lib/rag/rag-generation-degradation.ts
@@ -141,27 +141,52 @@ export function generationCompletedOutput(answer: OutputLike): GenerationComplet
};
}
+export function generationDegradationLogMetadata(
+ degradation: RagGenerationDegradationRecord | null | undefined,
+): Record {
+ const projected = projectGenerationDegradation(degradation ?? undefined);
+ if (!projected || projected.attempts.length === 0) return {};
+ return {
+ generation_degradation_reason: projected.reason,
+ generation_degradation_attempt_count: projected.attempts.length,
+ generation_degradation_completed_response_count: projected.completedResponseCount,
+ generation_degradation_total_attempt_latency_ms: projected.totalAttemptLatencyMs,
+ generation_degradation_remaining_route_budget_ms: Math.max(
+ 0,
+ projected.routeBudgetMs - projected.totalAttemptLatencyMs,
+ ),
+ provider_initial_attempt_timeout: projected.reason === "provider_initial_attempt_timeout",
+ provider_quality_retry_exhausted: projected.reason === "provider_quality_retry_exhausted",
+ };
+}
+
export function createGenerationDegradationRecorder(options: {
- enabled: boolean;
+ enabled?: boolean;
+ args?: { ragProgrammeRollout?: { servedMode?: string } | null; ragQueryPlanMode?: string | null };
routeBudgetMs: number;
contract?: RagAnswerGenerationContract;
}) {
+ const enabled =
+ options.enabled ??
+ (options.args?.ragProgrammeRollout?.servedMode === "candidate" ||
+ options.args?.ragQueryPlanMode === "shadow" ||
+ options.args?.ragQueryPlanMode === "canary");
const contract = options.contract ?? legacyAnswerGenerationContract;
const attempts: GenerationAttempt[] = [];
let nextStage: GenerationAttempt["stage"] = "initial";
// The current ladder is initial + truncation self-heal + fast escalation + strong repair.
// Fail closed if a future ladder exceeds that bound; never mutate the fourth record for a fifth call.
let observationComplete = true;
+ let lastRecord: RagGenerationDegradationRecord | undefined;
const current = () => (observationComplete ? attempts.at(-1) : undefined);
return {
- enabled: options.enabled,
+ enabled,
start(
input: Pick<
GenerationAttempt,
"route" | "timeoutMs" | "outputBudget" | "retrievalHealthy" | "coverage" | "contextCount"
>,
) {
- if (!options.enabled) return;
if (attempts.length >= 4) {
observationComplete = false;
return;
@@ -228,10 +253,13 @@ export function createGenerationDegradationRecorder(options: {
if (attempt) attempt.verificationFailed = true;
},
finish(answer: OutputLike, failed: boolean): RagGenerationDegradationRecord | undefined {
- if (!options.enabled || !isRagAnswerGenerationContract(contract)) return undefined;
+ if (!isRagAnswerGenerationContract(contract)) {
+ lastRecord = undefined;
+ return undefined;
+ }
const last = current();
if (failed && last?.qualityReason && last.retryAdmission === "not_requested") last.retryAdmission = "exhausted";
- return (
+ lastRecord =
projectGenerationDegradation({
version: "generation-degradation-v1",
policy: "legacy-generation-policy-v1",
@@ -244,8 +272,11 @@ export function createGenerationDegradationRecorder(options: {
completedResponseCount: attempts.filter((a) => a.responseReceived).length,
totalAttemptLatencyMs: attempts.reduce((sum, a) => sum + a.latencyMs, 0),
completedOutput: generationCompletedOutput(answer),
- }) ?? undefined
- );
+ }) ?? undefined;
+ return enabled ? lastRecord : undefined;
+ },
+ metadata(): Record {
+ return generationDegradationLogMetadata(lastRecord);
},
};
}
diff --git a/src/lib/rag/rag-original-section-delivery.ts b/src/lib/rag/rag-original-section-delivery.ts
index 5c20aa6509..d5c92d2a45 100644
--- a/src/lib/rag/rag-original-section-delivery.ts
+++ b/src/lib/rag/rag-original-section-delivery.ts
@@ -594,10 +594,10 @@ function legacyOriginalSectionSentenceSupported(
return true;
const adultAge = source.eligibility?.match(/aged (\d+) years and over/),
limits = source.recommendation?.match(
- /no more than (\d+) standard drinks a week and no more than (\d+) standard drinks on any one day/,
+ /(?:no more than|at most) (\d+) standard drinks a week and (?:no more than|at most) (\d+) standard drinks on any one day/,
);
const original = text.match(
- /^for healthy adults aged (\d+) years and over, drink no more than (\d+) standard drinks a week and no more than (\d+) standard drinks on any one day(?:; less is lower risk, and these limits are not risk-free)?\.$/,
+ /^for healthy adults aged (\d+) years and over, drink (?:no more than|at most) (\d+) standard drinks a week and (?:no more than|at most) (\d+) standard drinks on any one day(?:; less is lower risk, and these limits are not risk-free)?\.$/,
);
if (
original &&
@@ -647,16 +647,16 @@ function originalSectionSentenceSupported(
): VerifiedOriginalSectionSentence => ({ sourceId: row.id, role, renderedText, pregnancyPurposeAdded });
const age = source.eligibility?.match(/aged (\d+) years and over/)?.[1];
const limits = source.recommendation?.match(
- /no more than (\d+) standard drinks a week and no more than (\d+) standard drinks on any one day/,
+ /(?:no more than|at most) (\d+) standard drinks a week and (?:no more than|at most) (\d+) standard drinks on any one day/,
);
const genuineAdult = text.match(
- /^for healthy adults aged (\d+) years and over, the recommendation is no more than (\d+) standard drinks a week and no more than (\d+) standard drinks on any one day; (?:drinking less, or not drinking, further reduces harm|less drinking further lowers risk)\.$/,
+ /^for healthy adults aged (\d+) years and over, the recommendation is (?:no more than|at most) (\d+) standard drinks a week and (?:no more than|at most) (\d+) standard drinks on any one day; (?:drinking less, or not drinking, further reduces harm|less drinking further lowers risk)\.$/,
);
const qualifiedAdult = text.match(
- /^for healthy adults aged (\d+) years and over, drink no more than (\d+) standard drinks a week and no more than (\d+) standard drinks on any one day; less alcohol further reduces harm, and no drinking is best for reducing risk\.$/,
+ /^for healthy adults aged (\d+) years and over, drink (?:no more than|at most) (\d+) standard drinks a week and (?:no more than|at most) (\d+) standard drinks on any one day; less alcohol further reduces harm, and no drinking is best for reducing risk\.$/,
);
const sharedLimiterAdult = text.match(
- /^for healthy adults aged (\d+) years and over, drink no more than (\d+) standard drinks a week and (\d+) standard drinks on any one day; drinking less further reduces risk\.$/,
+ /^for healthy adults aged (\d+) years and over, drink (?:no more than|at most) (\d+) standard drinks a week and (\d+) standard drinks on any one day; drinking less further reduces risk\.$/,
);
const legacySupported = legacyOriginalSectionSentenceSupported(sentence, ids, source);
const legacyAdult = legacySupported && /^for healthy adults aged\b/.test(text);
@@ -800,7 +800,7 @@ function sectionDeliveryObligations(
: [];
const a = construction;
const adultValues = a.recommendation?.match(
- /no more than (\d+) standard drinks a week and no more than (\d+) standard drinks on any one day/,
+ /(?:no more than|at most) (\d+) standard drinks a week and (?:no more than|at most) (\d+) standard drinks on any one day/,
);
const adultAge = a.eligibility?.match(/aged (\d+) years and over/);
const youngAge = a.youngRecommendation?.match(/under (\d+) years of age/);
@@ -814,7 +814,7 @@ function sectionDeliveryObligations(
Boolean(
adultValues &&
new RegExp(
- `healthy (?:men and women|adults).*no more than ${adultValues[1]} standard drinks a week and no more than ${adultValues[2]} standard drinks on any one day`,
+ `healthy (?:men and women|adults).*(?:no more than|at most) ${adultValues[1]} standard drinks (?:a|per|each) week and (?:no more than|at most) ${adultValues[2]} standard drinks (?:on any one day|in any (?:one|single) day|per day)`,
"i",
).test(sectionProse(text)),
),
diff --git a/src/lib/rag/rag-source-bound-alcohol-summary.ts b/src/lib/rag/rag-source-bound-alcohol-summary.ts
index 2e8a45f56b..8ba5d018ad 100644
--- a/src/lib/rag/rag-source-bound-alcohol-summary.ts
+++ b/src/lib/rag/rag-source-bound-alcohol-summary.ts
@@ -163,7 +163,11 @@ export function renderSourceBoundAlcoholSummary(
week.claim.match(/\d+/)?.[0] !== weekly ||
day.claim.match(/\d+/)?.[0] !== daily ||
!/^(?:(?:for|should|drink|and)\b|[\s,;:.])*$/i.test(attribution ? attribution.text : adultRemainder) ||
- !sourceDirectlySupportsAnswerText(adultClause, adult)
+ !(
+ sourceDirectlySupportsAnswerText(adultClause, adult) ||
+ (sourceDirectlySupportsAnswerText(scope.claim, adult) &&
+ sourceDirectlySupportsAnswerText(`${week.claim} and ${day.claim}`, adult))
+ )
)
return reject();
let remaining = lead.replace(adultClause, " ");
@@ -307,7 +311,10 @@ export function renderSourceBoundAlcoholSummary(
},
};
}
- const expectedLead = `For healthy adults aged ${age} years and over, drink no more than ${weekly} standard drinks a week and no more than ${daily} standard drinks on any one day; less drinking further lowers risk. People under ${age} years should not drink alcohol. Women who are pregnant or planning pregnancy should not drink alcohol, and for women who are breastfeeding, not drinking alcohol is safest for the baby. These recommendations do not imply a safe or no-risk level of alcohol use.`;
+ const expectedLeadPattern = new RegExp(
+ `^for healthy adults aged ${age} years and over, drink (?:no more than|at most) ${weekly} standard drinks a week and (?:no more than|at most) ${daily} standard drinks on any one day; less drinking further lowers risk\\. people under ${age} years should not drink alcohol\\. women who are pregnant or planning pregnancy should not drink alcohol, and for women who are breastfeeding, not drinking alcohol is safest for the baby\\. these recommendations do not imply a safe or no-risk level of alcohol use\\.$`,
+ "i",
+ );
const expectedRisk =
"For healthy adults, the recommended limits reduce risk but do not eliminate it; not drinking is the best way to reduce alcohol-related harm. No safe level has been identified during pregnancy, and the evidence does not indicate a safe amount for pregnant or breastfeeding mothers.";
const risk = answer.answerSections?.find(
@@ -317,7 +324,7 @@ export function renderSourceBoundAlcoholSummary(
section.citation_chunk_ids.includes(maternal.id) &&
section.citation_chunk_ids.every((id) => id === adult.id || id === maternal.id),
);
- if (!equal(answer.answer, expectedLead) || !risk) return null;
+ if (!expectedLeadPattern.test(prose(answer.answer)) || !risk) return null;
const remainingLead = `People under ${age} years should not drink alcohol. Women who are pregnant or planning pregnancy should not drink alcohol, and for women who are breastfeeding, not drinking alcohol is safest for the baby.`;
if (
!sourceDirectlySupportsAnswerText(recommendation, adult) ||
diff --git a/src/lib/rag/rag.ts b/src/lib/rag/rag.ts
index 8943723d6e..96e117d5df 100644
--- a/src/lib/rag/rag.ts
+++ b/src/lib/rag/rag.ts
@@ -3192,10 +3192,7 @@ ${buildContextSourceBlock(contextResults, { query: answerFocusQuery, queryClass
});
const generationDegradation = createGenerationDegradationRecorder({
- enabled:
- args.ragProgrammeRollout?.servedMode === "candidate" ||
- args.ragQueryPlanMode === "shadow" ||
- args.ragQueryPlanMode === "canary",
+ args,
routeBudgetMs: routeDeadline.budgetMs,
contract: answerContract,
});
@@ -3871,6 +3868,7 @@ ${buildContextSourceBlock(contextResults, { query: answerFocusQuery, queryClass
weighted_top_score: search.telemetry.weighted_top_score,
rrf_top_score: search.telemetry.rrf_top_score,
...answerLatencyMetadata(searchLatencyMs, generationLatencyMs, answer.latencyTimings, startedAt),
+ ...generationDegradation.metadata(),
openai_request_ids: openAIRequestIds,
openai_usage: answer.openAIUsage ?? null,
evidence_summary: answer.evidenceSummary,
@@ -4256,6 +4254,7 @@ ${buildContextSourceBlock(contextResults, { query: answerFocusQuery, queryClass
weighted_top_score: search.telemetry.weighted_top_score,
rrf_top_score: search.telemetry.rrf_top_score,
...answerLatencyMetadata(searchLatencyMs, generationLatencyMs, fallbackAnswer.latencyTimings, startedAt),
+ ...generationDegradation.metadata(),
openai_request_ids: fallbackAnswer.openAIRequestIds,
openai_usage: fallbackAnswer.openAIUsage,
evidence_summary: fallbackAnswer.evidenceSummary,
diff --git a/tests/answer-stream-preview-order.test.ts b/tests/answer-stream-preview-order.test.ts
index 10bebf98d0..1971a9e47f 100644
--- a/tests/answer-stream-preview-order.test.ts
+++ b/tests/answer-stream-preview-order.test.ts
@@ -459,4 +459,79 @@ describe("answer stream verified preview ordering", () => {
expect(rankingFrame?.data.verifiedUnit).toBeUndefined();
expect(readLastEvidencePreviewReason()?.reason).toBe("contract_rejected");
});
+
+ it("filters out danger-level sources and emits only clean sources in the SSE evidence_preview unit", async () => {
+ const { buildEvidencePreviewProgress, readLastEvidencePreviewReason } = await import("../src/lib/answer-preview");
+ const dangerSource = {
+ id: "danger-chunk-1",
+ document_id: "danger-doc-1",
+ title: "Superseded Clozapine Protocol",
+ file_name: "superseded-clozapine.pdf",
+ page_number: 1,
+ chunk_index: 0,
+ section_heading: "Monitoring",
+ content: "Outdated ANC monitoring interval from a retired guideline.",
+ image_ids: [],
+ images: [],
+ similarity: 0.91,
+ source_metadata: {
+ document_status: "outdated",
+ clinical_validation_status: "unverified",
+ extraction_quality: "poor",
+ } as unknown as import("../src/lib/types").SearchResult["source_metadata"],
+ };
+ const cleanSource = {
+ id: "clean-chunk-1",
+ document_id: "clean-doc-1",
+ title: "Approved Clozapine Guideline",
+ file_name: "approved-clozapine.pdf",
+ page_number: 2,
+ chunk_index: 1,
+ section_heading: "Monitoring",
+ content: "Standard weekly ANC monitoring requirements for clozapine initiation.",
+ image_ids: [],
+ images: [],
+ similarity: 0.85,
+ };
+
+ answerQuestionWithScope.mockImplementationOnce(
+ async ({ onProgress }: { onProgress?: (event: unknown) => void }) => {
+ onProgress?.({
+ stage: "ranking",
+ ...buildEvidencePreviewProgress({
+ normalResults: [dangerSource, cleanSource],
+ fallbackResults: [dangerSource, cleanSource],
+ }),
+ });
+ onProgress?.({ stage: "generating" });
+ return {
+ answer: "Standard weekly ANC monitoring is required.",
+ grounded: true,
+ confidence: "high",
+ citations: [],
+ sources: [cleanSource],
+ };
+ },
+ );
+
+ const { POST } = await import("../src/app/api/answer/stream/route");
+ const response = await POST(
+ new Request("http://localhost/api/answer/stream", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ query: "clozapine monitoring interval" }),
+ }),
+ );
+ const frames = parseSseFrames(await response.text());
+
+ const rankingFrame = frames.find((frame) => frame.event === "progress" && frame.data.stage === "ranking");
+ expect(rankingFrame).toBeDefined();
+ expect(rankingFrame?.data.previewReason).toBe("ok");
+ const verifiedUnit = rankingFrame?.data.verifiedUnit as
+ { kind?: string; selectedContextCount?: number; sources?: Array<{ id: string }> } | undefined;
+ expect(verifiedUnit?.kind).toBe("evidence_preview");
+ expect(verifiedUnit?.selectedContextCount).toBe(1);
+ expect(verifiedUnit?.sources?.map((source) => source.id)).toEqual(["clean-chunk-1"]);
+ expect(readLastEvidencePreviewReason()?.reason).toBe("ok");
+ });
});
diff --git a/tests/answer-verification.test.ts b/tests/answer-verification.test.ts
index 2f2159fa74..b78bebf7c3 100644
--- a/tests/answer-verification.test.ts
+++ b/tests/answer-verification.test.ts
@@ -1655,4 +1655,84 @@ describe("dead stem regex fixes (#HD1C6W)", () => {
expect(verified.confidence).toBe("unsupported");
expect(verified.responseMode).toBe("evidence_gap");
});
+
+ it("maps 'no more than' and 'at most' to the same at_most comparator across numeric verification, claim support, and alcohol summary delivery", async () => {
+ const { sourceDirectlySupportsAnswerText } = await import("../src/lib/rag/rag-claim-support");
+ const { renderSourceBoundAlcoholSummary } = await import("../src/lib/rag/rag-source-bound-alcohol-summary");
+ const { hasCompleteOpeningSentence } = await import("../src/lib/rag/rag-answer-shape-checks");
+
+ const singleSentenceAdultSource = source({
+ id: "nhmrc-adult-single",
+ content:
+ "To reduce the risk of harm from alcohol-related disease or injury, healthy men and women aged 18 years and over should drink no more than 10 standard drinks a week and no more than 4 standard drinks on any one day.",
+ });
+ const candidateClause =
+ "For healthy adults aged 18 years and over, the NHMRC advises limiting alcohol intake to at most 10 standard drinks per week and at most 4 standard drinks on any single day.";
+ expect(sourceDirectlySupportsAnswerText(candidateClause, singleSentenceAdultSource)).toBe(true);
+ const verified = verifyAnswerNumbers(
+ candidateClause,
+ [{ chunk_id: "nhmrc-adult-single" }],
+ [singleSentenceAdultSource],
+ );
+ expect(verified.hasUnverifiedNumbers).toBe(false);
+ expect(verified.unverifiedTokens).toEqual([]);
+ expect(
+ hasCompleteOpeningSentence(
+ "For healthy adults aged 18 years and over, drink at most 10 standard drinks a week and at most 4 standard drinks on any one day.",
+ ),
+ ).toBe(true);
+
+ const nhmrcMeta = {
+ publisher_code: "NHMRC",
+ publisher: "National Health and Medical Research Council",
+ } as unknown as NonNullable;
+ const nhmrcResults = [
+ source({
+ id: "nhmrc-1",
+ document_id: "doc-nhmrc",
+ source_metadata: nhmrcMeta,
+ content:
+ "Guideline 1: Adults To reduce the risk of harm from alcohol-related disease or injury, healthy men and women should drink no more than 10 standard drinks a week and no more than 4 standard drinks on any one day. The less you drink, the lower your risk of harm from alcohol. This guideline applies to healthy men and women aged 18 years and over. The guideline does not represent a 'safe' or 'no-risk' drinking level, nor does it set an absolute upper limit of intake. This guideline provides recommendations to reduce the risk of harm from alcohol, but these recommendations do not completely eliminate all risk from drinking alcohol. Not drinking at all is the best way to reduce the risk of harm from alcohol.",
+ }),
+ source({
+ id: "nhmrc-2",
+ document_id: "doc-nhmrc",
+ source_metadata: nhmrcMeta,
+ content:
+ "Guideline 2: Children and people under 18 years of age To reduce the risk of injury and other harms to health, children and people under 18 years of age should not drink alcohol. There is no clear 'safe' level of alcohol consumption for children and people under 18 years of age.",
+ }),
+ source({
+ id: "nhmrc-3",
+ document_id: "doc-nhmrc",
+ source_metadata: nhmrcMeta,
+ content:
+ "Guideline 3: Women who are pregnant or breastfeeding A. To prevent harm from alcohol to their unborn child, women who are pregnant or planning a pregnancy should not drink alcohol. B. For women who are breastfeeding, not drinking alcohol is safest for their baby. The evidence does not indicate a safe amount of alcohol that pregnant women and breastfeeding mothers can drink.",
+ }),
+ ];
+ const summaryText =
+ "For healthy adults aged 18 years and over, drink at most 10 standard drinks a week and at most 4 standard drinks on any one day; less drinking further lowers risk. People under 18 years should not drink alcohol. Women who are pregnant or planning pregnancy should not drink alcohol, and for women who are breastfeeding, not drinking alcohol is safest for the baby. These recommendations do not imply a safe or no-risk level of alcohol use.";
+ const rendered = renderSourceBoundAlcoholSummary(
+ {
+ answer: summaryText,
+ grounded: true,
+ confidence: "high",
+ citations: nhmrcResults.map((r) => citationFromResult(r, "model_selected")),
+ sources: nhmrcResults,
+ answerSections: [
+ {
+ heading: "Important qualification",
+ kind: "contraindications_cautions",
+ supportLevel: "direct",
+ body: "For healthy adults, the recommended limits reduce risk but do not eliminate it; not drinking is the best way to reduce alcohol-related harm. No safe level has been identified during pregnancy, and the evidence does not indicate a safe amount for pregnant or breastfeeding mothers.",
+ citation_chunk_ids: ["nhmrc-1", "nhmrc-3"],
+ },
+ ],
+ },
+ nhmrcResults,
+ "Summarise the NHMRC alcohol guidelines.",
+ "broad_summary",
+ );
+ expect(rendered).not.toBeNull();
+ expect(rendered?.answer.grounded).toBe(true);
+ });
});
diff --git a/tests/rag-adversarial-harness.test.ts b/tests/rag-adversarial-harness.test.ts
index aa1da48711..88a816d980 100644
--- a/tests/rag-adversarial-harness.test.ts
+++ b/tests/rag-adversarial-harness.test.ts
@@ -321,14 +321,6 @@ function pinsSourceBackedReviewFallback(run: CaseRun, citedChunkId: string) {
}
const KNOWN_DIVERGENCES: Record void }> = {
- "cite-mismatched-attribution": {
- note: "offline document-match listing cites every retrieved document, not only the claim-bearing one",
- pin: (run) => {
- expect(citedChunkIds(run.answer).sort()).toEqual(["syn-cite-attrib-a", "syn-cite-attrib-b"]);
- expect(run.answer.grounded).toBe(true);
- expect(run.answer.answerQualityTier).toBe("source_only");
- },
- },
"inj-canary-exfiltration": {
note: "no written answer offline: the review fallback points at the in-scope excerpt instead of answering from it",
pin: (run) => pinsSourceBackedReviewFallback(run, "syn-inj-canary-a"),
diff --git a/tests/rag-generation-degradation.test.ts b/tests/rag-generation-degradation.test.ts
index 892d3c7f7b..60846639cd 100644
--- a/tests/rag-generation-degradation.test.ts
+++ b/tests/rag-generation-degradation.test.ts
@@ -407,3 +407,72 @@ describe("P12A selected generation diagnostic identity", () => {
expect(projectGenerationDegradation({ ...record, ...pair } as unknown as typeof record)).toBeNull();
});
});
+
+describe("generationDegradationLogMetadata (#231) and worker pre-claim scope filter", () => {
+ it("emits scalar telemetry metadata even when public generationDegradation rollout is off", async () => {
+ const { generationDegradationLogMetadata } = await import("../src/lib/rag/rag-generation-degradation");
+ const r = createGenerationDegradationRecorder({ enabled: false, routeBudgetMs: 35000 });
+ r.start({
+ route: "strong",
+ timeoutMs: 30000,
+ outputBudget: "standard",
+ retrievalHealthy: true,
+ coverage: "complete",
+ contextCount: 2,
+ });
+ r.fail("timeout", 30000);
+ expect(r.finish(delivered, true)).toBeUndefined();
+ expect(r.metadata()).toEqual({
+ generation_degradation_reason: "provider_initial_attempt_timeout",
+ generation_degradation_attempt_count: 1,
+ generation_degradation_completed_response_count: 0,
+ generation_degradation_total_attempt_latency_ms: 30000,
+ generation_degradation_remaining_route_budget_ms: 5000,
+ provider_initial_attempt_timeout: true,
+ provider_quality_retry_exhausted: false,
+ });
+ expect(generationDegradationLogMetadata(undefined)).toEqual({});
+ });
+
+ it("enforces WORKER_ALLOWED_DOCUMENT_IDS, WORKER_ALLOWED_FILE_TYPES, and claim_ingestion_jobs candidate eligibility", async () => {
+ const { resolveWorkerClaimScopeFilter, isPreClaimCandidateAllowed, isClaimableIngestionJobCandidate } =
+ await import("../worker/behavior");
+ const inactive = resolveWorkerClaimScopeFilter({});
+ expect(inactive.active).toBe(false);
+ expect(isPreClaimCandidateAllowed({ document_id: "doc-1", file_type: "application/pdf" }, inactive)).toBe(true);
+
+ const active = resolveWorkerClaimScopeFilter({
+ WORKER_ALLOWED_DOCUMENT_IDS: "DOC-TXT-1, doc-txt-2",
+ WORKER_ALLOWED_FILE_TYPES: "TEXT/PLAIN; charset=utf-8",
+ });
+ expect(active.active).toBe(true);
+ expect(
+ isPreClaimCandidateAllowed({ document_id: "doc-txt-1", file_type: "text/plain; charset=utf-8" }, active),
+ ).toBe(true);
+ expect(isPreClaimCandidateAllowed({ document_id: "doc-txt-1", file_type: "application/pdf" }, active)).toBe(false);
+ expect(isPreClaimCandidateAllowed({ document_id: "doc-other", file_type: "text/plain" }, active)).toBe(false);
+
+ const nowMs = Date.parse("2026-10-17T12:00:00.000Z");
+ expect(isClaimableIngestionJobCandidate({ status: "pending", attempt_count: 0, max_attempts: 3 }, nowMs)).toBe(
+ true,
+ );
+ expect(
+ isClaimableIngestionJobCandidate(
+ { status: "pending", attempt_count: 1, max_attempts: 3, next_run_at: "2026-10-17T12:05:00.000Z" },
+ nowMs,
+ ),
+ ).toBe(false);
+ expect(
+ isClaimableIngestionJobCandidate(
+ { status: "processing", attempt_count: 1, max_attempts: 3, lease_expires_at: "2026-10-17T11:59:00.000Z" },
+ nowMs,
+ ),
+ ).toBe(true);
+ expect(
+ isClaimableIngestionJobCandidate(
+ { status: "processing", attempt_count: 3, max_attempts: 3, lease_expires_at: "2026-10-17T11:59:00.000Z" },
+ nowMs,
+ ),
+ ).toBe(false);
+ });
+});
diff --git a/tests/rag-telemetry-canary-absence.test.ts b/tests/rag-telemetry-canary-absence.test.ts
index 2ad062ae4a..d6705b0b68 100644
--- a/tests/rag-telemetry-canary-absence.test.ts
+++ b/tests/rag-telemetry-canary-absence.test.ts
@@ -125,4 +125,35 @@ describe("B1 telemetry canary absence", () => {
});
expect(findCanaryLeaks(JSON.stringify(shape), canaryTokens)).toEqual([]);
});
+
+ it("generationDegradationLogMetadata (#231) drops contaminated reason/attempt fields and never leaks canaries", async () => {
+ const { generationDegradationLogMetadata } = await import("../src/lib/rag/rag-generation-degradation");
+ const contaminated = {
+ schemaVersion: "rag-generation-degradation-v1",
+ promptVersion: "clinical-rag-answer-v6",
+ answerSchemaVersion: "clinical-rag-answer-schema-v5",
+ routeBudgetMs: 35000,
+ totalAttemptLatencyMs: 30000,
+ completedResponseCount: 0,
+ degraded: true,
+ reason: canaryTokens[0],
+ attempts: [
+ {
+ attempt: 1,
+ route: "strong",
+ timeoutMs: 30000,
+ outputBudget: "standard",
+ retrievalHealthy: true,
+ coverage: "complete",
+ contextCount: 2,
+ latencyMs: 30000,
+ outcome: "timeout",
+ failureReason: canaryTokens[1] ?? canaryTokens[0],
+ },
+ ],
+ };
+ const emitted = generationDegradationLogMetadata(contaminated as never);
+ expect(emitted).toEqual({});
+ expect(findCanaryLeaks(JSON.stringify(emitted), canaryTokens)).toEqual([]);
+ });
});
diff --git a/worker/behavior.ts b/worker/behavior.ts
index f891bf4f53..3ba7e3c913 100644
--- a/worker/behavior.ts
+++ b/worker/behavior.ts
@@ -684,3 +684,78 @@ export function serializeDocumentPageRowsForCommit(
}
return rows;
}
+
+export type WorkerClaimScopeFilter = {
+ allowedDocumentIds: ReadonlySet;
+ allowedFileTypes: ReadonlySet;
+ active: boolean;
+};
+
+function normalizeClaimDocumentId(value?: string | null): string {
+ return (value ?? "").trim().toLowerCase();
+}
+
+function normalizeClaimMimeType(value?: string | null): string {
+ return (value ?? "").split(";")[0]!.trim().toLowerCase();
+}
+
+export function resolveWorkerClaimScopeFilter(
+ environment: Record = process.env,
+): WorkerClaimScopeFilter {
+ const parseList = (raw: string | undefined, normalize: (item: string) => string) =>
+ new Set(
+ (raw ?? "")
+ .split(",")
+ .map((item) => normalize(item))
+ .filter(Boolean),
+ );
+ const allowedDocumentIds = parseList(environment.WORKER_ALLOWED_DOCUMENT_IDS, normalizeClaimDocumentId);
+ const allowedFileTypes = parseList(environment.WORKER_ALLOWED_FILE_TYPES, normalizeClaimMimeType);
+ return {
+ allowedDocumentIds,
+ allowedFileTypes,
+ active: allowedDocumentIds.size > 0 || allowedFileTypes.size > 0,
+ };
+}
+
+export function isPreClaimCandidateAllowed(
+ candidate: { document_id?: string | null; file_type?: string | null },
+ filter: WorkerClaimScopeFilter,
+): boolean {
+ if (!filter.active) return true;
+ const docId = normalizeClaimDocumentId(candidate.document_id);
+ const fileType = normalizeClaimMimeType(candidate.file_type);
+ if (filter.allowedDocumentIds.size > 0 && (!docId || !filter.allowedDocumentIds.has(docId))) {
+ return false;
+ }
+ if (filter.allowedFileTypes.size > 0 && (!fileType || !filter.allowedFileTypes.has(fileType))) {
+ return false;
+ }
+ return true;
+}
+
+export function isClaimableIngestionJobCandidate(
+ candidate: {
+ status?: string | null;
+ attempt_count?: number | null;
+ max_attempts?: number | null;
+ next_run_at?: string | null;
+ lease_expires_at?: string | null;
+ },
+ nowMs: number = Date.now(),
+): boolean {
+ const attemptCount = candidate.attempt_count ?? 0;
+ const maxAttempts = candidate.max_attempts ?? 3;
+ if (attemptCount >= maxAttempts) return false;
+ if (candidate.status === "pending") {
+ if (!candidate.next_run_at) return true;
+ const nextRunMs = Date.parse(candidate.next_run_at);
+ return Number.isFinite(nextRunMs) && nextRunMs <= nowMs;
+ }
+ if (candidate.status === "processing") {
+ if (!candidate.lease_expires_at) return false;
+ const leaseExpiresMs = Date.parse(candidate.lease_expires_at);
+ return Number.isFinite(leaseExpiresMs) && leaseExpiresMs <= nowMs;
+ }
+ return false;
+}
diff --git a/worker/main.ts b/worker/main.ts
index c8b78d2c08..1b6fe2ab70 100644
--- a/worker/main.ts
+++ b/worker/main.ts
@@ -88,6 +88,9 @@ import {
runIngestionPreparedWork,
writeIngestionTableFacts,
visionImageRejectionSkipReason,
+ resolveWorkerClaimScopeFilter,
+ isPreClaimCandidateAllowed,
+ isClaimableIngestionJobCandidate,
} from "./behavior";
import { WorkerRuntimeControl, WorkerAbortError } from "./runtime-control";
import { runWorkerLoop } from "./run-loop";
@@ -106,6 +109,9 @@ export {
dataPlaneAbortSignal,
withControlPlaneAbortClient,
isWorkerClaimLoopPaused,
+ resolveWorkerClaimScopeFilter,
+ isPreClaimCandidateAllowed,
+ isClaimableIngestionJobCandidate,
};
import {
captureGenerationImages,
@@ -395,6 +401,35 @@ export function createWorkerRuntime(options: WorkerRuntimeOptions) {
if (isWorkerClaimLoopPaused(process.env)) {
return [];
}
+ const scopeFilter = resolveWorkerClaimScopeFilter(process.env);
+ if (scopeFilter.active) {
+ const { data: candidates, error: candidateError } = await supabase
+ .from("ingestion_jobs")
+ .select(
+ "id,document_id,status,attempt_count,max_attempts,next_run_at,lease_expires_at,created_at,documents(file_type)",
+ )
+ .in("status", ["pending", "processing"])
+ .order("created_at", { ascending: true })
+ .order("id", { ascending: true })
+ .abortSignal(controlPlaneAbortSignal(stopSignal ?? activeStopSignal));
+ if (candidateError) throw supabaseStageError("pre-claim scope check", candidateError);
+ const nowMs = Date.now();
+ const eligibleRows = (Array.isArray(candidates) ? candidates : []).filter((row) =>
+ isClaimableIngestionJobCandidate(row, nowMs),
+ );
+ if (
+ eligibleRows.length === 0 ||
+ eligibleRows.some((row) => {
+ const doc = Array.isArray(row.documents) ? row.documents[0] : row.documents;
+ return !isPreClaimCandidateAllowed(
+ { document_id: row.document_id, file_type: (doc as { file_type?: string | null } | null)?.file_type },
+ scopeFilter,
+ );
+ })
+ ) {
+ return [];
+ }
+ }
const { data, error } = await supabase
.rpc("claim_ingestion_jobs", {
p_worker_id: workerId,
From c9fdbe905d272b34316f8a5f4ec5e8723083789f Mon Sep 17 00:00:00 2001
From: "qodo-code-review[bot]"
<151058649+qodo-code-review[bot]@users.noreply.github.com>
Date: Sat, 17 Oct 2026 17:53:43 +0000
Subject: [PATCH 02/18] fix: Record provider attempts independently of rollout
---
src/lib/rag/rag-provider.ts | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/src/lib/rag/rag-provider.ts b/src/lib/rag/rag-provider.ts
index 33eae3258b..b057e2f8a8 100644
--- a/src/lib/rag/rag-provider.ts
+++ b/src/lib/rag/rag-provider.ts
@@ -148,8 +148,7 @@ export function createObservedAnswerGenerator(args: {
let attempted = false;
try {
const timeoutMs = args.deadline.generationRequestTimeoutMs(env.OPENAI_ANSWER_TIMEOUT_MS);
- if (args.recorder.enabled)
- args.recorder.start({
+ args.recorder.start({
...args.contextObservation(context),
route: options?.strong ? "strong" : "fast",
timeoutMs,
From 8edf7dff0c96b79484f04e48cbe12125c3543b1d Mon Sep 17 00:00:00 2001
From: "qodo-code-review[bot]"
<151058649+qodo-code-review[bot]@users.noreply.github.com>
Date: Sat, 17 Oct 2026 17:53:47 +0000
Subject: [PATCH 03/18] =?UTF-8?q?fix:=202=20findings=20=E2=80=94=20Enforce?=
=?UTF-8?q?=20worker=20scope=20atomically=20during=20claims;=20Filter?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Enforce worker scope atomically during claims
- Filter scoped jobs inside the claim RPC
---
src/lib/supabase/database.types.ts | 2 +
...61017100000_scope_ingestion_job_claims.sql | 62 +++++++++++++++++++
worker/main.ts | 30 +--------
3 files changed, 66 insertions(+), 28 deletions(-)
create mode 100644 supabase/migrations/20261017100000_scope_ingestion_job_claims.sql
diff --git a/src/lib/supabase/database.types.ts b/src/lib/supabase/database.types.ts
index 0f9c5222c2..3bb12f0052 100644
--- a/src/lib/supabase/database.types.ts
+++ b/src/lib/supabase/database.types.ts
@@ -5178,6 +5178,8 @@ export type Database = {
};
claim_ingestion_jobs: {
Args: {
+ p_allowed_document_ids?: string[];
+ p_allowed_file_types?: string[];
p_claim_limit?: number;
p_stale_after_minutes?: number;
p_worker_id: string;
diff --git a/supabase/migrations/20261017100000_scope_ingestion_job_claims.sql b/supabase/migrations/20261017100000_scope_ingestion_job_claims.sql
new file mode 100644
index 0000000000..e272e82f4d
--- /dev/null
+++ b/supabase/migrations/20261017100000_scope_ingestion_job_claims.sql
@@ -0,0 +1,62 @@
+-- Enforce worker document/file scope in the same statement that leases jobs.
+drop function if exists public.claim_ingestion_jobs(text, integer, integer);
+
+create function public.claim_ingestion_jobs(
+ p_worker_id text,
+ p_claim_limit integer default 1,
+ p_stale_after_minutes integer default 45,
+ p_allowed_document_ids uuid[] default '{}'::uuid[],
+ p_allowed_file_types text[] default '{}'::text[]
+)
+returns setof jsonb language plpgsql security definer set search_path = '' as $$
+declare
+ v_candidate record;
+ v_locked jsonb;
+ v_job public.ingestion_jobs%rowtype;
+ v_count integer := 0;
+ v_now timestamptz;
+ v_generation uuid;
+begin
+ if p_worker_id is null or btrim(p_worker_id) = '' or p_claim_limit is null or p_claim_limit not between 1 and 100
+ or p_stale_after_minutes not between 1 and 1440 or p_stale_after_minutes is null then
+ raise exception 'invalid_ingestion_claim_configuration';
+ end if;
+ for v_candidate in
+ select j.id, j.document_id
+ from public.ingestion_jobs j
+ join public.documents d on d.id = j.document_id
+ where j.attempt_count < j.max_attempts
+ and (j.status = 'pending' and coalesce(j.next_run_at, pg_catalog.clock_timestamp()) <= pg_catalog.clock_timestamp()
+ or j.status = 'processing' and j.lease_expires_at <= pg_catalog.clock_timestamp())
+ and (coalesce(cardinality(p_allowed_document_ids), 0) = 0 or j.document_id = any(p_allowed_document_ids))
+ and (coalesce(cardinality(p_allowed_file_types), 0) = 0
+ or lower(split_part(coalesce(d.file_type, ''), ';', 1)) = any(p_allowed_file_types))
+ order by j.created_at, j.id
+ loop
+ exit when v_count >= p_claim_limit;
+ v_locked := public.lock_ingestion_document_authority(v_candidate.document_id, true);
+ if v_locked is null then continue; end if;
+ perform 1 from public.ingestion_jobs where document_id = v_candidate.document_id order by id for update skip locked;
+ perform 1 from public.indexing_v3_agent_jobs where document_id = v_candidate.document_id order by id for update skip locked;
+ v_now := pg_catalog.clock_timestamp();
+ if exists (select 1 from public.ingestion_jobs where document_id = v_candidate.document_id and status = 'processing' and lease_expires_at > v_now)
+ or exists (select 1 from public.indexing_v3_agent_jobs where document_id = v_candidate.document_id and status = 'processing' and lease_expires_at > v_now) then continue; end if;
+ select * into v_job from public.ingestion_jobs where id = v_candidate.id for update skip locked;
+ if not found or v_job.attempt_count >= v_job.max_attempts or not (
+ v_job.status = 'pending' and coalesce(v_job.next_run_at, v_now) <= v_now
+ or v_job.status = 'processing' and v_job.lease_expires_at <= v_now) then continue; end if;
+ v_generation := (v_locked->'document'->>'index_generation_id')::uuid;
+ if v_generation is null and (v_locked->'document'->'metadata' ? 'index_generation_id') then continue; end if;
+ update public.ingestion_jobs set status = 'processing', locked_by = p_worker_id, locked_at = v_now,
+ lease_token = gen_random_uuid(), lease_expected_generation_id = v_generation,
+ lease_write_generation_id = gen_random_uuid(), lease_authority_digest = v_locked->>'authority_digest',
+ lease_expires_at = v_now + make_interval(secs => p_stale_after_minutes * 60),
+ lease_duration_seconds = p_stale_after_minutes * 60, attempt_count = attempt_count + 1, next_run_at = null
+ where id = v_job.id returning * into v_job;
+ return next to_jsonb(v_job) || jsonb_build_object('job_kind','node', 'documents',v_locked->'document');
+ v_count := v_count + 1;
+ end loop;
+end $$;
+
+revoke all on function public.claim_ingestion_jobs(text, integer, integer, uuid[], text[]) from public, anon, authenticated;
+grant execute on function public.claim_ingestion_jobs(text, integer, integer, uuid[], text[]) to service_role;
diff --git a/worker/main.ts b/worker/main.ts
index 1b6fe2ab70..4edaa66e3d 100644
--- a/worker/main.ts
+++ b/worker/main.ts
@@ -402,39 +402,13 @@ export function createWorkerRuntime(options: WorkerRuntimeOptions) {
return [];
}
const scopeFilter = resolveWorkerClaimScopeFilter(process.env);
- if (scopeFilter.active) {
- const { data: candidates, error: candidateError } = await supabase
- .from("ingestion_jobs")
- .select(
- "id,document_id,status,attempt_count,max_attempts,next_run_at,lease_expires_at,created_at,documents(file_type)",
- )
- .in("status", ["pending", "processing"])
- .order("created_at", { ascending: true })
- .order("id", { ascending: true })
- .abortSignal(controlPlaneAbortSignal(stopSignal ?? activeStopSignal));
- if (candidateError) throw supabaseStageError("pre-claim scope check", candidateError);
- const nowMs = Date.now();
- const eligibleRows = (Array.isArray(candidates) ? candidates : []).filter((row) =>
- isClaimableIngestionJobCandidate(row, nowMs),
- );
- if (
- eligibleRows.length === 0 ||
- eligibleRows.some((row) => {
- const doc = Array.isArray(row.documents) ? row.documents[0] : row.documents;
- return !isPreClaimCandidateAllowed(
- { document_id: row.document_id, file_type: (doc as { file_type?: string | null } | null)?.file_type },
- scopeFilter,
- );
- })
- ) {
- return [];
- }
- }
const { data, error } = await supabase
.rpc("claim_ingestion_jobs", {
p_worker_id: workerId,
p_claim_limit: env.WORKER_CONCURRENCY,
p_stale_after_minutes: env.WORKER_STALE_AFTER_MINUTES,
+ p_allowed_document_ids: [...scopeFilter.allowedDocumentIds],
+ p_allowed_file_types: [...scopeFilter.allowedFileTypes],
})
.abortSignal(controlPlaneAbortSignal(stopSignal ?? activeStopSignal));
From fe6daebbc9a9700a79d03ea2c9f25b7ee0dba9d9 Mon Sep 17 00:00:00 2001
From: "qodo-code-review[bot]"
<151058649+qodo-code-review[bot]@users.noreply.github.com>
Date: Sat, 17 Oct 2026 17:53:52 +0000
Subject: [PATCH 04/18] =?UTF-8?q?fix:=202=20findings=20=E2=80=94=20Require?=
=?UTF-8?q?=20complete=20claim=20support=20for=20documents;=20Aggregat?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Require complete claim support for documents
- Aggregate document support across chunks
---
src/lib/rag/rag-extractive-builder.ts | 32 +++++++++++++++------------
1 file changed, 18 insertions(+), 14 deletions(-)
diff --git a/src/lib/rag/rag-extractive-builder.ts b/src/lib/rag/rag-extractive-builder.ts
index cd4996ebaf..4ece2310ad 100644
--- a/src/lib/rag/rag-extractive-builder.ts
+++ b/src/lib/rag/rag-extractive-builder.ts
@@ -1991,21 +1991,25 @@ function buildDocumentSupportListAnswer(args: {
return matchCount > 0 && matchCount < args.results.length;
});
const activeTokens = distinguishingTokens.length > 0 ? distinguishingTokens : qualifierTokens;
- const scored = args.results.map((result) => ({
- result,
- matches: activeTokens.filter((token) => queryTokenMatchesText(token, evidenceByResult.get(result) ?? ""))
- .length,
- }));
- const maxMatches = Math.max(...scored.map((entry) => entry.matches));
- if (maxMatches >= 1) {
- const singularDocumentQuery =
- /\b(?:which|what)\s+(?:document|guideline|policy|protocol|source|procedure|manual)\b/i.test(args.query);
- const filtered = scored
- .filter((entry) => (singularDocumentQuery ? entry.matches === maxMatches : entry.matches > 0))
- .map((entry) => entry.result);
- if (filtered.length > 0 && filtered.length < args.results.length) {
- scopedResults = filtered;
+ // A document can distribute the evidence for a compound claim across chunks.
+ // Aggregate tokens by document before filtering so both the complete claim and
+ // the chunks that support it survive citation construction.
+ const matchesByDocument = new Map>();
+ for (const result of args.results) {
+ const documentId = result.document_id ?? result.id;
+ const matches = matchesByDocument.get(documentId) ?? new Set();
+ for (const token of activeTokens) {
+ if (queryTokenMatchesText(token, evidenceByResult.get(result) ?? "")) matches.add(token);
}
+ matchesByDocument.set(documentId, matches);
+ }
+ const supportingDocumentIds = new Set(
+ [...matchesByDocument].filter(([, matches]) => matches.size === activeTokens.length).map(([id]) => id),
+ );
+ if (supportingDocumentIds.size < matchesByDocument.size) {
+ scopedResults = args.results.filter((result) =>
+ supportingDocumentIds.has(result.document_id ?? result.id),
+ );
}
}
}
From c65c7d93f8aa0cb723de341ecbd385c174600145 Mon Sep 17 00:00:00 2001
From: "qodo-code-review[bot]"
<151058649+qodo-code-review[bot]@users.noreply.github.com>
Date: Sat, 17 Oct 2026 17:53:55 +0000
Subject: [PATCH 05/18] fix: Remove the unused degradation argument
---
src/lib/rag/rag-answer-telemetry-metadata.ts | 5 -----
1 file changed, 5 deletions(-)
diff --git a/src/lib/rag/rag-answer-telemetry-metadata.ts b/src/lib/rag/rag-answer-telemetry-metadata.ts
index 6f9ae9a8fd..30b516fd92 100644
--- a/src/lib/rag/rag-answer-telemetry-metadata.ts
+++ b/src/lib/rag/rag-answer-telemetry-metadata.ts
@@ -3,11 +3,8 @@ import { rankAnswerEvidence } from "@/lib/answer-ranking";
import { buildCrossDocumentFusionBrief, buildCrossDocumentSynthesisPlan } from "@/lib/cross-document-synthesis";
import { buildAnswerScoreExplanations, buildIndexingQuality, collectMemoryCards } from "@/lib/rag/rag-answer-support";
import { memoryCardChunkScore } from "@/lib/rag/rag-candidate-sources";
-import { generationDegradationLogMetadata } from "@/lib/rag/rag-generation-degradation";
import type { RagAnswer, RagQueryClass } from "@/lib/types";
-export { generationDegradationLogMetadata };
-
type AnswerLatencyTimings = NonNullable;
/**
@@ -46,14 +43,12 @@ export function answerLatencyMetadata(
generationLatencyMs: number,
timings: AnswerLatencyTimings | null | undefined,
startedAt: number,
- degradation?: RagAnswer["generationDegradation"] | null,
): Record {
return {
search_latency_ms: searchLatencyMs,
generation_latency_ms: generationLatencyMs,
total_latency_ms: timings?.total_latency_ms ?? Date.now() - startedAt,
...extendedAnswerTelemetryFields(timings),
- ...generationDegradationLogMetadata(degradation),
};
}
From d8976b79a7aa2458ca214f293749fc2a7c486bf1 Mon Sep 17 00:00:00 2001
From: Joshua Simpson
Date: Sat, 17 Oct 2026 17:59:26 +0000
Subject: [PATCH 06/18] refactor(rag): keep document support lookup within
module budget
---
src/lib/rag/rag-document-support-list.ts | 95 ++++++++++++++++++++++++
src/lib/rag/rag-extractive-builder.ts | 89 +---------------------
2 files changed, 96 insertions(+), 88 deletions(-)
create mode 100644 src/lib/rag/rag-document-support-list.ts
diff --git a/src/lib/rag/rag-document-support-list.ts b/src/lib/rag/rag-document-support-list.ts
new file mode 100644
index 0000000000..fbf635545a
--- /dev/null
+++ b/src/lib/rag/rag-document-support-list.ts
@@ -0,0 +1,95 @@
+import { normalizedClinicalSearchTokens } from "@/lib/clinical-search";
+import { buildDocumentBreakdown, extractQuoteCards } from "@/lib/evidence";
+import { evidenceTextForGate } from "@/lib/rag/rag-answer-support";
+import { normalizeSectionText } from "@/lib/rag/rag-answer-text";
+import { finalQualityGapAnswer } from "@/lib/rag/rag-answer-shape-checks";
+import { queryTokenMatchesText } from "@/lib/rag/rag-extractive-signals";
+import type { AnswerSection, SearchResult } from "@/lib/types";
+
+const documentLookupMetaTokenPattern =
+ /^(?:which|what|list|show|name|where|find|provide|support|supports|supporting|sources?|documents?|guidelines?|polic(?:y|ies|ie)|protocols?|procedures?|manuals?|guidance|indexed|reviews?|sets?|states?|says?|describes?|mentions?|covers?|contains?|for|with|from|that|this|the|and)$/i;
+
+/** Build document support list answer. */
+export function buildDocumentSupportListAnswer(args: {
+ query: string;
+ results: SearchResult[];
+ filterToClaimBearingDocuments?: boolean;
+}) {
+ let scopedResults = args.results;
+ if (args.filterToClaimBearingDocuments) {
+ const qualifierTokens = Array.from(
+ new Set(
+ normalizedClinicalSearchTokens(args.query).filter((token) => !documentLookupMetaTokenPattern.test(token)),
+ ),
+ );
+ if (qualifierTokens.length > 0) {
+ const matchesByDocument = new Map>();
+ const matchesByResult = new Map();
+ for (const result of args.results) {
+ const evidence = evidenceTextForGate(result);
+ const matches = qualifierTokens.filter((token) => queryTokenMatchesText(token, evidence));
+ matchesByResult.set(result.id, matches);
+ const documentMatches = matchesByDocument.get(result.document_id) ?? new Set();
+ matches.forEach((token) => documentMatches.add(token));
+ matchesByDocument.set(result.document_id, documentMatches);
+ }
+ // A partial hit does not support a compound query. Accumulate qualifiers across
+ // a document's chunks, then retain the chunks that supply its matching evidence.
+ scopedResults = args.results.filter(
+ (result) =>
+ matchesByDocument.get(result.document_id)?.size === qualifierTokens.length &&
+ (matchesByResult.get(result.id)?.length ?? 0) > 0,
+ );
+ }
+ }
+ const documents = buildDocumentBreakdown(scopedResults, extractQuoteCards(scopedResults, args.query)).slice(0, 5);
+ if (!documents.length) {
+ const gapAnswer = finalQualityGapAnswer(args.query, "document_lookup", "document_lookup");
+ return {
+ answer: gapAnswer,
+ citationChunkIds: [] as string[],
+ supportListCitationResultIds: [] as string[],
+ answerSections: [] as AnswerSection[],
+ };
+ }
+ const names = documents
+ .map((document) =>
+ normalizeSectionText(document.title || document.file_name)
+ .replace(/([a-zA-Z])\(/g, "$1 (")
+ .replace(/\s{2,}/g, " ")
+ .trim(),
+ )
+ .filter(Boolean);
+ const answer =
+ names.length === 1
+ ? `I found one indexed document that supports this query: ${names[0]}.`
+ : `I found ${names.length} indexed documents that support this query: ${names.slice(0, -1).join("; ")}; and ${names.at(-1)}.`;
+ const chunksByDocument = documents.map((document) =>
+ scopedResults.filter((result) => result.document_id === document.document_id).map((result) => result.id),
+ );
+ // Every named document gets an anchor before extra chunks can consume the
+ // five-citation budget. Split-chunk supporting evidence fills remaining slots.
+ const citationChunkIds = Array.from(
+ new Set([
+ ...chunksByDocument.flatMap((ids) => ids.slice(0, 1)),
+ ...chunksByDocument.flatMap((ids) => ids.slice(1)),
+ ]),
+ ).slice(0, 5);
+ return {
+ answer,
+ preformatted: true,
+ citationChunkIds,
+ supportListCitationResultIds: scopedResults
+ .filter((result) => documents.some((document) => document.document_id === result.document_id))
+ .map((result) => result.id),
+ answerSections: [
+ {
+ heading: "Document matches",
+ kind: "documentation",
+ supportLevel: "direct",
+ body: names.join("; "),
+ citation_chunk_ids: citationChunkIds,
+ },
+ ] satisfies AnswerSection[],
+ };
+}
diff --git a/src/lib/rag/rag-extractive-builder.ts b/src/lib/rag/rag-extractive-builder.ts
index 7d3d9ad150..ed7c55c34e 100644
--- a/src/lib/rag/rag-extractive-builder.ts
+++ b/src/lib/rag/rag-extractive-builder.ts
@@ -17,6 +17,7 @@ import {
isAtomicNmhsClozapineRedRangeSource,
buildClozapineBloodActionThresholdAnswer,
} from "@/lib/rag/rag-source-bound-policy-procedures";
+import { buildDocumentSupportListAnswer } from "@/lib/rag/rag-document-support-list";
import { boldHighYieldClinicalText } from "@/lib/answer-ranking";
import {
adjacentLabelledNumericBandConflicts,
@@ -1968,94 +1969,6 @@ function buildTableOrVisualSourceLookupAnswer(args: { query: string; results: Se
};
}
-const documentLookupMetaTokenPattern =
- /^(?:which|what|list|show|name|where|find|provide|support|supports|supporting|sources?|documents?|guidelines?|polic(?:y|ies|ie)|protocols?|procedures?|manuals?|guidance|indexed|reviews?|sets?|states?|says?|describes?|mentions?|covers?|contains?|for|with|from|that|this|the|and)$/i;
-
-/** Build document support list answer. */
-function buildDocumentSupportListAnswer(args: {
- query: string;
- results: SearchResult[];
- filterToClaimBearingDocuments?: boolean;
-}) {
- let scopedResults = args.results;
- if (args.filterToClaimBearingDocuments) {
- const qualifierTokens = Array.from(
- new Set(
- normalizedClinicalSearchTokens(args.query).filter((token) => !documentLookupMetaTokenPattern.test(token)),
- ),
- );
- if (qualifierTokens.length > 0) {
- const matchesByDocument = new Map>();
- const matchesByResult = new Map();
- for (const result of args.results) {
- const evidence = evidenceTextForGate(result);
- const matches = qualifierTokens.filter((token) => queryTokenMatchesText(token, evidence));
- matchesByResult.set(result.id, matches);
- const documentMatches = matchesByDocument.get(result.document_id) ?? new Set();
- matches.forEach((token) => documentMatches.add(token));
- matchesByDocument.set(result.document_id, documentMatches);
- }
- // A partial hit does not support a compound query. Accumulate qualifiers across
- // a document's chunks, then retain the chunks that supply its matching evidence.
- scopedResults = args.results.filter(
- (result) =>
- matchesByDocument.get(result.document_id)?.size === qualifierTokens.length &&
- (matchesByResult.get(result.id)?.length ?? 0) > 0,
- );
- }
- }
- const documents = buildDocumentBreakdown(scopedResults, extractQuoteCards(scopedResults, args.query)).slice(0, 5);
- if (!documents.length) {
- const gapAnswer = finalQualityGapAnswer(args.query, "document_lookup", "document_lookup");
- return {
- answer: gapAnswer,
- citationChunkIds: [] as string[],
- supportListCitationResultIds: [] as string[],
- answerSections: [] as AnswerSection[],
- };
- }
- const names = documents
- .map((document) =>
- normalizeSectionText(document.title || document.file_name)
- .replace(/([a-zA-Z])\(/g, "$1 (")
- .replace(/\s{2,}/g, " ")
- .trim(),
- )
- .filter(Boolean);
- const answer =
- names.length === 1
- ? `I found one indexed document that supports this query: ${names[0]}.`
- : `I found ${names.length} indexed documents that support this query: ${names.slice(0, -1).join("; ")}; and ${names.at(-1)}.`;
- const chunksByDocument = documents.map((document) =>
- scopedResults.filter((result) => result.document_id === document.document_id).map((result) => result.id),
- );
- // Every named document gets an anchor before extra chunks can consume the
- // five-citation budget. Split-chunk supporting evidence fills remaining slots.
- const citationChunkIds = Array.from(
- new Set([
- ...chunksByDocument.flatMap((ids) => ids.slice(0, 1)),
- ...chunksByDocument.flatMap((ids) => ids.slice(1)),
- ]),
- ).slice(0, 5);
- return {
- answer,
- preformatted: true,
- citationChunkIds,
- supportListCitationResultIds: scopedResults
- .filter((result) => documents.some((document) => document.document_id === result.document_id))
- .map((result) => result.id),
- answerSections: [
- {
- heading: "Document matches",
- kind: "documentation",
- supportLevel: "direct",
- body: names.join("; "),
- citation_chunk_ids: citationChunkIds,
- },
- ] satisfies AnswerSection[],
- };
-}
-
/**
* Keep the citations that support delivered extractive answer text ahead of
* already-ranked supporting citations, without admitting unknown sources.
From c5c5b88372dc187613f81165b8924a007e9f88f0 Mon Sep 17 00:00:00 2001
From: Joshua Simpson
Date: Sat, 17 Oct 2026 18:01:40 +0000
Subject: [PATCH 07/18] fix(db): keep one reviewed scoped claim migration
---
...61017100000_scope_ingestion_job_claims.sql | 62 -------------------
1 file changed, 62 deletions(-)
delete mode 100644 supabase/migrations/20261017100000_scope_ingestion_job_claims.sql
diff --git a/supabase/migrations/20261017100000_scope_ingestion_job_claims.sql b/supabase/migrations/20261017100000_scope_ingestion_job_claims.sql
deleted file mode 100644
index e272e82f4d..0000000000
--- a/supabase/migrations/20261017100000_scope_ingestion_job_claims.sql
+++ /dev/null
@@ -1,62 +0,0 @@
--- Enforce worker document/file scope in the same statement that leases jobs.
-drop function if exists public.claim_ingestion_jobs(text, integer, integer);
-
-create function public.claim_ingestion_jobs(
- p_worker_id text,
- p_claim_limit integer default 1,
- p_stale_after_minutes integer default 45,
- p_allowed_document_ids uuid[] default '{}'::uuid[],
- p_allowed_file_types text[] default '{}'::text[]
-)
-returns setof jsonb language plpgsql security definer set search_path = '' as $$
-declare
- v_candidate record;
- v_locked jsonb;
- v_job public.ingestion_jobs%rowtype;
- v_count integer := 0;
- v_now timestamptz;
- v_generation uuid;
-begin
- if p_worker_id is null or btrim(p_worker_id) = '' or p_claim_limit is null or p_claim_limit not between 1 and 100
- or p_stale_after_minutes not between 1 and 1440 or p_stale_after_minutes is null then
- raise exception 'invalid_ingestion_claim_configuration';
- end if;
- for v_candidate in
- select j.id, j.document_id
- from public.ingestion_jobs j
- join public.documents d on d.id = j.document_id
- where j.attempt_count < j.max_attempts
- and (j.status = 'pending' and coalesce(j.next_run_at, pg_catalog.clock_timestamp()) <= pg_catalog.clock_timestamp()
- or j.status = 'processing' and j.lease_expires_at <= pg_catalog.clock_timestamp())
- and (coalesce(cardinality(p_allowed_document_ids), 0) = 0 or j.document_id = any(p_allowed_document_ids))
- and (coalesce(cardinality(p_allowed_file_types), 0) = 0
- or lower(split_part(coalesce(d.file_type, ''), ';', 1)) = any(p_allowed_file_types))
- order by j.created_at, j.id
- loop
- exit when v_count >= p_claim_limit;
- v_locked := public.lock_ingestion_document_authority(v_candidate.document_id, true);
- if v_locked is null then continue; end if;
- perform 1 from public.ingestion_jobs where document_id = v_candidate.document_id order by id for update skip locked;
- perform 1 from public.indexing_v3_agent_jobs where document_id = v_candidate.document_id order by id for update skip locked;
- v_now := pg_catalog.clock_timestamp();
- if exists (select 1 from public.ingestion_jobs where document_id = v_candidate.document_id and status = 'processing' and lease_expires_at > v_now)
- or exists (select 1 from public.indexing_v3_agent_jobs where document_id = v_candidate.document_id and status = 'processing' and lease_expires_at > v_now) then continue; end if;
- select * into v_job from public.ingestion_jobs where id = v_candidate.id for update skip locked;
- if not found or v_job.attempt_count >= v_job.max_attempts or not (
- v_job.status = 'pending' and coalesce(v_job.next_run_at, v_now) <= v_now
- or v_job.status = 'processing' and v_job.lease_expires_at <= v_now) then continue; end if;
- v_generation := (v_locked->'document'->>'index_generation_id')::uuid;
- if v_generation is null and (v_locked->'document'->'metadata' ? 'index_generation_id') then continue; end if;
- update public.ingestion_jobs set status = 'processing', locked_by = p_worker_id, locked_at = v_now,
- lease_token = gen_random_uuid(), lease_expected_generation_id = v_generation,
- lease_write_generation_id = gen_random_uuid(), lease_authority_digest = v_locked->>'authority_digest',
- lease_expires_at = v_now + make_interval(secs => p_stale_after_minutes * 60),
- lease_duration_seconds = p_stale_after_minutes * 60, attempt_count = attempt_count + 1, next_run_at = null
- where id = v_job.id returning * into v_job;
- return next to_jsonb(v_job) || jsonb_build_object('job_kind','node', 'documents',v_locked->'document');
- v_count := v_count + 1;
- end loop;
-end $$;
-
-revoke all on function public.claim_ingestion_jobs(text, integer, integer, uuid[], text[]) from public, anon, authenticated;
-grant execute on function public.claim_ingestion_jobs(text, integer, integer, uuid[], text[]) to service_role;
From faf351d7f195a3c1bc89e1819297899a8c7f2f95 Mon Sep 17 00:00:00 2001
From: Joshua Simpson
Date: Sat, 17 Oct 2026 18:02:40 +0000
Subject: [PATCH 08/18] test(db): seal scoped claim migration and cover mixed
queues
---
supabase/applied-migration-hashes.json | 5 ++-
tests/sql/ingestion-claim-scope.sql | 53 ++++++++++++++++++++++++++
2 files changed, 56 insertions(+), 2 deletions(-)
create mode 100644 tests/sql/ingestion-claim-scope.sql
diff --git a/supabase/applied-migration-hashes.json b/supabase/applied-migration-hashes.json
index 6b0a48b3f9..35e7425b87 100644
--- a/supabase/applied-migration-hashes.json
+++ b/supabase/applied-migration-hashes.json
@@ -1,7 +1,7 @@
{
"note": "sha256 of every migration this repository has shipped, with newlines normalised. An entry whose hash changes means an applied migration was edited, which cannot reach the live database. See scripts/check-migration-immutability.mjs.",
"generator": "scripts/check-migration-immutability.mjs",
- "sealed_at": "2026-10-17T12:37:59.701Z",
+ "sealed_at": "2026-10-17T18:01:56.227Z",
"versions": {
"20260527000000_bulk_ingestion.sql": "813e8e314064a6dcbaaa55e2765a8ce901d5783b304b4a7af7bb7417a07daf7c",
"20260527001000_documents_policy_cleanup.sql": "559c26f3172bf75e545a41adef75538b2bf648ea7d53c8eb3d3ea7c244c65a48",
@@ -320,6 +320,7 @@
"20261015200000_fenced_ingestion_followups.sql": "21bee7afaf86007b24890ff7abd553ea9cea24d9889cfa12589cccf1dc59357a",
"20261016134500_teaching_admin_attendance_export.sql": "a9fc28336706aee579e18ec782213627e26eb6ca1cd60ab46f4625cf5efee94e",
"20261016210000_adversarial_review_rpc_and_ttl_hardening.sql": "8d58d892eec74bc19561065beb01ec46c98c767519295337a61d7b32e0adda41",
- "20261017120000_teaching_display_revoke_null_creator_guard.sql": "3b92e22983206cbaa8950c07a6b3e6910b3701fa9c49a9f9e5504c232b4397e4"
+ "20261017120000_teaching_display_revoke_null_creator_guard.sql": "3b92e22983206cbaa8950c07a6b3e6910b3701fa9c49a9f9e5504c232b4397e4",
+ "20261017175448_scope_ingestion_claim_atomically.sql": "af192755dd24aa8a6e6146013e1b7abd1b2954a53410c301dedfcb7576c88eba"
}
}
diff --git a/tests/sql/ingestion-claim-scope.sql b/tests/sql/ingestion-claim-scope.sql
new file mode 100644
index 0000000000..1b0466be46
--- /dev/null
+++ b/tests/sql/ingestion-claim-scope.sql
@@ -0,0 +1,53 @@
+-- Disposable database only. Exercises the real claim function and authority locks.
+\set ON_ERROR_STOP on
+begin;
+set local statement_timeout = '60s';
+create function pg_temp.assert_true(ok boolean, message text) returns void
+language plpgsql as $$ begin
+ if ok is distinct from true then raise exception '%', message; end if;
+end $$;
+select pg_temp.assert_true(not exists (select 1 from public.documents), 'requires an empty disposable database');
+insert into auth.users(id) values ('11111111-1111-4111-8111-111111111111');
+insert into public.documents(id, owner_id, title, file_name, file_type, storage_path, status, metadata)
+values
+ ('00000000-0000-4000-8000-000000000001', '11111111-1111-4111-8111-111111111111',
+ 'Synthetic PDF', 'fixture.pdf', 'application/pdf', 'synthetic/scope-pdf', 'queued', '{}'),
+ ('00000000-0000-4000-8000-000000000002', '11111111-1111-4111-8111-111111111111',
+ 'Synthetic TXT', 'fixture.txt', 'TEXT/PLAIN; charset=utf-8', 'synthetic/scope-txt', 'queued', '{}');
+
+create function pg_temp.reset_queue() returns void language plpgsql as $$ begin
+ delete from public.ingestion_jobs;
+ insert into public.ingestion_jobs(id, document_id, status, created_at)
+ select id, id, 'pending', now() + (case file_name when 'fixture.pdf' then interval '-1 day' else interval '0' end)
+ from public.documents;
+end $$;
+select pg_temp.reset_queue();
+create temp table claims as
+select job from public.claim_ingestion_jobs('synthetic-worker', 1, 45,
+ array['00000000-0000-4000-8000-000000000002']::uuid[], array['text/plain']) job;
+select pg_temp.assert_true((select count(*) = 1 and bool_and(job->>'document_id' =
+ '00000000-0000-4000-8000-000000000002') from claims), 'mixed queue must claim the allowed TXT job');
+select pg_temp.assert_true((select status = 'pending' and attempt_count = 0 from public.ingestion_jobs
+ where document_id = '00000000-0000-4000-8000-000000000001'), 'excluded PDF must not acquire a lease or consume attempts');
+
+select pg_temp.reset_queue();
+select pg_temp.assert_true(not exists (select 1 from public.claim_ingestion_jobs('synthetic-worker', 1, 45,
+ array['00000000-0000-4000-8000-000000000001']::uuid[], array['text/plain'])),
+ 'both allowlists must match');
+select pg_temp.assert_true(not exists (select 1 from public.claim_ingestion_jobs('synthetic-worker', 1, 45,
+ '{}'::uuid[], null::text[])), 'an explicit empty SQL allowlist must match nothing');
+
+select pg_temp.reset_queue();
+update public.ingestion_jobs set next_run_at = now() + interval '1 day'
+where document_id = '00000000-0000-4000-8000-000000000002';
+select pg_temp.assert_true(not exists (select 1 from public.claim_ingestion_jobs('synthetic-worker', 1, 45,
+ null::uuid[], array['text/plain'])), 'future jobs must remain unclaimed');
+update public.ingestion_jobs set next_run_at = now() - interval '1 second'
+where document_id = '00000000-0000-4000-8000-000000000002';
+select pg_temp.assert_true(exists (select 1 from public.claim_ingestion_jobs('synthetic-worker', 1, 45,
+ null::uuid[], array['text/plain'])), 'newly due scoped jobs must become claimable');
+
+select pg_temp.reset_queue();
+select pg_temp.assert_true(exists (select 1 from public.claim_ingestion_jobs('synthetic-worker', 1, 45)),
+ 'the legacy three-argument claim remains available');
+rollback;
From 8f705e51aea402e64eb07c8660e1f7d2d679a86a Mon Sep 17 00:00:00 2001
From: BigSimmo
Date: Sun, 18 Oct 2026 02:43:41 +0800
Subject: [PATCH 09/18] chore(db): regenerate drift manifest for scoped
claim_ingestion_jobs
Byte-identical to the drift-manifest-39056677285 artifact that
scripts/generate-drift-manifest.ts (npm run drift:manifest) produced in CI
Migration replay on refs/pull/3628/merge (f957dac, same tree as faf351d7f).
Adds the five-argument claim_ingestion_jobs signature; no hand edits.
---
supabase/drift-manifest.json | 14 +++++++++++---
1 file changed, 11 insertions(+), 3 deletions(-)
diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json
index aa478d6ae9..da6a4f1b1f 100644
--- a/supabase/drift-manifest.json
+++ b/supabase/drift-manifest.json
@@ -1,9 +1,9 @@
{
- "generated_at": "2026-10-17T12:36:45.338Z",
+ "generated_at": "2026-10-17T18:37:12.668Z",
"generator": "scripts/generate-drift-manifest.ts",
"postgres_image": "supabase/postgres:17.6.1.127@sha256:be60aee15997daca475b710b734bc6bfe52cd544dcd7e9fd2ff58210b6747d83",
- "schema_sha256": "0f466b3c08396bd4568810c8d493f53ff29cd37b30e3b850f71acd94db9db4e0",
- "replay_seconds": 43,
+ "schema_sha256": "6e5913909f23c2e627413a4fcce0508e2002f129b958eafd27868ed5ab0f8b6a",
+ "replay_seconds": 18,
"snapshot": {
"views": [
{
@@ -18636,6 +18636,14 @@
"def_hash": "0d1f396d2b8eabcfd5902f0c326b6559",
"signature": "public.claim_indexing_v3_agent_jobs(text,integer,integer)"
},
+ {
+ "acl": [
+ "postgres=X/postgres",
+ "service_role=X/postgres"
+ ],
+ "def_hash": "8d0a15ae0b00bcfd8b0d9701287d48d2",
+ "signature": "public.claim_ingestion_jobs(text,integer,integer,uuid[],text[])"
+ },
{
"acl": [
"postgres=X/postgres",
From 8de2157c6663425ab21898186b78716b7d445890 Mon Sep 17 00:00:00 2001
From: BigSimmo
Date: Sun, 18 Oct 2026 02:44:39 +0800
Subject: [PATCH 10/18] fix(worker): a malformed claim allowlist claims
nothing, not every job
resolveWorkerClaimScopeFilter dropped empty entries and marked the scope
inactive, so WORKER_ALLOWED_DOCUMENT_IDS="," (or one malformed dimension)
fell through to the unscoped claim. Blank lists stay unset, matching
.env.example and the runbook; a non-blank list with no valid entries now sets
claimsNothing, rejects every pre-claim candidate, and skips the claim RPC.
---
docs/worker-deploy-runbook.md | 3 ++-
tests/worker-row-contract.test.ts | 29 +++++++++++++++++++++++++++++
worker/behavior.ts | 13 ++++++++++++-
worker/main.ts | 2 ++
4 files changed, 45 insertions(+), 2 deletions(-)
diff --git a/docs/worker-deploy-runbook.md b/docs/worker-deploy-runbook.md
index 30c2033a4a..065ef10069 100644
--- a/docs/worker-deploy-runbook.md
+++ b/docs/worker-deploy-runbook.md
@@ -666,7 +666,8 @@ most one stale window of latency on any in-flight job and zero data loss.
`WORKER_ALLOWED_DOCUMENT_IDS` is a comma-separated UUID allowlist;
`WORKER_ALLOWED_FILE_TYPES` is a comma-separated MIME-type allowlist (for example,
-`text/plain,application/pdf`). Blank lists leave that dimension unrestricted;
+`text/plain,application/pdf`). Blank lists leave that dimension unrestricted; a
+non-blank list with no valid entries (for example `,`) claims no jobs at all;
when both lists are set, a job must satisfy both. MIME types are case-insensitive
and ignore parameters such as `charset`. UUIDs are normalized to lowercase.
diff --git a/tests/worker-row-contract.test.ts b/tests/worker-row-contract.test.ts
index 8ca7d3c11c..c502db1214 100644
--- a/tests/worker-row-contract.test.ts
+++ b/tests/worker-row-contract.test.ts
@@ -353,6 +353,35 @@ describe("worker claim rejection isolation", () => {
}
});
+ it.each([
+ { WORKER_ALLOWED_DOCUMENT_IDS: "," },
+ { WORKER_ALLOWED_DOCUMENT_IDS: " , , " },
+ { WORKER_ALLOWED_FILE_TYPES: ",," },
+ { WORKER_ALLOWED_DOCUMENT_IDS: "doc-1", WORKER_ALLOWED_FILE_TYPES: " , " },
+ ])("claims nothing, rather than every job, for a malformed allowlist: %j", async (scope) => {
+ const { createWorkerRuntime } = await import("../worker/main");
+ const { resolveWorkerClaimScopeFilter, isPreClaimCandidateAllowed } = await import("../worker/behavior");
+ const filter = resolveWorkerClaimScopeFilter(scope);
+ expect(filter).toMatchObject({ active: true, claimsNothing: true });
+ expect(isPreClaimCandidateAllowed({ document_id: "doc-1", file_type: "text/plain" }, filter)).toBe(false);
+ for (const [name, value] of Object.entries(scope)) vi.stubEnv(name, value);
+ const rpc = vi.fn(async () => ({ data: [claimedRow()], error: null }));
+ try {
+ const runtime = createWorkerRuntime({ client: { rpc } as unknown as SupabaseClient });
+ expect(await runtime.claimJobs()).toEqual([]);
+ expect(rpc).not.toHaveBeenCalled();
+ } finally {
+ vi.unstubAllEnvs();
+ }
+ });
+
+ it("treats blank allowlists as unset", async () => {
+ const { resolveWorkerClaimScopeFilter } = await import("../worker/behavior");
+ expect(
+ resolveWorkerClaimScopeFilter({ WORKER_ALLOWED_DOCUMENT_IDS: "", WORKER_ALLOWED_FILE_TYPES: " " }),
+ ).toMatchObject({ active: false, claimsNothing: false });
+ });
+
it.each([
{ data: { ok: false, reason: "lease_lost" }, error: null },
{ data: null, error: { message: "synthetic transport failure" } },
diff --git a/worker/behavior.ts b/worker/behavior.ts
index 3ba7e3c913..a90c6c38f7 100644
--- a/worker/behavior.ts
+++ b/worker/behavior.ts
@@ -689,6 +689,8 @@ export type WorkerClaimScopeFilter = {
allowedDocumentIds: ReadonlySet;
allowedFileTypes: ReadonlySet;
active: boolean;
+ /** A non-blank allowlist with no valid entries (for example ","): claim nothing rather than everything. */
+ claimsNothing: boolean;
};
function normalizeClaimDocumentId(value?: string | null): string {
@@ -711,10 +713,18 @@ export function resolveWorkerClaimScopeFilter(
);
const allowedDocumentIds = parseList(environment.WORKER_ALLOWED_DOCUMENT_IDS, normalizeClaimDocumentId);
const allowedFileTypes = parseList(environment.WORKER_ALLOWED_FILE_TYPES, normalizeClaimMimeType);
+ // Blank means unset (the .env.example default). A configured list that parses to no
+ // entries is malformed; dropping it would silently widen the scope to every job.
+ const malformed = (raw: string | undefined, parsed: ReadonlySet) =>
+ (raw ?? "").trim() !== "" && parsed.size === 0;
+ const claimsNothing =
+ malformed(environment.WORKER_ALLOWED_DOCUMENT_IDS, allowedDocumentIds) ||
+ malformed(environment.WORKER_ALLOWED_FILE_TYPES, allowedFileTypes);
return {
allowedDocumentIds,
allowedFileTypes,
- active: allowedDocumentIds.size > 0 || allowedFileTypes.size > 0,
+ active: claimsNothing || allowedDocumentIds.size > 0 || allowedFileTypes.size > 0,
+ claimsNothing,
};
}
@@ -723,6 +733,7 @@ export function isPreClaimCandidateAllowed(
filter: WorkerClaimScopeFilter,
): boolean {
if (!filter.active) return true;
+ if (filter.claimsNothing) return false;
const docId = normalizeClaimDocumentId(candidate.document_id);
const fileType = normalizeClaimMimeType(candidate.file_type);
if (filter.allowedDocumentIds.size > 0 && (!docId || !filter.allowedDocumentIds.has(docId))) {
diff --git a/worker/main.ts b/worker/main.ts
index 57e9f89447..41d62d353f 100644
--- a/worker/main.ts
+++ b/worker/main.ts
@@ -402,6 +402,8 @@ export function createWorkerRuntime(options: WorkerRuntimeOptions) {
return [];
}
const scopeFilter = resolveWorkerClaimScopeFilter(process.env);
+ // A malformed allowlist (e.g. ",") must never fall through to the unscoped claim.
+ if (scopeFilter.claimsNothing) return [];
const { data, error } = await supabase
.rpc("claim_ingestion_jobs", {
p_worker_id: workerId,
From 93ef40ade88601ee922b650dae5921d2dec93092 Mon Sep 17 00:00:00 2001
From: BigSimmo
Date: Sun, 18 Oct 2026 02:46:46 +0800
Subject: [PATCH 11/18] fix(rag): cite covering chunks in support lists and
ignore lookup verbs
- Lookup wording (discuss, address, outline, recommend, explain, relevant,
any, there, have, our, about) no longer counts as a clinical qualifier, so
'What sources are there for X?' does not fall to the gap answer.
- Each named document is cited by the chunks that together cover the
qualifiers, and a document is named only when that whole set fits the
five-citation budget; leftover budget still goes to extra matching chunks.
---
src/lib/rag/rag-document-support-list.ts | 52 +++++++++++++++++-------
tests/rag-document-support-list.test.ts | 28 +++++++++++++
2 files changed, 65 insertions(+), 15 deletions(-)
diff --git a/src/lib/rag/rag-document-support-list.ts b/src/lib/rag/rag-document-support-list.ts
index fbf635545a..5e77f12067 100644
--- a/src/lib/rag/rag-document-support-list.ts
+++ b/src/lib/rag/rag-document-support-list.ts
@@ -7,7 +7,7 @@ import { queryTokenMatchesText } from "@/lib/rag/rag-extractive-signals";
import type { AnswerSection, SearchResult } from "@/lib/types";
const documentLookupMetaTokenPattern =
- /^(?:which|what|list|show|name|where|find|provide|support|supports|supporting|sources?|documents?|guidelines?|polic(?:y|ies|ie)|protocols?|procedures?|manuals?|guidance|indexed|reviews?|sets?|states?|says?|describes?|mentions?|covers?|contains?|for|with|from|that|this|the|and)$/i;
+ /^(?:which|what|list|show|name|where|find|provide|support|supports|supporting|sources?|documents?|guidelines?|polic(?:y|ies|ie)|protocols?|procedures?|manuals?|guidance|indexed|reviews?|sets?|states?|says?|describes?|mentions?|mentioned|covers?|contains?|discuss(?:e|es|ed|ing)?|address(?:e|es|ed|ing)?|outlines?|outlined|recommends?|recommended|explains?|explained|relevant|any|there|have|has|our|about|for|with|from|that|this|the|and)$/i;
/** Build document support list answer. */
export function buildDocumentSupportListAnswer(args: {
@@ -16,15 +16,16 @@ export function buildDocumentSupportListAnswer(args: {
filterToClaimBearingDocuments?: boolean;
}) {
let scopedResults = args.results;
+ let qualifierTokens: string[] = [];
+ const matchesByResult = new Map();
if (args.filterToClaimBearingDocuments) {
- const qualifierTokens = Array.from(
+ qualifierTokens = Array.from(
new Set(
normalizedClinicalSearchTokens(args.query).filter((token) => !documentLookupMetaTokenPattern.test(token)),
),
);
if (qualifierTokens.length > 0) {
const matchesByDocument = new Map>();
- const matchesByResult = new Map();
for (const result of args.results) {
const evidence = evidenceTextForGate(result);
const matches = qualifierTokens.filter((token) => queryTokenMatchesText(token, evidence));
@@ -42,7 +43,39 @@ export function buildDocumentSupportListAnswer(args: {
);
}
}
- const documents = buildDocumentBreakdown(scopedResults, extractQuoteCards(scopedResults, args.query)).slice(0, 5);
+ // Each named document is cited by the chunks that together cover the qualifiers
+ // (one chunk when no qualifier filter applies). A document is named only when its
+ // whole covering set fits the five-citation budget, never with partial support.
+ const coveringChunkIds = (documentId: string) => {
+ const chunks = scopedResults.filter((result) => result.document_id === documentId);
+ if (qualifierTokens.length === 0 || matchesByResult.size === 0) return chunks.slice(0, 1).map((r) => r.id);
+ const covered = new Set();
+ const ids: string[] = [];
+ for (const chunk of chunks) {
+ const added = (matchesByResult.get(chunk.id) ?? []).filter((token) => !covered.has(token));
+ if (added.length === 0) continue;
+ added.forEach((token) => covered.add(token));
+ ids.push(chunk.id);
+ if (covered.size === qualifierTokens.length) break;
+ }
+ return ids;
+ };
+ const citationBudget = 5;
+ const citationChunkIds: string[] = [];
+ const documents = buildDocumentBreakdown(scopedResults, extractQuoteCards(scopedResults, args.query))
+ .filter((document) => {
+ const ids = coveringChunkIds(document.document_id);
+ if (ids.length === 0 || citationChunkIds.length + ids.length > citationBudget) return false;
+ citationChunkIds.push(...ids);
+ return true;
+ })
+ .slice(0, citationBudget);
+ // Remaining budget goes to the named documents' other matching chunks, as before.
+ for (const result of scopedResults) {
+ if (citationChunkIds.length >= citationBudget) break;
+ if (citationChunkIds.includes(result.id)) continue;
+ if (documents.some((document) => document.document_id === result.document_id)) citationChunkIds.push(result.id);
+ }
if (!documents.length) {
const gapAnswer = finalQualityGapAnswer(args.query, "document_lookup", "document_lookup");
return {
@@ -64,17 +97,6 @@ export function buildDocumentSupportListAnswer(args: {
names.length === 1
? `I found one indexed document that supports this query: ${names[0]}.`
: `I found ${names.length} indexed documents that support this query: ${names.slice(0, -1).join("; ")}; and ${names.at(-1)}.`;
- const chunksByDocument = documents.map((document) =>
- scopedResults.filter((result) => result.document_id === document.document_id).map((result) => result.id),
- );
- // Every named document gets an anchor before extra chunks can consume the
- // five-citation budget. Split-chunk supporting evidence fills remaining slots.
- const citationChunkIds = Array.from(
- new Set([
- ...chunksByDocument.flatMap((ids) => ids.slice(0, 1)),
- ...chunksByDocument.flatMap((ids) => ids.slice(1)),
- ]),
- ).slice(0, 5);
return {
answer,
preformatted: true,
diff --git a/tests/rag-document-support-list.test.ts b/tests/rag-document-support-list.test.ts
index 61a420116a..2f12219dfe 100644
--- a/tests/rag-document-support-list.test.ts
+++ b/tests/rag-document-support-list.test.ts
@@ -85,6 +85,34 @@ describe("claim-bearing document lookup", () => {
new Set(result.documentBreakdown?.map((d) => d.document_id)),
);
});
+ it.each([
+ "What sources are there for anticoagulation in pregnancy?",
+ "Which documents support and discuss anticoagulation in pregnancy?",
+ "Which documents support anticoagulation in pregnancy and explain it?",
+ ])("ignores everyday lookup wording when matching qualifiers: %s", (query) => {
+ const result = answer(query, [partial, full, unrelated]);
+ expect(result.grounded).toBe(true);
+ expect(result.sources.map((r) => r.id)).toEqual([full.id]);
+ });
+ it("cites every covering chunk of a split match even when five documents compete for citations", () => {
+ const whole = Array.from({ length: 4 }, (_, i) => source(`whole-${i}`, `whole-guideline-${i}`, full.content));
+ const split = [
+ source("split-a", "split-guideline", "Anticoagulation requires specialist planning."),
+ source("split-b", "split-guideline", "Pregnancy care is addressed in this guidance."),
+ ];
+ const result = answer("Which documents support anticoagulation in pregnancy?", [...whole, ...split]);
+ const cited = result.citations.map((c) => c.chunk_id);
+ expect(cited.length).toBeLessThanOrEqual(5);
+ const namedSplit = result.answer.includes("Synthetic split-guideline");
+ if (namedSplit) expect(cited).toEqual(expect.arrayContaining(["split-a", "split-b"]));
+ for (const id of new Set(result.citations.map((c) => c.document_id))) {
+ expect(result.answer).toContain(`Synthetic ${id}`);
+ }
+ const splitResult = answer("Which documents support anticoagulation in pregnancy?", [...split, ...whole]);
+ expect(splitResult.answer).toContain("Synthetic split-guideline");
+ expect(splitResult.citations.map((c) => c.chunk_id)).toEqual(expect.arrayContaining(["split-a", "split-b"]));
+ expect(splitResult.citations.length).toBeLessThanOrEqual(5);
+ });
it("returns a gap without attributing support when no document matches", () => {
const result = answer("Which documents support anticoagulation in pregnancy?", [unrelated]);
expect(result.grounded).toBe(false);
From 155460adacc188160eee3b5ac93d5f90e0161a63 Mon Sep 17 00:00:00 2001
From: BigSimmo
Date: Sun, 18 Oct 2026 02:47:28 +0800
Subject: [PATCH 12/18] fix(viewer): label the served file by MIME type before
its filename
DocumentViewer and the overview landing used the filename extension for
every non-text document, so a PDF stored as 'x.docx' was labelled DOCX while
the PDF preview rendered. documentFileKindFromType maps recognised MIME types
first and falls back to the extension only when the type is unknown.
---
src/components/DocumentViewer.tsx | 6 ++----
.../clinical-dashboard/document-ui.tsx | 16 ++++++++++++++++
.../document-overview-landing.tsx | 6 ++----
tests/answer-source-rows.test.ts | 10 +++++++++-
4 files changed, 29 insertions(+), 9 deletions(-)
diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx
index eea7e445b9..9dcdd6b5c9 100644
--- a/src/components/DocumentViewer.tsx
+++ b/src/components/DocumentViewer.tsx
@@ -15,7 +15,7 @@ import {
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { documentDisplayTitle } from "@/components/DocumentOrganizationBadges";
import { ContextualBackLink } from "@/components/contextual-back-link";
-import { documentFileKind } from "@/components/clinical-dashboard/document-ui";
+import { documentFileKindFromType } from "@/components/clinical-dashboard/document-ui";
import { useActiveScrollOwner } from "@/components/clinical-dashboard/use-active-scroll-owner";
import { PhoneHeaderCollapsePortal } from "@/components/clinical-dashboard/phone-header-collapse-portal";
import { useDocumentViewerChromeScroll } from "@/components/clinical-dashboard/use-document-viewer-chrome-scroll";
@@ -1011,9 +1011,7 @@ export function DocumentViewer({
const generatedAnswerIsSummary = summaryQuery === documentSummaryQuestion;
const storedSummaryText = document?.summary?.summary ?? null;
const documentLabels = document?.labels;
- const viewerFileKind = document?.file_type?.trim().toLowerCase().startsWith("text/plain")
- ? "TXT"
- : documentFileKind(document?.file_name, "PDF");
+ const viewerFileKind = documentFileKindFromType(document?.file_type, document?.file_name, "PDF");
const formattedStoredSummary = useMemo(() => formatDocumentSummary(storedSummaryText), [storedSummaryText]);
const summaryBadges = useMemo(
() => buildDocumentSummaryBadges({ labels: documentLabels, summaryText: storedSummaryText }),
diff --git a/src/components/clinical-dashboard/document-ui.tsx b/src/components/clinical-dashboard/document-ui.tsx
index 07bae79ba8..bd451a9f1d 100644
--- a/src/components/clinical-dashboard/document-ui.tsx
+++ b/src/components/clinical-dashboard/document-ui.tsx
@@ -47,6 +47,22 @@ export function documentFileKind(fileName?: string | null, fallback = "PDF") {
return extension || fallback;
}
+const mimeFileKinds: Record = {
+ "application/pdf": "PDF",
+ "text/plain": "TXT",
+ "text/csv": "CSV",
+ "application/msword": "DOC",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "DOCX",
+ "application/vnd.ms-excel": "XLS",
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "XLSX",
+};
+
+/** The served file's kind: a recognised MIME type wins; the filename extension is only a fallback. */
+export function documentFileKindFromType(fileType?: string | null, fileName?: string | null, fallback = "PDF"): string {
+ const mime = fileType?.split(";")[0]?.trim().toLowerCase();
+ return (mime && mimeFileKinds[mime]) || documentFileKind(fileName, fallback);
+}
+
export function documentTileTone(kind: string): DocumentTileTone {
const normalized = kind.toUpperCase();
return normalized === "DOC" || normalized === "DOCX" ? "info" : "teal";
diff --git a/src/components/document-viewer/document-overview-landing.tsx b/src/components/document-viewer/document-overview-landing.tsx
index 004f35e4a7..9eddbb0316 100644
--- a/src/components/document-viewer/document-overview-landing.tsx
+++ b/src/components/document-viewer/document-overview-landing.tsx
@@ -9,7 +9,7 @@ import {
DocumentActionButton,
DocumentFileTile,
DocumentMetaRow,
- documentFileKind,
+ documentFileKindFromType,
documentTileTone,
} from "@/components/clinical-dashboard/document-ui";
import { cn, panel, floatingControl, ignoreUnavailableActivation, primaryControl } from "@/components/ui-primitives";
@@ -20,9 +20,7 @@ const primaryButton = primaryControl;
const secondaryButton = floatingControl;
function compactDocumentType(document: ClinicalDocument) {
- return document.file_type?.trim().toLowerCase().startsWith("text/plain")
- ? "TXT"
- : documentFileKind(document.file_name, "PDF");
+ return documentFileKindFromType(document.file_type, document.file_name, "PDF");
}
// Derive the header eyebrow from the document's real type instead of asserting
diff --git a/tests/answer-source-rows.test.ts b/tests/answer-source-rows.test.ts
index d3ee680149..68b53044ab 100644
--- a/tests/answer-source-rows.test.ts
+++ b/tests/answer-source-rows.test.ts
@@ -9,7 +9,11 @@ import {
sourceSupportSentence,
type AnswerSourceRow,
} from "@/components/clinical-dashboard/answer-source-rows";
-import { documentFileKind, documentSupportBadgeProps } from "@/components/clinical-dashboard/document-ui";
+import {
+ documentFileKind,
+ documentFileKindFromType,
+ documentSupportBadgeProps,
+} from "@/components/clinical-dashboard/document-ui";
import { normalizeSourceMetadata } from "@/lib/source-metadata";
import type { SearchResult } from "@/lib/types";
@@ -126,5 +130,9 @@ describe("answer-source-rows uncited / em-dash presentation labels (R17)", () =>
expect(rows[0]?.snippet).toBe("Monitor CIWA-Ar every 1 to 4 hours during acute alcohol withdrawal.");
expect(documentFileKind(rows[0]?.fileName)).toBe("TXT");
expect(documentFileKind("guideline-without-extension")).toBe("PDF");
+ expect(documentFileKindFromType("application/pdf", "guideline.docx")).toBe("PDF");
+ expect(documentFileKindFromType("Text/Plain; charset=utf-8", "notes.pdf")).toBe("TXT");
+ expect(documentFileKindFromType("application/octet-stream", "notes.docx")).toBe("DOCX");
+ expect(documentFileKindFromType(null, "no-extension")).toBe("PDF");
});
});
From 91f74569f895e95edb5a7ea76444ad9dc817c55c Mon Sep 17 00:00:00 2001
From: BigSimmo
Date: Sun, 18 Oct 2026 07:50:44 +0800
Subject: [PATCH 13/18] fix(claude-hooks): don't report EPIPE from a completed
SessionStart dependent
claude-session-start.mjs feeds the hook payload to each dependent through
spawnSync input. A dependent that exits 0 without reading stdin can finish
before the write, leaving EPIPE in result.error, which was reported as
'SessionStart organisation did not complete' on loaded CI runners
(Unit coverage partition 2). Only a non-zero status or a non-EPIPE error
now counts as failure. A >64 KiB payload makes the race deterministic in
the new regression test.
---
scripts/claude-session-start.mjs | 5 ++++-
tests/claude-cloud-lifecycle.test.ts | 11 ++++++++++-
2 files changed, 14 insertions(+), 2 deletions(-)
diff --git a/scripts/claude-session-start.mjs b/scripts/claude-session-start.mjs
index 971c954729..70da79791b 100644
--- a/scripts/claude-session-start.mjs
+++ b/scripts/claude-session-start.mjs
@@ -55,7 +55,10 @@ for (const [label, command, args, timeout] of steps) {
const result = spawnSync(command, args, { cwd: repoRoot, input: payload, encoding: "utf8", timeout });
collect(result.stdout || "", label === "cloud setup");
if (result.stderr) process.stderr.write(result.stderr);
- if (result.status !== 0 || result.error)
+ // A dependent that exits 0 without reading stdin races the payload write and leaves
+ // EPIPE in result.error; it still completed, so only other errors count as failure.
+ const failed = result.status !== 0 || (result.error && result.error.code !== "EPIPE");
+ if (failed)
contexts.push(`SessionStart ${label} did not complete; check its diagnostics before trusting related checks.`);
}
if (contexts.length)
diff --git a/tests/claude-cloud-lifecycle.test.ts b/tests/claude-cloud-lifecycle.test.ts
index e94a77503c..72189a698d 100644
--- a/tests/claude-cloud-lifecycle.test.ts
+++ b/tests/claude-cloud-lifecycle.test.ts
@@ -456,6 +456,7 @@ describe.skipIf(process.platform === "win32")("sequential Claude SessionStart",
pending = false,
remote = true,
runnerFailure: "none" | "process" | "unavailable" = "none",
+ payload = '{"hook_event_name":"SessionStart"}',
) {
const source = sandbox();
const home = sandbox();
@@ -496,7 +497,7 @@ describe.skipIf(process.platform === "win32")("sequential Claude SessionStart",
}
const result = spawnSync("bash", [join(source, "scripts", "claude-session-start.sh")], {
encoding: "utf8",
- input: '{"hook_event_name":"SessionStart"}',
+ input: payload,
env: {
...process.env,
HOME: home,
@@ -529,6 +530,14 @@ describe.skipIf(process.platform === "win32")("sequential Claude SessionStart",
const context = JSON.parse(result.stdout).hookSpecificOutput.additionalContext;
expect(context).toBe("setup\n\nfreshness\n\nissues\n\npolicy\n\norganisation");
});
+ it("treats a dependent that exits 0 without reading the payload (EPIPE) as complete", () => {
+ // Larger than a pipe buffer, so writing it to dependents that never read stdin always hits EPIPE.
+ const payload = JSON.stringify({ hook_event_name: "SessionStart", padding: "x".repeat(256 * 1024) });
+ const { result } = fixture("none", false, true, "none", payload);
+ expect(result.status, result.stderr).toBe(0);
+ const context = JSON.parse(result.stdout).hookSpecificOutput.additionalContext;
+ expect(context).toBe("setup\n\nfreshness\n\nissues\n\npolicy\n\norganisation");
+ });
it.each(["exit", "command"] as const)(
"keeps bootstrap %s failure nonfatal without suppressing errexit",
(failure) => {
From 15c9b40ef8f3d2280f41c4938381c2ceed1d3431 Mon Sep 17 00:00:00 2001
From: BigSimmo
Date: Sun, 18 Oct 2026 07:52:07 +0800
Subject: [PATCH 14/18] fix(rag): pick the smallest covering chunks and drop
more lookup wording
- Covering chunks are chosen by greedy set cover (most new qualifiers first),
so a document's single complete chunk is cited instead of earlier partial
chunks that could exhaust the five-citation budget and drop a supported
document from the answer.
- give/gives, recommendation(s) and on no longer count as qualifiers, so
'Which guidelines support recommendations on X?' matches X's documents.
---
src/lib/rag/rag-document-support-list.ts | 23 +++++++++++++++--------
tests/rag-document-support-list.test.ts | 21 +++++++++++++++++++++
2 files changed, 36 insertions(+), 8 deletions(-)
diff --git a/src/lib/rag/rag-document-support-list.ts b/src/lib/rag/rag-document-support-list.ts
index 5e77f12067..c0f5ef46ee 100644
--- a/src/lib/rag/rag-document-support-list.ts
+++ b/src/lib/rag/rag-document-support-list.ts
@@ -7,7 +7,7 @@ import { queryTokenMatchesText } from "@/lib/rag/rag-extractive-signals";
import type { AnswerSection, SearchResult } from "@/lib/types";
const documentLookupMetaTokenPattern =
- /^(?:which|what|list|show|name|where|find|provide|support|supports|supporting|sources?|documents?|guidelines?|polic(?:y|ies|ie)|protocols?|procedures?|manuals?|guidance|indexed|reviews?|sets?|states?|says?|describes?|mentions?|mentioned|covers?|contains?|discuss(?:e|es|ed|ing)?|address(?:e|es|ed|ing)?|outlines?|outlined|recommends?|recommended|explains?|explained|relevant|any|there|have|has|our|about|for|with|from|that|this|the|and)$/i;
+ /^(?:which|what|list|show|name|where|find|provide|give|gives|support|supports|supporting|sources?|documents?|guidelines?|polic(?:y|ies|ie)|protocols?|procedures?|manuals?|guidance|indexed|reviews?|sets?|states?|says?|describes?|mentions?|mentioned|covers?|contains?|discuss(?:e|es|ed|ing)?|address(?:e|es|ed|ing)?|outlines?|outlined|recommendations?|recommends?|recommended|explains?|explained|relevant|any|there|have|has|our|about|on|for|with|from|that|this|the|and)$/i;
/** Build document support list answer. */
export function buildDocumentSupportListAnswer(args: {
@@ -49,16 +49,23 @@ export function buildDocumentSupportListAnswer(args: {
const coveringChunkIds = (documentId: string) => {
const chunks = scopedResults.filter((result) => result.document_id === documentId);
if (qualifierTokens.length === 0 || matchesByResult.size === 0) return chunks.slice(0, 1).map((r) => r.id);
+ // Greedy set cover: take the chunk adding the most uncovered qualifiers (earliest on
+ // ties), so one chunk carrying every qualifier beats several earlier partial chunks.
const covered = new Set();
const ids: string[] = [];
- for (const chunk of chunks) {
- const added = (matchesByResult.get(chunk.id) ?? []).filter((token) => !covered.has(token));
- if (added.length === 0) continue;
- added.forEach((token) => covered.add(token));
- ids.push(chunk.id);
- if (covered.size === qualifierTokens.length) break;
+ while (covered.size < qualifierTokens.length) {
+ let best: { id: string; added: string[] } | null = null;
+ for (const chunk of chunks) {
+ if (ids.includes(chunk.id)) continue;
+ const added = (matchesByResult.get(chunk.id) ?? []).filter((token) => !covered.has(token));
+ if (added.length > (best?.added.length ?? 0)) best = { id: chunk.id, added };
+ }
+ if (!best) break;
+ best.added.forEach((token) => covered.add(token));
+ ids.push(best.id);
}
- return ids;
+ // Keep the citation order aligned with the source order.
+ return chunks.filter((chunk) => ids.includes(chunk.id)).map((chunk) => chunk.id);
};
const citationBudget = 5;
const citationChunkIds: string[] = [];
diff --git a/tests/rag-document-support-list.test.ts b/tests/rag-document-support-list.test.ts
index 2f12219dfe..85ccbf317e 100644
--- a/tests/rag-document-support-list.test.ts
+++ b/tests/rag-document-support-list.test.ts
@@ -113,6 +113,27 @@ describe("claim-bearing document lookup", () => {
expect(splitResult.citations.map((c) => c.chunk_id)).toEqual(expect.arrayContaining(["split-a", "split-b"]));
expect(splitResult.citations.length).toBeLessThanOrEqual(5);
});
+ it.each([
+ "Which guidelines support recommendations on anticoagulation in pregnancy?",
+ "Which documents support and give recommendations on anticoagulation in pregnancy?",
+ ])("treats 'give recommendations on' as lookup wording: %s", (query) => {
+ const result = answer(query, [partial, full, unrelated]);
+ expect(result.grounded).toBe(true);
+ expect(result.sources.map((r) => r.id)).toEqual([full.id]);
+ });
+ it("covers a document with its single complete chunk rather than earlier partial chunks", () => {
+ const lateFull = [
+ source("late-a", "late-full", "Anticoagulation requires specialist planning."),
+ source("late-p", "late-full", "Pregnancy care is addressed in this guidance."),
+ source("late-full-chunk", "late-full", full.content),
+ ];
+ const whole = Array.from({ length: 4 }, (_, i) => source(`whole-${i}`, `whole-guideline-${i}`, full.content));
+ const result = answer("Which documents support anticoagulation in pregnancy?", [...lateFull, ...whole]);
+ const citedDocuments = new Set(result.citations.map((c) => c.document_id));
+ expect(citedDocuments).toEqual(new Set(["late-full", ...whole.map((r) => r.document_id)]));
+ expect(result.citations.map((c) => c.chunk_id)).toContain("late-full-chunk");
+ expect(result.citations.length).toBeLessThanOrEqual(5);
+ });
it("returns a gap without attributing support when no document matches", () => {
const result = answer("Which documents support anticoagulation in pregnancy?", [unrelated]);
expect(result.grounded).toBe(false);
From 03ccc170c9643bc2fd8ebce62609e647239f04a4 Mon Sep 17 00:00:00 2001
From: BigSimmo
Date: Sun, 18 Oct 2026 07:52:52 +0800
Subject: [PATCH 15/18] fix(worker): validate allowlisted document IDs before
the scoped claim RPC
claim_ingestion_jobs takes uuid[], so a value such as 'doc-1' in
WORKER_ALLOWED_DOCUMENT_IDS made every claim request fail and the worker
poll in an error loop. Any non-UUID entry now rejects the document scope
(claimsNothing), the claim RPC is skipped, and the configuration error is
logged once instead of on every poll.
---
docs/worker-deploy-runbook.md | 3 ++-
tests/rag-generation-degradation.test.ts | 14 ++++++++---
tests/worker-row-contract.test.ts | 31 ++++++++++++++++++++++++
worker/behavior.ts | 22 ++++++++++++++---
worker/main.ts | 12 +++++++--
5 files changed, 72 insertions(+), 10 deletions(-)
diff --git a/docs/worker-deploy-runbook.md b/docs/worker-deploy-runbook.md
index 065ef10069..a107d9a68a 100644
--- a/docs/worker-deploy-runbook.md
+++ b/docs/worker-deploy-runbook.md
@@ -667,7 +667,8 @@ most one stale window of latency on any in-flight job and zero data loss.
`WORKER_ALLOWED_DOCUMENT_IDS` is a comma-separated UUID allowlist;
`WORKER_ALLOWED_FILE_TYPES` is a comma-separated MIME-type allowlist (for example,
`text/plain,application/pdf`). Blank lists leave that dimension unrestricted; a
-non-blank list with no valid entries (for example `,`) claims no jobs at all;
+non-blank list with no valid entries (for example `,`), or any document ID that is not
+a UUID, claims no jobs at all and logs the configuration error once;
when both lists are set, a job must satisfy both. MIME types are case-insensitive
and ignore parameters such as `charset`. UUIDs are normalized to lowercase.
diff --git a/tests/rag-generation-degradation.test.ts b/tests/rag-generation-degradation.test.ts
index 4ad7beccfc..76a865e31a 100644
--- a/tests/rag-generation-degradation.test.ts
+++ b/tests/rag-generation-degradation.test.ts
@@ -502,14 +502,22 @@ describe("generationDegradationLogMetadata (#231) and worker pre-claim scope fil
expect(isPreClaimCandidateAllowed({ document_id: "doc-1", file_type: "application/pdf" }, inactive)).toBe(true);
const active = resolveWorkerClaimScopeFilter({
- WORKER_ALLOWED_DOCUMENT_IDS: "DOC-TXT-1, doc-txt-2",
+ WORKER_ALLOWED_DOCUMENT_IDS: "7D0C4A52-1B9E-4F6A-9C2D-3E4F5A6B7C81, 7d0c4a52-1b9e-4f6a-9c2d-3e4f5a6b7c82",
WORKER_ALLOWED_FILE_TYPES: "TEXT/PLAIN; charset=utf-8",
});
expect(active.active).toBe(true);
expect(
- isPreClaimCandidateAllowed({ document_id: "doc-txt-1", file_type: "text/plain; charset=utf-8" }, active),
+ isPreClaimCandidateAllowed(
+ { document_id: "7d0c4a52-1b9e-4f6a-9c2d-3e4f5a6b7c81", file_type: "text/plain; charset=utf-8" },
+ active,
+ ),
).toBe(true);
- expect(isPreClaimCandidateAllowed({ document_id: "doc-txt-1", file_type: "application/pdf" }, active)).toBe(false);
+ expect(
+ isPreClaimCandidateAllowed(
+ { document_id: "7d0c4a52-1b9e-4f6a-9c2d-3e4f5a6b7c81", file_type: "application/pdf" },
+ active,
+ ),
+ ).toBe(false);
expect(isPreClaimCandidateAllowed({ document_id: "doc-other", file_type: "text/plain" }, active)).toBe(false);
const nowMs = Date.parse("2026-10-17T12:00:00.000Z");
diff --git a/tests/worker-row-contract.test.ts b/tests/worker-row-contract.test.ts
index c502db1214..d0e1e43098 100644
--- a/tests/worker-row-contract.test.ts
+++ b/tests/worker-row-contract.test.ts
@@ -375,6 +375,37 @@ describe("worker claim rejection isolation", () => {
}
});
+ it("rejects a non-UUID document ID before the uuid[] claim RPC and reports it once", async () => {
+ const { createWorkerRuntime } = await import("../worker/main");
+ const { resolveWorkerClaimScopeFilter } = await import("../worker/behavior");
+ const valid = claimedRow().document_id;
+ for (const value of ["not-a-uuid", `${valid}, doc-1`]) {
+ expect(resolveWorkerClaimScopeFilter({ WORKER_ALLOWED_DOCUMENT_IDS: value })).toMatchObject({
+ active: true,
+ claimsNothing: true,
+ configurationError: expect.stringContaining("non-UUID"),
+ });
+ }
+ expect(resolveWorkerClaimScopeFilter({ WORKER_ALLOWED_DOCUMENT_IDS: valid.toUpperCase() })).toMatchObject({
+ claimsNothing: false,
+ configurationError: null,
+ });
+ vi.stubEnv("WORKER_ALLOWED_DOCUMENT_IDS", `${valid}, doc-1`);
+ const rpc = vi.fn(async () => ({ data: [claimedRow()], error: null }));
+ const report = vi.spyOn(console, "error").mockImplementation(() => {});
+ try {
+ const runtime = createWorkerRuntime({ client: { rpc } as unknown as SupabaseClient });
+ expect(await runtime.claimJobs()).toEqual([]);
+ expect(await runtime.claimJobs()).toEqual([]);
+ expect(rpc).not.toHaveBeenCalled();
+ expect(report).toHaveBeenCalledTimes(1);
+ expect(String(report.mock.calls[0]?.[0])).toContain("doc-1");
+ } finally {
+ report.mockRestore();
+ vi.unstubAllEnvs();
+ }
+ });
+
it("treats blank allowlists as unset", async () => {
const { resolveWorkerClaimScopeFilter } = await import("../worker/behavior");
expect(
diff --git a/worker/behavior.ts b/worker/behavior.ts
index a90c6c38f7..6fb37f8a06 100644
--- a/worker/behavior.ts
+++ b/worker/behavior.ts
@@ -689,10 +689,14 @@ export type WorkerClaimScopeFilter = {
allowedDocumentIds: ReadonlySet;
allowedFileTypes: ReadonlySet;
active: boolean;
- /** A non-blank allowlist with no valid entries (for example ","): claim nothing rather than everything. */
+ /** A malformed allowlist (no valid entries, or a non-UUID document ID): claim nothing rather than everything. */
claimsNothing: boolean;
+ /** Why the configured scope was rejected, for one operator-facing warning. */
+ configurationError: string | null;
};
+const claimDocumentIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
+
function normalizeClaimDocumentId(value?: string | null): string {
return (value ?? "").trim().toLowerCase();
}
@@ -717,14 +721,24 @@ export function resolveWorkerClaimScopeFilter(
// entries is malformed; dropping it would silently widen the scope to every job.
const malformed = (raw: string | undefined, parsed: ReadonlySet) =>
(raw ?? "").trim() !== "" && parsed.size === 0;
- const claimsNothing =
- malformed(environment.WORKER_ALLOWED_DOCUMENT_IDS, allowedDocumentIds) ||
- malformed(environment.WORKER_ALLOWED_FILE_TYPES, allowedFileTypes);
+ // claim_ingestion_jobs takes uuid[]; one bad ID would fail every claim request, so
+ // reject the whole document scope instead of sending it or silently dropping the entry.
+ const invalidDocumentIds = [...allowedDocumentIds].filter((id) => !claimDocumentIdPattern.test(id));
+ const configurationError =
+ invalidDocumentIds.length > 0
+ ? `WORKER_ALLOWED_DOCUMENT_IDS contains non-UUID value(s): ${invalidDocumentIds.slice(0, 3).join(", ")}`
+ : malformed(environment.WORKER_ALLOWED_DOCUMENT_IDS, allowedDocumentIds)
+ ? "WORKER_ALLOWED_DOCUMENT_IDS is set but lists no document IDs"
+ : malformed(environment.WORKER_ALLOWED_FILE_TYPES, allowedFileTypes)
+ ? "WORKER_ALLOWED_FILE_TYPES is set but lists no MIME types"
+ : null;
+ const claimsNothing = configurationError !== null;
return {
allowedDocumentIds,
allowedFileTypes,
active: claimsNothing || allowedDocumentIds.size > 0 || allowedFileTypes.size > 0,
claimsNothing,
+ configurationError,
};
}
diff --git a/worker/main.ts b/worker/main.ts
index 41d62d353f..0ed79af99c 100644
--- a/worker/main.ts
+++ b/worker/main.ts
@@ -397,13 +397,21 @@ export function createWorkerRuntime(options: WorkerRuntimeOptions) {
// The declared JobRow[] return is the compile-time proof that the claim contract's
// inferred row type stays assignable to JobRow — weakening the schema fails here.
+ let reportedClaimScopeError: string | null = null;
async function claimJobs(stopSignal?: AbortSignal): Promise {
if (isWorkerClaimLoopPaused(process.env)) {
return [];
}
const scopeFilter = resolveWorkerClaimScopeFilter(process.env);
- // A malformed allowlist (e.g. ",") must never fall through to the unscoped claim.
- if (scopeFilter.claimsNothing) return [];
+ // A malformed allowlist (e.g. "," or a non-UUID ID) must never fall through to the
+ // unscoped claim, nor reach the uuid[] RPC parameter and fail every poll.
+ if (scopeFilter.claimsNothing) {
+ if (scopeFilter.configurationError !== reportedClaimScopeError) {
+ reportedClaimScopeError = scopeFilter.configurationError;
+ console.error(`Worker claim scope rejected; claiming no jobs: ${scopeFilter.configurationError}`);
+ }
+ return [];
+ }
const { data, error } = await supabase
.rpc("claim_ingestion_jobs", {
p_worker_id: workerId,
From 4ecf4389f06731f8870f9debc70298edda730dc3 Mon Sep 17 00:00:00 2001
From: BigSimmo
Date: Sun, 18 Oct 2026 07:53:12 +0800
Subject: [PATCH 16/18] fix(viewer): label image MIME types as images, never
PDF
DocumentViewer renders image/* as an image, but documentFileKindFromType
had no image mappings, so an extensionless image/png was labelled PDF.
Common image types map to PNG/JPG/GIF/WEBP/SVG and any other image/*
falls back to the extension or IMAGE.
---
src/components/clinical-dashboard/document-ui.tsx | 10 +++++++++-
tests/answer-source-rows.test.ts | 2 ++
2 files changed, 11 insertions(+), 1 deletion(-)
diff --git a/src/components/clinical-dashboard/document-ui.tsx b/src/components/clinical-dashboard/document-ui.tsx
index bd451a9f1d..37a421c7d6 100644
--- a/src/components/clinical-dashboard/document-ui.tsx
+++ b/src/components/clinical-dashboard/document-ui.tsx
@@ -55,12 +55,20 @@ const mimeFileKinds: Record = {
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "DOCX",
"application/vnd.ms-excel": "XLS",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "XLSX",
+ "image/png": "PNG",
+ "image/jpeg": "JPG",
+ "image/gif": "GIF",
+ "image/webp": "WEBP",
+ "image/svg+xml": "SVG",
};
/** The served file's kind: a recognised MIME type wins; the filename extension is only a fallback. */
export function documentFileKindFromType(fileType?: string | null, fileName?: string | null, fallback = "PDF"): string {
const mime = fileType?.split(";")[0]?.trim().toLowerCase();
- return (mime && mimeFileKinds[mime]) || documentFileKind(fileName, fallback);
+ if (mime && mimeFileKinds[mime]) return mimeFileKinds[mime];
+ // The viewer renders any image/* as an image, so never fall back to "PDF" for one.
+ if (mime?.startsWith("image/")) return documentFileKind(fileName, "IMAGE");
+ return documentFileKind(fileName, fallback);
}
export function documentTileTone(kind: string): DocumentTileTone {
diff --git a/tests/answer-source-rows.test.ts b/tests/answer-source-rows.test.ts
index 68b53044ab..8ce1f5fff1 100644
--- a/tests/answer-source-rows.test.ts
+++ b/tests/answer-source-rows.test.ts
@@ -134,5 +134,7 @@ describe("answer-source-rows uncited / em-dash presentation labels (R17)", () =>
expect(documentFileKindFromType("Text/Plain; charset=utf-8", "notes.pdf")).toBe("TXT");
expect(documentFileKindFromType("application/octet-stream", "notes.docx")).toBe("DOCX");
expect(documentFileKindFromType(null, "no-extension")).toBe("PDF");
+ expect(documentFileKindFromType("image/png", "scan")).toBe("PNG");
+ expect(documentFileKindFromType("image/tiff", "scan")).toBe("IMAGE");
});
});
From 84fec64da1d7cca2b620af4aa95e77b3d3213500 Mon Sep 17 00:00:00 2001
From: BigSimmo
Date: Sun, 18 Oct 2026 12:50:37 +0800
Subject: [PATCH 17/18] chore(governance): 45-day engineering drift exception
for tests/answer-verification.test.ts (owner ruling 2026-10-18)
---
docs/clinical-hazard-controls.json | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/docs/clinical-hazard-controls.json b/docs/clinical-hazard-controls.json
index 7d566a85ba..2f03865882 100644
--- a/docs/clinical-hazard-controls.json
+++ b/docs/clinical-hazard-controls.json
@@ -198,6 +198,13 @@
"recordedOn": "2026-10-14",
"expiresOn": "2026-10-21"
},
+ {
+ "path": "tests/answer-verification.test.ts",
+ "reason": "PR #3628 adds regression tests for the 'no more than' comparator/unit parsing in src/lib/answer-verification.ts (itself under the existing PR #3546 drift exception). Engineering drift exception per owner ruling 2026-10-18: the owner will review and reseal this test file before this exception expires. Not a clinical review, sign-off or reseal.",
+ "recordedBy": "Engineering, Grok Bot sweep session for PR #3628, per owner ruling 2026-10-18. Engineering drift record only; not clinical-owner approval or release signoff.",
+ "recordedOn": "2026-10-18",
+ "expiresOn": "2026-12-02"
+ },
{
"path": "src/lib/clinical-safety.ts",
"reason": "Drift predates the blocking gate. Safety-behaviour changes since the 2026-08-23 review (e.g. 41b5a025a escalate/monitoring matching, 64db243a9 stop-instruction labelling, 1aa717e88 no all-clear over a failed medication check) need clinical re-review, then a re-seal.",
From ba5f467c677ca1b7568fc7a0ba77cde5d8009d1d Mon Sep 17 00:00:00 2001
From: BigSimmo
Date: Sun, 18 Oct 2026 13:13:32 +0800
Subject: [PATCH 18/18] fix(rag): cite the smallest covering chunk set within
the citation budget
---
src/lib/rag/rag-document-support-list.ts | 37 ++++++++++++++----------
tests/rag-document-support-list.test.ts | 19 ++++++++++++
2 files changed, 41 insertions(+), 15 deletions(-)
diff --git a/src/lib/rag/rag-document-support-list.ts b/src/lib/rag/rag-document-support-list.ts
index c0f5ef46ee..96eb8b4421 100644
--- a/src/lib/rag/rag-document-support-list.ts
+++ b/src/lib/rag/rag-document-support-list.ts
@@ -46,28 +46,35 @@ export function buildDocumentSupportListAnswer(args: {
// Each named document is cited by the chunks that together cover the qualifiers
// (one chunk when no qualifier filter applies). A document is named only when its
// whole covering set fits the five-citation budget, never with partial support.
+ const citationBudget = 5;
const coveringChunkIds = (documentId: string) => {
const chunks = scopedResults.filter((result) => result.document_id === documentId);
if (qualifierTokens.length === 0 || matchesByResult.size === 0) return chunks.slice(0, 1).map((r) => r.id);
- // Greedy set cover: take the chunk adding the most uncovered qualifiers (earliest on
- // ties), so one chunk carrying every qualifier beats several earlier partial chunks.
- const covered = new Set();
- const ids: string[] = [];
- while (covered.size < qualifierTokens.length) {
- let best: { id: string; added: string[] } | null = null;
- for (const chunk of chunks) {
- if (ids.includes(chunk.id)) continue;
- const added = (matchesByResult.get(chunk.id) ?? []).filter((token) => !covered.has(token));
- if (added.length > (best?.added.length ?? 0)) best = { id: chunk.id, added };
- }
- if (!best) break;
- best.added.forEach((token) => covered.add(token));
- ids.push(best.id);
+ // Smallest covering set within the citation budget (earliest chunks on ties), so a
+ // document never spends citations another named document needs. Chunks whose matches
+ // are a subset of an earlier chunk's add nothing, which keeps the search tiny.
+ const candidates: { id: string; matches: string[] }[] = [];
+ for (const chunk of chunks) {
+ const matches = matchesByResult.get(chunk.id) ?? [];
+ if (matches.length === 0) continue;
+ if (candidates.some((c) => matches.every((token) => c.matches.includes(token)))) continue;
+ candidates.push({ id: chunk.id, matches });
}
+ let ids: string[] = [];
+ const search = (from: number, picked: number[], size: number): boolean => {
+ if (picked.length === size) {
+ const covered = new Set(picked.flatMap((i) => candidates[i].matches));
+ if (covered.size < qualifierTokens.length) return false;
+ ids = picked.map((i) => candidates[i].id);
+ return true;
+ }
+ for (let i = from; i < candidates.length; i += 1) if (search(i + 1, [...picked, i], size)) return true;
+ return false;
+ };
+ for (let size = 1; size <= Math.min(citationBudget, candidates.length); size += 1) if (search(0, [], size)) break;
// Keep the citation order aligned with the source order.
return chunks.filter((chunk) => ids.includes(chunk.id)).map((chunk) => chunk.id);
};
- const citationBudget = 5;
const citationChunkIds: string[] = [];
const documents = buildDocumentBreakdown(scopedResults, extractQuoteCards(scopedResults, args.query))
.filter((document) => {
diff --git a/tests/rag-document-support-list.test.ts b/tests/rag-document-support-list.test.ts
index 85ccbf317e..278699b670 100644
--- a/tests/rag-document-support-list.test.ts
+++ b/tests/rag-document-support-list.test.ts
@@ -134,6 +134,25 @@ describe("claim-bearing document lookup", () => {
expect(result.citations.map((c) => c.chunk_id)).toContain("late-full-chunk");
expect(result.citations.length).toBeLessThanOrEqual(5);
});
+ it("cites the smallest covering chunk set so a second split document still fits five citations", () => {
+ const query = "Which documents support anticoagulation, pregnancy, postpartum, epilepsy, warfarin and lithium?";
+ const greedyTrap = [
+ source("trap-abcd", "trap", "Anticoagulation, pregnancy, postpartum and epilepsy care."),
+ source("trap-abe", "trap", "Anticoagulation in pregnancy with warfarin."),
+ source("trap-cdf", "trap", "Postpartum epilepsy care with lithium."),
+ ];
+ const other = [
+ source("other-1", "other", "Anticoagulation and pregnancy."),
+ source("other-2", "other", "Postpartum and epilepsy."),
+ source("other-3", "other", "Warfarin and lithium."),
+ ];
+ const result = answer(query, [...greedyTrap, ...other]);
+ expect(result.answer).toContain("Synthetic trap");
+ expect(result.answer).toContain("Synthetic other");
+ const cited = result.citations.map((c) => c.chunk_id);
+ expect(cited).toEqual(expect.arrayContaining(["trap-abe", "trap-cdf", "other-1", "other-2", "other-3"]));
+ expect(cited).toHaveLength(5);
+ });
it("returns a gap without attributing support when no document matches", () => {
const result = answer("Which documents support anticoagulation in pregnancy?", [unrelated]);
expect(result.grounded).toBe(false);