Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,9 @@ jobs:
- name: Setup Node and dependencies
uses: ./.github/actions/setup-node-cached

- name: Install deterministic Python PDF test prerequisite
run: python3 -m pip install --disable-pip-version-check "PyMuPDF==1.28.0"

- name: Unit tests (with coverage gate)
run: npm run test:coverage

Expand Down
6 changes: 5 additions & 1 deletion docs/branch-review-ledger.md

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
"test:e2e:visual": "node scripts/run-playwright.mjs --config=playwright.visual.config.ts",
"test:cross-tenant:staging": "node scripts/run-tsx.mjs scripts/test-cross-tenant-staging.ts",
"verify:cheap": "node scripts/run-heavy.mjs --npm-script verify:cheap:internal",
"verify:cheap:internal": "npm run check:runtime && npm run check:github-actions && npm run check:ci-scope && npm run check:ci-triage && npm run check:pr-policy && npm run sitemap:check && npm run brand:check && npm run check:type-scale && npm run check:icon-scale && npm run check:design-system-contract && npm run check:function-grants && npm run check:owner-scope && npm run lint && npm run typecheck && npm run test",
"verify:cheap:internal": "npm run check:runtime && npm run check:github-actions && npm run check:ci-scope && npm run check:ci-triage && npm run check:pr-policy && npm run sitemap:check && npm run brand:check && npm run check:therapy-data-index && npm run check:type-scale && npm run check:icon-scale && npm run check:design-system-contract && npm run check:function-grants && npm run check:owner-scope && npm run lint && npm run typecheck && npm run test",
"verify:pr-local": "node scripts/verify-pr-local.mjs",
"verify:ui": "npm run check:runtime && npm run test:e2e:pr",
"verify:release": "npm run check:runtime && npm run lint && npm run typecheck && npm run test && npm run build && npm run test:e2e && npm run check:production-readiness && npm run governance:release && npm run eval:quality:release",
Expand All @@ -65,6 +65,7 @@
"sitemap:check": "node scripts/run-tsx.mjs scripts/generate-site-map.ts --check",
"brand:update": "node scripts/run-tsx.mjs scripts/generate-brand-assets.ts",
"brand:check": "node scripts/run-tsx.mjs scripts/generate-brand-assets.ts --check",
"check:therapy-data-index": "node scripts/build-therapies-index.mjs --check",
"check:runtime": "node scripts/run-tsx.mjs scripts/check-runtime.ts",
"check:codex-autofix-workflow": "node scripts/check-codex-autofix-workflow.mjs",
"check:deployment-readiness": "node scripts/deployment-boot-smoke.mjs",
Expand Down
4 changes: 4 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ export default defineConfig({
baseURL,
trace: "retain-on-failure",
screenshot: "only-on-failure",
// Page routes cannot intercept requests made by a controlling service
// worker. Block workers for deterministic API mocks; ui-pwa.spec.ts opts
// back in explicitly for the dedicated worker lifecycle coverage.
serviceWorkers: "block",
// Disable CSS/web animations suite-wide so a click can't land mid-transition
// on a moving target (documented races in ui-stress/ui-smoke). The dedicated
// reduced-motion a11y spec emulates a per-test mode, so suite-wide settings
Expand Down
5,853 changes: 5,853 additions & 0 deletions public/therapy-compass-data/therapies-index.json

Large diffs are not rendered by default.

56 changes: 53 additions & 3 deletions scripts/build-therapies-index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ import { fileURLToPath } from "node:url";

const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const source = join(root, "public", "therapy-compass-data", "therapies.json");
const target = join(root, "src", "data", "therapies-index.json");
const serverTarget = join(root, "src", "data", "therapies-index.json");
const browserTarget = join(root, "public", "therapy-compass-data", "therapies-index.json");
const checkOnly = process.argv.includes("--check");

const therapies = JSON.parse(readFileSync(source, "utf8"));

Expand All @@ -35,5 +37,53 @@ const projected = therapies
}))
.sort((a, b) => a.name.localeCompare(b.name));

writeFileSync(target, `${JSON.stringify(projected, null, 2)}\n`);
console.log(`Wrote ${projected.length} therapy records to ${target}`);
const browserProjected = therapies
.map((therapy) => ({
slug: therapy.slug,
name: therapy.name,
category: therapy.category ?? null,
modality: therapy.modality ?? null,
clinicalSummary: therapy.clinicalSummary ?? null,
bestUsedFor: therapy.bestUsedFor ?? null,
indications: therapy.indications ?? null,
contraindicationsOrCautions: therapy.contraindicationsOrCautions ?? null,
targetSymptoms: therapy.targetSymptoms ?? null,
patientPopulation: therapy.patientPopulation ?? null,
setting: therapy.setting ?? null,
reviewStatus: therapy.reviewStatus ?? "needs_review",
patientSheetAvailable: Boolean(therapy.patientSheetAvailable),
briefInterventionAvailable: Boolean(therapy.briefInterventionAvailable),
tags: Array.isArray(therapy.tags) ? therapy.tags : [],
aliases: Array.isArray(therapy.aliases) ? therapy.aliases : [],
}))
.sort((a, b) => a.name.localeCompare(b.name));

function syncTarget(target, records) {
// Avoid a false OpenAI-key signature when ordinary words contain an embedded
// `sk-` sequence. JSON decoding restores the exact original string value.
const expected = `${JSON.stringify(records, null, 2).replace(/(?<=[A-Za-z0-9])sk-/g, "s\\u006b-")}\n`;
if (checkOnly) {
let actual = "";
try {
actual = readFileSync(target, "utf8");
} catch {
throw new Error(`Missing generated therapy index: ${target}`);
}
let parsed;
try {
parsed = JSON.parse(actual);
} catch {
throw new Error(`Generated therapy index is invalid JSON: ${target}`);
}
if (JSON.stringify(parsed) !== JSON.stringify(records)) {
throw new Error(`Generated therapy index is stale: ${target}`);
}
return;
}
writeFileSync(target, expected);
console.log(`Wrote ${records.length} therapy records to ${target}`);
}

syncTarget(serverTarget, projected);
syncTarget(browserTarget, browserProjected);
if (checkOnly) console.log(`Therapy indexes are current (${projected.length} records).`);
7 changes: 6 additions & 1 deletion scripts/ci-change-scope.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ const uiPatterns = [
/^tests\/ui-.*\.spec\.ts$/,
/^tests\/playwright-.*\.ts$/,
/^playwright(?:\..*)?\.config\.ts$/,
/^scripts\/(run-playwright|playwright-base-url)\.mjs$/,
/^scripts\/(run-playwright|playwright-base-url)\.(?:mjs|ts)$/,
];

const dbPatterns = [
Expand Down Expand Up @@ -413,6 +413,11 @@ function selfTest() {
coverage_changed: true,
ui_changed: true,
});
assertScope("playwright-base-url-helper", ["scripts/playwright-base-url.ts"], {
source_changed: true,
coverage_changed: true,
ui_changed: true,
});
assertScope("runtime-data", ["data/medications-snapshot.json"], {
source_changed: true,
coverage_changed: true,
Expand Down
8 changes: 6 additions & 2 deletions scripts/eval-retrieval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,13 +244,17 @@ const clinicalDocumentAliases: Record<string, string[]> = {
"Active Community Patients in the Emergency Department",
"Active Community Patients Emergency Department",
],
"Alcohol withdrawal": [
"Alcohol and Other Drugs - Addiction, Toxicity and Withdrawal",
"Alcohol and Other Drugs Management Guideline",
],
ClozapinePresAdminMonitor: [
"Clozapine Prescribing Administration Monitoring",
"Clozapine Prescribing Administration and Monitoring",
"Clozapine Prescribing Administering Monitoring",
"Clozapine Prescribing Administering Monitoring and Capillary Sampling",
],
PtSafetyPlan: ["Patient Safety Plan"],
PtSafetyPlan: ["Patient Safety Plan", "Safety Planning", "Safety Plan"],
};

const clinicalContentAliases: Record<string, string[]> = {
Expand Down Expand Up @@ -291,7 +295,7 @@ function textContainsClinicalTerm(text: string, term: string) {
const normalizedTerm = normalized(term);
if (!normalizedTerm) return false;
const escaped = normalizedTerm.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\s+/g, "\\s+");
return new RegExp(`(?:^|\\s)${escaped}(?:$|\\s)`).test(text);
return new RegExp(`(?:^|[^a-z0-9])${escaped}(?=$|[^a-z0-9])`).test(text);
}

function resultDocumentText(result: SearchResult) {
Expand Down
3 changes: 3 additions & 0 deletions scripts/run-live-tests.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@
import { spawnSync } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
import nextEnv from "@next/env";
import { childProcessExitCode } from "./child-process-result.mjs";
import { requireProviderTestPermission } from "./test-environment.mjs";
import { acquireHeavyRunLock } from "./test-run-lock.mjs";

const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const vitestBin = path.join(projectRoot, "node_modules", "vitest", "vitest.mjs");
const { loadEnvConfig } = nextEnv;
loadEnvConfig(projectRoot);

try {
requireProviderTestPermission();
Expand Down
13 changes: 10 additions & 3 deletions scripts/verify-release-offline.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ const steps = [
"test",
"build",
"test:e2e",
"check:production-readiness",
"governance:release",
"eval:quality:release:offline",
"check:function-grants",
"check:owner-scope",
"check:rag:fixtures",
"eval:rag:offline",
];
const offlineEnv = {
...process.env,
Expand All @@ -23,6 +24,12 @@ const offlineEnv = {
OPENAI_API_KEY: "",
OPENAI_ORG_ID: "",
OPENAI_PROJECT_ID: "",
NEXT_PUBLIC_SUPABASE_URL: "https://offline.invalid",
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: "offline-placeholder",
SUPABASE_SERVICE_ROLE_KEY: "offline-placeholder",
SUPABASE_DB_URL: "postgresql://offline:offline@offline.invalid:5432/offline",
CROSS_TENANT_SUPABASE_URL: "https://offline.invalid",
CROSS_TENANT_SUPABASE_SERVICE_ROLE_KEY: "offline-placeholder",
};

for (const step of steps) {
Expand Down
4 changes: 2 additions & 2 deletions src/app/api/documents/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } f
import { writeAuditLog } from "@/lib/audit";
import {
DocumentDetailRateLimitError,
documentDetailQuerySchema,
documentDetailApiQuerySchema,
loadAuthorizedDocumentDetail,
} from "@/lib/document-detail";
import { parseJsonBody } from "@/lib/validation/body";
Expand Down Expand Up @@ -114,7 +114,7 @@ async function updateStorageCleanupJob(args: {
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id: rawId } = await params;
const detailQuery = parseRequestQuery(request, documentDetailQuerySchema, "Invalid document detail query.");
const detailQuery = parseRequestQuery(request, documentDetailApiQuerySchema, "Invalid document detail query.");
const payload = await loadAuthorizedDocumentDetail({ request, rawId, query: detailQuery });
return NextResponse.json(payload);
} catch (error) {
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/documents/[id]/summarize/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
const rateLimit = await consumeApiRateLimit({ supabase, ownerId: user.id, bucket: "document_summarize" });
if (rateLimit.limited)
return rateLimitJsonResponse("Too many document summary requests. Retry shortly.", rateLimit);
const answer = await summarizeDocument(id, user.id);
const answer = await summarizeDocument(id, user.id, { signal: request.signal });
const governedResponse = buildGovernedAnswerClientResponse(answer);
logAnswerDiagnostics({
supabase,
Expand Down
82 changes: 71 additions & 11 deletions src/app/api/search/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,13 @@ const searchSchema = z.object({

type SearchRequestBody = z.infer<typeof searchSchema>;

const scopedSearchInflight = new Map<string, Promise<unknown>>();
type ScopedSearchInflight = {
promise: Promise<Record<string, unknown>>;
controller: AbortController;
waiters: number;
settled: boolean;
};
const scopedSearchInflight = new Map<string, ScopedSearchInflight>();

function isSourceLibrarySearchMode(mode: SearchRequestBody["mode"]) {
return mode === "documents" || mode === "differentials";
Expand All @@ -89,15 +95,62 @@ function scopedSearchKey(body: SearchRequestBody, ownerId?: string | null, publi
});
}

async function coalesceScopedSearch<T extends Record<string, unknown>>(key: string, producer: () => Promise<T>) {
const existing = scopedSearchInflight.get(key) as Promise<T> | undefined;
if (existing) return { payload: await existing, coalesced: true };
function callerAbortReason(signal: AbortSignal): Error {
return signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted.", "AbortError");
}

const pending = producer().finally(() => {
scopedSearchInflight.delete(key);
function awaitWithCallerSignal<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
if (signal.aborted) return Promise.reject(callerAbortReason(signal));
return new Promise<T>((resolve, reject) => {
const onAbort = () => {
cleanup();
reject(callerAbortReason(signal));
};
const cleanup = () => signal.removeEventListener("abort", onAbort);
signal.addEventListener("abort", onAbort, { once: true });
promise.then(
(value) => {
cleanup();
resolve(value);
},
(error) => {
cleanup();
reject(error);
},
);
});
scopedSearchInflight.set(key, pending);
return { payload: await pending, coalesced: false };
}

async function coalesceScopedSearch<T extends Record<string, unknown>>(
key: string,
producer: (signal: AbortSignal) => Promise<T>,
signal: AbortSignal,
) {
signal.throwIfAborted();
let entry = scopedSearchInflight.get(key);
const coalesced = Boolean(entry);
if (!entry) {
const controller = new AbortController();
const created: ScopedSearchInflight = {
promise: Promise.resolve({}),
controller,
waiters: 0,
settled: false,
};
created.promise = producer(controller.signal).finally(() => {
created.settled = true;
if (scopedSearchInflight.get(key) === created) scopedSearchInflight.delete(key);
});
scopedSearchInflight.set(key, created);
entry = created;
}
entry.waiters += 1;
try {
return { payload: (await awaitWithCallerSignal(entry.promise, signal)) as T, coalesced };
} finally {
entry.waiters -= 1;
if (entry.waiters === 0 && !entry.settled) entry.controller.abort();
}
Comment on lines +148 to +153

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Both coalescers abort the shared producer while its entry is still published. Eviction happens only in the producer's .finally, so a caller arriving between controller.abort() and that microtask joins a doomed promise and fails with an unrelated caller's AbortError.

  • src/app/api/search/route.ts#L148-L153: delete the key from scopedSearchInflight when waiters === 0 && !settled, before calling entry.controller.abort().
  • src/lib/openai.ts#L666-L674: pass the cache key into awaitInflightEmbedding (or store it on the entry) and delete it from queryEmbeddingInflight before aborting.
📍 Affects 2 files
  • src/app/api/search/route.ts#L148-L153 (this comment)
  • src/lib/openai.ts#L666-L674
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/api/search/route.ts` around lines 148 - 153, The coalescers must
remove an abandoned in-flight entry before aborting its shared producer. In
src/app/api/search/route.ts lines 148-153, update the waiters cleanup in the
shared-entry flow to delete the key from scopedSearchInflight when waiters
reaches zero and the entry is unsettled, then abort the controller. In
src/lib/openai.ts lines 666-674, pass the cache key into awaitInflightEmbedding
or store it on the entry, delete it from queryEmbeddingInflight before aborting,
and preserve existing behavior for settled or still-waited entries.

}

function buildDocumentMatchesFromResults(results: SearchResult[], limit: number) {
Expand Down Expand Up @@ -190,7 +243,6 @@ function compactImage(image: ChunkImage) {
image_type: image.image_type,
clinicalUseClass: image.clinicalUseClass,
caption: image.caption ? compactText(image.caption, 240) : "",
storage_path: image.storage_path,
searchable: image.searchable,
clinical_relevance_score: image.clinical_relevance_score,
tableLabel: image.tableLabel,
Expand Down Expand Up @@ -686,7 +738,9 @@ async function buildScopedSearchPayload(
body: SearchRequestBody,
supabase: ReturnType<typeof createAdminClient>,
ownerId?: string | null,
signal?: AbortSignal,
) {
signal?.throwIfAborted();
const searchFocusQuery = queryForClinicalMode(body.query, body.queryMode);
const effectiveQueryClass =
queryClassForClinicalMode(body.queryMode) ?? classifyRagQuery(searchFocusQuery).queryClass;
Expand All @@ -696,7 +750,9 @@ async function buildScopedSearchPayload(
accessScope,
documentIds: body.documentIds ?? (body.documentId ? [body.documentId] : undefined),
filters: body.filters,
signal,
});
signal?.throwIfAborted();
if (scope.documentIds?.length === 0) {
const relevance = buildEvidenceRelevance(searchFocusQuery, []);
const payload = {
Expand Down Expand Up @@ -741,6 +797,7 @@ async function buildScopedSearchPayload(
accessScope,
allowGlobalSearch: !ownerId,
queryMode: body.queryMode,
signal,
});
const resultLimit = isSourceLibrarySearchMode(body.mode)
? Math.max(body.topK ?? 12, Math.min(20, body.documentLimit))
Expand All @@ -760,6 +817,7 @@ async function buildScopedSearchPayload(
query: searchFocusQuery,
results,
limit: isSourceLibrarySearchMode(body.mode) ? body.documentLimit : undefined,
signal,
})
: [];
// Audit L10: compute relevance/visual evidence ONCE and share with the
Expand Down Expand Up @@ -930,8 +988,10 @@ export async function POST(request: Request) {
}

const key = scopedSearchKey(searchBody, ownerId, publicOnly);
const { payload, coalesced } = await coalesceScopedSearch(key, () =>
buildScopedSearchPayload(searchBody, supabase!, ownerId),
const { payload, coalesced } = await coalesceScopedSearch(
key,
(signal) => buildScopedSearchPayload(searchBody, supabase!, ownerId, signal),
request.signal,
);
return NextResponse.json({
...payload,
Expand Down
1 change: 0 additions & 1 deletion src/components/ClinicalDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2372,7 +2372,6 @@ export function ClinicalDashboard({
"",
documentsSearchHref({
query: trimmedQuery,
focus: true,
run: true,
...navigationContext,
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,6 @@ function GlobalStandaloneSearchShellClient({
navigateToMode(searchMode, {
query: trimmedQuery || undefined,
run: Boolean(trimmedQuery),
focus: true,
});
}

Expand Down
Loading
Loading