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 });

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 | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== route.ts ==\n'
cat -n src/app/api/documents/[id]/summarize/route.ts

printf '\n== summarizeDocument references ==\n'
rg -n "function summarizeDocument|const summarizeDocument|export .*summarizeDocument|summarizeDocument\(" src -S

printf '\n== candidate implementation ==\n'
fd -a "summarizeDocument.*" src

Repository: BigSimmo/Database

Length of output: 3839


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== summarizeDocument implementation ==\n'
sed -n '5040,5145p' src/lib/rag.ts

printf '\n== stream route usage ==\n'
sed -n '200,260p' src/app/api/answer/stream/route.ts

Repository: BigSimmo/Database

Length of output: 7978


Thread the abort signal through summary retrieval.
request.signal only reaches structured generation here; the document/chunk reads still run after disconnect, and the unindexed fallback can still be computed. Pass the signal into those reads or bail out after each await so aborted requests stop work earlier.

🤖 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/documents/`[id]/summarize/route.ts at line 42, Update the
summarizeDocument flow called by the route to propagate request.signal through
document and chunk retrieval, including the unindexed fallback computation.
Ensure each awaited read observes the signal or checks for abortion immediately
afterward so disconnected requests stop further work, while preserving normal
summary generation behavior.

Source: Coding guidelines

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 +124 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 | 🏗️ Heavy lift

Evict the inflight entry when the last waiter aborts the shared producer (both coalescers). In both files, when waiters reaches 0 and work is unsettled you abort the shared controller, but the map entry is only removed in the producer's own .finally, which runs after the producer rejects. A fresh caller with the same key/text arriving in that window coalesces onto the aborting producer and receives a spurious abort rejection despite never cancelling. Evict the entry at the same point you trigger the abort.

  • src/app/api/search/route.ts#L124-L153: in the finally, when entry.waiters === 0 && !entry.settled, after entry.controller.abort() also do if (scopedSearchInflight.get(key) === entry) scopedSearchInflight.delete(key) (the key is already in scope here).
  • src/lib/openai.ts#L666-L674: thread the cache key from embedTextWithTelemetry into awaitInflightEmbedding and, on the last-waiter abort, if (queryEmbeddingInflight.get(key) === entry) queryEmbeddingInflight.delete(key) alongside entry.controller.abort().
📍 Affects 2 files
  • src/app/api/search/route.ts#L124-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 124 - 153, Evict inflight entries
when the final waiter aborts unsettled shared work. In
src/app/api/search/route.ts:124-153, update coalesceScopedSearch so the
last-waiter abort also conditionally deletes the matching scopedSearchInflight
entry. In src/lib/openai.ts:666-674, pass the cache key from
embedTextWithTelemetry into awaitInflightEmbedding and conditionally delete the
matching queryEmbeddingInflight entry alongside the 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