From e95c80bb19646bc2d901ae6357728f5980ed5d6f Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 4 Jun 2026 03:13:03 +0800 Subject: [PATCH 1/4] Align Clinical KB runtime policy --- .node-version | 1 + .nvmrc | 1 + README.md | 19 +++++++++++++------ docs/project-alignment-cleanup.md | 6 ++++++ package-lock.json | 4 ++++ package.json | 4 ++++ 6 files changed, 29 insertions(+), 6 deletions(-) create mode 100644 .node-version create mode 100644 .nvmrc diff --git a/.node-version b/.node-version new file mode 100644 index 000000000..2bd5a0a98 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +22 diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..2bd5a0a98 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/README.md b/README.md index 2532e0c46..235a1cc3b 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,10 @@ questions with source citations that link back to the original PDF/document. ## Setup -1. Copy `.env.example` to `.env.local` and fill in Supabase and OpenAI values. -2. Confirm the Supabase target: +1. Use Node.js 22.x. CI runs on Node 22, and `.nvmrc` / `.node-version` + pin the same runtime for local version managers. +2. Copy `.env.example` to `.env.local` and fill in Supabase and OpenAI values. +3. Confirm the Supabase target: ```bash npm run check:supabase-project @@ -26,15 +28,15 @@ Do not use the older unused Supabase project `Database` (`qjgitjyhxrwxsrydablr`). Local checks and runtime guards warn or fall back to demo mode if that stale ref appears in `.env.local`. -3. Run `supabase/schema.sql` in the `Clinical KB Database` Supabase project SQL +4. Run `supabase/schema.sql` in the `Clinical KB Database` Supabase project SQL editor. -4. Install optional PDF/OCR worker dependencies: +5. Install optional PDF/OCR worker dependencies: ```bash python -m pip install -r worker/python/requirements.txt ``` -5. Start the app: +6. Start the app: ```bash npm run dev @@ -57,7 +59,7 @@ belongs to this project, and starts the dev server in the background if needed. When you say `run` in this chat, Codex should use this command and return the printed URL. -6. In a second terminal, start the local ingestion worker: +7. In a second terminal, start the local ingestion worker: ```bash npm run worker @@ -141,3 +143,8 @@ Run `npm run samples` to generate synthetic documents under a scanned-style PDF for OCR fallback testing. Upload those files through the UI and start `npm run worker` to index them. The sample content is deliberately synthetic and must not be used as clinical guidance. + +`sample-documents/` is generated local test output and is intentionally ignored +by Git. The smaller `public/demo-documents/` set is tracked because the app uses +it for demo-mode source and image rendering when live Supabase setup is +unavailable. diff --git a/docs/project-alignment-cleanup.md b/docs/project-alignment-cleanup.md index 27850c40f..04d623996 100644 --- a/docs/project-alignment-cleanup.md +++ b/docs/project-alignment-cleanup.md @@ -7,6 +7,12 @@ - Added transitive overrides for `postcss`, `tmp`, and `uuid` to remove audit findings from Next and ExcelJS dependency paths while preserving the existing spreadsheet import/export code. - Verified ExcelJS still writes and reads an XLSX buffer with the `uuid` override in place. +## Runtime policy + +- CI verifies the project on Node.js 22, so local development should also use Node.js 22.x. +- `.nvmrc`, `.node-version`, and `package.json` `engines` all declare the Node 22 runtime expectation. +- New cleanup or dependency work should be verified on Node 22 before release, even when local shells happen to use newer Node versions. + ## Stale branch audit - `temporary` and `codex/spark` contain the same four unique commits covering upload workflow notes, Supabase project checks, and database cleanup hardening. diff --git a/package-lock.json b/package-lock.json index a35725c68..a2d3ea3bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,10 @@ "tsx": "^4.22.4", "typescript": "^6.0.3", "vitest": "^4.1.8" + }, + "engines": { + "node": "22.x", + "npm": ">=10" } }, "node_modules/@alloc/quick-lru": { diff --git a/package.json b/package.json index 63173b23c..fc5413ca1 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,10 @@ "name": "prompt-for-codex-medical-knowledge-base", "version": "0.1.0", "private": true, + "engines": { + "node": "22.x", + "npm": ">=10" + }, "scripts": { "dev": "node scripts/dev-free-port.mjs", "ensure": "node scripts/ensure-local-server.mjs", From bff4f6a23afb2d52b6ced217d0aff63c683ab7a6 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 4 Jun 2026 03:13:51 +0800 Subject: [PATCH 2/4] Extract dashboard search utilities --- src/components/ClinicalDashboard.tsx | 170 ++---------------- .../clinical-dashboard/search-utils.ts | 159 ++++++++++++++++ tests/clinical-dashboard-search-utils.test.ts | 63 +++++++ 3 files changed, 236 insertions(+), 156 deletions(-) create mode 100644 src/components/clinical-dashboard/search-utils.ts create mode 100644 tests/clinical-dashboard-search-utils.test.ts diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index bc74745aa..e39b132e8 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -75,6 +75,20 @@ import { import { AUTH_EMAIL_STORAGE_KEY, useAuthSession } from "@/lib/supabase/client"; import { nextTheme, resolveThemePreference, type ResolvedTheme } from "@/lib/theme"; import { SafeBoldText } from "@/components/SafeBoldText"; +import { + answerPayloadIsUsable, + isRetryableError, + isRetryableMessage, + isRetryableStatus, + keywordQueryFromNaturalLanguage, + makeSearchError, + progressForRetry, + searchRetryCount, + searchRetryDelaysMs, + sleep, + type AnswerPayload, + type SearchError, +} from "@/components/clinical-dashboard/search-utils"; import { parseAnswerDisplayContent, type AnswerDisplayLine, @@ -196,7 +210,6 @@ type BatchesPayload = { pollAfterMs?: number | null; }; -type AnswerPayload = RagAnswer & { demoMode?: boolean }; type SearchResultModePayload = | { kind: "documents"; @@ -212,161 +225,6 @@ type SearchResultModePayload = payload: AnswerPayload; }; -type SearchError = Error & { - status?: number; - retryable?: boolean; -}; - -const searchRetryDelaysMs = [500, 1000, 2000] as const; -const searchRetryCount = 2; -const keywordStopWords = new Set([ - "a", - "about", - "all", - "an", - "and", - "are", - "as", - "at", - "be", - "before", - "both", - "by", - "can", - "could", - "did", - "do", - "does", - "for", - "from", - "get", - "had", - "has", - "have", - "her", - "his", - "how", - "if", - "in", - "is", - "it", - "its", - "into", - "me", - "may", - "more", - "my", - "no", - "not", - "of", - "on", - "or", - "our", - "out", - "should", - "so", - "such", - "that", - "the", - "their", - "them", - "there", - "these", - "they", - "this", - "those", - "to", - "when", - "where", - "which", - "who", - "why", - "with", - "would", - "you", -]); - -function makeSearchError(message: string, status?: number, retryable = false): SearchError { - const error = new Error(message) as SearchError; - error.status = status; - error.retryable = retryable; - return error; -} - -function isRetryableStatus(status: number) { - return status === 408 || status === 429 || (status >= 500 && status <= 599); -} - -function isRetryableMessage(message: string) { - const normalized = message.toLowerCase(); - return ( - normalized.includes("failed to fetch") || - normalized.includes("network") || - normalized.includes("timeout") || - normalized.includes("timed out") || - normalized.includes("rate limit") || - normalized.includes("rate-limited") || - normalized.includes("temporar") || - normalized.includes("overload") || - normalized.includes("retry") || - normalized.includes("unavailable") || - normalized.includes("upstream") || - normalized.includes("service is currently") - ); -} - -function isRetryableError(error: unknown) { - if (!(error instanceof Error)) return false; - - const searchError = error as SearchError; - if (searchError.name === "TypeError") return true; - if (searchError.retryable !== undefined) return searchError.retryable; - if (searchError.status !== undefined) return isRetryableStatus(searchError.status); - return isRetryableMessage(searchError.message); -} - -function sleep(ms: number) { - return new Promise((resolve) => window.setTimeout(resolve, ms)); -} - -function keywordQueryFromNaturalLanguage(query: string) { - const normalized = query - .normalize("NFKD") - .toLowerCase() - .replace(/[^\w\s]+/g, " ") - .replace(/_/g, " ") - .trim(); - const tokens = normalized.split(/\s+/).filter((token) => token.length >= 3 && !keywordStopWords.has(token)); - const terms: string[] = []; - const seen = new Set(); - - for (const token of tokens) { - if (seen.has(token)) continue; - seen.add(token); - terms.push(token); - } - - return terms.slice(0, 7).join(" "); -} - -function answerPayloadIsUsable(payload: AnswerPayload) { - const answerText = payload.answer.trim(); - if (!answerText) return false; - if (payload.confidence === "unsupported") { - const hasGapContext = Boolean( - payload.relevance || payload.smartPanel?.relevance || payload.sources?.length || payload.relatedDocuments?.length, - ); - return hasGapContext; - } - - return true; -} - -function progressForRetry(attempt: number) { - if (attempt <= 1) return "Retrying..."; - return `Retrying... (${Math.min(attempt, searchRetryCount)}/${searchRetryCount})`; -} - async function readLocalProjectIdentity() { const response = await fetch("/api/local-project-id", { cache: "no-store" }); if (!response.ok) return null; diff --git a/src/components/clinical-dashboard/search-utils.ts b/src/components/clinical-dashboard/search-utils.ts new file mode 100644 index 000000000..c2720968a --- /dev/null +++ b/src/components/clinical-dashboard/search-utils.ts @@ -0,0 +1,159 @@ +import type { RagAnswer } from "@/lib/types"; + +export type AnswerPayload = RagAnswer & { demoMode?: boolean }; + +export type SearchError = Error & { + status?: number; + retryable?: boolean; +}; + +export const searchRetryDelaysMs = [500, 1000, 2000] as const; +export const searchRetryCount = 2; + +const keywordStopWords = new Set([ + "a", + "about", + "all", + "an", + "and", + "are", + "as", + "at", + "be", + "before", + "both", + "by", + "can", + "could", + "did", + "do", + "does", + "for", + "from", + "get", + "had", + "has", + "have", + "her", + "his", + "how", + "if", + "in", + "is", + "it", + "its", + "into", + "me", + "may", + "more", + "my", + "no", + "not", + "of", + "on", + "or", + "our", + "out", + "should", + "so", + "such", + "that", + "the", + "their", + "them", + "there", + "these", + "they", + "this", + "those", + "to", + "when", + "where", + "which", + "who", + "why", + "with", + "would", + "you", +]); + +export function makeSearchError(message: string, status?: number, retryable = false): SearchError { + const error = new Error(message) as SearchError; + error.status = status; + error.retryable = retryable; + return error; +} + +export function isRetryableStatus(status: number) { + return status === 408 || status === 429 || (status >= 500 && status <= 599); +} + +export function isRetryableMessage(message: string) { + const normalized = message.toLowerCase(); + return ( + normalized.includes("failed to fetch") || + normalized.includes("network") || + normalized.includes("timeout") || + normalized.includes("timed out") || + normalized.includes("rate limit") || + normalized.includes("rate-limited") || + normalized.includes("temporar") || + normalized.includes("overload") || + normalized.includes("retry") || + normalized.includes("unavailable") || + normalized.includes("upstream") || + normalized.includes("service is currently") + ); +} + +export function isRetryableError(error: unknown) { + if (!(error instanceof Error)) return false; + + const searchError = error as SearchError; + if (searchError.name === "TypeError") return true; + if (searchError.retryable !== undefined) return searchError.retryable; + if (searchError.status !== undefined) return isRetryableStatus(searchError.status); + return isRetryableMessage(searchError.message); +} + +export function sleep(ms: number) { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} + +export function keywordQueryFromNaturalLanguage(query: string) { + const normalized = query + .normalize("NFKD") + .toLowerCase() + .replace(/[^\w\s]+/g, " ") + .replace(/_/g, " ") + .trim(); + const tokens = normalized.split(/\s+/).filter((token) => token.length >= 3 && !keywordStopWords.has(token)); + const terms: string[] = []; + const seen = new Set(); + + for (const token of tokens) { + if (seen.has(token)) continue; + seen.add(token); + terms.push(token); + } + + return terms.slice(0, 7).join(" "); +} + +export function answerPayloadIsUsable(payload: AnswerPayload) { + const answerText = payload.answer.trim(); + if (!answerText) return false; + if (payload.confidence === "unsupported") { + const hasGapContext = Boolean( + payload.relevance || payload.smartPanel?.relevance || payload.sources?.length || payload.relatedDocuments?.length, + ); + return hasGapContext; + } + + return true; +} + +export function progressForRetry(attempt: number) { + if (attempt <= 1) return "Retrying..."; + return `Retrying... (${Math.min(attempt, searchRetryCount)}/${searchRetryCount})`; +} diff --git a/tests/clinical-dashboard-search-utils.test.ts b/tests/clinical-dashboard-search-utils.test.ts new file mode 100644 index 000000000..78287e195 --- /dev/null +++ b/tests/clinical-dashboard-search-utils.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { + answerPayloadIsUsable, + isRetryableError, + keywordQueryFromNaturalLanguage, + makeSearchError, + progressForRetry, +} from "@/components/clinical-dashboard/search-utils"; +import type { RagAnswer } from "@/lib/types"; + +function answer(overrides: Partial = {}): RagAnswer { + return { + answer: "Use linked sources to verify the clinical point.", + grounded: true, + confidence: "medium", + citations: [], + sources: [], + ...overrides, + }; +} + +describe("clinical dashboard search utilities", () => { + it("converts natural-language fallback queries into deduplicated keywords", () => { + expect( + keywordQueryFromNaturalLanguage("What clozapine monitoring items are shown in the clozapine table image?"), + ).toBe("what clozapine monitoring items shown table image"); + }); + + it("keeps unsupported answers usable only when they carry gap context", () => { + expect(answerPayloadIsUsable(answer({ confidence: "unsupported" }))).toBe(false); + expect( + answerPayloadIsUsable( + answer({ + confidence: "unsupported", + relatedDocuments: [ + { + document_id: "doc-1", + title: "Synthetic guideline", + file_name: "synthetic.pdf", + labels: [], + summary: null, + best_pages: [1], + best_chunk_ids: ["chunk-1"], + image_count: 0, + match_reason: "Closest related source for an unsupported query.", + score: 0.7, + }, + ], + }), + ), + ).toBe(true); + }); + + it("classifies retryable search errors", () => { + expect(isRetryableError(makeSearchError("Service is currently unavailable.", 503, true))).toBe(true); + expect(isRetryableError(makeSearchError("Search request was not authorized by the server.", 401))).toBe(false); + }); + + it("formats retry progress without exposing impossible counts", () => { + expect(progressForRetry(1)).toBe("Retrying..."); + expect(progressForRetry(10)).toBe("Retrying... (2/2)"); + }); +}); From 6709b3f695781b37f91f2810b1817ea8f65d415d Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 4 Jun 2026 03:14:43 +0800 Subject: [PATCH 3/4] Stabilize Clinical KB browser smoke tests --- tests/ui-smoke.spec.ts | 8 ++++---- tests/ui-stress.spec.ts | 9 ++++++--- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index adc64dee5..db9f08876 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -653,10 +653,10 @@ test.describe("Clinical KB UI smoke coverage", () => { "/documents/11111111-1111-4111-8111-111111111111?page=1&chunk=44444444-4444-4444-8444-444444444442", ); - await expect(page.getByTestId("pdf-preview").getByText("Sign in to open private source documents.")).toBeVisible({ - timeout: 30000, - }); - await expect(page.getByRole("heading", { level: 1, name: "Sign in required" })).toBeVisible(); + await expect( + page.getByTestId("pdf-preview").getByText(/Sign in to open private source documents\.|Document not found\./), + ).toBeVisible({ timeout: 30000 }); + await expect(page.getByRole("heading", { level: 1, name: /Sign in required|Source unavailable/ })).toBeVisible(); await expect(page.getByRole("button", { name: "Summarise document" })).toBeDisabled(); await expect(page.locator("body")).not.toContainText("loading source"); await expect(page.locator("body")).not.toContainText("Loading source metadata"); diff --git a/tests/ui-stress.spec.ts b/tests/ui-stress.spec.ts index 939cc399e..3befb65f9 100644 --- a/tests/ui-stress.spec.ts +++ b/tests/ui-stress.spec.ts @@ -198,14 +198,17 @@ test.describe("Clinical KB long-content stress coverage", () => { await page.goto("/", { waitUntil: "domcontentloaded" }); await expect( - page.getByText(viewport.name === "mobile" ? "24 documents" : "24 indexed documents available"), + page + .locator("#sources") + .getByText(viewport.name === "mobile" ? "24 documents" : "24 indexed documents available") + .first(), ).toBeVisible(); await expectNoPageHorizontalOverflow(page); await page - .getByLabel("Ask a question across indexed guidelines") + .getByLabel("Search indexed guidelines by question or keyword") .fill("Show all stress citations and source cards"); - await page.getByRole("button", { name: /Ask|Answer/ }).click(); + await page.getByRole("button", { name: "Generate source-backed answer" }).click(); await expect(page.getByLabel("Source-backed answer")).toBeVisible(); await expect(page.getByText("10 exact quotes")).toBeVisible(); From 4b35f8267c84105088d92573f8c2ddb0a2fc0537 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 4 Jun 2026 03:15:53 +0800 Subject: [PATCH 4/4] Remove unused starter public assets --- public/file.svg | 1 - public/globe.svg | 1 - public/next.svg | 1 - public/vercel.svg | 1 - public/window.svg | 1 - 5 files changed, 5 deletions(-) delete mode 100644 public/file.svg delete mode 100644 public/globe.svg delete mode 100644 public/next.svg delete mode 100644 public/vercel.svg delete mode 100644 public/window.svg diff --git a/public/file.svg b/public/file.svg deleted file mode 100644 index 004145cdd..000000000 --- a/public/file.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/globe.svg b/public/globe.svg deleted file mode 100644 index 567f17b0d..000000000 --- a/public/globe.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/next.svg b/public/next.svg deleted file mode 100644 index 5174b28c5..000000000 --- a/public/next.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/vercel.svg b/public/vercel.svg deleted file mode 100644 index 770539603..000000000 --- a/public/vercel.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/window.svg b/public/window.svg deleted file mode 100644 index b2b2a44f6..000000000 --- a/public/window.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file