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); + }); +}