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
7 changes: 7 additions & 0 deletions docs/process-hardening.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,10 @@ All approved render-surface modules are extracted. `ClinicalDashboard.tsx` went
- **Why (measured):** PR #118 caught a main-side change (uncapped candidate score + blanket source-governance metadata weighting in `retrieval-selection.ts`) that regressed the golden set 23/23 → 16/23 (doc-recall@5 1.0 → 0.76) on the partially-enriched corpus. `verify:cheap` was green throughout — only the golden retrieval eval surfaced it. Unit tests do not exercise live ranking, so they cannot substitute.
- **Standing constraint (do not relearn):** source-governance metadata (`document_status`/`clinical_validation_status`/`extraction_quality`) must NOT weight retrieval **selection ordering**, and candidate relevance scores must stay clamped. Live scores saturate at 1.0 and the corpus is only partially enriched (unenriched → unknown/unverified), so metadata weighting buries correct documents. Governance belongs in ranking penalties and the answer/source-governance layer. See [[no-governance-weighting-in-retrieval-selection]] and `docs/rag-hybrid-findings-and-todo.md` (RC8).
- **Answer-generation changes** (synthesis prompt, post-processing) additionally run `eval:rag --limit 15` + `eval:quality --rag-only` (grounded-supported must not drop; citation-failure 0). A new opt-in `npm run eval:answer-quality` reports a structural per-intent **targeting** metric (informational) for measuring how precisely answers hit the asked question.

## Audit P2/P3 follow-up (2026-07-06)

- **P0 carry-over (PR #278 / `cursor/audit-p2-p3-hardening-b54f`):** RAG cache owner/indexing-version guards, synopsis parity in ranking/detectors, DELETE TOCTOU re-check, worker cache invalidation on job completion, and `RAG_QUERY_HASH_SECRET` required in production-like readiness checks.
- **P2 M13:** `20260702000000_commit_generation_preserve_legacy_artifacts.sql` must be applied to live Supabase before reindex commits can safely purge legacy NULL-generation rows. After apply, run `npm run check:m13-migration`, `npm run reindex:health`, and `npm run check:indexing`. `search_schema_health()` now reports `commit_document_index_generation.preserve_legacy_artifacts_migration` when the live function body is stale.
- **P2 upload hardening:** `/api/upload` consumes the `document_upload` rate-limit bucket (12/min owner, 3/min anonymous).
- **P3 dispositioned (no code change):** L9 searchable-only `image_count` (documented in `worker/main.ts`); L11 triple `readFile` peak-memory trade-off (documented at the ingestion site); L18 duplicate `audit_logs` policy in an already-applied migration (do not edit applied migrations — consolidate only if migrations are ever squashed); L19 CSP `script-src 'unsafe-inline'` deferred (no active XSS sink today; nonce migration needs dedicated UI verification).
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"backfill:text-normalization": "tsx scripts/backfill-text-normalization.ts",
"check:supabase-project": "tsx scripts/check-supabase-project.ts",
"check:indexing": "tsx scripts/check-indexing.ts",
"check:m13-migration": "tsx scripts/check-m13-migration.ts",
"check:type-scale": "node scripts/check-type-scale.mjs",
"recover:ingestion": "tsx scripts/recover-ingestion-queue.ts",
"registry:seed": "tsx scripts/seed-registry-records.ts",
Expand Down
54 changes: 54 additions & 0 deletions scripts/check-m13-migration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { loadEnvConfig } from "@next/env";

import { createAdminClient } from "@/lib/supabase/admin";

loadEnvConfig(process.cwd());

const M13_HEALTH_MARKER = "commit_document_index_generation.preserve_legacy_artifacts_migration";
const M13_MIGRATION = "20260702000000_commit_generation_preserve_legacy_artifacts.sql";

type SchemaHealth = {
ok?: boolean;
missing?: unknown;
};

function missingMarkers(data: unknown) {
if (!data || typeof data !== "object" || Array.isArray(data)) return [];
const missing = (data as SchemaHealth).missing;
return Array.isArray(missing) ? missing.map(String) : [];
}

async function main() {
const supabase = createAdminClient();
const { data, error } = await supabase.rpc("search_schema_health");
if (error) {
console.error("[M13 Migration] FAIL: search_schema_health unavailable:", error.message);
process.exit(1);
}

const missing = missingMarkers(data);
if (missing.includes(M13_HEALTH_MARKER)) {
console.error(
`[M13 Migration] FAIL: live commit_document_index_generation is missing the preserve-legacy-artifacts guard.`,
);
console.error(`Apply ${M13_MIGRATION} via the normal Supabase migration workflow, then run:`);
console.error(" npm run reindex:health");
console.error(" npm run check:indexing");
process.exit(1);
}

if (missing.includes("commit_document_index_generation.signature")) {
console.error("[M13 Migration] FAIL: commit_document_index_generation RPC is missing on the live project.");
process.exit(1);
}

console.log("[M13 Migration] PASS: commit generation preserve-legacy guard is live.");
if (missing.length > 0) {
console.log("[M13 Migration] Note: search_schema_health reported other missing items:", missing.join(", "));
}
}

main().catch((error) => {
console.error("[M13 Migration] FAIL:", error instanceof Error ? error.message : error);
process.exit(1);
});
5 changes: 5 additions & 0 deletions scripts/production-readiness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,11 @@ async function main() {
}
}

const productionLike = process.env.NODE_ENV === "production" || process.env.VERCEL_ENV === "production";
if (productionLike && !envModule.env.RAG_QUERY_HASH_SECRET) {
result.failures.push("RAG_QUERY_HASH_SECRET is required in a production-like environment.");
}

if (placeholderLooksLikeExample(envModule.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY ?? "")) {
result.warnings.push("NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY looks like a placeholder.");
}
Expand Down
31 changes: 25 additions & 6 deletions src/app/api/documents/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -506,12 +506,16 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i
// job (status "pending") racing this DELETE let the worker upload a new
// generation of image objects after the storage paths were enumerated,
// orphaning them permanently.
const { data: activeJobs, error: activeJobsError } = await supabase
.from("ingestion_jobs")
.select("id,status")
.eq("document_id", id)
.in("status", ["pending", "processing"])
.limit(1);
async function loadActiveJobs() {
return supabase
.from("ingestion_jobs")
.select("id,status")
.eq("document_id", id)
.in("status", ["pending", "processing"])
.limit(1);
}

const { data: activeJobs, error: activeJobsError } = await loadActiveJobs();

if (activeJobsError) throw new Error(activeJobsError.message);
if ((activeJobs ?? []).length > 0) {
Expand Down Expand Up @@ -547,6 +551,21 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i
imagePaths,
});

const { data: lateActiveJobs, error: lateActiveJobsError } = await loadActiveJobs();
if (lateActiveJobsError) throw new Error(lateActiveJobsError.message);
if ((lateActiveJobs ?? []).length > 0) {
const message =
"Document gained pending or processing indexing work during delete. Stop or wait for the worker before deleting.";
const ledgerWarning = await updateStorageCleanupJob({
supabase,
cleanupJobId,
status: "failed",
storageRemoved: 0,
warnings: [message],
});
throw new PublicApiError(ledgerWarning ? `${message}; ${ledgerWarning}` : message, 409);
}

try {
await deleteDocumentIndexTraceRows({ supabase, ownerId: user.id, documentId: id, chunkIds });
} catch (traceCleanupError) {
Expand Down
19 changes: 19 additions & 0 deletions src/app/api/upload/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ import { env, publicUploadsEnabled, publicWorkspaceOwnerId } from "@/lib/env";
import { assertAllowedFile, assertFileContentSignature, jsonError, PublicApiError } from "@/lib/http";
import { logger } from "@/lib/logger";
import { writeAuditLog } from "@/lib/audit";
import {
allowRateLimitInMemoryFallbackOnUnavailable,
consumeSubjectApiRateLimit,
rateLimitJsonResponse,
} from "@/lib/api-rate-limit";
import { planDocumentName, type DocumentNameSupabase } from "@/lib/document-naming";
import { createAdminClient } from "@/lib/supabase/admin";
import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth";
Expand Down Expand Up @@ -90,6 +95,20 @@ export async function POST(request: Request) {
if (!uploadOwnerId) {
return NextResponse.json({ error: "Public uploads are not configured for this workspace." }, { status: 503 });
}

const rateLimit = await consumeSubjectApiRateLimit({
supabase: adminSupabase,
subject: access.rateLimitSubject,
bucket: "document_upload",
allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(),
});
if (rateLimit.limited) {
return rateLimitJsonResponse(
"Document upload is temporarily rate limited because too many requests were received. Retry shortly.",
rateLimit,
);
}

const formData = await request.formData().catch((cause) => {
throw new PublicApiError("Invalid upload form data.", 400, {
code: "invalid_form_data",
Expand Down
16 changes: 12 additions & 4 deletions src/components/ClinicalDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3062,17 +3062,25 @@
window.requestAnimationFrame(() => mainRef.current?.scrollTo({ top: 0, behavior: "smooth" }));
if (updateUrl) updateDocumentSearchUrl(trimmedSearchText, targetMode);

const requestId = ++searchRequestSeqRef.current;

try {
const shortcutQueryMode = appModeQueryMode(targetMode, queryMode);
const payload = await runWithRetries(() =>
requestSourceLibrarySearch(trimmedSearchText, sourceLibraryMode, filtersOverride, shortcutQueryMode),
);
applySearchResult(payload);
if (requestId === searchRequestSeqRef.current) {
applySearchResult(payload);
}
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : "Document search failed");
if (requestId === searchRequestSeqRef.current) {
setError(requestError instanceof Error ? requestError.message : "Document search failed");
}
} finally {
setLoading(false);
setAnswerProgress(null);
if (requestId === searchRequestSeqRef.current) {
setLoading(false);
setAnswerProgress(null);
}
}
}

Expand Down Expand Up @@ -3190,7 +3198,7 @@
});
}

function stageAnswerFollowUpDraft(draft: string) {

Check warning on line 3201 in src/components/ClinicalDashboard.tsx

View workflow job for this annotation

GitHub Actions / verify

'stageAnswerFollowUpDraft' is defined but never used
setQuery(draft);
focusComposerInput();
}
Expand Down
3 changes: 2 additions & 1 deletion src/lib/answer-ranking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ function resultTexts(result: SearchResult) {
const metadataText = `${labels} ${result.document_summary ?? ""}`;
const titleText = `${result.title} ${result.file_name}`;
const sectionText = result.section_heading ?? "";
const synopsisText = result.retrieval_synopsis ?? "";
const contentText = `${sourceTextForModel(result.content)} ${imageEvidenceText(result)}`;
return {
title: normalizeText(titleText),
Expand All @@ -78,7 +79,7 @@ function resultTexts(result: SearchResult) {
metadata: normalizeText(metadataText),
adjacent: normalizeText(result.adjacent_context ?? ""),
combined: normalizeText(
`${titleText} ${sectionText} ${contentText} ${metadataText} ${result.adjacent_context ?? ""}`,
`${titleText} ${sectionText} ${synopsisText} ${contentText} ${metadataText} ${result.adjacent_context ?? ""}`,
),
};
}
Expand Down
11 changes: 10 additions & 1 deletion src/lib/api-rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,14 @@ export function allowRateLimitInMemoryFallbackOnUnavailable() {
}

export type ApiRateLimitBucket =
"answer" | "search" | "document_read" | "document_summarize" | "document_reindex" | "bulk_reindex" | "registry";
| "answer"
| "search"
| "document_read"
| "document_upload"
| "document_summarize"
| "document_reindex"
| "bulk_reindex"
| "registry";

export type ApiRateLimitResult = {
limited: boolean;
Expand All @@ -24,6 +31,7 @@ const apiRateLimitDefaults = {
answer: { limit: 30, windowSeconds: 60 },
search: { limit: 240, windowSeconds: 60 },
document_read: { limit: 180, windowSeconds: 60 },
document_upload: { limit: 12, windowSeconds: 60 },
document_summarize: { limit: 12, windowSeconds: 60 },
document_reindex: { limit: 6, windowSeconds: 60 },
bulk_reindex: { limit: 2, windowSeconds: 60 },
Expand All @@ -34,6 +42,7 @@ const anonymousApiRateLimitDefaults: Partial<Record<ApiRateLimitBucket, { limit:
answer: { limit: 6, windowSeconds: 60 },
search: { limit: 60, windowSeconds: 60 },
document_read: { limit: 45, windowSeconds: 60 },
document_upload: { limit: 3, windowSeconds: 60 },
};

type SupabaseAdmin = ReturnType<typeof createAdminClient>;
Expand Down
22 changes: 22 additions & 0 deletions src/lib/clinical-evidence-haystack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { SearchResult } from "@/lib/types";

export function clinicalImageEvidenceHaystack(images: SearchResult["images"]) {
return (images ?? [])
.map((image) =>
[image.tableTextSnippet, image.accessibleTableMarkdown, image.caption, image.tableTitle, image.tableLabel]
.filter(Boolean)
.join(" "),
)
.join(" ");
}

export function clinicalResultEvidenceHaystack(result: SearchResult) {
const tableFactText = (result.table_facts ?? [])
.map(
(fact) =>
`${fact.table_title ?? ""} ${fact.row_label ?? ""} ${fact.clinical_parameter ?? ""} ${fact.threshold_value ?? ""} ${fact.action ?? ""}`,
)
.join(" ");
const memoryCardText = (result.memory_cards ?? []).map((card) => `${card.title} ${card.content}`).join(" ");
return `${result.title} ${result.file_name} ${result.section_heading ?? ""} ${result.retrieval_synopsis ?? ""} ${result.content} ${tableFactText} ${memoryCardText} ${clinicalImageEvidenceHaystack(result.images)}`.toLowerCase();
}
29 changes: 7 additions & 22 deletions src/lib/clinical-search.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { isClinicalImageEvidence } from "@/lib/image-filtering";
import { clinicalResultEvidenceHaystack } from "@/lib/clinical-evidence-haystack";
import { expandClinicalVocabularyText } from "@/lib/clinical-vocabulary";
import { freshnessDecayPenalty, rankingConfig } from "@/lib/ranking-config";
import type {
Expand Down Expand Up @@ -720,30 +721,14 @@ function evidenceDensityBoost(result: SearchResult, tokens: string[]) {
}

export function hasDoseEvidenceSupport(result: SearchResult) {
const haystack = `${result.section_heading ?? ""} ${result.content} ${(result.table_facts ?? [])
.map(
(fact) =>
`${fact.table_title ?? ""} ${fact.row_label ?? ""} ${fact.clinical_parameter ?? ""} ${fact.threshold_value ?? ""} ${fact.action ?? ""}`,
)
.join(" ")} ${(result.memory_cards ?? []).map((card) => `${card.title} ${card.content}`).join(" ")} ${(
result.images ?? []
)
.map((image) => `${image.tableTextSnippet ?? ""} ${image.caption ?? ""} ${image.tableTitle ?? ""}`)
.join(" ")}`.toLowerCase();
const haystack = clinicalResultEvidenceHaystack(result);
return /\b(?:dose|dosage|dosing|mg|mcg|microgram|route|oral|intramuscular|subcutaneous|subcut|sublingual|\bim\b|\bpo\b|\bsc\b|\bsl\b|\bprn\b|administer\w*|titration|titrate|frequency|maximum|tablet|injection|antipsychotic|benzodiazepine|olanzapine|lorazepam|haloperidol|droperidol|promethazine|diazepam)\b/i.test(
haystack,
);
}

function hasMedicationDoseAmountEvidence(result: SearchResult) {
const haystack = `${result.section_heading ?? ""} ${result.content} ${(result.table_facts ?? [])
.map(
(fact) =>
`${fact.table_title ?? ""} ${fact.row_label ?? ""} ${fact.clinical_parameter ?? ""} ${fact.threshold_value ?? ""} ${fact.action ?? ""}`,
)
.join(" ")} ${(result.images ?? [])
.map((image) => `${image.tableTextSnippet ?? ""} ${image.caption ?? ""} ${image.tableTitle ?? ""}`)
.join(" ")}`.toLowerCase();
Comment thread
BigSimmo marked this conversation as resolved.
const haystack = clinicalResultEvidenceHaystack(result);
return /\b\d+(?:\.\d+)?\s?(?:mg|mcg|microgram|micrograms)\b/i.test(haystack);
}

Expand All @@ -762,13 +747,13 @@ export function hasNumericOrTableEvidence(result: SearchResult) {
) {
return true;
}
const content = `${result.section_heading ?? ""} ${result.content}`;
const haystack = clinicalResultEvidenceHaystack(result);
// number + clinical unit, or an explicit threshold/range token.
return /\b\d+(?:\.\d+)?\s?(?:mg|mcg|microgram|g|ml|mmol|mol|units?|%|x10\^?9|\/l|cells?)\b/i.test(content)
return /\b\d+(?:\.\d+)?\s?(?:mg|mcg|microgram|g|ml|mmol|mol|units?|%|x10\^?9|\/l|cells?)\b/i.test(haystack)
? true
: /\b\d/.test(content) &&
: /\b\d/.test(haystack) &&
/\b(?:threshold|cut[\s-]?off|withhold|cease|range|level|anc|wbc|fbc|neutrophil|titrat|maximum|max\b)/i.test(
content,
haystack,
);
}

Expand Down
10 changes: 5 additions & 5 deletions src/lib/deep-memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -697,13 +697,13 @@ export async function upsertDocumentDeepMemory(args: {

// All embeddings are in hand — replace the previous memory atomically-ish:
// delete then insert without any intervening network dependency (M11).
await args.supabase.from("document_memory_cards").delete().eq("document_id", args.document.id);
await args.supabase.from("document_sections").delete().eq("document_id", args.document.id);
await args.supabase
const { error: indexUnitDeleteError } = await args.supabase
.from("document_index_units")
.delete()
.eq("document_id", args.document.id)
.then(undefined, () => undefined);
.eq("document_id", args.document.id);
if (indexUnitDeleteError) throw new Error(indexUnitDeleteError.message);
Comment thread
BigSimmo marked this conversation as resolved.
await args.supabase.from("document_memory_cards").delete().eq("document_id", args.document.id);
await args.supabase.from("document_sections").delete().eq("document_id", args.document.id);

const { data: insertedSections, error: sectionError } = await args.supabase
.from("document_sections")
Expand Down
2 changes: 1 addition & 1 deletion src/lib/evidence-relevance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ function labelsText(labels?: Array<{ label?: string | null; label_type?: string

function sourceTextBlocks(source: SearchResult) {
const title = normalizeSearchText(
`${source.title} ${source.file_name} ${source.section_heading ?? ""} ${(source.section_path ?? []).join(" ")}`,
`${source.title} ${source.file_name} ${source.section_heading ?? ""} ${(source.section_path ?? []).join(" ")} ${source.retrieval_synopsis ?? ""}`,
);
const content = normalizeSearchText(
[
Expand Down
4 changes: 4 additions & 0 deletions src/lib/rag-cache-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/** Matches owner-scoped in-memory RAG cache keys (`rag-cache-v12|ownerId|...` and `ownerId|scope`). */
export function ragCacheKeyMatchesOwner(key: string, ownerId: string) {
return key.includes(`|${ownerId}|`) || key.startsWith(`${ownerId}|`);
}
Loading
Loading