diff --git a/frontend/components/dashboard/scan-terminal/AiPinnedCityCard.tsx b/frontend/components/dashboard/scan-terminal/AiPinnedCityCard.tsx index 1dfdc33b..dc758866 100644 --- a/frontend/components/dashboard/scan-terminal/AiPinnedCityCard.tsx +++ b/frontend/components/dashboard/scan-terminal/AiPinnedCityCard.tsx @@ -38,6 +38,14 @@ function toFiniteDecisionNumber(value: unknown) { return Number.isFinite(numeric) ? numeric : null; } +function getRowModelEntries(row: ScanOpportunityRow | null) { + const sources = row?.model_cluster_sources; + if (!sources || typeof sources !== "object") return []; + return Object.entries(sources) + .map(([name, value]) => [name, Number(value)] as const) + .filter(([, value]) => Number.isFinite(value)); +} + function parseEpochMs(value: unknown) { if (value == null || value === "") return null; const numeric = Number(value); @@ -228,11 +236,12 @@ export function AiPinnedCityCard({ item.cityName; const tempSymbol = detail?.temp_symbol || row?.temp_symbol || "°C"; const modelView = detail ? getModelView(detail, detail.local_date) : null; - const modelEntries = modelView + const detailModelEntries = modelView ? Object.entries(modelView.models || {}) .map(([name, value]) => [name, Number(value)] as const) .filter(([, value]) => Number.isFinite(value)) : []; + const modelEntries = detailModelEntries.length ? detailModelEntries : getRowModelEntries(row); const modelValues = modelEntries.map(([, value]) => value); const modelMin = modelValues.length ? Math.min(...modelValues) : null; const modelMax = modelValues.length ? Math.max(...modelValues) : null; @@ -325,10 +334,24 @@ export function AiPinnedCityCard({ : isEn ? `Model support is unavailable, so this city must rely on DEB path and ${observationSourceEn}.` : `暂无可用多模型支撑,需要主要参考 DEB 路径和${observationSourceZh}。`; - const aiPredictedMax = toFiniteDecisionNumber(aiCityForecast?.predicted_max); - const aiRangeLow = toFiniteDecisionNumber(aiCityForecast?.range_low); - const aiRangeHigh = toFiniteDecisionNumber(aiCityForecast?.range_high); - const aiConfidence = String(aiCityForecast?.confidence || "").trim() || null; + const rowAiPredictedMax = + toFiniteDecisionNumber(row?.ai_predicted_max) ?? + toFiniteDecisionNumber(row?.ai_predicted_high) ?? + toFiniteDecisionNumber(row?.cluster_median) ?? + debNumber; + const aiPredictedMax = + toFiniteDecisionNumber(aiCityForecast?.predicted_max) ?? rowAiPredictedMax; + const aiRangeLow = + toFiniteDecisionNumber(aiCityForecast?.range_low) ?? + toFiniteDecisionNumber(row?.ai_predicted_low) ?? + modelMin; + const aiRangeHigh = + toFiniteDecisionNumber(aiCityForecast?.range_high) ?? + toFiniteDecisionNumber(row?.ai_predicted_high) ?? + modelMax; + const aiConfidence = + String(aiCityForecast?.confidence || row?.ai_forecast_confidence || "").trim() || + (rowAiPredictedMax != null ? (isEn ? "fast" : "快速") : null); const decisionExpectedHighNumber = resolveExpectedHighCandidate({ aiPredictedMax, currentTemp: currentTempNumber, diff --git a/frontend/components/dashboard/scan-terminal/__tests__/aiCityForecastStreamState.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/aiCityForecastStreamState.test.ts index 24a0c580..06177d81 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/aiCityForecastStreamState.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/aiCityForecastStreamState.test.ts @@ -123,6 +123,20 @@ export function runTests() { assert.equal(errorState.payload?.status, "timeout_fallback"); assert.match(errorState.payload?.reason_zh || "", /DeepSeek|DEB|METAR/); + const modelFallbackState = buildAiCityErrorForecastState({ + cacheKey: `${cacheKey}:models`, + detail: cityDetail({ + deb: { prediction: 29 }, + multi_model: { ECMWF: 30, GFS: 32, ICON: 31 }, + } as unknown as Partial), + error: new Error("timeout"), + isEn: false, + report: "", + }); + assert.equal(modelFallbackState.payload?.city_forecast?.predicted_max, 31); + assert.equal(modelFallbackState.payload?.city_forecast?.range_low, 30); + assert.equal(modelFallbackState.payload?.city_forecast?.range_high, 32); + const hkoState = buildAiCityErrorForecastState({ cacheKey: `${cacheKey}:hko`, detail: cityDetail({ diff --git a/frontend/components/dashboard/scan-terminal/__tests__/aiPinnedHydrationPerformance.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/aiPinnedHydrationPerformance.test.ts index 661e2f00..9914f5d7 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/aiPinnedHydrationPerformance.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/aiPinnedHydrationPerformance.test.ts @@ -14,7 +14,17 @@ export function runTests() { "scan-terminal", "use-ai-pinned-city-workspace.ts", ); + const storePath = path.join(projectRoot, "hooks", "useDashboardStore.tsx"); + const cardPath = path.join( + projectRoot, + "components", + "dashboard", + "scan-terminal", + "AiPinnedCityCard.tsx", + ); const source = fs.readFileSync(workspacePath, "utf8"); + const storeSource = fs.readFileSync(storePath, "utf8"); + const cardSource = fs.readFileSync(cardPath, "utf8"); assert( !source.includes("waitForDeepAnalysisQueue"), @@ -24,4 +34,17 @@ export function runTests() { /store\.ensureCityDetail\(\s*nextCity,\s*false,\s*"full",?\s*\)/.test(source), "automatic deep analysis hydration should use cache-friendly full detail requests", ); + assert( + storeSource.includes("row.model_cluster_sources") && + storeSource.includes("deb_prediction") && + storeSource.includes("multi_model: multiModel"), + "decision-card preload must hydrate model cluster and DEB data from the scan row", + ); + assert( + cardSource.includes("getRowModelEntries") && + cardSource.includes("row?.ai_predicted_max") && + cardSource.includes("row?.cluster_median") && + cardSource.includes("detailModelEntries.length ? detailModelEntries : getRowModelEntries(row)"), + "decision card should render model support and AI predicted max from the row before full detail/AI stream arrives", + ); } diff --git a/frontend/hooks/useDashboardStore.tsx b/frontend/hooks/useDashboardStore.tsx index a454fc2b..a35ebb03 100644 --- a/frontend/hooks/useDashboardStore.tsx +++ b/frontend/hooks/useDashboardStore.tsx @@ -810,9 +810,61 @@ export function DashboardStoreProvider({ if (!cityName || findCachedCityDetail(cityDetailsByName, cityName)) return; // Pre-populate cache from scan terminal row so detail panel shows data immediately const now = Date.now(); + const currentTemp = Number(row.current_temp ?? row.current_max_so_far); + const debPrediction = Number(row.deb_prediction); + const rawModelSources = + row.model_cluster_sources && typeof row.model_cluster_sources === "object" + ? (row.model_cluster_sources as Record) + : {}; + const multiModel = Object.fromEntries( + Object.entries(rawModelSources) + .map(([name, value]) => [name, Number(value)] as const) + .filter(([, value]) => Number.isFinite(value)), + ); setCityDetailsByName((current) => ({ ...current, - [cityName]: { display_name: cityName, local_date: "", local_time: "", deb: {}, probabilities: {}, multi_model: {}, } as CityDetail, + [cityName]: { + name: String(row.city || cityName), + display_name: String(row.city_display_name || row.display_name || cityName), + detail_depth: "panel", + lat: 0, + lon: 0, + local_date: String(row.local_date || row.selected_date || ""), + local_time: String(row.local_time || ""), + temp_symbol: String(row.temp_symbol || "°C"), + current: { + temp: Number.isFinite(currentTemp) ? currentTemp : null, + max_so_far: Number.isFinite(Number(row.current_max_so_far)) + ? Number(row.current_max_so_far) + : Number.isFinite(currentTemp) + ? currentTemp + : null, + max_temp_time: null, + wu_settlement: null, + station_code: null, + station_name: String(row.airport || ""), + obs_time: String((row.metar_context as { last_time?: string } | null)?.last_time || ""), + obs_age_min: null, + wind_speed_kt: null, + wind_dir: null, + humidity: null, + cloud_desc: null, + clouds_raw: [], + visibility_mi: null, + wx_desc: null, + }, + deb: { + prediction: Number.isFinite(debPrediction) ? debPrediction : null, + }, + forecast: { today_high: null, daily: [] }, + hourly: { times: [], temps: [] }, + multi_model: multiModel, + probabilities: {}, + risk: { + level: String(row.risk_level || "medium"), + airport: String(row.airport || ""), + }, + } as CityDetail, })); setCityDetailMetaByName((current) => ({ ...current, diff --git a/src/utils/telegram_push.py b/src/utils/telegram_push.py index 4d75a87e..5033d426 100644 --- a/src/utils/telegram_push.py +++ b/src/utils/telegram_push.py @@ -1083,7 +1083,7 @@ def _process_airport_city( last_city: dict, chat_ids: List[str], bot: Any, -) -> tuple | None: +) -> Optional[Tuple[str, dict]]: """Process one airport city and return (city, new_state_entry) or None. This is the per-city unit used by the concurrent thread pool in diff --git a/tests/test_telegram_hashtags.py b/tests/test_telegram_hashtags.py index 791f1528..3e16a451 100644 --- a/tests/test_telegram_hashtags.py +++ b/tests/test_telegram_hashtags.py @@ -125,8 +125,8 @@ def test_high_freq_airport_push_forces_analysis_refresh(monkeypatch): calls = [] - def fake_analyze(city, force_refresh=False, **_kwargs): - calls.append((city, force_refresh)) + def fake_analyze(city, force_refresh=False, force_refresh_observations_only=False, **_kwargs): + calls.append((city, force_refresh, force_refresh_observations_only)) return { "local_time": "12:00", "current": {"temp": 31.0}, @@ -156,5 +156,5 @@ def test_high_freq_airport_push_forces_analysis_refresh(monkeypatch): ) assert sent is True - assert calls == [("qingdao", True)] + assert calls == [("qingdao", False, True)] assert bot.messages