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
3 changes: 2 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
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
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();
}
}

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
36 changes: 33 additions & 3 deletions src/components/factsheets/factsheet-detail-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
TriangleAlert,
Zap,
} from "lucide-react";
import { useState, type ReactNode } from "react";
import { useEffect, useState, type ReactNode } from "react";

import {
categoryTheme,
Expand All @@ -27,6 +27,12 @@ import {
} from "@/components/factsheets/factsheets-data";
import { factsheetGlyph } from "@/components/factsheets/factsheets-icons";
import { cn, toneDanger, toneWarning } from "@/components/ui-primitives";
import {
readSavedRegistrySlugs,
savedFactsheetsStorageKey,
subscribeSavedRegistrySlugs,
writeSavedRegistrySlugs,
} from "@/lib/saved-registry-storage";

function accentBorder(accent: string) {
return `color-mix(in srgb, ${accent} 35%, var(--surface))`;
Expand All @@ -40,12 +46,33 @@ export function FactsheetDetailPage({ factsheet }: { factsheet: Factsheet }) {
const theme = categoryTheme(factsheet.category);
const [readingLevel, setReadingLevel] = useState<"easy" | "standard">("easy");
const [saved, setSaved] = useState(false);
const [saveNotice, setSaveNotice] = useState("");
const [copied, setCopied] = useState(false);

const related = relatedFactsheets(factsheet.slug);
const moreInTopic = sameTopicFactsheets(factsheet.slug);
const toc = tocFor(factsheet);
const blocks = printBlocks(factsheet);
const blocks = printBlocks(factsheet, readingLevel);

useEffect(() => {
const refresh = () => setSaved(readSavedRegistrySlugs(savedFactsheetsStorageKey).includes(factsheet.slug));
refresh();
return subscribeSavedRegistrySlugs(refresh);
}, [factsheet.slug]);

function toggleSaved() {
const current = readSavedRegistrySlugs(savedFactsheetsStorageKey);
const next = current.includes(factsheet.slug)
? current.filter((slug) => slug !== factsheet.slug)
: [factsheet.slug, ...current];
if (!writeSavedRegistrySlugs(savedFactsheetsStorageKey, next)) {
setSaveNotice("Save failed. Check browser storage permissions and try again.");
return;
}
const nowSaved = next.includes(factsheet.slug);
setSaved(nowSaved);
setSaveNotice(nowSaved ? "Factsheet saved." : "Factsheet removed from saved items.");
}

function downloadPdf() {
if (typeof document === "undefined") return;
Expand Down Expand Up @@ -109,7 +136,7 @@ export function FactsheetDetailPage({ factsheet }: { factsheet: Factsheet }) {
) : null}
<button
type="button"
onClick={() => setSaved((value) => !value)}
onClick={toggleSaved}
aria-pressed={saved}
className={cn(
"inline-flex min-h-tap items-center gap-1.5 rounded-lg border px-3 text-sm font-bold transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]",
Expand All @@ -121,6 +148,9 @@ export function FactsheetDetailPage({ factsheet }: { factsheet: Factsheet }) {
<Bookmark className="h-4 w-4" aria-hidden="true" fill={saved ? "currentColor" : "none"} />
{saved ? "Saved" : "Save"}
</button>
<span aria-live="polite" className="sr-only">
{saveNotice}
</span>
<button
type="button"
onClick={downloadPdf}
Expand Down
Loading
Loading