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
253 lines
10 KiB
TypeScript
253 lines
10 KiB
TypeScript
"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>
|
||
);
|
||
}
|