Files
HealthLog/src/lib/jobs/feedback-aggregator.ts
T
Marc-André BombeckandClaude Opus 4.7 05c7f1474d feat(coach): per-message thumbs feedback + admin aggregate view
The v1.4.22 prose rewrite shipped without a way for users to signal
whether the new tone is landing. v1.4.23 H7 closes that loop with a
light-touch thumbs row + an admin first-week dashboard.

Schema: extend `RecommendationFeedback` with a polymorphic
`target_type` column (default "recommendation" so legacy rows stay
representative) plus a nullable `reason` field. New index on
(target_type, created_at) so the aggregator can scan per-surface
buckets without a full sweep. Migration `0040_recommendation_feedback_target_type`.

Route: POST /api/insights/chat/messages/:id/feedback. Body
{ rating: "helpful" | "unhelpful", reason?: string }. Verifies
ownership (404 on cross-account messages — no existence leak),
decrypts the message body for the dedup key, and writes the row with
target_type="coach" + a metric-source string encoding the user's
active prefs (`coach:tone=warm:verbosity=default`). Per-user 60/h rate
limit. 409 on a P2002 (already_rated) so a double-click is a no-op.

Aggregator: new `buildCoachFeedbackBuckets()` slices Coach rows by
(promptVersion, tone, verbosity). The summary blob the worker writes
to AppSettings.adminAiInsightsFeedbackSummary now carries both
`buckets` (pre-existing) and `coachBuckets` (H7 addition). Targets
the same singleton row so no second worker handler is needed.

Admin view: `/admin/coach-feedback` (new section slug) renders the
Coach buckets as a small DataTable (prompt version × tone × verbosity
× helpful × not-helpful × n × helpful-rate). Helpful-rate column
tinted via Dracula tokens (green ≥80%, yellow ≥50%, orange below).
First useful question this answers: "Is the v1.4.22 prose rewrite
landing well, or did the warm tone overshoot?" If the helpful-rate
for PROMPT_VERSION 4.23.x drops below 50% within the first 100
ratings, v1.4.24 walks the persona back.

UI: `<ThumbsUp>` / `<ThumbsDown>` buttons (lucide icons, no emoji)
under each persisted assistant bubble. Streaming bubbles + refusals
+ error bubbles skip the row so users can't rate before the message
lands on disk. Localised "Thanks for the signal." after submit.

OpenAPI: spec carries the new POST route + the request schema.
Coverage now includes the H4 + H7 routes — three additions over
v1.4.22 baseline.

Tests +9 unit (aggregator Coach-bucket slicing, admin section
render-paths) plus an integration round-trip for the route.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:51:27 +02:00

273 lines
8.8 KiB
TypeScript

/**
* v1.4.16 phase B5e — daily feedback aggregator.
*
* Runs once a day (04:00 Europe/Berlin via the pg-boss schedule
* registered in `reminder-worker.ts`). Aggregates the last 30 days
* of `RecommendationFeedback` rows into per-(severity, metricSourceType,
* providerType, promptVersion) buckets and writes a summary JSON to
* `AppSettings.adminAiInsightsFeedbackSummary` (singleton row).
*
* The summary feeds the `/admin/ai-quality` preview that surfaces
* helpful-rate per severity x provider — a v1.4.17 ratchet will read
* the same shape to drive prompt-tuning (low-helpful-rate buckets
* get a stricter "OMIT" or "REPHRASE" rule appended next prompt
* version, per research §5.2).
*
* Single-user-default-on policy (research §3): the aggregator runs
* unconditionally for the user's own ratings. Cross-user training
* stays admin-only and the summary lives on the singleton AppSettings
* row that's already gated behind requireAdmin().
*
* No prompt mutation in v1.4.16 — the aggregator only writes the
* summary blob; the prompt-tuning step is deferred to v1.4.17.
*/
import type { PrismaClient } from "@/generated/prisma/client";
export const DEFAULT_FEEDBACK_AGGREGATION_WINDOW_DAYS = 30;
export interface FeedbackBucket {
severity: string;
metricSourceType: string;
providerType: string;
promptVersion: string;
helpful: number;
notHelpful: number;
total: number;
/** Fraction in [0..1], rounded to 2 decimals. Always 0 when total=0. */
helpfulRate: number;
/**
* v1.4.23 H7 — surface origin so the admin dashboard can group
* recommendation rows separately from Coach assistant-message rows
* (the existing buckets blended both before this field landed).
*/
targetType: string;
}
/**
* v1.4.23 H7 — slim view onto the Coach-only rows so the admin
* coach-feedback dashboard can render the first-week aggregate without
* re-deriving the slice from the recommendation buckets.
*/
export interface CoachFeedbackBucket {
promptVersion: string;
/** Encoded as `tone=warm|neutral|concise` */
tone: string;
/** Encoded as `verbosity=brief|default|detailed` */
verbosity: string;
helpful: number;
notHelpful: number;
total: number;
helpfulRate: number;
}
export interface FeedbackAggregationSummary {
generatedAt: string;
windowDays: number;
buckets: FeedbackBucket[];
/**
* v1.4.23 H7 — Coach assistant-message buckets sliced by
* (promptVersion, tone, verbosity). Empty array when the
* aggregation window has no Coach feedback rows.
*/
coachBuckets: CoachFeedbackBucket[];
}
interface MinimalFeedbackRow {
recommendationSeverity: string;
metricSourceType: string;
providerType: string;
promptVersion: string;
helpful: boolean;
/**
* v1.4.23 H7 — present on every row from the new schema. Defaults to
* "recommendation" for legacy rows so the bucketing still works
* during the upgrade window when the migration has run but the route
* hasn't started writing the field yet.
*/
targetType?: string;
}
/**
* Pure helper — group rows by the four-tuple key and compute helpful
* counts per bucket. Exported so the unit test can pin the bucketing
* shape without going through Prisma. Callers that already have rows
* in memory (admin-only ad-hoc analysis tools) can reuse this without
* the Prisma roundtrip.
*
* Sort order is deterministic: severity, then metricSourceType, then
* providerType, then promptVersion. The admin UI rendering this list
* relies on the order being stable across aggregator runs.
*/
export function buildFeedbackBuckets(
rows: MinimalFeedbackRow[],
): FeedbackBucket[] {
const map = new Map<string, FeedbackBucket>();
for (const row of rows) {
const targetType = row.targetType ?? "recommendation";
const key = [
targetType,
row.recommendationSeverity,
row.metricSourceType,
row.providerType,
row.promptVersion,
].join("");
const existing = map.get(key);
if (existing) {
if (row.helpful) existing.helpful += 1;
else existing.notHelpful += 1;
existing.total += 1;
} else {
map.set(key, {
severity: row.recommendationSeverity,
metricSourceType: row.metricSourceType,
providerType: row.providerType,
promptVersion: row.promptVersion,
helpful: row.helpful ? 1 : 0,
notHelpful: row.helpful ? 0 : 1,
total: 1,
helpfulRate: 0,
targetType,
});
}
}
const buckets = [...map.values()];
for (const b of buckets) {
b.helpfulRate =
b.total === 0 ? 0 : Math.round((b.helpful / b.total) * 100) / 100;
}
buckets.sort((a, b) => {
return (
a.targetType.localeCompare(b.targetType) ||
a.severity.localeCompare(b.severity) ||
a.metricSourceType.localeCompare(b.metricSourceType) ||
a.providerType.localeCompare(b.providerType) ||
a.promptVersion.localeCompare(b.promptVersion)
);
});
return buckets;
}
/**
* v1.4.23 H7 — derive the Coach-only bucket view from the unified
* recommendation feedback rows. The Coach route writes the user's
* active prefs into `metricSourceType` as
* `coach:tone=<x>:verbosity=<y>`; this helper parses that back out so
* the admin dashboard can group on the typed dimensions without
* re-running a regex per row.
*
* Rows whose `targetType !== "coach"` are ignored. Rows whose
* `metricSourceType` does not match the encoded shape land under
* `tone=unknown:verbosity=unknown` so an early-write pre-migration row
* still contributes to a bucket rather than silently dropping.
*/
export function buildCoachFeedbackBuckets(
rows: MinimalFeedbackRow[],
): CoachFeedbackBucket[] {
const map = new Map<string, CoachFeedbackBucket>();
for (const row of rows) {
if ((row.targetType ?? "recommendation") !== "coach") continue;
const { tone, verbosity } = parseCoachMetricSource(row.metricSourceType);
const key = `${row.promptVersion}|${tone}|${verbosity}`;
const existing = map.get(key);
if (existing) {
if (row.helpful) existing.helpful += 1;
else existing.notHelpful += 1;
existing.total += 1;
} else {
map.set(key, {
promptVersion: row.promptVersion,
tone,
verbosity,
helpful: row.helpful ? 1 : 0,
notHelpful: row.helpful ? 0 : 1,
total: 1,
helpfulRate: 0,
});
}
}
const buckets = [...map.values()];
for (const b of buckets) {
b.helpfulRate =
b.total === 0 ? 0 : Math.round((b.helpful / b.total) * 100) / 100;
}
buckets.sort(
(a, b) =>
a.promptVersion.localeCompare(b.promptVersion) ||
a.tone.localeCompare(b.tone) ||
a.verbosity.localeCompare(b.verbosity),
);
return buckets;
}
const COACH_METRIC_SOURCE_RE = /^coach:tone=([^:]+):verbosity=([^:]+)$/;
function parseCoachMetricSource(metricSourceType: string): {
tone: string;
verbosity: string;
} {
const match = metricSourceType.match(COACH_METRIC_SOURCE_RE);
if (!match) return { tone: "unknown", verbosity: "unknown" };
return { tone: match[1], verbosity: match[2] };
}
export interface AggregateOptions {
/** Override "now" for deterministic tests. Defaults to `new Date()`. */
now?: Date;
/** Override the rolling window. Defaults to 30 days. */
windowDays?: number;
}
/**
* Read the rolling-window feedback rows, bucket them, and persist the
* summary to the singleton AppSettings row. Returns the summary so
* callers (the worker handler, ad-hoc admin tools) can log a digest.
*/
export async function aggregateRecommendationFeedback(
prisma: PrismaClient,
options: AggregateOptions = {},
): Promise<FeedbackAggregationSummary> {
const now = options.now ?? new Date();
const windowDays =
options.windowDays ?? DEFAULT_FEEDBACK_AGGREGATION_WINDOW_DAYS;
const since = new Date(now.getTime() - windowDays * 86_400_000);
const rows = await prisma.recommendationFeedback.findMany({
where: { createdAt: { gte: since } },
select: {
recommendationSeverity: true,
metricSourceType: true,
providerType: true,
promptVersion: true,
helpful: true,
targetType: true,
},
});
const summary: FeedbackAggregationSummary = {
generatedAt: now.toISOString(),
windowDays,
buckets: buildFeedbackBuckets(rows),
coachBuckets: buildCoachFeedbackBuckets(rows),
};
// The singleton AppSettings row is auto-created on first install
// (`/admin/general` already touches it). Use upsert so the
// aggregator works on a fresh DB during testcontainer integration
// runs without depending on init ordering.
await prisma.appSettings.upsert({
where: { id: "singleton" },
update: {
adminAiInsightsFeedbackSummary: summary as unknown as object,
},
create: {
id: "singleton",
adminAiInsightsFeedbackSummary: summary as unknown as object,
},
});
return summary;
}