From 12e315a9bb7c3d1700a1ace132bdae130ff4f425 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 30 Sep 2026 09:39:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(reply-quality):=20=F0=9F=91=8E=20keeps=20a?= =?UTF-8?q?=20snapshot=20of=20the=20turn;=20admin=20=E5=AF=B9=E8=AF=9D?= =?UTF-8?q?=E8=B4=A8=E9=87=8F=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE --- BLOCKED.md | 7 + CHANGELOG.md | 8 + CONTEXT.md | 2 +- docs/tasks/PROGRESS-reply-quality-20260930.md | 42 ++ frontend/DESIGN.md | 13 + frontend/docs/VOICE.md | 6 + frontend/src/app/admin/reply-quality/page.tsx | 5 + .../app/api/admin/reply-quality/[id]/route.ts | 79 ++++ .../src/app/api/admin/reply-quality/route.ts | 82 ++++ .../api/admin/reply-quality/stats/route.ts | 30 ++ .../src/app/api/reply-ratings/reason/route.ts | 49 ++ frontend/src/app/api/reply-ratings/route.ts | 15 +- frontend/src/app/globals.css | 25 ++ frontend/src/components/admin/admin-app.tsx | 2 + .../admin/reply-quality-resource.tsx | 246 ++++++++++ frontend/src/components/chat-transcript.tsx | 12 +- frontend/src/components/reply-down-reason.tsx | 72 +++ frontend/src/instrumentation.ts | 4 + frontend/src/lib/admin/auth-policy.ts | 2 + frontend/src/lib/admin/providers.ts | 1 + frontend/src/lib/legal-documents.ts | 3 + frontend/src/lib/reply-quality-capture.ts | 98 ++++ frontend/src/lib/reply-quality-labels.ts | 51 +++ .../src/lib/reply-quality-retention-core.ts | 19 + .../src/lib/reply-quality-retention-worker.ts | 37 ++ frontend/src/lib/reply-quality-snapshot.ts | 135 ++++++ frontend/src/lib/reply-ratings.ts | 32 +- ...20260930050000_reply_quality_snapshots.sql | 420 ++++++++++++++++++ .../feedback-complaints-20260930.test.tsx | 5 +- .../tests/reply-quality-20260930.test.tsx | 201 +++++++++ 30 files changed, 1696 insertions(+), 7 deletions(-) create mode 100644 docs/tasks/PROGRESS-reply-quality-20260930.md create mode 100644 frontend/src/app/admin/reply-quality/page.tsx create mode 100644 frontend/src/app/api/admin/reply-quality/[id]/route.ts create mode 100644 frontend/src/app/api/admin/reply-quality/route.ts create mode 100644 frontend/src/app/api/admin/reply-quality/stats/route.ts create mode 100644 frontend/src/app/api/reply-ratings/reason/route.ts create mode 100644 frontend/src/components/admin/reply-quality-resource.tsx create mode 100644 frontend/src/components/reply-down-reason.tsx create mode 100644 frontend/src/lib/reply-quality-capture.ts create mode 100644 frontend/src/lib/reply-quality-labels.ts create mode 100644 frontend/src/lib/reply-quality-retention-core.ts create mode 100644 frontend/src/lib/reply-quality-retention-worker.ts create mode 100644 frontend/src/lib/reply-quality-snapshot.ts create mode 100644 frontend/supabase/migrations/20260930050000_reply_quality_snapshots.sql create mode 100644 frontend/tests/reply-quality-20260930.test.tsx diff --git a/BLOCKED.md b/BLOCKED.md index ee39a666..ee4ab4dc 100644 --- a/BLOCKED.md +++ b/BLOCKED.md @@ -11,6 +11,13 @@ - 产物隐私补扫未通过:tracked 守卫 63 passed 不覆盖本轮未跟踪文件。显式复用隐私规则扫描新增产物时,M0 baseline JSON 的时刻数组 R003 命中 3 次;旧 baseline quick 日志混合编码无法以 UTF-8 或 GB18030 严格解码。未打印标记原值、未改既有隐私例外、未删除命中数据;需独立核查精确字段碰撞及完成日志保真扫描后,才可考虑纳入提交。证据 `artifacts/varga-resolution/closure-explicit-privacy.log`。 - M0 两次字节复跑未完成、M2/M3 not_started 是研究欠项,**不是上述环境失败的推断后果**。不得据此关单或宣称已交付。 +## 对话质量记录(2026-09-30,PROGRESS-reply-quality-20260930):真实 PostgreSQL 与真机待验 + +- 缺什么:本机无 Docker,`npm run test:db` 跑不了;迁移 `20260930050000_reply_quality_snapshots.sql`(👎 才能存快照、👍/取消删快照、90 天清空正文、后台查看与改状态写审计、按天/按模型统计)只有源码合同测试。无受控登录账号与实体手机,点 👎 后的原因面板与后台页面未在真实环境点过。 +- 替代证据:`frontend/tests/reply-quality-20260930.test.tsx`(快照只取服务器存的那条回答且哈希一致、上下文窗口与摘要截点、原因规范化、面板选填提交、SQL 与 RBAC 合同、保留期任务);部署到 staging 时迁移会被真实执行一次。 +- 解除条件:staging 迁移成功后,用受控账号对一条咨询回答点 👎 并选原因,在后台「对话质量记录」看到这一条、点「查看」能看到问答与上下文并在审计日志出现 `reply_quality.open`;改成 👍 后该记录消失;统计卡的 👍 / 👎 数与操作一致。 +- 校正会话里的 👍 / 👎 目前只在页面状态里,没有保存,因此不进对话质量记录(本轮不改)。 + ## 合规 A:用户协议 / 隐私政策 / 登录同意(2026-09-30) - **律师审定**:`frontend/src/lib/legal-documents.ts` 是依据代码写的初稿,页面顶部标「草稿 · 待律师审定」。其中 【待确认】 事实点共 13 条(`pendingLegalConfirmations()` 可列出),定稿前逐条确认;定稿后改 `legal-entity.ts` 的 `legalDocumentsVersion` 与 `legalDocumentsDraft: false`,老用户会在下次登录后看到一次「协议已更新」确认。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c88e333..8d801f85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # 印度占星 Skill 更新日志 +## 2026-09-30 — 点踩会把这一轮发给我们排查,后台新增「对话质量记录」(待验收) + +- 回答下面点 👎 后,这条回答下方出现一块小面板「哪里不满意?可以不选」:答非所问 / 内容不准 / 太长太空 / 语气不对 / 其他,可再补一句(最多 200 字)。全部选填,面板上写明「会把这一轮对话发给我们排查」。 +- 点 👎 的那一刻,服务器保存这一轮的提问与回答、模型当时读到的前文(按会话重建)、所用模型与耗时。点 👍 只计数,不保存任何文字;把 👎 改成 👍 或取消,保存的这一轮随之删除。 +- 保存 90 天,期满清空对话内容,只留日期、模型、原因和处理状态做统计;删除这段对话或注销账号时一并删除。隐私政策草稿已补一段说明。 +- 管理后台新增「对话质量记录」:顶部按天、按模型看 👍 / 👎 数和 👎 占比;列表按处理状态(待处理 / 处理中 / 已解决 / 忽略)、原因或近 7 / 30 天筛选,列表里不显示对话内容;点「查看」才加载这一轮,并记入审计日志;可改处理状态、写内部备注。 +- 新增数据库表 `reply_quality_snapshots`,`set_reply_rating()` 同签名替换(只加不改)。Skill 版本不 bump。 + ## 2026-09-30 — 用户协议、隐私政策与登录同意(草稿,待律师审定) - 新增「用户协议」`/terms` 与「隐私政策」`/privacy` 两个页面,未登录也能打开;页面顶部标明「草稿,待律师审定」。内容按产品实际情况写:收集哪些信息(含出生资料与档案里的他人资料)、交给哪些服务商(模型、邮件、地点搜索等,部分在境外)、保存多久、如何注销(7 天冷静期)、内容安全、未成年人、AI 生成内容说明等。 diff --git a/CONTEXT.md b/CONTEXT.md index 6103784c..152fa34c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -29,7 +29,7 @@ _避免使用_:正常回复、可评价回复 _避免使用_:原始异常、完整日志 **故障上下文快照**: -为排查 Agent 执行故障而保留的故障轮次及该次执行实际使用的近期上下文,不等同于完整会话副本。 +为排查 Agent 执行故障而保留的故障轮次及该次执行实际使用的近期上下文,不等同于完整会话副本。负向回复评价也保留一份:该轮提问与回答、按会话重建的模型上下文窗口与运行事实,90 天后清空正文。 _避免使用_:完整聊天记录、错误消息 **回复评价**: diff --git a/docs/tasks/PROGRESS-reply-quality-20260930.md b/docs/tasks/PROGRESS-reply-quality-20260930.md new file mode 100644 index 00000000..452e8117 --- /dev/null +++ b/docs/tasks/PROGRESS-reply-quality-20260930.md @@ -0,0 +1,42 @@ +# PROGRESS · 点踩收集 + 对话质量记录 · 2026-09-30 + +> 产品 2026-09-30「点赞点踩后台需要收集,点踩要收集这轮对话以便排查」,选定:快照 = 这一轮 + 模型当时看到的上下文;保留 90 天。执行:Claude fork 子代理 E。分支 `codex/reply-quality-20260930`,基于 `dbac4196`(合规轮 A–D 之后)。术语按 `CONTEXT.md`:回复评价、不满意原因、对话质量记录、故障上下文快照、处理状态。 + +## 做了什么 + +| 项 | 复用 | 新增 | +|---|---|---| +| 👍 / 👎 保存 | C 轮的 `reply_ratings`、`/api/reply-ratings`、`persistReplyRating` | `set_reply_rating()` 同签名替换:改成 👍 或取消时删掉快照 | +| 故障上下文快照 | `consultationHistoryWindow` / `parseSessionContextSummary`(咨询路由给模型的同一窗口)、`resolveSessionLanguageModel().contextWindow`、`usage_ledger` | `lib/reply-quality-snapshot.ts`(纯函数)、`lib/reply-quality-capture.ts`(服务端读会话 → 建快照 → `save_reply_quality_snapshot()`);表 `reply_quality_snapshots` | +| 不满意原因 | `ChatMessageActions` 行 | `components/reply-down-reason.tsx`(`MessageEntry` 自持状态,Home 不变)、`PUT /api/reply-ratings/reason`、`set_reply_quality_reason()` | +| 保留期 | 注销 worker 的 unref 定时器模式 | `reply-quality-retention-worker.ts`:每 6 小时调 `expire_reply_quality_snapshot_bodies(90)`,日志只写条数 | +| 后台 | `ResourceTable`、RBAC、`audit.admin_audit_logs`、C 轮反馈页结构 | 权限 `support.quality.read/write`(owner / operations / support 读写,auditor 只读);`/admin/reply-quality`、`/api/admin/reply-quality`(列表不带正文)、`/[id]`(查看写审计 `reply_quality.open`;改状态写 `reply_quality.update`)、`/stats`(按天 / 按模型 👍👎) | +| 隐私政策草稿 | `legal-documents.ts` | 收集、用途、保存期限三处各一句 | + +## 决定 + +- **上下文是重建的,不是生成时存的。** 👎 时服务器按会话里存的消息,用咨询路由同一个函数重建那一轮模型读到的窗口:只用该轮之前写好的会话摘要(`throughMessageIndex < 提问位置`),预算按会话当前模型的上下文窗口。偏差:该轮之后换过模型时预算可能不同;星盘数据块与系统提示不在快照里(运行事实里有 requestId、模型、token、耗时,可对 `usage_ledger` 查)。不在生成时存,是因为同日 D 轮正在改咨询路由,另存一份会改动热路径。 +- 客户端只发位置与哈希,不发文本;服务器只在「该位置是助手回答且哈希一致」时存,重新生成过的回答不存。 +- 校正会话的上下文另有组装方式,快照只取提问前最近 6 条并标 `rectification_recent_turns`。校正页的 👍 / 👎 本身没持久化,这轮不接。 +- 库里没有「Agent 执行故障」记录(咨询请求页只有请求生命周期),所以对话质量记录目前只有 👎 一种来源。 +- 原因提交会先等本条评价保存完成,避免快照还没建就写原因(`reply_quality_not_found`)。 + +## 改动的既有断言 + +| 文件 | 原值 | 新值 | 原因 | +|---|---|---|---| +| `tests/feedback-complaints-20260930.test.tsx` | `persistReplyRating(feedbackKey, toggleChatMessageFeedback(messageFeedback[feedbackKey], requested), message.text)` 一行 | `const next = toggleChatMessageFeedback(...)` 后 `persistReplyRating(feedbackKey, next, message.text)` | 同一个切换结果还要用来打开原因面板;保存的值不变 | + +## 验证(Linux,Node 22;基线 = `dbac4196` 全量) + +| 项 | 结果 | +|---|---| +| `tsc --noEmit` | 0 错 | +| `npm run lint` | 0 error,126 warning(同基线) | +| `npm test` 全量 | 基线 4,447 / fail 24 → 本轮 4,460 / fail 24,cancelled 0;失败名单与基线逐条相同(无 Docker 的数据库 / 部署套件);新增 13 条,消失 0 条 | +| 新测试 `reply-quality-20260930.test.tsx` | 13 / 13 | +| `home-shell-growth-contract` | 通过(Home 的 useState / useRef 数不变) | +| `next build` | exit 0;`/` 仍 `○ Static`;新增 `/admin/reply-quality` 与三个后台接口 | +| 首屏 gzip-9 | 651,976 B → 652,921 B(+0.14%,原因面板组件进了对话转录) | +| 隐私标记 `tests/test_repo_privacy_markers.py` | 通过 | +| 真实数据库 / 真机 | 未验证,见 `BLOCKED.md`「对话质量记录」 | diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index af19f669..72a50390 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -721,6 +721,19 @@ page. Three parts now, in reading order: The 👍 / 👎 under an answer (回复评价) is saved per conversation and message position with a 16-hex hash of the answer text, and restored when that conversation is opened again — but only onto the same text, so a regenerated answer starts unrated. Saving is fire-and-forget: a failed save never disturbs the conversation. +### 不满意原因 after 👎 (2026-09-30) + +- **When:** only right after a 👎 in the current view (`MessageEntry` owns `reasonOpen`; no Home state). A 👎 restored from the database does not reopen it; switching to 👍 or clearing closes it. +- **Where:** directly under that answer's action row, above the follow-up questions. Not a modal, not a toast. +- **Shape:** one panel on the canvas (`--radius-md`, hairline `--color-border`, max 520px, caption type in secondary ink): a head row (「哪里不满意?可以不选」 + a 32px × close), five pill chips (`aria-pressed`; current = muted surface, primary ink, darker border — no accent colour), a single-line note input (200 characters), and a foot row with the disclosure 「会把这一轮对话发给我们排查」 and a small 提交 button on the muted surface. Chips wrap on phones. +- **After submit:** the panel collapses to one status line 「已收到,谢谢。」 and closes after 1.2s. Nothing is required; closing keeps the 👎 and its snapshot. + +### Admin 对话质量记录 (2026-09-30) + +- Sidebar entry after 反馈与投诉 (`DislikeOutlined`). Top card 「回复评价统计」: 近 7 / 14 / 30 / 90 天 segment, 👍 / 👎 / 👎 占比 totals, then by-day and by-model tables side by side (stacked below `lg`). +- The list below is the shared `ResourceTable`; its one filter slot carries a 处理状态, `原因:…`, or 近 7 / 30 天. The list shows no conversation text — 快照 reads 有 / 已过期. +- 查看 opens an 880px modal that loads the snapshot on open (audited; the modal says so): 用户问题, 被点踩的回复, the reconstructed context (summary, dropped count, turns), run facts, then 处理状态 + 内部备注. Expired bodies show an info alert instead. + ### Settings dialog - **Placement:** the overlay and the onboarding paywall portal to `document.body`. `SidebarInset` stays `inert` while a dialog is open (the BUG-744~746 focus contract); the dialog itself must not sit inside that subtree (BUG-968). Closing still uses the existing overlay click, the header button, and the window-level Escape listener. diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 1eaf3542..46cf83fd 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -112,6 +112,12 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 这四句只在活动行里,是「流式生成中」的一部分:不进正文、不进历史、刷新后不出现;只往前走,不回头;不写秒数、不写「请稍候」,不承诺多久。 +## 不满意原因(2026-09-30) + +- 点 👎 后在该回答下方出现一块小面板,全部选填:标题「哪里不满意?可以不选」;原因「答非所问 / 内容不准 / 太长太空 / 语气不对 / 其他」;输入框占位「补充一句(可不填)」(最多 200 字);底部一句「会把这一轮对话发给我们排查」,按钮「提交」,失败后「再试一次」。 +- 成功只说「已收到,谢谢。」,一秒多后自己收起。不道歉、不承诺改进时间、不解释模型。 +- 这句告知不能删:👎 那一刻服务器已经保存了这一轮,面板只是补充原因。 + ## 反馈与投诉(2026-09-30) - 菜单与弹窗标题「反馈与投诉」;类型「问题反馈 / 内容举报 / 退款与扣点 / 其他」;输入框标签「发生了什么」「联系方式(选填)」;勾选「附上当前对话(只附编号,便于我们查看)」;按钮「提交」,发送中「正在提交…」。 diff --git a/frontend/src/app/admin/reply-quality/page.tsx b/frontend/src/app/admin/reply-quality/page.tsx new file mode 100644 index 00000000..641aa406 --- /dev/null +++ b/frontend/src/app/admin/reply-quality/page.tsx @@ -0,0 +1,5 @@ +import { ReplyQualityResource } from "@/components/admin/reply-quality-resource"; + +export default function Page() { + return ; +} diff --git a/frontend/src/app/api/admin/reply-quality/[id]/route.ts b/frontend/src/app/api/admin/reply-quality/[id]/route.ts new file mode 100644 index 00000000..ed861d1b --- /dev/null +++ b/frontend/src/app/api/admin/reply-quality/[id]/route.ts @@ -0,0 +1,79 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { requirePermission } from "@/lib/admin/auth"; +import { queryAdminRows } from "@/lib/admin/database"; +import { adminErrorResponse, invalidQueryResponse, requestId, requireAdminMutation } from "@/lib/admin/http"; +import { REPLY_QUALITY_STATUSES } from "@/lib/reply-quality-labels"; + +export const runtime = "nodejs"; + +/** + * One 对话质量记录 (compliance round 2026-09-30). GET returns the 故障上下文快照 + * (question, answer, context turns, run facts) and writes an audit row for the + * open in the same database call; the body may hold birth data and is shown + * only here. POST sets the 处理状态 and an internal note, also audited. + */ +const idSchema = z.string().uuid(); +const updateSchema = z.object({ + status: z.enum(REPLY_QUALITY_STATUSES), + adminNote: z.string().trim().max(2000).optional(), +}); + +type RouteContext = { params: Promise<{ id: string }> }; + +export async function GET(request: Request, context: RouteContext) { + try { + const session = await requirePermission("support.quality.read"); + const { id } = await context.params; + if (!idSchema.safeParse(id).success) return invalidQueryResponse({ id: ["invalid"] }); + const rows = await queryAdminRows<{ + id: string; + question: string | null; + answer: string | null; + context: unknown; + run_facts: unknown; + body_expired_at: Date | null; + }>("select * from public.admin_open_reply_quality($1::uuid,$2::uuid,$3::text)", [session.user.id, id, requestId(request)]); + const row = rows[0]; + if (!row) return NextResponse.json({ error: "记录不存在" }, { status: 404 }); + return NextResponse.json({ + data: { + id: row.id, + question: row.question, + answer: row.answer, + context: row.context, + runFacts: row.run_facts, + bodyExpiredAt: row.body_expired_at ? row.body_expired_at.toISOString() : null, + }, + }); + } catch (error) { + if (error instanceof Error && error.message.includes("reply_quality_not_found")) { + return NextResponse.json({ error: "记录不存在" }, { status: 404 }); + } + return adminErrorResponse(error); + } +} + +export async function POST(request: Request, context: RouteContext) { + try { + const session = await requireAdminMutation(request, "support.quality.write"); + const { id } = await context.params; + if (!idSchema.safeParse(id).success) return invalidQueryResponse({ id: ["invalid"] }); + const parsed = updateSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) return invalidQueryResponse(parsed.error.flatten()); + const rows = await queryAdminRows<{ id: string; status: string; admin_note: string | null; updated_at: Date }>( + "select * from public.admin_update_reply_quality($1::uuid,$2::uuid,$3::text,$4::text,$5::text)", + [session.user.id, id, parsed.data.status, parsed.data.adminNote ?? null, requestId(request)], + ); + const row = rows[0]; + return NextResponse.json({ + data: row ? { id: row.id, status: row.status, adminNote: row.admin_note, updatedAt: row.updated_at.toISOString() } : null, + }); + } catch (error) { + if (error instanceof Error && error.message.includes("reply_quality_not_found")) { + return NextResponse.json({ error: "记录不存在" }, { status: 404 }); + } + return adminErrorResponse(error); + } +} diff --git a/frontend/src/app/api/admin/reply-quality/route.ts b/frontend/src/app/api/admin/reply-quality/route.ts new file mode 100644 index 00000000..deb5be09 --- /dev/null +++ b/frontend/src/app/api/admin/reply-quality/route.ts @@ -0,0 +1,82 @@ +import { NextResponse } from "next/server"; + +import { requirePermission } from "@/lib/admin/auth"; +import { pageOffset, queryAdminRows } from "@/lib/admin/database"; +import { adminErrorResponse, invalidQueryResponse, parseListQuery, readonlyAdminMutation } from "@/lib/admin/http"; +import { parseReplyQualityFilter } from "@/lib/reply-quality-labels"; + +export const runtime = "nodejs"; + +/** + * 对话质量记录 list (compliance round 2026-09-30): 👎 records without their + * body. The snapshot itself is only returned by /api/admin/reply-quality/[id], + * which audits every open. The `status` filter slot carries a status, + * `reason:`, `model:` or `days:`. + */ +type Row = { + id: string; + user_id: string; + email: string | null; + session_id: string; + message_index: number; + session_type: string; + model_id: string | null; + reasons: string[]; + reason_note: string | null; + has_body: boolean; + status: string; + admin_note: string | null; + created_at: Date; + updated_at: Date; + total_count: string; +}; + +export const POST = readonlyAdminMutation; +export const PUT = readonlyAdminMutation; +export const PATCH = readonlyAdminMutation; +export const DELETE = readonlyAdminMutation; + +export async function GET(request: Request) { + try { + const session = await requirePermission("support.quality.read"); + const parsed = parseListQuery(request); + if (!parsed.success) return invalidQueryResponse(parsed.error.flatten()); + const filter = parseReplyQualityFilter(parsed.data.status); + const from = filter.days ? new Date(Date.now() - filter.days * 86_400_000).toISOString() : null; + const rows = await queryAdminRows( + "select * from public.admin_list_reply_quality($1::uuid,$2::text,$3::text,$4::text,$5::timestamptz,$6::timestamptz,$7::text,$8::integer,$9::integer)", + [ + session.user.id, + filter.status, + filter.reason, + filter.modelId, + from, + null, + parsed.data.q ? `%${parsed.data.q}%` : null, + parsed.data.pageSize, + pageOffset(parsed.data.page, parsed.data.pageSize), + ], + ); + return NextResponse.json({ + data: rows.map((row) => ({ + id: row.id, + userId: row.user_id, + email: row.email, + sessionId: row.session_id, + messageIndex: row.message_index, + sessionType: row.session_type, + modelId: row.model_id, + reasons: row.reasons, + reasonNote: row.reason_note, + hasBody: row.has_body, + status: row.status, + adminNote: row.admin_note, + createdAt: row.created_at.toISOString(), + updatedAt: row.updated_at.toISOString(), + })), + total: Number(rows[0]?.total_count ?? 0), + }); + } catch (error) { + return adminErrorResponse(error); + } +} diff --git a/frontend/src/app/api/admin/reply-quality/stats/route.ts b/frontend/src/app/api/admin/reply-quality/stats/route.ts new file mode 100644 index 00000000..1a638127 --- /dev/null +++ b/frontend/src/app/api/admin/reply-quality/stats/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from "next/server"; + +import { requirePermission } from "@/lib/admin/auth"; +import { queryAdminRows } from "@/lib/admin/database"; +import { adminErrorResponse, readonlyAdminMutation } from "@/lib/admin/http"; + +export const runtime = "nodejs"; + +/** 👍 / 👎 counts by day and by the session's model, last `days` days (default 14, max 90). */ +export const POST = readonlyAdminMutation; + +export async function GET(request: Request) { + try { + const session = await requirePermission("support.quality.read"); + const raw = Number(new URL(request.url).searchParams.get("days") ?? 14); + const days = Number.isInteger(raw) && raw >= 1 && raw <= 90 ? raw : 14; + const rows = await queryAdminRows<{ bucket_kind: string; bucket: string; up_count: string; down_count: string }>( + "select * from public.admin_reply_quality_stats($1::uuid,$2::integer)", + [session.user.id, days], + ); + const shape = (kind: string) => rows.filter((row) => row.bucket_kind === kind).map((row) => ({ + bucket: row.bucket, + up: Number(row.up_count), + down: Number(row.down_count), + })); + return NextResponse.json({ data: { days, byDay: shape("day"), byModel: shape("model") } }); + } catch (error) { + return adminErrorResponse(error); + } +} diff --git a/frontend/src/app/api/reply-ratings/reason/route.ts b/frontend/src/app/api/reply-ratings/reason/route.ts new file mode 100644 index 00000000..d9abf0d3 --- /dev/null +++ b/frontend/src/app/api/reply-ratings/reason/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable"; +import { normalizeReplyQualityReasons, REPLY_QUALITY_NOTE_MAX } from "@/lib/reply-quality-snapshot"; +import { createAdminSupabaseClient } from "@/lib/supabase/admin"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; + +export const runtime = "nodejs"; + +/** + * 不满意原因 for a 👎 (compliance round 2026-09-30). Optional: the rating and + * its snapshot are already saved by PUT /api/reply-ratings; this only adds the + * picked reasons and a short note to that snapshot. + */ +const bodySchema = z.object({ + sessionId: z.string().uuid(), + messageIndex: z.number().int().min(0).max(100_000), + reasons: z.array(z.string()).max(5), + note: z.string().max(REPLY_QUALITY_NOTE_MAX).optional(), +}); + +export async function PUT(request: Request) { + let supabase: Awaited>; + let service: ReturnType; + try { + supabase = await createServerSupabaseClient(); + service = createAdminSupabaseClient(); + } catch (error) { + return jsonForSupabaseSetupFailure(error, "PUT /api/reply-ratings/reason"); + } + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); + const parsed = bodySchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) return NextResponse.json({ error: "原因格式不正确" }, { status: 400 }); + const { reasons, note } = normalizeReplyQualityReasons(parsed.data.reasons, parsed.data.note); + const { error } = await service.rpc("set_reply_quality_reason", { + p_user_id: user.id, + p_session_id: parsed.data.sessionId, + p_message_index: parsed.data.messageIndex, + p_reasons: reasons, + p_note: note, + }); + if (error) { + const missing = error.message?.includes("reply_quality_not_found"); + return NextResponse.json({ error: missing ? "这条回答的排查记录不存在" : "暂时没能保存原因" }, { status: missing ? 404 : 503 }); + } + return NextResponse.json({ saved: true }); +} diff --git a/frontend/src/app/api/reply-ratings/route.ts b/frontend/src/app/api/reply-ratings/route.ts index 8a112623..26d640bc 100644 --- a/frontend/src/app/api/reply-ratings/route.ts +++ b/frontend/src/app/api/reply-ratings/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable"; +import { captureReplyQualitySnapshot, type ReplyQualityService } from "@/lib/reply-quality-capture"; import { createAdminSupabaseClient } from "@/lib/supabase/admin"; import { createServerSupabaseClient } from "@/lib/supabase/server"; @@ -11,7 +12,9 @@ export const runtime = "nodejs"; * 回复评价 (CONTEXT.md): the 👍 / 👎 on one assistant reply, persisted per * session + message position (compliance round 2026-09-30). Reads and writes * go through list_reply_ratings() / set_reply_rating(), which check that the - * session belongs to the caller. + * session belongs to the caller. A 👎 also keeps a 故障上下文快照 of that turn, + * built here from the stored session (reply-quality-capture); 👍 or clearing + * drops it inside set_reply_rating(). */ const putSchema = z.object({ sessionId: z.string().uuid(), @@ -73,5 +76,13 @@ export async function PUT(request: Request) { const notOwned = error.message?.includes("rating_session_not_owned"); return NextResponse.json({ error: notOwned ? "会话不属于当前账号" : "暂时没能保存评价" }, { status: notOwned ? 403 : 503 }); } - return NextResponse.json({ saved: true }); + if (parsed.data.rating !== "down") return NextResponse.json({ saved: true }); + // The rating is saved either way; a snapshot that cannot be built is skipped, never an error. + const snapshot = await captureReplyQualitySnapshot(context.service as unknown as ReplyQualityService, { + userId: user.id, + sessionId: parsed.data.sessionId, + messageIndex: parsed.data.messageIndex, + answerSha256: parsed.data.answerSha256, + }).catch(() => "failed" as const); + return NextResponse.json({ saved: true, snapshot }); } diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 4553780f..94142356 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -1459,6 +1459,31 @@ button:disabled:where(:not([data-slot="button"])) { cursor: default; opacity: .4 .rectification-message-entry.rectification-message-flash { animation: rectification-message-flash 600ms ease; } +/* 不满意原因 after a 👎 (2026-09-30): a quiet inline card under the actions, + everything optional; one line says the turn goes to review. */ +.reply-down-reason { + display: grid; + gap: var(--space-2); + max-width: 520px; + margin: var(--space-2) 0 var(--space-3); + padding: var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-canvas); + color: var(--color-ink-secondary); + font-size: var(--type-caption); +} +.reply-down-reason.is-sent { display: block; border: 0; padding: 0; background: transparent; } +.reply-down-reason-head, .reply-down-reason-foot { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); } +.reply-down-reason-close { display: grid; place-items: center; min-width: 32px; min-height: 32px; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--color-ink-tertiary); cursor: pointer; } +.reply-down-reason-close svg { width: 16px; height: 16px; } +.reply-down-reason-chips { display: flex; flex-wrap: wrap; gap: var(--space-2); } +.reply-down-reason-chips button { min-height: 32px; padding: 0 var(--space-3); border: 1px solid var(--color-border); border-radius: 999px; background: transparent; color: var(--color-ink-secondary); font: inherit; cursor: pointer; } +.reply-down-reason-chips button[aria-pressed="true"] { border-color: var(--color-ink-secondary); background: var(--color-canvas-muted); color: var(--color-ink); } +.reply-down-reason-note { min-height: 36px; padding: 0 var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius-sm); background: transparent; color: var(--color-ink); font: inherit; } +.reply-down-reason-send { min-height: 32px; padding: 0 var(--space-4); border: 0; border-radius: var(--radius-sm); background: var(--color-canvas-muted); color: var(--color-ink); font: inherit; font-weight: 600; cursor: pointer; } +.reply-down-reason-send:disabled { opacity: .6; cursor: default; } +.reply-down-reason button:focus-visible, .reply-down-reason-note:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; } .message-actions { display: flex; align-items: center; diff --git a/frontend/src/components/admin/admin-app.tsx b/frontend/src/components/admin/admin-app.tsx index 1e2d6b6b..9d35d57b 100644 --- a/frontend/src/components/admin/admin-app.tsx +++ b/frontend/src/components/admin/admin-app.tsx @@ -7,6 +7,7 @@ import { ControlOutlined, CreditCardOutlined, DatabaseOutlined, + DislikeOutlined, ExperimentOutlined, GiftOutlined, MessageOutlined, @@ -114,6 +115,7 @@ export function AdminApp({ children }: { children: ReactNode }) { { name: "usage", list: "/admin/usage", meta: { label: "用量与成本", icon: } }, { name: "rectification-telemetry", list: "/admin/rectification-telemetry", meta: { label: "校正统计", icon: } }, { name: "feedback", list: "/admin/feedback", meta: { label: "反馈与投诉", icon: } }, + { name: "reply-quality", list: "/admin/reply-quality", meta: { label: "对话质量记录", icon: } }, { name: "models", list: "/admin/models", meta: { label: "模型配置", icon: } }, { name: "feature-pricing", list: "/admin/feature-pricing", meta: { label: "功能定价", icon: } }, { name: "pricing-simulator", list: "/admin/pricing-simulator", meta: { label: "定价测算", icon: } }, diff --git a/frontend/src/components/admin/reply-quality-resource.tsx b/frontend/src/components/admin/reply-quality-resource.tsx new file mode 100644 index 00000000..d9df64c0 --- /dev/null +++ b/frontend/src/components/admin/reply-quality-resource.tsx @@ -0,0 +1,246 @@ +"use client"; + +import { useGetIdentity, useInvalidate } from "@refinedev/core"; +import { Alert, App, Button, Card, Col, Input, Modal, Row, Segmented, Select, Space, Spin, Statistic, Table, Tag, Typography, type TableColumnsType } from "antd"; +import { useEffect, useState } from "react"; + +import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers"; +import { + REPLY_QUALITY_REASON_LABELS, + REPLY_QUALITY_REASONS, + REPLY_QUALITY_RETENTION_DAYS, + REPLY_QUALITY_STATUS_LABELS, + REPLY_QUALITY_STATUSES, + type ReplyQualityReason, + type ReplyQualityStatus, +} from "@/lib/reply-quality-labels"; +import { formatAdminDate, ResourceTable } from "./resource-table"; + +const { Text, Paragraph } = Typography; + +type Record_ = { + id: string; + userId: string; + email: string | null; + sessionId: string; + messageIndex: number; + sessionType: string; + modelId: string | null; + reasons: ReplyQualityReason[]; + reasonNote: string | null; + hasBody: boolean; + status: ReplyQualityStatus; + adminNote: string | null; + createdAt: string; + updatedAt: string; +}; + +type Turn = { role: "user" | "assistant"; text: string }; +type Detail = { + id: string; + question: string | null; + answer: string | null; + context: { kind?: string; summaryText?: string | null; droppedCount?: number; turns?: Turn[] } | null; + runFacts: Record | null; + bodyExpiredAt: string | null; +}; + +type StatRow = { bucket: string; up: number; down: number }; +type Stats = { days: number; byDay: StatRow[]; byModel: StatRow[] }; + +const STATUS_COLOR: Record = { new: "gold", in_progress: "blue", resolved: "green", ignored: "default" }; +const SESSION_TYPE_LABEL: Record = { consultation: "咨询", birth_time_rectification: "校正" }; +const STAT_DAY_OPTIONS = [7, 14, 30, 90]; + +function downShare(row: StatRow) { + const total = row.up + row.down; + return total ? `${Math.round((row.down / total) * 100)}%` : "—"; +} + +function StatsPanel() { + const [days, setDays] = useState(14); + const [state, setState] = useState<{ days: number; stats?: Stats; error?: string } | null>(null); + + useEffect(() => { + let active = true; + adminRequestJson<{ data: Stats }>(`/api/admin/reply-quality/stats?days=${days}`) + .then((payload) => { if (active) setState({ days, stats: payload.data }); }) + .catch((error: unknown) => { if (active) setState({ days, error: error instanceof Error ? error.message : "读取失败" }); }); + return () => { active = false; }; + }, [days]); + + const ready = state?.days === days ? state : null; + const totals = (ready?.stats?.byDay ?? []).reduce((sum, row) => ({ up: sum.up + row.up, down: sum.down + row.down }), { up: 0, down: 0 }); + const columns = (keyTitle: string): TableColumnsType => [ + { title: keyTitle, dataIndex: "bucket" }, + { title: "👍", dataIndex: "up", width: 64 }, + { title: "👎", dataIndex: "down", width: 64 }, + { title: "👎 占比", width: 80, render: (_, row) => downShare(row) }, + ]; + + return ( + value={days} onChange={setDays} options={STAT_DAY_OPTIONS.map((value) => ({ value, label: `近 ${value} 天` }))} />}> + {!ready ? : ready.error ? : ( + + + + + + + + + size="small" rowKey="bucket" pagination={{ pageSize: 7 }} dataSource={ready.stats?.byDay ?? []} columns={columns("日期")} /> + + + size="small" rowKey="bucket" pagination={false} dataSource={ready.stats?.byModel ?? []} columns={columns("模型")} /> + + + + )} + + ); +} + +function TurnBlock({ label, text }: { label: string; text: string | null }) { + return ( +
+ {label} + {text || "—"} +
+ ); +} + +/** + * 对话质量记录 (compliance round 2026-09-30): every 👎 with its 不满意原因, + * plus 👍 / 👎 counts by day and model. The list carries no conversation text; + * opening one record loads its 故障上下文快照 and is written to the audit log. + * Bodies are blanked after the retention window; the row and counts stay. + */ +export function ReplyQualityResource() { + const { message } = App.useApp(); + const { data: identity } = useGetIdentity(); + const invalidate = useInvalidate(); + const canHandle = Boolean(identity?.permissions.includes("support.quality.write")); + const [opened, setOpened] = useState(null); + const [detail, setDetail] = useState(null); + const [detailError, setDetailError] = useState(null); + const [status, setStatus] = useState("in_progress"); + const [note, setNote] = useState(""); + const [saving, setSaving] = useState(false); + + function open(item: Record_) { + setOpened(item); + setDetail(null); + setDetailError(null); + setStatus(item.status === "new" ? "in_progress" : item.status); + setNote(item.adminNote ?? ""); + adminRequestJson<{ data: Detail }>(`/api/admin/reply-quality/${item.id}`, { headers: { "x-request-id": crypto.randomUUID() } }) + .then((payload) => setDetail(payload.data)) + .catch((error: unknown) => setDetailError(error instanceof Error ? error.message : "读取失败")); + } + + async function save() { + if (!opened) return; + setSaving(true); + try { + await adminRequestJson(`/api/admin/reply-quality/${opened.id}`, { + method: "POST", + headers: { "x-request-id": crypto.randomUUID() }, + body: JSON.stringify({ status, adminNote: note }), + }); + await invalidate({ resource: "reply-quality", invalidates: ["list"] }); + message.success("已更新处理状态"); + setOpened(null); + } catch (error) { + message.error(error instanceof Error ? error.message : "更新失败"); + } finally { + setSaving(false); + } + } + + const columns: TableColumnsType = [ + { title: "时间", dataIndex: "createdAt", render: formatAdminDate }, + { title: "场景", dataIndex: "sessionType", render: (value: string) => SESSION_TYPE_LABEL[value] ?? value }, + { title: "模型", dataIndex: "modelId", render: (value: string | null) => value ?? "—" }, + { + title: "不满意原因", + render: (_, item) => ( + + {item.reasons.length ? item.reasons.map((reason) => {REPLY_QUALITY_REASON_LABELS[reason] ?? reason}) : 未选} + {item.reasonNote ? 「{item.reasonNote}」 : null} + + ), + }, + { title: "用户", render: (_, item) => item.email ?? item.userId }, + { title: "快照", render: (_, item) => (item.hasBody ? "有" : 已过期) }, + { title: "处理状态", render: (_, item) => {REPLY_QUALITY_STATUS_LABELS[item.status]} }, + { title: "查看", fixed: "right", render: (_, item) => }, + ]; + + const turns = detail?.context?.turns ?? []; + return ( + + + + resource="reply-quality" + title="对话质量记录" + columns={columns} + statusOptions={[ + ...REPLY_QUALITY_STATUSES.map((value) => ({ value, label: REPLY_QUALITY_STATUS_LABELS[value] })), + ...REPLY_QUALITY_REASONS.map((value) => ({ value: `reason:${value}`, label: `原因:${REPLY_QUALITY_REASON_LABELS[value]}` })), + { value: "days:7", label: "近 7 天" }, + { value: "days:30", label: "近 30 天" }, + ]} + /> + setOpened(null)} + onOk={() => void save()} + okText="保存" + okButtonProps={{ disabled: !canHandle }} + confirmLoading={saving} + > + {opened ? ( + + + {SESSION_TYPE_LABEL[opened.sessionType] ?? opened.sessionType} · {opened.modelId ?? "—"} · {formatAdminDate(opened.createdAt)} · 会话 {opened.sessionId} 第 {opened.messageIndex} 条 + + 查看快照会写入审计日志。 + {detailError ? : !detail ? : detail.bodyExpiredAt ? ( + + ) : ( + <> + + + + + {detail.context?.summaryText ? : null} + {detail.context?.droppedCount ? 更早的 {detail.context.droppedCount} 条未进入窗口 : null} + {turns.length ? turns.map((turn, index) => ( + + )) : 这是会话第一轮,没有更早的对话。} + + + {detail.runFacts ? ( + +
{JSON.stringify(detail.runFacts, null, 2)}
+
+ ) : null} + + )} + + value={status} + onChange={setStatus} + disabled={!canHandle} + options={REPLY_QUALITY_STATUSES.map((value) => ({ value, label: REPLY_QUALITY_STATUS_LABELS[value] }))} + style={{ width: 200 }} + /> + setNote(event.target.value)} disabled={!canHandle} maxLength={2000} rows={3} placeholder="内部备注(用户看不到)" /> +
+ ) : null} +
+
+ ); +} diff --git a/frontend/src/components/chat-transcript.tsx b/frontend/src/components/chat-transcript.tsx index ab163cc5..329311c1 100644 --- a/frontend/src/components/chat-transcript.tsx +++ b/frontend/src/components/chat-transcript.tsx @@ -8,6 +8,7 @@ import { } from "@/components/chat-message-actions"; import { ConversationFollowUps } from "@/components/conversation-follow-ups"; import { persistReplyRating } from "@/lib/reply-ratings"; +import { ReplyDownReason } from "@/components/reply-down-reason"; import { isGeneralDailyFortuneQuestion } from "@/lib/consultation-entrypoint"; import { deriveConsultationFollowUps } from "@/lib/consultation-follow-ups"; import type { ConsultationDomain } from "@/lib/consultation-domain-registry"; @@ -26,7 +27,7 @@ import { noteStreamingRowRender, noteUnsplitListRender, } from "@/lib/home-streaming-render-probe"; -import { memo, useEffect, type MutableRefObject } from "react"; +import { memo, useEffect, useState, type MutableRefObject } from "react"; export type ChatTranscriptActions = Readonly<{ onFeedback: (feedbackKey: string, requested: ChatMessageFeedback) => void; @@ -92,6 +93,8 @@ function MessageEntry({ && message.state === "settled" && Boolean(message.text); const feedbackKey = `${sessionId}:${message.renderKey}`; + // 不满意原因 opens right after a 👎 in this view; stored 👎 ratings do not reopen it. + const [reasonOpen, setReasonOpen] = useState(false); const latestRegeneratableKey = !loading && !cancellationPending ? [...views].reverse().find((item) => ( item.role === "assistant" && item.state === "settled" && Boolean(item.text) @@ -119,13 +122,18 @@ function MessageEntry({ canRegenerate={message.renderKey === latestRegeneratableKey} onFeedback={(requested) => { // 回复评价 is saved here, where the current value and the reply text are both at hand. - void persistReplyRating(feedbackKey, toggleChatMessageFeedback(messageFeedback[feedbackKey], requested), message.text); + const next = toggleChatMessageFeedback(messageFeedback[feedbackKey], requested); + void persistReplyRating(feedbackKey, next, message.text); + setReasonOpen(next === "down"); actionsRef.current.onFeedback(feedbackKey, requested); }} onCopy={() => actionsRef.current.onCopy(feedbackKey, message.text)} onRegenerate={() => actionsRef.current.onRegenerate(message.renderKey)} /> )} + {showActions && reasonOpen && ( + setReasonOpen(false)} /> + )} [key, REPLY_QUALITY_REASON_LABELS[key]] as const); + +/** + * 不满意原因 after a 👎 (compliance round 2026-09-30). Everything here is + * optional: the 👎 and the turn's snapshot are already saved when this opens; + * closing it keeps them. One line says the turn is sent for review. + */ +export function ReplyDownReason({ feedbackKey, onClose }: Readonly<{ feedbackKey: string; onClose: () => void }>) { + const [picked, setPicked] = useState>(() => new Set()); + const [note, setNote] = useState(""); + const [state, setState] = useState<"idle" | "sending" | "sent" | "failed">("idle"); + + async function submit() { + if (state === "sending") return; + setState("sending"); + const ok = await saveReplyQualityReason(feedbackKey, REASONS.map(([key]) => key).filter((key) => picked.has(key)), note); + setState(ok ? "sent" : "failed"); + if (ok) window.setTimeout(onClose, 1200); + } + + if (state === "sent") { + return

已收到,谢谢。

; + } + + return ( +
+
+ 哪里不满意?可以不选 + +
+
+ {REASONS.map(([key, label]) => ( + + ))} +
+ setNote(event.target.value)} + /> +
+ 会把这一轮对话发给我们排查 + +
+
+ ); +} diff --git a/frontend/src/instrumentation.ts b/frontend/src/instrumentation.ts index e3b5a7da..fd8e79e6 100644 --- a/frontend/src/instrumentation.ts +++ b/frontend/src/instrumentation.ts @@ -18,4 +18,8 @@ export async function register(): Promise { // Account deletion: permanent purge after the 7-day cooling-off period. const { startAccountDeletionWorker } = await import("./lib/account-deletion-worker"); startAccountDeletionWorker(); + + // 对话质量记录: blank 👎 snapshot bodies after 90 days. + const { startReplyQualityRetentionWorker } = await import("./lib/reply-quality-retention-worker"); + startReplyQualityRetentionWorker(); } diff --git a/frontend/src/lib/admin/auth-policy.ts b/frontend/src/lib/admin/auth-policy.ts index def97b74..58c338ce 100644 --- a/frontend/src/lib/admin/auth-policy.ts +++ b/frontend/src/lib/admin/auth-policy.ts @@ -31,6 +31,8 @@ export const adminPermissions = [ "audit.read", "support.feedback.read", "support.feedback.write", + "support.quality.read", + "support.quality.write", ] as const; export type AdminPermission = (typeof adminPermissions)[number]; diff --git a/frontend/src/lib/admin/providers.ts b/frontend/src/lib/admin/providers.ts index c0112c6f..466aa7b8 100644 --- a/frontend/src/lib/admin/providers.ts +++ b/frontend/src/lib/admin/providers.ts @@ -206,6 +206,7 @@ const resourcePermissions: Record = { usage: { read: "billing.orders.read" }, "rectification-telemetry": { read: "admin.customers.read" }, feedback: { read: "support.feedback.read", write: "support.feedback.write" }, + "reply-quality": { read: "support.quality.read", write: "support.quality.write" }, models: { read: "models.read", write: "models.write" }, "model-releases": { read: "models.read", write: "models.publish" }, "feature-flags": { read: "admin.access", write: "ops.flags.write" }, diff --git a/frontend/src/lib/legal-documents.ts b/frontend/src/lib/legal-documents.ts index 5afd7d23..227d5595 100644 --- a/frontend/src/lib/legal-documents.ts +++ b/frontend/src/lib/legal-documents.ts @@ -124,6 +124,7 @@ export const PRIVACY: LegalDocument = { "· 使用内容:你与服务的对话内容、出生时间校正过程中的回答、生成的个人报告与合盘结果。", "· 交易信息:点数与会员的订单、兑换码使用与点数流水;在线支付时由支付服务商处理支付信息,我们不保存你的银行卡或支付账户密码。", "· 反馈信息:你通过「反馈与投诉」提交的内容与所附的会话信息。", + "· 回复评价:你对回答点「赞」时,我们只记录一次计数;你点「踩」时,我们会保存这一轮的提问与回答、模型当时参考的前文对话(可能包含你在对话里提到的出生资料等内容)、所用模型与耗时等运行信息,以及你选填的不满意原因与补充说明。改为点赞或取消点踩时,这份记录随之删除。", "· 设备与日志信息:为保障安全与排查故障,服务器会记录访问时间、请求地址、错误信息等必要日志【待确认:日志是否包含 IP 地址及保存期限】。浏览器中会保存登录状态 Cookie 与少量本地偏好(例如当前选中的人物)。", ], }, @@ -135,6 +136,7 @@ export const PRIVACY: LegalDocument = { "· 登录验证、账号安全、防止滥用(例如验证码发送与请求频率限制);", "· 点数扣除与退回、订单处理与客户服务;", "· 内容安全检查:对提问与模型回答进行检查,命中规则时拦截或替换,并记录去除身份信息的命中日志以便改进规则;", + "· 排查回答质量问题:由经授权的工作人员查看你点「踩」时保存的那一轮对话,找出答非所问、内容不准等问题的原因并改进服务;每次查看都会记入内部审计日志;", "· 以去除个人身份信息的汇总统计改进服务(例如出生时间校正的问题数量与收敛情况,不含出生资料与对话原文)。", "我们不会用你的出生资料或对话内容做与上述目的无关的用途,也不会出售你的个人信息。", ], @@ -160,6 +162,7 @@ export const PRIVACY: LegalDocument = { "· 注销账号:冷静期 7 天满后,上述数据与账号一并删除;法律法规要求保留的订单与点数流水去除个人身份信息后保存,期限依法确定【待确认:保存年限】;反馈与投诉保留处理记录并删除其中的联系方式【待确认:保存年限】。", "· 内容安全命中日志:去除身份信息后保存【待确认:保存期限】。", "· 出生时间校正的匿名统计:保存 180 天。", + "· 点「踩」时保存的对话记录:保存 90 天,期满后清空对话内容,只保留评价与不满意原因用于统计;你删除该条对话或注销账号时一并删除。", "· 服务器日志:【待确认:保存期限】。", ], }, diff --git a/frontend/src/lib/reply-quality-capture.ts b/frontend/src/lib/reply-quality-capture.ts new file mode 100644 index 00000000..3b8114a1 --- /dev/null +++ b/frontend/src/lib/reply-quality-capture.ts @@ -0,0 +1,98 @@ +import "server-only"; + +import { resolveSessionLanguageModel } from "./model-catalog"; +import { buildReplyQualitySnapshot } from "./reply-quality-snapshot"; + +/** The narrow part of the service client this module uses. */ +export type ReplyQualityService = { + from(table: string): { + select(columns: string): { + eq(column: string, value: unknown): { + eq(column: string, value: unknown): { maybeSingle(): PromiseLike<{ data: unknown; error: unknown }> }; + }; + }; + }; + rpc(name: string, args: Record): PromiseLike<{ data: unknown; error: { message?: string } | null }>; +}; + +function record(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; +} + +export type ReplyQualityCaptureResult = "saved" | "skipped" | "failed"; + +/** + * Keeps the 故障上下文快照 for a 👎 (compliance round 2026-09-30): reads the + * stored session, rebuilds the turn and its context, adds the run facts the + * usage ledger already has for that request (model, tokens, duration), and + * saves through save_reply_quality_snapshot(), which refuses unless the reply + * is currently rated 👎. + */ +export async function captureReplyQualitySnapshot( + service: ReplyQualityService, + input: Readonly<{ userId: string; sessionId: string; messageIndex: number; answerSha256: string }>, +): Promise { + const { data: sessionRow, error: sessionError } = await service + .from("chat_sessions") + .select("id,model_id,model_config_version,session_type,messages,context_summary") + .eq("id", input.sessionId) + .eq("user_id", input.userId) + .maybeSingle(); + const session = record(sessionRow); + if (sessionError || !session) return "skipped"; + const sessionType = session.session_type === "birth_time_rectification" ? "birth_time_rectification" : "consultation"; + const modelId = typeof session.model_id === "string" ? session.model_id : null; + let contextWindow: number | null = null; + if (modelId) { + const model = await resolveSessionLanguageModel( + modelId, + typeof session.model_config_version === "number" ? session.model_config_version : null, + ).catch(() => null); + contextWindow = typeof model?.contextWindow === "number" ? model.contextWindow : null; + } + const built = buildReplyQualitySnapshot({ + messages: session.messages, + messageIndex: input.messageIndex, + answerSha256: input.answerSha256, + sessionType, + contextSummary: session.context_summary, + contextWindow, + }); + if (!built.ok) return "skipped"; + + let runFacts: Record = { contextWindow }; + if (built.snapshot.requestId) { + const { data: ledger } = await service + .from("usage_ledger") + .select("actual_model_id,requested_model_id,input_tokens,output_tokens,duration_ms,feature_key") + .eq("user_id", input.userId) + .eq("request_id", built.snapshot.requestId) + .maybeSingle(); + const row = record(ledger); + if (row) { + runFacts = { + ...runFacts, + actualModelId: row.actual_model_id ?? null, + requestedModelId: row.requested_model_id ?? null, + inputTokens: row.input_tokens ?? null, + outputTokens: row.output_tokens ?? null, + durationMs: row.duration_ms ?? null, + featureKey: row.feature_key ?? null, + }; + } + } + + const { error } = await service.rpc("save_reply_quality_snapshot", { + p_user_id: input.userId, + p_session_id: input.sessionId, + p_message_index: input.messageIndex, + p_session_type: sessionType, + p_model_id: typeof runFacts.actualModelId === "string" ? runFacts.actualModelId : modelId, + p_request_id: built.snapshot.requestId, + p_question: built.snapshot.question, + p_answer: built.snapshot.answer, + p_context: built.snapshot.context, + p_run_facts: runFacts, + }); + return error ? "failed" : "saved"; +} diff --git a/frontend/src/lib/reply-quality-labels.ts b/frontend/src/lib/reply-quality-labels.ts new file mode 100644 index 00000000..220d390e --- /dev/null +++ b/frontend/src/lib/reply-quality-labels.ts @@ -0,0 +1,51 @@ +/** + * 对话质量记录 vocabulary shared by the 👎 picker, the API and the admin page + * (compliance round 2026-09-30). Client-safe: no node imports. + */ +export const REPLY_QUALITY_REASONS = ["off_topic", "inaccurate", "too_long_or_empty", "tone", "other"] as const; +export type ReplyQualityReason = (typeof REPLY_QUALITY_REASONS)[number]; + +/** 不满意原因, in the order the picker shows them. */ +export const REPLY_QUALITY_REASON_LABELS: Readonly> = { + off_topic: "答非所问", + inaccurate: "内容不准", + too_long_or_empty: "太长太空", + tone: "语气不对", + other: "其他", +}; + +export const REPLY_QUALITY_NOTE_MAX = 200; +export const REPLY_QUALITY_RETENTION_DAYS = 90; + +/** 处理状态 of a 对话质量记录. */ +export const REPLY_QUALITY_STATUSES = ["new", "in_progress", "resolved", "ignored"] as const; +export type ReplyQualityStatus = (typeof REPLY_QUALITY_STATUSES)[number]; + +export const REPLY_QUALITY_STATUS_LABELS: Readonly> = { + new: "待处理", + in_progress: "处理中", + resolved: "已解决", + ignored: "忽略", +}; + +export type ReplyQualityFilter = Readonly<{ + status: ReplyQualityStatus | null; + reason: ReplyQualityReason | null; + modelId: string | null; + days: number | null; +}>; + +/** + * The admin table has one filter slot; it carries `status`, `reason:`, + * `model:` or `days:`. Unknown values mean no filter. + */ +export function parseReplyQualityFilter(value: string | null | undefined): ReplyQualityFilter { + const empty = { status: null, reason: null, modelId: null, days: null }; + if (!value) return empty; + if ((REPLY_QUALITY_STATUSES as readonly string[]).includes(value)) return { ...empty, status: value as ReplyQualityStatus }; + const [kind, rest] = [value.slice(0, value.indexOf(":")), value.slice(value.indexOf(":") + 1)]; + if (kind === "reason" && (REPLY_QUALITY_REASONS as readonly string[]).includes(rest)) return { ...empty, reason: rest as ReplyQualityReason }; + if (kind === "model" && /^[\w.:/-]{1,120}$/.test(rest)) return { ...empty, modelId: rest }; + if (kind === "days" && /^\d{1,2}$/.test(rest) && Number(rest) >= 1 && Number(rest) <= 90) return { ...empty, days: Number(rest) }; + return empty; +} diff --git a/frontend/src/lib/reply-quality-retention-core.ts b/frontend/src/lib/reply-quality-retention-core.ts new file mode 100644 index 00000000..bf762149 --- /dev/null +++ b/frontend/src/lib/reply-quality-retention-core.ts @@ -0,0 +1,19 @@ +import { REPLY_QUALITY_RETENTION_DAYS } from "./reply-quality-labels.ts"; + +/** + * 对话质量记录 retention (compliance round 2026-09-30): snapshot bodies older + * than 90 days are blanked by expire_reply_quality_snapshot_bodies(); date, + * model, 不满意原因 and 处理状态 stay for statistics. Logs carry a count only. + */ +export const REPLY_QUALITY_RETENTION_TICK_MS = 6 * 60 * 60 * 1000; +export const REPLY_QUALITY_RETENTION_FIRST_TICK_MS = 2 * 60 * 1000; + +export async function runReplyQualityRetentionTick(deps: Readonly<{ + expire: (days: number) => Promise; + log: (line: string) => void; +}>): Promise { + const expired = await deps.expire(REPLY_QUALITY_RETENTION_DAYS); + if (expired === null) deps.log("[reply-quality-retention] expire failed"); + else if (expired > 0) deps.log(`[reply-quality-retention] expired=${expired}`); + return expired; +} diff --git a/frontend/src/lib/reply-quality-retention-worker.ts b/frontend/src/lib/reply-quality-retention-worker.ts new file mode 100644 index 00000000..d3bd1e68 --- /dev/null +++ b/frontend/src/lib/reply-quality-retention-worker.ts @@ -0,0 +1,37 @@ +import "server-only"; + +import { + REPLY_QUALITY_RETENTION_FIRST_TICK_MS, + REPLY_QUALITY_RETENTION_TICK_MS, + runReplyQualityRetentionTick, +} from "@/lib/reply-quality-retention-core"; +import { createAdminSupabaseClient } from "@/lib/supabase/admin"; + +type WorkerGlobal = typeof globalThis & { jyotishaReplyQualityRetention?: { stop: () => void } }; + +async function tick(): Promise { + const admin = createAdminSupabaseClient(); + await runReplyQualityRetentionTick({ + expire: async (days) => { + const { data, error } = await admin.rpc("expire_reply_quality_snapshot_bodies", { p_days: days }); + return error ? null : typeof data === "number" ? data : 0; + }, + log: (line) => console.info(line), + }); +} + +/** One unref'ed timer per process: two minutes after start, then every six hours. Idempotent. */ +export function startReplyQualityRetentionWorker(): void { + const state = globalThis as WorkerGlobal; + if (state.jyotishaReplyQualityRetention) return; + const run = () => { + tick().catch((error: unknown) => { + console.error(`[reply-quality-retention] tick failed reason=${error instanceof Error ? error.name : "UnknownError"}`); + }); + }; + const first = setTimeout(run, REPLY_QUALITY_RETENTION_FIRST_TICK_MS); + const every = setInterval(run, REPLY_QUALITY_RETENTION_TICK_MS); + first.unref?.(); + every.unref?.(); + state.jyotishaReplyQualityRetention = { stop: () => { clearTimeout(first); clearInterval(every); } }; +} diff --git a/frontend/src/lib/reply-quality-snapshot.ts b/frontend/src/lib/reply-quality-snapshot.ts new file mode 100644 index 00000000..eb9e6e08 --- /dev/null +++ b/frontend/src/lib/reply-quality-snapshot.ts @@ -0,0 +1,135 @@ +import { createHash } from "node:crypto"; + +import { REPLY_QUALITY_NOTE_MAX, REPLY_QUALITY_REASONS, type ReplyQualityReason } from "./reply-quality-labels.ts"; +import { + consultationHistoryWindow, + parseSessionContextSummary, +} from "./consultation-session-history.ts"; + +/** + * 故障上下文快照 for a 负向回复评价 (compliance round 2026-09-30). + * + * Built on the server from the stored session only — the client sends the + * rated position and the hash it saw, never text. The rated reply must still + * be the stored reply at that position (same 16-hex hash the client computes), + * otherwise nothing is kept: a regenerated or not-yet-stored answer is not + * the one the user judged. + * + * The context is the window the consultation route gives the model for that + * turn — `consultationHistoryWindow` over the messages before the question, + * with the session summary when it already covered them and the model's + * context-window budget. It is a reconstruction: a summary written after that + * turn is not used, and a model change since then only moves the budget. + * Rectification sessions assemble context differently; for them the snapshot + * keeps the last six turns before the question and says so. + */ + +export { + REPLY_QUALITY_NOTE_MAX, + REPLY_QUALITY_REASON_LABELS, + REPLY_QUALITY_REASONS, + REPLY_QUALITY_RETENTION_DAYS, + type ReplyQualityReason, +} from "./reply-quality-labels.ts"; +const RECTIFICATION_CONTEXT_TURNS = 6; +const TEXT_MAX = 16_000; + +export type ReplyQualityContext = Readonly<{ + kind: "consultation_window" | "rectification_recent_turns"; + summaryText: string | null; + droppedCount: number; + turns: readonly Readonly<{ role: "user" | "assistant"; text: string }>[]; +}>; + +export type ReplyQualitySnapshot = Readonly<{ + question: string | null; + answer: string; + requestId: string | null; + context: ReplyQualityContext; +}>; + +export type ReplyQualitySnapshotFailure = "not_assistant" | "answer_not_stored" | "answer_changed"; + +/** Same first 16 hex characters of SHA-256 the client stores with a rating. */ +export function replyAnswerHash(text: string): string { + return createHash("sha256").update(text, "utf8").digest("hex").slice(0, 16); +} + +type StoredMessage = { role?: unknown; text?: unknown; requestId?: unknown }; + +function asMessages(value: unknown): StoredMessage[] { + return Array.isArray(value) ? value.filter((item): item is StoredMessage => Boolean(item) && typeof item === "object") : []; +} + +function clip(text: string): string { + return text.length > TEXT_MAX ? text.slice(0, TEXT_MAX) : text; +} + +export function buildReplyQualitySnapshot(input: Readonly<{ + messages: unknown; + messageIndex: number; + answerSha256: string; + sessionType: "consultation" | "birth_time_rectification"; + contextSummary?: unknown; + contextWindow?: number | null; +}>): { ok: true; snapshot: ReplyQualitySnapshot } | { ok: false; reason: ReplyQualitySnapshotFailure } { + const messages = Array.isArray(input.messages) ? input.messages : []; + const rated = messages[input.messageIndex] as StoredMessage | undefined; + if (!rated || typeof rated !== "object") return { ok: false, reason: "answer_not_stored" }; + if (rated.role !== "assistant") return { ok: false, reason: "not_assistant" }; + if (typeof rated.text !== "string" || !rated.text) return { ok: false, reason: "answer_not_stored" }; + if (replyAnswerHash(rated.text) !== input.answerSha256) return { ok: false, reason: "answer_changed" }; + + let questionIndex = -1; + for (let index = input.messageIndex - 1; index >= 0; index -= 1) { + const candidate = messages[index] as StoredMessage | undefined; + if (candidate?.role === "user" && typeof candidate.text === "string" && candidate.text) { + questionIndex = index; + break; + } + } + const question = questionIndex >= 0 ? String((messages[questionIndex] as StoredMessage).text) : null; + const before = messages.slice(0, Math.max(0, questionIndex)); + const requestId = typeof rated.requestId === "string" + ? rated.requestId + : questionIndex >= 0 && typeof (messages[questionIndex] as StoredMessage).requestId === "string" + ? String((messages[questionIndex] as StoredMessage).requestId) + : null; + + let context: ReplyQualityContext; + if (input.sessionType === "consultation") { + const summary = parseSessionContextSummary(input.contextSummary); + // A summary written after this turn was not what the model read then. + const usableSummary = summary && summary.throughMessageIndex < questionIndex ? summary : null; + const window = consultationHistoryWindow(before, usableSummary, { contextWindow: input.contextWindow ?? null }); + context = { + kind: "consultation_window", + summaryText: window.summaryText, + droppedCount: window.droppedCount, + turns: window.tail.map((turn) => ({ role: turn.role, text: clip(turn.text) })), + }; + } else { + const turns = asMessages(before) + .filter((message) => (message.role === "user" || message.role === "assistant") && typeof message.text === "string" && message.text) + .slice(-RECTIFICATION_CONTEXT_TURNS) + .map((message) => ({ role: message.role as "user" | "assistant", text: clip(String(message.text)) })); + context = { kind: "rectification_recent_turns", summaryText: null, droppedCount: 0, turns }; + } + + return { + ok: true, + snapshot: { question: question === null ? null : clip(question), answer: clip(rated.text), requestId, context }, + }; +} + +/** Keeps only known reasons, in picker order, and a trimmed note. */ +export function normalizeReplyQualityReasons(reasons: readonly unknown[], note: unknown): { + reasons: ReplyQualityReason[]; + note: string | null; +} { + const picked = new Set(reasons.filter((reason): reason is ReplyQualityReason => ( + typeof reason === "string" && (REPLY_QUALITY_REASONS as readonly string[]).includes(reason) + ))); + const text = typeof note === "string" ? note.trim().slice(0, REPLY_QUALITY_NOTE_MAX) : ""; + return { reasons: REPLY_QUALITY_REASONS.filter((reason) => picked.has(reason)), note: text || null }; +} diff --git a/frontend/src/lib/reply-ratings.ts b/frontend/src/lib/reply-ratings.ts index 2d221e0f..e7c2f6e8 100644 --- a/frontend/src/lib/reply-ratings.ts +++ b/frontend/src/lib/reply-ratings.ts @@ -24,11 +24,14 @@ export async function answerHash(text: string): Promise { return [...new Uint8Array(digest)].slice(0, 8).map((byte) => byte.toString(16).padStart(2, "0")).join(""); } +/** The latest rating save per reply, so a 不满意原因 lands after its 👎 (and snapshot) exists. */ +const pendingRatingSaves = new Map>(); + /** Fire-and-forget; a failed save never disturbs the conversation. */ export function persistReplyRating(feedbackKey: string, rating: ChatMessageFeedback | undefined, text: string): Promise { const key = parseFeedbackKey(feedbackKey); if (!key) return Promise.resolve(); - return answerHash(text) + const saved = answerHash(text) .then((answerSha256) => fetch("/api/reply-ratings", { method: "PUT", credentials: "same-origin", @@ -36,6 +39,33 @@ export function persistReplyRating(feedbackKey: string, rating: ChatMessageFeedb body: JSON.stringify({ ...key, rating: rating ?? null, answerSha256 }), })) .then(() => undefined, () => undefined); + pendingRatingSaves.set(feedbackKey, saved); + return saved; +} + +/** + * 不满意原因 for a 👎: optional reasons and a short note, added to the snapshot + * the rating save kept. Resolves false when it could not be saved. + */ +export async function saveReplyQualityReason( + feedbackKey: string, + reasons: readonly string[], + note: string, +): Promise { + const key = parseFeedbackKey(feedbackKey); + if (!key) return false; + await pendingRatingSaves.get(feedbackKey); + try { + const response = await fetch("/api/reply-ratings/reason", { + method: "PUT", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...key, reasons, ...(note.trim() ? { note: note.trim() } : {}) }), + }); + return response.ok; + } catch { + return false; + } } type StoredRating = { messageIndex: number; rating: ChatMessageFeedback; answerSha256: string }; diff --git a/frontend/supabase/migrations/20260930050000_reply_quality_snapshots.sql b/frontend/supabase/migrations/20260930050000_reply_quality_snapshots.sql new file mode 100644 index 00000000..1aea06a8 --- /dev/null +++ b/frontend/supabase/migrations/20260930050000_reply_quality_snapshots.sql @@ -0,0 +1,420 @@ +-- 对话质量记录 from 负向回复评价 (compliance round 2026-09-30). +-- +-- A 👎 on an assistant reply keeps a 故障上下文快照 of that turn: the user's +-- question, the full answer and the context the model read for it (built by +-- the server from the stored session, never from client text), plus the +-- optional 不满意原因. 👍 is only counted in reply_ratings; it keeps no text. +-- +-- Retention: the snapshot body (question, answer, context) is blanked after 90 +-- days by expire_reply_quality_snapshot_bodies(); date, model, reasons and the +-- 处理状态 remain for statistics. The row belongs to the user (FK to +-- auth.users, not in account_deletion_kept_tables()), so 注销 deletes it. +-- +-- Add-only: new table, new functions, set_reply_rating() replaced with the +-- same signature so 👍 / clearing also removes a snapshot. + +begin; + +create table if not exists public.reply_quality_snapshots ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users(id) on delete cascade, + session_id uuid not null references public.chat_sessions(id) on delete cascade, + message_index integer not null check (message_index between 0 and 100000), + session_type text not null default 'consultation' + check (session_type in ('consultation', 'birth_time_rectification')), + model_id text check (model_id is null or char_length(model_id) <= 120), + request_id text check (request_id is null or char_length(request_id) <= 200), + reasons text[] not null default '{}'::text[] + check (reasons <@ array['off_topic', 'inaccurate', 'too_long_or_empty', 'tone', 'other']::text[]), + reason_note text check (reason_note is null or char_length(reason_note) <= 200), + question text check (question is null or char_length(question) <= 16000), + answer text check (answer is null or char_length(answer) <= 16000), + context jsonb check (context is null or jsonb_typeof(context) = 'object'), + run_facts jsonb not null default '{}'::jsonb check (jsonb_typeof(run_facts) = 'object'), + body_expired_at timestamptz, + status text not null default 'new' + check (status in ('new', 'in_progress', 'resolved', 'ignored')), + admin_note text check (admin_note is null or char_length(admin_note) <= 2000), + handled_by uuid, + created_at timestamptz not null default clock_timestamp(), + updated_at timestamptz not null default clock_timestamp(), + unique (user_id, session_id, message_index) +); + +create index if not exists reply_quality_snapshots_created_idx + on public.reply_quality_snapshots (created_at desc); +create index if not exists reply_quality_snapshots_status_created_idx + on public.reply_quality_snapshots (status, created_at desc); + +alter table public.reply_quality_snapshots enable row level security; +revoke all on table public.reply_quality_snapshots from public, anon, authenticated, service_role; + +do $$ +begin + if exists (select 1 from pg_roles where rolname = 'app_runtime') then + revoke all on table public.reply_quality_snapshots from app_runtime; + end if; + if exists (select 1 from pg_roles where rolname = 'admin_runtime') then + revoke all on table public.reply_quality_snapshots from admin_runtime; + end if; +end; +$$; + +-- --------------------------------------------------------------------------- +-- User side (service_role only; the route passes the authenticated user id) +-- --------------------------------------------------------------------------- + +-- Same signature as 20260930030000; now 👍 or clearing also drops the snapshot. +create or replace function public.set_reply_rating( + p_user_id uuid, + p_session_id uuid, + p_message_index integer, + p_rating text, + p_answer_sha256 text +) +returns boolean +language plpgsql +security definer +set search_path = '' +as $$ +begin + if not exists ( + select 1 from public.chat_sessions s where s.id = p_session_id and s.user_id = p_user_id + ) then + raise exception 'rating_session_not_owned' using errcode = '42501'; + end if; + if p_rating is null or p_rating <> 'down' then + delete from public.reply_quality_snapshots + where user_id = p_user_id and session_id = p_session_id and message_index = p_message_index; + end if; + if p_rating is null then + delete from public.reply_ratings + where user_id = p_user_id and session_id = p_session_id and message_index = p_message_index; + return true; + end if; + insert into public.reply_ratings (user_id, session_id, message_index, rating, answer_sha256) + values (p_user_id, p_session_id, p_message_index, p_rating, p_answer_sha256) + on conflict (user_id, session_id, message_index) do update + set rating = excluded.rating, + answer_sha256 = excluded.answer_sha256, + updated_at = clock_timestamp(); + return true; +end; +$$; + +-- Written only while the reply is rated 👎; a re-rated 👎 replaces the body +-- (the answer may have been regenerated) and keeps reasons and status. +create or replace function public.save_reply_quality_snapshot( + p_user_id uuid, + p_session_id uuid, + p_message_index integer, + p_session_type text, + p_model_id text, + p_request_id text, + p_question text, + p_answer text, + p_context jsonb, + p_run_facts jsonb +) +returns uuid +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_id uuid; +begin + if not exists ( + select 1 from public.reply_ratings r + where r.user_id = p_user_id and r.session_id = p_session_id + and r.message_index = p_message_index and r.rating = 'down' + ) then + raise exception 'reply_quality_not_down' using errcode = '22023'; + end if; + insert into public.reply_quality_snapshots ( + user_id, session_id, message_index, session_type, model_id, request_id, + question, answer, context, run_facts + ) values ( + p_user_id, p_session_id, p_message_index, coalesce(p_session_type, 'consultation'), + p_model_id, p_request_id, p_question, p_answer, p_context, coalesce(p_run_facts, '{}'::jsonb) + ) + on conflict (user_id, session_id, message_index) do update + set session_type = excluded.session_type, + model_id = excluded.model_id, + request_id = excluded.request_id, + question = excluded.question, + answer = excluded.answer, + context = excluded.context, + run_facts = excluded.run_facts, + body_expired_at = null, + updated_at = clock_timestamp() + returning id into v_id; + return v_id; +end; +$$; + +create or replace function public.set_reply_quality_reason( + p_user_id uuid, + p_session_id uuid, + p_message_index integer, + p_reasons text[], + p_note text +) +returns boolean +language plpgsql +security definer +set search_path = '' +as $$ +begin + update public.reply_quality_snapshots q + set reasons = coalesce(p_reasons, '{}'::text[]), + reason_note = nullif(btrim(coalesce(p_note, '')), ''), + updated_at = clock_timestamp() + where q.user_id = p_user_id and q.session_id = p_session_id and q.message_index = p_message_index; + if not found then + raise exception 'reply_quality_not_found' using errcode = '22023'; + end if; + return true; +end; +$$; + +-- Retention: bodies older than p_days are blanked; statistics stay. +create or replace function public.expire_reply_quality_snapshot_bodies(p_days integer default 90) +returns integer +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_count integer; +begin + update public.reply_quality_snapshots q + set question = null, + answer = null, + context = null, + body_expired_at = clock_timestamp(), + updated_at = clock_timestamp() + where q.body_expired_at is null + and q.created_at < clock_timestamp() - make_interval(days => greatest(1, coalesce(p_days, 90))); + get diagnostics v_count = row_count; + return v_count; +end; +$$; + +-- --------------------------------------------------------------------------- +-- Admin side (admin_runtime only, permission-checked; opening a body and every +-- status change are audited) +-- --------------------------------------------------------------------------- + +insert into public.admin_permissions (permission_key, description) values + ('support.quality.read', '查看对话质量记录'), + ('support.quality.write', '处理对话质量记录') +on conflict (permission_key) do update set description = excluded.description; + +with role_grants(role_code, permission_key) as (values + ('owner', 'support.quality.read'), ('owner', 'support.quality.write'), + ('operations', 'support.quality.read'), ('operations', 'support.quality.write'), + ('support', 'support.quality.read'), ('support', 'support.quality.write'), + ('auditor', 'support.quality.read') +) +insert into public.admin_role_permissions (role_id, permission_id) +select r.id, p.id +from role_grants g +join public.admin_roles r on r.code = g.role_code +join public.admin_permissions p on p.permission_key = g.permission_key +on conflict do nothing; + +-- The list never carries the body; opening one record does (and is audited). +create or replace function public.admin_list_reply_quality( + p_actor_user_id uuid, + p_status text, + p_reason text, + p_model_id text, + p_from timestamptz, + p_to timestamptz, + p_query text, + p_limit integer, + p_offset integer +) +returns table ( + id uuid, user_id uuid, email text, session_id uuid, message_index integer, + session_type text, model_id text, reasons text[], reason_note text, + has_body boolean, status text, admin_note text, handled_by uuid, + created_at timestamptz, updated_at timestamptz, total_count bigint +) +language plpgsql +stable +security definer +set search_path = '' +as $$ +begin + if not public.admin_has_permission(p_actor_user_id, 'support.quality.read') then + raise exception 'admin_permission_denied' using errcode = '42501'; + end if; + return query + select q.id, q.user_id, u.email, q.session_id, q.message_index, + q.session_type, q.model_id, q.reasons, q.reason_note, + (q.body_expired_at is null) as has_body, q.status, q.admin_note, q.handled_by, + q.created_at, q.updated_at, count(*) over() as total_count + from public.reply_quality_snapshots q + left join identity.users u on u.id = q.user_id + where (p_status is null or q.status = p_status) + and (p_reason is null or p_reason = any (q.reasons)) + and (p_model_id is null or q.model_id = p_model_id) + and (p_from is null or q.created_at >= p_from) + and (p_to is null or q.created_at < p_to) + and (p_query is null or u.email ilike p_query or q.user_id::text ilike p_query or q.reason_note ilike p_query) + order by (q.status = 'new') desc, q.created_at desc + limit greatest(1, least(coalesce(p_limit, 20), 100)) + offset greatest(0, coalesce(p_offset, 0)); +end; +$$; + +create or replace function public.admin_open_reply_quality( + p_actor_user_id uuid, + p_snapshot_id uuid, + p_request_id text +) +returns table ( + id uuid, question text, answer text, context jsonb, run_facts jsonb, + body_expired_at timestamptz +) +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_actor_email text; +begin + if not public.admin_has_permission(p_actor_user_id, 'support.quality.read') then + raise exception 'admin_permission_denied' using errcode = '42501'; + end if; + if not exists (select 1 from public.reply_quality_snapshots q where q.id = p_snapshot_id) then + raise exception 'reply_quality_not_found' using errcode = '22023'; + end if; + select lower(btrim(u.email)) into v_actor_email from identity.users u where u.id = p_actor_user_id; + insert into audit.admin_audit_logs ( + actor_user_id, actor_email, actor_role, action, target_type, target_id, + before_value, after_value, request_id, permission_used, reason + ) values ( + p_actor_user_id, coalesce(v_actor_email, 'unknown@invalid'), 'admin', + 'reply_quality.open', 'reply_quality_snapshot', p_snapshot_id, + null, null, p_request_id, 'support.quality.read', 'quality review' + ) on conflict do nothing; + return query + select q.id, q.question, q.answer, q.context, q.run_facts, q.body_expired_at + from public.reply_quality_snapshots q where q.id = p_snapshot_id; +end; +$$; + +create or replace function public.admin_update_reply_quality( + p_actor_user_id uuid, + p_snapshot_id uuid, + p_status text, + p_admin_note text, + p_request_id text +) +returns table (id uuid, status text, admin_note text, handled_by uuid, updated_at timestamptz) +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_actor_email text; + v_before jsonb; +begin + if not public.admin_has_permission(p_actor_user_id, 'support.quality.write') then + raise exception 'admin_permission_denied' using errcode = '42501'; + end if; + if p_status not in ('new', 'in_progress', 'resolved', 'ignored') then + raise exception 'reply_quality_status_invalid' using errcode = '22023'; + end if; + if p_admin_note is not null and char_length(p_admin_note) > 2000 then + raise exception 'reply_quality_note_too_long' using errcode = '22023'; + end if; + select jsonb_build_object('status', q.status, 'admin_note', q.admin_note) + into v_before + from public.reply_quality_snapshots q where q.id = p_snapshot_id for update; + if v_before is null then + raise exception 'reply_quality_not_found' using errcode = '22023'; + end if; + select lower(btrim(u.email)) into v_actor_email from identity.users u where u.id = p_actor_user_id; + update public.reply_quality_snapshots q + set status = p_status, + admin_note = nullif(btrim(coalesce(p_admin_note, '')), ''), + handled_by = p_actor_user_id, + updated_at = clock_timestamp() + where q.id = p_snapshot_id; + insert into audit.admin_audit_logs ( + actor_user_id, actor_email, actor_role, action, target_type, target_id, + before_value, after_value, request_id, permission_used, reason + ) values ( + p_actor_user_id, coalesce(v_actor_email, 'unknown@invalid'), 'admin', + 'reply_quality.update', 'reply_quality_snapshot', p_snapshot_id, + v_before, jsonb_build_object('status', p_status), + p_request_id, 'support.quality.write', 'quality review' + ) on conflict do nothing; + return query + select q.id, q.status, q.admin_note, q.handled_by, q.updated_at + from public.reply_quality_snapshots q where q.id = p_snapshot_id; +end; +$$; + +-- 👍 / 👎 counts by day and by the session's model, from reply_ratings (the +-- rating row carries no model; the session's model is the one that answered). +create or replace function public.admin_reply_quality_stats(p_actor_user_id uuid, p_days integer) +returns table (bucket_kind text, bucket text, up_count bigint, down_count bigint) +language plpgsql +stable +security definer +set search_path = '' +as $$ +declare + v_since timestamptz := clock_timestamp() - make_interval(days => greatest(1, least(coalesce(p_days, 14), 90))); +begin + if not public.admin_has_permission(p_actor_user_id, 'support.quality.read') then + raise exception 'admin_permission_denied' using errcode = '42501'; + end if; + return query + select 'day'::text, to_char(r.updated_at at time zone 'Asia/Shanghai', 'YYYY-MM-DD'), + count(*) filter (where r.rating = 'up'), count(*) filter (where r.rating = 'down') + from public.reply_ratings r + where r.updated_at >= v_since + group by 2 + union all + select 'model'::text, coalesce(s.model_id, '未知'), + count(*) filter (where r.rating = 'up'), count(*) filter (where r.rating = 'down') + from public.reply_ratings r + join public.chat_sessions s on s.id = r.session_id + where r.updated_at >= v_since + group by 2 + order by 1, 2; +end; +$$; + +revoke all on function public.set_reply_rating(uuid, uuid, integer, text, text) from public, anon, authenticated; +revoke all on function public.save_reply_quality_snapshot(uuid, uuid, integer, text, text, text, text, text, jsonb, jsonb) from public, anon, authenticated; +revoke all on function public.set_reply_quality_reason(uuid, uuid, integer, text[], text) from public, anon, authenticated; +revoke all on function public.expire_reply_quality_snapshot_bodies(integer) from public, anon, authenticated; +revoke all on function public.admin_list_reply_quality(uuid, text, text, text, timestamptz, timestamptz, text, integer, integer) from public, anon, authenticated; +revoke all on function public.admin_open_reply_quality(uuid, uuid, text) from public, anon, authenticated; +revoke all on function public.admin_update_reply_quality(uuid, uuid, text, text, text) from public, anon, authenticated; +revoke all on function public.admin_reply_quality_stats(uuid, integer) from public, anon, authenticated; + +grant execute on function public.set_reply_rating(uuid, uuid, integer, text, text) to service_role; +grant execute on function public.save_reply_quality_snapshot(uuid, uuid, integer, text, text, text, text, text, jsonb, jsonb) to service_role; +grant execute on function public.set_reply_quality_reason(uuid, uuid, integer, text[], text) to service_role; +grant execute on function public.expire_reply_quality_snapshot_bodies(integer) to service_role; + +do $$ +begin + if exists (select 1 from pg_roles where rolname = 'admin_runtime') then + grant execute on function public.admin_list_reply_quality(uuid, text, text, text, timestamptz, timestamptz, text, integer, integer) to admin_runtime; + grant execute on function public.admin_open_reply_quality(uuid, uuid, text) to admin_runtime; + grant execute on function public.admin_update_reply_quality(uuid, uuid, text, text, text) to admin_runtime; + grant execute on function public.admin_reply_quality_stats(uuid, integer) to admin_runtime; + end if; +end; +$$; + +commit; diff --git a/frontend/tests/feedback-complaints-20260930.test.tsx b/frontend/tests/feedback-complaints-20260930.test.tsx index a61b5262..4760354a 100644 --- a/frontend/tests/feedback-complaints-20260930.test.tsx +++ b/frontend/tests/feedback-complaints-20260930.test.tsx @@ -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\);/); }); diff --git a/frontend/tests/reply-quality-20260930.test.tsx b/frontend/tests/reply-quality-20260930.test.tsx new file mode 100644 index 00000000..0955af77 --- /dev/null +++ b/frontend/tests/reply-quality-20260930.test.tsx @@ -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; +const find = (h: Harness, predicate: (node: ReturnType[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 }[] = []; + 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( {}} />); + 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[] = []; + 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( {}} />); + 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*