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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user