mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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>
178 lines
5.5 KiB
TypeScript
178 lines
5.5 KiB
TypeScript
/**
|
|
* v1.4.23 H7 — Coach assistant-message thumbs feedback route.
|
|
*
|
|
* Round-trip: seed a session + a coach conversation + an assistant
|
|
* message, POST a thumbs rating, assert a `RecommendationFeedback` row
|
|
* landed with `targetType="coach"` and the encoded prefs metric source.
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
process.env.ENCRYPTION_KEY ??=
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
|
|
import { encrypt } from "@/lib/crypto";
|
|
|
|
import { cookieJar, headerJar } from "./mock-next-headers";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
vi.mock("next/headers", async () => {
|
|
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
|
return {
|
|
headers: vi.fn(async () => ({
|
|
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
|
})),
|
|
cookies: vi.fn(async () => ({
|
|
get: (name: string) => {
|
|
const value = cookieJar.get(name);
|
|
return value ? { name, value } : undefined;
|
|
},
|
|
set: (name: string, value: string) => {
|
|
cookieJar.set(name, value);
|
|
},
|
|
delete: (name: string) => {
|
|
cookieJar.delete(name);
|
|
},
|
|
})),
|
|
};
|
|
});
|
|
|
|
vi.mock("@/lib/db-compat", () => ({
|
|
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
async function seedSession(username: string) {
|
|
const prisma = getPrismaClient();
|
|
const user = await prisma.user.create({
|
|
data: { username, email: `${username}@example.test` },
|
|
});
|
|
const session = await prisma.session.create({
|
|
data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) },
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
return user;
|
|
}
|
|
|
|
async function seedAssistantMessage(userId: string, content: string) {
|
|
const prisma = getPrismaClient();
|
|
const conversation = await prisma.coachConversation.create({
|
|
data: { userId, title: "Test conversation" },
|
|
});
|
|
const ciphertext = encrypt(content);
|
|
return prisma.coachMessage.create({
|
|
data: {
|
|
conversationId: conversation.id,
|
|
role: "assistant",
|
|
encryptedContent: Buffer.from(ciphertext, "utf8"),
|
|
providerType: "codex",
|
|
promptVersion: "4.23.0",
|
|
},
|
|
});
|
|
}
|
|
|
|
describe("POST /api/insights/chat/messages/:id/feedback", () => {
|
|
it("persists a thumbs-up with target_type=coach and the encoded metric source", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await seedSession("coach-feedback-h7");
|
|
const message = await seedAssistantMessage(user.id, "Your last week...");
|
|
|
|
const { POST } = await import(
|
|
"@/app/api/insights/chat/messages/[id]/feedback/route"
|
|
);
|
|
const res = await (
|
|
POST as (
|
|
req: Request,
|
|
ctx: { params: Promise<{ id: string }> },
|
|
) => Promise<Response>
|
|
)(
|
|
new Request(
|
|
`http://localhost/api/insights/chat/messages/${message.id}/feedback`,
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ rating: "helpful" }),
|
|
},
|
|
),
|
|
{ params: Promise.resolve({ id: message.id }) },
|
|
);
|
|
expect(res.status).toBe(201);
|
|
|
|
const rows = await prisma.recommendationFeedback.findMany({
|
|
where: { userId: user.id },
|
|
});
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0].targetType).toBe("coach");
|
|
expect(rows[0].helpful).toBe(true);
|
|
expect(rows[0].recommendationSeverity).toBe("coach");
|
|
// Default prefs encode as warm/default — the user has no
|
|
// coachPrefsJson row so the route falls back to defaults.
|
|
expect(rows[0].metricSourceType).toBe("coach:tone=warm:verbosity=default");
|
|
expect(rows[0].providerType).toBe("codex");
|
|
expect(rows[0].promptVersion).toBe("4.23.0");
|
|
});
|
|
|
|
it("returns 404 when the message belongs to another user", async () => {
|
|
const otherUser = await getPrismaClient().user.create({
|
|
data: { username: "other-coach-h7" },
|
|
});
|
|
const otherMessage = await seedAssistantMessage(otherUser.id, "private");
|
|
await seedSession("attacker-h7");
|
|
|
|
const { POST } = await import(
|
|
"@/app/api/insights/chat/messages/[id]/feedback/route"
|
|
);
|
|
const res = await (
|
|
POST as (
|
|
req: Request,
|
|
ctx: { params: Promise<{ id: string }> },
|
|
) => Promise<Response>
|
|
)(
|
|
new Request(
|
|
`http://localhost/api/insights/chat/messages/${otherMessage.id}/feedback`,
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ rating: "helpful" }),
|
|
},
|
|
),
|
|
{ params: Promise.resolve({ id: otherMessage.id }) },
|
|
);
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it("rejects an unknown rating value with 422", async () => {
|
|
const user = await seedSession("coach-feedback-422");
|
|
const message = await seedAssistantMessage(user.id, "Reply.");
|
|
|
|
const { POST } = await import(
|
|
"@/app/api/insights/chat/messages/[id]/feedback/route"
|
|
);
|
|
const res = await (
|
|
POST as (
|
|
req: Request,
|
|
ctx: { params: Promise<{ id: string }> },
|
|
) => Promise<Response>
|
|
)(
|
|
new Request(
|
|
`http://localhost/api/insights/chat/messages/${message.id}/feedback`,
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ rating: "meh" }),
|
|
},
|
|
),
|
|
{ params: Promise.resolve({ id: message.id }) },
|
|
);
|
|
expect(res.status).toBe(422);
|
|
});
|
|
});
|