diff --git a/frontend/components/dashboard/scan-terminal/TrainingDashboard.tsx b/frontend/components/dashboard/scan-terminal/TrainingDashboard.tsx index 65ac737c..6da129b2 100644 --- a/frontend/components/dashboard/scan-terminal/TrainingDashboard.tsx +++ b/frontend/components/dashboard/scan-terminal/TrainingDashboard.tsx @@ -56,10 +56,20 @@ type DebSummaryPayload = { versions?: Record; }; +type DebRecentStrategy = { + recent_7d?: DebWindowSummary; + recent_14d?: DebWindowSummary; + trust_tier?: "high" | "medium" | "low" | "insufficient" | string; + recommendation?: "primary" | "supporting" | "context_only" | "insufficient" | string; + bias_direction?: "under" | "over" | "neutral" | "unknown" | string; + reason?: string; +}; + type TrainingCity = { city_id: string; name: string; deb?: MetricPayload; + deb_recent?: DebRecentStrategy | null; mu?: MetricPayload; }; @@ -87,6 +97,27 @@ function barColor(hr: number) { return "#dc2626"; } +function trustBadgeClass(tier?: string) { + if (tier === "high") return "border-emerald-200 bg-emerald-50 text-emerald-700"; + if (tier === "medium") return "border-amber-200 bg-amber-50 text-amber-700"; + if (tier === "low") return "border-rose-200 bg-rose-50 text-rose-700"; + return "border-slate-200 bg-slate-50 text-slate-500"; +} + +function trustLabel(tier: string | undefined, isEn: boolean) { + if (tier === "high") return isEn ? "High" : "高"; + if (tier === "medium") return isEn ? "Medium" : "中"; + if (tier === "low") return isEn ? "Low" : "低"; + return isEn ? "Thin" : "少"; +} + +function recommendationLabel(value: string | undefined, isEn: boolean) { + if (value === "primary") return isEn ? "Primary" : "主用"; + if (value === "supporting") return isEn ? "Support" : "辅助"; + if (value === "context_only") return isEn ? "Context" : "参考"; + return isEn ? "Insufficient" : "样本少"; +} + const TRAINING_CACHE_KEY = "polyweather_training_accuracy_v1"; const TRAINING_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours @@ -322,12 +353,15 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) { )} {/* ── Combined Table ── */} -
- +
+
+ + + @@ -338,12 +372,12 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) { {debSorted.length || muSorted.length ? ( (() => { - const cities = new Map(); - for (const c of debSorted) cities.set(c.city_id, { deb: c.deb, mu: c.mu, name: c.name }); + const cities = new Map(); + for (const c of debSorted) cities.set(c.city_id, { deb: c.deb, debRecent: c.deb_recent, mu: c.mu, name: c.name }); for (const c of muSorted) { const existing = cities.get(c.city_id); if (existing) existing.mu = c.mu; - else cities.set(c.city_id, { deb: c.deb, mu: c.mu, name: c.name }); + else cities.set(c.city_id, { deb: c.deb, debRecent: c.deb_recent, mu: c.mu, name: c.name }); } const merged = [...cities.entries()] .sort((a, b) => { @@ -352,14 +386,30 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) { return bMax - aMax; }) .slice(0, 30); - return merged.map(([cityId, { deb, mu, name }], i) => { + return merged.map(([cityId, { deb, debRecent, mu, name }], i) => { const debHit = deb?.hit_rate ?? 0; const muHit = mu?.hit_rate ?? 0; const brier = mu?.brier_score; + const recent7 = debRecent?.recent_7d?.hit_rate; + const recent14 = debRecent?.recent_14d?.hit_rate; return ( + + + @@ -375,7 +425,7 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) { })() ) : ( - diff --git a/frontend/components/ops/training/TrainingPageClient.tsx b/frontend/components/ops/training/TrainingPageClient.tsx index df761cfb..348d2564 100644 --- a/frontend/components/ops/training/TrainingPageClient.tsx +++ b/frontend/components/ops/training/TrainingPageClient.tsx @@ -56,6 +56,22 @@ interface CityAccuracy { total_days: number; details_str: string; } | null; + deb_recent?: { + recent_7d?: { + hit_rate?: number | null; + samples?: number; + mae?: number | null; + }; + recent_14d?: { + hit_rate?: number | null; + samples?: number; + mae?: number | null; + }; + trust_tier?: string; + recommendation?: string; + bias_direction?: string; + reason?: string; + } | null; mu?: { mae: number; hit_rate: number; @@ -87,6 +103,31 @@ interface DebSummary { }; } +function debTrustBadgeClass(tier?: string) { + if (tier === "high") return "bg-emerald-500/15 text-emerald-300 border-emerald-500/30"; + if (tier === "medium") return "bg-amber-500/15 text-amber-300 border-amber-500/30"; + if (tier === "low") return "bg-rose-500/15 text-rose-300 border-rose-500/30"; + return "bg-slate-500/15 text-slate-300 border-slate-500/30"; +} + +function debTrustLabel(tier?: string) { + if (tier === "high") return "高可信"; + if (tier === "medium") return "中可信"; + if (tier === "low") return "低可信"; + return "样本少"; +} + +function debRecommendationLabel(recommendation?: string) { + if (recommendation === "primary") return "主用"; + if (recommendation === "supporting") return "辅助"; + if (recommendation === "context_only") return "仅参考"; + return "不足"; +} + +function formatPct(value: number | null | undefined) { + return value == null ? "—" : `${value.toFixed(0)}%`; +} + export function TrainingPageClient() { const [loading, setLoading] = useState(true); const [status, setStatus] = useState(null); @@ -279,6 +320,8 @@ export function TrainingPageClient() { + + @@ -296,6 +339,29 @@ export function TrainingPageClient() { {row.name} {row.city_id} + + diff --git a/frontend/lib/ops-api.ts b/frontend/lib/ops-api.ts index d1b5659e..5263446a 100644 --- a/frontend/lib/ops-api.ts +++ b/frontend/lib/ops-api.ts @@ -157,6 +157,32 @@ export const opsApi = { hits?: number; details_str: string; } | null; + deb_recent?: { + recent_7d?: { + start_date?: string | null; + end_date?: string | null; + samples?: number; + hits?: number; + hit_rate?: number | null; + mae?: number | null; + bias?: number | null; + city_count?: number; + }; + recent_14d?: { + start_date?: string | null; + end_date?: string | null; + samples?: number; + hits?: number; + hit_rate?: number | null; + mae?: number | null; + bias?: number | null; + city_count?: number; + }; + trust_tier?: string; + recommendation?: string; + bias_direction?: string; + reason?: string; + } | null; mu?: { mae: number; hit_rate: number; diff --git a/tests/test_ops_training_accuracy.py b/tests/test_ops_training_accuracy.py index 3138539c..c070a9c2 100644 --- a/tests/test_ops_training_accuracy.py +++ b/tests/test_ops_training_accuracy.py @@ -39,6 +39,56 @@ def test_training_accuracy_payload_includes_recent_deb_summary(): assert "deb_v2_bucket_calibrated" in payload["deb_summary"]["versions"] +def test_training_accuracy_payload_includes_city_deb_trust_strategy(): + history = { + "alpha": { + "2026-06-01": {"actual_high": 20.0, "deb_prediction": 20.1}, + "2026-06-02": {"actual_high": 21.0, "deb_prediction": 21.1}, + "2026-06-03": {"actual_high": 22.0, "deb_prediction": 22.2}, + "2026-06-04": {"actual_high": 23.0, "deb_prediction": 23.0}, + "2026-06-05": {"actual_high": 24.0, "deb_prediction": 24.1}, + }, + "beta": { + "2026-06-01": {"actual_high": 30.0, "deb_prediction": 28.0}, + "2026-06-02": {"actual_high": 31.0, "deb_prediction": 29.0}, + "2026-06-03": {"actual_high": 32.0, "deb_prediction": 30.0}, + "2026-06-04": {"actual_high": 33.0, "deb_prediction": 31.0}, + "2026-06-05": {"actual_high": 34.0, "deb_prediction": 32.0}, + }, + "gamma": { + "2026-06-05": {"actual_high": 25.0, "deb_prediction": 25.1}, + }, + } + registry = { + "alpha": {"name": "Alpha"}, + "beta": {"name": "Beta"}, + "gamma": {"name": "Gamma"}, + } + + payload = _build_training_accuracy_payload( + history, + registry, + today_str="2026-06-07", + ) + + rows = {row["city_id"]: row for row in payload["accuracy"]} + + assert rows["alpha"]["deb_recent"]["recent_7d"]["samples"] == 5 + assert rows["alpha"]["deb_recent"]["recent_7d"]["hit_rate"] == 100.0 + assert rows["alpha"]["deb_recent"]["trust_tier"] == "high" + assert rows["alpha"]["deb_recent"]["recommendation"] == "primary" + assert rows["alpha"]["deb_recent"]["bias_direction"] == "neutral" + + assert rows["beta"]["deb_recent"]["recent_14d"]["samples"] == 5 + assert rows["beta"]["deb_recent"]["trust_tier"] == "low" + assert rows["beta"]["deb_recent"]["recommendation"] == "context_only" + assert rows["beta"]["deb_recent"]["bias_direction"] == "under" + assert "recent_14d" in rows["beta"]["deb_recent"]["reason"] + + assert rows["gamma"]["deb_recent"]["trust_tier"] == "insufficient" + assert rows["gamma"]["deb_recent"]["recommendation"] == "insufficient" + + def test_training_accuracy_payload_caps_version_backtest_samples(monkeypatch): start = date(2025, 1, 1) history = { diff --git a/web/services/ops_api.py b/web/services/ops_api.py index e1734db6..34ef3097 100644 --- a/web/services/ops_api.py +++ b/web/services/ops_api.py @@ -2280,8 +2280,12 @@ def _evaluate_deb_records( } -def _build_city_deb_accuracy(city_id: str, city_rows: Dict[str, Dict[str, Any]], today_str: str) -> Optional[Dict[str, Any]]: - rows = [] +def _build_city_deb_rows( + city_id: str, + city_rows: Dict[str, Dict[str, Any]], + today_str: str, +) -> List[Dict[str, Any]]: + rows: List[Dict[str, Any]] = [] for target_date, record in sorted((city_rows or {}).items()): if target_date >= today_str or not isinstance(record, dict): continue @@ -2293,6 +2297,78 @@ def _build_city_deb_accuracy(city_id: str, city_rows: Dict[str, Dict[str, Any]], "deb_prediction": record.get("deb_prediction"), } ) + return rows + + +def _deb_recent_bias_direction(bias: Optional[float]) -> str: + if bias is None: + return "unknown" + if bias <= -0.5: + return "under" + if bias >= 0.5: + return "over" + return "neutral" + + +def _build_deb_recent_trust_strategy( + recent_7d: Dict[str, Any], + recent_14d: Dict[str, Any], +) -> Dict[str, Any]: + window_key = "recent_14d" if int(recent_14d.get("samples") or 0) >= int(recent_7d.get("samples") or 0) else "recent_7d" + metrics = recent_14d if window_key == "recent_14d" else recent_7d + samples = int(metrics.get("samples") or 0) + hit_rate = _sf(metrics.get("hit_rate")) + mae = _sf(metrics.get("mae")) + bias = _sf(metrics.get("bias")) + + if samples < 3 or hit_rate is None or mae is None: + trust_tier = "insufficient" + recommendation = "insufficient" + reason = f"{window_key}: only {samples} settled DEB samples." + elif hit_rate >= 67.0 and mae <= 1.25: + trust_tier = "high" + recommendation = "primary" + reason = f"{window_key}: {hit_rate:.1f}% hit rate, MAE {mae:.2f}°." + elif hit_rate >= 34.0 and mae <= 1.75: + trust_tier = "medium" + recommendation = "supporting" + reason = f"{window_key}: {hit_rate:.1f}% hit rate, MAE {mae:.2f}°." + else: + trust_tier = "low" + recommendation = "context_only" + reason = f"{window_key}: {hit_rate:.1f}% hit rate, MAE {mae:.2f}°." + + return { + "trust_tier": trust_tier, + "recommendation": recommendation, + "bias_direction": _deb_recent_bias_direction(bias), + "reason": reason, + } + + +def _build_city_deb_recent_strategy( + city_id: str, + city_rows: Dict[str, Dict[str, Any]], + today_str: str, +) -> Optional[Dict[str, Any]]: + today_date = datetime.strptime(today_str, "%Y-%m-%d").date() + recent_7_start = (today_date - timedelta(days=7)).isoformat() + recent_14_start = (today_date - timedelta(days=14)).isoformat() + end_date = (today_date - timedelta(days=1)).isoformat() + rows = _build_city_deb_rows(city_id, city_rows, today_str) + if not rows: + return None + recent_7d = _evaluate_deb_records(rows, start_date=recent_7_start, end_date=end_date) + recent_14d = _evaluate_deb_records(rows, start_date=recent_14_start, end_date=end_date) + return { + "recent_7d": recent_7d, + "recent_14d": recent_14d, + **_build_deb_recent_trust_strategy(recent_7d, recent_14d), + } + + +def _build_city_deb_accuracy(city_id: str, city_rows: Dict[str, Dict[str, Any]], today_str: str) -> Optional[Dict[str, Any]]: + rows = _build_city_deb_rows(city_id, city_rows, today_str) metrics = _evaluate_deb_records(rows) if not metrics["samples"]: return None @@ -2433,6 +2509,7 @@ def _build_training_accuracy_payload( for city_id, info in (city_registry or {}).items(): city_rows = history.get(city_id) or history.get(str(city_id).strip().lower()) or {} deb_payload = _build_city_deb_accuracy(city_id, city_rows, today) + deb_recent = _build_city_deb_recent_strategy(city_id, city_rows, today) if deb_payload else None mu_payload = _build_city_mu_accuracy(city_id, city_rows, today) if deb_payload or mu_payload: accuracy_data.append( @@ -2440,6 +2517,7 @@ def _build_training_accuracy_payload( "city_id": city_id, "name": (info or {}).get("name") or city_id, "deb": deb_payload, + "deb_recent": deb_recent, "mu": mu_payload, } )
# {isEn ? "City" : "城市"}{isEn ? "DEB Trust" : "DEB 信任"}{isEn ? "7d" : "7天"}{isEn ? "14d" : "14天"} {isEn ? "DEB Hit" : "DEB 命中"} {isEn ? "DEB Error" : "DEB 误差"} {isEn ? "μ Hit" : "μ 命中"}
{i + 1} {name} + {debRecent ? ( + + {trustLabel(debRecent.trust_tier, isEn)} · {recommendationLabel(debRecent.recommendation, isEn)} + + ) : ( + -- + )} + {recent7 == null ? "--" : `${recent7.toFixed(0)}%`}{recent14 == null ? "--" : `${recent14.toFixed(0)}%`} {deb ? `${debHit.toFixed(0)}%` : "--"}
+ {data === null ? (isEn ? "Loading..." : "加载中...") : (isEn ? "No training data" : "暂无训练数据")}
城市DEB 策略近 7 / 14 天 DEB 命中 DEB MAE DEB 天数 + {row.deb_recent ? ( + + {debTrustLabel(row.deb_recent.trust_tier)} · {debRecommendationLabel(row.deb_recent.recommendation)} + + ) : ( + + )} + + {row.deb_recent ? ( + + {formatPct(row.deb_recent.recent_7d?.hit_rate)} + / + {formatPct(row.deb_recent.recent_14d?.hit_rate)} + + ) : ( + "—" + )} + {row.deb ? ( - + 无有效准确率记录