feat(reply-quality): 👎 keeps a snapshot of the turn; admin 对话质量记录
A 👎 now saves, server side, the rated turn plus the context window the model read for it (reconstructed from the stored session with the same consultationHistoryWindow the consult route uses), model and run facts. 👍 is only counted. Switching to 👍 or clearing deletes the snapshot. Bodies are blanked after 90 days; the row cascades on session delete and on account deletion. - Optional 不满意原因 panel under the answer after a 👎 (five reasons, 200-char note, "会把这一轮对话发给我们排查"). - Admin 对话质量记录: 👍/👎 stats by day and model, list without text, audited snapshot open, 处理状态 + note (support.quality.read/write). - Privacy draft: what a 👎 keeps, why, 90 days, deletion. - Migration 20260930050000 is add-only; set_reply_rating() replaced with the same signature. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c1b4868cd9
commit
12e315a9bb
@@ -162,6 +162,9 @@ test("reply ratings: keyed by session and position, saved on toggle, restored on
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
assert.match(read("../src/components/chat-transcript.tsx"), /persistReplyRating\(feedbackKey, toggleChatMessageFeedback\(messageFeedback\[feedbackKey\], requested\), message\.text\)/);
|
||||
// 原值: persistReplyRating(feedbackKey, toggleChatMessageFeedback(...), message.text) inline.
|
||||
// 新值: the toggled value is named `next` once and saved; the same value also opens 不满意原因.
|
||||
// 原因: reply-quality round 2026-09-30 needs the toggled rating twice; what is saved is unchanged.
|
||||
assert.match(read("../src/components/chat-transcript.tsx"), /const next = toggleChatMessageFeedback\(messageFeedback\[feedbackKey\], requested\);\s+void persistReplyRating\(feedbackKey, next, message\.text\);/);
|
||||
assert.match(read("../src/app/(app)/page.tsx"), /useReplyRatingsSync\(activeSession, setMessageFeedback\);/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
|
||||
import { ReplyDownReason } from "../src/components/reply-down-reason";
|
||||
import { PRIVACY } from "../src/lib/legal-documents";
|
||||
import { REPLY_QUALITY_RETENTION_DAYS, parseReplyQualityFilter } from "../src/lib/reply-quality-labels";
|
||||
import { runReplyQualityRetentionTick } from "../src/lib/reply-quality-retention-core";
|
||||
import { buildReplyQualitySnapshot, normalizeReplyQualityReasons, replyAnswerHash } from "../src/lib/reply-quality-snapshot";
|
||||
import { answerHash, persistReplyRating, saveReplyQualityReason } from "../src/lib/reply-ratings";
|
||||
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support";
|
||||
|
||||
// 对话质量记录: 👎 keeps a 故障上下文快照, 👍 is only counted (compliance round 2026-09-30).
|
||||
Object.assign(globalThis, { React });
|
||||
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
|
||||
const SESSION = "22222222-2222-4222-8222-222222222222";
|
||||
const migration = read("../supabase/migrations/20260930050000_reply_quality_snapshots.sql");
|
||||
|
||||
// Fictional conversation only.
|
||||
const messages = [
|
||||
{ role: "user", text: "第一问:今年适合换工作吗?", requestId: "req-1" },
|
||||
{ role: "assistant", text: "第一答:可以考虑,但先看下半年。", requestId: "req-1" },
|
||||
{ role: "user", text: "第二问:那感情呢?", requestId: "req-2" },
|
||||
{ role: "assistant", text: "第二答:感情上宜慢。", requestId: "req-2" },
|
||||
{ role: "user", text: "第三问:健康要注意什么?", requestId: "req-3" },
|
||||
{ role: "assistant", text: "第三答:注意作息。", requestId: "req-3" },
|
||||
];
|
||||
|
||||
test("the server hash matches the browser hash the rating was saved with", async () => {
|
||||
assert.equal(replyAnswerHash("第三答:注意作息。"), await answerHash("第三答:注意作息。"));
|
||||
});
|
||||
|
||||
test("snapshot: only the stored reply the user judged, with its question and the window before it", () => {
|
||||
const hash = replyAnswerHash("第三答:注意作息。");
|
||||
const built = buildReplyQualitySnapshot({ messages, messageIndex: 5, answerSha256: hash, sessionType: "consultation" });
|
||||
assert.ok(built.ok);
|
||||
assert.equal(built.snapshot.question, "第三问:健康要注意什么?");
|
||||
assert.equal(built.snapshot.answer, "第三答:注意作息。");
|
||||
assert.equal(built.snapshot.requestId, "req-3");
|
||||
assert.equal(built.snapshot.context.kind, "consultation_window");
|
||||
assert.deepEqual(built.snapshot.context.turns.map((turn) => turn.text), [messages[0]!.text, messages[1]!.text, messages[2]!.text, messages[3]!.text]);
|
||||
assert.ok(!built.snapshot.context.turns.some((turn) => turn.text.startsWith("第三")), "the rated turn is not its own context");
|
||||
|
||||
assert.deepEqual(buildReplyQualitySnapshot({ messages, messageIndex: 5, answerSha256: "0".repeat(16), sessionType: "consultation" }), { ok: false, reason: "answer_changed" });
|
||||
assert.deepEqual(buildReplyQualitySnapshot({ messages, messageIndex: 4, answerSha256: hash, sessionType: "consultation" }), { ok: false, reason: "not_assistant" });
|
||||
assert.deepEqual(buildReplyQualitySnapshot({ messages, messageIndex: 9, answerSha256: hash, sessionType: "consultation" }), { ok: false, reason: "answer_not_stored" });
|
||||
});
|
||||
|
||||
test("snapshot: a summary counts only if it was written before the question", () => {
|
||||
const summary = (throughMessageIndex: number) => ({
|
||||
version: 1, text: "摘要:前两轮问了工作和感情。", throughRequestId: "req-2", throughMessageIndex, messageCount: 4, updatedAt: "2026-09-30T00:00:00Z",
|
||||
});
|
||||
const hash = replyAnswerHash("第三答:注意作息。");
|
||||
const before = buildReplyQualitySnapshot({ messages, messageIndex: 5, answerSha256: hash, sessionType: "consultation", contextSummary: summary(3) });
|
||||
assert.ok(before.ok);
|
||||
assert.equal(before.snapshot.context.summaryText, "摘要:前两轮问了工作和感情。");
|
||||
assert.equal(before.snapshot.context.turns.length, 0, "turns the summary covers are not repeated");
|
||||
const after = buildReplyQualitySnapshot({ messages, messageIndex: 5, answerSha256: hash, sessionType: "consultation", contextSummary: summary(5) });
|
||||
assert.ok(after.ok);
|
||||
assert.equal(after.snapshot.context.summaryText, null, "a summary written after this turn was not what the model read");
|
||||
assert.equal(after.snapshot.context.turns.length, 4);
|
||||
});
|
||||
|
||||
test("snapshot: rectification sessions keep the last turns before the question and say so", () => {
|
||||
const built = buildReplyQualitySnapshot({ messages, messageIndex: 5, answerSha256: replyAnswerHash("第三答:注意作息。"), sessionType: "birth_time_rectification" });
|
||||
assert.ok(built.ok);
|
||||
assert.equal(built.snapshot.context.kind, "rectification_recent_turns");
|
||||
assert.equal(built.snapshot.context.turns.length, 4);
|
||||
});
|
||||
|
||||
test("reasons: known keys only, in picker order; the note is trimmed to 200 characters", () => {
|
||||
assert.deepEqual(normalizeReplyQualityReasons(["tone", "drop table", "off_topic", "tone"], " 太笼统 "), { reasons: ["off_topic", "tone"], note: "太笼统" });
|
||||
assert.deepEqual(normalizeReplyQualityReasons([], " "), { reasons: [], note: null });
|
||||
assert.equal(normalizeReplyQualityReasons([], "字".repeat(300)).note?.length, 200);
|
||||
assert.deepEqual(parseReplyQualityFilter("resolved"), { status: "resolved", reason: null, modelId: null, days: null });
|
||||
assert.deepEqual(parseReplyQualityFilter("reason:inaccurate"), { status: null, reason: "inaccurate", modelId: null, days: null });
|
||||
assert.deepEqual(parseReplyQualityFilter("days:7"), { status: null, reason: null, modelId: null, days: 7 });
|
||||
assert.deepEqual(parseReplyQualityFilter("days:365"), { status: null, reason: null, modelId: null, days: null });
|
||||
assert.deepEqual(parseReplyQualityFilter("reason:drop table"), { status: null, reason: null, modelId: null, days: null });
|
||||
});
|
||||
|
||||
test("database: 👍 or clearing removes the snapshot; a snapshot needs a stored 👎; bodies expire; 注销 deletes", () => {
|
||||
assert.match(migration, /if p_rating is null or p_rating <> 'down' then\s+delete from public\.reply_quality_snapshots/);
|
||||
assert.match(migration, /r\.rating = 'down'\s+\) then\s+raise exception 'reply_quality_not_down'/);
|
||||
assert.match(migration, /set question = null,\s+answer = null,\s+context = null,\s+body_expired_at = clock_timestamp\(\)/);
|
||||
assert.match(migration, /user_id uuid not null references auth\.users\(id\) on delete cascade/);
|
||||
assert.match(migration, /session_id uuid not null references public\.chat_sessions\(id\) on delete cascade/);
|
||||
const kept = read("../supabase/migrations/20260930020000_account_deletion_requests.sql");
|
||||
const keptList = kept.slice(kept.indexOf("function public.account_deletion_kept_tables()"), kept.indexOf("$$;", kept.indexOf("function public.account_deletion_kept_tables()")));
|
||||
assert.doesNotMatch(keptList, /reply_quality/, "注销 purges the snapshots with the account");
|
||||
assert.match(migration, /revoke all on table public\.reply_quality_snapshots from public, anon, authenticated, service_role;/);
|
||||
assert.doesNotMatch(migration, /\b(drop table|drop column|alter table public\.(?!reply_quality_snapshots))/i, "additive only");
|
||||
// 👍 keeps no text: the only writer of a body requires a stored 👎.
|
||||
const save = migration.slice(migration.indexOf("function public.save_reply_quality_snapshot"), migration.indexOf("function public.set_reply_quality_reason"));
|
||||
assert.match(save, /r\.rating = 'down'/);
|
||||
assert.doesNotMatch(save, /'up'/);
|
||||
});
|
||||
|
||||
test("admin: opening a snapshot and every status change are audited; the list carries no body", () => {
|
||||
assert.match(migration, /'reply_quality\.open', 'reply_quality_snapshot', p_snapshot_id/);
|
||||
assert.match(migration, /'reply_quality\.update', 'reply_quality_snapshot', p_snapshot_id/);
|
||||
assert.match(migration, /\('auditor', 'support\.quality\.read'\)\n\)/);
|
||||
const list = migration.slice(migration.indexOf("function public.admin_list_reply_quality"), migration.indexOf("function public.admin_open_reply_quality"));
|
||||
assert.doesNotMatch(list, /q\.question|q\.answer|q\.context/);
|
||||
assert.match(read("../src/lib/admin/auth-policy.ts"), /"support\.quality\.read",\n "support\.quality\.write",/);
|
||||
assert.match(read("../src/lib/admin/providers.ts"), /"reply-quality": \{ read: "support\.quality\.read", write: "support\.quality\.write" \}/);
|
||||
assert.match(read("../src/components/admin/admin-app.tsx"), /label: "对话质量记录"/);
|
||||
const detail = read("../src/app/api/admin/reply-quality/[id]/route.ts");
|
||||
assert.match(detail, /admin_open_reply_quality\(\$1::uuid,\$2::uuid,\$3::text\)", \[session\.user\.id, id, requestId\(request\)\]/);
|
||||
assert.match(detail, /requireAdminMutation\(request, "support\.quality\.write"\)/);
|
||||
assert.match(read("../src/app/api/admin/reply-quality/route.ts"), /requirePermission\("support\.quality\.read"\)/);
|
||||
});
|
||||
|
||||
test("retention: the worker asks for 90 days and logs counts only", async () => {
|
||||
const lines: string[] = [];
|
||||
let asked = 0;
|
||||
assert.equal(await runReplyQualityRetentionTick({ expire: async (days) => { asked = days; return 3; }, log: (line) => lines.push(line) }), 3);
|
||||
assert.equal(asked, REPLY_QUALITY_RETENTION_DAYS);
|
||||
assert.equal(REPLY_QUALITY_RETENTION_DAYS, 90);
|
||||
assert.deepEqual(lines, ["[reply-quality-retention] expired=3"]);
|
||||
assert.match(read("../src/instrumentation.ts"), /startReplyQualityRetentionWorker\(\)/);
|
||||
});
|
||||
|
||||
test("the 👎 route keeps a snapshot only for a 👎, built on the server", () => {
|
||||
const route = read("../src/app/api/reply-ratings/route.ts");
|
||||
assert.match(route, /rating === "down"/);
|
||||
assert.match(route, /captureReplyQualitySnapshot\(/);
|
||||
const capture = read("../src/lib/reply-quality-capture.ts");
|
||||
assert.match(capture, /\.eq\("user_id", input\.userId\)/);
|
||||
assert.match(capture, /rpc\("save_reply_quality_snapshot"/);
|
||||
});
|
||||
|
||||
test("the privacy draft says what a 👎 keeps, why, for how long, and that 注销 deletes it", () => {
|
||||
const text = PRIVACY.sections.flatMap((section) => section.paragraphs).join("\n");
|
||||
assert.match(text, /点「踩」时,我们会保存这一轮的提问与回答、模型当时参考的前文对话/);
|
||||
assert.match(text, /排查回答质量问题/);
|
||||
assert.match(text, /保存 90 天,期满后清空对话内容/);
|
||||
assert.match(text, /注销账号时一并删除/);
|
||||
});
|
||||
|
||||
type Harness = ReturnType<typeof createClientLifecycleHarness>;
|
||||
const find = (h: Harness, predicate: (node: ReturnType<Harness["elements"]>[number]) => boolean) => h.elements().find(predicate);
|
||||
|
||||
test("不满意原因 is optional: submitting with nothing picked still thanks the user, after the 👎 save", async () => {
|
||||
const calls: { url: string; body: Record<string, unknown> }[] = [];
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (url: string, init?: RequestInit) => {
|
||||
calls.push({ url, body: JSON.parse(String(init?.body ?? "{}")) });
|
||||
return new Response(JSON.stringify({ saved: true }), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
const h = createClientLifecycleHarness();
|
||||
try {
|
||||
const key = `${SESSION}:message-5`;
|
||||
void persistReplyRating(key, "down", "第三答:注意作息。");
|
||||
await h.render(<ReplyDownReason feedbackKey={key} onClose={() => {}} />);
|
||||
assert.ok(h.container.text.includes("哪里不满意?可以不选"));
|
||||
assert.ok(h.container.text.includes("会把这一轮对话发给我们排查"));
|
||||
assert.deepEqual(h.elements().filter((node) => node.getAttribute("aria-pressed") !== null).map((node) => node.text), ["答非所问", "内容不准", "太长太空", "语气不对", "其他"]);
|
||||
await h.event(find(h, (node) => node.tagName === "BUTTON" && node.text === "提交")!);
|
||||
await h.idle();
|
||||
assert.deepEqual(calls.map((call) => call.url), ["/api/reply-ratings", "/api/reply-ratings/reason"]);
|
||||
assert.deepEqual(calls[1]!.body, { sessionId: SESSION, messageIndex: 5, reasons: [] });
|
||||
assert.ok(h.container.text.includes("已收到,谢谢。"));
|
||||
assert.deepEqual(h.errors, []);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
await h.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("picked reasons and the note are sent; an unsaved conversation sends nothing", async () => {
|
||||
const calls: Record<string, unknown>[] = [];
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (_url: string, init?: RequestInit) => {
|
||||
calls.push(JSON.parse(String(init?.body ?? "{}")));
|
||||
return new Response("{}", { status: 200 });
|
||||
}) as typeof fetch;
|
||||
const h = createClientLifecycleHarness();
|
||||
try {
|
||||
await h.render(<ReplyDownReason feedbackKey={`${SESSION}:message-3`} onClose={() => {}} />);
|
||||
await h.event(find(h, (node) => node.tagName === "BUTTON" && node.text === "语气不对")!);
|
||||
await h.event(find(h, (node) => node.tagName === "BUTTON" && node.text === "答非所问")!);
|
||||
assert.equal(find(h, (node) => node.tagName === "BUTTON" && node.text === "答非所问")?.getAttribute("aria-pressed"), "true");
|
||||
await h.event(find(h, (node) => node.tagName === "INPUT")!, "onChange", { target: { value: " 没回答我问的年份 " } });
|
||||
await h.event(find(h, (node) => node.tagName === "BUTTON" && node.text === "提交")!);
|
||||
await h.idle();
|
||||
assert.deepEqual(calls, [{ sessionId: SESSION, messageIndex: 3, reasons: ["off_topic", "tone"], note: "没回答我问的年份" }]);
|
||||
assert.equal(await saveReplyQualityReason("local-draft:message-3", ["tone"], ""), false);
|
||||
assert.equal(calls.length, 1);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
await h.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("the picker opens only right after a 👎 in the transcript", () => {
|
||||
const transcript = read("../src/components/chat-transcript.tsx");
|
||||
assert.match(transcript, /setReasonOpen\(next === "down"\)/);
|
||||
assert.match(transcript, /\{showActions && reasonOpen && \(\s*<ReplyDownReason feedbackKey=\{feedbackKey\}/);
|
||||
});
|
||||
Reference in New Issue
Block a user