feat(reply-quality): 👎 keeps a snapshot of the turn; admin 对话质量记录
A 👎 now saves, server side, the rated turn plus the context window the model read for it (reconstructed from the stored session with the same consultationHistoryWindow the consult route uses), model and run facts. 👍 is only counted. Switching to 👍 or clearing deletes the snapshot. Bodies are blanked after 90 days; the row cascades on session delete and on account deletion. - Optional 不满意原因 panel under the answer after a 👎 (five reasons, 200-char note, "会把这一轮对话发给我们排查"). - Admin 对话质量记录: 👍/👎 stats by day and model, list without text, audited snapshot open, 处理状态 + note (support.quality.read/write). - Privacy draft: what a 👎 keeps, why, 90 days, deletion. - Migration 20260930050000 is add-only; set_reply_rating() replaced with the same signature. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c1b4868cd9
commit
12e315a9bb
@@ -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: <ExperimentOutlined /> } },
|
||||
{ name: "rectification-telemetry", list: "/admin/rectification-telemetry", meta: { label: "校正统计", icon: <BarChartOutlined /> } },
|
||||
{ name: "feedback", list: "/admin/feedback", meta: { label: "反馈与投诉", icon: <MessageOutlined /> } },
|
||||
{ name: "reply-quality", list: "/admin/reply-quality", meta: { label: "对话质量记录", icon: <DislikeOutlined /> } },
|
||||
{ name: "models", list: "/admin/models", meta: { label: "模型配置", icon: <ApiOutlined /> } },
|
||||
{ name: "feature-pricing", list: "/admin/feature-pricing", meta: { label: "功能定价", icon: <CreditCardOutlined /> } },
|
||||
{ name: "pricing-simulator", list: "/admin/pricing-simulator", meta: { label: "定价测算", icon: <ExperimentOutlined /> } },
|
||||
|
||||
@@ -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<string, unknown> | null;
|
||||
bodyExpiredAt: string | null;
|
||||
};
|
||||
|
||||
type StatRow = { bucket: string; up: number; down: number };
|
||||
type Stats = { days: number; byDay: StatRow[]; byModel: StatRow[] };
|
||||
|
||||
const STATUS_COLOR: Record<ReplyQualityStatus, string> = { new: "gold", in_progress: "blue", resolved: "green", ignored: "default" };
|
||||
const SESSION_TYPE_LABEL: Record<string, string> = { 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<StatRow> => [
|
||||
{ title: keyTitle, dataIndex: "bucket" },
|
||||
{ title: "👍", dataIndex: "up", width: 64 },
|
||||
{ title: "👎", dataIndex: "down", width: 64 },
|
||||
{ title: "👎 占比", width: 80, render: (_, row) => downShare(row) },
|
||||
];
|
||||
|
||||
return (
|
||||
<Card size="small" title="回复评价统计" extra={<Segmented<number> value={days} onChange={setDays} options={STAT_DAY_OPTIONS.map((value) => ({ value, label: `近 ${value} 天` }))} />}>
|
||||
{!ready ? <Spin /> : ready.error ? <Alert type="error" showIcon message="统计读取失败" description={ready.error} /> : (
|
||||
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}><Statistic title="👍" value={totals.up} /></Col>
|
||||
<Col span={8}><Statistic title="👎" value={totals.down} /></Col>
|
||||
<Col span={8}><Statistic title="👎 占比" value={downShare({ bucket: "", ...totals })} /></Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} lg={12}>
|
||||
<Table<StatRow> size="small" rowKey="bucket" pagination={{ pageSize: 7 }} dataSource={ready.stats?.byDay ?? []} columns={columns("日期")} />
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
<Table<StatRow> size="small" rowKey="bucket" pagination={false} dataSource={ready.stats?.byModel ?? []} columns={columns("模型")} />
|
||||
</Col>
|
||||
</Row>
|
||||
</Space>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TurnBlock({ label, text }: { label: string; text: string | null }) {
|
||||
return (
|
||||
<div>
|
||||
<Text strong>{label}</Text>
|
||||
<Paragraph style={{ whiteSpace: "pre-wrap", marginBottom: 0 }} ellipsis={{ rows: 8, expandable: true }}>{text || "—"}</Paragraph>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 对话质量记录 (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<AdminIdentity>();
|
||||
const invalidate = useInvalidate();
|
||||
const canHandle = Boolean(identity?.permissions.includes("support.quality.write"));
|
||||
const [opened, setOpened] = useState<Record_ | null>(null);
|
||||
const [detail, setDetail] = useState<Detail | null>(null);
|
||||
const [detailError, setDetailError] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<ReplyQualityStatus>("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<Record_> = [
|
||||
{ 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) => (
|
||||
<Space size={[4, 4]} wrap style={{ maxWidth: 320 }}>
|
||||
{item.reasons.length ? item.reasons.map((reason) => <Tag key={reason}>{REPLY_QUALITY_REASON_LABELS[reason] ?? reason}</Tag>) : <Text type="secondary">未选</Text>}
|
||||
{item.reasonNote ? <Text type="secondary">「{item.reasonNote}」</Text> : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ title: "用户", render: (_, item) => item.email ?? item.userId },
|
||||
{ title: "快照", render: (_, item) => (item.hasBody ? "有" : <Text type="secondary">已过期</Text>) },
|
||||
{ title: "处理状态", render: (_, item) => <Tag color={STATUS_COLOR[item.status]}>{REPLY_QUALITY_STATUS_LABELS[item.status]}</Tag> },
|
||||
{ title: "查看", fixed: "right", render: (_, item) => <Button size="small" onClick={() => open(item)}>查看</Button> },
|
||||
];
|
||||
|
||||
const turns = detail?.context?.turns ?? [];
|
||||
return (
|
||||
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
|
||||
<StatsPanel />
|
||||
<ResourceTable<Record_>
|
||||
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 天" },
|
||||
]}
|
||||
/>
|
||||
<Modal
|
||||
title="对话质量记录"
|
||||
width={880}
|
||||
open={opened !== null}
|
||||
onCancel={() => setOpened(null)}
|
||||
onOk={() => void save()}
|
||||
okText="保存"
|
||||
okButtonProps={{ disabled: !canHandle }}
|
||||
confirmLoading={saving}
|
||||
>
|
||||
{opened ? (
|
||||
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
|
||||
<Text type="secondary">
|
||||
{SESSION_TYPE_LABEL[opened.sessionType] ?? opened.sessionType} · {opened.modelId ?? "—"} · {formatAdminDate(opened.createdAt)} · 会话 <Text copyable>{opened.sessionId}</Text> 第 {opened.messageIndex} 条
|
||||
</Text>
|
||||
<Text type="secondary">查看快照会写入审计日志。</Text>
|
||||
{detailError ? <Alert type="error" showIcon message="快照读取失败" description={detailError} /> : !detail ? <Spin /> : detail.bodyExpiredAt ? (
|
||||
<Alert type="info" showIcon message={`快照已按 ${REPLY_QUALITY_RETENTION_DAYS} 天保留期清空`} description={`清空于 ${formatAdminDate(detail.bodyExpiredAt)},只保留原因与处理记录。`} />
|
||||
) : (
|
||||
<>
|
||||
<TurnBlock label="用户问题" text={detail.question} />
|
||||
<TurnBlock label="被点踩的回复" text={detail.answer} />
|
||||
<Card size="small" title={detail.context?.kind === "rectification_recent_turns" ? "此前最近几轮(校正会话)" : "模型当时读到的上下文(按会话重建)"}>
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
{detail.context?.summaryText ? <TurnBlock label="会话摘要" text={detail.context.summaryText} /> : null}
|
||||
{detail.context?.droppedCount ? <Text type="secondary">更早的 {detail.context.droppedCount} 条未进入窗口</Text> : null}
|
||||
{turns.length ? turns.map((turn, index) => (
|
||||
<TurnBlock key={index} label={turn.role === "user" ? "用户" : "助手"} text={turn.text} />
|
||||
)) : <Text type="secondary">这是会话第一轮,没有更早的对话。</Text>}
|
||||
</Space>
|
||||
</Card>
|
||||
{detail.runFacts ? (
|
||||
<Card size="small" title="运行事实">
|
||||
<pre style={{ margin: 0, whiteSpace: "pre-wrap" }}>{JSON.stringify(detail.runFacts, null, 2)}</pre>
|
||||
</Card>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
<Select<ReplyQualityStatus>
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
disabled={!canHandle}
|
||||
options={REPLY_QUALITY_STATUSES.map((value) => ({ value, label: REPLY_QUALITY_STATUS_LABELS[value] }))}
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
<Input.TextArea value={note} onChange={(event) => setNote(event.target.value)} disabled={!canHandle} maxLength={2000} rows={3} placeholder="内部备注(用户看不到)" />
|
||||
</Space>
|
||||
) : null}
|
||||
</Modal>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
<ReplyDownReason feedbackKey={feedbackKey} onClose={() => setReasonOpen(false)} />
|
||||
)}
|
||||
<ConversationFollowUps
|
||||
questions={followUps}
|
||||
disabled={productEntrypointsDisabled}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { REPLY_QUALITY_NOTE_MAX, REPLY_QUALITY_REASON_LABELS, REPLY_QUALITY_REASONS } from "@/lib/reply-quality-labels";
|
||||
import { saveReplyQualityReason } from "@/lib/reply-ratings";
|
||||
|
||||
const REASONS = REPLY_QUALITY_REASONS.map((key) => [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<ReadonlySet<string>>(() => 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 <p className="reply-down-reason is-sent" role="status">已收到,谢谢。</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="reply-down-reason" role="group" aria-label="不满意原因">
|
||||
<div className="reply-down-reason-head">
|
||||
<span>哪里不满意?可以不选</span>
|
||||
<button type="button" className="reply-down-reason-close" aria-label="关闭" onClick={onClose}><X aria-hidden="true" /></button>
|
||||
</div>
|
||||
<div className="reply-down-reason-chips">
|
||||
{REASONS.map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-pressed={picked.has(key)}
|
||||
onClick={() => setPicked((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(key)) next.delete(key); else next.add(key);
|
||||
return next;
|
||||
})}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
className="reply-down-reason-note"
|
||||
type="text"
|
||||
value={note}
|
||||
maxLength={REPLY_QUALITY_NOTE_MAX}
|
||||
placeholder="补充一句(可不填)"
|
||||
aria-label="补充说明"
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
/>
|
||||
<div className="reply-down-reason-foot">
|
||||
<span>会把这一轮对话发给我们排查</span>
|
||||
<button type="button" className="reply-down-reason-send" disabled={state === "sending"} onClick={() => void submit()}>
|
||||
{state === "failed" ? "再试一次" : "提交"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user