mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
feat(db): RecommendationFeedback model with GDPR cascade + provider attribution
v1.4.16 phase B5e: ship the table that backs per-recommendation thumbs-up / thumbs-down feedback. Each row captures (userId, recId, recText) with snapshotted severity + metric-source attributes plus the provider + prompt-version that produced the rec — server-fills the attribution from the user's most recent cached payload so the client can't tamper. Dedup key (userId, recommendationId, recommendationText) lets a regeneration that rewrites the same id with new text re-rate cleanly while blocking accidental double-submission of the same (id + text). Indexes: (userId, createdAt) for the per-user timeline and (severity, helpful, createdAt) for the daily aggregator's slice. Both are needed by the v1.4.16 admin AI quality preview. GDPR: ON DELETE CASCADE through users; cascade-delete integration test now seeds and asserts a feedback row. Same migration adds AppSettings.adminAiInsightsFeedbackSummary — the JSON blob the daily aggregator (next commit) writes a per-(severity x provider x prompt) helpful-rate to. Null until the aggregator runs or until there is feedback in the 30-day window. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
170c62aac9
commit
badc893853
3 files changed
+132
No files matched your search
@@ -0,0 +1,56 @@
|
||||
-- v1.4.16 phase B5e: per-recommendation thumbs-up/thumbs-down feedback.
|
||||
--
|
||||
-- Each row captures one rating event from one user against one
|
||||
-- recommendation. The dedup key (userId, recommendationId,
|
||||
-- recommendationText) lets a regeneration that rewrites the same id
|
||||
-- with new text re-rate cleanly while preventing accidental double-
|
||||
-- submission of the same (id + text) pair.
|
||||
--
|
||||
-- Provider attribution (providerType + promptVersion) is filled
|
||||
-- server-side from the user's most recent cached insight payload, so
|
||||
-- the client can't tamper with which provider / prompt version a
|
||||
-- thumbs-down is attributed to. The aggregator slices on these to
|
||||
-- power the admin AI quality dashboard.
|
||||
--
|
||||
-- Indexes:
|
||||
-- (userId, createdAt) — per-user timeline lookups
|
||||
-- (severity, helpful, createdAt) — aggregator's cross-user slice
|
||||
-- The unique key doubles as a (userId, recommendationId, text) lookup.
|
||||
--
|
||||
-- GDPR: ON DELETE CASCADE through users → personal data follows the
|
||||
-- account; covered in tests/integration/cascade-delete.test.ts.
|
||||
|
||||
CREATE TABLE "recommendation_feedback" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"recommendation_id" TEXT NOT NULL,
|
||||
"recommendation_text" TEXT NOT NULL,
|
||||
"recommendation_severity" TEXT NOT NULL,
|
||||
"metric_source_type" TEXT NOT NULL,
|
||||
"metric_source_time_range" TEXT NOT NULL,
|
||||
"helpful" BOOLEAN NOT NULL,
|
||||
"provider_type" TEXT NOT NULL,
|
||||
"prompt_version" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "recommendation_feedback_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "rec_feedback_user_rec_text_key"
|
||||
ON "recommendation_feedback" ("user_id", "recommendation_id", "recommendation_text");
|
||||
|
||||
CREATE INDEX "recommendation_feedback_user_id_created_at_idx"
|
||||
ON "recommendation_feedback" ("user_id", "created_at");
|
||||
|
||||
CREATE INDEX "recommendation_feedback_recommendation_severity_helpful_cre_idx"
|
||||
ON "recommendation_feedback" ("recommendation_severity", "helpful", "created_at");
|
||||
|
||||
ALTER TABLE "recommendation_feedback"
|
||||
ADD CONSTRAINT "recommendation_feedback_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- v1.4.16 phase B5e: daily-aggregated feedback summary written by the
|
||||
-- pg-boss `feedback-aggregator` job. Read by /admin/ai-quality.
|
||||
ALTER TABLE "app_settings"
|
||||
ADD COLUMN "admin_ai_insights_feedback_summary" JSONB;
|
||||
@@ -129,6 +129,7 @@ model User {
|
||||
dataBackups DataBackup[]
|
||||
achievements UserAchievement[]
|
||||
feedback Feedback[]
|
||||
recommendationFeedback RecommendationFeedback[]
|
||||
idempotencyKeys IdempotencyKey[]
|
||||
devices Device[]
|
||||
integrationStatuses IntegrationStatus[]
|
||||
@@ -547,6 +548,20 @@ model AppSettings {
|
||||
adminAiModel String @default("gpt-4o-mini") @map("admin_ai_model")
|
||||
adminAiBaseUrl String @default("https://api.openai.com/v1") @map("admin_ai_base_url")
|
||||
|
||||
// v1.4.16 phase B5e — daily-aggregated rec-feedback summary written
|
||||
// by `feedback-aggregator.ts`. Shape:
|
||||
// {
|
||||
// "generatedAt": "2026-05-09T04:00:00.000Z",
|
||||
// "windowDays": 30,
|
||||
// "buckets": [
|
||||
// { "severity": "important", "metricSourceType": "bloodPressure",
|
||||
// "providerType": "codex", "promptVersion": "4.16.0",
|
||||
// "helpful": 12, "notHelpful": 3, "total": 15, "helpfulRate": 0.8 }
|
||||
// ]
|
||||
// }
|
||||
// Null = aggregator hasn't run yet OR there's no feedback in the window.
|
||||
adminAiInsightsFeedbackSummary Json? @map("admin_ai_insights_feedback_summary")
|
||||
|
||||
@@map("app_settings")
|
||||
}
|
||||
|
||||
@@ -792,3 +807,45 @@ model HostMetric {
|
||||
@@index([capturedAt])
|
||||
@@map("host_metrics")
|
||||
}
|
||||
|
||||
// ─── Recommendation Feedback (v1.4.16 phase B5e) ───────────────
|
||||
//
|
||||
// Per-recommendation thumbs-up / thumbs-down rating. Captured client-
|
||||
// side under each rec card; persisted with provider attribution
|
||||
// (providerType + promptVersion) so the daily aggregator can slice
|
||||
// helpful-rate per (severity x provider x prompt) for the admin AI
|
||||
// quality dashboard.
|
||||
//
|
||||
// Default-on for the single-user case (only the user's ratings shape
|
||||
// the user's future prompts); cross-user aggregate stays admin-only
|
||||
// (no prompt-tuning flow yet — that ratchet is v1.4.17).
|
||||
//
|
||||
// Privacy: server-side fills providerType + promptVersion from the
|
||||
// user's most recent cached payload (User.insightsCachedText) so the
|
||||
// client can't tamper with the attribution.
|
||||
//
|
||||
// `(userId, recommendationId, recommendationText)` is the dedup key.
|
||||
// recommendationText snapshots the rec at submission time so a re-
|
||||
// generation 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.
|
||||
model RecommendationFeedback {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
recommendationId String @map("recommendation_id")
|
||||
recommendationText String @map("recommendation_text")
|
||||
recommendationSeverity String @map("recommendation_severity")
|
||||
metricSourceType String @map("metric_source_type")
|
||||
metricSourceTimeRange String @map("metric_source_time_range")
|
||||
helpful Boolean
|
||||
providerType String @map("provider_type")
|
||||
promptVersion String @map("prompt_version")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, recommendationId, recommendationText], map: "rec_feedback_user_rec_text_key")
|
||||
@@index([userId, createdAt])
|
||||
@@index([recommendationSeverity, helpful, createdAt])
|
||||
@@map("recommendation_feedback")
|
||||
}
|
||||
@@ -97,6 +97,22 @@ describe("user.delete cascades to all personal-data tables", () => {
|
||||
},
|
||||
});
|
||||
|
||||
// v1.4.16 phase B5e: rec-feedback rows must follow the user on
|
||||
// erasure (provider attribution makes them personal data).
|
||||
await prisma.recommendationFeedback.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
recommendationId: "rec-cascade-1",
|
||||
recommendationText: "Discuss home BP log with your physician.",
|
||||
recommendationSeverity: "important",
|
||||
metricSourceType: "bloodPressure",
|
||||
metricSourceTimeRange: "last7days",
|
||||
helpful: true,
|
||||
providerType: "codex",
|
||||
promptVersion: "4.16.0",
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.idempotencyKey.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
@@ -168,6 +184,9 @@ describe("user.delete cascades to all personal-data tables", () => {
|
||||
expect(
|
||||
await prisma.userAchievement.count({ where: { userId: user.id } }),
|
||||
).toBe(0);
|
||||
expect(
|
||||
await prisma.recommendationFeedback.count({ where: { userId: user.id } }),
|
||||
).toBe(0);
|
||||
expect(
|
||||
await prisma.idempotencyKey.count({ where: { userId: user.id } }),
|
||||
).toBe(0);
|
||||
|
||||
Reference in new issue
Block a user