Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
387a403
Implement #100 Phase 0 contract proof and flag-gated Phase 1 evidence…
claude Aug 13, 2026
31c984a
Record #1909 handoff; update #100 and #310 ledger rows
claude Aug 13, 2026
8f837e4
Merge branch 'main' into claude/rag-incremental-delivery-lpw15e
BigSimmo Aug 13, 2026
be4f0ff
fix: reconcile incremental evidence previews
BigSimmo Aug 13, 2026
bd246b9
fix: derive incremental previews from governed payload
BigSimmo Aug 13, 2026
0f9c618
fix(ci): format incremental delivery coverage
BigSimmo Aug 13, 2026
03e8425
Merge branch 'main' into claude/rag-incremental-delivery-lpw15e
BigSimmo Aug 13, 2026
95647e8
Merge branch 'main' into claude/rag-incremental-delivery-lpw15e
BigSimmo Aug 13, 2026
130d39a
fix: emit verified previews before answer generation
BigSimmo Aug 13, 2026
3e8ff16
Merge branch 'main' into claude/rag-incremental-delivery-lpw15e
BigSimmo Aug 13, 2026
e308d44
test(answer): retain real search scope schema in stream preview mock
BigSimmo Aug 13, 2026
83efeae
Merge branch 'main' into claude/rag-incremental-delivery-lpw15e
BigSimmo Aug 13, 2026
d68d1f9
Merge branch 'main' into claude/rag-incremental-delivery-lpw15e
BigSimmo Aug 13, 2026
d661f35
Merge branch 'main' into claude/rag-incremental-delivery-lpw15e
BigSimmo Aug 13, 2026
44e0b54
fix: strictly validate verified stream units
BigSimmo Aug 13, 2026
8c979bc
test: cover strict verified-unit boundary validation
BigSimmo Aug 13, 2026
4498226
test: assert the ranking frame carries an evidence preview
BigSimmo Aug 13, 2026
b1f63b4
chore: keep example environment keys sorted
BigSimmo Aug 13, 2026
92d68d4
Merge main into claude/rag-incremental-delivery-lpw15e
BigSimmo Aug 13, 2026
c361fc7
refactor: extract preview final-path reconciliation
BigSimmo Aug 13, 2026
da73dc1
refactor: keep preview logic outside the RAG monolith
BigSimmo Aug 13, 2026
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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,10 @@ SENTRY_ENVIRONMENT=production
# Optional JSON override for app-layer ranking weights (see src/lib/ranking-config.ts).
# Omit for current defaults. Example (enable diversity demotion + linear freshness):
# RAG_RANKING_CONFIG={"documentDiversityPenalty":0.03,"freshness":{"mode":"linear"}}
# #100 Phase 1: emit the governed retrieval-complete evidence preview as a verified unit
# on the answer stream. Server emission flag; client rendering ships separately. Keep false
# until the offline contract proof has landed and enablement is deliberately staged.
RAG_INCREMENTAL_EVIDENCE_PREVIEW=false
# Ambiguity-only structured semantic reranking. Keep false until the retrieval canary is approved.
RAG_SEMANTIC_RERANK_ENABLED=false
# Append OR-relaxed recall behind weak-but-nonzero strict text matches (P8b extension).
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
| 2026-08-13 | claude/rag-incremental-delivery-lpw15e | 387a403a4bf802208420f6847771ada24e1e3eb1 | #100 Phase 0 contract proof + flag-gated Phase 1 evidence preview (stream contract, answer-preview, rag.ts emission) | PR #1909 opened; flag default off, no retrieval/generation behaviour change | verify:pr-local failed:(none); new contract tests 13/13; production-readiness expected demo-mode gap only |
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"version": 1,
"id": "75df9b82-7ecc-4aa2-bdac-653da976fe53",
"createdOn": "2026-08-13",
"action": "update",
"payload": {
"id": "#100",
"detail": "UPDATE 2026-08-13 (PR #1909): Phase 0 offline contract proof and flag-gated Phase 1 server emission implemented (RAG_INCREMENTAL_EVIDENCE_PREVIEW, default false). Remaining: client parsing/rendering phase behind its own flag + verify:ui, then the design's provider-backed acceptance gates before production enablement; Phase 2 stays provider-gated. **Design complete; runtime work remains provider-gated.** [`verified-answer-incremental-delivery-design.md`](verified-answer-incremental-delivery-design.md) records the clinical-governance decision and staged contract: keep the `progress`/`final`/`error` allowlist; disclose bounded, owner-scoped evidence only after the canonical danger-level source-governance refusal permits it, then emit complete answer sections only after each reuses the full production verification boundary; reconcile every preview byte-for-byte with the authoritative `final`; discard all previews on error/cancel/retry; deploy behind separate parse/emission/render flags. Phase 0 contract proof and Phase 1 evidence preview can be developed offline, but visible rollout still needs clinical/browser proof. Phase 2 changes generation architecture and requires explicit approval for answer-quality evals plus a baseline/post live canary pair. **Naive token streaming remains REFUTED:** never re-land `token`, `revising`, provisional prose, or a weaker stream-only verifier. Cross-references #021."
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"version": 1,
"id": "e1506952-64c6-472f-9da4-812f8d69b483",
"createdOn": "2026-08-13",
"action": "update",
"payload": {
"id": "#310",
"detail": "UPDATE 2026-08-13: PR #1851 head now carries the 1-edit cap itself (typoDistanceLimit >=5 -> 1); the owed regression test (fluoxetine!=duloxetine, prednisone!=prednisolone with both records present, setraline recovery preserved) was pushed to that branch as d2d3256, 17/17 passing. Row closeable when #1851 merges. MEASURED 2026-08-12 by running the matcher itself, not by reading it. PR #1851 adds Damerau-Levenshtein typo recovery to `src/lib/catalog-search.ts` (`fuzzySearchTokenCount`, `boundedTypoDistance`, `typoDistanceLimit`) and folds it into the score. The tier `term.length >= 8 -> 2 edits` is the problem: Damerau counts an adjacent transposition as ONE edit, so `fluoxetine` -> `duloxetine` is distance 2 (substitute f->d, transpose lu->ul) and both are 10 characters. Confirmed hits against the PR's own algorithm: **fluoxetine -> duloxetine** (SSRI vs SNRI, different drugs), **prednisone -> prednisolone** (different drugs). Intended cases also confirmed working: sertraline -> setraline, olanzapine -> olanzepine. The existing guards DO hold — SSRI/SNRI, ADHD/ODD, citalopram/escitalopram, clozapine/clonazepam and quetiapine/olanzapine all correctly return no match. ONE MITIGATION, stated so this is not over-read: terms under 5 characters are excluded entirely. The fuzzy trigger is evaluated independently for each candidate record, so the hazard persists when both the exact drug and a two-edit near-match are present: the exact record receives a literal score while the wrong drug can independently receive a fuzzy score and appear as an additional result. Blast radius is wide because `catalog-search.ts` feeds ELEVEN modules — medications.ts (prescribing), dsm.ts, differentials.ts, differential-stream.ts, universal-search.ts, specifiers-search-index.ts, tools-catalog.ts, form-ranker.ts, service-ranker.ts. TESTED FIX: capping the >=8 tier at 1 edit removes both cross-drug hits and preserves every legitimate typo recovery in the sample — a one-line change to `typoDistanceLimit`. Next: if PR #1851 is still open, raise this on it; if it merged, apply the cap directly and add a test over real catalogue drug names with both the exact and near-match records present, asserting the wrong drug is excluded while the exact drug remains. Stop: do not remove fuzzy search outright — the typo recovery is genuinely useful and the guards are otherwise well judged. Note `classifyPullRequestFiles` returns clinicalRisk:true for this path (governance preflight fires) but ragRanking:false, which is correct — this is catalogue ranking, not the pgvector retrieval path."
}
}
7 changes: 6 additions & 1 deletion src/app/api/answer/stream/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,10 @@ function streamAnswer(
async start(controller) {
const streamStartedAt = Date.now();
let completionSent = false;
// Verified units are append-only within one SSE response. Keep this state in
// the stream, never globally or across attempts, so duplicate/out-of-order
// progress callbacks fail closed at the public boundary.
let lastVerifiedUnitSequence: number | null = null;
const send = <Name extends AnswerStreamEventName>(event: Name, data: AnswerStreamEventMap[Name]) => {
try {
controller.enqueue(encoder.encode(encodeSse(event, data)));
Expand All @@ -177,10 +181,11 @@ function streamAnswer(
}
};
const sendProgress = (event: unknown) => {
const publicEvent = toPublicAnswerProgressEvent(event);
const publicEvent = toPublicAnswerProgressEvent(event, lastVerifiedUnitSequence);
if (!publicEvent || (publicEvent.stage === "complete" && completionSent)) return;
if (publicEvent.stage === "complete") completionSent = true;
send("progress", publicEvent);
if (publicEvent.verifiedUnit) lastVerifiedUnitSequence = publicEvent.verifiedUnit.sequence;
};
const sendComplete = () => {
sendProgress({ stage: "complete", elapsedMs: Date.now() - streamStartedAt });
Expand Down
4 changes: 3 additions & 1 deletion src/lib/answer-client-payload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ const sourceFieldPolicy = {
images: "server",
} as const satisfies Record<keyof SearchResult, "client" | "server">;

function trimSourceForClient(source: SearchResult): SearchResult {
/** Exported for the verified evidence preview (#100), which must cross the route
* boundary through the exact same trim as the final payload — never a copy of it. */
export function trimSourceForClient(source: SearchResult): SearchResult {
const trimmed = Object.fromEntries(
(Object.keys(sourceFieldPolicy) as Array<keyof SearchResult>)
.filter((key) => sourceFieldPolicy[key] === "client" && key in source)
Expand Down
66 changes: 66 additions & 0 deletions src/lib/answer-preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// #100 Phase 1 — verified evidence preview (server-only).
// Builds the single retrieval-complete verified unit defined in
// docs/verified-answer-incremental-delivery-design.md. The unit must reuse the
// production gates, never approximate them: the same danger-level
// source-governance decision that governs the final response, and the exact
// route-boundary source trim the final payload goes through. If the gates
// cannot permit disclosure the preview is simply absent — there is no weaker
// "stream-safe" variant.

import { trimSourceForClient } from "@/lib/answer-client-payload";
import { env } from "@/lib/env";
import { hasDangerSourceGovernanceWarning, sourceGovernanceWarnings } from "@/lib/source-governance";
import type { VerifiedEvidencePreviewUnit, VerifiedUnit } from "@/lib/answer-stream-contract";
import type { EvidenceRelevance, SearchResult } from "@/lib/types";

export type { VerifiedUnit };

const evidencePreviewMaxSources = 12;

/**
* Build the retrieval-complete evidence preview, or null when nothing may be disclosed.
*
* Deliberately stricter than the final response's refusal: the final gate only refuses
* grounded, supported answers over danger-level sources, because unsupported/evidence-gap
* responses withhold content anyway. At preview time the answer's support level is not yet
* known, so ANY danger-level governance warning suppresses the preview — a preview must
* never disclose source content the final governed response might withhold.
*/
export function buildEvidencePreviewUnit(args: {
results: SearchResult[];
/** The full source set the final answer may expose; used to fail closed early. */
governanceResults?: SearchResult[];
relevance?: EvidenceRelevance | null;
}): VerifiedEvidencePreviewUnit | null {
if (!args.results.length) return null;
const warnings = sourceGovernanceWarnings({
results: args.governanceResults ?? args.results,
relevance: args.relevance ?? null,
});
if (hasDangerSourceGovernanceWarning(warnings)) return null;
const selected = args.results.slice(0, evidencePreviewMaxSources);
return {
schemaVersion: 1,
kind: "evidence_preview",
sequence: 0,
sources: selected.map(trimSourceForClient),
selectedContextCount: args.results.length,
};
}

/** Keep the ranking event small and keep final-path reconciliation out of the RAG monolith. */
export function buildEvidencePreviewProgress(args: {
normalResults: SearchResult[];
fallbackResults: SearchResult[];
governanceResults: SearchResult[];
relevance?: EvidenceRelevance | null;
}): { verifiedUnit?: VerifiedEvidencePreviewUnit } {
if (!env.RAG_INCREMENTAL_EVIDENCE_PREVIEW) return {};
const fallbackIds = new Set(args.fallbackResults.map((result) => result.id));
const verifiedUnit = buildEvidencePreviewUnit({
results: args.normalResults.filter((result) => fallbackIds.has(result.id)),
governanceResults: args.governanceResults,
relevance: args.relevance,
});
return verifiedUnit ? { verifiedUnit } : {};
}
17 changes: 16 additions & 1 deletion src/lib/answer-progress-public.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { isDeliverableVerifiedUnit, type VerifiedUnit } from "@/lib/answer-stream-contract";

export type PublicAnswerProgressStage =
| "scoping"
| "retrieving"
Expand All @@ -18,14 +20,20 @@ export type PublicAnswerProgressEvent = {
australianSourceCount?: number;
waSourceCount?: number;
elapsedMs?: number;
/** #100: optional verified unit (already governed + client-trimmed server-side).
* Old clients ignore it; it only crosses the boundary when it validates. */
verifiedUnit?: VerifiedUnit;
};

function safeProgressNumber(value: unknown) {
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : undefined;
}

/** Convert internal RAG progress into the minimal, stable DTO allowed at the browser boundary. */
export function toPublicAnswerProgressEvent(event: unknown): PublicAnswerProgressEvent | null {
export function toPublicAnswerProgressEvent(
event: unknown,
lastVerifiedUnitSequence: number | null = null,
): PublicAnswerProgressEvent | null {
if (!event || typeof event !== "object") return null;
const value = event as Record<string, unknown>;
const resultCount = safeProgressNumber(value.resultCount);
Expand Down Expand Up @@ -86,9 +94,16 @@ export function toPublicAnswerProgressEvent(event: unknown): PublicAnswerProgres
return null;
}

// The verified unit crosses the boundary only when it passes the stream contract's
// structural validation; a malformed or oversized unit is dropped, never repaired.
const verifiedUnit = isDeliverableVerifiedUnit(value.verifiedUnit, lastVerifiedUnitSequence)
? value.verifiedUnit
: undefined;

return {
stage,
message,
...(verifiedUnit === undefined ? {} : { verifiedUnit }),
...(resultCount === undefined ? {} : { resultCount }),
...(selectedContextCount === undefined ? {} : { selectedContextCount }),
...(australianSourceCount === undefined ? {} : { australianSourceCount }),
Expand Down
Loading
Loading