Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
212 lines
14 KiB
TypeScript
212 lines
14 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import test from "node:test";
|
|
import React from "react";
|
|
import { toast } from "sonner";
|
|
|
|
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();
|
|
const successBefore = toast.success;
|
|
const thanks: string[] = [];
|
|
toast.success = (message) => { thanks.push(String(message)); return 1; };
|
|
let closed = 0;
|
|
try {
|
|
const key = `${SESSION}:message-5`;
|
|
void persistReplyRating(key, "down", "第三答:注意作息。");
|
|
await h.render(<ReplyDownReason feedbackKey={key} onClose={() => { closed += 1; }} />);
|
|
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: [] });
|
|
// 原值: h.container.text 含「已收到,谢谢。」(面板原地换成一行字,1.2 秒后关)
|
|
// 新值: 「已收到,谢谢。」是 success toast,面板随即关闭(onClose 1 次)
|
|
// 原因: 2026-09-30 产品「报错和提示不要占页面布局」
|
|
assert.deepEqual(thanks, ["已收到,谢谢。"]);
|
|
assert.equal(closed, 1);
|
|
assert.deepEqual(h.errors, []);
|
|
} finally {
|
|
toast.success = successBefore;
|
|
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\}/);
|
|
});
|