From 30faac989c19975dc746283a0770e15928171a4d Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Thu, 7 May 2026 21:04:26 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A7=92=E5=BC=80=20AI=20=E9=A2=84=E6=B5=8B?= =?UTF-8?q?=E6=9C=80=E9=AB=98=E6=B8=A9=EF=BC=9Apreview=20=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E6=8F=90=E5=89=8D=E4=B8=8B=E5=8F=91=E7=A1=AE=E5=AE=9A?= =?UTF-8?q?=E6=80=A7=E9=A2=84=E6=B5=8B=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 之前 predicted_max 只在 AI 流式 final 事件返回后才显示(10-25 秒), 但后端 fallback 早在 1ms 内就算好了 cluster median / DEB 中枢。 现在 preview 事件同步下发确定性 predicted_max + range_low/high, 前端也在 fallback payload 中自动从 multi_model + DEB 计算预测值, 用户点击城市后 ~100ms 即可看到 AI 预测最高温。 --- .../ai-city-forecast-stream-state.ts | 119 ++++++++++++++++-- .../scan-terminal/scan-terminal-client.ts | 7 ++ web/scan_terminal_service.py | 5 + 3 files changed, 124 insertions(+), 7 deletions(-) diff --git a/frontend/components/dashboard/scan-terminal/ai-city-forecast-stream-state.ts b/frontend/components/dashboard/scan-terminal/ai-city-forecast-stream-state.ts index 203cf06f..523709d8 100644 --- a/frontend/components/dashboard/scan-terminal/ai-city-forecast-stream-state.ts +++ b/frontend/components/dashboard/scan-terminal/ai-city-forecast-stream-state.ts @@ -146,6 +146,40 @@ function getAiCityStreamProgressText( return ""; } +function computeFallbackPredictedMax(detail: CityDetail | null): number | null { + if (!detail) return null; + const multiModel = detail.multi_model ?? {}; + const entries = Object.entries(multiModel).filter( + ([, v]) => v != null && Number.isFinite(v), + ) as [string, number][]; + const values = entries.map(([, v]) => v); + const debValue = detail.deb?.prediction ?? null; + const nonDebValues = entries + .filter(([name]) => !name.toLowerCase().includes("deb")) + .map(([, v]) => v); + const sorted = [...nonDebValues].sort((a, b) => a - b); + const clusterMedian = + sorted.length > 0 ? sorted[Math.floor(sorted.length / 2)] : null; + let predicted = clusterMedian; + if (predicted == null && debValue != null && Number.isFinite(debValue)) + predicted = debValue; + if (predicted == null && values.length > 0) + predicted = values.reduce((a, b) => a + b, 0) / values.length; + if (predicted == null) { + const isHkoObservation = isHkoObservationCity(detail); + const currentTemp = + (isHkoObservation + ? detail?.current?.temp + : detail?.airport_current?.temp ?? + detail?.airport_primary?.temp ?? + detail?.current?.temp) ?? null; + predicted = currentTemp != null && Number.isFinite(currentTemp) + ? currentTemp + : null; + } + return predicted; +} + export function buildAiCityFallbackPayload({ detail, error, @@ -195,6 +229,12 @@ export function buildAiCityFallbackPayload({ const reasonZh = `DEB、多模型集合和最新${sourceZh}已足够给出当前方向判断;页面会在 DeepSeek 返回后合并完整机场报文解读。`; const reasonEn = `DEB, the model cluster and latest ${sourceEn} are enough for the current directional read; the page will merge the full airport-bulletin read when DeepSeek returns.`; + const fallbackPredictedMax = computeFallbackPredictedMax(detail); + const multiModel = detail?.multi_model ?? {}; + const modelValues = Object.values(multiModel).filter( + (v): v is number => v != null && Number.isFinite(v), + ); + return { city_forecast: { confidence: "low", @@ -204,9 +244,9 @@ export function buildAiCityFallbackPayload({ metar_read_zh: metarZh, model_cluster_note_en: "", model_cluster_note_zh: "", - predicted_max: null, - range_high: null, - range_low: null, + predicted_max: fallbackPredictedMax, + range_high: modelValues.length > 0 ? Math.max(...modelValues) : null, + range_low: modelValues.length > 0 ? Math.min(...modelValues) : null, reasoning_en: reasonEn, reasoning_zh: reasonZh, risks_en: [], @@ -251,6 +291,60 @@ export function buildAiCityLoadingForecastState({ return loadingState; } +function hasPreviewQuantFields(progress: AiCityStreamProgress) { + return ( + progress.predicted_max != null || + progress.range_low != null || + progress.range_high != null + ); +} + +function mergePreviewQuantPayload( + current: AiCityForecastState, + progress: AiCityStreamProgress, +): AiCityForecastPayload { + const prev = current.payload ?? {}; + const prevCf = prev.city_forecast ?? {}; + return { + ...prev, + status: prev.status ?? "ready", + city_forecast: { + ...prevCf, + ...(progress.predicted_max != null + ? { predicted_max: progress.predicted_max } + : {}), + ...(progress.range_low != null + ? { range_low: progress.range_low } + : {}), + ...(progress.range_high != null + ? { range_high: progress.range_high } + : {}), + ...(progress.confidence != null + ? { confidence: progress.confidence } + : {}), + ...(progress.unit != null ? { unit: progress.unit } : {}), + ...(progress.metar_read_zh != null + ? { metar_read_zh: progress.metar_read_zh } + : {}), + ...(progress.metar_read_en != null + ? { metar_read_en: progress.metar_read_en } + : {}), + ...(progress.final_judgment_zh != null + ? { final_judgment_zh: progress.final_judgment_zh } + : {}), + ...(progress.final_judgment_en != null + ? { final_judgment_en: progress.final_judgment_en } + : {}), + ...(progress.model_cluster_note_zh != null + ? { model_cluster_note_zh: progress.model_cluster_note_zh } + : {}), + ...(progress.model_cluster_note_en != null + ? { model_cluster_note_en: progress.model_cluster_note_en } + : {}), + }, + }; +} + export function buildAiCityProgressForecastState({ cacheKey, current, @@ -263,25 +357,36 @@ export function buildAiCityProgressForecastState({ progress: AiCityStreamProgress; }) { const progressText = getAiCityStreamProgressText(progress, isEn); - if (!progressText) return null; + const hasQuant = hasPreviewQuantFields(progress); + if (!progressText && !hasQuant) return null; const cachedProgressState = readCachedAiForecastState(cacheKey); const nextStreamText = progress.stage === "calling_ai" && cachedProgressState?.streamText ? cachedProgressState.streamText - : progressText; + : progressText || cachedProgressState?.streamText || ""; + const nextPayload = hasQuant + ? mergePreviewQuantPayload( + cachedProgressState ?? current, + progress, + ) + : cachedProgressState?.payload ?? current.payload; const cachedNextState: AiCityForecastState = { ...cachedProgressState, status: "loading", streamText: nextStreamText, + payload: nextPayload, }; writeCachedAiForecastState(cacheKey, cachedNextState); return { ...current, - status: "loading", + status: "loading" as const, streamText: progress.stage === "calling_ai" && current.streamText ? current.streamText - : progressText, + : progressText || current.streamText || "", + payload: hasQuant + ? mergePreviewQuantPayload(current, progress) + : current.payload, } satisfies AiCityForecastState; } diff --git a/frontend/components/dashboard/scan-terminal/scan-terminal-client.ts b/frontend/components/dashboard/scan-terminal/scan-terminal-client.ts index 2d759cfe..e058bc27 100644 --- a/frontend/components/dashboard/scan-terminal/scan-terminal-client.ts +++ b/frontend/components/dashboard/scan-terminal/scan-terminal-client.ts @@ -30,7 +30,14 @@ export type AiCityStreamProgress = { final_judgment_zh?: string | null; metar_read_en?: string | null; metar_read_zh?: string | null; + model_cluster_note_en?: string | null; + model_cluster_note_zh?: string | null; raw_length?: number | null; + predicted_max?: number | null; + range_low?: number | null; + range_high?: number | null; + confidence?: string | null; + unit?: string | null; }; export const scanTerminalQueryPolicy = { diff --git a/web/scan_terminal_service.py b/web/scan_terminal_service.py index c5737c04..d49f190c 100644 --- a/web/scan_terminal_service.py +++ b/web/scan_terminal_service.py @@ -635,6 +635,11 @@ def stream_scan_city_ai_forecast_payload( "final_judgment_en": preview_raw.get("final_judgment_en"), "model_cluster_note_zh": preview_raw.get("model_cluster_note_zh"), "model_cluster_note_en": preview_raw.get("model_cluster_note_en"), + "predicted_max": preview_raw.get("predicted_max"), + "range_low": preview_raw.get("range_low"), + "range_high": preview_raw.get("range_high"), + "confidence": preview_raw.get("confidence"), + "unit": preview_raw.get("unit"), }, ) yield _sse_event(