Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 31 additions & 16 deletions src/lib/answer-verification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,22 +498,37 @@ export function applyNumericVerification(answer: RagAnswer, verificationSources?
const sources = verificationSources ?? answer.sources ?? [];
const unverified = new Set<string>();

// B4: the model is instructed to put dose details in structured
// answerSections (kind medication_dose), so a top-level-only scan never sees
// section-body doses. Verify the top-level answer AND every section body.
// Each section is scoped to its own citation_chunk_ids when present, so a
// dose is only credited against the chunks that section actually cites;
// sections with no citations fall back to the answer-level citations.
const answerVerification = verifyAnswerNumbers(answer.answer, answer.citations, sources);
for (const token of answerVerification.unverifiedTokens) unverified.add(token);

for (const section of answer.answerSections ?? []) {
const sectionCitations =
section.citation_chunk_ids.length > 0
? section.citation_chunk_ids.map((chunk_id) => ({ chunk_id }))
: answer.citations;
const sectionVerification = verifyAnswerNumbers(section.body, sectionCitations, sources);
for (const token of sectionVerification.unverifiedTokens) unverified.add(token);
const claimScopedValues = (answer.supportedClaims ?? []).filter(
(claim) => extractClinicalValueAtoms(claim.text).length > 0,
);
if (claimScopedValues.length > 0) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Verify raw sections even when claim-scoped values exist

When any supported claim contains a clinical value, this branch skips the previous raw answer/section scan and only verifies values that made it into supportedClaims. claimInputs derives those claims via splitClaims, which drops short snippets under eight characters, so a model section like Maximum dose: 500 mg (body 500 mg) can bypass numeric faithfulness whenever another longer supported numeric claim is present; before this change every section body was scanned directly. Please keep the claim-scoped check but also scan answer/section text for clinical value atoms that were not covered by a claim.

Useful? React with 👍 / 👎.

// Claim assessment has already checked entity, polarity, clinical dimension,
// and exact value co-location. Verify each value against the union of chunks
// that directly support its claim. Checking unrelated top-level citations
// either rejects valid multi-source answers or permits cross-claim support.
for (const claim of claimScopedValues) {
const verification = verifyAnswerNumbers(
claim.text,
claim.supportingChunkIds.map((chunk_id) => ({ chunk_id })),
sources,
);
for (const token of verification.unverifiedTokens) unverified.add(token);
}
} else {
// B4: before claim provenance is available, verify top-level prose and every
// section body against their explicit citation scopes. This preserves the
// fail-closed parse/fallback paths as well as direct callers of this helper.
const answerVerification = verifyAnswerNumbers(answer.answer, answer.citations, sources);
for (const token of answerVerification.unverifiedTokens) unverified.add(token);

for (const section of answer.answerSections ?? []) {
const sectionCitations =
section.citation_chunk_ids.length > 0
? section.citation_chunk_ids.map((chunk_id) => ({ chunk_id }))
: answer.citations;
const sectionVerification = verifyAnswerNumbers(section.body, sectionCitations, sources);
for (const token of sectionVerification.unverifiedTokens) unverified.add(token);
}
}

if (unverified.size === 0) return answer;
Expand Down
45 changes: 22 additions & 23 deletions src/lib/rag-extractive-answer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,12 +288,16 @@ export function classifyAnswerIntent(query: string, queryClass: RagQueryClass):
if (hasResultActionSignal) return "red_result_action";
if (/\b(?:dose|dosing|dosage|max(?:imum)?|mg|mcg|renal|eGFR|creatinine)\b/i.test(query)) return "dose";
if (/\b(?:pathway|refer|referral|criteria|ect|electroconvulsive)\b/.test(normalized)) return "pathway_referral";
// Retrieval classification and answer intent are different concerns. A
// document_lookup route can still ask for the document's clinical content
// (for example, "What should a safety plan include?"). Treat it as a source
// lookup only when the wording explicitly asks to find/open/select a source;
// otherwise the extractive path must select responsive clinical facts rather
// than reference-list lines that merely mention a guideline or procedure.
if (
queryClass === "document_lookup" ||
/\b(?:find|show|open|which)\b.*\b(?:document|guideline|procedure|policy|protocol|form|source|file)\b/.test(
normalized,
) ||
/\b(?:documentation|forms?|documents?|sources?|guidelines?|procedure|policy|protocol)\b/.test(normalized)
)
Comment thread
BigSimmo marked this conversation as resolved.
) {
return "document_lookup";
}
Expand Down Expand Up @@ -343,7 +347,7 @@ function answerIntentEvidencePattern(intent: AnswerIntent) {
case "document_lookup":
return /\b(?:document|guideline|procedure|policy|protocol|form|source|file|support|supports|covers|contains)\b/i;
default:
return /\b(?:assess|arrange|check|continue|review|treat|manage|monitor|refer|dose|risk|therapy|diagnos\w*)\b/i;
return /\b(?:assess|arrange|check|collaborat\w*|complete|conduct|continue|develop|diagnos\w*|document|dose|ensure|identify|include|incorporate|involve|link|manage|monitor|provide|record|refer|revise|review\w*|risk|share|therapy|treat|update)\b/i;
}
}

Expand Down Expand Up @@ -736,7 +740,7 @@ function factSupportsAnswerIntent(
if (/^what\s+is\b/i.test(query)) {
return /\b(?:is|are|means|defined|characteri[sz]ed|involves|refers\s+to)\b/i.test(text);
}
return /\b(?:assess|arrange|check|continue|review|treat|manage|monitor|refer|dose|risk|therapy|diagnos\w*)\b/i.test(
return /\b(?:assess|arrange|check|collaborat\w*|complete|conduct|continue|develop|diagnos\w*|document|dose|ensure|identify|include|incorporate|involve|link|manage|monitor|provide|record|refer|revise|review\w*|risk|share|therapy|treat|update)\b/i.test(
text,
);
}
Expand Down Expand Up @@ -964,7 +968,10 @@ function sectionForFactKind(kind: ExtractedClinicalFactKind): Pick<AnswerSection
case "caveat":
return { heading: "Caveat", kind: "source_gap" };
default:
return { heading: "Bottom line", kind: "bottom_line" };
// "Bottom line" is intentionally rejected by the generated-answer
// template detector. Use the neutral display heading while preserving
// the semantic kind so deterministic facts do not fail their own gate.
return { heading: "Key point", kind: "bottom_line" };
}
}

Expand Down Expand Up @@ -1790,26 +1797,21 @@ export function finalizeRagAnswerQuality(
queryClass: RagQueryClass,
verificationSources?: SearchResult[],
): RagAnswer {
const qualityChecked = finalizeRagAnswerQualityCore(answer, query, queryClass);
return applyProviderLabels(
assessAndEnforceClaimSupport(finalizeRagAnswerQualityCore(answer, query, queryClass, verificationSources)),
applyNumericVerification(assessAndEnforceClaimSupport(qualityChecked), verificationSources),
);
}

/**
* Finalizes an answer by applying quality gates, sanitizing content, and verifying numeric claims.
* Finalizes answer prose by applying textual quality gates and sanitizing content.
*
* @param answer - The answer to validate and finalize
* @param query - The user query used to assess relevance and highlight clinical terms
* @param queryClass - The classification of the user query
* @param verificationSources - Optional sources used to verify numeric claims
* @returns The finalized RAG answer with validated content, sections, and confidence metadata
*/
function finalizeRagAnswerQualityCore(
answer: RagAnswer,
query: string,
queryClass: RagQueryClass,
verificationSources?: SearchResult[],
): RagAnswer {
function finalizeRagAnswerQualityCore(answer: RagAnswer, query: string, queryClass: RagQueryClass): RagAnswer {
// Deterministic, template-built answers (document-support lists, table/visual source
// references) are well-formed by construction and carry no free-text clinical claims.
// The clinical-prose sanitizer/quality gate below is designed for model prose and would
Expand Down Expand Up @@ -1886,12 +1888,9 @@ function finalizeRagAnswerQualityCore(
})
.filter((section): section is Exclude<typeof section, null> => Boolean(section));

return applyNumericVerification(
{
...answer,
answer: boldHighYieldClinicalText(cleanedAnswer, query),
answerSections,
},
verificationSources,
);
return {
...answer,
answer: boldHighYieldClinicalText(cleanedAnswer, query),
answerSections,
};
}
76 changes: 75 additions & 1 deletion src/lib/rag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -904,6 +904,61 @@ function applyConfidenceGate(
};
}

/**
* Allow a score-blocked routine document-content query to use the deterministic
* answer only when that answer independently passes the final safety gates.
*
* This is deliberately narrower than the normal extractive router: it cannot
* recover medication, threshold, comparison, broad-summary, complex, or weakly
* related queries. The retrieval diagnostic remains blocked so the UI still
* presents the recovered answer with low-trust guidance.
*/
function hasValidatedRoutineExtractiveRecovery(args: {
query: string;
queryClass: RagQueryClass;
results: SearchResult[];
route: { mode: "unsupported" | "extractive" | "fast" | "strong"; reason: string };
sourceBacked: boolean;
}) {
if (
args.queryClass !== "document_lookup" ||
args.route.mode !== "fast" ||
args.route.reason !== "strong_routine_retrieval" ||
!args.sourceBacked
) {
return false;
}

const candidate = finalizeRagAnswerQuality(
buildExtractiveAnswer({
query: args.query,
queryClass: args.queryClass,
results: args.results,
quoteCards: [],
documentBreakdown: [],
evidenceSummary: undefined,
sourceCoverage: undefined,
conflictsOrGaps: [],
visualEvidence: [],
bestSource: null,
smartPanel: undefined,
relatedDocuments: [],
routeReason: `${args.route.reason}; validated_routine_extractive_recovery`,
timings: undefined,
}),
args.query,
args.queryClass,
);

return (
candidate.grounded &&
candidate.confidence !== "unsupported" &&
candidate.citations.length > 0 &&
candidate.responseMode !== "evidence_gap" &&
!/final_quality_gate:/.test(candidate.routingReason ?? "")
);
}

/** Clamp confidence. */
function clampConfidence(
proposed: RagAnswer["confidence"] | undefined,
Expand Down Expand Up @@ -4164,7 +4219,26 @@ async function answerQuestionWithScopeUncoalesced(
results: answerInputResults,
answerMode: routeFromRouting.mode,
});
const gatedRoute = applyConfidenceGate(routeFromRouting, queryClass, initialRetrievalDiagnostics);
const validatedRoutineExtractiveRecovery =
initialRetrievalDiagnostics.gateStatus === "blocked" &&
hasValidatedRoutineExtractiveRecovery({
query: args.query,
queryClass,
results: answerInputResults,
route: routeFromRouting,
sourceBacked: relevance.isSourceBacked,
});
const routeBeforeConfidenceGate = validatedRoutineExtractiveRecovery
? {
...routeFromRouting,
mode: "extractive" as const,
model: null,
reason: `${routeFromRouting.reason}; validated_routine_extractive_recovery`,
}
: routeFromRouting;
const gatedRoute = validatedRoutineExtractiveRecovery
? { route: routeBeforeConfidenceGate }
: applyConfidenceGate(routeBeforeConfidenceGate, queryClass, initialRetrievalDiagnostics);
Comment thread
BigSimmo marked this conversation as resolved.
// In source-only mode (offline, or auto with no usable key) we never call the model. Route to
// the deterministic extractive path when evidence is usable, but preserve the confidence gate's
// "unsupported" decision so weak evidence still fails closed to a source-gap answer rather than
Expand Down
Loading