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:
Jesse_Chen
2026-09-26 15:36:29 +08:00
co-authored by Claude Opus 5.5
parent f74825a27c
commit d0bfc1fc3d
27 changed files with 2422 additions and 6 deletions
@@ -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>
);
}