Treat stale observations as weak evidence
City-card fallback reads now stop using stale METAR or official observations as strong live anchors. A stale observation no longer forces high/low revisions, and both backend and browser AI cache keys include the observation fingerprint so updated report times, receipt times, temperatures, or stale status invalidate old AI text. Constraint: Cached city AI reads must not survive a material observation update Rejected: Let stale METAR trigger observed-break revisions | stale reports can be older than the active temperature path Confidence: high Scope-risk: moderate Tested: pytest tests/test_web_observability.py -q Tested: npm run build
This commit is contained in:
@@ -670,6 +670,12 @@ def _build_city_ai_fallback(
|
||||
is_airport_metar = observation_anchor.get("is_airport_metar") is not False
|
||||
airport_current = ai_input.get("airport_current") if isinstance(ai_input.get("airport_current"), dict) else {}
|
||||
current_obs = ai_input.get("current") if isinstance(ai_input.get("current"), dict) else {}
|
||||
metar_context = ai_input.get("metar_context") if isinstance(ai_input.get("metar_context"), dict) else {}
|
||||
observation_stale = bool(
|
||||
metar_context.get("stale_for_today")
|
||||
or airport_current.get("stale_for_today")
|
||||
or current_obs.get("stale_for_today")
|
||||
)
|
||||
current_temp = _safe_float(
|
||||
airport_current.get("temp") if is_airport_metar else current_obs.get("temp")
|
||||
)
|
||||
@@ -682,6 +688,7 @@ def _build_city_ai_fallback(
|
||||
[value for value in (current_temp, current_max_so_far) if value is not None],
|
||||
default=None,
|
||||
)
|
||||
observed_high_for_revision = None if observation_stale else observed_high_so_far
|
||||
predicted = deb_value
|
||||
if predicted is None and values:
|
||||
predicted = sum(values) / len(values)
|
||||
@@ -735,25 +742,25 @@ def _build_city_ai_fallback(
|
||||
model_range_high = range_high
|
||||
model_range_low = range_low
|
||||
current_above_predicted = (
|
||||
observed_high_so_far is not None
|
||||
observed_high_for_revision is not None
|
||||
and predicted is not None
|
||||
and observed_high_so_far > predicted + 0.2
|
||||
and observed_high_for_revision > predicted + 0.2
|
||||
)
|
||||
current_above_model_range = (
|
||||
observed_high_so_far is not None
|
||||
observed_high_for_revision is not None
|
||||
and model_range_high is not None
|
||||
and observed_high_so_far > model_range_high + 0.2
|
||||
and observed_high_for_revision > 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
|
||||
observed_high_for_revision is not None
|
||||
and predicted is not None
|
||||
and observed_high_so_far < predicted - 1.5
|
||||
and observed_high_for_revision < predicted - 1.5
|
||||
)
|
||||
current_below_model_range = (
|
||||
observed_high_so_far is not None
|
||||
observed_high_for_revision is not None
|
||||
and model_range_low is not None
|
||||
and observed_high_so_far < model_range_low - 0.2
|
||||
and observed_high_for_revision < 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)
|
||||
@@ -761,19 +768,19 @@ def _build_city_ai_fallback(
|
||||
if observed_high_break:
|
||||
predicted = max(
|
||||
value
|
||||
for value in (predicted, observed_high_so_far)
|
||||
for value in (predicted, observed_high_for_revision)
|
||||
if value is not None
|
||||
)
|
||||
if range_high is not None and observed_high_so_far is not None:
|
||||
range_high = max(range_high, observed_high_so_far)
|
||||
if range_high is not None and observed_high_for_revision is not None:
|
||||
range_high = max(range_high, observed_high_for_revision)
|
||||
elif observed_low_break:
|
||||
predicted = min(
|
||||
value
|
||||
for value in (predicted, observed_high_so_far)
|
||||
for value in (predicted, observed_high_for_revision)
|
||||
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)
|
||||
if range_low is not None and observed_high_for_revision is not None:
|
||||
range_low = min(range_low, observed_high_for_revision)
|
||||
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 ""
|
||||
@@ -800,6 +807,9 @@ def _build_city_ai_fallback(
|
||||
elif content_preview:
|
||||
metar_zh = f"{bulletin_zh}快速解读已先完成;本轮 AI 增强未完整返回,当前以 DEB、多模型与{source_name_zh}为准。"
|
||||
metar_en = f"The fast {bulletin_en} read is available; this AI enhancement was incomplete, so DEB, model cluster and {source_name_en} carry the read."
|
||||
elif raw_metar and observation_stale:
|
||||
metar_zh = f"{station} 可用 METAR 显示 {metar_temp or '温度未知'},报文时间 {obs_time or '未知'};但该观测已标记为过旧,当前只能作为背景参考,不能作为强实况锚点。"
|
||||
metar_en = f"{station} available METAR shows {metar_temp or 'unknown temperature'} at {obs_time or 'unknown time'}, but the observation is flagged as stale, so treat it as background context rather than a strong live anchor."
|
||||
elif raw_metar:
|
||||
metar_zh = f"{station} 最新 METAR 显示 {metar_temp or '温度未知'},报文时间 {obs_time or '未知'};当前先把它作为实况锚点,并结合后续报文确认温度路径。"
|
||||
metar_en = f"{station} latest METAR shows {metar_temp or 'unknown temperature'} at {obs_time or 'unknown time'}; use it as the live anchor while later reports confirm the path."
|
||||
@@ -828,6 +838,9 @@ def _build_city_ai_fallback(
|
||||
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 observation_stale:
|
||||
final_zh = f"{city} 最高温暂以 {predicted_text} 附近为中枢;当前可用{source_name_zh}已过旧,先以 DEB 和多模型路径为主。"
|
||||
final_en = f"{city} daily high is centered near {predicted_text}; the available {source_name_en} is stale, so DEB and the model path carry the read for now."
|
||||
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."
|
||||
@@ -849,6 +862,9 @@ def _build_city_ai_fallback(
|
||||
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."
|
||||
elif observation_stale:
|
||||
fallback_reasoning_zh = f"当前为快速证据模式;可用{source_name_zh}已过旧,不能作为强实况锚点,暂由 DEB 和多模型集合支撑本轮最高温中枢,等待新的{source_name_zh}确认。"
|
||||
fallback_reasoning_en = f"This is the fast evidence mode; the available {source_name_en} is stale and should not be used as a strong live anchor, so DEB and the model cluster carry the current daily-high center until a newer {source_name_en} confirms it."
|
||||
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."
|
||||
@@ -866,6 +882,9 @@ def _build_city_ai_fallback(
|
||||
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."]
|
||||
elif observation_stale:
|
||||
risks_zh = [f"当前{source_name_zh}过旧;新报文若明显偏离 DEB 和模型路径,需要重新校准最高温中枢。"]
|
||||
risks_en = [f"The current {source_name_en} is stale; if a newer report diverges from DEB and the model path, recalibrate the daily-high center."]
|
||||
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."]
|
||||
@@ -1991,11 +2010,30 @@ def _scan_city_ai_cache_key(ai_input: Dict[str, Any]) -> str:
|
||||
observation_anchor = ai_input.get("observation_anchor") if isinstance(ai_input.get("observation_anchor"), dict) else {}
|
||||
is_airport_metar = observation_anchor.get("is_airport_metar") is not False
|
||||
airport_current = ai_input.get("airport_current") if isinstance(ai_input.get("airport_current"), dict) else {}
|
||||
current_obs = ai_input.get("current") if isinstance(ai_input.get("current"), dict) else {}
|
||||
metar_context = ai_input.get("metar_context") if isinstance(ai_input.get("metar_context"), dict) else {}
|
||||
observation_obs = (
|
||||
ai_input.get("metar_today_obs") or ai_input.get("metar_recent_obs") or []
|
||||
if is_airport_metar
|
||||
else ai_input.get("settlement_today_obs") or ai_input.get("settlement_recent_obs") or []
|
||||
)
|
||||
observation_fingerprint = {
|
||||
"stale_for_today": metar_context.get("stale_for_today"),
|
||||
"last_observation_time": metar_context.get("last_observation_time"),
|
||||
"last_time": metar_context.get("last_time"),
|
||||
"last_temp": metar_context.get("last_temp"),
|
||||
"max_time": metar_context.get("max_time"),
|
||||
"max_temp": metar_context.get("max_temp"),
|
||||
"airport_obs_time": airport_current.get("obs_time"),
|
||||
"airport_report_time": airport_current.get("report_time"),
|
||||
"airport_receipt_time": airport_current.get("receipt_time"),
|
||||
"airport_temp": airport_current.get("temp"),
|
||||
"airport_max_so_far": airport_current.get("max_so_far"),
|
||||
"current_obs_time": current_obs.get("obs_time"),
|
||||
"current_report_time": current_obs.get("report_time"),
|
||||
"current_temp": current_obs.get("temp"),
|
||||
"current_max_so_far": current_obs.get("max_so_far"),
|
||||
}
|
||||
key_payload = {
|
||||
"prompt_version": SCAN_CITY_AI_PROMPT_VERSION,
|
||||
"schema_version": ai_input.get("schema_version"),
|
||||
@@ -2006,6 +2044,7 @@ def _scan_city_ai_cache_key(ai_input: Dict[str, Any]) -> str:
|
||||
"observation_source": observation_anchor.get("source") or ("metar" if is_airport_metar else "official"),
|
||||
"station": observation_anchor.get("station_code"),
|
||||
"metar": airport_current.get("raw_metar") if is_airport_metar else None,
|
||||
"observation_fingerprint": observation_fingerprint,
|
||||
"obs": observation_obs,
|
||||
}
|
||||
raw = json.dumps(key_payload, sort_keys=True, ensure_ascii=False, default=str)
|
||||
|
||||
Reference in New Issue
Block a user