From 956cb8a8e34b41dfe1671f73cd8c20b534779a7f Mon Sep 17 00:00:00 2001
From: "2569718930@qq.com" <2569718930@qq.com>
Date: Mon, 8 Jun 2026 13:37:20 +0800
Subject: [PATCH] Use usable recent DEB training rankings
---
.../scan-terminal/TrainingDashboard.tsx | 28 +++++--
.../trainingDashboardDebRanking.test.ts | 49 +++++++++++
.../opsTrainingDebRecentRanking.test.ts | 49 +++++++++++
.../ops/training/TrainingAccuracyCharts.tsx | 8 +-
.../ops/training/TrainingPageClient.tsx | 39 ++++++---
frontend/lib/deb-training-ranking.ts | 84 +++++++++++++++++++
6 files changed, 236 insertions(+), 21 deletions(-)
create mode 100644 frontend/components/dashboard/scan-terminal/__tests__/trainingDashboardDebRanking.test.ts
create mode 100644 frontend/components/ops/__tests__/opsTrainingDebRecentRanking.test.ts
create mode 100644 frontend/lib/deb-training-ranking.ts
diff --git a/frontend/components/dashboard/scan-terminal/TrainingDashboard.tsx b/frontend/components/dashboard/scan-terminal/TrainingDashboard.tsx
index 8a882d6b..74b94e83 100644
--- a/frontend/components/dashboard/scan-terminal/TrainingDashboard.tsx
+++ b/frontend/components/dashboard/scan-terminal/TrainingDashboard.tsx
@@ -11,6 +11,7 @@ import {
YAxis,
} from "recharts";
import { TrendingUp, Target, Thermometer, Hash, BarChart3, Crosshair } from "lucide-react";
+import { buildDebRecentRankingRows } from "@/lib/deb-training-ranking";
type MetricPayload = {
hit_rate: number;
@@ -178,6 +179,11 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) {
const data = payload?.accuracy ?? null;
const debSummary = payload?.deb_summary;
const debSorted = useMemo(() => (data || []).filter((c) => c.deb).sort((a, b) => (b.deb?.hit_rate ?? 0) - (a.deb?.hit_rate ?? 0)), [data]);
+ const debRecentRanked = useMemo(() => buildDebRecentRankingRows(data || []), [data]);
+ const debRecentRankIndex = useMemo(
+ () => new Map(debRecentRanked.map((row, index) => [row.cityId, index])),
+ [debRecentRanked],
+ );
const muSorted = useMemo(() => (data || []).filter((c) => c.mu).sort((a, b) => (b.mu?.hit_rate ?? 0) - (a.mu?.hit_rate ?? 0)), [data]);
const debStats = useMemo(() => {
@@ -206,12 +212,19 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) {
}, [muSorted]);
const debHitChart = useMemo(
- () => debSorted.slice(0, 18).map((c) => ({ name: c.name, value: Number((c.deb?.hit_rate ?? 0).toFixed(1)) })),
- [debSorted],
+ () => debRecentRanked.slice(0, 18).map((c) => ({ name: c.name, value: c.hitRate })),
+ [debRecentRanked],
);
const debMaeChart = useMemo(
- () => [...debSorted].sort((a, b) => (a.deb?.mae ?? 99) - (b.deb?.mae ?? 99)).slice(0, 18).map((c) => ({ name: c.name, value: Number((c.deb?.mae ?? 0).toFixed(2)) })),
- [debSorted],
+ () => [...debRecentRanked]
+ .sort((a, b) => {
+ if (a.usableScore !== b.usableScore) return b.usableScore - a.usableScore;
+ if (a.trustScore !== b.trustScore) return b.trustScore - a.trustScore;
+ return a.mae - b.mae;
+ })
+ .slice(0, 18)
+ .map((c) => ({ name: c.name, value: c.mae })),
+ [debRecentRanked],
);
const muHitChart = useMemo(
() => muSorted.slice(0, 18).map((c) => ({ name: c.name, value: Number((c.mu?.hit_rate ?? 0).toFixed(1)) })),
@@ -296,7 +309,7 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) {
) : null}
-
+
@@ -307,7 +320,7 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) {
-
+
@@ -401,6 +414,9 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) {
}
const merged = [...cities.entries()]
.sort((a, b) => {
+ const aDebRank = debRecentRankIndex.get(a[0]) ?? 9999;
+ const bDebRank = debRecentRankIndex.get(b[0]) ?? 9999;
+ if (aDebRank !== bDebRank) return aDebRank - bDebRank;
const aMax = Math.max(a[1].deb?.hit_rate ?? 0, a[1].mu?.hit_rate ?? 0);
const bMax = Math.max(b[1].deb?.hit_rate ?? 0, b[1].mu?.hit_rate ?? 0);
return bMax - aMax;
diff --git a/frontend/components/dashboard/scan-terminal/__tests__/trainingDashboardDebRanking.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/trainingDashboardDebRanking.test.ts
new file mode 100644
index 00000000..f606a860
--- /dev/null
+++ b/frontend/components/dashboard/scan-terminal/__tests__/trainingDashboardDebRanking.test.ts
@@ -0,0 +1,49 @@
+import { buildDebRecentRankingRows } from "@/lib/deb-training-ranking";
+
+function assert(condition: unknown, message: string) {
+ if (!condition) throw new Error(message);
+}
+
+export function runTests() {
+ const rows = buildDebRecentRankingRows([
+ {
+ city_id: "legacy",
+ name: "Legacy Good",
+ deb: { hit_rate: 95, mae: 0.9, total_days: 40 },
+ deb_recent: {
+ recent_7d: { hit_rate: 0, mae: 2.5, samples: 4 },
+ recent_14d: { hit_rate: 15, mae: 2.1, samples: 8 },
+ trust_tier: "low",
+ recommendation: "context_only",
+ },
+ },
+ {
+ city_id: "usable",
+ name: "Usable Recent",
+ deb: { hit_rate: 40, mae: 1.4, total_days: 20 },
+ deb_recent: {
+ recent_7d: { hit_rate: 75, mae: 0.7, samples: 4 },
+ recent_14d: { hit_rate: 70, mae: 0.8, samples: 8 },
+ trust_tier: "high",
+ recommendation: "primary",
+ },
+ },
+ {
+ city_id: "support",
+ name: "Support Recent",
+ deb: { hit_rate: 35, mae: 1.5, total_days: 20 },
+ deb_recent: {
+ recent_7d: { hit_rate: 50, mae: 1.2, samples: 4 },
+ recent_14d: { hit_rate: 50, mae: 1.2, samples: 8 },
+ trust_tier: "medium",
+ recommendation: "supporting",
+ },
+ },
+ ] as any);
+
+ assert(rows[0].cityId === "usable", "high-trust recent DEB city should rank first");
+ assert(rows[1].cityId === "support", "supporting recent DEB city should rank before low-trust legacy hit rate");
+ assert(rows[2].cityId === "legacy", "low-trust historical performer should rank after usable recent cities");
+ assert(rows[0].hitRate === 75, "ranking rows should use recent hit rate for chart value");
+ assert(rows[0].mae === 0.7, "ranking rows should use recent MAE for chart value");
+}
diff --git a/frontend/components/ops/__tests__/opsTrainingDebRecentRanking.test.ts b/frontend/components/ops/__tests__/opsTrainingDebRecentRanking.test.ts
new file mode 100644
index 00000000..099be6a1
--- /dev/null
+++ b/frontend/components/ops/__tests__/opsTrainingDebRecentRanking.test.ts
@@ -0,0 +1,49 @@
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import path from "node:path";
+
+function readFrontend(...parts: string[]) {
+ return fs.readFileSync(path.join(process.cwd(), ...parts), "utf8");
+}
+
+export function runTests() {
+ const trainingPage = readFrontend("components", "ops", "training", "TrainingPageClient.tsx");
+ const charts = readFrontend("components", "ops", "training", "TrainingAccuracyCharts.tsx");
+
+ assert.match(
+ trainingPage,
+ /buildDebRecentRankingRows/,
+ "ops training page should share the DEB usable-recent ranking helper with the terminal dashboard",
+ );
+ assert.match(
+ trainingPage,
+ /debRecentRanked/,
+ "ops training page should build chart rows from the usable-recent DEB ranking",
+ );
+ assert.match(
+ trainingPage,
+ /debRecentRankIndex/,
+ "ops training detail table should follow the same usable-recent DEB order before falling back to historical scores",
+ );
+ assert.doesNotMatch(
+ trainingPage,
+ /sort\(\s*\(a,\s*b\)\s*=>\s*\(\(b\.deb\?\.hit_rate/,
+ "ops training DEB chart must not regress to sorting by long-term historical hit rate",
+ );
+
+ assert.match(
+ charts,
+ /DEB 可用近期命中率 by 城市/,
+ "ops DEB hit-rate chart should label the metric as usable recent accuracy",
+ );
+ assert.match(
+ charts,
+ /DEB 可用近期 MAE by 城市/,
+ "ops DEB MAE chart should label the metric as usable recent MAE",
+ );
+ assert.match(
+ charts,
+ /可用近期命中率/,
+ "ops DEB hit-rate tooltip should describe the usable recent metric",
+ );
+}
diff --git a/frontend/components/ops/training/TrainingAccuracyCharts.tsx b/frontend/components/ops/training/TrainingAccuracyCharts.tsx
index 75e8aa6a..b675ee03 100644
--- a/frontend/components/ops/training/TrainingAccuracyCharts.tsx
+++ b/frontend/components/ops/training/TrainingAccuracyCharts.tsx
@@ -68,7 +68,7 @@ export function TrainingAccuracyCharts({
{debChartData.length > 0 ? (
- DEB 命中率 by 城市
+ DEB 可用近期命中率 by 城市
@@ -78,7 +78,7 @@ export function TrainingAccuracyCharts({
[`${Number(value).toFixed(1)}%`, "命中率"]}
+ formatter={(value: unknown) => [`${Number(value).toFixed(1)}%`, "可用近期命中率"]}
/>
{debChartData.map((entry, i) => (
@@ -93,7 +93,7 @@ export function TrainingAccuracyCharts({
- DEB MAE by 城市
+ DEB 可用近期 MAE by 城市
@@ -103,7 +103,7 @@ export function TrainingAccuracyCharts({
[`${Number(value).toFixed(1)}°`, "MAE"]}
+ formatter={(value: unknown) => [`${Number(value).toFixed(1)}°`, "可用近期 MAE"]}
/>
{debChartData.map((entry, i) => (
diff --git a/frontend/components/ops/training/TrainingPageClient.tsx b/frontend/components/ops/training/TrainingPageClient.tsx
index 1fd6ffb8..fb6a0ecd 100644
--- a/frontend/components/ops/training/TrainingPageClient.tsx
+++ b/frontend/components/ops/training/TrainingPageClient.tsx
@@ -6,6 +6,7 @@ import { RefreshCcw, TrendingUp, TrendingDown, Target, Activity } from "lucide-r
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
+import { buildDebRecentRankingRows } from "@/lib/deb-training-ranking";
import { opsApi } from "@/lib/ops-api";
import type { SystemStatusPayload } from "@/types/ops";
import Link from "next/link";
@@ -183,19 +184,23 @@ export function TrainingPageClient() {
const usableWindowLabel = (window?: string) =>
window === "recent_14d" ? "DEB 可用近 14 天命中" : "DEB 可用近 7 天命中";
+ const debRecentRanked = useMemo(() => buildDebRecentRankingRows(accuracy || []), [accuracy]);
+ const debRecentRankIndex = useMemo(
+ () => new Map(debRecentRanked.map((row, index) => [row.cityId, index])),
+ [debRecentRanked],
+ );
+
const debChartData = useMemo(() => {
- if (!accuracy?.length) return [];
- return accuracy
- .filter((c) => c.deb && c.deb.total_days >= 5)
- .sort((a, b) => (b.deb?.hit_rate ?? 0) - (a.deb?.hit_rate ?? 0))
+ return debRecentRanked
+ .slice(0, 24)
.map((c) => ({
name: c.name,
- cityId: c.city_id,
- hitRate: Number((c.deb?.hit_rate ?? 0).toFixed(1)),
- mae: Number((c.deb?.mae ?? 0).toFixed(1)),
- days: c.deb?.total_days ?? 0,
+ cityId: c.cityId,
+ hitRate: c.hitRate,
+ mae: c.mae,
+ days: c.samples,
}));
- }, [accuracy]);
+ }, [debRecentRanked]);
const muChartData = useMemo(() => {
if (!accuracy?.length) return [];
@@ -212,6 +217,18 @@ export function TrainingPageClient() {
}));
}, [accuracy]);
+ const sortedAccuracy = useMemo(() => {
+ if (!accuracy?.length) return [];
+ return [...accuracy].sort((a, b) => {
+ const aDebRank = debRecentRankIndex.get(a.city_id) ?? 9999;
+ const bDebRank = debRecentRankIndex.get(b.city_id) ?? 9999;
+ if (aDebRank !== bDebRank) return aDebRank - bDebRank;
+ const aMax = Math.max(a.deb?.hit_rate ?? 0, a.mu?.hit_rate ?? 0);
+ const bMax = Math.max(b.deb?.hit_rate ?? 0, b.mu?.hit_rate ?? 0);
+ return bMax - aMax;
+ });
+ }, [accuracy, debRecentRankIndex]);
+
if (loading) return 加载中...
;
if (!status) return 加载失败
;
@@ -350,8 +367,8 @@ export function TrainingPageClient() {
- {accuracy && accuracy.length > 0 ? (
- accuracy.map((row) => (
+ {sortedAccuracy.length > 0 ? (
+ sortedAccuracy.map((row) => (
|
{row.name}
diff --git a/frontend/lib/deb-training-ranking.ts b/frontend/lib/deb-training-ranking.ts
new file mode 100644
index 00000000..2c8eee4c
--- /dev/null
+++ b/frontend/lib/deb-training-ranking.ts
@@ -0,0 +1,84 @@
+export type DebTrainingMetric = {
+ hit_rate?: number | null;
+ mae?: number | null;
+ total_days?: number;
+};
+
+export type DebTrainingWindow = {
+ hit_rate?: number | null;
+ mae?: number | null;
+ samples?: number;
+};
+
+export type DebTrainingRecent = {
+ recent_7d?: DebTrainingWindow | null;
+ recent_14d?: DebTrainingWindow | null;
+ trust_tier?: string | null;
+ recommendation?: string | null;
+};
+
+export type DebTrainingCity = {
+ city_id: string;
+ name: string;
+ deb?: DebTrainingMetric | null;
+ deb_recent?: DebTrainingRecent | null;
+};
+
+export type DebRecentRankingRow = {
+ cityId: string;
+ name: string;
+ hitRate: number;
+ mae: number;
+ samples: number;
+ trustScore: number;
+ usableScore: number;
+};
+
+function trustScore(tier?: string | null) {
+ if (tier === "high") return 3;
+ if (tier === "medium") return 2;
+ if (tier === "low") return 1;
+ return 0;
+}
+
+function usableScore(recommendation?: string | null) {
+ if (recommendation === "primary") return 2;
+ if (recommendation === "supporting") return 1;
+ return 0;
+}
+
+function selectRecentWindow(recent?: DebTrainingRecent | null): DebTrainingWindow | null {
+ const recent7 = recent?.recent_7d || null;
+ if (Number(recent7?.samples || 0) > 0) return recent7;
+ const recent14 = recent?.recent_14d || null;
+ if (Number(recent14?.samples || 0) > 0) return recent14;
+ return null;
+}
+
+export function buildDebRecentRankingRows(cities: DebTrainingCity[]): DebRecentRankingRow[] {
+ return (cities || [])
+ .filter((city) => city.deb)
+ .map((city) => {
+ const recentWindow = selectRecentWindow(city.deb_recent);
+ return {
+ cityId: city.city_id,
+ name: city.name,
+ hitRate: Number(
+ (recentWindow?.hit_rate ?? city.deb?.hit_rate ?? 0).toFixed(1),
+ ),
+ mae: Number(
+ (recentWindow?.mae ?? city.deb?.mae ?? 0).toFixed(2),
+ ),
+ samples: Number(recentWindow?.samples || 0),
+ trustScore: trustScore(city.deb_recent?.trust_tier),
+ usableScore: usableScore(city.deb_recent?.recommendation),
+ };
+ })
+ .sort((a, b) => {
+ if (a.usableScore !== b.usableScore) return b.usableScore - a.usableScore;
+ if (a.trustScore !== b.trustScore) return b.trustScore - a.trustScore;
+ if (a.hitRate !== b.hitRate) return b.hitRate - a.hitRate;
+ if (a.samples !== b.samples) return b.samples - a.samples;
+ return a.name.localeCompare(b.name);
+ });
+}
|