From 0c9b07faaf0578efaf86acca0f8b735c0f95d5dd Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Tue, 28 Apr 2026 06:36:33 +0800 Subject: [PATCH] Keep provider reads behind deterministic evidence guards DeepSeek can return a polished city forecast that conflicts with already-computed stale-observation, observed-break, or peak-window evidence. The completion path now carries the deterministic fallback guard state and overwrites only the critical fields when provider text or numbers contradict those local facts. Constraint: Airport-read latency optimization keeps provider output narrow, so backend completion remains the authority for final highs and evidence conflicts. Rejected: Trust provider final wording when present | it can reintroduce stale METAR anchors or miss observed high breaks. Confidence: high Scope-risk: narrow Tested: pytest tests/test_web_observability.py -q Tested: npm run build --- CHANGELOG.md | 1 + tests/test_web_observability.py | 98 +++++++++++++++++++++++++++++++++ web/scan_terminal_service.py | 77 ++++++++++++++++++++++++++ 3 files changed, 176 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 545aedf2..e638e42e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - 城市决策卡兜底判断新增实测突破识别:当最新 METAR/观测已高于 DEB 中枢或模型上沿时,改为提示最高温中枢需要上修 - 城市决策卡兜底判断补充实测偏低和峰值窗口已过分支:峰后未追上模型时提示下修压力,峰前偏低时只提示等待确认 - 城市决策卡新增过旧 METAR/观测识别:过旧报文只作为背景参考,不再触发强实况锚点、上修或下修判断;AI 缓存键同步纳入观测时间与 stale 状态 +- 城市决策卡新增 AI 结果后处理护栏:完整 DeepSeek 返回若与过旧观测、实测突破、峰后下修等确定性证据冲突,会以后端规则覆盖关键数值和结论文案 - 城市决策卡市场层改用完整 `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 103cc671..27605f0c 100644 --- a/tests/test_web_observability.py +++ b/tests/test_web_observability.py @@ -474,6 +474,104 @@ def test_city_ai_schema_completion_trims_dangling_taf_clause(): ] +def test_city_ai_schema_completion_guards_stale_observation_text(): + payload = scan_terminal_service._complete_city_ai_payload( + { + "predicted_max": 36.0, + "range_low": 35.0, + "range_high": 37.0, + "unit": "°C", + "confidence": "medium", + "final_judgment_zh": "Manila 最新 METAR 已经支撑 36°C 高温中枢。", + "final_judgment_en": "Manila latest METAR supports a 36°C high center.", + "metar_read_zh": "RPLL 最新 METAR 显示 36°C,当前作为强实况锚点。", + "metar_read_en": "RPLL latest METAR shows 36°C and is a strong live anchor.", + "reasoning_zh": "最新 METAR 与模型共同支撑上修。", + "reasoning_en": "Latest METAR and models jointly support an upward revision.", + "risks_zh": ["若继续升温,需要上修。"], + "risks_en": ["If it keeps warming, revise upward."], + "model_cluster_note_zh": "3/3 个模型集中。", + "model_cluster_note_en": "3/3 models are clustered.", + }, + { + "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", + ) + + 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 "共同支撑上修" not in payload["reasoning_zh"] + assert "deterministic_guard_fields" in payload["_polyweather_meta"] + + +def test_city_ai_schema_completion_guards_observed_high_break_numbers(): + payload = scan_terminal_service._complete_city_ai_payload( + { + "predicted_max": 34.0, + "range_low": 32.5, + "range_high": 34.7, + "unit": "°C", + "confidence": "medium", + "final_judgment_zh": "Manila 最高温仍以 34.0°C 为中枢。", + "final_judgment_en": "Manila high remains centered near 34.0°C.", + "metar_read_zh": "RPLL 最新 METAR 显示 35°C,CAVOK。", + "metar_read_en": "RPLL latest METAR shows 35°C and CAVOK.", + "reasoning_zh": "模型区间仍覆盖当前路径,无需上修。", + "reasoning_en": "The model range still covers the path, so no upward revision is needed.", + "risks_zh": ["后续报文偏离再修正。"], + "risks_en": ["Revise if later reports diverge."], + "model_cluster_note_zh": "4/4 个模型集中。", + "model_cluster_note_en": "4/4 models are clustered.", + }, + { + "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", + ) + + assert payload["predicted_max"] == 35.0 + assert payload["range_high"] == 35.0 + assert "上修到至少 35.0°C" in payload["final_judgment_zh"] + assert "无需上修" not in payload["reasoning_zh"] + assert "deterministic_guard_fields" in payload["_polyweather_meta"] + + def test_cities_endpoint_uses_denver_display_name_for_aurora_market(): response = client.get("/api/cities") assert response.status_code == 200 diff --git a/web/scan_terminal_service.py b/web/scan_terminal_service.py index 62856a00..d100d360 100644 --- a/web/scan_terminal_service.py +++ b/web/scan_terminal_service.py @@ -888,6 +888,23 @@ def _build_city_ai_fallback( 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."] + evidence_guard = { + "observation_stale": observation_stale, + "observed_high_break": observed_high_break, + "observed_low_break": observed_low_break, + "observed_low_lag": observed_low_lag, + "peak_has_passed": peak_has_passed, + "peak_is_closing": peak_is_closing, + "peak_not_started": peak_not_started, + "observed_high_so_far": observed_high_so_far, + "observed_high_for_revision": observed_high_for_revision, + "original_predicted": original_predicted, + "model_range_low": model_range_low, + "model_range_high": model_range_high, + "predicted_max": predicted, + "range_low": range_low, + "range_high": range_high, + } return { "predicted_max": partial_ai.get("predicted_max", predicted), "range_low": partial_ai.get("range_low", range_low), @@ -913,6 +930,7 @@ def _build_city_ai_fallback( "raw_content_preview": content_preview, "partial_ai_fields": sorted(partial_ai.keys()), "raw_metar": _truncate_ai_text(raw_metar, 1000), + "evidence_guard": evidence_guard, }, } @@ -996,6 +1014,65 @@ def _complete_city_ai_payload( meta["schema_completed_fields"] = completed if trimmed: meta["trimmed_incomplete_fields"] = sorted(set(trimmed)) + guard_meta = (fallback.get("_polyweather_meta") or {}).get("evidence_guard") + if isinstance(guard_meta, dict): + deterministic_fields: List[str] = [] + numeric_guard_active = bool( + guard_meta.get("observed_high_break") + or guard_meta.get("observed_low_break") + or guard_meta.get("observation_stale") + ) + text_guard_active = bool( + numeric_guard_active + or guard_meta.get("peak_has_passed") + or (guard_meta.get("observed_low_lag") and guard_meta.get("peak_not_started")) + ) + if numeric_guard_active: + for field in ("predicted_max", "range_low", "range_high"): + guarded_value = fallback.get(field) + if guarded_value is not None and out.get(field) != guarded_value: + out[field] = guarded_value + deterministic_fields.append(field) + if guard_meta.get("observation_stale"): + guarded_text_fields = ( + "metar_read_zh", + "metar_read_en", + "final_judgment_zh", + "final_judgment_en", + "reasoning_zh", + "reasoning_en", + "risks_zh", + "risks_en", + ) + elif text_guard_active: + guarded_text_fields = ( + "final_judgment_zh", + "final_judgment_en", + "reasoning_zh", + "reasoning_en", + "risks_zh", + "risks_en", + ) + else: + guarded_text_fields = () + for field in guarded_text_fields: + guarded_value = fallback.get(field) + if guarded_value not in (None, "", []) and out.get(field) != guarded_value: + out[field] = guarded_value + deterministic_fields.append(field) + if deterministic_fields: + meta["deterministic_guard_fields"] = sorted(set(deterministic_fields)) + meta["deterministic_guard_reason"] = { + key: guard_meta.get(key) + for key in ( + "observation_stale", + "observed_high_break", + "observed_low_break", + "observed_low_lag", + "peak_has_passed", + ) + if guard_meta.get(key) + } out["_polyweather_meta"] = meta return out