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
77 changes: 49 additions & 28 deletions src/app/api/answer/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
} from "@/lib/source-governance";
import { parseJsonBody } from "@/lib/validation/body";
import { createAdminClient } from "@/lib/supabase/admin";
import { nonProductionSupabaseDemoFallbackReason } from "@/lib/supabase/errors";
import * as serverAuth from "@/lib/supabase/auth";
import type { RagAnswer } from "@/lib/types";

Expand All @@ -35,6 +36,8 @@ const answerSchema = z.object({
skipCache: z.boolean().optional().default(false),
});

type AnswerRequestBody = z.infer<typeof answerSchema>;

function answerDegradedModeSignal(answer?: Pick<RagAnswer, "degradedMode" | "answerQualityTier" | "fallbackReason">) {
if (answer?.degradedMode) return answer.degradedMode;
const active = answer?.answerQualityTier === "source_only";
Expand All @@ -44,26 +47,33 @@ function answerDegradedModeSignal(answer?: Pick<RagAnswer, "degradedMode" | "ans
};
}

function buildDemoAnswerPayload(body: AnswerRequestBody, fallbackReason?: string) {
const answer = demoAnswer(body.query, body.documentId, body.documentIds);
const answerFocusQuery = queryForClinicalMode(body.query, body.queryMode);
const smartApiPlan = buildSmartRagApiPlan({
query: answerFocusQuery,
queryClass: queryClassForClinicalMode(body.queryMode) ?? classifyRagQuery(answerFocusQuery).queryClass,
results: answer.sources,
routeMode: answer.routingMode,
retrievalStrategy: "hybrid",
});
return {
...answer,
responseMode: smartApiPlan.displayMode,
smartApiPlan,
demoMode: true,
degradedMode: fallbackReason ? { active: true, reason: fallbackReason } : answerDegradedModeSignal(answer),
...(fallbackReason ? { fallbackMode: "non_production_demo", fallbackReason } : {}),
};
}

export async function POST(request: Request) {
let body: AnswerRequestBody | null = null;
try {
const body = await parseJsonBody(request, answerSchema, "Invalid answer request.");
const answerBody = await parseJsonBody(request, answerSchema, "Invalid answer request.");
body = answerBody;
if (isDemoMode()) {
const answer = demoAnswer(body.query, body.documentId, body.documentIds);
const answerFocusQuery = queryForClinicalMode(body.query, body.queryMode);
const smartApiPlan = buildSmartRagApiPlan({
query: answerFocusQuery,
queryClass: queryClassForClinicalMode(body.queryMode) ?? classifyRagQuery(answerFocusQuery).queryClass,
results: answer.sources,
routeMode: answer.routingMode,
retrievalStrategy: "hybrid",
});
return NextResponse.json({
...answer,
responseMode: smartApiPlan.displayMode,
smartApiPlan,
demoMode: true,
degradedMode: answerDegradedModeSignal(answer),
});
return NextResponse.json(buildDemoAnswerPayload(answerBody));
}

const supabase = createAdminClient();
Expand All @@ -84,8 +94,8 @@ export async function POST(request: Request) {
supabase,
ownerId: access.ownerId,
publicOnly,
documentIds: body.documentIds ?? (body.documentId ? [body.documentId] : undefined),
filters: body.filters,
documentIds: answerBody.documentIds ?? (answerBody.documentId ? [answerBody.documentId] : undefined),
filters: answerBody.filters,
});
if (scope.documentIds?.length === 0) {
return NextResponse.json({
Expand All @@ -96,22 +106,26 @@ export async function POST(request: Request) {
citations: [],
sources: [],
degradedMode: answerDegradedModeSignal(),
scope: { ...scope, queryMode: body.queryMode },
scope: { ...scope, queryMode: answerBody.queryMode },
sourceGovernanceWarnings: sourceGovernanceWarnings({ results: [] }),
});
}

const singleDocumentScope = Boolean(body.documentId && !body.documentIds?.length && scope.activeFilterCount === 0);
const singleDocumentScope = Boolean(
answerBody.documentId && !answerBody.documentIds?.length && scope.activeFilterCount === 0,
);
const answer = await answerQuestionWithScope({
query: body.query,
documentId: singleDocumentScope ? body.documentId : undefined,
query: answerBody.query,
documentId: singleDocumentScope ? answerBody.documentId : undefined,
documentIds: singleDocumentScope
? undefined
: (scope.documentIds ?? body.documentIds ?? (body.documentId ? [body.documentId] : undefined)),
: (scope.documentIds ??
answerBody.documentIds ??
(answerBody.documentId ? [answerBody.documentId] : undefined)),
ownerId: access.ownerId,
allowGlobalSearch: !access.ownerId,
queryMode: body.queryMode,
skipCache: body.skipCache,
queryMode: answerBody.queryMode,
skipCache: answerBody.skipCache,
signal: request.signal,
});
const warnings = sourceGovernanceWarnings({
Expand All @@ -132,15 +146,15 @@ export async function POST(request: Request) {
citations: [],
sources: [],
degradedMode: answerDegradedModeSignal(answer),
scope: { ...scope, queryMode: body.queryMode },
scope: { ...scope, queryMode: answerBody.queryMode },
sourceGovernanceWarnings: warnings,
});
}

return NextResponse.json({
...answer,
degradedMode: answerDegradedModeSignal(answer),
scope: { ...scope, queryMode: body.queryMode },
scope: { ...scope, queryMode: answerBody.queryMode },
sourceGovernanceWarnings: warnings,
});
} catch (error) {
Expand All @@ -154,6 +168,13 @@ export async function POST(request: Request) {
return jsonError(error, error.status);
}
if (error instanceof Error) {
const fallbackBody = body;
const fallbackReason = fallbackBody ? nonProductionSupabaseDemoFallbackReason(error) : null;
if (fallbackBody && fallbackReason) {
return NextResponse.json(buildDemoAnswerPayload(fallbackBody, fallbackReason), {
headers: { "X-Clinical-KB-Fallback": fallbackReason },
});
}
return jsonError(
new PublicApiError("Answer generation failed. Retry with a narrower question.", 500, { code: error.name }),
500,
Expand Down
91 changes: 51 additions & 40 deletions src/app/api/search/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
queryTextForStorage,
} from "@/lib/query-privacy";
import { safeErrorLogDetails } from "@/lib/privacy";
import { nonProductionSupabaseDemoFallbackReason } from "@/lib/supabase/errors";
import type { ChunkImage, ClinicalSourceMetadata, SearchResult } from "@/lib/types";

export const runtime = "nodejs";
Expand Down Expand Up @@ -311,6 +312,43 @@ function searchDegradedModeSignal(telemetry?: { embedding_skip_reason?: string |
};
}

function buildDemoSearchPayload(body: SearchRequestBody, fallbackReason?: string) {
const searchFocusQuery = queryForClinicalMode(body.query, body.queryMode);
const queryClass = queryClassForClinicalMode(body.queryMode) ?? classifyRagQuery(searchFocusQuery).queryClass;
const results = annotateSearchResults(
searchFocusQuery,
demoSearch(body.query, body.topK ?? 8, body.documentId, body.documentIds),
);
const relevance = buildEvidenceRelevance(searchFocusQuery, results);
const documentMatches = isSourceLibrarySearchMode(body.mode)
? annotateDocumentMatches(searchFocusQuery, buildDocumentMatchesFromResults(results, body.documentLimit), results)
: [];
const cachedVisualEvidence = buildVisualEvidence(results);
return {
results: compactSearchResults(searchFocusQuery, results),
facets: buildSearchFacets(results),
visualEvidence: cachedVisualEvidence,
relevance,
smartPanel: {
...buildSmartPanel(searchFocusQuery, results, { relevance, visualEvidence: cachedVisualEvidence }),
relevance,
},
smartApiPlan: buildSmartRagApiPlan({
query: searchFocusQuery,
queryClass,
results,
retrievalStrategy: "hybrid",
routeMode: isSourceLibrarySearchMode(body.mode) ? undefined : "fast",
preferredResponseMode: isSourceLibrarySearchMode(body.mode) ? "document_lookup" : undefined,
}),
relatedDocuments: [],
documentMatches,
demoMode: true,
degradedMode: fallbackReason ? { active: true, reason: fallbackReason } : searchDegradedModeSignal(),
...(fallbackReason ? { fallbackMode: "non_production_demo", fallbackReason } : {}),
};
}

function facetCounts(values: Array<string | null | undefined>, limit = 12) {
const counts = new Map<string, number>();
for (const raw of values) {
Expand Down Expand Up @@ -845,47 +883,13 @@ function extractSqlState(error: unknown) {
export async function POST(request: Request) {
let supabase: ReturnType<typeof createAdminClient> | null = null;
let ownerId: string | null = null;
let body: SearchRequestBody | null = null;

try {
const body = await parseJsonBody(request, searchSchema, "Invalid search request.");
const searchBody = await parseJsonBody(request, searchSchema, "Invalid search request.");
body = searchBody;
if (isDemoMode()) {
const searchFocusQuery = queryForClinicalMode(body.query, body.queryMode);
const queryClass = queryClassForClinicalMode(body.queryMode) ?? classifyRagQuery(searchFocusQuery).queryClass;
const results = annotateSearchResults(
searchFocusQuery,
demoSearch(body.query, body.topK ?? 8, body.documentId, body.documentIds),
);
const relevance = buildEvidenceRelevance(searchFocusQuery, results);
const documentMatches = isSourceLibrarySearchMode(body.mode)
? annotateDocumentMatches(
searchFocusQuery,
buildDocumentMatchesFromResults(results, body.documentLimit),
results,
)
: [];
const cachedVisualEvidence = buildVisualEvidence(results);
return NextResponse.json({
results: compactSearchResults(searchFocusQuery, results),
facets: buildSearchFacets(results),
visualEvidence: cachedVisualEvidence,
relevance,
smartPanel: {
...buildSmartPanel(searchFocusQuery, results, { relevance, visualEvidence: cachedVisualEvidence }),
relevance,
},
smartApiPlan: buildSmartRagApiPlan({
query: searchFocusQuery,
queryClass,
results,
retrievalStrategy: "hybrid",
routeMode: isSourceLibrarySearchMode(body.mode) ? undefined : "fast",
preferredResponseMode: isSourceLibrarySearchMode(body.mode) ? "document_lookup" : undefined,
}),
relatedDocuments: [],
documentMatches,
demoMode: true,
degradedMode: searchDegradedModeSignal(),
});
return NextResponse.json(buildDemoSearchPayload(searchBody));
}

supabase = createAdminClient();
Expand All @@ -906,9 +910,9 @@ export async function POST(request: Request) {
);
}

const key = scopedSearchKey(body, ownerId, publicOnly);
const key = scopedSearchKey(searchBody, ownerId, publicOnly);
const { payload, coalesced } = await coalesceScopedSearch(key, () =>
buildScopedSearchPayload(body, supabase!, ownerId, publicOnly),
buildScopedSearchPayload(searchBody, supabase!, ownerId, publicOnly),
);
return NextResponse.json({
...payload,
Expand All @@ -929,6 +933,13 @@ export async function POST(request: Request) {
}
if (error instanceof Error && error.message.trim()) {
const code = classifySearchFailure(error);
const fallbackBody = body;
const fallbackReason = fallbackBody ? nonProductionSupabaseDemoFallbackReason(error) : null;
if (fallbackBody && fallbackReason) {
return NextResponse.json(buildDemoSearchPayload(fallbackBody, fallbackReason), {
headers: { "X-Clinical-KB-Fallback": fallbackReason },
});
}
const failurePayload = {
results: [],
telemetry: {
Expand Down
12 changes: 10 additions & 2 deletions src/lib/api-rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ export function allowRateLimitInMemoryFallbackOnUnavailable() {
return isLocalNoAuthMode() || process.env.NODE_ENV === "production";
}

function allowAnonymousRateLimitFallback(bucket: ApiRateLimitBucket, allowInMemoryFallbackOnUnavailable?: boolean) {
if (allowInMemoryFallbackOnUnavailable) return true;

// Anonymous public read/search paths must stay reachable if the durable limiter
// migration is temporarily unavailable; the per-instance limiter still applies.
return bucket === "answer" || bucket === "search" || bucket === "document_read" || bucket === "registry";
}

export type ApiRateLimitBucket =
| "answer"
| "search"
Expand Down Expand Up @@ -159,7 +167,7 @@ export async function consumeSubjectApiRateLimit(args: {
});

if (error) {
if (args.allowInMemoryFallbackOnUnavailable) {
if (allowAnonymousRateLimitFallback(args.bucket, args.allowInMemoryFallbackOnUnavailable)) {
console.warn("Durable anonymous API rate limit check unavailable; using local in-memory fallback.", {
bucket: args.bucket,
code: error.code,
Expand All @@ -177,7 +185,7 @@ export async function consumeSubjectApiRateLimit(args: {

const row = parseRateLimitRow(data);
if (!row || typeof row.limited !== "boolean") {
if (args.allowInMemoryFallbackOnUnavailable) {
if (allowAnonymousRateLimitFallback(args.bucket, args.allowInMemoryFallbackOnUnavailable)) {
return consumeInMemoryApiRateLimit({
ownerId: args.subject.subjectKey,
bucket: args.bucket,
Expand Down
18 changes: 18 additions & 0 deletions src/lib/supabase/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
function errorMessage(error: unknown) {
if (error instanceof Error) return error.message;
if (typeof error === "string") return error;
if (error && typeof error === "object" && "message" in error) {
return String((error as { message?: unknown }).message ?? "");
}
return String(error ?? "");
}

export function isSupabaseApiKeyConfigurationError(error: unknown) {
return /\b(?:unregistered|invalid)\s+api\s+key\b/i.test(errorMessage(error));
}

export function nonProductionSupabaseDemoFallbackReason(error: unknown) {
if (process.env.NODE_ENV === "production") return null;
if (!isSupabaseApiKeyConfigurationError(error)) return null;
return "supabase_api_key_configuration_unavailable";
}
Loading
Loading