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
2 changes: 2 additions & 0 deletions docs/site-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,7 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check`
## API routes

- `/api/answer` - Generate answer response. Source: `src/app/api/answer/route.ts`.
- `/api/answer-feedback` - Route discovered from app directory Source: `src/app/api/answer-feedback/route.ts`.
- `/api/answer/stream` - Streaming answer response. Source: `src/app/api/answer/stream/route.ts`.
- `/api/differentials` - Route discovered from app directory Source: `src/app/api/differentials/route.ts`.
- `/api/differentials/[slug]` - Route discovered from app directory Source: `src/app/api/differentials/[slug]/route.ts`.
Expand All @@ -553,6 +554,7 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check`
- `/api/documents/[id]` - Document detail operations. Source: `src/app/api/documents/[id]/route.ts`.
- `/api/documents/[id]/labels` - Document label operations. Source: `src/app/api/documents/[id]/labels/route.ts`.
- `/api/documents/[id]/reindex` - Single-document reindex operation. Source: `src/app/api/documents/[id]/reindex/route.ts`.
- `/api/documents/[id]/reviews` - Route discovered from app directory Source: `src/app/api/documents/[id]/reviews/route.ts`.
- `/api/documents/[id]/search` - Search within one document. Source: `src/app/api/documents/[id]/search/route.ts`.
- `/api/documents/[id]/signed-url` - Private document signed URL. Source: `src/app/api/documents/[id]/signed-url/route.ts`.
- `/api/documents/[id]/summarize` - Document summary operation. Source: `src/app/api/documents/[id]/summarize/route.ts`.
Expand Down
78 changes: 78 additions & 0 deletions src/app/api/answer-feedback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import {
allowRateLimitInMemoryFallbackOnUnavailable,
consumeSubjectApiRateLimit,
rateLimitJsonResponse,
} from "@/lib/api-rate-limit";
import { isDemoMode } from "@/lib/env";
import { jsonError, PublicApiError } from "@/lib/http";
import { publicAccessContext } from "@/lib/public-api-access";
import { createAdminClient } from "@/lib/supabase/admin";
import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth";
import { parseJsonBody } from "@/lib/validation/body";

export const runtime = "nodejs";

const uuid = z.string().uuid();
const bodySchema = z
.object({
interactionId: uuid,
Comment thread
BigSimmo marked this conversation as resolved.
feedbackCategory: z.enum([
"verified",
"needs_correction",
"source_insufficient",
"wrong_source",
"missing_source",
"unsupported_answer",
"numeric_error",
"outdated_guidance",
]),
answerHash: z.string().regex(/^[a-f0-9]{64}$/),
citedSourceIds: z.array(uuid).max(80).optional().default([]),
sourceIds: z.array(uuid).max(80).optional().default([]),
route: z.string().trim().max(100).nullable().optional().default(null),
model: z.string().trim().max(100).nullable().optional().default(null),
providerRequestIds: z.array(z.string().trim().min(1).max(200)).max(10).optional().default([]),
})
.strict();

export async function POST(request: Request) {
try {
if (isDemoMode())
return NextResponse.json({ error: "Answer feedback is unavailable in demo mode." }, { status: 400 });
const body = await parseJsonBody(request, bodySchema, "Invalid answer feedback.");
const supabase = createAdminClient();
const access = await publicAccessContext(request, supabase);
const rateLimit = await consumeSubjectApiRateLimit({
supabase,
subject: access.rateLimitSubject,
bucket: "answer_feedback",
allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(),
});
if (rateLimit.limited) return rateLimitJsonResponse("Too many feedback requests. Retry shortly.", rateLimit);

const { error } = await supabase.from("rag_answer_feedback").insert({
interaction_id: body.interactionId,
owner_id: access.ownerId ?? null,
feedback_category: body.feedbackCategory,
answer_hash: body.answerHash,
cited_source_ids: [...new Set(body.citedSourceIds)],
source_ids: [...new Set(body.sourceIds)],
route: body.route,
model: body.model,
provider_request_ids: [...new Set(body.providerRequestIds)],
});
if (error) {
if (error.code === "23505") {
return NextResponse.json({ error: "Feedback has already been recorded for this answer." }, { status: 409 });
}
throw new PublicApiError("Answer feedback could not be saved.", 500, { code: "feedback_insert_failed" });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
return NextResponse.json({ ok: true }, { status: 201 });
} catch (error) {
if (error instanceof AuthenticationError) return unauthorizedResponse();
if (error instanceof PublicApiError) return jsonError(error, error.status);
return jsonError(error, 500);
}
}
14 changes: 10 additions & 4 deletions src/app/api/answer/route.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import { NextResponse } from "next/server";
import { z } from "zod";
import { demoAnswer } from "@/lib/demo-data";
Expand Down Expand Up @@ -71,13 +72,14 @@ function buildDemoAnswerPayload(body: AnswerRequestBody, fallbackReason?: string
}

export async function POST(request: Request) {
const interactionId = randomUUID();
const routeStartedAt = Date.now();
let body: AnswerRequestBody | null = null;
try {
const answerBody = await parseJsonBody(request, answerSchema, "Invalid answer request.");
body = answerBody;
if (isDemoMode()) {
return NextResponse.json(buildDemoAnswerPayload(answerBody));
return NextResponse.json({ ...buildDemoAnswerPayload(answerBody), interactionId });
}

const supabase = createAdminClient();
Expand Down Expand Up @@ -111,6 +113,7 @@ export async function POST(request: Request) {
degradedMode: answerDegradedModeSignal(),
scope: { ...scope, queryMode: answerBody.queryMode },
sourceGovernanceWarnings: sourceGovernanceWarnings({ results: [] }),
interactionId,
});
}

Expand Down Expand Up @@ -165,6 +168,7 @@ export async function POST(request: Request) {
degradedMode: answerDegradedModeSignal(answer),
scope: { ...scope, queryMode: answerBody.queryMode },
sourceGovernanceWarnings: warnings,
interactionId,
});
}

Expand All @@ -182,6 +186,7 @@ export async function POST(request: Request) {
degradedMode: answerDegradedModeSignal(answer),
scope: { ...scope, queryMode: answerBody.queryMode },
sourceGovernanceWarnings: warnings,
interactionId,
},
serverTiming ? { headers: { "Server-Timing": serverTiming } } : undefined,
);
Expand All @@ -199,9 +204,10 @@ export async function POST(request: Request) {
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 NextResponse.json(
{ ...buildDemoAnswerPayload(fallbackBody, fallbackReason), interactionId },
{ headers: { "X-Clinical-KB-Fallback": fallbackReason } },
);
}
return jsonError(
new PublicApiError("Answer generation failed. Retry with a narrower question.", 500, { code: error.name }),
Expand Down
7 changes: 6 additions & 1 deletion src/app/api/answer/stream/route.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import { z } from "zod";
import { demoAnswer } from "@/lib/demo-data";
import { isDemoMode } from "@/lib/env";
Expand Down Expand Up @@ -126,6 +127,7 @@ function buildDemoStreamAnswer(body: AnswerBody, fallbackReason?: string) {
function streamAnswer(body: AnswerBody, accessScope: RetrievalAccessScope, signal?: AbortSignal) {
const ownerId = accessScope.ownerId;
const encoder = new TextEncoder();
const interactionId = randomUUID();

return new Response(
new ReadableStream({
Expand Down Expand Up @@ -169,6 +171,7 @@ function streamAnswer(body: AnswerBody, accessScope: RetrievalAccessScope, signa
degradedMode: answerDegradedModeSignal(),
scope: { ...scope, queryMode: body.queryMode },
sourceGovernanceWarnings: sourceGovernanceWarnings({ results: [] }),
interactionId,
});
return;
}
Expand Down Expand Up @@ -226,6 +229,7 @@ function streamAnswer(body: AnswerBody, accessScope: RetrievalAccessScope, signa
degradedMode: answerDegradedModeSignal(answer),
scope: scope ? { ...scope, queryMode: body.queryMode } : undefined,
sourceGovernanceWarnings: warnings,
interactionId,
});
return;
}
Expand All @@ -241,6 +245,7 @@ function streamAnswer(body: AnswerBody, accessScope: RetrievalAccessScope, signa
degradedMode: answerDegradedModeSignal(answer),
scope: scope ? { ...scope, queryMode: body.queryMode } : undefined,
sourceGovernanceWarnings: warnings,
interactionId,
});
} catch (error) {
logStreamError(error);
Expand All @@ -249,7 +254,7 @@ function streamAnswer(body: AnswerBody, accessScope: RetrievalAccessScope, signa
// error — the UI's answer search uses this route, not /api/answer.
const fallbackReason = nonProductionSupabaseDemoFallbackReason(error);
if (fallbackReason) {
send("final", buildDemoStreamAnswer(body, fallbackReason));
send("final", { ...buildDemoStreamAnswer(body, fallbackReason), interactionId });
return;
}
const streamError = streamErrorPayload(error);
Expand Down
118 changes: 118 additions & 0 deletions src/app/api/documents/[id]/reviews/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { consumeApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit";
import { env, isDemoMode } from "@/lib/env";
import { jsonError, PublicApiError } from "@/lib/http";
import {
checkIngestionMutationSafety,
hasActiveAgentEnrichmentJob,
ingestionMutationSafetyPayload,
} from "@/lib/ingestion-mutation-safety";
import { invalidateRagCachesForOwner } from "@/lib/rag";
import { isValidReviewDate, sourceReviewDecisionSchema } from "@/lib/source-review";
import { createAdminClient } from "@/lib/supabase/admin";
import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth";
import { parseJsonBody } from "@/lib/validation/body";
import { parseRouteParams } from "@/lib/validation/params";

export const runtime = "nodejs";

const paramsSchema = z.object({ id: z.string().uuid() });
const bodySchema = z
.object({
decision: sourceReviewDecisionSchema,
reason: z.string().trim().min(3).max(2000),
evidenceReferences: z.array(z.string().trim().min(1).max(500)).max(30).optional().default([]),
reviewDate: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.refine(isValidReviewDate, "Review date must be a valid date that is not in the future.")
.nullable()
.optional()
.default(null),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
replacementDocumentId: z.string().uuid().nullable().optional().default(null),
})
.strict()
.superRefine((value, context) => {
if ((value.decision === "approved" || value.decision === "locally_reviewed") && !value.evidenceReferences.length) {
context.addIssue({
code: "custom",
path: ["evidenceReferences"],
message: "Evidence is required for promotion.",
});
}
if (value.decision === "superseded" && !value.replacementDocumentId) {
context.addIssue({ code: "custom", path: ["replacementDocumentId"], message: "A replacement is required." });
}
});

export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id: rawId } = await params;
const { id } = parseRouteParams({ id: rawId }, paramsSchema, "Invalid document id.");
if (isDemoMode())
return NextResponse.json({ error: "Source reviews are unavailable in demo mode." }, { status: 400 });

const supabase = createAdminClient();
const user = await requireAuthenticatedUser(request, supabase);
const body = await parseJsonBody(request, bodySchema, "Invalid source review.");
const rateLimit = await consumeApiRateLimit({ supabase, ownerId: user.id, bucket: "source_review" });
if (rateLimit.limited) return rateLimitJsonResponse("Too many source review requests. Retry shortly.", rateLimit);

const promotesSource = body.decision === "approved" || body.decision === "locally_reviewed";
if (promotesSource) {
const { data: document, error: documentError } = await supabase
.from("documents")
.select("id,status")
.eq("id", id)
.eq("owner_id", user.id)
.maybeSingle();
if (documentError) throw new Error(documentError.message);
if (!document) return NextResponse.json({ error: "Document not found." }, { status: 404 });
if (document.status !== "indexed") {
return NextResponse.json({ error: "Source promotion requires completed document indexing." }, { status: 409 });
}

const safety = await checkIngestionMutationSafety({
supabase,
documentIds: [id],
action: "Source promotion",
checkActiveJobs: true,
staleAfterMinutes: env.WORKER_STALE_AFTER_MINUTES,
});
if (!safety.ok) return NextResponse.json(ingestionMutationSafetyPayload(safety), { status: safety.status });

const enrichmentActive = await hasActiveAgentEnrichmentJob({
supabase,
documentId: id,
staleAfterMinutes: env.WORKER_STALE_AFTER_MINUTES,
});
if (enrichmentActive) {
return NextResponse.json({ error: "Source promotion is paused while enrichment is active." }, { status: 409 });
}
}

const { data, error } = await supabase.rpc("record_source_review", {
p_document_id: id,
p_reviewer_id: user.id,
p_decision: body.decision,
Comment thread
BigSimmo marked this conversation as resolved.
p_reason: body.reason,
p_evidence_references: body.evidenceReferences,
p_review_date: body.reviewDate,
p_replacement_document_id: body.replacementDocumentId,
});
if (error) {
if (/document not found/i.test(error.message)) {
return NextResponse.json({ error: "Document not found." }, { status: 404 });
}
throw new PublicApiError("Source review could not be recorded.", 400, { code: "source_review_rejected" });
}

invalidateRagCachesForOwner(user.id);
return NextResponse.json({ review: data }, { status: 201 });
} catch (error) {
if (error instanceof AuthenticationError) return unauthorizedResponse();
if (error instanceof PublicApiError) return jsonError(error, error.status);
return jsonError(error, 500);
}
}
43 changes: 18 additions & 25 deletions src/components/ClinicalDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2556,45 +2556,41 @@ export function ClinicalDashboard({
async function submitAnswerFeedback(feedbackType: AnswerFeedbackType) {
if (!answer || pendingFeedback) return;
if (clientDemoMode) {
setActionNotice({ tone: "warning", message: "Answer review is available after signing in to a real library." });
setActionNotice({ tone: "warning", message: "Answer review is unavailable for synthetic demo answers." });
return;
}
if (!answer.interactionId) {
setActionNotice({ tone: "warning", message: "This answer predates traceable feedback. Run the question again." });
return;
}

setPendingFeedback(feedbackType);
try {
const sourceChunkIds = Array.from(new Set(sources.map((source) => source.id).filter(Boolean)));
const citedChunkIds = Array.from(new Set(answer.citations.map((citation) => citation.chunk_id).filter(Boolean)));
const sourceFiles = Array.from(
new Set([
...sources.map((source) => source.file_name).filter(Boolean),
...answer.citations.map((citation) => citation.file_name).filter(Boolean),
]),
);
const response = await fetch("/api/eval-cases", {
const digest = await window.crypto.subtle.digest("SHA-256", new TextEncoder().encode(answer.answer));
const answerHash = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
const response = await fetch("/api/answer-feedback", {
method: "POST",
headers: {
"Content-Type": "application/json",
...authorizationHeader,
},
body: JSON.stringify({
query,
feedbackType,
rating: feedbackType === "verified" ? "good" : "needs_fixing",
answer: answer.answer,
queryMode,
queryClass: answer.queryClass,
filters: compactScopeFilters(scopeFilters),
sourceChunkIds,
citedChunkIds,
sourceFiles,
sourceGovernanceWarnings: sourceGovernanceWarnings.map((warning) => warning.message),
unverifiedNumericTokens: answer.unverifiedNumericTokens ?? [],
interactionId: answer.interactionId,
feedbackCategory: feedbackType,
answerHash,
sourceIds: sourceChunkIds,
citedSourceIds: citedChunkIds,
route: answer.routingMode ?? null,
model: answer.modelUsed ?? null,
providerRequestIds: Array.from(new Set(answer.openAIRequestIds ?? [])).slice(0, 10),
}),
});

if (response.status === 401) {
markSessionExpired();
setActionNotice({ tone: "warning", message: "Sign in again before saving answer review." });
setActionNotice({ tone: "warning", message: "The session could not be validated for feedback." });
return;
}
if (!response.ok) {
Expand All @@ -2603,10 +2599,7 @@ export function ClinicalDashboard({
}
setActionNotice({
tone: "success",
message:
feedbackType === "verified"
? "Verified answer saved for eval coverage."
: "Answer issue saved for eval coverage.",
message: feedbackType === "verified" ? "Verified answer feedback saved." : "Answer issue feedback saved.",
});
} catch (feedbackError) {
setActionNotice({
Expand Down
Loading