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
10 changes: 10 additions & 0 deletions src/app/api/health/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ import { allowDeepHealthProbe } from "@/lib/deep-probe-auth";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";

/**
* Reports application configuration and health status.
*
* The `deep=1` query parameter enables an authorized Supabase connectivity
* check. Deep checks are skipped in demo mode or when Supabase is not
* configured.
*
* @param request - The incoming request, including query parameters and deep-check authorization credentials
* @returns A JSON health report with an HTTP 200 status when ready, or 503 when a required check fails
*/
export async function GET(request: Request) {
const deep = new URL(request.url).searchParams.get("deep") === "1";
const supabaseConfigured = Boolean(env.NEXT_PUBLIC_SUPABASE_URL && env.SUPABASE_SERVICE_ROLE_KEY);
Expand Down
26 changes: 26 additions & 0 deletions src/app/api/setup-status/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,12 @@ function setupStatusCacheTtl(payload: SetupStatusPayload) {
return payload.indexingActive ? SETUP_STATUS_ACTIVE_CACHE_MS : SETUP_STATUS_IDLE_CACHE_MS;
}

/**
* Creates an HTTP response containing the setup status payload and polling metadata.
*
* @param payload - The setup status information to include in the response
* @returns A JSON response with caching and polling headers
*/
function setupStatusResponse(payload: SetupStatusPayload) {
return NextResponse.json(payload, {
headers: {
Expand All @@ -296,13 +302,24 @@ const COARSE_SETUP_DETAIL: Record<SetupCheckStatus, string> = {
unknown: "Status unavailable. Operators can see specifics via the health deep probe or server logs.",
};

/**
* Replaces setup check details with generic status-based messages.
*
* @param payload - The setup status payload whose check details should be coarsened
* @returns A setup status payload with sanitized check details
*/
function coarseSetupStatusPayload(payload: SetupStatusPayload): SetupStatusPayload {
return {
...payload,
checks: payload.checks.map((item) => ({ ...item, detail: COARSE_SETUP_DETAIL[item.status] })),
};
}

/**
* Builds the current setup and indexing status payload.
*
* @returns The setup checks, indexing activity state, polling interval, and generation timestamp.
*/
async function buildSetupStatusPayload(): Promise<SetupStatusPayload> {
const supabase = supabaseProjectCanBeQueried ? createAdminClient() : null;
const unavailable = await readSupabaseAvailability(supabase);
Expand Down Expand Up @@ -431,6 +448,15 @@ async function readSetupStatusPayload() {
}
}

/**
* Serves setup and health status for the current project request.
*
* Local requests receive detailed check information. Internet-reachable requests
* receive detailed information only when authorized for deep health probes;
* otherwise, per-check details are generalized.
*
* @returns An HTTP response containing setup status or an unsafe-origin response.
*/
export async function GET(request: Request) {
const identity = localProjectRequestIdentityPayload(request);
if (!identity.localServer.safeLocalOrigin) {
Expand Down
14 changes: 13 additions & 1 deletion src/lib/answer-verification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,12 @@ const actionableNumericSectionKinds = new Set<AnswerSectionKind>([
"required_actions",
]);

/**
* Determines whether an answer contains actionable numeric content that requires numeric verification.
*
* @param answer - The answer to evaluate
* @returns `true` if the answer contains actionable numeric context and is eligible for verification gating, `false` otherwise.
*/
function hasActionableNumericContext(answer: RagAnswer) {
if (!answer.grounded || answer.confidence === "unsupported") return false;
if (answer.queryClass === "medication_dose_risk" || answer.queryClass === "table_threshold") return true;
Expand All @@ -239,7 +245,13 @@ function hasActionableNumericContext(answer: RagAnswer) {
// `verificationSources` overrides the corpus numbers are checked against. The model path
// passes the packed context it actually generated from (answer.sources stays the unpacked
// answer-input set for the client/eval boundary); other callers omit it and verify against
// answer.sources as before.
/**
* Verifies numeric figures in an answer against its cited source content and annotates unsupported figures.
*
* @param answer - The answer whose numeric content is verified and updated.
* @param verificationSources - Optional source corpus to use instead of `answer.sources`.
* @returns The updated answer with faithfulness warnings and evidence-gap handling when figures are unsupported.
*/
export function applyNumericVerification(answer: RagAnswer, verificationSources?: SearchResult[]): RagAnswer {
const sources = verificationSources ?? answer.sources ?? [];
const unverified = new Set<string>();
Expand Down
30 changes: 28 additions & 2 deletions src/lib/api-rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import { PublicApiError } from "@/lib/http";
import type { RateLimitSubject } from "@/lib/public-api-access";
import type { createAdminClient } from "@/lib/supabase/admin";

/** Prefer durable RPC rate limits; fall back to per-instance memory when the DB function is unavailable. */
/**
* Determines whether in-memory rate limiting may be used when durable rate limiting is unavailable.
*
* @returns `true` in local no-auth mode or production, `false` otherwise.
*/
export function allowRateLimitInMemoryFallbackOnUnavailable() {
return isLocalNoAuthMode() || process.env.NODE_ENV === "production";
}
Expand All @@ -13,11 +17,23 @@ export function allowRateLimitInMemoryFallbackOnUnavailable() {
// when the durable limiter is unavailable. A per-process Map gives N× the intended limit across N
// horizontally-scaled instances during a limiter outage — unacceptable for expensive/abusable
// paths: `answer` (paid provider generation) and `document_upload` (storage writes + ingestion
// cost). Local-no-auth dev keeps the in-memory fallback for single-instance usability.
/**
* Identifies rate-limit buckets that must fail closed when the limiter is unavailable.
*
* @param bucket - The rate-limit bucket to evaluate
* @returns `true` for the `answer` and `document_upload` buckets, `false` otherwise
*/
function failsClosedOnLimiterUnavailable(bucket: ApiRateLimitBucket) {
return bucket === "answer" || bucket === "document_upload";
}

/**
* Determines whether anonymous requests may use an in-memory rate limiter when the durable limiter is unavailable.
*
* @param bucket - The rate-limit bucket being evaluated.
* @param allowInMemoryFallbackOnUnavailable - Whether to allow in-memory fallback when the durable limiter is unavailable.
* @returns `true` if in-memory fallback is allowed, `false` otherwise.
*/
function allowAnonymousRateLimitFallback(bucket: ApiRateLimitBucket, allowInMemoryFallbackOnUnavailable?: boolean) {
// Fail-closed buckets must not fall back to a per-instance limiter in a distributed production
// runtime. If the durable limiter is unavailable, fail closed before any expensive work starts.
Expand Down Expand Up @@ -149,6 +165,16 @@ export async function consumeApiRateLimit(args: {
};
}

/**
* Applies a durable rate limit to an owner or anonymous subject.
*
* Anonymous `answer` requests are constrained by both subject-specific and global
* limits. In-memory fallback is disabled for fail-closed buckets unless local
* no-auth mode is enabled.
*
* @param args - Rate-limiting configuration and subject identity.
* @returns The rate-limit decision and window metadata.
*/
export async function consumeSubjectApiRateLimit(args: {
supabase: SupabaseAdmin;
subject: RateLimitSubject;
Expand Down
7 changes: 6 additions & 1 deletion src/lib/deep-probe-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import { env } from "@/lib/env";
// `x-health-deep-token` header. Used by /api/health (deep Supabase probe) and
// /api/setup-status (detailed setup checks) so both surfaces gate internal error text and
// project posture behind the same secret. Constant-time compare; fails closed when the secret
// is unset or the token length/value differs.
/**
* Determines whether a request is authorized for deep health probe access.
*
* @param request - The request containing the deep probe authorization token
* @returns `true` if the request token matches the configured secret, `false` otherwise.
*/
export function allowDeepHealthProbe(request: Request): boolean {
const secret = env.HEALTH_DEEP_PROBE_SECRET;
if (!secret) return false;
Expand Down
17 changes: 16 additions & 1 deletion src/lib/rag-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,15 @@ export function packedContextCacheKey(
].join("|");
}

/**
* Adds compact context from adjacent document chunks to the leading search results.
*
* @param supabase - The Supabase client used to retrieve adjacent document chunks
* @param results - Search results to enrich with adjacent context
* @param queryClass - Query classification used to determine how many results to enrich
* @param options - Controls whether cross-document context packing is enabled
* @returns Search results with adjacent context added when matching chunks are available
*/
export async function packAdjacentSourceContext(
supabase: ReturnType<typeof createAdminClient>,
results: SearchResult[],
Expand Down Expand Up @@ -696,7 +705,13 @@ export async function packAdjacentSourceContext(
// flagged unverified, blanking a correct answer. Overlay the packed adjacent_context onto
// the answer-input results (by chunk id) to rebuild the exact verification corpus, WITHOUT
// mutating answer.sources itself (the route-boundary client trim and eval byte-identity
// both depend on answer.sources staying unpacked — see answer-client-payload.ts).
/**
* Aligns adjacent source context with packed search results.
*
* @param results - The search results to update.
* @param packed - Search results containing the desired adjacent context.
* @returns Results with matching adjacent context values copied from `packed`.
*/
export function attachAdjacentContext(results: SearchResult[], packed: SearchResult[]): SearchResult[] {
const adjacentById = new Map<string, string>();
for (const source of packed) {
Expand Down
20 changes: 18 additions & 2 deletions src/lib/rag-extractive-answer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1729,7 +1729,15 @@ function applyProviderLabels(answer: RagAnswer): RagAnswer {

// Public wrapper: runs quality finalization, then stamps provider/quality labels so the UI can
// disclose source-only (lower-quality) answers and verify-against-sources guidance.
/** Finalize rag answer quality. */
/**
* Finalizes an extractive RAG answer's quality and provider metadata.
*
* @param answer - The answer to finalize
* @param query - The query the answer addresses
* @param queryClass - The classified query type
* @param verificationSources - Optional sources used to verify numeric claims
* @returns The finalized RAG answer
*/
export function finalizeRagAnswerQuality(
answer: RagAnswer,
query: string,
Expand All @@ -1739,7 +1747,15 @@ export function finalizeRagAnswerQuality(
return applyProviderLabels(finalizeRagAnswerQualityCore(answer, query, queryClass, verificationSources));
}

/** Finalize rag answer quality core. */
/**
* Finalizes an answer by applying quality checks, gap handling, section cleanup, and numeric verification.
*
* @param answer - The answer to finalize.
* @param query - The user query used to evaluate answer relevance and clinical intent.
* @param queryClass - The classified query type used for quality and fallback decisions.
* @param verificationSources - Optional sources used to verify numeric claims.
* @returns The quality-checked and finalized answer.
*/
function finalizeRagAnswerQualityCore(
answer: RagAnswer,
query: string,
Expand Down
19 changes: 17 additions & 2 deletions src/lib/rag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3599,7 +3599,12 @@ export async function answerQuestion(query: string, documentId?: string) {
return answerQuestionWithScope({ query, documentId, allowGlobalSearch: true });
}

/** Answer question with scope. */
/**
* Answers a question within the requested search scope, coalescing identical in-flight requests when caching is enabled.
*
* @param args - The question, scope, cache, progress, and request options.
* @returns The generated answer.
*/
export async function answerQuestionWithScope(args: AnswerQuestionWithScopeArgs): Promise<RagAnswer> {
const startedAt = Date.now();
const coalescingEnabled = !args.skipCache && env.RAG_ANSWER_CACHE_TTL_MS > 0 && env.RAG_ANSWER_CACHE_SIZE > 0;
Expand Down Expand Up @@ -3637,7 +3642,17 @@ export async function answerQuestionWithScope(args: AnswerQuestionWithScopeArgs)
return pending;
}

/** Answer question with scope uncoalesced. */
/**
* Generates a clinically grounded answer within the requested document scope.
*
* Uses cached or shared answers when available, retrieves supporting evidence, selects an
* answer route, and falls back to extractive or unsupported responses when generation is
* unavailable or fails.
*
* @param args - Query, document scope, routing options, callbacks, and request controls.
* @param startedAt - Timestamp used to calculate total request latency.
* @returns The finalized answer with citations, retrieval diagnostics, and response metadata.
*/
async function answerQuestionWithScopeUncoalesced(
args: AnswerQuestionWithScopeArgs,
startedAt: number,
Expand Down
8 changes: 8 additions & 0 deletions src/lib/security-headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ export type ContentSecurityPolicyOptions = SecurityHeaderFlags & {
nonce: string;
};

/**
* Builds a Content Security Policy for the current runtime.
*
* @param isDevelopment - Whether development script permissions should be used.
* @param isLocalHttpRuntime - Whether insecure-request upgrades should be omitted.
* @param nonce - The nonce used to authorize production scripts.
* @returns The Content Security Policy header value.
*/
export function buildContentSecurityPolicy({
isDevelopment,
isLocalHttpRuntime,
Expand Down