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
This commit is contained in:
2569718930@qq.com
2026-04-28 06:36:33 +08:00
parent 0eab2fe628
commit 0c9b07faaf
3 changed files with 176 additions and 0 deletions
+1
View File
@@ -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` 这类重复单位展示
- 决策卡展示文案将“概率差”收口为“模型-市场差”,明确口径为 `模型概率 - 市场隐含概率`
+98
View File
@@ -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°CCAVOK。",
"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
+77
View File
@@ -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