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 = {