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
51 changes: 30 additions & 21 deletions src/app/api/answer/stream/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,7 @@ import {
sourceGovernanceWarnings,
} from "@/lib/source-governance";
import { createAdminClient } from "@/lib/supabase/admin";
import { nonProductionSupabaseDemoFallbackReason } from "@/lib/supabase/errors";
import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth";
import { logger } from "@/lib/logger";
import { parseJsonBody } from "@/lib/validation/body";
Expand DownExpand Up@@ -102,6 +103,29 @@ function logStreamError(error: unknown) {
});
}

function buildDemoStreamAnswer(body: AnswerBody, fallbackReason?: string) {
const demo = demoAnswer(body.query, body.documentId, body.documentIds);
const answerFocusQuery = queryForClinicalMode(body.query, body.queryMode);
const sources = annotateSearchResults(answerFocusQuery, demo.sources);
const relevance = buildEvidenceRelevance(answerFocusQuery, sources);
return {
...demo,
sources,
relevance,
smartPanel: demo.smartPanel ? { ...demo.smartPanel, relevance } : demo.smartPanel,
smartApiPlan: buildSmartRagApiPlan({
query: answerFocusQuery,
queryClass: queryClassForClinicalMode(body.queryMode) ?? classifyRagQuery(answerFocusQuery).queryClass,
results: sources,
routeMode: demo.routingMode,
retrievalStrategy: "hybrid",
}),
demoMode: true,
degradedMode: fallbackReason ? { active: true, reason: fallbackReason } : answerDegradedModeSignal(demo),
...(fallbackReason ? { fallbackMode: "non_production_demo", fallbackReason } : {}),
};
}

function streamAnswer(body: AnswerBody, ownerId?: string, signal?: AbortSignal, publicOnly = false) {
const encoder = new TextEncoder();

Expand DownExpand Up@@ -142,27 +166,7 @@ function streamAnswer(body: AnswerBody, ownerId?: string, signal?: AbortSignal,
body.documentId && !body.documentIds?.length && scope?.activeFilterCount === 0,
);
const answer = isDemoMode()
? (() => {
const demo = demoAnswer(body.query, body.documentId, body.documentIds);
const answerFocusQuery = queryForClinicalMode(body.query, body.queryMode);
const sources = annotateSearchResults(answerFocusQuery, demo.sources);
const relevance = buildEvidenceRelevance(answerFocusQuery, sources);
return {
...demo,
sources,
relevance,
smartPanel: demo.smartPanel ? { ...demo.smartPanel, relevance } : demo.smartPanel,
smartApiPlan: buildSmartRagApiPlan({
query: answerFocusQuery,
queryClass:
queryClassForClinicalMode(body.queryMode) ?? classifyRagQuery(answerFocusQuery).queryClass,
results: sources,
routeMode: demo.routingMode,
retrievalStrategy: "hybrid",
}),
demoMode: true,
};
})()
? buildDemoStreamAnswer(body)
: await answerQuestionWithScope({
query: body.query,
documentId: singleDocumentScope ? body.documentId : undefined,
Expand DownExpand Up@@ -206,6 +210,11 @@ function streamAnswer(body: AnswerBody, ownerId?: string, signal?: AbortSignal,
});
} catch (error) {
logStreamError(error);
const fallbackReason = nonProductionSupabaseDemoFallbackReason(error);
if (fallbackReason) {
send("final", buildDemoStreamAnswer(body, fallbackReason));
return;
}
const streamError = streamErrorPayload(error);
send("error", { error: streamError.message, status: streamError.status, details: streamError.details });
} finally {
Expand Down
44 changes: 36 additions & 8 deletions src/components/ClinicalDashboard.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -312,6 +312,18 @@

type SourceLibrarySearchMode = Extract<AppModeSearchKind, "documents" | "differentials">;

function hasNonProductionSupabaseApiKeyFallback(checks: SetupCheck[]) {
return (
process.env.NODE_ENV !== "production" &&
checks.some(
(check) =>
check.id === "search" &&
check.status !== "ready" &&
/\b(?:unregistered|invalid)\s+api\s+key\b/i.test(check.detail),
)
);
}

function parseSseData(lines: string[]) {
const data = lines.join("\n").trim();
if (!data) return null;
Expand All@@ -328,6 +340,11 @@
return typeof message === "string" && message.trim() ? message.trim() : null;
}

function findSseSeparator(buffer: string) {
const match = /\r?\n\r?\n/.exec(buffer);
return match ? { index: match.index, length: match[0].length } : null;
}

async function readAnswerStream(response: Response, onProgress: (message: string) => void): Promise<AnswerPayload> {
if (!response.body) throw makeSearchError("Answer stream could not be opened.", undefined, true);

Expand DownExpand Up@@ -378,25 +395,31 @@
}
if (event === "final") {
finalPayload = data as AnswerPayload;
return true;
}

return false;
}

while (true) {
const { value, done } = await reader.read();
buffer += decoder.decode(value, { stream: !done });

let separatorIndex = buffer.indexOf("\n\n");
while (separatorIndex >= 0) {
const block = buffer.slice(0, separatorIndex).trim();
buffer = buffer.slice(separatorIndex + 2);
if (block) processEvent(block);
separatorIndex = buffer.indexOf("\n\n");
let separator = findSseSeparator(buffer);
while (separator) {
const block = buffer.slice(0, separator.index).trim();
buffer = buffer.slice(separator.index + separator.length);
if (block && processEvent(block) && finalPayload) {
await reader.cancel().catch(() => undefined);
return finalPayload as AnswerPayload;
}
separator = findSseSeparator(buffer);
}

if (done) break;
}

if (buffer.trim()) processEvent(buffer.trim());
if (buffer.trim() && processEvent(buffer.trim()) && finalPayload) return finalPayload as AnswerPayload;
if (!finalPayload) throw makeSearchError("Answer stream ended before a final answer was received.", undefined, true);
return finalPayload as AnswerPayload;
}
Expand DownExpand Up@@ -1711,12 +1734,17 @@
const canUsePublicSearchApis = localProjectReady && hasReadyPublicSearchSetup(setupChecks);
const canUseDegradedLocalSearchApis =
process.env.NODE_ENV !== "production" && localProjectReady && hasReadyRequiredPublicSearchConfig(setupChecks);
const canUseNonProductionDemoFallback = localProjectReady && hasNonProductionSupabaseApiKeyFallback(setupChecks);
const canUsePrivateApis =
localProjectReady && (localNoAuthMode || localDevCanAttemptPrivateApis || authStatus === "authenticated");
const canUploadDocuments = canUsePrivateApis || (publicUploadsEnabled() && canUsePublicSearchApis);
const canAttemptDeployedPublicSearch = isDeployedClinicalKb() && localProjectReady;
const canRunSearch =
explicitDemoMode || canUsePublicSearchApis || canUseDegradedLocalSearchApis || canAttemptDeployedPublicSearch;
explicitDemoMode ||
canUsePublicSearchApis ||
canUseDegradedLocalSearchApis ||
canUseNonProductionDemoFallback ||
canAttemptDeployedPublicSearch;
const closeDashboardTransientSurfaces = useCallback(
(except?: "guide" | "settings" | "accountSetup" | "mobileSidebar" | "documents" | "upload") => {
if (except !== "guide") setGuideOpen(false);
Expand DownExpand Up@@ -2453,7 +2481,7 @@
updateHash();
window.addEventListener("hashchange", updateHash);
return () => window.removeEventListener("hashchange", updateHash);
}, []);

Check warning on line 2484 in src/components/ClinicalDashboard.tsx

View workflow job for this annotation

GitHub Actions/ verify

React Hook useEffect has missing dependencies: 'executeSearch' and 'scopeFilters'. Either include them or remove the dependency array

useEffect(() => {
return () => {
Expand Down
7 changes: 7 additions & 0 deletions src/components/clinical-dashboard/master-search-header.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -330,6 +330,13 @@ export function MasterSearchHeader({
useEffect(() => {
onBottomComposerScrollHiddenChange?.(bottomComposerHidden);
}, [bottomComposerHidden, onBottomComposerScrollHiddenChange]);

useEffect(() => {
if (!loading || !commandDropdownOpen) return undefined;
const frame = window.requestAnimationFrame(() => setCommandDropdownOpen(false));
return () => window.cancelAnimationFrame(frame);
}, [commandDropdownOpen, loading]);

// Stable, header-owned element the composer is portaled into; we move it in and
// out of the page-owned slot rather than portaling into the slot directly.
const [desktopHomeComposerHost, setDesktopHomeComposerHost] = useState<HTMLDivElement | null>(null);
Expand Down
69 changes: 69 additions & 0 deletions tests/private-access-routes.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3367,6 +3367,75 @@ describe("private document API access", () => {
expect(answerQuestionWithScope).not.toHaveBeenCalled();
});

it("falls back to a final streamed demo answer outside production when Supabase rejects the API key", async () => {
const answerQuestionWithScope = vi.fn(async () => ({
answer: "Live answer",
grounded: true,
confidence: "medium",
citations: [],
sources: [],
}));
const client = createSupabaseMock((call) =>
call.table === "documents" && call.operation === "select" ? fail("Unregistered API key") : ok([]),
);
mockRuntime(client, { answerQuestionWithScope });
const { POST } = await import("../src/app/api/answer/stream/route");

const response = await POST(
request("/api/answer/stream", {
method: "POST",
body: JSON.stringify({ query: "clozapine monitoring" }),
}),
);
const body = await response.text();
const finalPayload = ssePayload(body, "final");

expect(response.status).toBe(200);
expect(body).not.toContain("event: error");
expect(finalPayload).toMatchObject({
demoMode: true,
fallbackMode: "non_production_demo",
fallbackReason: "supabase_api_key_configuration_unavailable",
degradedMode: { active: true, reason: "supabase_api_key_configuration_unavailable" },
});
expect(String(finalPayload.answer)).toContain("Synthetic");
expect(answerQuestionWithScope).not.toHaveBeenCalled();
});

it("does not stream demo fallback in production when Supabase rejects the API key", async () => {
vi.stubEnv("NODE_ENV", "production");
const answerQuestionWithScope = vi.fn(async () => ({
answer: "Live answer",
grounded: true,
confidence: "medium",
citations: [],
sources: [],
}));
const client = createSupabaseMock((call) =>
call.table === "documents" && call.operation === "select" ? fail("Unregistered API key") : ok([]),
);
mockRuntime(client, { answerQuestionWithScope });
const { POST } = await import("../src/app/api/answer/stream/route");

const response = await POST(
request("/api/answer/stream", {
method: "POST",
body: JSON.stringify({ query: "clozapine monitoring" }),
}),
);
const body = await response.text();
const errorPayload = ssePayload(body, "error");

expect(response.status).toBe(200);
expect(body).not.toContain("event: final");
expect(errorPayload).toMatchObject({
error: "Answer generation failed. Retry with a narrower question.",
status: 500,
details: { code: "Error" },
});
expect(answerQuestionWithScope).not.toHaveBeenCalled();
});

it("uses an anonymous in-memory limiter for managed local no-auth search", async () => {
const searchChunksWithTelemetry = vi.fn(async () => ({
results: [],
Expand Down
Loading