From e6010d824234bb6995abde635e9b32061cda27d2 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Tue, 28 Apr 2026 06:52:00 +0800 Subject: [PATCH] Make city decision cards easier to trust at a glance The decision card now exposes deterministic guard state, data freshness, AI readiness and market sync directly in the card header so users can understand whether they are looking at fresh evidence, a fallback, or a stale/exception case before reading the full explanation. Constraint: Keep the first useful read available while DeepSeek airport/HKO details are still streaming.\nRejected: Add another expanded evidence panel | header-level badges are faster to scan and avoid increasing card depth.\nConfidence: high\nScope-risk: narrow\nReversibility: clean\nTested: npm run build\nNot-tested: Browser visual QA on production data. --- CHANGELOG.md | 1 + .../components/dashboard/Dashboard.module.css | 85 +++++++- .../scan-terminal/AiPinnedForecastView.tsx | 204 +++++++++++++++++- .../dashboard/scan-terminal/types.ts | 9 + 4 files changed, 289 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c39ff63..e7449b6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - 城市决策卡兜底判断补充实测偏低和峰值窗口已过分支:峰后未追上模型时提示下修压力,峰前偏低时只提示等待确认 - 城市决策卡新增过旧 METAR/观测识别:过旧报文只作为背景参考,不再触发强实况锚点、上修或下修判断;AI 缓存键同步纳入观测时间与 stale 状态 - 城市决策卡新增 AI 结果后处理护栏:完整 DeepSeek 返回若与过旧观测、实测突破、峰后下修等确定性证据冲突,会以后端规则覆盖关键数值和结论文案 +- 城市决策卡新增状态标签与数据新鲜度提示,直接标出 AI 是否完成、市场价格是否同步、METAR/官方观测是否过旧或已突破模型区间,减少用户等待和误读 - 后端 Scan Terminal 代码拆出 `scan_city_ai_helpers.py`,将城市 AI JSON 解析、fallback 文案、schema completion 与证据护栏从主服务文件中剥离,降低后续维护成本 - 城市决策卡市场层改用完整 `all_buckets` 并严格识别 exact / range / or higher / or lower 温度桶方向,避免最高温中枢错配到不合理尾部桶 - 温度桶标签统一规范化 `C/F/°C/°F`,修复 `31°°C` 这类重复单位展示 diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css index 1220c9e1..f070e910 100644 --- a/frontend/components/dashboard/Dashboard.module.css +++ b/frontend/components/dashboard/Dashboard.module.css @@ -11330,6 +11330,68 @@ padding: 7px 10px; } +.root :global(.scan-ai-city-status-tags) { + display: flex; + flex-wrap: wrap; + gap: 7px; + margin-top: 10px; +} + +.root :global(.scan-ai-city-status-tag) { + border: 1px solid rgba(159, 178, 199, 0.16); + border-radius: 999px; + background: rgba(15, 23, 42, 0.62); + color: #cbd5e1; + font-size: 11px; + font-weight: 900; + letter-spacing: 0.01em; + padding: 5px 9px; +} + +.root :global(.scan-ai-city-status-tag.green) { + border-color: rgba(34, 197, 94, 0.34); + background: rgba(34, 197, 94, 0.12); + color: #86efac; +} + +.root :global(.scan-ai-city-status-tag.blue) { + border-color: rgba(77, 163, 255, 0.36); + background: rgba(77, 163, 255, 0.13); + color: #9ecbff; +} + +.root :global(.scan-ai-city-status-tag.amber) { + border-color: rgba(245, 158, 11, 0.38); + background: rgba(245, 158, 11, 0.13); + color: #fcd34d; +} + +.root :global(.scan-ai-city-status-tag.red) { + border-color: rgba(248, 113, 113, 0.4); + background: rgba(239, 68, 68, 0.13); + color: #fca5a5; +} + +.root :global(.scan-ai-city-status-tag.muted) { + color: #94a3b8; +} + +.root :global(.scan-ai-city-freshness) { + display: flex; + flex-wrap: wrap; + gap: 7px; + margin-top: 8px; + color: #8fa4bd; + font-size: 11px; + font-weight: 760; +} + +.root :global(.scan-ai-city-freshness span) { + border-radius: 8px; + background: rgba(148, 163, 184, 0.08); + padding: 4px 7px; +} + .root :global(.scan-ai-city-hero-side) { min-width: 180px; display: grid; @@ -13688,7 +13750,8 @@ .root :global(.scan-terminal.light .scan-ai-market-decision-stats small), .root :global(.scan-terminal.light .scan-ai-decision-metrics span), .root :global(.scan-terminal.light .scan-ai-market-bucket), -.root :global(.scan-terminal.light .scan-ai-city-pills span) { +.root :global(.scan-terminal.light .scan-ai-city-pills span), +.root :global(.scan-terminal.light .scan-ai-city-freshness span) { background: #eef2f7; border-color: #e2e8f0; } @@ -13710,6 +13773,7 @@ .root :global(.scan-terminal.light .scan-ai-city-section p), .root :global(.scan-terminal.light .scan-ai-decision-band p), .root :global(.scan-terminal.light .scan-ai-city-pills span), +.root :global(.scan-terminal.light .scan-ai-city-freshness), .root :global(.scan-terminal.light .scan-ai-decision-metrics span), .root :global(.scan-terminal.light .scan-ai-market-bucket span), .root :global(.scan-terminal.light .scan-ai-market-decision span), @@ -14174,7 +14238,8 @@ .root:global(.light) :global(.scan-terminal.light .scan-ai-decision-band), .root:global(.light) :global(.scan-terminal.light .scan-ai-market-decision), .root:global(.light) :global(.scan-terminal.light .scan-ai-decision-metrics span), -.root:global(.light) :global(.scan-terminal.light .scan-ai-city-pills span) { +.root:global(.light) :global(.scan-terminal.light .scan-ai-city-pills span), +.root:global(.light) :global(.scan-terminal.light .scan-ai-city-freshness span) { background: #eef7ff !important; border-color: rgba(37, 99, 235, 0.16) !important; } @@ -14185,3 +14250,19 @@ .root:global(.light) :global(.scan-terminal.light .scan-ai-city-card span:not(.scan-ai-city-kicker)) { color: #334155 !important; } + +.root:global(.light) :global(.scan-terminal.light .scan-ai-city-status-tag.green) { + color: #047857 !important; +} + +.root:global(.light) :global(.scan-terminal.light .scan-ai-city-status-tag.blue) { + color: #1d4ed8 !important; +} + +.root:global(.light) :global(.scan-terminal.light .scan-ai-city-status-tag.amber) { + color: #b45309 !important; +} + +.root:global(.light) :global(.scan-terminal.light .scan-ai-city-status-tag.red) { + color: #b91c1c !important; +} diff --git a/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx b/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx index 662b4465..f1c35bd2 100644 --- a/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx +++ b/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx @@ -94,6 +94,63 @@ function isHkoObservationCity(detail?: CityDetail | null) { return source === "hko"; } +type StatusTagTone = "green" | "blue" | "amber" | "red" | "muted"; + +type StatusTag = { + label: string; + tone: StatusTagTone; +}; + +function formatFreshnessAge(value: unknown, isEn: boolean) { + const minutes = Number(value); + if (!Number.isFinite(minutes) || minutes < 0) return ""; + if (minutes < 1) return isEn ? "just now" : "刚刚"; + if (minutes < 60) { + const rounded = Math.max(1, Math.round(minutes)); + return isEn ? `${rounded}m ago` : `${rounded} 分钟前`; + } + const hours = Math.floor(minutes / 60); + const remaining = Math.round(minutes % 60); + if (remaining <= 0) return isEn ? `${hours}h ago` : `${hours} 小时前`; + return isEn ? `${hours}h ${remaining}m ago` : `${hours} 小时 ${remaining} 分钟前`; +} + +function buildObservationFreshnessLabel({ + detail, + displayTime, + isEn, + isHkoObservation, +}: { + detail: CityDetail | null; + displayTime: string; + isEn: boolean; + isHkoObservation: boolean; +}) { + const source = isHkoObservation ? (isEn ? "HKO" : "天文台") : "METAR"; + const stale = Boolean( + detail?.metar_status?.stale_for_today || + detail?.airport_current?.stale_for_today || + detail?.current?.observation_status === "stale", + ); + if (stale) return isEn ? `${source} stale` : `${source} 过旧`; + const ageLabel = formatFreshnessAge( + isHkoObservation ? detail?.current?.obs_age_min : detail?.airport_current?.obs_age_min ?? detail?.current?.obs_age_min, + isEn, + ); + if (ageLabel) return `${source} ${ageLabel}`; + if (displayTime) return `${source} ${displayTime}`; + return isEn ? `${source} time pending` : `${source} 时间待确认`; +} + +function uniqueStatusTags(tags: Array) { + const seen = new Set(); + return tags.filter((tag): tag is StatusTag => { + if (!tag?.label || seen.has(tag.label)) return false; + seen.add(tag.label); + return true; + }); +} + function AiPinnedCityCard({ item, detail, @@ -274,6 +331,122 @@ function AiPinnedCityCard({ marketStatus, tempSymbol, }); + const aiMeta = aiCityForecast?._polyweather_meta || null; + const guardReason = aiMeta?.deterministic_guard_reason || {}; + const observationStale = Boolean( + detail?.metar_status?.stale_for_today || + detail?.airport_current?.stale_for_today || + detail?.current?.observation_status === "stale" || + guardReason.observation_stale, + ); + const observedHighBreak = Boolean( + guardReason.observed_high_break || + (currentTempNumber != null && + modelMax != null && + currentTempNumber > modelMax + 0.2), + ); + const observedLowBreak = Boolean(guardReason.observed_low_break); + const observedLowLag = Boolean(guardReason.observed_low_lag); + const peakHasPassed = Boolean( + guardReason.peak_has_passed || + ["past", "post_peak", "after_peak"].includes( + String((row as { window_phase?: string | null } | null)?.window_phase || "").toLowerCase(), + ), + ); + const observationFreshnessLabel = buildObservationFreshnessLabel({ + detail, + displayTime: metarReportTimeDisplay, + isEn, + isHkoObservation, + }); + const aiStatusLabel = + aiForecast.status === "loading" + ? isEn + ? "AI reading" + : "AI 解读中" + : aiForecast.status === "ready" && aiCityForecast + ? aiForecast.payload?.degraded || aiMeta?.fallback + ? isEn + ? "Rule fallback" + : "规则兜底" + : isEn + ? "AI ready" + : "AI 已完成" + : aiForecast.status === "failed" + ? isEn + ? "AI failed" + : "AI 失败" + : isEn + ? "AI pending" + : "AI 待返回"; + const aiStatusTone: StatusTagTone = + aiForecast.status === "loading" + ? "blue" + : aiForecast.status === "ready" && aiCityForecast + ? aiForecast.payload?.degraded || aiMeta?.fallback + ? "amber" + : "green" + : aiForecast.status === "failed" + ? "red" + : "muted"; + const marketFreshnessLabel = + marketDecisionView.status === "ready" + ? isEn + ? "Market synced" + : "市场已同步" + : marketDecisionView.status === "loading" + ? isEn + ? "Market loading" + : "市场同步中" + : isEn + ? "No market price" + : "暂无市场价"; + const marketStatusTone: StatusTagTone = + marketDecisionView.status === "ready" + ? "green" + : marketDecisionView.status === "loading" + ? "blue" + : "muted"; + const statusTags = uniqueStatusTags([ + observedHighBreak + ? { + label: isEn ? "Observed above models" : "实测突破模型", + tone: "red", + } + : null, + observedLowBreak + ? { + label: isEn ? "Peak revised down" : "峰值下修", + tone: "blue", + } + : null, + observedLowLag + ? { + label: isEn ? "Warm-up needs proof" : "等待升温确认", + tone: "amber", + } + : null, + observationStale + ? { + label: isEn ? "Stale observation" : "观测过旧", + tone: "amber", + } + : null, + peakHasPassed + ? { + label: isEn ? "Peak window passed" : "峰值窗口已过", + tone: "muted", + } + : null, + { + label: aiStatusLabel, + tone: aiStatusTone, + }, + { + label: marketFreshnessLabel, + tone: marketStatusTone, + }, + ]).slice(0, 6); const localizedRisksRaw = (isEn ? aiCityForecast?.risks_en : aiCityForecast?.risks_zh) || []; const localizedRisks = Array.isArray(localizedRisksRaw) @@ -313,6 +486,21 @@ function AiPinnedCityCard({ {isEn ? "Model" : "模型"} {modelRange} {isEn ? "Peak" : "峰值"} {peakWindow} +
+ {statusTags.map((tag) => ( + + {tag.label} + + ))} +
+
+ {observationFreshnessLabel} + {marketFreshnessLabel} + {aiStatusLabel} +
{isEn ? "Expected high" : "预计最高温"} @@ -477,20 +665,20 @@ function AiPinnedCityCard({ aiForecast.streamText || (isEn ? isHkoObservation - ? "DeepSeek is reading the HKO observation and city context..." - : "DeepSeek is reading the airport bulletin and city context..." + ? "Fast read is ready; AI is adding HKO observation details..." + : "Fast read is ready; AI is adding airport bulletin details..." : isHkoObservation - ? "DeepSeek 正在统一解读香港天文台观测和城市上下文…" - : "DeepSeek 正在统一解读机场报文和城市上下文…")} + ? "快速判断已完成,AI 正在补充香港天文台观测细节…" + : "快速判断已完成,AI 正在补充机场报文细节…")}

{isEn ? isHkoObservation - ? "One v4-flash stream now drives both the HKO observation read and city judgment." - : "One v4-flash stream now drives both the airport read and city judgment." + ? "Rule evidence is shown first; the full HKO AI read will merge automatically." + : "Rule evidence is shown first; the full airport AI read will merge automatically." : isHkoObservation - ? "现在由 v4-flash 一条流同时生成香港天文台观测解读和城市判断。" - : "现在由 v4-flash 一条流同时生成机场报文解读和城市判断。"} + ? "先展示规则证据,完整香港天文台 AI 解读返回后会自动合并。" + : "先展示规则证据,完整机场 AI 解读返回后会自动合并。"}

) : aiForecast.status === "ready" && aiCityForecast ? ( diff --git a/frontend/components/dashboard/scan-terminal/types.ts b/frontend/components/dashboard/scan-terminal/types.ts index c140097d..418d122c 100644 --- a/frontend/components/dashboard/scan-terminal/types.ts +++ b/frontend/components/dashboard/scan-terminal/types.ts @@ -3,6 +3,14 @@ export type AiPinnedCity = { displayName?: string | null; addedAt: number; }; + +export type AiCityForecastMeta = { + fallback?: boolean | null; + deterministic_guard_fields?: string[] | null; + deterministic_guard_reason?: Record | null; + [key: string]: unknown; +}; + export type AiCityForecastPayload = { status?: string | null; reason?: string | null; @@ -29,6 +37,7 @@ export type AiCityForecastPayload = { risks_en?: string[] | null; model_cluster_note_zh?: string | null; model_cluster_note_en?: string | null; + _polyweather_meta?: AiCityForecastMeta | null; } | null; }; export type AiCityForecastState = {