mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
feat(api): /api/insights/feedback persists thumbs with provider attribution
v1.4.16 phase B5e: ship the POST endpoint that backs the per-rec
thumbs-up / thumbs-down UI. Body is the four rec-attributes the
client just rendered (id, text-snapshot, severity, metric-source +
time-range) plus the boolean verdict; server-fills providerType +
promptVersion via `resolveFeedbackAttribution()` so the client cannot
poison the daily aggregator's slice by claiming a thumbs-down was
attributed to a rival provider.
Idempotent via the existing `withIdempotency()` wrapper — a retry
with the same Idempotency-Key replays the cached response with
`X-Idempotent-Replay: true`. The dedup unique key on the model
(userId, recommendationId, recommendationText) returns 409 when the
same rec-text is re-rated — that's by design, the aggregator wants
per-text signal not per-id signal so a regeneration that rewrites
the same id with new text re-rates cleanly.
The attribution helper prefers `chainProviderType` over `providerType`
in the latest insights.generate audit row so when the B5b multi-
provider chain falls through to a secondary, the feedback row
reflects the working provider not the configured one. Falls back to
"unknown" + the current PROMPT_VERSION constant when no audit row
exists yet — the aggregator buckets that separately.
Audit row: action `insights.recommendation.feedback`, details carry
the feedback id, severity, helpful, providerType, promptVersion.
Tests:
- 7 unit tests for the Zod request schema (valid path, severity
poisoning, time-range poisoning, empty fields, stringified bool,
extra-field stripping)
- 5 unit tests for `pickProviderType()` priority + fallback
- 4 integration tests: 201 happy path with provider attribution,
409 dedup, 422 validation, fallback to unknown when no audit row
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
badc893853
commit
8255e3e49a
6 files changed
+591
No files matched your search
@@ -0,0 +1,103 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { resolveFeedbackAttribution } from "@/lib/ai/feedback-attribution";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { recommendationFeedbackRequestSchema } from "@/lib/validations/recommendation-feedback";
|
||||
|
||||
/**
|
||||
* v1.4.16 phase B5e — POST /api/insights/feedback.
|
||||
*
|
||||
* Per-recommendation thumbs-up / thumbs-down. The body contains the
|
||||
* rec id + snapshot text + severity + metric-source attributes the
|
||||
* client just rendered. Server-side fills providerType + promptVersion
|
||||
* via `resolveFeedbackAttribution()` so the client cannot tamper with
|
||||
* the slice the daily aggregator computes.
|
||||
*
|
||||
* The endpoint is idempotent via `withIdempotency()` — a retry with
|
||||
* the same `Idempotency-Key` for the same `(userId, method, path)`
|
||||
* replays the cached response with `X-Idempotent-Replay: true`.
|
||||
*
|
||||
* 201 on insert; 409 on the `(userId, recommendationId,
|
||||
* recommendationText)` unique violation (the user already rated this
|
||||
* exact rec text). 422 on a validation failure.
|
||||
*/
|
||||
async function handlePost(request: NextRequest) {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: rawBody, error } = await safeJson<unknown>(request);
|
||||
if (error) return error;
|
||||
|
||||
const parsed = recommendationFeedbackRequestSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "insights.recommendation.feedback" },
|
||||
meta: { outcome: "validation_failed" },
|
||||
});
|
||||
return apiError("Invalid feedback payload", 422);
|
||||
}
|
||||
const body = parsed.data;
|
||||
|
||||
const attribution = await resolveFeedbackAttribution(user.id);
|
||||
|
||||
try {
|
||||
const created = await prisma.recommendationFeedback.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
recommendationId: body.recommendationId,
|
||||
recommendationText: body.recommendationText,
|
||||
recommendationSeverity: body.recommendationSeverity,
|
||||
metricSourceType: body.metricSourceType,
|
||||
metricSourceTimeRange: body.metricSourceTimeRange,
|
||||
helpful: body.helpful,
|
||||
providerType: attribution.providerType,
|
||||
promptVersion: attribution.promptVersion,
|
||||
},
|
||||
select: { id: true, createdAt: true },
|
||||
});
|
||||
|
||||
await auditLog("insights.recommendation.feedback", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
feedbackId: created.id,
|
||||
recommendationId: body.recommendationId,
|
||||
severity: body.recommendationSeverity,
|
||||
helpful: body.helpful,
|
||||
providerType: attribution.providerType,
|
||||
promptVersion: attribution.promptVersion,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.recommendation.feedback" },
|
||||
meta: {
|
||||
outcome: "created",
|
||||
severity: body.recommendationSeverity,
|
||||
helpful: body.helpful,
|
||||
providerType: attribution.providerType,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ id: created.id, createdAt: created.createdAt }, 201);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === "P2002"
|
||||
) {
|
||||
annotate({
|
||||
action: { name: "insights.recommendation.feedback" },
|
||||
meta: { outcome: "already_rated" },
|
||||
});
|
||||
return apiError("already_rated", 409);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = apiHandler(withIdempotency(handlePost));
|
||||
Reference in new issue
Block a user