From 834e283ccbd3957bb140a3f5c632e01dae24805a Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 27 Jun 2026 13:12:53 +0800 Subject: [PATCH 1/2] chore(runtime): pin toolchain to Node 24 / npm 11 The exact-equality Node 22 / npm 10 guards rejected the upgraded Node 24 runtime, blocking npm install (preinstall), the dev server, npm run ensure, and check:runtime. Bump engines, packageManager, .nvmrc/.node-version, all runtime guards, the release gate (and its test), and CI to Node 24 / npm 11. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 2 +- .node-version | 2 +- .nvmrc | 2 +- package.json | 6 +++--- scripts/check-node-engine.cjs | 8 ++++---- scripts/check-runtime.ts | 4 ++-- scripts/dev-free-port.mjs | 4 ++-- scripts/ensure-local-server.mjs | 4 ++-- tests/check-runtime.test.ts | 18 +++++++++--------- 9 files changed, 25 insertions(+), 25 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e294d9923..e1b63a6f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: 22 + node-version: 24 cache: npm - name: Install dependencies diff --git a/.node-version b/.node-version index 2bd5a0a98..a45fd52cc 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -22 +24 diff --git a/.nvmrc b/.nvmrc index 2bd5a0a98..a45fd52cc 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22 +24 diff --git a/package.json b/package.json index 398e7abbb..19301734c 100644 --- a/package.json +++ b/package.json @@ -2,10 +2,10 @@ "name": "prompt-for-codex-medical-knowledge-base", "version": "0.1.0", "private": true, - "packageManager": "npm@10.9.8", + "packageManager": "npm@11.12.1", "engines": { - "node": "22.x", - "npm": "10.x" + "node": "24.x", + "npm": "11.x" }, "scripts": { "dev": "node scripts/dev-free-port.mjs", diff --git a/scripts/check-node-engine.cjs b/scripts/check-node-engine.cjs index 0ea27aa96..fe8ea7953 100644 --- a/scripts/check-node-engine.cjs +++ b/scripts/check-node-engine.cjs @@ -3,12 +3,12 @@ const npmUserAgent = process.env.npm_config_user_agent ?? ""; const npmVersion = npmUserAgent.match(/\bnpm\/(\d+\.\d+\.\d+)/)?.[1] ?? ""; const npmMajor = Number(npmVersion.split(".")[0]); -if (major !== 22) { - console.error(`This project must be installed with Node 22.x. Current runtime: ${process.versions.node}.`); +if (major !== 24) { + console.error(`This project must be installed with Node 24.x. Current runtime: ${process.versions.node}.`); process.exit(1); } -if (npmVersion && npmMajor !== 10) { - console.error(`This project must be installed with npm 10.x. Current npm runtime: ${npmVersion}.`); +if (npmVersion && npmMajor !== 11) { + console.error(`This project must be installed with npm 11.x. Current npm runtime: ${npmVersion}.`); process.exit(1); } diff --git a/scripts/check-runtime.ts b/scripts/check-runtime.ts index f2d81496b..aab5c30bd 100644 --- a/scripts/check-runtime.ts +++ b/scripts/check-runtime.ts @@ -43,11 +43,11 @@ function runtimeResult(runtimeName: string, version: string, expectedMajor: numb }; } -export function checkNodeRuntime(version: string, expectedMajor = 22): RuntimeCheckResult { +export function checkNodeRuntime(version: string, expectedMajor = 24): RuntimeCheckResult { return runtimeResult("Node", version, expectedMajor); } -export function checkNpmRuntime(userAgent = process.env.npm_config_user_agent ?? "", expectedMajor = 10): RuntimeCheckResult { +export function checkNpmRuntime(userAgent = process.env.npm_config_user_agent ?? "", expectedMajor = 11): RuntimeCheckResult { if (!userAgent) { return { ok: true, diff --git a/scripts/dev-free-port.mjs b/scripts/dev-free-port.mjs index f2da35fac..ee86c9802 100644 --- a/scripts/dev-free-port.mjs +++ b/scripts/dev-free-port.mjs @@ -5,8 +5,8 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { appName, stableProjectPort } from "./local-server-utils.mjs"; -if (Number(process.versions.node.split(".")[0]) !== 22) { - console.error(`Clinical KB local server requires Node 22.x. Current runtime: ${process.versions.node}.`); +if (Number(process.versions.node.split(".")[0]) !== 24) { + console.error(`Clinical KB local server requires Node 24.x. Current runtime: ${process.versions.node}.`); process.exit(1); } diff --git a/scripts/ensure-local-server.mjs b/scripts/ensure-local-server.mjs index cf2a0bb3f..3d9ffbbb0 100644 --- a/scripts/ensure-local-server.mjs +++ b/scripts/ensure-local-server.mjs @@ -7,8 +7,8 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { appName, localProjectId, projectPortEnd, stableProjectPort } from "./local-server-utils.mjs"; -if (Number(process.versions.node.split(".")[0]) !== 22) { - console.error(`Clinical KB local server requires Node 22.x. Current runtime: ${process.versions.node}.`); +if (Number(process.versions.node.split(".")[0]) !== 24) { + console.error(`Clinical KB local server requires Node 24.x. Current runtime: ${process.versions.node}.`); process.exit(1); } diff --git a/tests/check-runtime.test.ts b/tests/check-runtime.test.ts index 2e2c706d2..b97c65aff 100644 --- a/tests/check-runtime.test.ts +++ b/tests/check-runtime.test.ts @@ -3,30 +3,30 @@ import { describe, expect, it } from "vitest"; import { checkNodeRuntime, checkNpmRuntime } from "../scripts/check-runtime"; describe("runtime release gate", () => { - it("accepts the Node 22 release target", () => { - expect(checkNodeRuntime("22.22.3")).toMatchObject({ + it("accepts the Node 24 release target", () => { + expect(checkNodeRuntime("24.15.0")).toMatchObject({ ok: true, - expectedMajor: 22, + expectedMajor: 24, }); }); it("rejects older and newer major runtimes", () => { - expect(checkNodeRuntime("21.7.0")).toMatchObject({ ok: false }); - expect(checkNodeRuntime("24.15.0")).toMatchObject({ ok: false }); + expect(checkNodeRuntime("22.22.3")).toMatchObject({ ok: false }); + expect(checkNodeRuntime("25.1.0")).toMatchObject({ ok: false }); }); it("reports unparsable runtime versions as failures", () => { expect(checkNodeRuntime("not-a-version")).toMatchObject({ ok: false }); }); - it("accepts the npm 10 release package manager", () => { - expect(checkNpmRuntime("npm/10.9.8 node/v22.22.3 win32 x64")).toMatchObject({ + it("accepts the npm 11 release package manager", () => { + expect(checkNpmRuntime("npm/11.12.1 node/v24.15.0 win32 x64")).toMatchObject({ ok: true, - expectedMajor: 10, + expectedMajor: 11, }); }); it("rejects newer npm majors for release verification", () => { - expect(checkNpmRuntime("npm/11.17.0 node/v22.22.3 win32 x64")).toMatchObject({ ok: false }); + expect(checkNpmRuntime("npm/12.1.0 node/v24.15.0 win32 x64")).toMatchObject({ ok: false }); }); }); From b2d702b7e5e414d7e264fae340eb612565b24990 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 27 Jun 2026 13:13:09 +0800 Subject: [PATCH 2/2] fix: address code-audit findings (search scope, PHI, answers, UI, CSS) - search-scope: deterministic .order(id) before .range() so scoped document paging cannot silently skip/duplicate docs past the 1000-row page (H1). - eval-cases route: store normalized query + privacy metadata, gate verbatim answer/note behind RAG_PERSIST_RAW_QUERY_TEXT (PHI at rest, M3). - rag-answer-text: only truncate at a quoted JSON key, never at prose words like 'confidence:'/'body:' (M1). - rag extractive: gate grounded/confidence on an actually-extracted answer; guard the quote-card find() non-null assertion (M7). - worker: evict progressUpdateState per job (L2). - docx extractor: map real image MIME types instead of defaulting to png (L3). - signed-url routes: UUID-validate the id -> 400 not 500 (L6). - DashboardFloatingFab: clear copy-notice timeout on unmount (L7). - evidence: snippet ellipsis keyed on real truncation (L8). - globals.css: consolidate radius tokens to @theme; drop dead duplicate --duration-* (L12/L13). Co-Authored-By: Claude Opus 4.8 --- .../api/documents/[id]/signed-url/route.ts | 6 +++++- src/app/api/eval-cases/route.ts | 15 +++++++++++---- src/app/api/images/[id]/signed-url/route.ts | 6 +++++- src/app/globals.css | 16 +++++++--------- src/components/DashboardFloatingFab.tsx | 6 +++++- src/lib/evidence.ts | 8 ++++++-- src/lib/extractors/document.ts | 19 ++++++++++++++++++- src/lib/rag-answer-text.ts | 14 ++++++++++++-- src/lib/rag.ts | 17 +++++++++++++---- src/lib/search-scope.ts | 4 ++++ tests/eval-cases-route.test.ts | 13 ++++++++++--- worker/main.ts | 3 +++ 12 files changed, 99 insertions(+), 28 deletions(-) diff --git a/src/app/api/documents/[id]/signed-url/route.ts b/src/app/api/documents/[id]/signed-url/route.ts index ce55e7b40..a950b99db 100644 --- a/src/app/api/documents/[id]/signed-url/route.ts +++ b/src/app/api/documents/[id]/signed-url/route.ts @@ -1,14 +1,16 @@ import { NextResponse } from "next/server"; +import { z } from "zod"; import { getDemoDocument } from "@/lib/demo-data"; import { env } from "@/lib/env"; import { isDemoMode } from "@/lib/env"; -import { jsonError } from "@/lib/http"; +import { jsonError, PublicApiError } from "@/lib/http"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; export const runtime = "nodejs"; const signedUrlTtlSeconds = 60 * 10; +const routeIdSchema = z.string().uuid(); export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) { try { @@ -24,6 +26,8 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id: }); } + if (!routeIdSchema.safeParse(id).success) throw new PublicApiError("Invalid document id."); + const supabase = createAdminClient(); const user = await requireAuthenticatedUser(_request, supabase); const { data: document, error } = await supabase diff --git a/src/app/api/eval-cases/route.ts b/src/app/api/eval-cases/route.ts index a2f4039a9..7d7b0b7a3 100644 --- a/src/app/api/eval-cases/route.ts +++ b/src/app/api/eval-cases/route.ts @@ -2,8 +2,9 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { normalizedClinicalSearchTokens } from "@/lib/clinical-search"; import { clinicalQueryModeSchema } from "@/lib/clinical-query-mode"; -import { isDemoMode } from "@/lib/env"; +import { env, isDemoMode } from "@/lib/env"; import { jsonError, PublicApiError } from "@/lib/http"; +import { queryPrivacyMetadata, queryTextForStorage } from "@/lib/query-privacy"; import { searchScopeFiltersSchema } from "@/lib/search-scope"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; @@ -88,7 +89,10 @@ export async function POST(request: Request) { .from("rag_query_misses") .insert({ owner_id: user.id, - query: parsed.data.query, + // Raw clinical queries are potential PHI: store the normalized form unless + // raw retention is explicitly enabled (RET-H4), matching every other + // rag_query_misses / rag_queries writer. + query: queryTextForStorage(parsed.data.query), normalized_query: normalizedQuery, query_class: parsed.data.queryClass ?? parsed.data.queryMode, top_files: sourceFiles, @@ -101,11 +105,14 @@ export async function POST(request: Request) { promoted_eval_case: true, promoted_at: new Date().toISOString(), metadata: { + ...queryPrivacyMetadata(parsed.data.query), interaction: "answer_eval_capture", rating, feedback_type: parsed.data.feedbackType ?? null, - note: parsed.data.note, - answer: parsed.data.answer, + // Verbatim clinical note/answer are the larger PHI surface; only retain + // them when raw retention is explicitly enabled (RET-H4). + note: env.RAG_PERSIST_RAW_QUERY_TEXT ? parsed.data.note : "", + answer: env.RAG_PERSIST_RAW_QUERY_TEXT ? parsed.data.answer : "", query_class: parsed.data.queryClass ?? null, query_mode: parsed.data.queryMode, filters: parsed.data.filters ?? {}, diff --git a/src/app/api/images/[id]/signed-url/route.ts b/src/app/api/images/[id]/signed-url/route.ts index 2eacd226d..3f1a6d254 100644 --- a/src/app/api/images/[id]/signed-url/route.ts +++ b/src/app/api/images/[id]/signed-url/route.ts @@ -1,14 +1,16 @@ import { NextResponse } from "next/server"; +import { z } from "zod"; import { getDemoImage } from "@/lib/demo-data"; import { env } from "@/lib/env"; import { isDemoMode } from "@/lib/env"; -import { jsonError } from "@/lib/http"; +import { jsonError, PublicApiError } from "@/lib/http"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; export const runtime = "nodejs"; const signedUrlTtlSeconds = 60 * 10; +const routeIdSchema = z.string().uuid(); export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) { try { @@ -25,6 +27,8 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id: }); } + if (!routeIdSchema.safeParse(id).success) throw new PublicApiError("Invalid image id."); + const supabase = createAdminClient(); const user = await requireAuthenticatedUser(_request, supabase); const { data: image, error } = await supabase diff --git a/src/app/globals.css b/src/app/globals.css index 6c6f42680..e4137b84d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -14,6 +14,7 @@ * rounded-xl bottom sheets, dialogs, large shells */ @theme { + --radius-xs: 0.25rem; --radius-sm: 0.375rem; --radius-md: 0.5rem; --radius-lg: 0.625rem; @@ -152,10 +153,9 @@ --disabled: #94a3b8; --focus: #1aa99b; - /* Motion primitives (shared by both themes) */ - --duration-fast: 150ms; - --duration-base: 200ms; - --duration-slow: 250ms; + /* Motion primitives (shared by both themes). The duration values live with + the easing tokens lower in this block (120/180/240ms); they are not + redeclared here to avoid shadowed, dead definitions. */ --ease-out-soft: cubic-bezier(0.22, 1, 0.36, 1); --ease-spring: cubic-bezier(0.34, 1.3, 0.64, 1); @@ -178,11 +178,9 @@ --space-8: 2rem; --space-12: 3rem; --space-16: 4rem; - --radius-xs: 0.25rem; - --radius-sm: 0.375rem; - --radius-md: 0.5rem; - --radius-lg: 0.75rem; - --radius-xl: 1rem; + /* Radius tokens are the single source of truth in @theme above (they also + generate the rounded-* utilities); do not redefine them here or var() and + the utilities drift apart. */ --duration-fast: 120ms; --duration-base: 180ms; --duration-slow: 240ms; diff --git a/src/components/DashboardFloatingFab.tsx b/src/components/DashboardFloatingFab.tsx index c2d119ed8..98aa73a04 100644 --- a/src/components/DashboardFloatingFab.tsx +++ b/src/components/DashboardFloatingFab.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { ArrowUp, ClipboardCopy, ExternalLink, Plus, Search, X } from "lucide-react"; import { cn, floatingControl } from "@/components/ui-primitives"; @@ -52,6 +52,10 @@ export function DashboardFloatingFab() { setOpen(false); }, []); + // Clear any pending copy-notice timeout on unmount so it can't fire after the + // component is gone (leaked timer / stray setState). + useEffect(() => clearNotice, [clearNotice]); + return (
diff --git a/src/lib/evidence.ts b/src/lib/evidence.ts index b298a5b44..fe1711f43 100644 --- a/src/lib/evidence.ts +++ b/src/lib/evidence.ts @@ -297,14 +297,18 @@ export function selectBestSourceRecommendation( const directQuote = quoteCards.find((quote) => quote.chunk_id === best.id); const documentQuote = quoteCards.find((quote) => quote.document_id === best.document_id); const quote = directQuote?.quote ?? documentQuote?.quote; - const snippet = quote ?? normalizeText(best.content).slice(0, 260).trim(); + // Track truncation by the pre-slice length rather than guessing from the + // post-trim snippet length (=== 260 dropped the ellipsis when trim shortened a + // truncated slice, and appended a spurious "..." to a complete 260-char quote). + const fullContent = normalizeText(best.content).trim(); + const snippet = quote ?? (fullContent.length > 260 ? `${fullContent.slice(0, 257).trimEnd()}...` : fullContent); const citation = citationFromResult(best); return { ...citation, source_strength: best.source_strength ?? sourceStrengthForSimilarity(best.similarity), score: best.hybrid_score ?? best.similarity, - snippet: snippet.length === 260 ? `${snippet.slice(0, 257).trim()}...` : snippet, + snippet, quote, section_heading: best.section_heading, image_count: (best.images ?? []).filter((image) => isClinicalImageEvidence(image)).length, diff --git a/src/lib/extractors/document.ts b/src/lib/extractors/document.ts index b92110242..73daac961 100644 --- a/src/lib/extractors/document.ts +++ b/src/lib/extractors/document.ts @@ -146,7 +146,24 @@ async function extractDocx(buffer: Buffer) { if (file.dir) continue; const bytes = await file.async("nodebuffer"); const ext = path.extname(name).toLowerCase() || ".png"; - const mimeType = ext === ".jpg" || ext === ".jpeg" ? "image/jpeg" : ext === ".webp" ? "image/webp" : "image/png"; + // Map the actual media extension to its real MIME type. Previously every + // non-jpg/webp extension (including .emf/.wmf/.tiff/.bmp/.gif vector and + // legacy-raster figures common in clinical .docx) was mislabeled image/png, + // which was then written to storage/DB and sent to the vision API as PNG. + const docxImageMimeByExt: Record = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".webp": "image/webp", + ".bmp": "image/bmp", + ".tif": "image/tiff", + ".tiff": "image/tiff", + ".emf": "image/emf", + ".wmf": "image/wmf", + ".svg": "image/svg+xml", + }; + const mimeType = docxImageMimeByExt[ext] ?? "application/octet-stream"; const outputPath = path.join(tempRoot, `docx-image-${index}${ext}`); await writeFile(outputPath, bytes); images.push({ diff --git a/src/lib/rag-answer-text.ts b/src/lib/rag-answer-text.ts index 095272be0..f8ecb3b18 100644 --- a/src/lib/rag-answer-text.ts +++ b/src/lib/rag-answer-text.ts @@ -8,6 +8,12 @@ const likelyFragmentPhrases = /\b(?:answer|heading|body|grounded|confidence|citations?|answerSections?|citation_chunk_ids|conflictsOrGaps|quoteCards?|source_chunk_ids|chunk_id)\b/i; const answerSectionArtifactPattern = /"?(answer|heading|body|grounded|confidence|citations?|answerSections?|citation_chunk_ids|conflictsOrGaps|quoteCards?|source_chunk_ids|chunk_id)"?\s*:\s*/i; +// Mid-text truncation must only fire on a genuine leaked JSON key — i.e. a +// double-quoted key followed by a colon ("confidence":). A bare English word + +// colon ("...document the confidence: high...") is legitimate clinical prose and +// must NOT cause the rest of the answer/section to be sliced away. +const leakedJsonKeyPattern = + /"(answer|heading|body|grounded|confidence|citations?|answerSections?|citation_chunk_ids|conflictsOrGaps|quoteCards?|source_chunk_ids|chunk_id)"\s*:/i; export function normalizeSectionText(value: string) { return value.trim().replace(/\s+/g, " "); @@ -88,11 +94,15 @@ export function sanitizeStructuredText( const normalized = normalizeSectionText(sourceTextForClinicalProse(value)); if (!normalized) return ""; + // A leaked key at the very start is stripped (lenient: it is clearly an + // artifact prefix). Mid-text, only truncate at a genuine quoted JSON key so we + // never cut real prose at an ordinary word like "confidence:" or "body:". + const leakedKeyIndex = normalized.search(leakedJsonKeyPattern); const trimmed = normalized.search(answerSectionArtifactPattern) === 0 ? normalized.replace(answerSectionArtifactPattern, "").trim() - : normalized.search(answerSectionArtifactPattern) > 0 - ? normalized.slice(0, normalized.search(answerSectionArtifactPattern)).trim() + : leakedKeyIndex > 0 + ? normalized.slice(0, leakedKeyIndex).trim() : normalized; const finalText = keepLeading ? trimmed : trimmed.trim(); diff --git a/src/lib/rag.ts b/src/lib/rag.ts index f9a40acf1..96ad7b46f 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -3483,8 +3483,12 @@ function buildExtractiveAnswer(args: { } } for (const quote of quoteCards) { - if (!citationIds.has(quote.chunk_id)) - citations.push(resultCitation(args.results.find((result) => result.id === quote.chunk_id)!)); + if (!citationIds.has(quote.chunk_id)) { + // Guard the lookup: a quote card whose chunk_id was filtered out of results + // would make find() return undefined and resultCitation(undefined) throw. + const source = args.results.find((result) => result.id === quote.chunk_id); + if (source) citations.push(resultCitation(source)); + } citationIds.add(quote.chunk_id); } @@ -3506,10 +3510,15 @@ function buildExtractiveAnswer(args: { sourceCount: args.results.length, }); + // When no concise source sentence could be extracted, buildNaturalExtractiveAnswer + // returns a sentinel non-answer with no citationChunkIds. Do not present that as a + // grounded, confidence-rated answer just because compactCitations seeded a citation. + const hasExtractedAnswer = naturalAnswer.citationChunkIds.length > 0; + return { answer: naturalAnswer.answer, - grounded: citations.length > 0, - confidence: deriveConfidence(args.results, citations.length), + grounded: hasExtractedAnswer && citations.length > 0, + confidence: hasExtractedAnswer ? deriveConfidence(args.results, citations.length) : "low", citations: citations.slice(0, 5), sources: args.results, modelUsed: null, diff --git a/src/lib/search-scope.ts b/src/lib/search-scope.ts index bce64b94b..275f534b4 100644 --- a/src/lib/search-scope.ts +++ b/src/lib/search-scope.ts @@ -160,6 +160,10 @@ export async function resolveSearchScope(args: { .from("documents") .select("id,metadata,import_batch_id") .eq("status", "indexed") + // Deterministic total order over the unique id column. Without it, separate + // LIMIT/OFFSET page queries have no stable order, so rows can be silently + // skipped or duplicated across pages and the resolved scope is incomplete. + .order("id", { ascending: true }) .range(offset, Math.min(offset + documentScopeQueryPageSize - 1, maxResolvedDocuments - 1)); if (args.ownerId) documentQuery = documentQuery.eq("owner_id", args.ownerId); if (explicitIds.length) documentQuery = documentQuery.in("id", explicitIds); diff --git a/tests/eval-cases-route.test.ts b/tests/eval-cases-route.test.ts index 1c99febc2..2719a0122 100644 --- a/tests/eval-cases-route.test.ts +++ b/tests/eval-cases-route.test.ts @@ -37,7 +37,7 @@ afterEach(() => { describe("/api/eval-cases", () => { it("captures a good answer as a promoted eval case and filters malformed chunk ids", async () => { const { client, insert } = createInsertMock(); - vi.doMock("@/lib/env", () => ({ isDemoMode: () => false })); + vi.doMock("@/lib/env", () => ({ isDemoMode: () => false, env: { RAG_PERSIST_RAW_QUERY_TEXT: false } })); vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient: () => client })); vi.doMock("@/lib/supabase/auth", () => ({ AuthenticationError: class AuthenticationError extends Error {}, @@ -63,6 +63,9 @@ describe("/api/eval-cases", () => { expect(response.status).toBe(201); expect(payload).toMatchObject({ owner_id: userId, + // PHI control (RET-H4): with raw retention OFF the verbatim query is NOT + // stored — only the normalized form. + query: "what monitoring is needed for clozapine?", query_class: "table_threshold", miss_reason: "answer_good_eval", top_files: ["CG.MHSP.ClozapinePresAdminMonitor.pdf"], @@ -77,12 +80,16 @@ describe("/api/eval-cases", () => { query_class: "table_threshold", source_chunk_ids_rejected: 1, cited_chunk_ids_rejected: 1, + // Privacy metadata folded in; verbatim answer dropped when retention is off. + raw_query_retained: false, + query_hash: expect.any(String), + answer: "", }); }); it("captures a needs-fixing answer without requiring expected UUID fields", async () => { const { client, insert } = createInsertMock(); - vi.doMock("@/lib/env", () => ({ isDemoMode: () => false })); + vi.doMock("@/lib/env", () => ({ isDemoMode: () => false, env: { RAG_PERSIST_RAW_QUERY_TEXT: false } })); vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient: () => client })); vi.doMock("@/lib/supabase/auth", () => ({ AuthenticationError: class AuthenticationError extends Error {}, @@ -114,7 +121,7 @@ describe("/api/eval-cases", () => { it("captures category-specific missed-answer feedback for eval promotion", async () => { const { client, insert } = createInsertMock(); - vi.doMock("@/lib/env", () => ({ isDemoMode: () => false })); + vi.doMock("@/lib/env", () => ({ isDemoMode: () => false, env: { RAG_PERSIST_RAW_QUERY_TEXT: false } })); vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient: () => client })); vi.doMock("@/lib/supabase/auth", () => ({ AuthenticationError: class AuthenticationError extends Error {}, diff --git a/worker/main.ts b/worker/main.ts index 8b56bb50b..22a99a2c7 100644 --- a/worker/main.ts +++ b/worker/main.ts @@ -1507,6 +1507,9 @@ async function processJob(job: JobRow) { } } finally { await cleanupExtractedTemporaryPaths(extracted); + // The progress-throttle entry is only needed while this job is processing; + // drop it so the module-level map does not grow for the worker's lifetime. + progressUpdateState.delete(job.id); } }