Skip to content
Merged
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
18 changes: 9 additions & 9 deletions docs/operator-backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,15 @@ Findings inventory for handover: [audit-handover-2026-07-14.md](audit-handover-2

## Launch-gating actions

| Action | Status | Blocked by | Verify command | Runbook |
| -------------------------------------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Apply July-8 migration batch (a–g) to live | ✅ done | — | `SUPABASE_ENVIRONMENT=production npm run check:july8-live-batch` (2026-07-13: 6 live, apply=no-op) | [operator-apply-july8-batch.md](operator-apply-july8-batch.md) |
| Apply drift-codify forward migration (step 1h) | ✅ done | — | Applied and drift/readiness verified 2026-07-13; verify only unless new reviewed drift is found | [database-drift-detection.md](database-drift-detection.md) |
| Apply repo-ahead migrations to live (post-2026-07-13) | ⏳ pending | Queue includes `20260717120000`, `20260717170000`, and `20260717171000`; a forward migration to purge private/non-indexed `document_title_words` rows must be merged and reviewed before apply | Zero unsafe title-word rows; `npm run check:drift`; then `eval:retrieval:quality` (36/36) for the corrector | [deploy-corrector-public-titles.md](deploy-corrector-public-titles.md) · [operator-apply-performance-latency-remediation.md](operator-apply-performance-latency-remediation.md) |
| Full release gate (bounded OpenAI spend) | ⏳ pending | migrations 1 applied | `npm run verify:release`; `npm run eval:quality -- --rag-only` | [launch-operator-runbook.md §2](launch-operator-runbook.md) |
| Provision staging Supabase project (`Clinical KB Staging`, ap-southeast-2) | ⏳ pending | — | `npm run check:indexing` after `db push` | [staging-setup.md](staging-setup.md) |
| Staging soak + rollback rehearsal on Railway | ⏳ pending | staging provisioned | `scripts/soak-test.ts --confirm-staging` (answer p95 ≤ 25 s) | [launch-operator-runbook.md §4](launch-operator-runbook.md) · [capacity-review.md](capacity-review.md) |
| Production deploy to Railway | ✅ done | — | App deployment recorded live 2026-07-14; re-verify with `GET /api/health` and deployment readiness | [deployment-architecture.md](deployment-architecture.md) |
| Action | Status | Blocked by | Verify command | Runbook |
| -------------------------------------------------------------------------- | ---------- | -------------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Apply July-8 migration batch (a–g) to live | ✅ done | — | `SUPABASE_ENVIRONMENT=production npm run check:july8-live-batch` (2026-07-13: 6 live, apply=no-op) | [operator-apply-july8-batch.md](operator-apply-july8-batch.md) |
| Apply drift-codify forward migration (step 1h) | ✅ done | — | Applied and drift/readiness verified 2026-07-13; verify only unless new reviewed drift is found | [database-drift-detection.md](database-drift-detection.md) |
| Apply repo-ahead migrations to live (post-2026-07-13) | ✅ done | — | Zero unsafe title-word rows; `npm run check:drift`; then `eval:retrieval:quality` (36/36) for the corrector | [deploy-corrector-public-titles.md](deploy-corrector-public-titles.md) · [operator-apply-performance-latency-remediation.md](operator-apply-performance-latency-remediation.md) |
| Full release gate (bounded OpenAI spend) | ⏳ pending | migrations 1 applied | `npm run verify:release`; `npm run eval:quality -- --rag-only` | [launch-operator-runbook.md §2](launch-operator-runbook.md) |
| Provision staging Supabase project (`Clinical KB Staging`, ap-southeast-2) | ⏳ pending | — | `npm run check:indexing` after `db push` | [staging-setup.md](staging-setup.md) |
| Staging soak + rollback rehearsal on Railway | ⏳ pending | staging provisioned | `scripts/soak-test.ts --confirm-staging` (answer p95 ≤ 25 s) | [launch-operator-runbook.md §4](launch-operator-runbook.md) · [capacity-review.md](capacity-review.md) |
| Production deploy to Railway | ✅ done | — | App deployment recorded live 2026-07-14; re-verify with `GET /api/health` and deployment readiness | [deployment-architecture.md](deployment-architecture.md) |

## Post-deploy actions

Expand Down
8 changes: 4 additions & 4 deletions src/app/api/documents/[id]/signed-url/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,10 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
const registryHref =
(typeof metadata.registry_detail_href === "string" && metadata.registry_detail_href) ||
registryCorpusDetailHref({
kind: metadata.registry_record_kind,
slug: metadata.registry_record_slug,
subkind: metadata.registry_record_subkind,
recordId: metadata.registry_record_id,
kind: metadata.registry_record_kind as string | undefined,
slug: metadata.registry_record_slug as string | undefined,
subkind: metadata.registry_record_subkind as string | undefined,
recordId: metadata.registry_record_id as string | undefined,
});
if (source.source_kind === "registry_record" && registryHref) {
return NextResponse.json({
Expand Down
8 changes: 4 additions & 4 deletions src/components/clinical-dashboard/source-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,10 @@ export function sourceResultHref(source: SearchResult) {
? (source.source_metadata as Record<string, unknown>)
: {};
const registryHref = registryCorpusDetailHref({
kind: metadata.registry_record_kind,
slug: metadata.registry_record_slug,
subkind: metadata.registry_record_subkind,
recordId: metadata.registry_record_id,
kind: metadata.registry_record_kind as string | undefined,
slug: metadata.registry_record_slug as string | undefined,
subkind: metadata.registry_record_subkind as string | undefined,
recordId: metadata.registry_record_id as string | undefined,
});
if (registryHref) return registryHref;
return `/documents/${source.document_id}?page=${source.page_number ?? 1}&chunk=${source.id}`;
Expand Down
15 changes: 11 additions & 4 deletions src/lib/api-rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,10 +349,17 @@ function consumeInMemoryApiRateLimit({
const windowMs = windowSeconds * 1000;
const key = `${ownerId}:${bucket}`;

// Evict expired entries to prevent memory leak
for (const [k, v] of inMemoryApiRateLimits.entries()) {
if (now >= v.resetAtMs) {
inMemoryApiRateLimits.delete(k);
// Lazy per-key eviction: the most common path touches only the accessed entry.
// A full-map sweep runs only when the map exceeds a size ceiling so stale entries
// don't accumulate indefinitely, without scanning on every request.
const EVICTION_SIZE_CEILING = 2000;
const stale = inMemoryApiRateLimits.get(key);
if (stale && now >= stale.resetAtMs) {
inMemoryApiRateLimits.delete(key);
}
if (inMemoryApiRateLimits.size > EVICTION_SIZE_CEILING) {
for (const [k, v] of inMemoryApiRateLimits.entries()) {
if (now >= v.resetAtMs) inMemoryApiRateLimits.delete(k);
}
}

Expand Down
4 changes: 2 additions & 2 deletions src/lib/citations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,9 @@ function registryCitationHref(citation: Citation) {
if (!slug) return null;

return registryCorpusDetailHref({
kind: metadata.registry_record_kind,
kind: metadata.registry_record_kind ?? undefined,
slug: encodeURIComponent(slug),
subkind: metadata.registry_record_subkind,
subkind: metadata.registry_record_subkind ?? undefined,
});
}

Expand Down
1 change: 1 addition & 0 deletions src/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ const envSchema = z.object({
// batches of this size. 256 keeps total tokens well under the ceiling even for the
// largest (narrative-profile) chunks while staying far below the 2048 input cap.
OPENAI_EMBEDDING_BATCH_SIZE: z.coerce.number().int().positive().max(2048).default(256),
OPENAI_EMBEDDING_CONCURRENCY_LIMIT: z.coerce.number().int().positive().default(5),
OPENAI_VISION_MODEL: z.string().default("gpt-5.6-terra"),
OPENAI_VISION_IMAGE_DETAIL: z.enum(["auto", "low", "high"]).default("auto"),
OPENAI_REQUEST_TIMEOUT_MS: z.coerce.number().int().positive().default(45000),
Expand Down
118 changes: 79 additions & 39 deletions src/lib/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,11 @@ function isTimeoutError(error: unknown) {
}

export function mapOpenAIError(error: unknown, operation: OpenAIOperation) {
if (error instanceof PublicApiError) return error;
if (
error instanceof PublicApiError ||
(error && typeof error === "object" && "name" in error && error.name === "PublicApiError")
)
return error as PublicApiError;

const status = getErrorStatus(error);
const code = getErrorCode(error) ?? "openai_request_failed";
Expand Down Expand Up @@ -580,48 +584,84 @@ export async function embedTexts(texts: string[], options?: { signal?: AbortSign
// embedding of an unrelated text, even across batch boundaries (extends IDX-C1).
const byIndex = new Array<number[]>(uniqueTexts.length);
const batches = chunkIntoBatches(uniqueTexts, env.OPENAI_EMBEDDING_BATCH_SIZE);
let batchStart = 0;
for (const batch of batches) {
const response = await client.embeddings.create(
{
model: env.OPENAI_EMBEDDING_MODEL,
input: batch,
// IDX-C2: request the exact dimension the schema's vector(N) columns expect.
dimensions: env.EMBEDDING_DIMENSIONS,
},
requestOptions({ operation: "embedding", signal: options?.signal }),
);

// IDX-C2: a short response means some inputs silently produced no embedding.
if (response.data.length !== batch.length) {
throw new PublicApiError(
`OpenAI returned ${response.data.length} embeddings for ${batch.length} inputs.`,
502,
{ code: "openai_embedding_count_mismatch" },
);
}

// IDX-C1: the embeddings API does not guarantee response order; each item carries
// an explicit `index` into the request's input array. Reassemble by that index
// (offset to the global position) so embeddings are never mismatched to chunks.
for (const item of response.data) {
if (item.index < 0 || item.index >= batch.length) {
throw new PublicApiError(`OpenAI returned an out-of-range embedding index ${item.index}.`, 502, {
code: "openai_embedding_index_range",
});
}
// IDX-C2: guard against a model whose dimension does not match the schema.
if (item.embedding.length !== env.EMBEDDING_DIMENSIONS) {
throw new PublicApiError(
`OpenAI embedding has ${item.embedding.length} dimensions; expected ${env.EMBEDDING_DIMENSIONS}. ` +
`Check OPENAI_EMBEDDING_MODEL and EMBEDDING_DIMENSIONS match supabase/schema.sql.`,
502,
{ code: "openai_embedding_dimension_mismatch" },
// IDX-C3: a single embeddings request is capped at 2048 inputs / ~300k tokens, so a
// full-corpus re-embed must be split. To maximize throughput while respecting rate
// limits, batches run concurrently up to env.OPENAI_EMBEDDING_CONCURRENCY_LIMIT (default: 5).
// Reassembly uses the GLOBAL index (task.offset + item.index) so a chunk is never stored with the
// embedding of an unrelated text, even across batch/concurrency boundaries (extends IDX-C1).
let currentOffset = 0;
const tasks = batches.map((batch) => {
const offset = currentOffset;
currentOffset += batch.length;
return { batch, offset };
});

const concurrencyLimit = env.OPENAI_EMBEDDING_CONCURRENCY_LIMIT;
let nextTaskIndex = 0;
let firstError: unknown = null;

const worker = async () => {
while (nextTaskIndex < tasks.length && !firstError) {
if (options?.signal?.aborted) break;

const task = tasks[nextTaskIndex++];
if (!task) break;
Comment thread
BigSimmo marked this conversation as resolved.

try {
const response = await client.embeddings.create(
{
model: env.OPENAI_EMBEDDING_MODEL,
input: task.batch,
// IDX-C2: request the exact dimension the schema's vector(N) columns expect.
dimensions: env.EMBEDDING_DIMENSIONS,
},
requestOptions({ operation: "embedding", signal: options?.signal }),
);

// IDX-C2: a short response means some inputs silently produced no embedding.
if (response.data.length !== task.batch.length) {
throw new PublicApiError(
`OpenAI returned ${response.data.length} embeddings for ${task.batch.length} inputs.`,
502,
{ code: "openai_embedding_count_mismatch" },
);
}

// IDX-C1: the embeddings API does not guarantee response order; each item carries
// an explicit `index` into the request's input array. Reassemble by that index
// (offset to the global position) so embeddings are never mismatched to chunks.
for (const item of response.data) {
if (item.index < 0 || item.index >= task.batch.length) {
throw new PublicApiError(`OpenAI returned an out-of-range embedding index ${item.index}.`, 502, {
code: "openai_embedding_index_range",
});
}
// IDX-C2: guard against a model whose dimension does not match the schema.
if (item.embedding.length !== env.EMBEDDING_DIMENSIONS) {
throw new PublicApiError(
`OpenAI embedding has ${item.embedding.length} dimensions; expected ${env.EMBEDDING_DIMENSIONS}. ` +
`Check OPENAI_EMBEDDING_MODEL and EMBEDDING_DIMENSIONS match supabase/schema.sql.`,
502,
{ code: "openai_embedding_dimension_mismatch" },
);
}
byIndex[task.offset + item.index] = item.embedding;
}
} catch (err) {
if (!firstError) {
firstError = err;
}
break;
}
byIndex[batchStart + item.index] = item.embedding;
}
batchStart += batch.length;
};

const workers = Array.from({ length: Math.min(concurrencyLimit, tasks.length) }, worker);
await Promise.all(workers);

if (firstError) {
throw firstError;
}

return outputIndexes.map((index) => byIndex[index]);
Expand Down
10 changes: 6 additions & 4 deletions src/lib/registry-corpus-links.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { RegistryCorpusKind } from "./registry-corpus";

export function registryCorpusDetailHref(args: {
kind: unknown;
slug: unknown;
subkind?: unknown;
recordId?: unknown;
kind: RegistryCorpusKind | string | null | undefined;
slug: string | null | undefined;
subkind?: string | null | undefined;
recordId?: string | null | undefined;
}) {
const kind = typeof args.kind === "string" ? args.kind : null;
const slug = typeof args.slug === "string" ? args.slug : null;
Expand Down
8 changes: 4 additions & 4 deletions src/lib/universal-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,10 +426,10 @@ function searchResultDocumentHref(result: SearchResult) {
? (result.source_metadata as Record<string, unknown>)
: {};
const registryHref = registryCorpusDetailHref({
kind: metadata.registry_record_kind,
slug: metadata.registry_record_slug,
subkind: metadata.registry_record_subkind,
recordId: metadata.registry_record_id,
kind: metadata.registry_record_kind as string | undefined,
slug: metadata.registry_record_slug as string | undefined,
subkind: metadata.registry_record_subkind as string | undefined,
recordId: metadata.registry_record_id as string | undefined,
});
if (registryHref) return registryHref;
return `/documents/${result.document_id}`;
Expand Down
Loading
Loading