Tighten fallback reads around peak-window evidence

Fallback city-card reads now distinguish three cases that previously collapsed into the generic fast-evidence copy: observed highs above the model path, observed highs still lagging after the peak window, and low observations before the peak window that should wait for confirmation rather than down-revise immediately. The same pass removes three unused private helpers from the scan terminal service.

Constraint: Fallback output must be useful before the full AI airport-bulletin read returns

Rejected: Treat any low latest METAR as a down-revision | early-day observations can be below the forecast before the peak window

Rejected: Keep unused helper wrappers | they were unreferenced and added noise to an already large module

Confidence: high

Scope-risk: moderate

Tested: pytest tests/test_web_observability.py -q

Tested: npm run build
This commit is contained in:
2569718930@qq.com
2026-04-28 06:22:07 +08:00
parent d1d9f80f0f
commit 0a3242c6ec
3 changed files with 239 additions and 63 deletions
+1
View File
@@ -6,6 +6,7 @@
- 城市决策卡兜底文案明确标记“快速证据模式”,避免在 DeepSeek 未完整返回时误写成“AI 机场报文解读正常”
- 城市决策卡流式 AI 解读改为只请求 METAR/官方观测核心解读与判断依据,最高温中枢、模型一致性和风险清单由后端规则补齐,减少等待时间
- 城市决策卡兜底判断新增实测突破识别:当最新 METAR/观测已高于 DEB 中枢或模型上沿时,改为提示最高温中枢需要上修
- 城市决策卡兜底判断补充实测偏低和峰值窗口已过分支:峰后未追上模型时提示下修压力,峰前偏低时只提示等待确认
- 城市决策卡市场层改用完整 `all_buckets` 并严格识别 exact / range / or higher / or lower 温度桶方向,避免最高温中枢错配到不合理尾部桶
- 温度桶标签统一规范化 `C/F/°C/°F`,修复 `31°°C` 这类重复单位展示
- 决策卡展示文案将“概率差”收口为“模型-市场差”,明确口径为 `模型概率 - 市场隐含概率`
+112
View File
@@ -173,6 +173,118 @@ def test_city_ai_fallback_revises_up_when_latest_metar_breaks_above_models():
assert "继续上修最高温中枢" in payload["risks_zh"][0]
def test_city_ai_fallback_revises_down_after_peak_when_observed_high_lags():
payload = scan_terminal_service._build_city_ai_fallback(
{
"city_display_name": "London",
"temp_symbol": "°C",
"deb": {"prediction": 30.0},
"model_cluster": {
"sources": [
{"value": 29.2},
{"value": 30.0},
{"value": 31.1},
]
},
"window_phase": "post_peak",
"peak_window_label": "14:00-16:59",
"observation_anchor": {
"is_airport_metar": True,
"station_code": "EGLL",
},
"airport_current": {
"station_code": "EGLL",
"temp": 27.0,
"max_so_far": 27.5,
"report_time": "16:30Z",
"raw_metar": "EGLL 271630Z 22008KT 9999 SCT030 27/15 Q1012",
},
},
locale="zh-CN",
reason="stream preview",
)
assert payload["predicted_max"] == 27.5
assert "峰值窗口(14:00-16:59)已过或接近结束" in payload["final_judgment_zh"]
assert "最高温中枢需先下修到 27.5°C" in payload["final_judgment_zh"]
assert "共同支撑本轮最高温中枢" not in payload["reasoning_zh"]
assert "下修压力" in payload["reasoning_zh"]
assert "继续下修最高温中枢" in payload["risks_zh"][0]
def test_city_ai_fallback_does_not_downrevise_before_peak_window():
payload = scan_terminal_service._build_city_ai_fallback(
{
"city_display_name": "Dubai",
"temp_symbol": "°C",
"deb": {"prediction": 41.0},
"model_cluster": {
"sources": [
{"value": 40.5},
{"value": 41.0},
{"value": 41.6},
]
},
"window_phase": "early_today",
"minutes_until_peak_start": 240,
"peak_window_label": "14:00-16:59",
"observation_anchor": {
"is_airport_metar": True,
"station_code": "OMDB",
},
"airport_current": {
"station_code": "OMDB",
"temp": 35.0,
"report_time": "08:00Z",
"raw_metar": "OMDB 270800Z 29007KT CAVOK 35/20 Q1008",
},
},
locale="zh-CN",
reason="stream preview",
)
assert payload["predicted_max"] == 41.0
assert "暂不直接下修" in payload["reasoning_zh"]
assert "峰值窗口尚未到来" in payload["reasoning_zh"]
assert "若峰值窗口前继续偏低,需要下修最高温中枢" in payload["risks_zh"][0]
def test_city_ai_fallback_marks_peak_window_passed_without_waiting_for_warming():
payload = scan_terminal_service._build_city_ai_fallback(
{
"city_display_name": "Paris",
"temp_symbol": "°C",
"deb": {"prediction": 28.0},
"model_cluster": {
"sources": [
{"value": 27.6},
{"value": 28.0},
{"value": 28.5},
]
},
"window_phase": "post_peak",
"peak_window_label": "13:00-15:59",
"observation_anchor": {
"is_airport_metar": True,
"station_code": "LFPG",
},
"airport_current": {
"station_code": "LFPG",
"temp": 27.0,
"max_so_far": 27.2,
"report_time": "17:00Z",
"raw_metar": "LFPG 271700Z 25006KT 9999 FEW035 27/13 Q1014",
},
},
locale="zh-CN",
reason="stream preview",
)
assert "峰值窗口(13:00-15:59)已过" in payload["final_judgment_zh"]
assert "不是继续按待升温路径解读" 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(
{
+126 -63
View File
@@ -456,11 +456,6 @@ def _extract_json_string_field_fragment(raw_text: str, field: str) -> tuple[str,
return _decode_json_string_fragment("".join(chars)).strip(), closed
def _extract_json_string_field_from_fragment(raw_text: str, field: str) -> str:
value, _closed = _extract_json_string_field_fragment(raw_text, field)
return value
_CITY_AI_TEXT_FIELDS = {
"metar_read_zh",
"metar_read_en",
@@ -678,34 +673,107 @@ def _build_city_ai_fallback(
current_temp = _safe_float(
airport_current.get("temp") if is_airport_metar else current_obs.get("temp")
)
current_max_so_far = _safe_float(
ai_input.get("current_max_so_far")
or current_obs.get("max_so_far")
or airport_current.get("max_so_far")
)
observed_high_so_far = max(
[value for value in (current_temp, current_max_so_far) if value is not None],
default=None,
)
predicted = deb_value
if predicted is None and values:
predicted = sum(values) / len(values)
if predicted is None:
predicted = current_temp
predicted = observed_high_so_far
range_low = min(values) if values else predicted
range_high = max(values) if values else predicted
peak = ai_input.get("peak") if isinstance(ai_input.get("peak"), dict) else {}
window_phase = str(
ai_input.get("window_phase")
or peak.get("window_phase")
or peak.get("phase")
or ""
).strip().lower()
remaining_window_minutes = _safe_float(
ai_input.get("remaining_window_minutes")
if ai_input.get("remaining_window_minutes") is not None
else peak.get("remaining_window_minutes")
if peak.get("remaining_window_minutes") is not None
else peak.get("remaining_minutes")
)
minutes_until_peak_start = _safe_float(
ai_input.get("minutes_until_peak_start")
if ai_input.get("minutes_until_peak_start") is not None
else peak.get("minutes_until_peak_start")
)
minutes_until_peak_end = _safe_float(
ai_input.get("minutes_until_peak_end")
if ai_input.get("minutes_until_peak_end") is not None
else peak.get("minutes_until_peak_end")
)
peak_window_label = str(
ai_input.get("peak_window_label")
or peak.get("peak_window_label")
or peak.get("label")
or ""
).strip()
peak_has_passed = (
window_phase in {"post_peak", "past"}
or (minutes_until_peak_end is not None and minutes_until_peak_end < 0)
)
peak_is_closing = (
window_phase == "active_peak"
and remaining_window_minutes is not None
and remaining_window_minutes <= 90
)
peak_not_started = (
window_phase in {"early_today", "setup_today", "tomorrow", "week_ahead"}
or (minutes_until_peak_start is not None and minutes_until_peak_start > 0)
)
model_range_high = range_high
model_range_low = range_low
current_above_predicted = (
current_temp is not None
observed_high_so_far is not None
and predicted is not None
and current_temp > predicted + 0.2
and observed_high_so_far > predicted + 0.2
)
current_above_model_range = (
current_temp is not None
observed_high_so_far is not None
and model_range_high is not None
and current_temp > model_range_high + 0.2
and observed_high_so_far > 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
and predicted is not None
and observed_high_so_far < predicted - 1.5
)
current_below_model_range = (
observed_high_so_far is not None
and model_range_low is not None
and observed_high_so_far < 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)
original_predicted = predicted
if observed_high_break:
predicted = max(
value
for value in (predicted, current_temp)
for value in (predicted, observed_high_so_far)
if value is not None
)
if range_high is not None and current_temp is not None:
range_high = max(range_high, current_temp)
if range_high is not None and observed_high_so_far is not None:
range_high = max(range_high, observed_high_so_far)
elif observed_low_break:
predicted = min(
value
for value in (predicted, observed_high_so_far)
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)
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 ""
@@ -739,9 +807,12 @@ 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 "--"
current_text = _format_ai_temperature(observed_high_so_far, unit) or "--"
original_predicted_text = _format_ai_temperature(original_predicted, unit) or "--"
model_range_high_text = _format_ai_temperature(model_range_high, unit) or "--"
model_range_low_text = _format_ai_temperature(model_range_low, unit) or "--"
peak_label_text_zh = f"{peak_window_label}" if peak_window_label else ""
peak_label_text_en = f" ({peak_window_label})" if peak_window_label else ""
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()
@@ -751,36 +822,53 @@ def _build_city_ai_fallback(
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 observed_low_break:
final_zh = f"{city} 峰值窗口{peak_label_text_zh}已过或接近结束,实测最高仍约 {current_text},低于原先 {original_predicted_text} 中枢;最高温中枢需先下修到 {predicted_text} 附近。"
final_en = f"{city} peak window{peak_label_text_en} has passed or is nearly over, and observed high is still near {current_text}, below the prior {original_predicted_text} center; revise the daily-high center down toward {predicted_text}."
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 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."
else:
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."
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}已突破原模型路径,若后续报文继续持平或升温,需要继续上修最高温中枢。"]
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."]
)
if partial_ai:
fallback_reasoning_zh = f"AI {bulletin_zh}解读已用于校准日内节奏;DEB 与多模型集合继续约束最高温中枢,后续{source_name_zh}用于确认是否需要上调或下修。"
fallback_reasoning_en = 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."
elif observed_high_break:
fallback_reasoning_zh = f"当前为快速证据模式;最新{source_name_zh}已高于原先 {original_predicted_text} 中枢{(',并超过模型上沿 ' + model_range_high_text) if current_above_model_range else ''},本轮最高温判断应优先承认实测突破并等待完整 AI {bulletin_zh}解读合并。"
fallback_reasoning_en = 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."
elif observed_low_break:
fallback_reasoning_zh = f"当前为快速证据模式;峰值窗口已过或接近结束,最新实测高点仍低于原先 {original_predicted_text} 中枢{(',并低于模型下沿 ' + model_range_low_text) if current_below_model_range else ''},本轮最高温判断应优先承认下修压力并等待完整 AI {bulletin_zh}解读合并。"
fallback_reasoning_en = f"This is the fast evidence mode; the peak window has passed or is nearly over, and the observed high remains below the prior {original_predicted_text} center{(' and below the model lower edge ' + model_range_low_text) if current_below_model_range else ''}, so the high-temperature read should first acknowledge downward revision pressure and merge the full AI {bulletin_en} read when available."
elif observed_low_lag and peak_not_started:
fallback_reasoning_zh = f"当前为快速证据模式;最新{source_name_zh}仍低于原先 {original_predicted_text} 中枢,但峰值窗口尚未到来,暂不直接下修,只把后续升温是否追上模型路径作为关键确认。"
fallback_reasoning_en = f"This is the fast evidence mode; latest {source_name_en} remains below the prior {original_predicted_text} center, but the peak window has not arrived, so do not revise down yet and use later warming as the key confirmation."
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."
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."
reasoning_zh = str(partial_ai.get("reasoning_zh") or "").strip() or fallback_reasoning_zh
reasoning_en = str(partial_ai.get("reasoning_en") or "").strip() or fallback_reasoning_en
if observed_high_break:
risks_zh = [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."]
elif observed_low_break:
risks_zh = [f"峰值窗口已过或接近结束且实测仍偏低,若后续{source_name_zh}没有反弹,需要继续下修最高温中枢。"]
risks_en = [f"The peak window has passed or is nearly over while observations remain low; if later {source_name_en} does not rebound, keep revising the daily-high center lower."]
elif observed_low_lag:
risks_zh = [f"最新{source_name_zh}仍未追上原模型路径;若峰值窗口前继续偏低,需要下修最高温中枢。"]
risks_en = [f"Latest {source_name_en} has not caught up with the prior model path; if it stays low before the peak window, revise the daily-high center lower."]
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."]
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."]
return {
"predicted_max": partial_ai.get("predicted_max", predicted),
"range_low": partial_ai.get("range_low", range_low),
@@ -966,27 +1054,6 @@ def _normalize_ai_city_key(value: Any) -> str:
return str(value or "").strip().lower().replace(" ", "").replace("-", "").replace("_", "")
def _compact_ai_distribution(row: Dict[str, Any]) -> List[Dict[str, Any]]:
raw_items = row.get("distribution_full") or row.get("distribution_preview") or []
if not isinstance(raw_items, list):
return []
out: List[Dict[str, Any]] = []
for item in raw_items:
if not isinstance(item, dict):
continue
out.append(
{
"label": item.get("label"),
"value": item.get("value"),
"unit": item.get("unit") or row.get("target_unit") or row.get("temp_symbol"),
"model_probability": item.get("model_probability"),
"market_probability": item.get("market_probability"),
"highlighted": item.get("highlighted"),
}
)
return out
def _compact_ai_model_sources(row: Dict[str, Any]) -> List[Dict[str, Any]]:
raw_sources = row.get("model_cluster_sources")
if not isinstance(raw_sources, dict):
@@ -1945,10 +2012,6 @@ def _scan_city_ai_cache_key(ai_input: Dict[str, Any]) -> str:
return "city-ai:" + hashlib.sha256(raw.encode("utf-8")).hexdigest()
def _city_forecast_cache_key(city_name: str) -> str:
return f"city_forecast:{SCAN_CITY_AI_PROMPT_VERSION}:{city_name.lower()}"
def _sse_event(event: str, payload: Dict[str, Any]) -> str:
return (
f"event: {event}\n"