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:
@@ -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` 这类重复单位展示
|
||||
- 决策卡展示文案将“概率差”收口为“模型-市场差”,明确口径为 `模型概率 - 市场隐含概率`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user