feat(rectification): anonymous aggregate telemetry + admin summary page
One row per rectification Case, written once when the range card is first delivered (GET /api/rectification/cases/[caseId], fire-and-forget after the response is built). Numbers and closed enums only: no user / case / session id, birth data, names, text or timestamps finer than the ISO week. Dedupe via a separate case_id ledger that cascades with the Case (and account deletion). Migration 20260926010000 is additive: two RLS tables with no runtime table grants, SECURITY DEFINER write (service_role), purge (service_role) and aggregate-only summary (admin_runtime) functions; 180-day retention. Admin: 「校正统计」 page + GET /api/admin/rectification-telemetry (admin.customers.read), aggregates only, no per-row view or export. TASK-rectification-telemetry-20260926. test:db not run locally (no Docker). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f74825a27c
commit
d0bfc1fc3d
@@ -3,6 +3,7 @@
|
||||
import {
|
||||
ApiOutlined,
|
||||
AuditOutlined,
|
||||
BarChartOutlined,
|
||||
ControlOutlined,
|
||||
CreditCardOutlined,
|
||||
DatabaseOutlined,
|
||||
@@ -110,6 +111,7 @@ export function AdminApp({ children }: { children: ReactNode }) {
|
||||
{ name: "credit-transactions", list: "/admin/credit-transactions", meta: { label: "积分流水", icon: <TransactionOutlined /> } },
|
||||
{ name: "consultations", list: "/admin/consultations", meta: { label: "咨询请求", icon: <MessageOutlined /> } },
|
||||
{ name: "usage", list: "/admin/usage", meta: { label: "用量与成本", icon: <ExperimentOutlined /> } },
|
||||
{ name: "rectification-telemetry", list: "/admin/rectification-telemetry", meta: { label: "校正统计", icon: <BarChartOutlined /> } },
|
||||
{ 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,252 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, Card, Col, Progress, Row, Segmented, Space, Spin, Statistic, Table, Typography } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { adminRequestJson } from "@/lib/admin/providers";
|
||||
import {
|
||||
TELEMETRY_BIRTH_TIME_SOURCE_LABEL,
|
||||
TELEMETRY_GAP_BUCKET_LABEL,
|
||||
TELEMETRY_PERCENT_SHOWN_BUCKETS,
|
||||
TELEMETRY_QUESTION_BUCKET_LABEL,
|
||||
TELEMETRY_STOP_REASON_LABEL,
|
||||
TELEMETRY_SUMMARY_DEFAULT_WEEKS,
|
||||
TELEMETRY_SUMMARY_WEEK_OPTIONS,
|
||||
TELEMETRY_WIDTH_BUCKET_LABEL,
|
||||
share,
|
||||
type RectificationTelemetrySummary,
|
||||
type TelemetryBucket,
|
||||
type TelemetryVersionRow,
|
||||
type TelemetryWeek,
|
||||
} from "@/lib/admin/rectification-telemetry-summary";
|
||||
|
||||
type LoadState =
|
||||
| { status: "loading"; weeks: number }
|
||||
| { status: "ready"; weeks: number; summary: RectificationTelemetrySummary }
|
||||
| { status: "error"; weeks: number; message: string };
|
||||
|
||||
function formatNumber(value: number | null, suffix = "") {
|
||||
return value === null || !Number.isFinite(value)
|
||||
? "—"
|
||||
: `${value.toLocaleString("zh-CN", { maximumFractionDigits: 1 })}${suffix}`;
|
||||
}
|
||||
|
||||
function formatPercent(value: number | null) {
|
||||
return value === null ? "—" : `${(value * 100).toFixed(0)}%`;
|
||||
}
|
||||
|
||||
function formatMinutes(seconds: number | null) {
|
||||
return seconds === null ? "—" : formatNumber(seconds / 60, " 分钟");
|
||||
}
|
||||
|
||||
function BucketTable({
|
||||
rows,
|
||||
labels,
|
||||
total,
|
||||
emptyText,
|
||||
keyTitle = "区间",
|
||||
}: {
|
||||
keyTitle?: string;
|
||||
rows: readonly TelemetryBucket[];
|
||||
labels: Readonly<Record<string, string>>;
|
||||
total: number;
|
||||
emptyText: string;
|
||||
}) {
|
||||
return (
|
||||
<Table<TelemetryBucket>
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey="key"
|
||||
dataSource={[...rows]}
|
||||
locale={{ emptyText }}
|
||||
columns={[
|
||||
{ title: keyTitle, dataIndex: "key", render: (key: string) => labels[key] ?? key },
|
||||
{ title: "会话", dataIndex: "count", width: 64 },
|
||||
{
|
||||
title: "占比",
|
||||
width: "36%",
|
||||
render: (_, row) => {
|
||||
const value = share(row.count, total);
|
||||
return <Progress percent={value === null ? 0 : Math.round(value * 100)} size="small" showInfo />;
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function RectificationTelemetrySummaryView() {
|
||||
const [state, setState] = useState<LoadState>({ status: "loading", weeks: TELEMETRY_SUMMARY_DEFAULT_WEEKS });
|
||||
const weeks = state.weeks;
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
adminRequestJson<{ data: RectificationTelemetrySummary }>(`/api/admin/rectification-telemetry?weeks=${weeks}`)
|
||||
.then((payload) => {
|
||||
if (active) setState({ status: "ready", weeks, summary: payload.data });
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (active) {
|
||||
setState({
|
||||
status: "error",
|
||||
weeks,
|
||||
message: error instanceof Error ? error.message : "统计暂时不可用",
|
||||
});
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [weeks]);
|
||||
|
||||
const summary = state.status === "ready" ? state.summary : null;
|
||||
const total = summary?.sessions ?? 0;
|
||||
const gateKnown = summary ? summary.precisionGate.met + summary.precisionGate.notMet : 0;
|
||||
const percentShown = summary
|
||||
? summary.gapDistribution
|
||||
.filter((bucket) => TELEMETRY_PERCENT_SHOWN_BUCKETS.includes(bucket.key))
|
||||
.reduce((sum, bucket) => sum + bucket.count, 0)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={18} style={{ display: "flex" }}>
|
||||
<Card
|
||||
title="生时校正统计"
|
||||
extra={(
|
||||
<Segmented
|
||||
value={weeks}
|
||||
options={TELEMETRY_SUMMARY_WEEK_OPTIONS.map((value) => ({ label: `近 ${value} 周`, value }))}
|
||||
onChange={(value) => setState({ status: "loading", weeks: Number(value) })}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
匿名汇总:每个校正会话在第一次给出范围卡时记一行,只有数字和类别,不含用户、出生资料或对话内容。
|
||||
这里只看汇总,不能逐条查看或导出;数据保留 180 天。
|
||||
{summary?.since ? ` 当前统计从 ${summary.since} 那一周起。` : ""}
|
||||
</Typography.Paragraph>
|
||||
{state.status === "error" ? (
|
||||
<Alert style={{ marginTop: 12 }} type="warning" showIcon message="统计暂时不可用" description={state.message} />
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
{state.status === "loading" ? (
|
||||
<Card><Spin /></Card>
|
||||
) : null}
|
||||
|
||||
{summary ? (
|
||||
<>
|
||||
<Card title="概览">
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={12} md={6}><Statistic title="校正会话" value={summary.sessions} /></Col>
|
||||
<Col xs={12} md={6}>
|
||||
<Statistic
|
||||
title="最终范围宽度(中位 / 90%)"
|
||||
value={`${formatNumber(summary.medians.rangeWidthMinutesP50)} / ${formatNumber(summary.medians.rangeWidthMinutesP90)}`}
|
||||
suffix="分钟"
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<Statistic
|
||||
title="提问数(中位 / 90%)"
|
||||
value={`${formatNumber(summary.medians.questionsTotalP50)} / ${formatNumber(summary.medians.questionsTotalP90)}`}
|
||||
suffix="题"
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<Statistic
|
||||
title="精度门槛达标率"
|
||||
value={formatPercent(share(summary.precisionGate.met, gateKnown))}
|
||||
suffix={gateKnown > 0 ? `(${summary.precisionGate.met}/${gateKnown})` : undefined}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} md={6}><Statistic title="用时(中位)" value={formatMinutes(summary.medians.durationSecondsP50)} /></Col>
|
||||
<Col xs={12} md={6}><Statistic title="补充经历(中位)" value={formatNumber(summary.medians.experiencesAddedP50, " 件")} /></Col>
|
||||
<Col xs={12} md={6}><Statistic title="候选分钟数(中位)" value={formatNumber(summary.medians.candidateCountP50)} /></Col>
|
||||
<Col xs={12} md={6}><Statistic title="卡上显示百分比" value={formatPercent(share(percentShown, total))} /></Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Row gutter={[18, 18]}>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title="最终范围宽度分布">
|
||||
<BucketTable rows={summary.widthDistribution} labels={TELEMETRY_WIDTH_BUCKET_LABEL} total={total} emptyText="暂无数据" />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title="提问数分布">
|
||||
<BucketTable rows={summary.questionDistribution} labels={TELEMETRY_QUESTION_BUCKET_LABEL} total={total} emptyText="暂无数据" />
|
||||
<Typography.Paragraph type="secondary" style={{ margin: "12px 0 0" }}>
|
||||
每个会话平均:定向 {formatNumber(summary.questionTypes.targeted)} · 引导 {formatNumber(summary.questionTypes.guided)}
|
||||
{" "}· 带年月探针 {formatNumber(summary.questionTypes.datedProbe)} · 性格 {formatNumber(summary.questionTypes.personality)}
|
||||
{" "}· 开放 {formatNumber(summary.questionTypes.open)} · 其他 {formatNumber(summary.questionTypes.other)}。
|
||||
问过引导题的会话 {summary.questionTypes.guidedAskedSessions} 个。
|
||||
</Typography.Paragraph>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title="停止原因">
|
||||
<BucketTable keyTitle="原因" rows={summary.stopReasons} labels={TELEMETRY_STOP_REASON_LABEL} total={total} emptyText="暂无数据" />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title="第一名与第二名差距">
|
||||
<BucketTable rows={summary.gapDistribution} labels={TELEMETRY_GAP_BUCKET_LABEL} total={total} emptyText="暂无数据" />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card title="按周趋势">
|
||||
<Table<TelemetryWeek>
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey="week"
|
||||
dataSource={[...summary.weekly]}
|
||||
scroll={{ x: 640 }}
|
||||
columns={[
|
||||
{ title: "周(周一起)", dataIndex: "week" },
|
||||
{ title: "会话", dataIndex: "sessions" },
|
||||
{ title: "宽度中位", dataIndex: "rangeWidthMinutesP50", render: (value: number | null) => formatNumber(value, " 分钟") },
|
||||
{ title: "提问中位", dataIndex: "questionsTotalP50", render: (value: number | null) => formatNumber(value, " 题") },
|
||||
{
|
||||
title: "门槛达标",
|
||||
render: (_, row) => row.precisionGateKnown > 0
|
||||
? `${formatPercent(share(row.precisionGateMet, row.precisionGateKnown))}(${row.precisionGateMet}/${row.precisionGateKnown})`
|
||||
: "—",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Row gutter={[18, 18]}>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title="出生时间来源">
|
||||
<BucketTable keyTitle="来源" rows={summary.birthTimeSources} labels={TELEMETRY_BIRTH_TIME_SOURCE_LABEL} total={total} emptyText="暂无数据" />
|
||||
<Typography.Paragraph type="secondary" style={{ margin: "12px 0 0" }}>
|
||||
起始窗口半径中位 {formatNumber(summary.medians.windowRadiusMinutesP50, " 分钟")}。
|
||||
</Typography.Paragraph>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title="算法与策略版本">
|
||||
<Table<TelemetryVersionRow>
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey={(row) => `${row.algorithmVersion}|${row.policyVersion}|${row.skillVersion}`}
|
||||
dataSource={[...summary.versions]}
|
||||
locale={{ emptyText: "暂无数据" }}
|
||||
columns={[
|
||||
{ title: "算法", dataIndex: "algorithmVersion", render: (value: string | null) => value ?? "—" },
|
||||
{ title: "策略", dataIndex: "policyVersion", render: (value: string | null) => value ?? "—" },
|
||||
{ title: "Skill", dataIndex: "skillVersion", render: (value: string | null) => value ?? "—" },
|
||||
{ title: "会话", dataIndex: "count", width: 72 },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
) : null}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user