Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
2 changes: 2 additions & 0 deletions .gitleaksignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@
# generic-api-key rule on historical commit content scanned in PR history.
27121756b10df6d93841ebf48b0f1991e0144a1f:data/medications-snapshot.json:generic-api-key:22253
27121756b10df6d93841ebf48b0f1991e0144a1f:data/medications-snapshot.json:generic-api-key:47690
b65acddaa13e69e0ad2a49783116056b3a1a3639:data/medications-snapshot.json:generic-api-key:21520
b65acddaa13e69e0ad2a49783116056b3a1a3639:data/medications-snapshot.json:generic-api-key:46330
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
"reindex:cleanup-staged": "tsx scripts/cleanup-abandoned-reindex-generations.ts",
"supabase:recovery-status": "tsx scripts/supabase-recovery-status.ts",
"promote:query-misses": "tsx scripts/promote-query-misses.ts",
"promote:public-documents": "tsx scripts/promote-public-documents.ts",
"eval:rag": "node scripts/run-eval-safe.mjs scripts/eval-rag.ts",
"eval:answer-quality": "node scripts/run-eval-safe.mjs scripts/eval-answer-quality.ts",
"eval:quality": "node scripts/run-eval-safe.mjs scripts/eval-quality.ts",
Expand Down
209 changes: 209 additions & 0 deletions scripts/commit-access-rag-fix.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import fs from "node:fs";
import { execSync } from "node:child_process";

const ownerScope = `import { isDemoMode, isLocalNoAuthMode } from "@/lib/env";

export const PUBLIC_OWNER_FILTER_SENTINEL = "00000000-0000-0000-0000-000000000000";

export function requireOwnerScope(ownerId: string | null | undefined): string | undefined {
if (ownerId) return ownerId;
if (isDemoMode() || isLocalNoAuthMode() || process.env.NODE_ENV === "test") {
return undefined;
}
throw new Error(
"Owner-scoped retrieval was called without an ownerId; refusing to run to avoid returning another tenant's data.",
);
}

export function retrievalOwnerFilter(args: {
ownerId?: string | null;
documentIds?: string[];
allowGlobalSearch?: boolean;
}): string | null | undefined {
if (args.ownerId) return requireOwnerScope(args.ownerId);
if (isDemoMode() || isLocalNoAuthMode() || process.env.NODE_ENV === "test") {
return undefined;
}
if (args.allowGlobalSearch || args.documentIds?.length) {
return PUBLIC_OWNER_FILTER_SENTINEL;
}
throw new Error(
"Owner-scoped retrieval was called without an ownerId; refusing to run to avoid returning another tenant's data.",
);
}
`;

fs.writeFileSync("src/lib/owner-scope.ts", ownerScope);

let rag = fs.readFileSync("src/lib/rag.ts", "utf8");
rag = rag.replace(
'import { requireOwnerScope } from "@/lib/owner-scope";',
'import { requireOwnerScope, retrievalOwnerFilter } from "@/lib/owner-scope";',
);
rag = rag.replace(
/function ownerScopeForDocumentFilteredRetrieval\([\s\S]*?\n\}/,
`function ownerScopeForDocumentFilteredRetrieval(
ownerId: string | undefined,
documentIds: string[] | undefined,
allowGlobalSearch?: boolean,
) {
return retrievalOwnerFilter({ ownerId, documentIds, allowGlobalSearch });
}`,
);
rag = rag.replaceAll(
"ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds)",
"ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds, args.allowGlobalSearch)",
);
rag = rag.replaceAll(
"ownerScopeForDocumentFilteredRetrieval(args.ownerId, documentFilterList)",
"ownerScopeForDocumentFilteredRetrieval(args.ownerId, documentFilterList, args.allowGlobalSearch)",
);
rag = rag.replace(
`documentFilter ? [documentFilter] : undefined,
),`,
`documentFilter ? [documentFilter] : undefined,
documentFilter ? undefined : args.allowGlobalSearch,
),`,
);
rag = rag.replace(
`documentIds: documentFilterList,
matchCount: textCandidateCount,`,
`documentIds: documentFilterList,
allowGlobalSearch: args.allowGlobalSearch,
matchCount: textCandidateCount,`,
);
rag = rag.replaceAll(
`documentIds: documentFilterList,
matchCount: Math.min(candidateCount, 48),`,
`documentIds: documentFilterList,
allowGlobalSearch: args.allowGlobalSearch,
matchCount: Math.min(candidateCount, 48),`,
);
rag = rag.replace(
`documentIds: documentFilterList,
matchCount: Math.min(candidateCount, 64),`,
`documentIds: documentFilterList,
allowGlobalSearch: args.allowGlobalSearch,
matchCount: Math.min(candidateCount, 64),`,
);
rag = rag.replace(
"documentIds?: string[];\n matchCount: number;\n}) {\n const runChunkText",
"documentIds?: string[];\n allowGlobalSearch?: boolean;\n matchCount: number;\n}) {\n const runChunkText",
);
for (const fn of ["searchTableFactCandidates", "searchEmbeddingFieldCandidates", "searchIndexUnitCandidates"]) {
rag = rag.replace(
new RegExp(`async function ${fn}\\([\\s\\S]*?documentIds\\?: string\\[\\];\\n matchCount: number;`),
(m) => m.replace("documentIds?: string[];\n matchCount: number;", "documentIds?: string[];\n allowGlobalSearch?: boolean;\n matchCount: number;"),
);
}
if (!rag.includes('else documentQuery = documentQuery.is("owner_id", null);')) {
rag = rag.replace(
`if (args.ownerId) documentQuery = documentQuery.eq("owner_id", args.ownerId);
const { data: documents, error: documentsError } = await documentQuery;`,
`if (args.ownerId) documentQuery = documentQuery.eq("owner_id", args.ownerId);
else documentQuery = documentQuery.is("owner_id", null);
const { data: documents, error: documentsError } = await documentQuery;`,
);
}
fs.writeFileSync("src/lib/rag.ts", rag);

let enrichment = fs.readFileSync("src/lib/document-enrichment.ts", "utf8");
enrichment = enrichment.replace(
'import { requireOwnerScope } from "@/lib/owner-scope";',
'import { retrievalOwnerFilter } from "@/lib/owner-scope";',
);
enrichment = enrichment.replace(
"owner_filter: args.ownerId ? requireOwnerScope(args.ownerId) : null,",
"owner_filter: retrievalOwnerFilter({ ownerId: args.ownerId, documentIds: args.documentIds }),",
);
fs.writeFileSync("src/lib/document-enrichment.ts", enrichment);

let memory = fs.readFileSync("src/lib/deep-memory.ts", "utf8");
if (!memory.includes("retrievalOwnerFilter")) {
memory = memory.replace(
'import { requireOwnerScope } from "@/lib/owner-scope";',
'import { retrievalOwnerFilter } from "@/lib/owner-scope";',
);
memory = memory.replace(
/owner_filter:[\s\S]*?requireOwnerScope\(args\.ownerId\)[\s\S]*?\),/,
`owner_filter: retrievalOwnerFilter({
ownerId: args.ownerId,
documentIds: args.documentIds,
allowGlobalSearch: !args.ownerId && !args.documentIds?.length,
}),`,
);
}
fs.writeFileSync("src/lib/deep-memory.ts", memory);

let schema = fs.readFileSync("supabase/schema.sql", "utf8");
if (!schema.includes("create or replace function public.retrieval_owner_matches")) {
schema = schema.replace(
"create or replace function public.match_document_chunks(",
`create or replace function public.retrieval_owner_matches(owner_filter uuid, row_owner_id uuid)
returns boolean
language sql
immutable
parallel safe
set search_path = public, pg_temp
as $$
select case
when owner_filter is null then true
when owner_filter = '00000000-0000-0000-0000-000000000000'::uuid then row_owner_id is null
else row_owner_id = owner_filter
end;
$$;

create or replace function public.match_document_chunks(`,
);
}
schema = schema.replaceAll("(owner_filter is null or d.owner_id = owner_filter)", "public.retrieval_owner_matches(owner_filter, d.owner_id)");
schema = schema.replaceAll("(owner_filter is null or l.owner_id = owner_filter)", "public.retrieval_owner_matches(owner_filter, l.owner_id)");
schema = schema.replaceAll("(owner_filter is null or s.owner_id = owner_filter)", "public.retrieval_owner_matches(owner_filter, s.owner_id)");
schema = schema.replaceAll("(owner_filter is null or f.owner_id = owner_filter)", "public.retrieval_owner_matches(owner_filter, f.owner_id)");
fs.writeFileSync("supabase/schema.sql", schema);

const names = [
"retrieval_owner_matches",
"match_document_chunks",
"match_document_chunks_hybrid",
"match_document_memory_cards_hybrid",
"match_documents_for_query",
"match_document_chunks_text",
"match_document_lookup_chunks_text",
"get_related_document_metadata",
"match_document_table_facts_text",
"match_document_embedding_fields_hybrid",
"match_document_index_units_hybrid",
];
const chunks = names.map((name) => {
const re = new RegExp(`create or replace function public\\.${name}[\\s\\S]*?\\n\\$\\$;`, "i");
const match = schema.match(re);
if (!match) throw new Error(`missing ${name}`);
return match[0];
});
fs.writeFileSync(
"supabase/migrations/20260705210000_retrieval_owner_filter_sentinel.sql",
`-- Public-only retrieval owner filter sentinel for hybrid RPCs.\nset search_path = public, extensions, pg_temp;\n\n${chunks.join("\n\n")}\n`,
);

let ownerTest = fs.readFileSync("tests/owner-scope.test.ts", "utf8");
if (!ownerTest.includes("retrievalOwnerFilter")) {
ownerTest += `\ndescribe("retrievalOwnerFilter", () => {
it("returns the public sentinel for anonymous production global search", async () => {
vi.doMock("@/lib/env", () => ({ isDemoMode: () => false, isLocalNoAuthMode: () => false }));
vi.stubEnv("NODE_ENV", "production");
const { retrievalOwnerFilter, PUBLIC_OWNER_FILTER_SENTINEL } = await import("../src/lib/owner-scope");
expect(retrievalOwnerFilter({ allowGlobalSearch: true })).toBe(PUBLIC_OWNER_FILTER_SENTINEL);
});
});\n`;
fs.writeFileSync("tests/owner-scope.test.ts", ownerTest);
}

execSync(
"git add src/lib/owner-scope.ts src/lib/rag.ts src/lib/document-enrichment.ts src/lib/deep-memory.ts supabase/schema.sql supabase/migrations/20260705210000_retrieval_owner_filter_sentinel.sql tests/owner-scope.test.ts scripts/commit-access-rag-fix.mjs",
{ stdio: "inherit" },
);
execSync('git commit -m "fix(rag): scope anonymous retrieval to public documents via owner sentinel"', {
stdio: "inherit",
});
console.log("committed");
16 changes: 15 additions & 1 deletion scripts/eval-quality.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ type EvalQualityArgs = {
retrievalOnly: boolean;
ragOnly: boolean;
skipPreflight: boolean;
forceEmbedding: boolean;
};

export type RagQualityResult = {
Expand Down Expand Up @@ -150,6 +151,7 @@ function parseArgs(argv: string[]): EvalQualityArgs {
retrievalOnly: false,
ragOnly: false,
skipPreflight: false,
forceEmbedding: false,
};

for (let index = 0; index < argv.length; index += 1) {
Expand All @@ -176,6 +178,10 @@ function parseArgs(argv: string[]): EvalQualityArgs {
args.skipPreflight = true;
continue;
}
if (token === "--force-embedding") {
args.forceEmbedding = true;
continue;
}

const value = argv[index + 1];
if (!value || value.startsWith("--")) throw new Error(`Missing value for ${token}`);
Expand Down Expand Up @@ -476,6 +482,11 @@ export function buildEvalQualityReport(args: {
`retrieval content_recall_at_5 ${retrievalSummary.content_recall_at_5} below ${qualityThresholds.retrievalContentRecallAt5}`,
);
}
if (retrievalSummary.force_embedding_failure_count > 0) {
thresholdFailures.push(
`retrieval force_embedding_failure_count ${retrievalSummary.force_embedding_failure_count} above 0`,
);
}
if (governance.stale_rate > qualityThresholds.staleTopResultRate) {
thresholdFailures.push(
`top-result stale_rate ${governance.stale_rate} above ${qualityThresholds.staleTopResultRate}`,
Expand Down Expand Up @@ -664,6 +675,8 @@ ${markdownTable([
## Retrieval Decision Metrics

${markdownTable([
["Force-embedding cases", retrieval.force_embedding_case_count],
["Force-embedding failures", retrieval.force_embedding_failure_count],
["Embedding skipped rate", retrieval.embedding_skipped_rate],
["Median text candidate budget", retrieval.median_text_candidate_budget],
["Second-stage rerank rate", retrieval.second_stage_rerank_rate],
Expand Down Expand Up @@ -728,6 +741,7 @@ async function runRetrievalQualityCases(args: {
ownerId?: string;
limit?: number;
query?: string;
forceEmbedding?: boolean;
supabase: Awaited<ReturnType<typeof loadAdminClient>>;
}) {
const [{ searchChunksWithTelemetry }, capturedCases] = await Promise.all([
Expand Down Expand Up @@ -756,7 +770,7 @@ async function runRetrievalQualityCases(args: {
topK: retrievalLimitForGoldenCase(testCase),
minSimilarity: 0.12,
skipCache: true,
forceEmbedding: testCase.forceEmbedding,
forceEmbedding: testCase.forceEmbedding || args.forceEmbedding,
}),
);
const latencyMs =
Expand Down
24 changes: 24 additions & 0 deletions scripts/eval-retrieval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ type EvalArgs = {
export type GoldenRetrievalResult = {
id: string;
query: string;
forceEmbedding: boolean;
expectedQueryClass: string;
actualQueryClass: string | null;
expectedDocumentSubstrings: string[];
Expand Down Expand Up @@ -516,6 +517,11 @@ export function evaluateGoldenRetrievalCase(args: {
const tableEvidenceFoundAtK = hasTableEvidence(args.results, topK);
const actualQueryClass = args.telemetry.query_class ?? null;
const failures: string[] = [];
const vectorLayerCount = Object.entries(args.telemetry.retrieval_layer_counts ?? {}).reduce(
(sum, [layer, count]) =>
["embedding_fields", "index_units", "hybrid_vector", "vector_fallback"].includes(layer) ? sum + count : sum,
0,
);
const hitAtK =
documentHitsAtK.missing.length === 0 &&
contentHitsAtK.missing.length === 0 &&
Expand All @@ -533,10 +539,23 @@ export function evaluateGoldenRetrievalCase(args: {
if (args.testCase.expectTableEvidence && !tableEvidenceFound) {
failures.push("expected table evidence in top 5");
}
if (args.testCase.forceEmbedding) {
if (args.telemetry.embedding_skipped) failures.push("forceEmbedding expected embedding to run");
if (args.telemetry.retrieval_strategy === "search_cache") failures.push("forceEmbedding served search cache");
if (
args.telemetry.retrieval_strategy === "text_fast_path" ||
args.telemetry.retrieval_strategy === "document_lookup_fast_path"
) {
failures.push(`forceEmbedding returned lexical strategy ${args.telemetry.retrieval_strategy}`);
}
if (args.telemetry.coverage_gate_decision === "accepted") failures.push("forceEmbedding returned coverage gate");
if (vectorLayerCount <= 0) failures.push("forceEmbedding found no vector-layer candidates");
}

return {
id: args.testCase.id,
query: args.testCase.query,
forceEmbedding: args.testCase.forceEmbedding ?? false,
expectedQueryClass: args.testCase.expectedQueryClass,
actualQueryClass,
expectedDocumentSubstrings: args.testCase.expectedDocumentSubstrings,
Expand Down Expand Up @@ -612,6 +631,7 @@ export function summarizeGoldenRetrievalResults(results: GoldenRetrievalResult[]
}
return counts;
}, {});
const forceEmbeddingResults = results.filter((result) => result.forceEmbedding);
return {
case_count: results.length,
document_recall_at_5: Number(
Expand Down Expand Up @@ -647,6 +667,8 @@ export function summarizeGoldenRetrievalResults(results: GoldenRetrievalResult[]
embedding_skip_reason_counts: embeddingSkipReasonCounts,
text_fast_path_reason_counts: textFastPathReasonCounts,
retrieval_layer_counts: layerCounts,
force_embedding_case_count: forceEmbeddingResults.length,
force_embedding_failure_count: forceEmbeddingResults.filter((result) => result.failures.length > 0).length,
median_text_candidate_budget: percentile(textCandidateBudgets, 50),
second_stage_rerank_rate: Number(
(results.filter((result) => result.secondStageRerankUsed).length / Math.max(results.length, 1)).toFixed(4),
Expand Down Expand Up @@ -723,6 +745,8 @@ function printHumanSummary(summary: ReturnType<typeof summarizeGoldenRetrievalRe
console.log(` retrieval_strategy_counts=${JSON.stringify(summary.retrieval_strategy_counts)}`);
console.log(` retrieval_plan_counts=${JSON.stringify(summary.retrieval_plan_counts)}`);
console.log(` retrieval_layer_counts=${JSON.stringify(summary.retrieval_layer_counts)}`);
console.log(` force_embedding_case_count=${summary.force_embedding_case_count}`);
console.log(` force_embedding_failure_count=${summary.force_embedding_failure_count}`);
console.log(` embedding_skipped_rate=${summary.embedding_skipped_rate}`);
console.log(` embedding_skip_reason_counts=${JSON.stringify(summary.embedding_skip_reason_counts)}`);
console.log(` text_fast_path_reason_counts=${JSON.stringify(summary.text_fast_path_reason_counts)}`);
Expand Down
Loading
Loading