Files
PolyWeather/web/scan_terminal_ai_compact.py
T
2569718930@qq.com 2ee00f8016 MiMo AI 能力扩展:TAF解读、概率分布解读、异常检测、市场概览
AI 解读字段扩展:
    - 新增 taf_read_zh/en:解读机场预报中影响今日峰值窗口的变化
    - 新增 probability_read_zh/en:描述概率分布形态(最高桶、偏左/偏右)
    - stream max_tokens 900→1200 容纳新输出字段
    - 缓存 key 简化为 METAR原文+观测时间,大幅提升命中率
    - 兜底函数补全 TAF 和概率字段的确定性生成

    异常检测:
    - 纯数学计算,零 AI 延迟:实测温度 vs 全部模型预测上下限
    - 三级告警:breakout_above / breakout_below / deviation

    市场概览:
    - 新增 POST /api/scan/terminal/overview(MiMo 批量解读,缓存10分钟)
    - 前端 MarketOverviewBanner 可折叠横幅(顶栏与标签栏之间)
    - 移动端适配 640px/768px 断点,暗色/亮色双主题

    Scope-risk: MEDIUM — 170 测试通过,TypeScript 零错误,ruff 零告警
    Tested: python -m pytest -q (170 passed), npx tsc --noEmit (0 errors), ruff check .
2026-05-14 22:41:31 +08:00

467 lines
20 KiB
Python

from __future__ import annotations
import re
from datetime import datetime
from typing import Any, Dict, List, Optional
from web.scan_city_ai_helpers import _safe_float, _truncate_ai_text
def _compact_ai_candidate(row: Dict[str, Any]) -> Dict[str, Any]:
return {
"id": row.get("id"),
"action": row.get("action"),
"side": row.get("side"),
"target_label": row.get("target_label"),
"target_value": row.get("target_value"),
"target_threshold": row.get("target_threshold"),
"target_unit": row.get("target_unit"),
"market_probability": row.get("market_probability"),
"market_event_probability": row.get("market_event_probability"),
"yes_ask": row.get("yes_ask"),
"no_ask": row.get("no_ask"),
"ask": row.get("ask"),
"spread": row.get("spread"),
"quote_age_ms": row.get("quote_age_ms"),
"cluster_role": row.get("cluster_role"),
"model_cluster_sources": _compact_ai_model_sources(row),
"metar_context": row.get("metar_context") or {},
"window_phase": row.get("window_phase"),
"peak_window_label": row.get("peak_window_label"),
"minutes_until_peak_start": row.get("minutes_until_peak_start"),
"minutes_until_peak_end": row.get("minutes_until_peak_end"),
"trend_alignment": row.get("trend_alignment"),
"tradable": row.get("tradable"),
"accepting_orders": row.get("accepting_orders"),
}
def _normalize_ai_city_key(value: Any) -> str:
return str(value or "").strip().lower().replace(" ", "").replace("-", "").replace("_", "")
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):
return []
sources: List[Dict[str, Any]] = []
for name, value in raw_sources.items():
if _safe_float(value) is None:
continue
sources.append({"model": str(name), "value": value})
return sources[:12]
def _observation_sort_key(point: Dict[str, Any]) -> tuple[int, str]:
raw_time = str(point.get("time") or "").strip()
try:
parsed = datetime.fromisoformat(raw_time.replace("Z", "+00:00"))
return parsed.hour * 60 + parsed.minute, raw_time
except Exception:
pass
match = re.search(r"(\d{1,2}):(\d{2})", raw_time)
if match:
hour = max(0, min(23, int(match.group(1))))
minute = max(0, min(59, int(match.group(2))))
return hour * 60 + minute, raw_time
return 9999, raw_time
def _compact_observation_points(raw_points: Any, limit: int = 24) -> List[Dict[str, Any]]:
if not isinstance(raw_points, list):
return []
points: List[Dict[str, Any]] = []
for item in raw_points:
if isinstance(item, dict):
temp = _safe_float(item.get("temp"))
time_value = str(item.get("time") or item.get("obs_time") or item.get("time_label") or "").strip()
elif isinstance(item, (list, tuple)) and len(item) >= 2:
time_value = str(item[0] or "").strip()
temp = _safe_float(item[1])
else:
continue
if temp is None or not time_value:
continue
points.append({"time": time_value, "temp": temp})
sorted_points = sorted(points, key=_observation_sort_key)
return sorted_points[-max(1, int(limit)) :]
def _compact_ai_text(value: Any, limit: int = 700) -> Optional[str]:
text = _truncate_ai_text(value, limit).strip()
return text or None
def _compact_hourly_context(raw_hourly: Any) -> Dict[str, Any]:
if not isinstance(raw_hourly, dict):
return {}
times = raw_hourly.get("times") or raw_hourly.get("time") or []
temps = raw_hourly.get("temps") or raw_hourly.get("temperature_2m") or []
radiation = raw_hourly.get("radiation") or raw_hourly.get("shortwave_radiation") or []
if not isinstance(times, list) or not isinstance(temps, list):
return {}
points: List[Dict[str, Any]] = []
for idx, raw_time in enumerate(times):
temp = _safe_float(temps[idx] if idx < len(temps) else None)
if temp is None:
continue
time_text = str(raw_time or "").strip()
if "T" in time_text:
time_text = time_text.split("T", 1)[1][:5]
elif len(time_text) > 5:
time_text = time_text[:5]
point: Dict[str, Any] = {"time": time_text, "temp": temp}
rad = _safe_float(radiation[idx] if isinstance(radiation, list) and idx < len(radiation) else None)
if rad is not None:
point["radiation"] = rad
points.append(point)
if not points:
return {}
max_point = max(points, key=lambda item: _safe_float(item.get("temp")) or -999.0)
sample_indexes = {
idx
for idx in range(len(points))
if idx % 2 == 0 or idx >= len(points) - 4 or points[idx] is max_point
}
samples = [points[idx] for idx in sorted(sample_indexes)][-14:]
return {
"sample_count": len(points),
"forecast_hourly_max": max_point,
"samples": samples,
}
def _compact_taf_context(raw_taf_data: Any) -> Dict[str, Any]:
if not isinstance(raw_taf_data, dict):
return {}
signal = raw_taf_data.get("signal") if isinstance(raw_taf_data.get("signal"), dict) else {}
source = signal or raw_taf_data
raw_taf = raw_taf_data.get("raw_taf") or source.get("raw_taf")
compact: Dict[str, Any] = {
"available": bool(source.get("available") or raw_taf),
"raw_taf": _compact_ai_text(raw_taf, 900),
"issue_time": raw_taf_data.get("issue_time") or source.get("issue_time"),
"valid_time_from": raw_taf_data.get("valid_time_from") or source.get("valid_time_from"),
"valid_time_to": raw_taf_data.get("valid_time_to") or source.get("valid_time_to"),
"peak_window": source.get("peak_window"),
"suppression_level": source.get("suppression_level"),
"disruption_level": source.get("disruption_level"),
"wind_shift": source.get("wind_shift"),
"wind_regimes": source.get("wind_regimes"),
"summary_zh": _compact_ai_text(source.get("summary_zh"), 260),
"summary_en": _compact_ai_text(source.get("summary_en"), 260),
}
segments = source.get("segments") if isinstance(source.get("segments"), list) else []
markers = source.get("markers") if isinstance(source.get("markers"), list) else []
if segments:
compact["segments"] = segments[:3]
if markers:
compact["markers"] = markers[:4]
return {key: value for key, value in compact.items() if value not in (None, "", [])}
def _compact_vertical_context(raw_vertical: Any) -> Dict[str, Any]:
if not isinstance(raw_vertical, dict):
return {}
keys = [
"source",
"window_start",
"window_end",
"suppression_risk",
"trigger_risk",
"mixing_strength",
"shear_risk",
"heating_setup",
"heating_score",
"summary_zh",
"summary_en",
]
compact: Dict[str, Any] = {}
for key in keys:
value = raw_vertical.get(key)
if isinstance(value, str):
value = _compact_ai_text(value, 280)
if value not in (None, "", []):
compact[key] = value
return compact
def _compact_intraday_context(raw_intraday: Any) -> Dict[str, Any]:
if not isinstance(raw_intraday, dict):
return {}
compact: Dict[str, Any] = {}
for key in [
"headline",
"headline_en",
"confidence",
"base_case_bucket",
"upside_bucket",
"downside_bucket",
"next_observation_time",
"peak_window",
]:
value = raw_intraday.get(key)
if isinstance(value, str):
value = _compact_ai_text(value, 220)
if value not in (None, "", []):
compact[key] = value
signals = raw_intraday.get("signal_contributions")
if isinstance(signals, list):
compact["signal_contributions"] = [
{
"label": item.get("label"),
"label_en": item.get("label_en"),
"direction": item.get("direction"),
"strength": item.get("strength"),
"summary": _compact_ai_text(item.get("summary"), 180),
"summary_en": _compact_ai_text(item.get("summary_en"), 180),
}
for item in signals[:4]
if isinstance(item, dict)
]
return compact
def _build_metar_decision_context(data: Dict[str, Any]) -> Dict[str, Any]:
today_obs = _compact_observation_points(data.get("metar_today_obs"), 36)
recent_obs = _compact_observation_points(data.get("metar_recent_obs"), 12)
settlement_obs = _compact_observation_points(data.get("settlement_today_obs"), 36)
airport_current = data.get("airport_current") if isinstance(data.get("airport_current"), dict) else {}
metar_status = data.get("metar_status") if isinstance(data.get("metar_status"), dict) else {}
source_obs = today_obs or recent_obs or settlement_obs
trend_source = recent_obs or source_obs[-4:]
last_point = source_obs[-1] if source_obs else {}
first_trend = trend_source[0] if trend_source else {}
last_trend = trend_source[-1] if trend_source else {}
max_point = None
for point in source_obs:
if max_point is None or float(point["temp"]) >= float(max_point["temp"]):
max_point = point
last_temp = _safe_float(last_point.get("temp"))
first_temp = _safe_float(first_trend.get("temp"))
trend_last_temp = _safe_float(last_trend.get("temp"))
trend_delta = (
trend_last_temp - first_temp
if trend_last_temp is not None and first_temp is not None and len(trend_source) >= 2
else None
)
station = data.get("risk") if isinstance(data.get("risk"), dict) else {}
current = data.get("current") if isinstance(data.get("current"), dict) else {}
settlement_station = data.get("settlement_station") if isinstance(data.get("settlement_station"), dict) else {}
settlement_source = str(
current.get("settlement_source")
or settlement_station.get("settlement_source")
or "metar"
).strip().lower()
is_hko = settlement_source == "hko"
source_label = "HKO" if is_hko else "METAR"
return {
"source": source_label,
"is_airport_metar": not is_hko,
"station": (
current.get("station_code")
or settlement_station.get("settlement_station_code")
or station.get("icao")
or airport_current.get("station_code")
),
"station_label": (
current.get("station_name")
or settlement_station.get("settlement_station_label")
or station.get("airport")
or airport_current.get("station_label")
),
"today_obs": today_obs[-12:],
"recent_obs": recent_obs[-8:],
"settlement_today_obs": settlement_obs[-12:],
"obs_count": len(source_obs),
"last_time": last_point.get("time"),
"last_temp": last_temp,
"max_temp": _safe_float((max_point or {}).get("temp")),
"max_time": (max_point or {}).get("time"),
"trend_delta": trend_delta,
"stale_for_today": bool(metar_status.get("stale_for_today")),
"available_for_today": bool(metar_status.get("available_for_today")),
"last_observation_time": metar_status.get("last_observation_time"),
"airport_current_temp": _safe_float(airport_current.get("temp")),
"airport_max_so_far": _safe_float(airport_current.get("max_so_far")),
"airport_obs_time": airport_current.get("obs_time"),
"airport_report_time": airport_current.get("report_time"),
"airport_raw_metar": airport_current.get("raw_metar"),
"airport_wx_desc": airport_current.get("wx_desc"),
"airport_cloud_desc": airport_current.get("cloud_desc"),
"airport_visibility_mi": _safe_float(airport_current.get("visibility_mi")),
"airport_wind_speed_kt": _safe_float(airport_current.get("wind_speed_kt")),
"airport_wind_dir": _safe_float(airport_current.get("wind_dir")),
"airport_humidity": _safe_float(airport_current.get("humidity")),
}
def _city_observation_anchor(data: Dict[str, Any]) -> Dict[str, Any]:
current = data.get("current") if isinstance(data.get("current"), dict) else {}
settlement_station = data.get("settlement_station") if isinstance(data.get("settlement_station"), dict) else {}
airport_current = data.get("airport_current") if isinstance(data.get("airport_current"), dict) else {}
risk = data.get("risk") if isinstance(data.get("risk"), dict) else {}
source = str(
current.get("settlement_source")
or settlement_station.get("settlement_source")
or "metar"
).strip().lower()
is_hko = source == "hko"
if is_hko:
station_code = (
current.get("station_code")
or settlement_station.get("settlement_station_code")
or "HKO"
)
station_label = (
current.get("station_name")
or settlement_station.get("settlement_station_label")
or "Hong Kong Observatory"
)
return {
"source": "hko",
"source_label": "Hong Kong Observatory",
"is_airport_metar": False,
"station_code": station_code,
"station_label": station_label,
"read_label_zh": "香港天文台观测解读",
"read_label_en": "Hong Kong Observatory observation read",
"instruction_zh": (
"该城市使用香港天文台/HKO 官方站点观测,提供温度、今日最高/最低温、相对湿度、"
"10分钟平均风速和风向数据。它不是机场 METAR,不得使用 METAR、TAF、机场报文、报文时间等称谓,"
"应使用「观测时间」「风速」「风向」「湿度」等官方气象站用语。"
"观测解读必须具体说明:最新观测时间、温度、风向风速对升温路径的影响(增温/降温/中性)、"
"湿度是否可能压制日间升温。涉及风时必须说明具体风向及来源(如「偏北风」「海风/陆风」)。"
"如果有今日最高温和最低温数据,应结合判断温度走势。"
),
"instruction_en": (
"This city uses Hong Kong Observatory/HKO official station observations, providing temperature, "
"today's high/low, relative humidity, 10-min mean wind speed and direction. "
"It is NOT an airport METAR; do not use METAR, TAF, airport bulletin, or report time terminology. "
"Use terms like observation time, wind speed, wind direction, humidity. "
"The observation read must be specific: latest observation time, temperature, whether the wind "
"direction tends to warm, cool or be neutral for today's high path and why, and whether humidity "
"may suppress daytime heating. When mentioning wind, specify the direction and source "
"(e.g. northerly, sea breeze/land breeze). If today's high/low are available, use them to assess the trend."
),
}
return {
"source": "metar",
"source_label": "METAR",
"is_airport_metar": True,
"station_code": risk.get("icao") or airport_current.get("station_code"),
"station_label": risk.get("airport") or airport_current.get("station_label"),
"read_label_zh": "机场报文解读",
"read_label_en": "airport-bulletin read",
"instruction_zh": "该城市使用机场 METAR/TAF 作为日内实况证据。",
"instruction_en": "This city uses airport METAR/TAF as intraday observation evidence.",
}
def _compact_ai_city_group(rows: List[Dict[str, Any]]) -> Dict[str, Any]:
first = rows[0]
return {
"city": first.get("city"),
"city_display_name": first.get("city_display_name") or first.get("display_name") or first.get("city"),
"selected_date": first.get("selected_date") or first.get("local_date"),
"local_time": first.get("local_time"),
"temp_symbol": first.get("temp_symbol") or first.get("target_unit"),
"current_temp": first.get("current_temp"),
"current_max_so_far": first.get("current_max_so_far"),
"window_phase": first.get("window_phase"),
"remaining_window_minutes": first.get("remaining_window_minutes"),
"peak_window_label": first.get("peak_window_label"),
"minutes_until_peak_start": first.get("minutes_until_peak_start"),
"minutes_until_peak_end": first.get("minutes_until_peak_end"),
"metar_context": first.get("metar_context") or {},
"model_cluster": {
"core_low": first.get("cluster_core_low"),
"core_high": first.get("cluster_core_high"),
"median": first.get("cluster_median"),
"deb_reference": first.get("cluster_deb_reference"),
"model_count": first.get("cluster_model_count"),
"sources": _compact_ai_model_sources(first),
},
"contracts": [_compact_ai_candidate(row) for row in rows],
}
def build_scan_ai_prompt(payload: Dict[str, Any], *, max_rows: int) -> Dict[str, Any]:
raw_rows = [
row
for row in (payload.get("rows") or [])[:max_rows]
if isinstance(row, dict) and row.get("id")
]
grouped: Dict[str, List[Dict[str, Any]]] = {}
for row in raw_rows:
key = "|".join(
[
_normalize_ai_city_key(row.get("city") or row.get("city_display_name")),
str(row.get("selected_date") or row.get("local_date") or ""),
]
)
grouped.setdefault(key, []).append(row)
cities = [_compact_ai_city_group(rows) for rows in grouped.values() if rows]
sent_contracts = sum(len(city.get("contracts") or []) for city in cities)
return {
"schema_version": "city_forecast_v1",
"snapshot_id": payload.get("snapshot_id"),
"generated_at": payload.get("generated_at"),
"summary": payload.get("summary") or {},
"filters": payload.get("filters") or {},
"city_count": len(cities),
"candidate_row_count": len(raw_rows),
"cities": cities,
"_polyweather_input_meta": {
"sent_cities": len(cities),
"sent_contracts": sent_contracts,
},
}
def _compact_probability_context(probabilities: Any, deb: Any, unit: str) -> dict:
if not isinstance(probabilities, dict):
return {}
dist = probabilities.get("distribution")
if not isinstance(dist, list) or not dist:
return {}
top_buckets = sorted(
[b for b in dist if isinstance(b, dict) and b.get("probability")],
key=lambda b: float(b.get("probability", 0)),
reverse=True,
)[:3]
compact = {
"top_buckets": [
{
"label": b.get("label", ""),
"prob": round(float(b.get("probability", 0)) * 100),
}
for b in top_buckets
],
}
mu = probabilities.get("mu")
if mu is not None:
compact["mu"] = mu
spread = (
round(float(probabilities.get("calibrated_sigma") or 0), 1)
or round(float(probabilities.get("raw_sigma") or 0), 1)
or None
)
if spread is not None:
compact["sigma"] = spread
deb_val = deb.get("prediction") if isinstance(deb, dict) else None
if deb_val is not None and mu is not None:
if deb_val > mu:
compact["skew"] = "right"
elif deb_val < mu:
compact["skew"] = "left"
else:
compact["skew"] = "centered"
if unit:
compact["unit"] = unit
return compact