From b45df727b29aad8ba4ec5d4e96d1f0599d7dad8a Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 11 Jul 2026 02:28:34 +0800 Subject: [PATCH 1/3] refactor: harden RAG and dashboard boundaries --- docs/branch-review-ledger.md | 2 + docs/codebase-index.md | 30 +- docs/deployment-architecture.md | 15 +- scripts/deployment-boot-smoke.mjs | 2 +- scripts/dev-free-port.mjs | 2 +- scripts/ensure-local-server.mjs | 2 +- scripts/guard-next-build.mjs | 2 +- scripts/playwright-base-url.ts | 2 +- scripts/run-playwright.mjs | 2 +- src/components/ClinicalDashboard.tsx | 59 ++- src/components/DocumentViewer.tsx | 2 +- .../clinical-output-helpers.tsx | 264 +++++++++++ .../clinical-dashboard/dashboard-contracts.ts | 23 + .../clinical-dashboard/dashboard-nav.tsx | 12 +- .../clinical-dashboard/document-admin.tsx | 18 +- .../clinical-dashboard/evidence-panels.tsx | 260 +---------- .../global-mockup-search-shell.tsx | 103 +++-- .../clinical-dashboard/output-panel.tsx | 2 +- src/lib/bounded-ttl-cache.ts | 33 ++ src/lib/client-env.ts | 8 + src/lib/local-project-guard.ts | 2 +- {scripts => src/lib}/local-server-utils.mjs | 0 src/lib/rag-answer-support.ts | 214 +++++++++ src/lib/rag-cache.ts | 15 +- src/lib/rag-contracts.ts | 105 +++++ src/lib/rag-extractive-answer.ts | 2 +- src/lib/rag-query-guard.ts | 34 ++ src/lib/rag-retrieval-variants.ts | 16 +- src/lib/rag.ts | 414 ++---------------- tests/architecture-boundaries.test.ts | 233 ++++++++++ tests/bounded-ttl-cache.test.ts | 34 ++ 31 files changed, 1168 insertions(+), 744 deletions(-) create mode 100644 src/components/clinical-dashboard/clinical-output-helpers.tsx create mode 100644 src/components/clinical-dashboard/dashboard-contracts.ts create mode 100644 src/lib/bounded-ttl-cache.ts create mode 100644 src/lib/client-env.ts rename {scripts => src/lib}/local-server-utils.mjs (100%) create mode 100644 src/lib/rag-answer-support.ts create mode 100644 src/lib/rag-contracts.ts create mode 100644 src/lib/rag-query-guard.ts create mode 100644 tests/architecture-boundaries.test.ts create mode 100644 tests/bounded-ttl-cache.test.ts diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index c9f5f5a27..819138604 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -26,3 +26,5 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD | 2026-07-10 | codex/review-autofix-flow | 155c801cd58f797037d8aaa8b885405a1c599249 | codex-autofix-flow | Fixed exact connector authorization, trusted-marker deduplication, and strict self-trigger matching; added regression coverage. | `npm run check:codex-autofix-workflow`; focused Vitest (4 passed); `npm run verify:cheap` pre-test stages passed before tool timeout; `npm test` (1,415 passed, 1 skipped); focused Prettier check; `npm run check:github-actions` | | 2026-07-10 | codex/review-autofix-flow | 155c801cd58f797037d8aaa8b885405a1c599249 | codex-autofix-flow-followup | Fixed untrusted workflow-level concurrency interference and migrated the bridge from the Node 20 action runtime to `actions/github-script@v9`; added direct embedded-script execution coverage. | Focused Vitest (13 passed); targeted ESLint; `tsc --noEmit`; `npm run check:codex-autofix-workflow`; `npm run check:github-actions`; focused Prettier check; `git diff --check` | | 2026-07-10 | codex/review-autofix-flow | 155c801cd58f797037d8aaa8b885405a1c599249 | codex-autofix-residual-fixes | Replaced one-shot PR deduplication with a three-cycle head-SHA cap, made comment permission failures fail visibly, and pinned `github-script` v9.0.0 to its verified immutable commit. | TDD red run (7 expected failures); focused Vitest green run (15 passed); `npm run verify:cheap` (152 files passed, 1 skipped; 1,426 tests passed, 1 skipped); focused Prettier check; `git diff --check`; official `git ls-remote` tag verification | +| 2026-07-10 | codex/architecture-review-fixes | 648abfa3f7c91395b5eeca543f70e0b6ea59e9e0 | architecture-review | Seven findings fixed in the working tree: three runtime cycles, unbounded owner caches, a client/server env boundary breach, reversed runtime-to-scripts ownership, and architecture-doc drift. | `npm run test -- tests/architecture-boundaries.test.ts tests/bounded-ttl-cache.test.ts tests/rag-score.test.ts tests/rag-cache-utils.test.ts tests/rag-cache-invalidation.test.ts tests/evidence-panels.test.ts tests/clinical-dashboard-merge-artifacts.test.ts`; `npm run verify:cheap`; `npm run check:production-readiness:ci` | +| 2026-07-10 | codex/architecture-review-fixes | 648abfa3f7c91395b5eeca543f70e0b6ea59e9e0 | frontend-architecture-review | Shared cycle/env findings confirmed and fixed; three additional findings fixed for defeated lazy boundaries, duplicate shell/dashboard subscriptions, and unstable search-context values. | `npm run test -- tests/architecture-boundaries.test.ts tests/evidence-panels.test.ts tests/clinical-dashboard-merge-artifacts.test.ts`; `npm run verify:cheap`; UI gate deferred pending explicit local-API approval | diff --git a/docs/codebase-index.md b/docs/codebase-index.md index bc47dabb4..4bbbda55a 100644 --- a/docs/codebase-index.md +++ b/docs/codebase-index.md @@ -41,7 +41,7 @@ Structured map for AI agents and onboarding. For live routes, see `docs/site-map ### Shell and routing - **Root layout:** `src/app/layout.tsx` — fonts, `AuthProvider`, global CSS -- **App shell:** `src/app/app-shell-client.tsx` — `GlobalSearchShell` via `src/lib/shell-route-config.ts` +- **App shell:** `src/components/clinical-dashboard/global-search-shell.tsx` — route-aware standalone shell and lazy dashboard dispatch via `global-mockup-search-shell.tsx` - **Home:** `src/app/page.tsx` — dashboard rendered by shell - **Dashboard:** `src/components/ClinicalDashboard.tsx` + `src/components/clinical-dashboard/` - **Modes (8):** `src/lib/app-modes.ts` — answer, documents, services, forms, favourites, differentials, prescribing, tools @@ -81,14 +81,16 @@ Structured map for AI agents and onboarding. For live routes, see `docs/site-map ### RAG, retrieval, answers -| Module | Role | -| ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | -| `rag.ts` | Main answer pipeline orchestrator | -| `rag-routing.ts`, `rag-provider.ts`, `rag-answer-text.ts`, `smart-rag-api.ts` | Model routing, provider modes, API surface | -| `clinical-search.ts`, `clinical-query-mode.ts`, `retrieval-selection.ts` | Query modes and retrieval selection | -| `answer-ranking.ts`, `answer-verification.ts`, `answer-formatting.ts`, `answer-follow-up.ts`, `answer-render-policy.ts` | Answer quality and rendering | -| `citations.ts`, `cross-document-synthesis.ts`, `evidence-relevance.ts` | Evidence and synthesis | -| `ranking-config.ts`, `search-scope.ts`, `rag-eval-cases.ts` | Ranking tuning and eval fixtures | +| Module | Role | +| ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | +| `rag.ts` | Main answer pipeline orchestrator | +| `rag-routing.ts`, `rag-provider.ts`, `rag-answer-text.ts`, `smart-rag-api.ts` | Model routing, provider modes, API surface | +| `rag-contracts.ts`, `rag-answer-support.ts`, `rag-query-guard.ts` | Shared RAG contracts and pure answer/query policy | +| `rag-cache.ts`, `rag-retrieval-variants.ts` | Bounded caches and retrieval variants | +| `clinical-search.ts`, `clinical-query-mode.ts`, `retrieval-selection.ts` | Query modes and retrieval selection | +| `answer-ranking.ts`, `answer-verification.ts`, `answer-formatting.ts`, `answer-follow-up.ts`, `answer-render-policy.ts` | Answer quality and rendering | +| `citations.ts`, `cross-document-synthesis.ts`, `evidence-relevance.ts` | Evidence and synthesis | +| `ranking-config.ts`, `search-scope.ts`, `rag-eval-cases.ts` | Ranking tuning and eval fixtures | ### Ingestion and indexing @@ -127,11 +129,11 @@ Structured map for AI agents and onboarding. For live routes, see `docs/site-map ### Infra helpers -| Module | Role | -| ------------------------------------------------------------------------------- | ------------------------------------------------------------- | -| `openai.ts`, `embedding-dimensions.ts`, `api-rate-limit.ts` | External APIs and rate limits | -| `validation/` | `body.ts`, `query.ts`, `params.ts`, `http.ts`, `form-data.ts` | -| `shell-route-config.ts`, `document-flow-routes.ts`, `local-project-identity.ts` | Routing and project identity | +| Module | Role | +| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------- | +| `openai.ts`, `embedding-dimensions.ts`, `api-rate-limit.ts` | External APIs and rate limits | +| `validation/` | `body.ts`, `query.ts`, `params.ts`, `http.ts`, `form-data.ts` | +| `app-modes.ts`, `document-flow-routes.ts`, `local-project-identity.ts`, `local-server-utils.mjs` | Routing and project identity | --- diff --git a/docs/deployment-architecture.md b/docs/deployment-architecture.md index 3bb2ccbac..c3495a4eb 100644 --- a/docs/deployment-architecture.md +++ b/docs/deployment-architecture.md @@ -213,18 +213,19 @@ Rules: clinical production documents into staging. - One staging app container + one staging worker container from the _same_ images, different env. `RAG_PROVIDER_MODE=auto` with staging OpenAI key. -- **Known change required:** `src/lib/supabase/project.ts` pins the expected - project to production, so `check:supabase-project` will (correctly) fail on - staging until the expected-project table is made environment-aware. Do this - when the staging project is provisioned — a deliberate speed bump so staging - cannot silently be pointed at production. +- `src/lib/supabase/project.ts` is staging-aware only when both + `SUPABASE_STAGING_PROJECT_REF` and `SUPABASE_STAGING_PROJECT_NAME` are set. + The declared staging ref must differ from production and every stale project; + otherwise `check:supabase-project` fails closed. See `docs/staging-setup.md`. - The soak test (`scripts/soak-test.ts`) targets staging **only** — see `docs/capacity-review.md`. ## 6. Rollout and rollback -- Images are built from `main` (CI job to be added once a host account - exists), tagged with the git SHA, and deployed after the standard gates +- `.github/workflows/docker-image.yml` validates both container builds on + `main`, release branches, a weekly schedule, and container-affecting pull + requests. It deliberately does not push to a registry; registry publication + and deployment remain host-specific release steps after the standard gates (`verify` + `ui-smoke` + the clinical governance preflight where relevant). - Rollback = redeploy the previous image tag. Database migrations follow the existing rule: committed migrations + `schema.sql` reconciliation only, never diff --git a/scripts/deployment-boot-smoke.mjs b/scripts/deployment-boot-smoke.mjs index 9ce7b56f3..c53bf68ef 100644 --- a/scripts/deployment-boot-smoke.mjs +++ b/scripts/deployment-boot-smoke.mjs @@ -5,7 +5,7 @@ import { tmpdir } from "node:os"; import { resolve } from "node:path"; import { setTimeout as delay } from "node:timers/promises"; -import { appName, localProjectId } from "./local-server-utils.mjs"; +import { appName, localProjectId } from "../src/lib/local-server-utils.mjs"; function parsePositiveInt(name, fallback) { const rawValue = process.env[name]; diff --git a/scripts/dev-free-port.mjs b/scripts/dev-free-port.mjs index fc635ad0d..6075bd9fa 100644 --- a/scripts/dev-free-port.mjs +++ b/scripts/dev-free-port.mjs @@ -3,7 +3,7 @@ import { spawn } from "node:child_process"; import net from "node:net"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { appName, stableProjectPort } from "./local-server-utils.mjs"; +import { appName, stableProjectPort } from "../src/lib/local-server-utils.mjs"; if (Number(process.versions.node.split(".")[0]) !== 24) { console.error(`Clinical KB local server requires Node 24.x. Current runtime: ${process.versions.node}.`); diff --git a/scripts/ensure-local-server.mjs b/scripts/ensure-local-server.mjs index a5daa0ace..67cd699b3 100644 --- a/scripts/ensure-local-server.mjs +++ b/scripts/ensure-local-server.mjs @@ -5,7 +5,7 @@ import http from "node:http"; import net from "node:net"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { appName, localProjectId, projectPortEnd, stableProjectPort } from "./local-server-utils.mjs"; +import { appName, localProjectId, projectPortEnd, stableProjectPort } from "../src/lib/local-server-utils.mjs"; if (Number(process.versions.node.split(".")[0]) !== 24) { console.error(`Clinical KB local server requires Node 24.x. Current runtime: ${process.versions.node}.`); diff --git a/scripts/guard-next-build.mjs b/scripts/guard-next-build.mjs index 6006235ee..618c782f6 100644 --- a/scripts/guard-next-build.mjs +++ b/scripts/guard-next-build.mjs @@ -2,7 +2,7 @@ import http from "node:http"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { appName, localProjectId, projectPortEnd, stableProjectPort } from "./local-server-utils.mjs"; +import { appName, localProjectId, projectPortEnd, stableProjectPort } from "../src/lib/local-server-utils.mjs"; const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const expectedProjectId = localProjectId(projectRoot); diff --git a/scripts/playwright-base-url.ts b/scripts/playwright-base-url.ts index 143556905..391733dc2 100644 --- a/scripts/playwright-base-url.ts +++ b/scripts/playwright-base-url.ts @@ -1,6 +1,6 @@ import { execFileSync, spawnSync } from "node:child_process"; import path from "node:path"; -import { appName, localProjectId, stableProjectPort } from "./local-server-utils.mjs"; +import { appName, localProjectId, stableProjectPort } from "../src/lib/local-server-utils.mjs"; const projectRoot = path.resolve(__dirname, ".."); const ensureScript = path.join(projectRoot, "scripts", "ensure-local-server.mjs"); diff --git a/scripts/run-playwright.mjs b/scripts/run-playwright.mjs index 99e6013e8..59941e7e0 100644 --- a/scripts/run-playwright.mjs +++ b/scripts/run-playwright.mjs @@ -4,7 +4,7 @@ import http from "node:http"; import net from "node:net"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { appName, localProjectId, projectPortEnd, stableProjectPort } from "./local-server-utils.mjs"; +import { appName, localProjectId, projectPortEnd, stableProjectPort } from "../src/lib/local-server-utils.mjs"; if (Number(process.versions.node.split(".")[0]) !== 24) { console.error(`Clinical KB Playwright checks require Node 24.x. Current runtime: ${process.versions.node}.`); diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 67758856f..aab12a9d0 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -26,9 +26,9 @@ import { import { type CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { type DocumentDeleteResult } from "@/components/DocumentManagementActions"; import { extractSafetyFindings } from "@/lib/clinical-safety"; +import { isLocalNoAuthMode, publicUploadsEnabled } from "@/lib/client-env"; import { readLocalProjectIdentity, unsafeLocalProjectMessage } from "@/lib/local-project-identity"; import { isDeployedClinicalKb } from "@/lib/deployed-app"; -import { isLocalNoAuthMode, publicUploadsEnabled } from "@/lib/env"; import { appBackdrop, answerSurface, @@ -78,13 +78,14 @@ import { MasterSearchHeader } from "@/components/clinical-dashboard/master-searc import { useScrollHideReporter } from "@/components/clinical-dashboard/use-hide-on-scroll"; import { SearchCommandProvider } from "@/components/clinical-dashboard/search-command-context"; import { answerRecovery, errorCopy } from "@/lib/ui-copy"; -import { applicationsLauncherItemCount } from "@/components/applications-launcher-page"; import { - DrawerGroupLabel, type DocumentDrawerMode, type DocumentDrawerStatusFilter, + type DocumentPagination, type LabelReviewMutationBody, -} from "@/components/clinical-dashboard/document-admin"; + navigationHashes, + recentQueryStorageKey, +} from "@/components/clinical-dashboard/dashboard-contracts"; const DifferentialsHome = dynamic( () => import("@/components/clinical-dashboard/differentials-home").then((m) => m.DifferentialsHome), @@ -101,10 +102,6 @@ const MedicationPrescribingWorkspace = dynamic( ), { ssr: false }, ); -export const ApplicationsLauncherWorkspace = dynamic( - () => import("@/components/applications-launcher-page").then((m) => m.ApplicationsLauncherWorkspace), - { ssr: false }, -); const DocumentDrawer = dynamic( () => import("@/components/clinical-dashboard/document-admin").then((m) => m.DocumentDrawer), { ssr: false }, @@ -191,13 +188,9 @@ import type { } from "@/lib/types"; import type { SearchScopeFilters } from "@/lib/search-scope"; import { differentialsMobileCompareAddonSlotId, modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; +import { toolCatalogRecords } from "@/lib/tools-catalog"; import { createQuoteFollowUp, type AnswerViewMode, shouldPollForUpdates } from "@/lib/ward-output"; -export const navigationHashes = ["#search", "#quotes", "#images", "#sources"] as const; -export const mobileSectionFabMediaQuery = - "(max-width: 768px), ((max-width: 1023px) and (hover: none) and (pointer: coarse))"; - -export const recentQueryStorageKey = "clinical-kb-recent-queries"; const documentPageSize = 150; const activeIndexingPollFallbackMs = 5_000; const setupRecheckPollMs = 60_000; @@ -205,13 +198,6 @@ const indexingWorkDetailsPollMs = 15_000; const stagedDashboardExtraction = { answerSurface: true, } as const; -export type DocumentPagination = { - limit: number; - offset: number; - total: number; - nextOffset: number; - hasMore: boolean; -}; type RefreshOptions = { includeSetup?: boolean; includeDashboardData?: boolean; @@ -885,6 +871,21 @@ export function ClinicalDashboard({ const [indexingMonitorFilter, setIndexingMonitorFilter] = useState("all"); const [recentQueries, setRecentQueries] = useState([]); const [commandScopes, setCommandScopes] = useState([]); + const removeCommandScope = useCallback( + (scopeId: string) => setCommandScopes((current) => current.filter((scope) => scope !== scopeId)), + [], + ); + const clearCommandScopes = useCallback(() => setCommandScopes([]), []); + const searchCommandContextValue = useMemo( + () => ({ + query, + modeId: searchMode, + commandScopes, + onRemoveScope: removeCommandScope, + onClearScopes: clearCommandScopes, + }), + [query, searchMode, commandScopes, removeCommandScope, clearCommandScopes], + ); const [indexingActionId, setIndexingActionId] = useState(null); const [indexingActive, setIndexingActive] = useState(false); const [nextRefreshDelayMs, setNextRefreshDelayMs] = useState(null); @@ -2830,7 +2831,7 @@ export function ClinicalDashboard({ href: "#search", count: activeModeResultKind === "tools" - ? applicationsLauncherItemCount + ? toolCatalogRecords.length : activeModeResultKind === "favourites" ? null : activeModeResultKind === "documents" @@ -3185,15 +3186,7 @@ export function ClinicalDashboard({ )} >

Clinical Guide

- setCommandScopes((current) => current.filter((scope) => scope !== scopeId)), - onClearScopes: () => setCommandScopes([]), - }} - > +
); } + +function DrawerGroupLabel({ title }: { title: string }) { + return ( +

{title}

+ ); +} diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index d7ab5ef23..d996899da 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -68,7 +68,7 @@ import { clearCachedSignedUrl, getCachedSignedUrl, setCachedSignedUrl } from "@/ import { readLocalProjectIdentity, unsafeLocalProjectMessage } from "@/lib/local-project-identity"; import { formatClinicalDate } from "@/lib/source-metadata"; import { partitionViewerImages } from "@/lib/image-filtering"; -import { isLocalNoAuthMode } from "@/lib/env"; +import { isLocalNoAuthMode } from "@/lib/client-env"; import { useAuthSession } from "@/lib/supabase/client"; import { SafeBoldText } from "@/components/SafeBoldText"; import { DocumentManagementActions } from "@/components/DocumentManagementActions"; diff --git a/src/components/clinical-dashboard/clinical-output-helpers.tsx b/src/components/clinical-dashboard/clinical-output-helpers.tsx new file mode 100644 index 000000000..c9d784fb2 --- /dev/null +++ b/src/components/clinical-dashboard/clinical-output-helpers.tsx @@ -0,0 +1,264 @@ +import Link from "next/link"; +import { + BookOpen, + ClipboardCheck, + ExternalLink, + FileText, + ListChecks, + Search, + ShieldAlert, + Target, +} from "lucide-react"; + +import { AccessibleTable } from "@/components/AccessibleTable"; +import { comparableAnswerText } from "@/components/clinical-dashboard/display-text"; +import { + chatMicroAction, + cn, + EmptyState, + sourceCard, + textMuted, + toneDanger, + toneInfo, + toneNeutral, + toneSuccess, + toneWarning, +} from "@/components/ui-primitives"; +import { emptyStates } from "@/lib/ui-copy"; +import { type AnswerEvidenceMapRow, type AnswerViewMode, buildClinicalOutputSections } from "@/lib/ward-output"; + +type ClinicalDetailSection = ReturnType[number]; + +function isRedundantStructuredItem(item: string, primaryAnswer: string) { + const itemText = comparableAnswerText(item); + const answerText = comparableAnswerText(primaryAnswer); + if (!itemText || !answerText) return false; + if (answerText.includes(itemText) || itemText.includes(answerText)) return true; + if (itemText.length < 40) return false; + const answerWords = new Set(answerText.split(" ").filter((word) => word.length > 3)); + const itemWords = itemText.split(" ").filter((word) => word.length > 3); + if (itemWords.length < 6) return false; + const sharedWords = itemWords.filter((word) => answerWords.has(word)).length; + if (sharedWords / itemWords.length >= 0.82) return true; + return answerText.includes(itemText.slice(0, Math.min(160, itemText.length))); +} + +export function displayItemsForClinicalDetailSection( + section: ClinicalDetailSection, + primaryAnswer: string, + showLead: boolean, +) { + if (showLead) return section.items; + const nonRedundantItems = section.items.filter((item) => !isRedundantStructuredItem(item, primaryAnswer)); + return nonRedundantItems.length > 0 || section.items.length === 0 ? nonRedundantItems : section.items; +} + +const clinicalDetailPriority: Record = { + action: 10, + escalation: 20, + thresholds: 30, + cautions: 40, + monitoring: 50, + medication: 60, + documentation: 70, + comparison: 80, + "support-map": 90, + "source-gap": 100, +}; + +export function clinicalDetailContentCount(section: ClinicalDetailSection) { + if (section.items.length > 0) return section.items.length; + const tableRows = + section.tables?.reduce((total, table) => total + (table.rows?.length ?? (table.markdown ? 1 : 0)), 0) ?? 0; + return tableRows || section.tables?.length || 0; +} + +export function sortClinicalDetailSections(sections: ClinicalDetailSection[]) { + return [...sections].sort((left, right) => { + const leftPriority = clinicalDetailPriority[left.id] ?? 75; + const rightPriority = clinicalDetailPriority[right.id] ?? 75; + if (leftPriority !== rightPriority) return leftPriority - rightPriority; + return left.title.localeCompare(right.title); + }); +} + +export function clinicalDetailMeta(section: ClinicalDetailSection): { + icon: typeof Search; + eyebrow: string; + toneClassName: string; + accentClassName: string; +} { + if (section.id === "thresholds") { + return { + icon: Target, + eyebrow: "Thresholds", + toneClassName: toneWarning, + accentClassName: "bg-[color:var(--warning)]", + }; + } + if (section.id === "escalation" || section.id === "cautions" || section.id === "source-gap") { + return { + icon: ShieldAlert, + eyebrow: section.id === "source-gap" ? "Source gap" : "Risk", + toneClassName: toneDanger, + accentClassName: "bg-[color:var(--danger)]", + }; + } + if (section.id === "monitoring" || section.id === "medication") { + return { + icon: ClipboardCheck, + eyebrow: section.id === "monitoring" ? "Monitoring" : "Medication", + toneClassName: toneWarning, + accentClassName: "bg-[color:var(--warning)]", + }; + } + if (section.id === "support-map" || section.id === "comparison") { + return { + icon: BookOpen, + eyebrow: section.id === "support-map" ? "Evidence support" : "Comparison", + toneClassName: toneInfo, + accentClassName: "bg-[color:var(--info)]", + }; + } + if (section.id === "documentation") { + return { + icon: FileText, + eyebrow: "Documentation", + toneClassName: toneNeutral, + accentClassName: "bg-[color:var(--border-strong)]", + }; + } + return { + icon: ListChecks, + eyebrow: "Clinical action", + toneClassName: toneSuccess, + accentClassName: "bg-[color:var(--success)]", + }; +} + +export function clinicalDetailSummaryItems(sections: ClinicalDetailSection[]) { + const countById = (ids: string[]) => + sections + .filter((section) => ids.includes(section.id)) + .reduce((total, section) => total + clinicalDetailContentCount(section), 0); + const tableCount = sections.reduce((total, section) => total + (section.tables?.length ?? 0), 0); + const items = [ + { label: "Actions", value: countById(["action", "escalation", "documentation"]) }, + { label: "Monitoring", value: countById(["monitoring", "medication"]) }, + { label: "Tables", value: tableCount }, + { label: "Cautions", value: countById(["cautions", "source-gap"]) }, + { label: "Evidence", value: countById(["support-map", "comparison"]) }, + ]; + return items.filter((item) => item.value > 0); +} + +export function AnswerViewModeControl({ + value, + onChange, +}: { + value: AnswerViewMode; + onChange: (mode: AnswerViewMode) => void; +}) { + const modes: Array<{ value: AnswerViewMode; label: string; shortLabel: string; icon: typeof Search }> = [ + { value: "standard", label: "Standard", shortLabel: "All", icon: ListChecks }, + { value: "high_yield", label: "High-yield", shortLabel: "Key", icon: Target }, + { value: "evidence_map", label: "Evidence map", shortLabel: "Map", icon: BookOpen }, + ]; + + return ( +
+ {modes.map((mode) => { + const Icon = mode.icon; + const active = value === mode.value; + return ( + + ); + })} +
+ ); +} + +export const simpleClinicalTableProps = { + compact: false, + expandOnMobile: true, +} as const; + +function compactEvidenceCell(value: string | null | undefined, max = 140) { + const text = value ? value.replace(/\s+/g, " ").trim() : ""; + return text.length > max ? `${text.slice(0, max - 1).trim()}…` : text; +} + +export function EvidenceMapTable({ rows }: { rows: AnswerEvidenceMapRow[] }) { + if (rows.length === 0) { + return ; + } + + const tableRows = rows.map((row) => [ + compactEvidenceCell(row.section), + row.supportLevel, + String(row.citationCount), + compactEvidenceCell(row.sourceStatus), + compactEvidenceCell(row.bestSourceLabel, 72), + row.bestLinkedPassage || "Open source passage.", + ]); + const linkedRows = rows.filter((row) => row.href); + + return ( +
+ + {linkedRows.length ? ( +
+ {linkedRows.map((row) => ( + + + {row.section} + {row.bestSourceLabel} + + + Open source + + + + ))} +
+ ) : null} +
+ ); +} diff --git a/src/components/clinical-dashboard/dashboard-contracts.ts b/src/components/clinical-dashboard/dashboard-contracts.ts new file mode 100644 index 000000000..2416ee0d3 --- /dev/null +++ b/src/components/clinical-dashboard/dashboard-contracts.ts @@ -0,0 +1,23 @@ +import type { DocumentLabelType } from "@/lib/types"; + +export const navigationHashes = ["#search", "#quotes", "#images", "#sources"] as const; + +export const mobileSectionFabMediaQuery = + "(max-width: 768px), ((max-width: 1023px) and (hover: none) and (pointer: coarse))"; + +export const recentQueryStorageKey = "clinical-kb-recent-queries"; + +export type DocumentPagination = { + limit: number; + offset: number; + total: number; + nextOffset: number; + hasMore: boolean; +}; + +export type DocumentDrawerMode = "recent" | "library" | "source" | "admin"; + +export type DocumentDrawerStatusFilter = "all" | "indexed" | "indexing" | "failed"; + +export type LabelReviewMutationBody = + { labelId: string; action: "approve" | "hide" | "restore" } | { label: string; label_type: DocumentLabelType }; diff --git a/src/components/clinical-dashboard/dashboard-nav.tsx b/src/components/clinical-dashboard/dashboard-nav.tsx index 56b011478..6778d2c8a 100644 --- a/src/components/clinical-dashboard/dashboard-nav.tsx +++ b/src/components/clinical-dashboard/dashboard-nav.tsx @@ -1,17 +1,19 @@ "use client"; +import dynamic from "next/dynamic"; import { useCallback, useEffect, useRef, useState } from "react"; import { FileText, X } from "lucide-react"; -import { - ApplicationsLauncherWorkspace, - mobileSectionFabMediaQuery, - navigationHashes, -} from "@/components/ClinicalDashboard"; +import { mobileSectionFabMediaQuery, navigationHashes } from "@/components/clinical-dashboard/dashboard-contracts"; import { cn } from "@/components/ui-primitives"; import { useDismissableLayer } from "@/components/use-dismissable-layer"; import { type AppModeId, appModeSearchConfig } from "@/lib/app-modes"; +const ApplicationsLauncherWorkspace = dynamic( + () => import("@/components/applications-launcher-page").then((module) => module.ApplicationsLauncherWorkspace), + { ssr: false }, +); + export function ToolsHub({ query, desktopComposerSlotId }: { query: string; desktopComposerSlotId?: string }) { return ; } diff --git a/src/components/clinical-dashboard/document-admin.tsx b/src/components/clinical-dashboard/document-admin.tsx index 1ea29e5e2..40285f732 100644 --- a/src/components/clinical-dashboard/document-admin.tsx +++ b/src/components/clinical-dashboard/document-admin.tsx @@ -16,7 +16,6 @@ import { Tag, } from "lucide-react"; -import { type DocumentPagination } from "@/components/ClinicalDashboard"; import { DocumentManagementActions, type DocumentDeleteResult } from "@/components/DocumentManagementActions"; import { DocumentOrganizationBadges, @@ -26,6 +25,12 @@ import { import { DocumentTagCloud } from "@/components/DocumentTagCloud"; import { SafeBoldText } from "@/components/SafeBoldText"; import { StatusBadge } from "@/components/clinical-dashboard/badges"; +import type { + DocumentDrawerMode, + DocumentDrawerStatusFilter, + DocumentPagination, + LabelReviewMutationBody, +} from "@/components/clinical-dashboard/dashboard-contracts"; import { cn, EmptyState, @@ -123,9 +128,6 @@ function labelTypeDisplay(value: DocumentLabelType) { return documentLabelTypeOptions.find((option) => option.value === value)?.label ?? value.replaceAll("_", " "); } -export type LabelReviewMutationBody = - { labelId: string; action: "approve" | "hide" | "restore" } | { label: string; label_type: DocumentLabelType }; - function DocumentLabelReviewPanel({ documents, canManage, @@ -1092,8 +1094,6 @@ export function DocumentDrawer({ } export type LibraryHealthTarget = "documents" | "setup" | "indexing" | "failures"; -export type DocumentDrawerMode = "recent" | "library" | "source" | "admin"; -export type DocumentDrawerStatusFilter = "all" | "indexed" | "indexing" | "failed"; export type IndexingMonitorFilter = "all" | "active" | "failed"; export type UploadIndexingTab = "setup" | "upload" | "jobs" | "quality"; @@ -1110,9 +1110,3 @@ function statusFilterLabel(filter: DocumentDrawerStatusFilter) { if (filter === "failed") return "Failed documents"; return "All documents"; } - -export function DrawerGroupLabel({ title }: { title: string }) { - return ( -

{title}

- ); -} diff --git a/src/components/clinical-dashboard/evidence-panels.tsx b/src/components/clinical-dashboard/evidence-panels.tsx index 77e182f41..f930d10cd 100644 --- a/src/components/clinical-dashboard/evidence-panels.tsx +++ b/src/components/clinical-dashboard/evidence-panels.tsx @@ -5,7 +5,6 @@ import { type RefObject, useId, useState } from "react"; import { Activity, AlertCircle, - BookOpen, CheckCircle2, ChevronDown, ClipboardCheck, @@ -14,7 +13,6 @@ import { FileText, Filter, Layers, - ListChecks, Loader2, Plus, Quote, @@ -26,7 +24,6 @@ import { Target, } from "lucide-react"; -import { AccessibleTable } from "@/components/AccessibleTable"; import { type AnswerFeedbackType } from "@/lib/answer-feedback"; import { ClinicalOutputPanel } from "@/components/clinical-dashboard/output-panel"; import { @@ -37,12 +34,13 @@ import { } from "@/components/clinical-dashboard/answer-content"; import { CopyButton } from "@/components/clinical-dashboard/answer-status"; import { StrengthBadge } from "@/components/clinical-dashboard/badges"; -import { SectionHeading } from "@/components/clinical-dashboard/dashboard-shell"; import { - cleanDisplayTitle, - compactSourceSnippet, - comparableAnswerText, -} from "@/components/clinical-dashboard/display-text"; + displayItemsForClinicalDetailSection, + EvidenceMapTable, + sortClinicalDetailSections, +} from "@/components/clinical-dashboard/clinical-output-helpers"; +import { SectionHeading } from "@/components/clinical-dashboard/dashboard-shell"; +import { cleanDisplayTitle, compactSourceSnippet } from "@/components/clinical-dashboard/display-text"; import { SourceActionRow } from "@/components/clinical-dashboard/source-actions"; import { chatMicroAction, @@ -59,7 +57,6 @@ import { tableMicroActionRow, textMuted, toneDanger, - toneInfo, toneNeutral, toneSuccess, toneWarning, @@ -92,6 +89,17 @@ import { buildHighYieldClinicalOutputSections, } from "@/lib/ward-output"; +export { + AnswerViewModeControl, + clinicalDetailContentCount, + clinicalDetailMeta, + clinicalDetailSummaryItems, + displayItemsForClinicalDetailSection, + EvidenceMapTable, + simpleClinicalTableProps, + sortClinicalDetailSections, +} from "@/components/clinical-dashboard/clinical-output-helpers"; + type AnswerSupportPriority = { title: string; detail: string; @@ -282,131 +290,8 @@ export function AnswerSupportSummaryCard({ ); } -function isRedundantStructuredItem(item: string, primaryAnswer: string) { - const itemText = comparableAnswerText(item); - const answerText = comparableAnswerText(primaryAnswer); - if (!itemText || !answerText) return false; - if (answerText.includes(itemText) || itemText.includes(answerText)) return true; - if (itemText.length < 40) return false; - const answerWords = new Set(answerText.split(" ").filter((word) => word.length > 3)); - const itemWords = itemText.split(" ").filter((word) => word.length > 3); - if (itemWords.length < 6) return false; - const sharedWords = itemWords.filter((word) => answerWords.has(word)).length; - if (sharedWords / itemWords.length >= 0.82) return true; - return answerText.includes(itemText.slice(0, Math.min(160, itemText.length))); -} - type ClinicalDetailSection = ReturnType[number]; -export function displayItemsForClinicalDetailSection( - section: ClinicalDetailSection, - primaryAnswer: string, - showLead: boolean, -) { - if (showLead) return section.items; - const nonRedundantItems = section.items.filter((item) => !isRedundantStructuredItem(item, primaryAnswer)); - return nonRedundantItems.length > 0 || section.items.length === 0 ? nonRedundantItems : section.items; -} - -const clinicalDetailPriority: Record = { - action: 10, - escalation: 20, - thresholds: 30, - cautions: 40, - monitoring: 50, - medication: 60, - documentation: 70, - comparison: 80, - "support-map": 90, - "source-gap": 100, -}; - -export function clinicalDetailContentCount(section: ClinicalDetailSection) { - if (section.items.length > 0) return section.items.length; - const tableRows = - section.tables?.reduce((total, table) => total + (table.rows?.length ?? (table.markdown ? 1 : 0)), 0) ?? 0; - return tableRows || section.tables?.length || 0; -} - -export function sortClinicalDetailSections(sections: ClinicalDetailSection[]) { - return [...sections].sort((left, right) => { - const leftPriority = clinicalDetailPriority[left.id] ?? 75; - const rightPriority = clinicalDetailPriority[right.id] ?? 75; - if (leftPriority !== rightPriority) return leftPriority - rightPriority; - return left.title.localeCompare(right.title); - }); -} - -export function clinicalDetailMeta(section: ClinicalDetailSection): { - icon: typeof Search; - eyebrow: string; - toneClassName: string; - accentClassName: string; -} { - if (section.id === "thresholds") { - return { - icon: Target, - eyebrow: "Thresholds", - toneClassName: toneWarning, - accentClassName: "bg-[color:var(--warning)]", - }; - } - if (section.id === "escalation" || section.id === "cautions" || section.id === "source-gap") { - return { - icon: ShieldAlert, - eyebrow: section.id === "source-gap" ? "Source gap" : "Risk", - toneClassName: toneDanger, - accentClassName: "bg-[color:var(--danger)]", - }; - } - if (section.id === "monitoring" || section.id === "medication") { - return { - icon: ClipboardCheck, - eyebrow: section.id === "monitoring" ? "Monitoring" : "Medication", - toneClassName: toneWarning, - accentClassName: "bg-[color:var(--warning)]", - }; - } - if (section.id === "support-map" || section.id === "comparison") { - return { - icon: BookOpen, - eyebrow: section.id === "support-map" ? "Evidence support" : "Comparison", - toneClassName: toneInfo, - accentClassName: "bg-[color:var(--info)]", - }; - } - if (section.id === "documentation") { - return { - icon: FileText, - eyebrow: "Documentation", - toneClassName: toneNeutral, - accentClassName: "bg-[color:var(--border-strong)]", - }; - } - return { - icon: ListChecks, - eyebrow: "Clinical action", - toneClassName: toneSuccess, - accentClassName: "bg-[color:var(--success)]", - }; -} - -export function clinicalDetailSummaryItems(sections: ClinicalDetailSection[]) { - const countById = (ids: string[]) => - sections - .filter((section) => ids.includes(section.id)) - .reduce((total, section) => total + clinicalDetailContentCount(section), 0); - const tableCount = sections.reduce((total, section) => total + (section.tables?.length ?? 0), 0); - const items = [ - { label: "Actions", value: countById(["action", "escalation", "documentation"]) }, - { label: "Monitoring", value: countById(["monitoring", "medication"]) }, - { label: "Tables", value: tableCount }, - { label: "Cautions", value: countById(["cautions", "source-gap"]) }, - { label: "Evidence", value: countById(["support-map", "comparison"]) }, - ]; - return items.filter((item) => item.value > 0); -} - type ClinicalNotesTabId = "essentials" | "actions" | "safety"; type ClinicalNotesRow = { @@ -1269,121 +1154,10 @@ export function VerificationWorkspace({ ); } -export function AnswerViewModeControl({ - value, - onChange, -}: { - value: AnswerViewMode; - onChange: (mode: AnswerViewMode) => void; -}) { - const modes: Array<{ value: AnswerViewMode; label: string; shortLabel: string; icon: typeof Search }> = [ - { value: "standard", label: "Standard", shortLabel: "All", icon: ListChecks }, - { value: "high_yield", label: "High-yield", shortLabel: "Key", icon: Target }, - { value: "evidence_map", label: "Evidence map", shortLabel: "Map", icon: BookOpen }, - ]; - - return ( -
- {modes.map((mode) => { - const Icon = mode.icon; - const active = value === mode.value; - return ( - - ); - })} -
- ); -} - -export const simpleClinicalTableProps = { - compact: false, - expandOnMobile: true, -} as const; - -function compactEvidenceCell(value: string | null | undefined, max = 140) { - const text = value ? value.replace(/\s+/g, " ").trim() : ""; - return text.length > max ? `${text.slice(0, max - 1).trim()}…` : text; -} - // Moved to a light module so the dashboard can import it without pulling this heavy component // tree into the initial bundle; re-exported here to keep evidence-panels' public API stable. export { evidenceMapRowsFromRenderModel } from "@/components/clinical-dashboard/evidence-map-model"; -export function EvidenceMapTable({ rows }: { rows: AnswerEvidenceMapRow[] }) { - if (rows.length === 0) { - return ; - } - - const tableRows = rows.map((row) => [ - compactEvidenceCell(row.section), - row.supportLevel, - String(row.citationCount), - compactEvidenceCell(row.sourceStatus), - compactEvidenceCell(row.bestSourceLabel, 72), - row.bestLinkedPassage || "Open source passage.", - ]); - const linkedRows = rows.filter((row) => row.href); - - return ( -
- - {linkedRows.length ? ( -
- {linkedRows.map((row) => ( - - - {row.section} - {row.bestSourceLabel} - - - Open source - - - - ))} -
- ) : null} -
- ); -} - export function AnswerSafetyNotice({ demoMode, weakEvidence = false, diff --git a/src/components/clinical-dashboard/global-mockup-search-shell.tsx b/src/components/clinical-dashboard/global-mockup-search-shell.tsx index 7b4f5c5fb..cc841aece 100644 --- a/src/components/clinical-dashboard/global-mockup-search-shell.tsx +++ b/src/components/clinical-dashboard/global-mockup-search-shell.tsx @@ -1,20 +1,21 @@ "use client"; +import dynamic from "next/dynamic"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { Suspense, type CSSProperties, type ReactNode, type UIEvent, + useCallback, useEffect, useMemo, useRef, useState, } from "react"; -import { ClinicalDashboard } from "@/components/clinical-dashboard"; import { AccountSetupDialog } from "@/components/clinical-dashboard/account-setup-dialog"; -import { recentQueryStorageKey } from "@/components/ClinicalDashboard"; +import { recentQueryStorageKey } from "@/components/clinical-dashboard/dashboard-contracts"; import { SearchCommandProvider } from "@/components/clinical-dashboard/search-command-context"; import { SettingsDialog } from "@/components/clinical-dashboard/settings-dialog"; import { @@ -43,6 +44,11 @@ import type { SearchScopeFilters } from "@/lib/search-scope"; import { useAuthSession } from "@/lib/supabase/client"; import type { ClinicalQueryMode } from "@/lib/types"; +const ClinicalDashboard = dynamic( + () => import("@/components/ClinicalDashboard").then((module) => module.ClinicalDashboard), + { ssr: false, loading: () => }, +); + const mockupQueryModeOptions: Array<{ value: ClinicalQueryMode; label: string }> = [ { value: "auto", label: "Auto" }, { value: "monitoring_schedule", label: "Monitoring" }, @@ -91,7 +97,53 @@ export function GlobalMockupSearchShell(props: GlobalMockupSearchShellProps) { ); } -function GlobalMockupSearchShellClient({ +function GlobalMockupSearchShellClient(props: GlobalMockupSearchShellProps) { + const pathname = usePathname(); + const searchParams = useSearchParams(); + const initialMode = props.initialMode ?? "answer"; + const visibleShellModes = visibleAppModeDefinitions().filter( + (mode) => !props.availableModeIds?.length || props.availableModeIds.includes(mode.id), + ); + const fallbackMode = visibleShellModes[0]?.id ?? initialMode; + const initialSearchMode = + props.availableModeIds?.length && !props.availableModeIds.includes(initialMode) ? fallbackMode : initialMode; + const requestedMode = searchParams.get("mode"); + const resolvedSearchMode = + isAppModeId(requestedMode) && + isAppModeVisible(requestedMode) && + (!props.availableModeIds?.length || props.availableModeIds.includes(requestedMode)) + ? requestedMode + : initialSearchMode; + const requestedQuery = (searchParams.get("q") ?? searchParams.get("query") ?? "").trim(); + const hasSubmittedModeSearch = searchParams.get("run") === "1" && requestedQuery.length > 0; + const isHomeRoute = pathname === "/"; + const isDocumentFlowRoute = pathname === "/documents/search" || pathname.startsWith("/documents/source"); + const isDocumentSearchMockupRoute = pathname.startsWith("/mockups/document-search") || isDocumentFlowRoute; + const shouldRenderDashboardSearch = + hasSubmittedModeSearch && + resolvedSearchMode !== "services" && + resolvedSearchMode !== "forms" && + resolvedSearchMode !== "favourites" && + resolvedSearchMode !== "differentials" && + !isDocumentSearchMockupRoute; + const isMedicationDetailRoute = /^\/medications\/[^/]+$/.test(pathname); + const shouldRenderClinicalDashboard = !isMedicationDetailRoute && (isHomeRoute || shouldRenderDashboardSearch); + + if (shouldRenderClinicalDashboard) { + return ( + + ); + } + + return ; +} + +function GlobalMockupStandaloneSearchShellClient({ children, initialMode = "answer", availableModeIds, @@ -150,12 +202,26 @@ function GlobalMockupSearchShellClient({ const [accountSetupOpen, setAccountSetupOpen] = useState(false); const [recentQueries, setRecentQueries] = useState([]); const [commandScopes, setCommandScopes] = useState([]); + const removeCommandScope = useCallback( + (scopeId: string) => setCommandScopes((current) => current.filter((scope) => scope !== scopeId)), + [], + ); + const clearCommandScopes = useCallback(() => setCommandScopes([]), []); + const searchCommandContextValue = useMemo( + () => ({ + query, + modeId: searchMode, + commandScopes, + onRemoveScope: removeCommandScope, + onClearScopes: clearCommandScopes, + }), + [query, searchMode, commandScopes, removeCommandScope, clearCommandScopes], + ); const [bottomSearchScrollHidden, setBottomSearchScrollHidden] = useState(false); const { theme, toggleTheme } = useTheme(); const auth = useAuthSession(); const sidebarIdentity = useMemo(() => deriveSidebarIdentity(auth.session?.user.email), [auth.session?.user.email]); const hasSubmittedModeSearch = requestedRun && requestedQuery.length > 0; - const isHomeRoute = pathname === "/"; const isDocumentFlowRoute = pathname === "/documents/search" || pathname.startsWith("/documents/source"); const isDocumentSearchMockupRoute = pathname.startsWith("/mockups/document-search") || isDocumentFlowRoute; const isDocumentCommandSearchView = pathname === "/documents/search" && requestedQuery.length > 0; @@ -325,9 +391,6 @@ function GlobalMockupSearchShellClient({ setMainElement(node); }; - const isMedicationDetailRoute = /^\/medications\/[^/]+$/.test(pathname); - const shouldRenderClinicalDashboard = !isMedicationDetailRoute && (isHomeRoute || shouldRenderDashboardSearch); - // Page canvases can become nested scrollers when `overflow-x-hidden` pairs with // a flex height cap (overflow-y becomes auto per CSS). Capture descendant scroll // so the phone dock/header still hide while users scroll results. @@ -344,18 +407,7 @@ function GlobalMockupSearchShellClient({ main.addEventListener("scroll", onScrollCapture, { capture: true, passive: true }); return () => main.removeEventListener("scroll", onScrollCapture, { capture: true }); - }, [mainElement, shouldRenderClinicalDashboard, chromeVisible]); - - if (shouldRenderClinicalDashboard) { - return ( - - ); - } + }, [mainElement, chromeVisible]); if (!chromeVisible) { return ( @@ -489,18 +541,7 @@ function GlobalMockupSearchShellClient({ } > - - setCommandScopes((current) => current.filter((scope) => scope !== scopeId)), - onClearScopes: () => setCommandScopes([]), - }} - > - {children} - + {children}
diff --git a/src/components/clinical-dashboard/output-panel.tsx b/src/components/clinical-dashboard/output-panel.tsx index 86fb51b27..36e66524a 100644 --- a/src/components/clinical-dashboard/output-panel.tsx +++ b/src/components/clinical-dashboard/output-panel.tsx @@ -15,7 +15,7 @@ import { EvidenceMapTable, simpleClinicalTableProps, sortClinicalDetailSections, -} from "@/components/clinical-dashboard/evidence-panels"; +} from "@/components/clinical-dashboard/clinical-output-helpers"; import { cn, iconTilePremium, metadataPill, panelSubtle, subtleStatusPill } from "@/components/ui-primitives"; import type { RagAnswer } from "@/lib/types"; import { diff --git a/src/lib/bounded-ttl-cache.ts b/src/lib/bounded-ttl-cache.ts new file mode 100644 index 000000000..a9b8b5b0b --- /dev/null +++ b/src/lib/bounded-ttl-cache.ts @@ -0,0 +1,33 @@ +export type ExpiringCacheEntry = { expiresAt: number }; + +export function readExpiringCacheEntry(cache: Map, key: K, now = Date.now()) { + const cached = cache.get(key); + if (!cached) return null; + if (cached.expiresAt <= now) { + cache.delete(key); + return null; + } + return cached; +} + +export function writeBoundedExpiringCacheEntry( + cache: Map, + key: K, + value: V, + maxEntries: number, + now = Date.now(), +) { + for (const [cachedKey, cachedValue] of cache) { + if (cachedValue.expiresAt <= now) cache.delete(cachedKey); + } + + if (maxEntries <= 0) return; + if (cache.has(key)) cache.delete(key); + cache.set(key, value); + + while (cache.size > maxEntries) { + const oldestKey = cache.keys().next().value; + if (oldestKey === undefined) break; + cache.delete(oldestKey); + } +} diff --git a/src/lib/client-env.ts b/src/lib/client-env.ts new file mode 100644 index 000000000..a23d5a66d --- /dev/null +++ b/src/lib/client-env.ts @@ -0,0 +1,8 @@ +/** Client-safe environment helpers. Keep this module limited to NEXT_PUBLIC_* values. */ +export function isLocalNoAuthMode() { + return process.env.NODE_ENV !== "production" && process.env.NEXT_PUBLIC_LOCAL_NO_AUTH === "true"; +} + +export function publicUploadsEnabled() { + return process.env.NEXT_PUBLIC_PUBLIC_UPLOADS_ENABLED === "true"; +} diff --git a/src/lib/local-project-guard.ts b/src/lib/local-project-guard.ts index 1336000c5..ac10abadb 100644 --- a/src/lib/local-project-guard.ts +++ b/src/lib/local-project-guard.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import type { LocalProjectIdentityPayload } from "@/lib/local-project-identity"; -import { appName, localProjectId, projectPortEnd, projectPortStart } from "../../scripts/local-server-utils.mjs"; +import { appName, localProjectId, projectPortEnd, projectPortStart } from "@/lib/local-server-utils.mjs"; const localHosts = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); diff --git a/scripts/local-server-utils.mjs b/src/lib/local-server-utils.mjs similarity index 100% rename from scripts/local-server-utils.mjs rename to src/lib/local-server-utils.mjs diff --git a/src/lib/rag-answer-support.ts b/src/lib/rag-answer-support.ts new file mode 100644 index 000000000..31416193a --- /dev/null +++ b/src/lib/rag-answer-support.ts @@ -0,0 +1,214 @@ +import { ragDeepMemoryVersion } from "@/lib/deep-memory"; +import { normalizeSectionText, splitBalancedWords } from "@/lib/rag-answer-text"; +import { normalizeSourceMetadata } from "@/lib/source-metadata"; +import { sourceTextForDisplay } from "@/lib/source-text-sanitizer"; +import type { + Citation, + DocumentIndexQuality, + DocumentMemoryCard, + RagAnswer, + RagQueryClass, + SearchResult, +} from "@/lib/types"; + +export const machineReadableFallbackAnswer = + "The indexed sources were not machine-readable enough to produce a formatted answer."; + +export function scoreValue(result: SearchResult) { + const similarity = result.similarity ?? 0; + const hybrid = result.hybrid_score ?? similarity; + if (similarity > 0 && hybrid > similarity + 0.12) return similarity; + return Math.min(1, hybrid); +} + +export function deriveConfidence( + results: SearchResult[], + acceptedCitations: Array>, +): RagAnswer["confidence"] { + if (acceptedCitations.length === 0 || results.length === 0) return "unsupported"; + const citedIds = new Set(acceptedCitations.map((citation) => citation.chunk_id)); + const citedResults = results.filter((result) => citedIds.has(result.id)); + const strongest = citedResults.reduce((max, result) => Math.max(max, scoreValue(result)), 0); + const strongestNonSynthetic = citedResults.reduce( + (max, result) => (result.similarity_origin === "synthetic_text" ? max : Math.max(max, scoreValue(result))), + 0, + ); + if (strongestNonSynthetic >= 0.82 && acceptedCitations.length >= 2) return "high"; + if (strongest >= 0.64) return "medium"; + return "low"; +} + +export function fallbackReasonFromRouting(reason?: string | null) { + if (!reason) return null; + return ( + reason + .split(";") + .map((part) => part.trim()) + .find((part) => + /source_only_[a-z_]+|fallback|unsupported|no_|limited_retrieval|gap|conflict|failed|confidence_gate|low_signal/i.test( + part, + ), + ) ?? null + ); +} + +export function collectMemoryCards(results: SearchResult[], limit = 8) { + const seen = new Set(); + const cards: DocumentMemoryCard[] = []; + for (const result of results) { + for (const card of result.memory_cards ?? []) { + const key = card.id ?? `${card.document_id}:${card.card_type}:${card.title}:${card.content}`; + if (seen.has(key)) continue; + seen.add(key); + cards.push(card); + if (cards.length >= limit) return cards; + } + } + return cards; +} + +export function buildIndexingQuality(results: SearchResult[], memoryCards: DocumentMemoryCard[]): DocumentIndexQuality { + const sourceMetadata = results.map((result) => normalizeSourceMetadata(result.source_metadata)); + const indexedQualityRows = results + .map((result) => result.indexing_quality) + .filter((quality): quality is NonNullable => Boolean(quality)); + const lowestQualityScore = indexedQualityRows.reduce( + (lowest, quality) => Math.min(lowest, Number(quality.quality_score ?? 1)), + 1, + ); + const indexedExtractionQuality = indexedQualityRows.some((quality) => quality.extraction_quality === "poor") + ? "poor" + : indexedQualityRows.some((quality) => quality.extraction_quality === "partial") + ? "partial" + : indexedQualityRows.some((quality) => quality.extraction_quality === "good") + ? "good" + : null; + const extractionQuality = sourceMetadata.some((metadata) => metadata.extraction_quality === "poor") + ? "poor" + : sourceMetadata.some((metadata) => metadata.extraction_quality === "partial") + ? "partial" + : indexedExtractionQuality + ? indexedExtractionQuality + : sourceMetadata.length > 0 + ? "good" + : "unknown"; + return { + indexingVersion: ragDeepMemoryVersion, + memoryVersion: ragDeepMemoryVersion, + extractionQuality, + missingEmbeddings: indexedQualityRows.reduce((sum, quality) => { + const missing = Number(quality.metrics?.missing_embeddings ?? 0); + return sum + (Number.isFinite(missing) ? missing : 0); + }, 0), + sectionCount: indexedQualityRows.reduce((sum, quality) => { + const sectionCount = Number(quality.metrics?.section_count ?? 0); + return Math.max(sum, Number.isFinite(sectionCount) ? sectionCount : 0); + }, 0), + qualityScore: indexedQualityRows.length > 0 ? Number(lowestQualityScore.toFixed(3)) : undefined, + qualityIssues: Array.from(new Set(indexedQualityRows.flatMap((quality) => quality.issues ?? []))).slice(0, 8), + memoryCardCount: memoryCards.length, + stale: sourceMetadata.some((metadata) => metadata.document_status === "outdated"), + }; +} + +export function buildAnswerScoreExplanations( + results: SearchResult[], + limit = 8, +): NonNullable { + return results.slice(0, limit).map((result) => ({ + chunk_id: result.id, + document_id: result.document_id, + finalScore: Number( + (result.score_explanation?.finalScore ?? result.hybrid_score ?? result.similarity ?? 0).toFixed(4), + ), + score_explanation: result.score_explanation, + })); +} + +export function evidenceTextForGate(result: SearchResult) { + const tableText = (result.table_facts ?? []) + .map((fact) => + [fact.table_title, fact.row_label, fact.clinical_parameter, fact.threshold_value, fact.action].join(" "), + ) + .join(" "); + const imageText = (result.images ?? []) + .map((image) => + [image.caption, image.tableTitle, image.tableLabel, image.tableTextSnippet, image.clinicalUseReason] + .filter(Boolean) + .join(" "), + ) + .join(" "); + const unitText = result.index_unit + ? [result.index_unit.unit_type, result.index_unit.title, result.index_unit.content].join(" ") + : ""; + return normalizeSectionText( + [ + result.title, + result.file_name, + result.section_heading, + result.section_path?.join(" "), + result.retrieval_synopsis, + result.content, + tableText, + imageText, + unitText, + ] + .filter(Boolean) + .join(" "), + ).toLowerCase(); +} + +function memoryCardAnswerScore(card: DocumentMemoryCard, query: string, queryClass: RagQueryClass) { + const content = sourceTextForDisplay(card.content); + if (!content) return -1; + const hasSpecificDoseEvidence = + /\b(?:mg|mcg|microgram|oral|intramuscular|\bim\b|\bpo\b|\bprn\b|repeat(?:ing)? doses?|dose may be repeated|maximum \d|administer|titration|olanzapine|lorazepam|haloperidol|droperidol|promethazine|diazepam)\b/i.test( + content, + ); + if ( + queryClass === "medication_dose_risk" && + /\b(?:supporting information|relevant standards|references|document owner|authorisation|authorised by|published date|effective from|amendment|polypharmacy and high dose antipsychotic prescribing procedure)\b/i.test( + content, + ) && + !hasSpecificDoseEvidence + ) { + return -1; + } + const normalizedContentTokens = new Set(splitBalancedWords(`${card.title} ${content}`)); + const queryTokens = splitBalancedWords(query).filter((token) => token.length > 3); + const tokenHits = queryTokens.filter((token) => normalizedContentTokens.has(token)).length; + const typeBoost = + queryClass === "medication_dose_risk" && + ["medication", "threshold", "table_row", "risk", "workflow"].includes(card.card_type) + ? 0.38 + : queryClass === "table_threshold" && ["table_row", "threshold"].includes(card.card_type) + ? 0.32 + : card.card_type === "section_summary" + ? 0.02 + : 0.12; + const doseBoost = + queryClass === "medication_dose_risk" && + /\b(?:dose|dosage|dosing|mg|mcg|microgram|oral|intramuscular|\bim\b|\bpo\b|\bprn\b|route|titration|administer|olanzapine|lorazepam|haloperidol|droperidol|promethazine|diazepam)\b/i.test( + content, + ) + ? 0.42 + : 0; + const lowValueTitlePenalty = + queryClass === "medication_dose_risk" && card.card_type === "section_summary" && !hasSpecificDoseEvidence + ? -0.35 + : 0; + + return tokenHits * 0.08 + typeBoost + doseBoost + (card.confidence ?? 0) * 0.08 + lowValueTitlePenalty; +} + +export function rankMemoryCardsForAnswer(cards: DocumentMemoryCard[], query: string, queryClass: RagQueryClass) { + return [...cards] + .map((card, index) => ({ + card, + index, + score: memoryCardAnswerScore(card, query, queryClass), + })) + .filter((item) => item.score >= 0) + .sort((a, b) => b.score - a.score || a.index - b.index) + .map((item) => item.card); +} diff --git a/src/lib/rag-cache.ts b/src/lib/rag-cache.ts index 1c36de32c..748ecd118 100644 --- a/src/lib/rag-cache.ts +++ b/src/lib/rag-cache.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import { createAdminClient } from "@/lib/supabase/admin"; import { buildClinicalTextSearchQuery } from "@/lib/clinical-search"; +import { readExpiringCacheEntry, writeBoundedExpiringCacheEntry } from "@/lib/bounded-ttl-cache"; import { ragDeepMemoryVersion } from "@/lib/deep-memory"; import { env } from "@/lib/env"; import { queryCacheKeyForStorage } from "@/lib/query-privacy"; @@ -8,7 +9,7 @@ import { ragCacheKeyMatchesOwner } from "@/lib/rag-cache-utils"; import { compactContextText } from "@/lib/rag-source-block"; import { committedIndexGeneration } from "@/lib/reindex-pipeline"; import { normalizeSourceMetadata } from "@/lib/source-metadata"; -import { retrievalPlanForQueryClass, type SearchChunksArgs, type SearchTelemetry } from "@/lib/rag"; +import { retrievalPlanForQueryClass, type SearchChunksArgs, type SearchTelemetry } from "@/lib/rag-contracts"; import type { Json } from "@/lib/supabase/database.types"; import type { RagAnswer, RagQueryClass, SearchResult } from "@/lib/types"; @@ -20,6 +21,7 @@ const searchCache = new Map< >(); const ragCacheDependencyVersion = "rag-cache-v12"; const cacheIndexingVersionTtlMs = 5000; +const cacheIndexingVersionMaxEntries = 512; const cacheIndexingVersionCache = new Map(); function scopeKey(args: Pick) { @@ -275,8 +277,8 @@ function sharedCacheSelector( export async function cacheIndexingVersion(args: Pick) { const cacheKey = cacheIndexingVersionCacheKey(args); - const cached = cacheIndexingVersionCache.get(cacheKey); - if (cached && cached.expiresAt > Date.now()) return cached.value; + const cached = readExpiringCacheEntry(cacheIndexingVersionCache, cacheKey); + if (cached) return cached.value; let value = `${ragDeepMemoryVersion}:index-stamp-unavailable`; try { @@ -306,7 +308,12 @@ export async function cacheIndexingVersion(args: Pick; + retrieval_layer_top_scores?: Record; + retrieval_layer_latencies_ms?: Record; + hybrid_rpc_errors?: Record; + retrieval_provenance_counts?: Record; + retrieval_plan?: string; + retrieval_intent?: RetrievalIntent; + retrieval_selection?: RetrievalSelectionSummary; + coverage_gate_decision?: "accepted" | "rejected" | "not_applicable"; + coverage_gate_reason?: string | null; + vector_skipped_reason?: string | null; + source_image_required?: boolean; + source_image_satisfied?: boolean; + second_stage_rerank_used?: boolean; + second_stage_rerank_latency_ms?: number; + visual_direct_image_count?: number; + weighted_top_score?: number; + rrf_top_score?: number; + top_score?: number; + second_top_score?: number; + score_spread?: number; + score_distinct_documents?: number; + retrieval_candidate_count?: number; + retrieval_strategy?: + | "search_cache" + | "text_fast_path" + | "document_lookup_fast_path" + | "hybrid" + | "vector_fallback" + | "unsupported_short_circuit"; +}; + +export function retrievalPlanForQueryClass(queryClass?: RagQueryClass) { + switch (queryClass) { + case "document_lookup": + return "document_lookup:title_label_section_then_chunks"; + case "table_threshold": + return "table_threshold:table_facts_visual_units_then_chunks"; + case "medication_dose_risk": + return "medication_dose_risk:medication_rows_thresholds_monitoring_then_chunks"; + case "comparison": + return "comparison:diverse_documents_sections_memory_then_chunks"; + case "broad_summary": + return "broad_summary:document_summaries_sections_memory_then_chunks"; + default: + return "balanced_hybrid:chunks_fields_units_memory"; + } +} diff --git a/src/lib/rag-extractive-answer.ts b/src/lib/rag-extractive-answer.ts index 1a6f22c8b..4c8607ea0 100644 --- a/src/lib/rag-extractive-answer.ts +++ b/src/lib/rag-extractive-answer.ts @@ -15,7 +15,7 @@ import { machineReadableFallbackAnswer, rankMemoryCardsForAnswer, scoreValue, -} from "@/lib/rag"; +} from "@/lib/rag-answer-support"; import { hasClinicalAnswerQualityIssue, looksLikeJsonArtifact, diff --git a/src/lib/rag-query-guard.ts b/src/lib/rag-query-guard.ts new file mode 100644 index 000000000..eb96d97a6 --- /dev/null +++ b/src/lib/rag-query-guard.ts @@ -0,0 +1,34 @@ +import type { ClinicalQueryAnalysis } from "@/lib/types"; + +const clearlyNonClinicalConsumerPattern = + /\b(coffee\s*machine|espresso|kitchen|recipe|holiday|hotel|restaurant|car|mortgage|insurance|gaming|laptop|phone|television|tv|washing\s*machine|air\s*fryer|vacuum|flight|airline)\b/i; + +export const clearlyOutsideCorpusMedicalPattern = + /\b(?:diabetic ketoacidosis|dka|community acquired pneumonia|pneumonia|antibiotic|ssri|adolescent depression|hyperkalaemia|hyperkalemia)\b/i; + +export const unavailableDocumentNoisePattern = + /\b(?:newly uploaded|future synthetic|not been uploaded|not uploaded|2027 revised|airport travel policy|gardening equipment checklist)\b/i; + +function unsupportedSoftTailEligible(analysis: ClinicalQueryAnalysis) { + if (analysis.queryClass !== "unsupported_or_general") return false; + if (analysis.documentTitleIntent || analysis.medications.length || analysis.thresholdTerms.length) return false; + if (analysis.reasons.some((reason) => reason !== "no_specific_rag_class_terms")) return false; + return true; +} + +export function shouldShortCircuitUnsupportedSearch(query: string, analysis: ClinicalQueryAnalysis) { + if (unavailableDocumentNoisePattern.test(query)) return true; + if (clearlyOutsideCorpusMedicalPattern.test(query) && analysis.documentTitleTerms.length === 0) return true; + if (!unsupportedSoftTailEligible(analysis)) return false; + if (clearlyNonClinicalConsumerPattern.test(query)) return true; + return analysis.confidence <= 0.42 && analysis.expandedTerms.length <= 5; +} + +// True only for queries that would short-circuit via the soft tail itself, not a pattern guard. +export function isUnsupportedSoftTailAnalysis(query: string, analysis: ClinicalQueryAnalysis) { + if (unavailableDocumentNoisePattern.test(query)) return false; + if (clearlyOutsideCorpusMedicalPattern.test(query) && analysis.documentTitleTerms.length === 0) return false; + if (!unsupportedSoftTailEligible(analysis)) return false; + if (clearlyNonClinicalConsumerPattern.test(query)) return false; + return analysis.confidence <= 0.42 && analysis.expandedTerms.length <= 5; +} diff --git a/src/lib/rag-retrieval-variants.ts b/src/lib/rag-retrieval-variants.ts index 283c8301a..9ea0ae6c6 100644 --- a/src/lib/rag-retrieval-variants.ts +++ b/src/lib/rag-retrieval-variants.ts @@ -1,13 +1,16 @@ import { createAdminClient } from "@/lib/supabase/admin"; +import { readExpiringCacheEntry, writeBoundedExpiringCacheEntry } from "@/lib/bounded-ttl-cache"; import { retrievalOwnerFilter } from "@/lib/owner-scope"; import { buildClinicalTextSearchQuery, normalizedClinicalSearchTokens, queriedZoneColour } from "@/lib/clinical-search"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; -import { shouldShortCircuitUnsupportedSearch, type SearchChunksArgs } from "@/lib/rag"; +import type { SearchChunksArgs } from "@/lib/rag-contracts"; +import { shouldShortCircuitUnsupportedSearch } from "@/lib/rag-query-guard"; import type { ClinicalQueryAnalysis, RagQueryClass, SearchResult } from "@/lib/types"; const maxRetrievalQueryVariants = 4; export const maxTextRpcQueryVariants = 3; const ragAliasCacheTtlMs = 60_000; +const maxRagAliasCacheEntries = 256; const maxRagAliasesPerScope = 200; const maxRagAliasExpansions = 12; @@ -102,8 +105,8 @@ export async function fetchEnabledRagAliases( ownerId?: string, ): Promise { const cacheKey = ownerId ? `owner:${ownerId}` : "global"; - const cached = ragAliasCache.get(cacheKey); - if (cached && cached.expiresAt > Date.now()) return cached.aliases; + const cached = readExpiringCacheEntry(ragAliasCache, cacheKey); + if (cached) return cached.aliases; /** Read scope. */ async function readScope(scopeOwnerId: string | null) { @@ -133,7 +136,12 @@ export async function fetchEnabledRagAliases( merged.push(alias); if (merged.length >= maxRagAliasesPerScope) break; } - ragAliasCache.set(cacheKey, { aliases: merged, expiresAt: Date.now() + ragAliasCacheTtlMs }); + writeBoundedExpiringCacheEntry( + ragAliasCache, + cacheKey, + { aliases: merged, expiresAt: Date.now() + ragAliasCacheTtlMs }, + maxRagAliasCacheEntries, + ); return merged; } catch { // Do not cache an empty result on a transient rag_aliases read failure: caching [] would suppress diff --git a/src/lib/rag.ts b/src/lib/rag.ts index 5520b6ee4..043d81e7d 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -114,15 +114,42 @@ import { import { fetchRelatedDocumentMetadata, fetchRelatedDocuments } from "@/lib/document-enrichment"; import { boldHighYieldClinicalText, boldRagAnswerHighYieldText, rankAnswerEvidence } from "@/lib/answer-ranking"; import { applyMemoryCardBoosts, fetchMemoryCardsForQuery, ragDeepMemoryVersion } from "@/lib/deep-memory"; -import { cleanClinicalSummaryText, isLowYieldClinicalText, sourceTextForDisplay } from "@/lib/source-text-sanitizer"; +import { + buildAnswerScoreExplanations, + buildIndexingQuality, + collectMemoryCards, + deriveConfidence, + evidenceTextForGate, + fallbackReasonFromRouting, + machineReadableFallbackAnswer, + scoreValue, +} from "@/lib/rag-answer-support"; +export { + buildAnswerScoreExplanations, + buildIndexingQuality, + collectMemoryCards, + deriveConfidence, + evidenceTextForGate, + fallbackReasonFromRouting, + machineReadableFallbackAnswer, + rankMemoryCardsForAnswer, + scoreValue, +} from "@/lib/rag-answer-support"; +import { retrievalPlanForQueryClass, type SearchChunksArgs, type SearchTelemetry } from "@/lib/rag-contracts"; +export { retrievalPlanForQueryClass, type SearchChunksArgs, type SearchTelemetry } from "@/lib/rag-contracts"; +import { + clearlyOutsideCorpusMedicalPattern, + isUnsupportedSoftTailAnalysis, + unavailableDocumentNoisePattern, +} from "@/lib/rag-query-guard"; +export { shouldShortCircuitUnsupportedSearch } from "@/lib/rag-query-guard"; +import { cleanClinicalSummaryText, isLowYieldClinicalText } from "@/lib/source-text-sanitizer"; import { hasClinicalAnswerQualityIssue, isUsableAnswerSectionText, looksLikeJsonArtifact, - normalizeSectionText, sanitizeAnswerText, sanitizeStructuredText, - splitBalancedWords, metadataText, safeRecord, } from "@/lib/rag-answer-text"; @@ -157,8 +184,6 @@ import type { ClinicalImageUseClass, Citation, ClinicalQueryAnalysis, - CorpusGroundingVerdict, - DocumentIndexQuality, DocumentIndexUnitMatch, DocumentMemoryCard, EvidenceRelevance, @@ -172,7 +197,6 @@ import type { RagAnswer, SearchResult, SmartRagApiPlan, - ClinicalQueryMode, } from "@/lib/types"; const answerSectionKinds = [ @@ -342,9 +366,6 @@ const confidenceOrder = { high: 3, } as const; -export const machineReadableFallbackAnswer = - "The indexed sources were not machine-readable enough to produce a formatted answer."; - /** Throw if aborted. */ function throwIfAborted(signal?: AbortSignal) { if (signal?.aborted) { @@ -352,31 +373,6 @@ function throwIfAborted(signal?: AbortSignal) { } } -export type SearchChunksArgs = { - query: string; - topK?: number; - minSimilarity?: number; - documentId?: string; - documentIds?: string[]; - ownerId?: string; - allowGlobalSearch?: boolean; - skipCache?: boolean; - queryMode?: ClinicalQueryMode; - signal?: AbortSignal; - // Internal: set when this call is a re-run on a trigram-corrected query, to prevent the - // unsupported-short-circuit typo-correction path from recursing more than once. - typoCorrected?: boolean; - // Diagnostic/eval-only: bypass every lexical text-fast-path so retrieval always exercises - // the embedding/vector stage. Lets the golden eval measure the vector index directly for a - // re-index, instead of being masked by lexical shortcuts. Never set on production paths. - forceEmbedding?: boolean; - // Lightweight-preview only: never call the OpenAI embedding API — return the lexical/trigram - // candidates gathered before the vector stage. Used by the cross-entity typeahead, where a - // small document preview does not justify an embedding round-trip per keystroke. The Answer - // and Documents full-search paths never set this, so their retrieval is unchanged. - lexicalOnly?: boolean; -}; - export type AnswerProgressEvent = { stage: "retrieved" | "routing" | "generating" | "retrying" | "finalizing" | "cached"; message: string; @@ -405,93 +401,6 @@ type AnswerQuestionWithScopeArgs = SearchChunksArgs & { signal?: AbortSignal; }; -export type SearchTelemetry = { - search_cache_hit: boolean; - shared_cache_hit?: boolean; - shared_cache_status?: "hit" | "miss"; - shared_cache_miss_reason?: string | null; - query_class?: RagQueryClass; - vector_candidate_count?: number; - text_candidate_count?: number; - embedding_field_count?: number; - retrieval_query_variant_count?: number; - rag_alias_count?: number; - rag_alias_expansion_count?: number; - text_fast_path_latency_ms: number; - text_candidate_budget?: number; - text_fast_path_reason?: string | null; - // P8b extension: how OR-relaxation participated in the text layer. "empty_fallback" is the - // long-standing relax-on-zero path; "weak_augment" appends OR recall behind weak-but-nonzero - // strict matches (issue: strict-AND could bury the right chunk without ever relaxing). - text_or_relaxation_used?: "none" | "empty_fallback" | "weak_augment"; - // Finding #11: corpus-grounding verdict for unsupported-soft-tail queries (absent when the - // query never entered the soft tail). See src/lib/corpus-grounding.ts. - corpus_grounding?: CorpusGroundingVerdict; - // RC9 observability: how many final results carry a fabricated (non-cosine) similarity. - synthetic_similarity_count?: number; - embedding_skipped: boolean; - embedding_skip_reason?: string | null; - embedding_latency_ms: number; - embedding_cache_hit: boolean; - supabase_rpc_latency_ms: number; - rerank_latency_ms: number; - memory_card_count?: number; - memory_top_score?: number; - index_unit_count?: number; - index_unit_top_score?: number; - retrieval_layer_counts?: Record; - retrieval_layer_top_scores?: Record; - retrieval_layer_latencies_ms?: Record; - // P0.1: per-RPC failure codes for the hybrid retrieval layers. A non-empty map means a hybrid - // layer errored (not merely returned zero matches) and the app silently degraded — the exact - // failure mode that hid the schema drift. Surfaced in telemetry + logged via logger.error. - hybrid_rpc_errors?: Record; - retrieval_provenance_counts?: Record; - retrieval_plan?: string; - retrieval_intent?: RetrievalIntent; - retrieval_selection?: RetrievalSelectionSummary; - coverage_gate_decision?: "accepted" | "rejected" | "not_applicable"; - coverage_gate_reason?: string | null; - vector_skipped_reason?: string | null; - source_image_required?: boolean; - source_image_satisfied?: boolean; - second_stage_rerank_used?: boolean; - second_stage_rerank_latency_ms?: number; - visual_direct_image_count?: number; - weighted_top_score?: number; - rrf_top_score?: number; - top_score?: number; - second_top_score?: number; - score_spread?: number; - score_distinct_documents?: number; - retrieval_candidate_count?: number; - retrieval_strategy?: - | "search_cache" - | "text_fast_path" - | "document_lookup_fast_path" - | "hybrid" - | "vector_fallback" - | "unsupported_short_circuit"; -}; - -/** Retrieval plan for query class. */ -export function retrievalPlanForQueryClass(queryClass?: RagQueryClass) { - switch (queryClass) { - case "document_lookup": - return "document_lookup:title_label_section_then_chunks"; - case "table_threshold": - return "table_threshold:table_facts_visual_units_then_chunks"; - case "medication_dose_risk": - return "medication_dose_risk:medication_rows_thresholds_monitoring_then_chunks"; - case "comparison": - return "comparison:diverse_documents_sections_memory_then_chunks"; - case "broad_summary": - return "broad_summary:document_summaries_sections_memory_then_chunks"; - default: - return "balanced_hybrid:chunks_fields_units_memory"; - } -} - const visualEvidenceUnitTypes = new Set([ "visual_summary", "visual_askable_question", @@ -798,48 +707,6 @@ const answerJsonSchema = z.object({ .default([]), }); -// Audit M1: confidence must reflect the strength of the evidence the answer -// actually CITES. Taking the max similarity over ALL retrieved results let an -// uncited high-similarity chunk grant "high" confidence to an answer built on -// weak citations, so the strongest-score scan is scoped to the cited subset. -// A citation that maps to no known chunk contributes nothing (fail low). -// -// RC9: results tagged `similarity_origin: "synthetic_text"` carry a similarity FABRICATED from -// lexical/structural signals (0.58-floor formulas in the document-lookup fast path, memory-card -// chunk loader, and table-fact signal matches), not a real cosine. Those fabrications routinely -// clear the 0.82 bar (memory-card hybrid reaches 0.89, document-lookup 0.94), which let a -// lexical-only citation mint "high" confidence. Synthetic-origin evidence is therefore capped at -// "medium": "high" requires at least one cited result whose score is NOT a fabricated synthetic -// similarity. (`strongestNonSynthetic` below excludes `synthetic_text` origins but is still the -// hybrid score via scoreValue, not a pure cosine — a stricter pure-`similarity` gate would change -// behaviour and needs eval validation.) Ordering, routing, and coverage gates are untouched — this -// only stops the fabricated scale from masquerading as strong semantic evidence in the label. -/** Derive confidence. */ -export function deriveConfidence( - results: SearchResult[], - acceptedCitations: Array>, -): RagAnswer["confidence"] { - if (acceptedCitations.length === 0 || results.length === 0) return "unsupported"; - const citedIds = new Set(acceptedCitations.map((citation) => citation.chunk_id)); - const citedResults = results.filter((result) => citedIds.has(result.id)); - const strongest = citedResults.reduce((max, result) => Math.max(max, scoreValue(result)), 0); - const strongestNonSynthetic = citedResults.reduce( - (max, result) => (result.similarity_origin === "synthetic_text" ? max : Math.max(max, scoreValue(result))), - 0, - ); - if (strongestNonSynthetic >= 0.82 && acceptedCitations.length >= 2) return "high"; - if (strongest >= 0.64) return "medium"; - return "low"; -} - -/** Score value. */ -export function scoreValue(result: SearchResult) { - const similarity = result.similarity ?? 0; - const hybrid = result.hybrid_score ?? similarity; - if (similarity > 0 && hybrid > similarity + 0.12) return similarity; - return Math.min(1, hybrid); -} - /** Build retrieval diagnostics. */ function buildRetrievalDiagnostics(args: { queryClass: RagQueryClass; @@ -1107,28 +974,6 @@ function hasOpenAIUsage(usage: OpenAITokenUsage) { return Object.values(usage).some((value) => typeof value === "number" && value > 0); } -/** Fallback reason from routing. */ -export function fallbackReasonFromRouting(reason?: string | null) { - if (!reason) return null; - return ( - reason - .split(";") - .map((part) => part.trim()) - .find((part) => - /source_only_[a-z_]+|fallback|unsupported|no_|limited_retrieval|gap|conflict|failed|confidence_gate|low_signal/i.test( - part, - ), - ) ?? null - ); -} - -const clearlyNonClinicalConsumerPattern = - /\b(coffee\s*machine|espresso|kitchen|recipe|holiday|hotel|restaurant|car|mortgage|insurance|gaming|laptop|phone|television|tv|washing\s*machine|air\s*fryer|vacuum|flight|airline)\b/i; -const clearlyOutsideCorpusMedicalPattern = - /\b(?:diabetic ketoacidosis|dka|community acquired pneumonia|pneumonia|antibiotic|ssri|adolescent depression|hyperkalaemia|hyperkalemia)\b/i; -const unavailableDocumentNoisePattern = - /\b(?:newly uploaded|future synthetic|not been uploaded|not uploaded|2027 revised|airport travel policy|gardening equipment checklist)\b/i; - const queryClassifierOutputSchema = { type: "object", description: "Low-cost query classification fallback for clinical RAG retrieval only.", @@ -1385,38 +1230,6 @@ export async function analyzeQueryWithClassifierFallback( } } -// Shared eligibility gate for the unsupported soft tail: a low-signal unsupported_or_general -// analysis with no title/medication/threshold intent and no non-default reasons. -/** Unsupported soft tail eligible. */ -function unsupportedSoftTailEligible(analysis: ClinicalQueryAnalysis) { - if (analysis.queryClass !== "unsupported_or_general") return false; - if (analysis.documentTitleIntent || analysis.medications.length || analysis.thresholdTerms.length) return false; - if (analysis.reasons.some((reason) => reason !== "no_specific_rag_class_terms")) return false; - return true; -} - -/** Should short circuit unsupported search. */ -export function shouldShortCircuitUnsupportedSearch(query: string, analysis: ClinicalQueryAnalysis) { - if (unavailableDocumentNoisePattern.test(query)) return true; - if (clearlyOutsideCorpusMedicalPattern.test(query) && analysis.documentTitleTerms.length === 0) return true; - if (!unsupportedSoftTailEligible(analysis)) return false; - if (clearlyNonClinicalConsumerPattern.test(query)) return true; - return analysis.confidence <= 0.42 && analysis.expandedTerms.length <= 5; -} - -// True only for queries that would short-circuit via the soft tail itself — NOT via the -// pattern guards (out-of-corpus medical list, consumer-noise, unavailable-document noise). -// Corpus grounding is scoped to exactly this branch: the pattern-guarded refusals and every -// higher-confidence class keep their existing behaviour untouched. -/** Is unsupported soft tail analysis. */ -function isUnsupportedSoftTailAnalysis(query: string, analysis: ClinicalQueryAnalysis) { - if (unavailableDocumentNoisePattern.test(query)) return false; - if (clearlyOutsideCorpusMedicalPattern.test(query) && analysis.documentTitleTerms.length === 0) return false; - if (!unsupportedSoftTailEligible(analysis)) return false; - if (clearlyNonClinicalConsumerPattern.test(query)) return false; - return analysis.confidence <= 0.42 && analysis.expandedTerms.length <= 5; -} - /** Metadata expansion term score. */ function metadataExpansionTermScore(queryTokens: Set, value: string, sourceWeight: number) { const tokens = normalizedClinicalSearchTokens(value); @@ -1909,82 +1722,6 @@ async function searchDocumentLookupFastPath(args: { .slice(0, args.matchCount); } -/** Collect memory cards. */ -export function collectMemoryCards(results: SearchResult[], limit = 8) { - const seen = new Set(); - const cards: DocumentMemoryCard[] = []; - for (const result of results) { - for (const card of result.memory_cards ?? []) { - const key = card.id ?? `${card.document_id}:${card.card_type}:${card.title}:${card.content}`; - if (seen.has(key)) continue; - seen.add(key); - cards.push(card); - if (cards.length >= limit) return cards; - } - } - return cards; -} - -/** Build indexing quality. */ -export function buildIndexingQuality(results: SearchResult[], memoryCards: DocumentMemoryCard[]): DocumentIndexQuality { - const sourceMetadata = results.map((result) => normalizeSourceMetadata(result.source_metadata)); - const indexedQualityRows = results - .map((result) => result.indexing_quality) - .filter((quality): quality is NonNullable => Boolean(quality)); - const lowestQualityScore = indexedQualityRows.reduce( - (lowest, quality) => Math.min(lowest, Number(quality.quality_score ?? 1)), - 1, - ); - const indexedExtractionQuality = indexedQualityRows.some((quality) => quality.extraction_quality === "poor") - ? "poor" - : indexedQualityRows.some((quality) => quality.extraction_quality === "partial") - ? "partial" - : indexedQualityRows.some((quality) => quality.extraction_quality === "good") - ? "good" - : null; - const extractionQuality = sourceMetadata.some((metadata) => metadata.extraction_quality === "poor") - ? "poor" - : sourceMetadata.some((metadata) => metadata.extraction_quality === "partial") - ? "partial" - : indexedExtractionQuality - ? indexedExtractionQuality - : sourceMetadata.length > 0 - ? "good" - : "unknown"; - return { - indexingVersion: ragDeepMemoryVersion, - memoryVersion: ragDeepMemoryVersion, - extractionQuality, - missingEmbeddings: indexedQualityRows.reduce((sum, quality) => { - const missing = Number(quality.metrics?.missing_embeddings ?? 0); - return sum + (Number.isFinite(missing) ? missing : 0); - }, 0), - sectionCount: indexedQualityRows.reduce((sum, quality) => { - const sectionCount = Number(quality.metrics?.section_count ?? 0); - return Math.max(sum, Number.isFinite(sectionCount) ? sectionCount : 0); - }, 0), - qualityScore: indexedQualityRows.length > 0 ? Number(lowestQualityScore.toFixed(3)) : undefined, - qualityIssues: Array.from(new Set(indexedQualityRows.flatMap((quality) => quality.issues ?? []))).slice(0, 8), - memoryCardCount: memoryCards.length, - stale: sourceMetadata.some((metadata) => metadata.document_status === "outdated"), - }; -} - -/** Build answer score explanations. */ -export function buildAnswerScoreExplanations( - results: SearchResult[], - limit = 8, -): NonNullable { - return results.slice(0, limit).map((result) => ({ - chunk_id: result.id, - document_id: result.document_id, - finalScore: Number( - (result.score_explanation?.finalScore ?? result.hybrid_score ?? result.similarity ?? 0).toFixed(4), - ), - score_explanation: result.score_explanation, - })); -} - /** Score explanation log metadata. */ function scoreExplanationLogMetadata(scoreExplanations: NonNullable) { return { @@ -2756,40 +2493,6 @@ function shouldReturnBeforeMemory( return !shouldUseMemoryBeforeFastPath(queryClass); } -/** Evidence text for gate. */ -export function evidenceTextForGate(result: SearchResult) { - const tableText = (result.table_facts ?? []) - .map((fact) => - [fact.table_title, fact.row_label, fact.clinical_parameter, fact.threshold_value, fact.action].join(" "), - ) - .join(" "); - const imageText = (result.images ?? []) - .map((image) => - [image.caption, image.tableTitle, image.tableLabel, image.tableTextSnippet, image.clinicalUseReason] - .filter(Boolean) - .join(" "), - ) - .join(" "); - const unitText = result.index_unit - ? [result.index_unit.unit_type, result.index_unit.title, result.index_unit.content].join(" ") - : ""; - return normalizeSectionText( - [ - result.title, - result.file_name, - result.section_heading, - result.section_path?.join(" "), - result.retrieval_synopsis, - result.content, - tableText, - imageText, - unitText, - ] - .filter(Boolean) - .join(" "), - ).toLowerCase(); -} - /** Top evidence text. */ function topEvidenceText(results: SearchResult[], limit = 5) { return results.slice(0, limit).map(evidenceTextForGate).join(" "); @@ -3173,63 +2876,6 @@ function shouldUseMemoryBeforeFastPath(queryClass: RagQueryClass) { return queryClass === "table_threshold" || queryClass === "medication_dose_risk" || queryClass === "comparison"; } -/** Memory card answer score. */ -function memoryCardAnswerScore(card: DocumentMemoryCard, query: string, queryClass: RagQueryClass) { - const content = sourceTextForDisplay(card.content); - if (!content) return -1; - const hasSpecificDoseEvidence = - /\b(?:mg|mcg|microgram|oral|intramuscular|\bim\b|\bpo\b|\bprn\b|repeat(?:ing)? doses?|dose may be repeated|maximum \d|administer|titration|olanzapine|lorazepam|haloperidol|droperidol|promethazine|diazepam)\b/i.test( - content, - ); - if ( - queryClass === "medication_dose_risk" && - /\b(?:supporting information|relevant standards|references|document owner|authorisation|authorised by|published date|effective from|amendment|polypharmacy and high dose antipsychotic prescribing procedure)\b/i.test( - content, - ) && - !hasSpecificDoseEvidence - ) { - return -1; - } - const normalizedContentTokens = new Set(splitBalancedWords(`${card.title} ${content}`)); - const queryTokens = splitBalancedWords(query).filter((token) => token.length > 3); - const tokenHits = queryTokens.filter((token) => normalizedContentTokens.has(token)).length; - const typeBoost = - queryClass === "medication_dose_risk" && - ["medication", "threshold", "table_row", "risk", "workflow"].includes(card.card_type) - ? 0.38 - : queryClass === "table_threshold" && ["table_row", "threshold"].includes(card.card_type) - ? 0.32 - : card.card_type === "section_summary" - ? 0.02 - : 0.12; - const doseBoost = - queryClass === "medication_dose_risk" && - /\b(?:dose|dosage|dosing|mg|mcg|microgram|oral|intramuscular|\bim\b|\bpo\b|\bprn\b|route|titration|administer|olanzapine|lorazepam|haloperidol|droperidol|promethazine|diazepam)\b/i.test( - content, - ) - ? 0.42 - : 0; - const lowValueTitlePenalty = - queryClass === "medication_dose_risk" && card.card_type === "section_summary" && !hasSpecificDoseEvidence - ? -0.35 - : 0; - - return tokenHits * 0.08 + typeBoost + doseBoost + (card.confidence ?? 0) * 0.08 + lowValueTitlePenalty; -} - -/** Rank memory cards for answer. */ -export function rankMemoryCardsForAnswer(cards: DocumentMemoryCard[], query: string, queryClass: RagQueryClass) { - return [...cards] - .map((card, index) => ({ - card, - index, - score: memoryCardAnswerScore(card, query, queryClass), - })) - .filter((item) => item.score >= 0) - .sort((a, b) => b.score - a.score || a.index - b.index) - .map((item) => item.card); -} - /** Search chunks with telemetry. */ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { assertGlobalSearchAllowed(args); diff --git a/tests/architecture-boundaries.test.ts b/tests/architecture-boundaries.test.ts new file mode 100644 index 000000000..6b19685c5 --- /dev/null +++ b/tests/architecture-boundaries.test.ts @@ -0,0 +1,233 @@ +import fs from "node:fs"; +import path from "node:path"; +import ts from "typescript"; +import { describe, expect, it } from "vitest"; + +const projectRoot = path.resolve(import.meta.dirname, ".."); +const sourceRoots = [path.join(projectRoot, "src"), path.join(projectRoot, "worker")]; +const extensions = [".ts", ".tsx", ".mts", ".mjs", ".js", ".jsx"]; + +function sourceFiles() { + const files: string[] = []; + const visit = (directory: string) => { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const filePath = path.join(directory, entry.name); + if (entry.isDirectory()) visit(filePath); + else if (extensions.includes(path.extname(entry.name))) files.push(path.resolve(filePath)); + } + }; + sourceRoots.forEach(visit); + return files; +} + +function scriptKind(filePath: string) { + if (filePath.endsWith(".tsx")) return ts.ScriptKind.TSX; + if (filePath.endsWith(".jsx")) return ts.ScriptKind.JSX; + if (filePath.endsWith(".js") || filePath.endsWith(".mjs")) return ts.ScriptKind.JS; + return ts.ScriptKind.TS; +} + +function moduleSpecifiers(filePath: string) { + const source = ts.createSourceFile( + filePath, + fs.readFileSync(filePath, "utf8"), + ts.ScriptTarget.Latest, + true, + scriptKind(filePath), + ); + const staticImports = new Set(); + const dynamicImports = new Set(); + + for (const statement of source.statements) { + if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier)) { + const clause = statement.importClause; + const namedImports = + clause?.namedBindings && ts.isNamedImports(clause.namedBindings) ? clause.namedBindings : null; + const typeOnly = + clause?.isTypeOnly || + Boolean( + clause && + !clause.name && + namedImports?.elements.length && + namedImports.elements.every((item) => item.isTypeOnly), + ); + if (!typeOnly) staticImports.add(statement.moduleSpecifier.text); + } + if ( + ts.isExportDeclaration(statement) && + statement.moduleSpecifier && + ts.isStringLiteral(statement.moduleSpecifier) + ) { + const typeOnly = + statement.isTypeOnly || + Boolean( + statement.exportClause && + ts.isNamedExports(statement.exportClause) && + statement.exportClause.elements.length > 0 && + statement.exportClause.elements.every((item) => item.isTypeOnly), + ); + if (!typeOnly) staticImports.add(statement.moduleSpecifier.text); + } + } + + const visit = (node: ts.Node) => { + if ( + ts.isCallExpression(node) && + node.expression.kind === ts.SyntaxKind.ImportKeyword && + node.arguments.length === 1 && + ts.isStringLiteral(node.arguments[0]) + ) { + dynamicImports.add(node.arguments[0].text); + } + ts.forEachChild(node, visit); + }; + visit(source); + + return { source, staticImports, dynamicImports }; +} + +function resolveModule(fromFile: string, specifier: string, fileSet: Set) { + let base: string; + if (specifier.startsWith("@/")) base = path.join(projectRoot, "src", specifier.slice(2)); + else if (specifier.startsWith(".")) base = path.resolve(path.dirname(fromFile), specifier); + else return null; + + const candidates = [ + base, + ...extensions.map((extension) => `${base}${extension}`), + ...extensions.map((extension) => path.join(base, `index${extension}`)), + ]; + return candidates.map((candidate) => path.resolve(candidate)).find((candidate) => fileSet.has(candidate)) ?? null; +} + +function runtimeGraph() { + const files = sourceFiles(); + const fileSet = new Set(files); + const graph = new Map(); + const parsed = new Map>(); + + for (const file of files) { + const modules = moduleSpecifiers(file); + parsed.set(file, modules); + const dependencies = [...modules.staticImports, ...modules.dynamicImports] + .map((specifier) => resolveModule(file, specifier, fileSet)) + .filter((dependency): dependency is string => Boolean(dependency)); + graph.set(file, [...new Set(dependencies)]); + } + + return { files, fileSet, graph, parsed }; +} + +function runtimeCycles(graph: Map) { + let nextIndex = 0; + const indexes = new Map(); + const lowLinks = new Map(); + const stack: string[] = []; + const onStack = new Set(); + const cycles: string[][] = []; + + const visit = (file: string) => { + indexes.set(file, nextIndex); + lowLinks.set(file, nextIndex); + nextIndex += 1; + stack.push(file); + onStack.add(file); + + for (const dependency of graph.get(file) ?? []) { + if (!indexes.has(dependency)) { + visit(dependency); + lowLinks.set(file, Math.min(lowLinks.get(file)!, lowLinks.get(dependency)!)); + } else if (onStack.has(dependency)) { + lowLinks.set(file, Math.min(lowLinks.get(file)!, indexes.get(dependency)!)); + } + } + + if (lowLinks.get(file) !== indexes.get(file)) return; + const component: string[] = []; + let current: string; + do { + current = stack.pop()!; + onStack.delete(current); + component.push(current); + } while (current !== file); + if (component.length > 1) cycles.push(component); + }; + + for (const file of graph.keys()) { + if (!indexes.has(file)) visit(file); + } + return cycles; +} + +function relative(filePath: string) { + return path.relative(projectRoot, filePath).replaceAll("\\", "/"); +} + +describe("architecture boundaries", () => { + it("has no runtime import cycles", () => { + const { graph } = runtimeGraph(); + const cycles = runtimeCycles(graph).map((cycle) => cycle.map(relative).sort()); + expect(cycles).toEqual([]); + }); + + it("keeps server environment and provider modules out of the client graph", () => { + const { files, graph, parsed } = runtimeGraph(); + const clientEntries = files.filter((file) => { + const firstStatement = parsed.get(file)?.source.statements[0]; + return Boolean( + firstStatement && + ts.isExpressionStatement(firstStatement) && + ts.isStringLiteral(firstStatement.expression) && + firstStatement.expression.text === "use client", + ); + }); + const clientGraph = new Set(clientEntries); + const pending = [...clientEntries]; + while (pending.length) { + const file = pending.pop()!; + for (const dependency of graph.get(file) ?? []) { + if (clientGraph.has(dependency)) continue; + clientGraph.add(dependency); + pending.push(dependency); + } + } + + const forbidden = new Set([ + "src/lib/env.ts", + "src/lib/openai.ts", + "src/lib/rag.ts", + "src/lib/supabase/admin.ts", + "src/lib/supabase/server.ts", + ]); + expect([...clientGraph].map(relative).filter((file) => forbidden.has(file))).toEqual([]); + }); + + it("does not make runtime source modules depend on operational scripts", () => { + const scriptImports = sourceFiles().flatMap((file) => { + const modules = moduleSpecifiers(file); + return [...modules.staticImports, ...modules.dynamicImports] + .filter((specifier) => + path.resolve(path.dirname(file), specifier).startsWith(path.join(projectRoot, "scripts")), + ) + .map((specifier) => `${relative(file)} -> ${specifier}`); + }); + expect(scriptImports).toEqual([]); + }); + + it("does not defeat a dynamic boundary with a static import of the same module", () => { + const { fileSet, parsed } = runtimeGraph(); + const collisions: string[] = []; + for (const [file, modules] of parsed) { + const staticTargets = new Set( + [...modules.staticImports] + .map((specifier) => resolveModule(file, specifier, fileSet)) + .filter((target): target is string => Boolean(target)), + ); + for (const specifier of modules.dynamicImports) { + const target = resolveModule(file, specifier, fileSet); + if (target && staticTargets.has(target)) collisions.push(`${relative(file)} -> ${relative(target)}`); + } + } + expect(collisions).toEqual([]); + }); +}); diff --git a/tests/bounded-ttl-cache.test.ts b/tests/bounded-ttl-cache.test.ts new file mode 100644 index 000000000..e61d8cfe1 --- /dev/null +++ b/tests/bounded-ttl-cache.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { readExpiringCacheEntry, writeBoundedExpiringCacheEntry } from "@/lib/bounded-ttl-cache"; + +describe("bounded TTL cache", () => { + it("removes expired entries when they are read", () => { + const cache = new Map([["expired", { expiresAt: 99, value: "stale" }]]); + + expect(readExpiringCacheEntry(cache, "expired", 100)).toBeNull(); + expect(cache.has("expired")).toBe(false); + }); + + it("prunes expired entries before enforcing the entry limit", () => { + const cache = new Map([ + ["expired", { expiresAt: 99, value: "stale" }], + ["live", { expiresAt: 200, value: "current" }], + ]); + + writeBoundedExpiringCacheEntry(cache, "new", { expiresAt: 300, value: "new" }, 2, 100); + + expect([...cache.keys()]).toEqual(["live", "new"]); + }); + + it("evicts the oldest live entry when the cache exceeds its bound", () => { + const cache = new Map([ + ["first", { expiresAt: 200, value: 1 }], + ["second", { expiresAt: 200, value: 2 }], + ]); + + writeBoundedExpiringCacheEntry(cache, "third", { expiresAt: 200, value: 3 }, 2, 100); + + expect([...cache.keys()]).toEqual(["second", "third"]); + }); +}); From a9bfec344890d619c70e913346d408bb671cd320 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 11 Jul 2026 02:56:25 +0800 Subject: [PATCH 2/3] docs: record architecture integration review --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 819138604..579bb5ad8 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -28,3 +28,4 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD | 2026-07-10 | codex/review-autofix-flow | 155c801cd58f797037d8aaa8b885405a1c599249 | codex-autofix-residual-fixes | Replaced one-shot PR deduplication with a three-cycle head-SHA cap, made comment permission failures fail visibly, and pinned `github-script` v9.0.0 to its verified immutable commit. | TDD red run (7 expected failures); focused Vitest green run (15 passed); `npm run verify:cheap` (152 files passed, 1 skipped; 1,426 tests passed, 1 skipped); focused Prettier check; `git diff --check`; official `git ls-remote` tag verification | | 2026-07-10 | codex/architecture-review-fixes | 648abfa3f7c91395b5eeca543f70e0b6ea59e9e0 | architecture-review | Seven findings fixed in the working tree: three runtime cycles, unbounded owner caches, a client/server env boundary breach, reversed runtime-to-scripts ownership, and architecture-doc drift. | `npm run test -- tests/architecture-boundaries.test.ts tests/bounded-ttl-cache.test.ts tests/rag-score.test.ts tests/rag-cache-utils.test.ts tests/rag-cache-invalidation.test.ts tests/evidence-panels.test.ts tests/clinical-dashboard-merge-artifacts.test.ts`; `npm run verify:cheap`; `npm run check:production-readiness:ci` | | 2026-07-10 | codex/architecture-review-fixes | 648abfa3f7c91395b5eeca543f70e0b6ea59e9e0 | frontend-architecture-review | Shared cycle/env findings confirmed and fixed; three additional findings fixed for defeated lazy boundaries, duplicate shell/dashboard subscriptions, and unstable search-context values. | `npm run test -- tests/architecture-boundaries.test.ts tests/evidence-panels.test.ts tests/clinical-dashboard-merge-artifacts.test.ts`; `npm run verify:cheap`; UI gate deferred pending explicit local-API approval | +| 2026-07-11 | codex/architecture-review-integration | b45df727b29aad8ba4ec5d4e96d1f0599d7dad8a | branch-integration-review | Replayed the reviewed architecture fixes onto current `origin/main`; preserved current CI/autofix history and found no new high-confidence defect in the integrated diff. | `npm run check:runtime`; `npm run check:github-actions`; `npm run sitemap:check`; `npm run lint`; `npm run typecheck`; focused Vitest (24 passed); full Vitest with `--testTimeout=30000` (1,433 passed, 1 skipped); `git diff --check` | From 665103250ccc33b5870862b8d8467607a1ae5d23 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 11 Jul 2026 03:19:54 +0800 Subject: [PATCH 3/3] docs: format branch review ledger --- docs/branch-review-ledger.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 579bb5ad8..80b660b16 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -18,14 +18,14 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD ## Review Records -| Date | Branch or ref | Reviewed HEAD | Scope | Outcome | Checks | -| ---------- | ----------------------------- | ---------------------------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 2026-07-09 | example/branch | abc1234 | branch-cleanup | Example: already merged into `main`; no unique patch content. | `git log --right-only --cherry-pick main...example/branch`; `git diff --name-status main...example/branch` | -| 2026-07-10 | codex/pr-testing-streamlining | 155c801cd58f797037d8aaa8b885405a1c599249 | working-tree diff: PR testing streamlining | Changes requested: 2 P1 clinical-gate defects and 7 P2/P3 scope, local parity, and UI-process defects. | `npm run check:ci-scope`; `npm run check:github-actions`; `npm run check:codex-autofix-workflow`; `npm run eval:rag:offline`; `npm run test:e2e:critical`; targeted scope classifications; `git diff --check` | -| 2026-07-10 | codex/pr-testing-streamlining | 155c801cd58f797037d8aaa8b885405a1c599249 | working-tree remediation review | All recorded P1-P3 findings fixed; no remaining high-confidence issue in the changed scope. | `npm run verify:cheap`; `npm run verify:pr-local`; `npm run eval:rag:offline`; `npm run test:e2e:critical`; `npm run test:e2e:advisory`; CI YAML parse; scope/action/Codex guards; `git diff --check` | -| 2026-07-10 | codex/review-autofix-flow | 155c801cd58f797037d8aaa8b885405a1c599249 | codex-autofix-flow | Fixed exact connector authorization, trusted-marker deduplication, and strict self-trigger matching; added regression coverage. | `npm run check:codex-autofix-workflow`; focused Vitest (4 passed); `npm run verify:cheap` pre-test stages passed before tool timeout; `npm test` (1,415 passed, 1 skipped); focused Prettier check; `npm run check:github-actions` | -| 2026-07-10 | codex/review-autofix-flow | 155c801cd58f797037d8aaa8b885405a1c599249 | codex-autofix-flow-followup | Fixed untrusted workflow-level concurrency interference and migrated the bridge from the Node 20 action runtime to `actions/github-script@v9`; added direct embedded-script execution coverage. | Focused Vitest (13 passed); targeted ESLint; `tsc --noEmit`; `npm run check:codex-autofix-workflow`; `npm run check:github-actions`; focused Prettier check; `git diff --check` | -| 2026-07-10 | codex/review-autofix-flow | 155c801cd58f797037d8aaa8b885405a1c599249 | codex-autofix-residual-fixes | Replaced one-shot PR deduplication with a three-cycle head-SHA cap, made comment permission failures fail visibly, and pinned `github-script` v9.0.0 to its verified immutable commit. | TDD red run (7 expected failures); focused Vitest green run (15 passed); `npm run verify:cheap` (152 files passed, 1 skipped; 1,426 tests passed, 1 skipped); focused Prettier check; `git diff --check`; official `git ls-remote` tag verification | -| 2026-07-10 | codex/architecture-review-fixes | 648abfa3f7c91395b5eeca543f70e0b6ea59e9e0 | architecture-review | Seven findings fixed in the working tree: three runtime cycles, unbounded owner caches, a client/server env boundary breach, reversed runtime-to-scripts ownership, and architecture-doc drift. | `npm run test -- tests/architecture-boundaries.test.ts tests/bounded-ttl-cache.test.ts tests/rag-score.test.ts tests/rag-cache-utils.test.ts tests/rag-cache-invalidation.test.ts tests/evidence-panels.test.ts tests/clinical-dashboard-merge-artifacts.test.ts`; `npm run verify:cheap`; `npm run check:production-readiness:ci` | -| 2026-07-10 | codex/architecture-review-fixes | 648abfa3f7c91395b5eeca543f70e0b6ea59e9e0 | frontend-architecture-review | Shared cycle/env findings confirmed and fixed; three additional findings fixed for defeated lazy boundaries, duplicate shell/dashboard subscriptions, and unstable search-context values. | `npm run test -- tests/architecture-boundaries.test.ts tests/evidence-panels.test.ts tests/clinical-dashboard-merge-artifacts.test.ts`; `npm run verify:cheap`; UI gate deferred pending explicit local-API approval | -| 2026-07-11 | codex/architecture-review-integration | b45df727b29aad8ba4ec5d4e96d1f0599d7dad8a | branch-integration-review | Replayed the reviewed architecture fixes onto current `origin/main`; preserved current CI/autofix history and found no new high-confidence defect in the integrated diff. | `npm run check:runtime`; `npm run check:github-actions`; `npm run sitemap:check`; `npm run lint`; `npm run typecheck`; focused Vitest (24 passed); full Vitest with `--testTimeout=30000` (1,433 passed, 1 skipped); `git diff --check` | +| Date | Branch or ref | Reviewed HEAD | Scope | Outcome | Checks | +| ---------- | ------------------------------------- | ---------------------------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 2026-07-09 | example/branch | abc1234 | branch-cleanup | Example: already merged into `main`; no unique patch content. | `git log --right-only --cherry-pick main...example/branch`; `git diff --name-status main...example/branch` | +| 2026-07-10 | codex/pr-testing-streamlining | 155c801cd58f797037d8aaa8b885405a1c599249 | working-tree diff: PR testing streamlining | Changes requested: 2 P1 clinical-gate defects and 7 P2/P3 scope, local parity, and UI-process defects. | `npm run check:ci-scope`; `npm run check:github-actions`; `npm run check:codex-autofix-workflow`; `npm run eval:rag:offline`; `npm run test:e2e:critical`; targeted scope classifications; `git diff --check` | +| 2026-07-10 | codex/pr-testing-streamlining | 155c801cd58f797037d8aaa8b885405a1c599249 | working-tree remediation review | All recorded P1-P3 findings fixed; no remaining high-confidence issue in the changed scope. | `npm run verify:cheap`; `npm run verify:pr-local`; `npm run eval:rag:offline`; `npm run test:e2e:critical`; `npm run test:e2e:advisory`; CI YAML parse; scope/action/Codex guards; `git diff --check` | +| 2026-07-10 | codex/review-autofix-flow | 155c801cd58f797037d8aaa8b885405a1c599249 | codex-autofix-flow | Fixed exact connector authorization, trusted-marker deduplication, and strict self-trigger matching; added regression coverage. | `npm run check:codex-autofix-workflow`; focused Vitest (4 passed); `npm run verify:cheap` pre-test stages passed before tool timeout; `npm test` (1,415 passed, 1 skipped); focused Prettier check; `npm run check:github-actions` | +| 2026-07-10 | codex/review-autofix-flow | 155c801cd58f797037d8aaa8b885405a1c599249 | codex-autofix-flow-followup | Fixed untrusted workflow-level concurrency interference and migrated the bridge from the Node 20 action runtime to `actions/github-script@v9`; added direct embedded-script execution coverage. | Focused Vitest (13 passed); targeted ESLint; `tsc --noEmit`; `npm run check:codex-autofix-workflow`; `npm run check:github-actions`; focused Prettier check; `git diff --check` | +| 2026-07-10 | codex/review-autofix-flow | 155c801cd58f797037d8aaa8b885405a1c599249 | codex-autofix-residual-fixes | Replaced one-shot PR deduplication with a three-cycle head-SHA cap, made comment permission failures fail visibly, and pinned `github-script` v9.0.0 to its verified immutable commit. | TDD red run (7 expected failures); focused Vitest green run (15 passed); `npm run verify:cheap` (152 files passed, 1 skipped; 1,426 tests passed, 1 skipped); focused Prettier check; `git diff --check`; official `git ls-remote` tag verification | +| 2026-07-10 | codex/architecture-review-fixes | 648abfa3f7c91395b5eeca543f70e0b6ea59e9e0 | architecture-review | Seven findings fixed in the working tree: three runtime cycles, unbounded owner caches, a client/server env boundary breach, reversed runtime-to-scripts ownership, and architecture-doc drift. | `npm run test -- tests/architecture-boundaries.test.ts tests/bounded-ttl-cache.test.ts tests/rag-score.test.ts tests/rag-cache-utils.test.ts tests/rag-cache-invalidation.test.ts tests/evidence-panels.test.ts tests/clinical-dashboard-merge-artifacts.test.ts`; `npm run verify:cheap`; `npm run check:production-readiness:ci` | +| 2026-07-10 | codex/architecture-review-fixes | 648abfa3f7c91395b5eeca543f70e0b6ea59e9e0 | frontend-architecture-review | Shared cycle/env findings confirmed and fixed; three additional findings fixed for defeated lazy boundaries, duplicate shell/dashboard subscriptions, and unstable search-context values. | `npm run test -- tests/architecture-boundaries.test.ts tests/evidence-panels.test.ts tests/clinical-dashboard-merge-artifacts.test.ts`; `npm run verify:cheap`; UI gate deferred pending explicit local-API approval | +| 2026-07-11 | codex/architecture-review-integration | b45df727b29aad8ba4ec5d4e96d1f0599d7dad8a | branch-integration-review | Replayed the reviewed architecture fixes onto current `origin/main`; preserved current CI/autofix history and found no new high-confidence defect in the integrated diff. | `npm run check:runtime`; `npm run check:github-actions`; `npm run sitemap:check`; `npm run lint`; `npm run typecheck`; focused Vitest (24 passed); full Vitest with `--testTimeout=30000` (1,433 passed, 1 skipped); `git diff --check` |