-
Notifications
You must be signed in to change notification settings - Fork 0
feat: recover privacy-minimized clinical feedback #611
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
2229f49
feat: recover privacy-minimized clinical feedback
BigSimmo ddf9dc6
Merge remote-tracking branch 'origin/main' into codex/cleanup-domain1…
BigSimmo 525e154
Merge remote-tracking branch 'origin/main' into codex/cleanup-domain1…
BigSimmo 3891152
Merge remote-tracking branch 'origin/main' into codex/cleanup-domain1…
BigSimmo be0b3eb
fix: harden clinical feedback review guards
BigSimmo File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| 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" }); | ||
|
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); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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), | ||
|
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, | ||
|
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); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.