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*