mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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>
54 lines
1.8 KiB
TypeScript
54 lines
1.8 KiB
TypeScript
import { z } from "zod/v4";
|
|
|
|
/**
|
|
* v1.4.16 phase B5e — request body for `POST /api/insights/feedback`.
|
|
*
|
|
* Mirrors the `RecommendationFeedback` Prisma model fields the client
|
|
* is allowed to specify. `providerType` and `promptVersion` are NOT
|
|
* part of the request shape — the server fills them from the user's
|
|
* most recent cached payload + the current PROMPT_VERSION constant
|
|
* so the client can't tamper with the attribution that the daily
|
|
* aggregator slices on for the admin AI quality dashboard.
|
|
*
|
|
* Severity + dataWindow are constrained to the same vocabulary as the
|
|
* AI schema (`aiRecommendationSchema` / `aiRecommendationRationaleSchema`)
|
|
* so a bogus rating attempt with `severity = "wat"` is rejected at the
|
|
* edge instead of poisoning the aggregator.
|
|
*/
|
|
export const recommendationFeedbackSeverity = z.enum([
|
|
"info",
|
|
"suggestion",
|
|
"important",
|
|
"urgent",
|
|
]);
|
|
|
|
export const recommendationFeedbackTimeRange = z.enum([
|
|
"last7days",
|
|
"last30days",
|
|
"last90days",
|
|
"allTime",
|
|
]);
|
|
|
|
export const recommendationFeedbackRequestSchema = z.object({
|
|
/**
|
|
* Stable per-payload rec id. Dedup happens on
|
|
* (userId, recommendationId, recommendationText).
|
|
*/
|
|
recommendationId: z.string().min(1).max(200),
|
|
/**
|
|
* Snapshot of the rec text at submission time. Dedup partner so a
|
|
* regeneration that rewrites the same id with new text counts as a
|
|
* different row — that's intentional, the aggregator wants per-text
|
|
* signal not per-id signal.
|
|
*/
|
|
recommendationText: z.string().min(1).max(2000),
|
|
recommendationSeverity: recommendationFeedbackSeverity,
|
|
metricSourceType: z.string().min(1).max(80),
|
|
metricSourceTimeRange: recommendationFeedbackTimeRange,
|
|
helpful: z.boolean(),
|
|
});
|
|
|
|
export type RecommendationFeedbackRequest = z.infer<
|
|
typeof recommendationFeedbackRequestSchema
|
|
>;
|