From 0eab2fe6282f754a8adfa2ab9f4d2232223249ac Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Tue, 28 Apr 2026 06:28:29 +0800 Subject: [PATCH] Treat stale observations as weak evidence City-card fallback reads now stop using stale METAR or official observations as strong live anchors. A stale observation no longer forces high/low revisions, and both backend and browser AI cache keys include the observation fingerprint so updated report times, receipt times, temperatures, or stale status invalidate old AI text. Constraint: Cached city AI reads must not survive a material observation update Rejected: Let stale METAR trigger observed-break revisions | stale reports can be older than the active temperature path Confidence: high Scope-risk: moderate Tested: pytest tests/test_web_observability.py -q Tested: npm run build --- CHANGELOG.md | 1 + .../scan-terminal/use-ai-city-card-data.ts | 6 +- tests/test_web_observability.py | 75 +++++++++++++++++++ web/scan_terminal_service.py | 67 +++++++++++++---- 4 files changed, 134 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6ac1e0c..545aedf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - 城市决策卡流式 AI 解读改为只请求 METAR/官方观测核心解读与判断依据,最高温中枢、模型一致性和风险清单由后端规则补齐,减少等待时间 - 城市决策卡兜底判断新增实测突破识别:当最新 METAR/观测已高于 DEB 中枢或模型上沿时,改为提示最高温中枢需要上修 - 城市决策卡兜底判断补充实测偏低和峰值窗口已过分支:峰后未追上模型时提示下修压力,峰前偏低时只提示等待确认 +- 城市决策卡新增过旧 METAR/观测识别:过旧报文只作为背景参考,不再触发强实况锚点、上修或下修判断;AI 缓存键同步纳入观测时间与 stale 状态 - 城市决策卡市场层改用完整 `all_buckets` 并严格识别 exact / range / or higher / or lower 温度桶方向,避免最高温中枢错配到不合理尾部桶 - 温度桶标签统一规范化 `C/F/°C/°F`,修复 `31°°C` 这类重复单位展示 - 决策卡展示文案将“概率差”收口为“模型-市场差”,明确口径为 `模型概率 - 市场隐含概率` diff --git a/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts b/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts index 74748333..b45f5785 100644 --- a/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts +++ b/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts @@ -14,7 +14,7 @@ import type { CityDetail, MarketScan } from "@/lib/dashboard-types"; import { extractStreamingAirportRead } from "./ai-city-stream"; import { normalizeCityKey } from "./decision-utils"; -const AI_CITY_FORECAST_CACHE_PREFIX = "polyWeather_aiCityForecast_v5"; +const AI_CITY_FORECAST_CACHE_PREFIX = "polyWeather_aiCityForecast_v6"; const AI_CITY_FORECAST_CACHE_TTL_MS = 60 * 60 * 1000; const AI_CITY_FORECAST_MAX_CONCURRENT_STREAMS = 2; const CITY_MARKET_SCAN_CACHE_PREFIX = "polyWeather_cityMarketScan_v3"; @@ -447,8 +447,12 @@ export function useAiCityForecast({ observationCurrent.report_time, observationCurrent.obs_time_epoch, observationCurrent.obs_time, + observationCurrent.receipt_time, observationCurrent.temp, + observationCurrent.max_so_far, observationCurrent.station_code, + detail.metar_status?.stale_for_today, + detail.metar_status?.last_observation_time, ] .filter((part) => part != null && part !== "") .join("|"); diff --git a/tests/test_web_observability.py b/tests/test_web_observability.py index 3ee9d058..103cc671 100644 --- a/tests/test_web_observability.py +++ b/tests/test_web_observability.py @@ -285,6 +285,81 @@ def test_city_ai_fallback_marks_peak_window_passed_without_waiting_for_warming() assert "避免继续上调最高温中枢" in payload["risks_zh"][0] +def test_city_ai_fallback_treats_stale_metar_as_background_not_anchor(): + payload = scan_terminal_service._build_city_ai_fallback( + { + "city_display_name": "Manila", + "temp_symbol": "°C", + "deb": {"prediction": 34.0}, + "model_cluster": { + "sources": [ + {"value": 33.5}, + {"value": 34.0}, + {"value": 34.4}, + ] + }, + "metar_context": { + "stale_for_today": True, + "last_observation_time": "00:00Z", + }, + "observation_anchor": { + "is_airport_metar": True, + "station_code": "RPLL", + }, + "airport_current": { + "station_code": "RPLL", + "temp": 36.0, + "report_time": "00:00Z", + "raw_metar": "RPLL 270000Z 34004KT CAVOK 36/24 Q1009", + }, + }, + locale="zh-CN", + reason="stream preview", + ) + + assert payload["predicted_max"] == 34.0 + assert "过旧" in payload["metar_read_zh"] + assert "不能作为强实况锚点" in payload["metar_read_zh"] + assert "先以 DEB 和多模型路径为主" in payload["final_judgment_zh"] + assert "不能作为强实况锚点" in payload["reasoning_zh"] + assert "上修到至少 36.0°C" not in payload["final_judgment_zh"] + + +def test_city_ai_cache_key_changes_when_observation_fingerprint_changes(): + base_input = { + "schema_version": "single_city_forecast_v2", + "city": "Manila", + "local_date": "2026-04-28", + "deb": {"prediction": 34.0}, + "observation_anchor": { + "source": "METAR", + "is_airport_metar": True, + "station_code": "RPLL", + }, + "airport_current": { + "station_code": "RPLL", + "temp": 34.0, + "report_time": "03:00Z", + "receipt_time": "2026-04-28T03:02:00Z", + "raw_metar": "RPLL 280300Z 34004KT CAVOK 34/24 Q1009", + }, + "metar_context": { + "stale_for_today": False, + "last_observation_time": "03:00Z", + }, + "metar_today_obs": [{"time": "03:00Z", "temp": 34.0}], + } + changed_input = { + **base_input, + "airport_current": { + **base_input["airport_current"], + "receipt_time": "2026-04-28T03:30:00Z", + }, + } + + assert scan_terminal_service._scan_city_ai_cache_key(base_input) != scan_terminal_service._scan_city_ai_cache_key(changed_input) + + def test_city_ai_stream_request_only_asks_provider_for_observation_read(): request_payload = scan_terminal_service._build_city_ai_stream_request( { diff --git a/web/scan_terminal_service.py b/web/scan_terminal_service.py index 388da035..62856a00 100644 --- a/web/scan_terminal_service.py +++ b/web/scan_terminal_service.py @@ -670,6 +670,12 @@ def _build_city_ai_fallback( is_airport_metar = observation_anchor.get("is_airport_metar") is not False airport_current = ai_input.get("airport_current") if isinstance(ai_input.get("airport_current"), dict) else {} current_obs = ai_input.get("current") if isinstance(ai_input.get("current"), dict) else {} + metar_context = ai_input.get("metar_context") if isinstance(ai_input.get("metar_context"), dict) else {} + observation_stale = bool( + metar_context.get("stale_for_today") + or airport_current.get("stale_for_today") + or current_obs.get("stale_for_today") + ) current_temp = _safe_float( airport_current.get("temp") if is_airport_metar else current_obs.get("temp") ) @@ -682,6 +688,7 @@ def _build_city_ai_fallback( [value for value in (current_temp, current_max_so_far) if value is not None], default=None, ) + observed_high_for_revision = None if observation_stale else observed_high_so_far predicted = deb_value if predicted is None and values: predicted = sum(values) / len(values) @@ -735,25 +742,25 @@ def _build_city_ai_fallback( model_range_high = range_high model_range_low = range_low current_above_predicted = ( - observed_high_so_far is not None + observed_high_for_revision is not None and predicted is not None - and observed_high_so_far > predicted + 0.2 + and observed_high_for_revision > predicted + 0.2 ) current_above_model_range = ( - observed_high_so_far is not None + observed_high_for_revision is not None and model_range_high is not None - and observed_high_so_far > model_range_high + 0.2 + and observed_high_for_revision > model_range_high + 0.2 ) observed_high_break = bool(current_above_predicted or current_above_model_range) current_below_predicted = ( - observed_high_so_far is not None + observed_high_for_revision is not None and predicted is not None - and observed_high_so_far < predicted - 1.5 + and observed_high_for_revision < predicted - 1.5 ) current_below_model_range = ( - observed_high_so_far is not None + observed_high_for_revision is not None and model_range_low is not None - and observed_high_so_far < model_range_low - 0.2 + and observed_high_for_revision < model_range_low - 0.2 ) observed_low_break = bool(current_below_predicted and (peak_has_passed or peak_is_closing)) observed_low_lag = bool(current_below_predicted and not observed_low_break) @@ -761,19 +768,19 @@ def _build_city_ai_fallback( if observed_high_break: predicted = max( value - for value in (predicted, observed_high_so_far) + for value in (predicted, observed_high_for_revision) if value is not None ) - if range_high is not None and observed_high_so_far is not None: - range_high = max(range_high, observed_high_so_far) + if range_high is not None and observed_high_for_revision is not None: + range_high = max(range_high, observed_high_for_revision) elif observed_low_break: predicted = min( value - for value in (predicted, observed_high_so_far) + for value in (predicted, observed_high_for_revision) if value is not None ) - if range_low is not None and observed_high_so_far is not None: - range_low = min(range_low, observed_high_so_far) + if range_low is not None and observed_high_for_revision is not None: + range_low = min(range_low, observed_high_for_revision) city = str(ai_input.get("city_display_name") or ai_input.get("city") or "this city") station = str((airport_current.get("station_code") if is_airport_metar else None) or observation_anchor.get("station_code") or current_obs.get("station_code") or "") raw_metar = str(airport_current.get("raw_metar") or "").strip() if is_airport_metar else "" @@ -800,6 +807,9 @@ def _build_city_ai_fallback( elif content_preview: metar_zh = f"{bulletin_zh}快速解读已先完成;本轮 AI 增强未完整返回,当前以 DEB、多模型与{source_name_zh}为准。" metar_en = f"The fast {bulletin_en} read is available; this AI enhancement was incomplete, so DEB, model cluster and {source_name_en} carry the read." + elif raw_metar and observation_stale: + metar_zh = f"{station} 可用 METAR 显示 {metar_temp or '温度未知'},报文时间 {obs_time or '未知'};但该观测已标记为过旧,当前只能作为背景参考,不能作为强实况锚点。" + metar_en = f"{station} available METAR shows {metar_temp or 'unknown temperature'} at {obs_time or 'unknown time'}, but the observation is flagged as stale, so treat it as background context rather than a strong live anchor." elif raw_metar: metar_zh = f"{station} 最新 METAR 显示 {metar_temp or '温度未知'},报文时间 {obs_time or '未知'};当前先把它作为实况锚点,并结合后续报文确认温度路径。" metar_en = f"{station} latest METAR shows {metar_temp or 'unknown temperature'} at {obs_time or 'unknown time'}; use it as the live anchor while later reports confirm the path." @@ -828,6 +838,9 @@ def _build_city_ai_fallback( elif peak_has_passed: final_zh = f"{city} 峰值窗口{peak_label_text_zh}已过;最高温暂以 {predicted_text} 附近为中枢,并以已观测到的高点为主要校准。" final_en = f"{city} peak window{peak_label_text_en} has passed; the daily high stays centered near {predicted_text}, calibrated mainly against the observed high so far." + elif observation_stale: + final_zh = f"{city} 最高温暂以 {predicted_text} 附近为中枢;当前可用{source_name_zh}已过旧,先以 DEB 和多模型路径为主。" + final_en = f"{city} daily high is centered near {predicted_text}; the available {source_name_en} is stale, so DEB and the model path carry the read for now." elif timed_out: final_zh = f"{city} 预计最高温暂以 {predicted_text} 附近为中枢;当前已先用 DEB、多模型和{source_name_zh}快速证据模式判断。" final_en = f"{city} daily high is centered near {predicted_text}; the current read uses the fast DEB/model/{source_name_en} evidence mode." @@ -849,6 +862,9 @@ def _build_city_ai_fallback( elif peak_has_passed: fallback_reasoning_zh = f"当前为快速证据模式;峰值窗口已过,后续{source_name_zh}主要用于确认是否已形成日内高点,而不是继续按待升温路径解读。" fallback_reasoning_en = f"This is the fast evidence mode; the peak window has passed, so later {source_name_en} updates mainly confirm whether the daily high is already set rather than assuming further warming." + elif observation_stale: + fallback_reasoning_zh = f"当前为快速证据模式;可用{source_name_zh}已过旧,不能作为强实况锚点,暂由 DEB 和多模型集合支撑本轮最高温中枢,等待新的{source_name_zh}确认。" + fallback_reasoning_en = f"This is the fast evidence mode; the available {source_name_en} is stale and should not be used as a strong live anchor, so DEB and the model cluster carry the current daily-high center until a newer {source_name_en} confirms it." else: fallback_reasoning_zh = f"当前为快速证据模式;DEB、多模型集合和最新{source_name_zh}共同支撑本轮最高温中枢,完整 AI {bulletin_zh}解读返回后再合并。" fallback_reasoning_en = f"This is the fast evidence mode; DEB, the model cluster and latest {source_name_en} jointly support the current daily-high center, and the full AI {bulletin_en} read will be merged when available." @@ -866,6 +882,9 @@ def _build_city_ai_fallback( elif peak_has_passed: risks_zh = [f"峰值窗口已过,后续{source_name_zh}若未再创新高,应避免继续上调最高温中枢。"] risks_en = [f"The peak window has passed; avoid raising the daily-high center unless later {source_name_en} sets a new high."] + elif observation_stale: + risks_zh = [f"当前{source_name_zh}过旧;新报文若明显偏离 DEB 和模型路径,需要重新校准最高温中枢。"] + risks_en = [f"The current {source_name_en} is stale; if a newer report diverges from DEB and the model path, recalibrate the daily-high center."] else: risks_zh = [f"后续{source_name_zh}若明显偏离模型路径,需及时修正最高温中枢。"] risks_en = [f"If later {source_name_en} updates diverge from the model path, revise the daily-high center promptly."] @@ -1991,11 +2010,30 @@ def _scan_city_ai_cache_key(ai_input: Dict[str, Any]) -> str: observation_anchor = ai_input.get("observation_anchor") if isinstance(ai_input.get("observation_anchor"), dict) else {} is_airport_metar = observation_anchor.get("is_airport_metar") is not False airport_current = ai_input.get("airport_current") if isinstance(ai_input.get("airport_current"), dict) else {} + current_obs = ai_input.get("current") if isinstance(ai_input.get("current"), dict) else {} + metar_context = ai_input.get("metar_context") if isinstance(ai_input.get("metar_context"), dict) else {} observation_obs = ( ai_input.get("metar_today_obs") or ai_input.get("metar_recent_obs") or [] if is_airport_metar else ai_input.get("settlement_today_obs") or ai_input.get("settlement_recent_obs") or [] ) + observation_fingerprint = { + "stale_for_today": metar_context.get("stale_for_today"), + "last_observation_time": metar_context.get("last_observation_time"), + "last_time": metar_context.get("last_time"), + "last_temp": metar_context.get("last_temp"), + "max_time": metar_context.get("max_time"), + "max_temp": metar_context.get("max_temp"), + "airport_obs_time": airport_current.get("obs_time"), + "airport_report_time": airport_current.get("report_time"), + "airport_receipt_time": airport_current.get("receipt_time"), + "airport_temp": airport_current.get("temp"), + "airport_max_so_far": airport_current.get("max_so_far"), + "current_obs_time": current_obs.get("obs_time"), + "current_report_time": current_obs.get("report_time"), + "current_temp": current_obs.get("temp"), + "current_max_so_far": current_obs.get("max_so_far"), + } key_payload = { "prompt_version": SCAN_CITY_AI_PROMPT_VERSION, "schema_version": ai_input.get("schema_version"), @@ -2006,6 +2044,7 @@ def _scan_city_ai_cache_key(ai_input: Dict[str, Any]) -> str: "observation_source": observation_anchor.get("source") or ("metar" if is_airport_metar else "official"), "station": observation_anchor.get("station_code"), "metar": airport_current.get("raw_metar") if is_airport_metar else None, + "observation_fingerprint": observation_fingerprint, "obs": observation_obs, } raw = json.dumps(key_payload, sort_keys=True, ensure_ascii=False, default=str)