Files
Jyotisha/frontend/src/components/admin/rectification-telemetry-summary.tsx
T
Jesse_ChenandClaude Opus 5.5 d0bfc1fc3d 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
2026-09-26 15:36:29 +08:00

253 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}