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",
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
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)");
+ });
+});
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();