diff --git a/CHANGELOG.md b/CHANGELOG.md index a832aca8..648bc1c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - 城市决策卡新增 AI 机场报文解读缓存说明:页面内存缓存保留 loading / 流式片段 / 最终结果,`localStorage` 保存最终成功 payload,后端 AI 缓存不再因 `local_time` 变化失效 - 城市决策卡兜底文案明确标记“快速证据模式”,避免在 DeepSeek 未完整返回时误写成“AI 机场报文解读正常” - 城市决策卡流式 AI 解读改为只请求 METAR/官方观测核心解读与判断依据,最高温中枢、模型一致性和风险清单由后端规则补齐,减少等待时间 +- 城市决策卡兜底判断新增实测突破识别:当最新 METAR/观测已高于 DEB 中枢或模型上沿时,改为提示最高温中枢需要上修 - 城市决策卡市场层改用完整 `all_buckets` 并严格识别 exact / range / or higher / or lower 温度桶方向,避免最高温中枢错配到不合理尾部桶 - 温度桶标签统一规范化 `C/F/°C/°F`,修复 `31°°C` 这类重复单位展示 - 决策卡展示文案将“概率差”收口为“模型-市场差”,明确口径为 `模型概率 - 市场隐含概率` diff --git a/tests/test_web_observability.py b/tests/test_web_observability.py index 2e2474bf..bc5c802f 100644 --- a/tests/test_web_observability.py +++ b/tests/test_web_observability.py @@ -135,6 +135,44 @@ def test_city_ai_fallback_reasoning_identifies_fast_evidence_mode(): assert "AI 增强可作为后续补充" not in payload["reasoning_zh"] +def test_city_ai_fallback_revises_up_when_latest_metar_breaks_above_models(): + payload = scan_terminal_service._build_city_ai_fallback( + { + "city_display_name": "Manila", + "temp_symbol": "°C", + "deb": {"prediction": 34.0}, + "model_cluster": { + "sources": [ + {"value": 32.5}, + {"value": 33.8}, + {"value": 34.0}, + {"value": 34.7}, + ] + }, + "observation_anchor": { + "is_airport_metar": True, + "station_code": "RPLL", + }, + "airport_current": { + "station_code": "RPLL", + "temp": 35.0, + "report_time": "03:00Z / 当地 11:00", + "raw_metar": "RPLL 270300Z 34004KT CAVOK 35/24 Q1009", + }, + }, + locale="zh-CN", + reason="stream preview", + ) + + assert payload["predicted_max"] == 35.0 + assert payload["range_high"] == 35.0 + assert "高于原先 34.0°C 中枢" in payload["final_judgment_zh"] + assert "上修到至少 35.0°C" in payload["final_judgment_zh"] + assert "共同支撑本轮最高温中枢" not in payload["reasoning_zh"] + assert "超过模型上沿 34.7°C" in payload["reasoning_zh"] + assert "继续上修最高温中枢" in payload["risks_zh"][0] + + 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 cc356c5b..79cdfaad 100644 --- a/web/scan_terminal_service.py +++ b/web/scan_terminal_service.py @@ -685,6 +685,27 @@ def _build_city_ai_fallback( predicted = current_temp range_low = min(values) if values else predicted range_high = max(values) if values else predicted + model_range_high = range_high + current_above_predicted = ( + current_temp is not None + and predicted is not None + and current_temp > predicted + 0.2 + ) + current_above_model_range = ( + current_temp is not None + and model_range_high is not None + and current_temp > model_range_high + 0.2 + ) + observed_high_break = bool(current_above_predicted or current_above_model_range) + original_predicted = predicted + if observed_high_break: + predicted = max( + value + for value in (predicted, current_temp) + if value is not None + ) + if range_high is not None and current_temp is not None: + range_high = max(range_high, current_temp) 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 "" @@ -718,12 +739,18 @@ def _build_city_ai_fallback( metar_zh = f"当前没有可用的{source_name_zh}正文,暂以 DEB、多模型路径与最新实测为主。" metar_en = f"No raw {source_name_en} text is available, so DEB, latest observations and the model cluster carry the read." predicted_text = _format_ai_temperature(predicted, unit) or "--" + current_text = _format_ai_temperature(current_temp, unit) or "--" + original_predicted_text = _format_ai_temperature(original_predicted, unit) or "--" + model_range_high_text = _format_ai_temperature(model_range_high, unit) or "--" if partial_ai.get("final_judgment_zh") or partial_ai.get("final_judgment_en"): final_zh = str(partial_ai.get("final_judgment_zh") or partial_ai.get("final_judgment_en") or "").strip() final_en = str(partial_ai.get("final_judgment_en") or partial_ai.get("final_judgment_zh") or "").strip() elif partial_ai: final_zh = f"{city} 预计最高温暂以 {predicted_text} 附近为中枢;AI 已先完成{bulletin_zh}解读,最高温结论结合 DEB、多模型与最新实测校准。" final_en = f"{city} daily high is centered near {predicted_text}; AI has already read the {bulletin_en}, with the high calibrated against DEB, the model cluster and latest observations." + elif observed_high_break: + final_zh = f"{city} 最新实测已达 {current_text},高于原先 {original_predicted_text} 中枢;最高温中枢需先上修到至少 {predicted_text} 附近。" + final_en = f"{city} latest observation has reached {current_text}, above the prior {original_predicted_text} center; the daily-high center should be revised up to at least near {predicted_text}." 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." @@ -733,15 +760,27 @@ def _build_city_ai_fallback( reasoning_zh = str(partial_ai.get("reasoning_zh") or "").strip() or ( f"AI {bulletin_zh}解读已用于校准日内节奏;DEB 与多模型集合继续约束最高温中枢,后续{source_name_zh}用于确认是否需要上调或下修。" if partial_ai + else f"当前为快速证据模式;最新{source_name_zh}已高于原先 {original_predicted_text} 中枢{(',并超过模型上沿 ' + model_range_high_text) if current_above_model_range else ''},本轮最高温判断应优先承认实测突破并等待完整 AI {bulletin_zh}解读合并。" + if observed_high_break else f"当前为快速证据模式;DEB、多模型集合和最新{source_name_zh}共同支撑本轮最高温中枢,完整 AI {bulletin_zh}解读返回后再合并。" ) reasoning_en = str(partial_ai.get("reasoning_en") or "").strip() or ( f"The AI {bulletin_en} read is already used to calibrate the intraday pace; DEB and the model cluster still constrain the high-temperature center, while later {source_name_en} updates confirm whether to revise it." if partial_ai + else f"This is the fast evidence mode; latest {source_name_en} is above the prior {original_predicted_text} center{(' and above the model upper edge ' + model_range_high_text) if current_above_model_range else ''}, so the high-temperature read should first acknowledge the observed break and merge the full AI {bulletin_en} read when available." + if observed_high_break else 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." ) - 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."] + risks_zh = ( + [f"最新{source_name_zh}已突破原模型路径,若后续报文继续持平或升温,需要继续上修最高温中枢。"] + if observed_high_break + else [f"后续{source_name_zh}若明显偏离模型路径,需及时修正最高温中枢。"] + ) + risks_en = ( + [f"Latest {source_name_en} has already broken above the prior model path; if later reports hold steady or warm further, keep revising the daily-high center upward."] + if observed_high_break + else [f"If later {source_name_en} updates diverge from the model path, revise the daily-high center promptly."] + ) return { "predicted_max": partial_ai.get("predicted_max", predicted), "range_low": partial_ai.get("range_low", range_low),