2ee00f8016
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 .
1431 lines
55 KiB
Python
1431 lines
55 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import re
|
||
import threading
|
||
import time
|
||
import hashlib
|
||
from concurrent.futures import TimeoutError as FutureTimeoutError
|
||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||
from datetime import datetime
|
||
from typing import Any, Dict, Iterator, List, Optional
|
||
|
||
import httpx
|
||
from loguru import logger
|
||
|
||
from web.analysis_service import _analyze
|
||
from web.core import CITIES
|
||
from src.data_collection.city_registry import ALIASES
|
||
from web.scan_city_ai_fallback import (
|
||
_build_city_ai_fallback,
|
||
_complete_city_ai_payload,
|
||
)
|
||
from web.scan_city_ai_helpers import (
|
||
_extract_ai_json_object,
|
||
_extract_provider_stream_delta,
|
||
_provider_response_meta,
|
||
_safe_float,
|
||
)
|
||
from web.scan_city_ai_prompt import (
|
||
SCAN_CITY_AI_PROMPT_VERSION,
|
||
build_city_ai_stream_request,
|
||
)
|
||
from web.scan_city_ai_provider import (
|
||
_call_deepseek_city_ai as _call_deepseek_city_ai_provider,
|
||
)
|
||
from web.scan_terminal_cache import (
|
||
clear_scan_terminal_refreshing,
|
||
get_cached_scan_ai_result,
|
||
get_cached_scan_terminal_payload,
|
||
get_scan_terminal_cache_entry,
|
||
mark_scan_terminal_refreshing,
|
||
set_cached_scan_ai_result,
|
||
set_cached_scan_terminal_payload,
|
||
set_scan_terminal_failure_state,
|
||
)
|
||
from web.scan_terminal_city_row import _scan_city_terminal_rows
|
||
from web.scan_terminal_ai_compact import (
|
||
_build_metar_decision_context,
|
||
_city_observation_anchor,
|
||
_compact_hourly_context,
|
||
_compact_intraday_context,
|
||
_compact_observation_points,
|
||
_compact_probability_context,
|
||
_compact_taf_context,
|
||
_compact_vertical_context,
|
||
build_scan_ai_prompt,
|
||
)
|
||
from web.scan_terminal_ai_merge import _normalize_ai_items, merge_scan_ai_result
|
||
from web.scan_terminal_filters import (
|
||
normalize_scan_terminal_filters as _normalize_scan_terminal_filters,
|
||
)
|
||
from web.scan_terminal_payloads import (
|
||
build_failed_scan_terminal_payload,
|
||
build_scan_terminal_snapshot_id,
|
||
build_stale_scan_terminal_payload,
|
||
)
|
||
from web.scan_terminal_ranker import build_ranked_scan_terminal_result
|
||
|
||
_SCAN_CITY_AI_CACHE_LOCK = threading.Lock()
|
||
_SCAN_CITY_AI_CACHE: Dict[str, Dict[str, Any]] = {}
|
||
|
||
|
||
def _env_int(
|
||
name: str,
|
||
default: int,
|
||
*,
|
||
min_value: int,
|
||
max_value: Optional[int] = None,
|
||
) -> int:
|
||
try:
|
||
value = int(os.getenv(name, str(default)))
|
||
except Exception:
|
||
value = int(default)
|
||
value = max(int(min_value), value)
|
||
if max_value is not None:
|
||
value = min(int(max_value), value)
|
||
return value
|
||
|
||
|
||
SCAN_TERMINAL_PAYLOAD_TTL_SEC = max(
|
||
10,
|
||
int(os.getenv("POLYWEATHER_SCAN_TERMINAL_PAYLOAD_TTL_SEC", "120")),
|
||
)
|
||
SCAN_TERMINAL_BUILD_TIMEOUT_SEC = max(
|
||
8,
|
||
int(os.getenv("POLYWEATHER_SCAN_TERMINAL_BUILD_TIMEOUT_SEC", "22")),
|
||
)
|
||
DEFAULT_SCAN_AI_MODEL = "mimo-v2.5-pro"
|
||
DEFAULT_SCAN_AI_BASE_URL = "https://token-plan-cn.xiaomimimo.com/v1"
|
||
SCAN_AI_API_KEY_ENV_HINT = (
|
||
"POLYWEATHER_SCAN_AI_API_KEY "
|
||
"(or POLYWEATHER_MIMO_API_KEY / POLYWEATHER_DEEPSEEK_API_KEY)"
|
||
)
|
||
|
||
|
||
def _env_str(*names: str, default: str = "") -> str:
|
||
for name in names:
|
||
value = str(os.getenv(name) or "").strip()
|
||
if value:
|
||
return value
|
||
return str(default).strip()
|
||
|
||
|
||
def _scan_ai_api_key() -> str:
|
||
return _env_str(
|
||
"POLYWEATHER_SCAN_AI_API_KEY",
|
||
"POLYWEATHER_MIMO_API_KEY",
|
||
"POLYWEATHER_DEEPSEEK_API_KEY",
|
||
)
|
||
|
||
|
||
def _infer_scan_ai_provider(base_url: str, model: str) -> str:
|
||
text = f"{base_url} {model}".lower()
|
||
if "xiaomimimo" in text or "mimo" in text:
|
||
return "mimo"
|
||
if "deepseek" in text:
|
||
return "deepseek"
|
||
return "openai-compatible"
|
||
|
||
|
||
def _scan_ai_provider_label(provider: str) -> str:
|
||
normalized = provider.strip().lower()
|
||
if normalized == "mimo":
|
||
return "MiMo"
|
||
if normalized == "deepseek":
|
||
return "DeepSeek"
|
||
return "AI provider"
|
||
|
||
|
||
SCAN_AI_MODEL = _env_str("POLYWEATHER_SCAN_AI_MODEL", default=DEFAULT_SCAN_AI_MODEL)
|
||
SCAN_CITY_AI_MODEL = _env_str(
|
||
"POLYWEATHER_SCAN_CITY_AI_MODEL",
|
||
"POLYWEATHER_SCAN_AI_MODEL",
|
||
default=SCAN_AI_MODEL or DEFAULT_SCAN_AI_MODEL,
|
||
)
|
||
SCAN_AI_BASE_URL = _env_str(
|
||
"POLYWEATHER_SCAN_AI_BASE_URL",
|
||
"POLYWEATHER_MIMO_BASE_URL",
|
||
"POLYWEATHER_DEEPSEEK_BASE_URL",
|
||
default=DEFAULT_SCAN_AI_BASE_URL,
|
||
).rstrip("/")
|
||
SCAN_AI_PROVIDER = _env_str(
|
||
"POLYWEATHER_SCAN_AI_PROVIDER",
|
||
default=_infer_scan_ai_provider(SCAN_AI_BASE_URL, SCAN_CITY_AI_MODEL),
|
||
)
|
||
SCAN_AI_PROVIDER_LABEL = _env_str(
|
||
"POLYWEATHER_SCAN_AI_PROVIDER_LABEL",
|
||
default=_scan_ai_provider_label(SCAN_AI_PROVIDER),
|
||
)
|
||
SCAN_AI_ENABLED = str(
|
||
os.getenv("POLYWEATHER_SCAN_AI_ENABLED") or "false"
|
||
).strip().lower() in {"1", "true", "yes", "on"}
|
||
SCAN_AI_TIMEOUT_SEC = _env_int(
|
||
"POLYWEATHER_SCAN_AI_TIMEOUT_SEC",
|
||
40,
|
||
min_value=10,
|
||
max_value=120,
|
||
)
|
||
SCAN_CITY_AI_TIMEOUT_SEC = _env_int(
|
||
"POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC",
|
||
30,
|
||
min_value=10,
|
||
max_value=120,
|
||
)
|
||
SCAN_CITY_AI_RETRY_ON_STREAM_PARSE_ERROR = str(
|
||
os.getenv("POLYWEATHER_SCAN_CITY_AI_RETRY_ON_STREAM_PARSE_ERROR") or "false"
|
||
).strip().lower() in {"1", "true", "yes", "on"}
|
||
SCAN_AI_CACHE_TTL_SEC = max(
|
||
30,
|
||
int(os.getenv("POLYWEATHER_SCAN_AI_CACHE_TTL_SEC", "3600")),
|
||
)
|
||
SCAN_AI_MAX_ROWS = _env_int("POLYWEATHER_SCAN_AI_MAX_ROWS", 40, min_value=1)
|
||
SCAN_AI_MAX_TOKENS = _env_int(
|
||
"POLYWEATHER_SCAN_AI_MAX_TOKENS",
|
||
3200,
|
||
min_value=600,
|
||
max_value=64000,
|
||
)
|
||
SCAN_CITY_AI_MAX_TOKENS = _env_int(
|
||
"POLYWEATHER_SCAN_CITY_AI_MAX_TOKENS",
|
||
1200,
|
||
min_value=400,
|
||
max_value=64000,
|
||
)
|
||
SCAN_CITY_AI_STREAM_MAX_TOKENS = _env_int(
|
||
"POLYWEATHER_SCAN_CITY_AI_STREAM_MAX_TOKENS",
|
||
min(SCAN_CITY_AI_MAX_TOKENS, 1200),
|
||
min_value=400,
|
||
max_value=64000,
|
||
)
|
||
|
||
|
||
def _normalize_locale(value: Any) -> str:
|
||
text = str(value or "").strip().lower()
|
||
return "en-US" if text.startswith("en") else "zh-CN"
|
||
|
||
|
||
def _normalize_city_key(value: Any) -> str:
|
||
text = str(value or "").strip().lower().replace("-", " ")
|
||
text = re.sub(r"\s+", " ", text)
|
||
return ALIASES.get(text, text)
|
||
|
||
|
||
def _start_scan_terminal_background_refresh(filters: Dict[str, Any]) -> bool:
|
||
if not mark_scan_terminal_refreshing(filters):
|
||
return False
|
||
|
||
def _runner() -> None:
|
||
try:
|
||
_build_scan_terminal_payload_uncached(filters, force_refresh=True)
|
||
except Exception as exc: # pragma: no cover - defensive background guard
|
||
logger.warning("scan terminal background refresh failed: {}", exc)
|
||
finally:
|
||
clear_scan_terminal_refreshing(filters)
|
||
|
||
thread = threading.Thread(
|
||
target=_runner,
|
||
name="polyweather-scan-terminal-refresh",
|
||
daemon=True,
|
||
)
|
||
thread.start()
|
||
return True
|
||
|
||
|
||
def _call_deepseek_scan_ai(ai_input: Dict[str, Any]) -> Dict[str, Any]:
|
||
api_key = _scan_ai_api_key()
|
||
if not api_key:
|
||
raise RuntimeError(f"{SCAN_AI_API_KEY_ENV_HINT} is not configured")
|
||
|
||
system_prompt = (
|
||
"你是 PolyWeather 的付费 V4-Pro 城市最高温预测员。你只能基于用户提供的 JSON 快照做判断,"
|
||
"不得编造城市、价格、概率、盘口或天气数据。输入已经按城市分组,每城包含 DEB、"
|
||
"多个天气模型预测值 model_cluster.sources、METAR 实测序列、机场原始报文和候选合约。"
|
||
"你的首要任务不是分析套利,也不是推荐 BUY YES/NO,而是预测该城市今日最终最高温是多少。"
|
||
"必须输出城市级最高温点估计、置信区间、置信度、峰值窗口状态、机场报文解读和一句预测理由。"
|
||
"V4 禁止使用 EMOS、EMOS peak、EMOS probability、edge 或 Kelly 作为交易依据;"
|
||
"最高温预测必须直接参考该城市全部 model_cluster.sources、DEB、峰值窗口和 METAR/机场报文。"
|
||
"如果天气模型之间分歧大,必须放宽置信区间并降低 confidence;如果 METAR 与模型路径冲突,必须解释修正方向。"
|
||
"必须先判断 peak_window_label、minutes_until_peak_start/end 和 window_phase:峰值窗口尚未到来时,"
|
||
"不能因为 METAR 暂未触达目标温度就下最终结论,只能说明仍需峰值窗口验证;"
|
||
"必须检查 metar_context 的 today_obs/recent_obs、max_temp、last_temp、trend_delta、"
|
||
"airport_raw_metar、airport_wx_desc、airport_cloud_desc、airport_wind_* 和 stale 状态;"
|
||
"合约只作为下游映射:可以为每个候选 row_id 给出 forecast_match(core/edge/outside/watch)和一句原因,"
|
||
"但不要输出交易建议,不要使用套利、仓位、edge 或 Kelly 语言。必须输出 JSON object。"
|
||
)
|
||
model_snapshot = dict(ai_input)
|
||
model_snapshot.pop("_polyweather_input_meta", None)
|
||
user_payload = {
|
||
"task": (
|
||
"Return strict JSON only with: summary_zh, summary_en, city_forecasts, contract_notes. "
|
||
"city_forecasts items require city, predicted_max, range_low, range_high, unit, confidence, "
|
||
"peak_window_zh, peak_window_en, metar_read_zh, metar_read_en, reasoning_zh, reasoning_en, model_cluster_note. "
|
||
"contract_notes items are optional and require row_id, forecast_match, reason_zh, reason_en; "
|
||
"forecast_match must be one of core, edge, outside, watch. "
|
||
"Focus on final max temperature prediction; do not output recommendations/vetoed/downgraded unless needed for backward compatibility. "
|
||
"Do not mention EMOS, edge, Kelly, arbitrage, position size, or trading recommendation. "
|
||
"Keep every city forecast concise: one sentence for METAR read and one sentence for reasoning."
|
||
),
|
||
"snapshot": model_snapshot,
|
||
}
|
||
timeout = httpx.Timeout(
|
||
timeout=float(SCAN_AI_TIMEOUT_SEC),
|
||
connect=min(8.0, float(SCAN_AI_TIMEOUT_SEC)),
|
||
read=float(SCAN_AI_TIMEOUT_SEC),
|
||
write=10.0,
|
||
pool=5.0,
|
||
)
|
||
with httpx.Client(timeout=timeout) as client:
|
||
response = client.post(
|
||
f"{SCAN_AI_BASE_URL}/chat/completions",
|
||
headers={
|
||
"Authorization": f"Bearer {api_key}",
|
||
"Content-Type": "application/json",
|
||
},
|
||
json={
|
||
"model": SCAN_CITY_AI_MODEL,
|
||
"temperature": 0.1,
|
||
"max_tokens": SCAN_AI_MAX_TOKENS,
|
||
"response_format": {"type": "json_object"},
|
||
"messages": [
|
||
{"role": "system", "content": system_prompt},
|
||
{
|
||
"role": "user",
|
||
"content": json.dumps(user_payload, ensure_ascii=False),
|
||
},
|
||
],
|
||
},
|
||
)
|
||
response.raise_for_status()
|
||
data = response.json()
|
||
content = (
|
||
((data.get("choices") or [{}])[0].get("message") or {}).get("content")
|
||
if isinstance(data, dict)
|
||
else None
|
||
)
|
||
parsed = _extract_ai_json_object(str(content or ""))
|
||
if isinstance(data, dict):
|
||
parsed["_polyweather_meta"] = {
|
||
"usage": data.get("usage"),
|
||
"finish_reason": ((data.get("choices") or [{}])[0] or {}).get("finish_reason"),
|
||
}
|
||
return parsed
|
||
|
||
|
||
def _build_city_ai_prompt(data: Dict[str, Any]) -> Dict[str, Any]:
|
||
local_date = str(data.get("local_date") or "").strip()
|
||
multi_model_daily = data.get("multi_model_daily") if isinstance(data.get("multi_model_daily"), dict) else {}
|
||
daily_entry = multi_model_daily.get(local_date) if isinstance(multi_model_daily, dict) else {}
|
||
if not isinstance(daily_entry, dict):
|
||
daily_entry = {}
|
||
daily_models = daily_entry.get("models") if isinstance(daily_entry.get("models"), dict) else None
|
||
models = daily_models or (data.get("multi_model") if isinstance(data.get("multi_model"), dict) else {})
|
||
model_values = [_safe_float(value) for value in (models or {}).values()]
|
||
model_values = [value for value in model_values if value is not None]
|
||
metar_context = _build_metar_decision_context(data)
|
||
observation_anchor = _city_observation_anchor(data)
|
||
current = data.get("current") if isinstance(data.get("current"), dict) else {}
|
||
airport_current = data.get("airport_current") if isinstance(data.get("airport_current"), dict) else {}
|
||
airport_primary = data.get("airport_primary") if isinstance(data.get("airport_primary"), dict) else {}
|
||
risk = data.get("risk") if isinstance(data.get("risk"), dict) else {}
|
||
|
||
return {
|
||
"schema_version": "single_city_forecast_v2",
|
||
"prompt_version": SCAN_CITY_AI_PROMPT_VERSION,
|
||
"task": "predict_city_daily_high_and_read_observation",
|
||
"city": data.get("name"),
|
||
"city_display_name": data.get("display_name") or data.get("name"),
|
||
"local_date": local_date,
|
||
"local_time": data.get("local_time"),
|
||
"temp_symbol": data.get("temp_symbol"),
|
||
"timezone_offset_seconds": data.get("utc_offset_seconds"),
|
||
"observation_anchor": observation_anchor,
|
||
"settlement_station": data.get("settlement_station") or {},
|
||
"current": {
|
||
"temp": current.get("temp"),
|
||
"max_so_far": current.get("max_so_far"),
|
||
"max_temp_time": current.get("max_temp_time"),
|
||
"obs_time": current.get("obs_time"),
|
||
"station_code": current.get("station_code"),
|
||
"station_name": current.get("station_name"),
|
||
"wind_speed_kt": current.get("wind_speed_kt"),
|
||
"wind_dir": current.get("wind_dir"),
|
||
"humidity": current.get("humidity"),
|
||
"pressure_hpa": current.get("pressure_hpa"),
|
||
"observation_source": current.get("settlement_source"),
|
||
},
|
||
"airport": {
|
||
"name": risk.get("airport") or airport_current.get("station_label") or airport_primary.get("station_label"),
|
||
"icao": risk.get("icao") or airport_current.get("station_code") or airport_primary.get("station_code"),
|
||
"distance_km": risk.get("distance_km"),
|
||
},
|
||
"model_cluster": {
|
||
"sources": [
|
||
*([
|
||
{"model": "DEB (fusion)", "value": ((daily_entry.get("deb") or {}).get("prediction") if isinstance(daily_entry.get("deb"), dict) else None) or ((data.get("deb") or {}).get("prediction") if isinstance(data.get("deb"), dict) else None)}
|
||
] if (((daily_entry.get("deb") or {}).get("prediction") if isinstance(daily_entry.get("deb"), dict) else None) or ((data.get("deb") or {}).get("prediction") if isinstance(data.get("deb"), dict) else None)) is not None else []),
|
||
*[
|
||
{"model": str(name), "value": value}
|
||
for name, value in (models or {}).items()
|
||
if _safe_float(value) is not None
|
||
],
|
||
],
|
||
"model_count": len(model_values) + (1 if (((daily_entry.get("deb") or {}).get("prediction") if isinstance(daily_entry.get("deb"), dict) else None) or ((data.get("deb") or {}).get("prediction") if isinstance(data.get("deb"), dict) else None)) is not None else 0),
|
||
"min": min(model_values) if model_values else None,
|
||
"max": max(model_values) if model_values else None,
|
||
"spread": (max(model_values) - min(model_values)) if len(model_values) >= 2 else None,
|
||
},
|
||
"peak": data.get("peak") or {},
|
||
"metar_context": metar_context,
|
||
"airport_current": {
|
||
"temp": airport_current.get("temp"),
|
||
"obs_time": airport_current.get("obs_time"),
|
||
"report_time": airport_current.get("report_time"),
|
||
"receipt_time": airport_current.get("receipt_time"),
|
||
"wind_speed_kt": airport_current.get("wind_speed_kt"),
|
||
"wind_dir": airport_current.get("wind_dir"),
|
||
"humidity": airport_current.get("humidity"),
|
||
"cloud_desc": airport_current.get("cloud_desc"),
|
||
"visibility_mi": airport_current.get("visibility_mi"),
|
||
"wx_desc": airport_current.get("wx_desc"),
|
||
"raw_metar": airport_current.get("raw_metar"),
|
||
"station_code": airport_current.get("station_code"),
|
||
"station_label": airport_current.get("station_label"),
|
||
},
|
||
"taf": _compact_taf_context(data.get("taf")),
|
||
"probability": _compact_probability_context(
|
||
data.get("probabilities") if isinstance(data.get("probabilities"), dict) else None,
|
||
data.get("deb") if isinstance(data.get("deb"), dict) else None,
|
||
data.get("temp_symbol"),
|
||
),
|
||
"vertical_profile_signal": _compact_vertical_context(
|
||
data.get("vertical_profile_signal")
|
||
),
|
||
"intraday_meteorology": _compact_intraday_context(
|
||
data.get("intraday_meteorology")
|
||
),
|
||
"hourly": _compact_hourly_context(data.get("hourly")),
|
||
"metar_today_obs": _compact_observation_points(data.get("metar_today_obs"), 18),
|
||
"metar_recent_obs": _compact_observation_points(data.get("metar_recent_obs"), 8),
|
||
"settlement_today_obs": _compact_observation_points(
|
||
data.get("settlement_today_obs"),
|
||
18,
|
||
),
|
||
}
|
||
|
||
|
||
def _call_deepseek_city_ai(ai_input: Dict[str, Any], *, locale: str = "zh-CN") -> Dict[str, Any]:
|
||
return _call_deepseek_city_ai_provider(
|
||
ai_input,
|
||
locale=locale,
|
||
api_key=_scan_ai_api_key(),
|
||
base_url=SCAN_AI_BASE_URL,
|
||
model=SCAN_CITY_AI_MODEL,
|
||
max_tokens=SCAN_CITY_AI_MAX_TOKENS,
|
||
timeout_sec=SCAN_CITY_AI_TIMEOUT_SEC,
|
||
)
|
||
|
||
|
||
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 {}
|
||
metar_context = ai_input.get("metar_context") if isinstance(ai_input.get("metar_context"), dict) else {}
|
||
key_payload = {
|
||
"prompt_version": SCAN_CITY_AI_PROMPT_VERSION,
|
||
"city": ai_input.get("city"),
|
||
"local_date": ai_input.get("local_date"),
|
||
"station": observation_anchor.get("station_code"),
|
||
"raw_metar": airport_current.get("raw_metar") if is_airport_metar else None,
|
||
"obs_time": airport_current.get("obs_time") or metar_context.get("last_observation_time"),
|
||
"stale_for_today": metar_context.get("stale_for_today"),
|
||
}
|
||
raw = json.dumps(key_payload, sort_keys=True, ensure_ascii=False, default=str)
|
||
return "city-ai:" + hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||
|
||
|
||
def _quick_metar_cache_key(data: Dict[str, Any]) -> str:
|
||
airport_current = data.get("airport_current") if isinstance(data.get("airport_current"), dict) else {}
|
||
current = data.get("current") if isinstance(data.get("current"), dict) else {}
|
||
observation_anchor = data.get("observation_anchor") if isinstance(data.get("observation_anchor"), dict) else {}
|
||
raw_metar = airport_current.get("raw_metar") or current.get("raw_metar")
|
||
obs_time = airport_current.get("obs_time") or current.get("obs_time")
|
||
if raw_metar and obs_time:
|
||
finger = {
|
||
"city": data.get("name"),
|
||
"raw_metar": raw_metar,
|
||
"obs_time": obs_time,
|
||
"station": observation_anchor.get("station_code"),
|
||
"prompt_version": SCAN_CITY_AI_PROMPT_VERSION,
|
||
}
|
||
raw = json.dumps(finger, sort_keys=True, ensure_ascii=False, default=str)
|
||
return "city-ai:" + hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||
return ""
|
||
|
||
|
||
def _sse_event(event: str, payload: Dict[str, Any]) -> str:
|
||
return (
|
||
f"event: {event}\n"
|
||
f"data: {json.dumps(payload, ensure_ascii=False, default=str)}\n\n"
|
||
)
|
||
|
||
|
||
def _build_city_ai_stream_request(
|
||
ai_input: Dict[str, Any],
|
||
*,
|
||
locale: str,
|
||
) -> Dict[str, Any]:
|
||
return build_city_ai_stream_request(
|
||
ai_input,
|
||
locale=locale,
|
||
model=SCAN_CITY_AI_MODEL,
|
||
max_tokens=SCAN_CITY_AI_STREAM_MAX_TOKENS,
|
||
)
|
||
|
||
|
||
def _cache_city_ai_payload(
|
||
cache_key: str,
|
||
*,
|
||
data: Dict[str, Any],
|
||
generated_at: str,
|
||
ai_raw: Dict[str, Any],
|
||
quick_key: str = "",
|
||
) -> None:
|
||
entry = {
|
||
"expires_at": time.time() + SCAN_AI_CACHE_TTL_SEC,
|
||
"generated_at": generated_at,
|
||
"city": data.get("name"),
|
||
"city_display_name": data.get("display_name"),
|
||
"payload": ai_raw,
|
||
}
|
||
with _SCAN_CITY_AI_CACHE_LOCK:
|
||
_SCAN_CITY_AI_CACHE[cache_key] = entry
|
||
if quick_key and quick_key != cache_key:
|
||
_SCAN_CITY_AI_CACHE[quick_key] = entry
|
||
|
||
|
||
def _is_city_ai_fallback(ai_raw: Any) -> bool:
|
||
if not isinstance(ai_raw, dict):
|
||
return True
|
||
meta = ai_raw.get("_polyweather_meta")
|
||
return bool(isinstance(meta, dict) and meta.get("fallback"))
|
||
|
||
|
||
def _build_city_ai_result_payload(
|
||
*,
|
||
data: Dict[str, Any],
|
||
generated_at: str,
|
||
started_at: float,
|
||
ai_raw: Dict[str, Any],
|
||
cached: bool = False,
|
||
degraded: bool = False,
|
||
reason: Optional[str] = None,
|
||
reason_zh: Optional[str] = None,
|
||
reason_en: Optional[str] = None,
|
||
) -> Dict[str, Any]:
|
||
payload: Dict[str, Any] = {
|
||
"status": "ready",
|
||
"cached": cached,
|
||
"model": SCAN_CITY_AI_MODEL,
|
||
"provider": SCAN_AI_PROVIDER,
|
||
"city": data.get("name"),
|
||
"city_display_name": data.get("display_name"),
|
||
"generated_at": generated_at,
|
||
"duration_ms": int((time.time() - started_at) * 1000),
|
||
"city_forecast": ai_raw,
|
||
}
|
||
if degraded:
|
||
payload["degraded"] = True
|
||
if reason:
|
||
payload["reason"] = reason
|
||
if reason_zh:
|
||
payload["reason_zh"] = reason_zh
|
||
if reason_en:
|
||
payload["reason_en"] = reason_en
|
||
return payload
|
||
|
||
|
||
def stream_scan_city_ai_forecast_payload(
|
||
city: str,
|
||
*,
|
||
force_refresh: bool = False,
|
||
locale: str = "zh-CN",
|
||
) -> Iterator[str]:
|
||
started_at = time.time()
|
||
city_name = _normalize_city_key(city)
|
||
normalized_locale = _normalize_locale(locale)
|
||
if not city_name:
|
||
yield _sse_event("final", {"status": "failed", "reason": "city is required"})
|
||
return
|
||
if city_name not in CITIES:
|
||
reason_en = f"Unknown city: {city_name}"
|
||
reason_zh = f"未知城市:{city_name}"
|
||
yield _sse_event(
|
||
"final",
|
||
{
|
||
"status": "failed",
|
||
"model": SCAN_CITY_AI_MODEL,
|
||
"provider": SCAN_AI_PROVIDER,
|
||
"city": city_name,
|
||
"city_display_name": str(city or "").strip() or city_name,
|
||
"reason": reason_en if normalized_locale == "en-US" else reason_zh,
|
||
"reason_en": reason_en,
|
||
"reason_zh": reason_zh,
|
||
},
|
||
)
|
||
return
|
||
|
||
yield _sse_event(
|
||
"progress",
|
||
{
|
||
"stage": "loading_city",
|
||
"message_zh": "正在读取城市实况、模型和最新观测…",
|
||
"message_en": "Loading city observations, model cluster and latest observation…",
|
||
},
|
||
)
|
||
data = _analyze(
|
||
city_name,
|
||
force_refresh=False,
|
||
include_llm_commentary=False,
|
||
detail_mode="full",
|
||
)
|
||
ai_input = _build_city_ai_prompt(data)
|
||
cache_key = _scan_city_ai_cache_key(ai_input)
|
||
quick_key = _quick_metar_cache_key(data)
|
||
if not force_refresh:
|
||
with _SCAN_CITY_AI_CACHE_LOCK:
|
||
cached = _SCAN_CITY_AI_CACHE.get(cache_key) or (
|
||
_SCAN_CITY_AI_CACHE.get(quick_key) if quick_key else None
|
||
)
|
||
if cached and cached.get("expires_at", 0) >= time.time():
|
||
yield _sse_event(
|
||
"final",
|
||
{
|
||
"status": "ready",
|
||
"cached": True,
|
||
"model": SCAN_CITY_AI_MODEL,
|
||
"provider": SCAN_AI_PROVIDER,
|
||
"city": cached.get("city") or city_name,
|
||
"city_display_name": cached.get("city_display_name") or city_name,
|
||
"generated_at": cached.get("generated_at"),
|
||
"duration_ms": 0,
|
||
"city_forecast": cached.get("payload"),
|
||
},
|
||
)
|
||
return
|
||
preview_raw = _build_city_ai_fallback(
|
||
ai_input,
|
||
locale=normalized_locale,
|
||
reason="stream preview",
|
||
)
|
||
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
|
||
calling_message_zh = (
|
||
f"{SCAN_AI_PROVIDER_LABEL} 正在快速增强机场报文解读…"
|
||
if is_airport_metar
|
||
else f"{SCAN_AI_PROVIDER_LABEL} 正在快速增强香港天文台观测解读…"
|
||
)
|
||
calling_message_en = (
|
||
f"{SCAN_AI_PROVIDER_LABEL} is adding a fast airport-bulletin enhancement…"
|
||
if is_airport_metar
|
||
else f"{SCAN_AI_PROVIDER_LABEL} is adding a fast HKO observation enhancement…"
|
||
)
|
||
yield _sse_event(
|
||
"preview",
|
||
{
|
||
"city": data.get("name") or city_name,
|
||
"city_display_name": data.get("display_name") or city_name,
|
||
"metar_read_zh": preview_raw.get("metar_read_zh"),
|
||
"metar_read_en": preview_raw.get("metar_read_en"),
|
||
"final_judgment_zh": preview_raw.get("final_judgment_zh"),
|
||
"final_judgment_en": preview_raw.get("final_judgment_en"),
|
||
"model_cluster_note_zh": preview_raw.get("model_cluster_note_zh"),
|
||
"model_cluster_note_en": preview_raw.get("model_cluster_note_en"),
|
||
"predicted_max": preview_raw.get("predicted_max"),
|
||
"range_low": preview_raw.get("range_low"),
|
||
"range_high": preview_raw.get("range_high"),
|
||
"confidence": preview_raw.get("confidence"),
|
||
"unit": preview_raw.get("unit"),
|
||
},
|
||
)
|
||
yield _sse_event(
|
||
"progress",
|
||
{
|
||
"stage": "calling_ai",
|
||
"city": data.get("name") or city_name,
|
||
"city_display_name": data.get("display_name") or city_name,
|
||
"message_zh": calling_message_zh,
|
||
"message_en": calling_message_en,
|
||
},
|
||
)
|
||
|
||
if not SCAN_AI_ENABLED:
|
||
yield _sse_event(
|
||
"final",
|
||
{
|
||
"status": "disabled",
|
||
"model": SCAN_CITY_AI_MODEL,
|
||
"provider": SCAN_AI_PROVIDER,
|
||
"city": data.get("name") or city_name,
|
||
"city_display_name": data.get("display_name") or city_name,
|
||
"reason": "POLYWEATHER_SCAN_AI_ENABLED is not enabled",
|
||
},
|
||
)
|
||
return
|
||
if not _scan_ai_api_key():
|
||
yield _sse_event(
|
||
"final",
|
||
{
|
||
"status": "missing_key",
|
||
"model": SCAN_CITY_AI_MODEL,
|
||
"provider": SCAN_AI_PROVIDER,
|
||
"city": data.get("name") or city_name,
|
||
"city_display_name": data.get("display_name") or city_name,
|
||
"reason": f"{SCAN_AI_API_KEY_ENV_HINT} is not configured",
|
||
},
|
||
)
|
||
return
|
||
|
||
request_json = _build_city_ai_stream_request(ai_input, locale=normalized_locale)
|
||
timeout = httpx.Timeout(
|
||
timeout=float(SCAN_CITY_AI_TIMEOUT_SEC),
|
||
connect=min(8.0, float(SCAN_CITY_AI_TIMEOUT_SEC)),
|
||
read=float(SCAN_CITY_AI_TIMEOUT_SEC),
|
||
write=10.0,
|
||
pool=5.0,
|
||
)
|
||
headers = {
|
||
"Authorization": f"Bearer {_scan_ai_api_key()}",
|
||
"Content-Type": "application/json",
|
||
}
|
||
accumulated = ""
|
||
last_meta: Dict[str, Any] = {}
|
||
try:
|
||
logger.info(
|
||
"scan city AI stream request city={} locale={} input_bytes={} max_tokens={} timeout_sec={}",
|
||
ai_input.get("city"),
|
||
normalized_locale,
|
||
len(json.dumps(request_json, ensure_ascii=False, default=str).encode("utf-8")),
|
||
request_json.get("max_tokens"),
|
||
SCAN_CITY_AI_TIMEOUT_SEC,
|
||
)
|
||
with httpx.Client(timeout=timeout) as client:
|
||
with client.stream(
|
||
"POST",
|
||
f"{SCAN_AI_BASE_URL}/chat/completions",
|
||
headers=headers,
|
||
json=request_json,
|
||
) as response:
|
||
response.raise_for_status()
|
||
for line in response.iter_lines():
|
||
text = str(line or "").strip()
|
||
if not text or not text.startswith("data:"):
|
||
continue
|
||
payload_text = text[5:].strip()
|
||
if payload_text == "[DONE]":
|
||
break
|
||
try:
|
||
chunk = json.loads(payload_text)
|
||
except Exception:
|
||
continue
|
||
last_meta = _provider_response_meta(chunk) or last_meta
|
||
delta = _extract_provider_stream_delta(chunk)
|
||
if delta:
|
||
accumulated += delta
|
||
yield _sse_event(
|
||
"delta",
|
||
{
|
||
"content": delta,
|
||
"raw_length": len(accumulated),
|
||
},
|
||
)
|
||
degraded = False
|
||
degraded_reason: Optional[str] = None
|
||
try:
|
||
ai_raw = _extract_ai_json_object(accumulated)
|
||
if isinstance(ai_raw, dict):
|
||
ai_raw["_polyweather_meta"] = {
|
||
**last_meta,
|
||
"streamed": True,
|
||
}
|
||
ai_raw = _complete_city_ai_payload(
|
||
ai_raw,
|
||
ai_input,
|
||
locale=normalized_locale,
|
||
)
|
||
except Exception as exc:
|
||
retry_reason = str(exc)
|
||
if SCAN_CITY_AI_RETRY_ON_STREAM_PARSE_ERROR:
|
||
yield _sse_event(
|
||
"progress",
|
||
{
|
||
"stage": "retry_non_stream",
|
||
"message_zh": "流式内容为空或 JSON 不完整,正在改用非流式严格 JSON 重试…",
|
||
"message_en": "Stream content was empty or incomplete JSON; retrying with a strict non-stream request…",
|
||
"raw_length": len(accumulated),
|
||
"reason": retry_reason,
|
||
},
|
||
)
|
||
try:
|
||
ai_raw = _call_deepseek_city_ai(ai_input, locale=normalized_locale)
|
||
if isinstance(ai_raw, dict):
|
||
meta = ai_raw.get("_polyweather_meta")
|
||
if not isinstance(meta, dict):
|
||
meta = {}
|
||
ai_raw["_polyweather_meta"] = {
|
||
**meta,
|
||
"streamed": False,
|
||
"stream_retry_non_stream": True,
|
||
"stream_retry_reason": retry_reason,
|
||
"stream_raw_length": len(accumulated),
|
||
}
|
||
if _is_city_ai_fallback(ai_raw):
|
||
degraded = True
|
||
degraded_reason = retry_reason
|
||
except Exception as retry_exc:
|
||
degraded = True
|
||
degraded_reason = str(retry_exc)
|
||
ai_raw = _build_city_ai_fallback(
|
||
ai_input,
|
||
locale=normalized_locale,
|
||
reason=degraded_reason or retry_reason,
|
||
raw_content=accumulated,
|
||
)
|
||
else:
|
||
degraded = True
|
||
degraded_reason = retry_reason
|
||
ai_raw = _build_city_ai_fallback(
|
||
ai_input,
|
||
locale=normalized_locale,
|
||
reason=retry_reason,
|
||
raw_content=accumulated,
|
||
)
|
||
generated_at = datetime.utcnow().isoformat() + "Z"
|
||
if not _is_city_ai_fallback(ai_raw):
|
||
_cache_city_ai_payload(
|
||
cache_key,
|
||
data=data,
|
||
generated_at=generated_at,
|
||
ai_raw=ai_raw,
|
||
quick_key=quick_key,
|
||
)
|
||
yield _sse_event(
|
||
"final",
|
||
_build_city_ai_result_payload(
|
||
data=data,
|
||
generated_at=generated_at,
|
||
started_at=started_at,
|
||
ai_raw=ai_raw,
|
||
degraded=degraded,
|
||
reason=degraded_reason,
|
||
reason_en=degraded_reason,
|
||
reason_zh=degraded_reason,
|
||
),
|
||
)
|
||
except httpx.TimeoutException as exc:
|
||
duration_ms = int((time.time() - started_at) * 1000)
|
||
reason_en = f"{SCAN_AI_PROVIDER_LABEL} city AI timed out after {SCAN_CITY_AI_TIMEOUT_SEC}s"
|
||
reason_zh = f"{SCAN_AI_PROVIDER_LABEL} 城市 AI 在 {SCAN_CITY_AI_TIMEOUT_SEC} 秒内未返回"
|
||
logger.warning(
|
||
"scan city AI stream timeout fallback city={} duration_ms={} model={} error={}",
|
||
data.get("name") or city_name,
|
||
duration_ms,
|
||
SCAN_CITY_AI_MODEL,
|
||
exc,
|
||
)
|
||
ai_raw = _build_city_ai_fallback(
|
||
ai_input,
|
||
locale=normalized_locale,
|
||
reason=reason_en if normalized_locale == "en-US" else reason_zh,
|
||
raw_content=accumulated,
|
||
)
|
||
generated_at = datetime.utcnow().isoformat() + "Z"
|
||
yield _sse_event(
|
||
"final",
|
||
_build_city_ai_result_payload(
|
||
data=data,
|
||
generated_at=generated_at,
|
||
started_at=started_at,
|
||
ai_raw=ai_raw,
|
||
degraded=True,
|
||
reason=reason_en if normalized_locale == "en-US" else reason_zh,
|
||
reason_en=reason_en,
|
||
reason_zh=reason_zh,
|
||
),
|
||
)
|
||
except Exception as exc:
|
||
reason = str(exc)
|
||
logger.warning(
|
||
"scan city AI stream failed city={} model={} error={}",
|
||
data.get("name") or city_name,
|
||
SCAN_CITY_AI_MODEL,
|
||
reason,
|
||
)
|
||
yield _sse_event(
|
||
"final",
|
||
{
|
||
"status": "failed",
|
||
"model": SCAN_CITY_AI_MODEL,
|
||
"provider": SCAN_AI_PROVIDER,
|
||
"city": data.get("name") or city_name,
|
||
"city_display_name": data.get("display_name") or city_name,
|
||
"duration_ms": int((time.time() - started_at) * 1000),
|
||
"reason": reason,
|
||
"reason_en": reason,
|
||
"reason_zh": reason,
|
||
"raw_reason": reason,
|
||
},
|
||
)
|
||
|
||
|
||
def build_scan_city_ai_forecast_payload(
|
||
city: str,
|
||
*,
|
||
force_refresh: bool = False,
|
||
locale: str = "zh-CN",
|
||
) -> Dict[str, Any]:
|
||
started_at = time.time()
|
||
city_name = _normalize_city_key(city)
|
||
normalized_locale = _normalize_locale(locale)
|
||
if not city_name:
|
||
return {"status": "failed", "reason": "city is required"}
|
||
if city_name not in CITIES:
|
||
return {
|
||
"status": "failed",
|
||
"model": SCAN_CITY_AI_MODEL,
|
||
"provider": SCAN_AI_PROVIDER,
|
||
"city": city_name,
|
||
"city_display_name": str(city or "").strip() or city_name,
|
||
"reason": (
|
||
f"Unknown city: {city_name}"
|
||
if normalized_locale == "en-US"
|
||
else f"未知城市:{city_name}"
|
||
),
|
||
"reason_en": f"Unknown city: {city_name}",
|
||
"reason_zh": f"未知城市:{city_name}",
|
||
}
|
||
logger.info(
|
||
"scan city AI forecast requested city={} force_refresh={} locale={} model={}",
|
||
city_name,
|
||
force_refresh,
|
||
normalized_locale,
|
||
SCAN_CITY_AI_MODEL,
|
||
)
|
||
data = _analyze(
|
||
city_name,
|
||
force_refresh=False,
|
||
include_llm_commentary=False,
|
||
detail_mode="full",
|
||
)
|
||
ai_input = _build_city_ai_prompt(data)
|
||
cache_key = _scan_city_ai_cache_key(ai_input)
|
||
quick_key = _quick_metar_cache_key(data)
|
||
if not force_refresh:
|
||
with _SCAN_CITY_AI_CACHE_LOCK:
|
||
cached = _SCAN_CITY_AI_CACHE.get(cache_key) or (
|
||
_SCAN_CITY_AI_CACHE.get(quick_key) if quick_key else None
|
||
)
|
||
if cached and cached.get("expires_at", 0) >= time.time():
|
||
logger.info(
|
||
"scan city AI forecast cache hit city={} model={}",
|
||
cached.get("city") or city_name,
|
||
SCAN_CITY_AI_MODEL,
|
||
)
|
||
return {
|
||
"status": "ready",
|
||
"cached": True,
|
||
"model": SCAN_CITY_AI_MODEL,
|
||
"provider": SCAN_AI_PROVIDER,
|
||
"city": cached.get("city") or city_name,
|
||
"city_display_name": cached.get("city_display_name") or city_name,
|
||
"generated_at": cached.get("generated_at"),
|
||
"duration_ms": 0,
|
||
"city_forecast": cached.get("payload"),
|
||
}
|
||
|
||
if not SCAN_AI_ENABLED:
|
||
logger.warning(
|
||
"scan city AI forecast disabled city={} model={}",
|
||
data.get("name") or city_name,
|
||
SCAN_CITY_AI_MODEL,
|
||
)
|
||
return {
|
||
"status": "disabled",
|
||
"model": SCAN_CITY_AI_MODEL,
|
||
"provider": SCAN_AI_PROVIDER,
|
||
"city": data.get("name") or city_name,
|
||
"city_display_name": data.get("display_name") or city_name,
|
||
"reason": "POLYWEATHER_SCAN_AI_ENABLED is not enabled",
|
||
}
|
||
if not _scan_ai_api_key():
|
||
logger.warning(
|
||
"scan city AI forecast missing provider key city={} model={}",
|
||
data.get("name") or city_name,
|
||
SCAN_CITY_AI_MODEL,
|
||
)
|
||
return {
|
||
"status": "missing_key",
|
||
"model": SCAN_CITY_AI_MODEL,
|
||
"provider": SCAN_AI_PROVIDER,
|
||
"city": data.get("name") or city_name,
|
||
"city_display_name": data.get("display_name") or city_name,
|
||
"reason": f"{SCAN_AI_API_KEY_ENV_HINT} is not configured",
|
||
}
|
||
|
||
try:
|
||
logger.info(
|
||
"scan city AI forecast calling provider city={} station={} model={} raw_metar_present={}",
|
||
data.get("name") or city_name,
|
||
((ai_input.get("airport") or {}).get("icao") if isinstance(ai_input.get("airport"), dict) else None),
|
||
SCAN_CITY_AI_MODEL,
|
||
bool(
|
||
(ai_input.get("airport_current") or {}).get("raw_metar")
|
||
if isinstance(ai_input.get("airport_current"), dict)
|
||
else False
|
||
),
|
||
)
|
||
ai_raw = _call_deepseek_city_ai(ai_input, locale=normalized_locale)
|
||
except httpx.TimeoutException as exc:
|
||
duration_ms = int((time.time() - started_at) * 1000)
|
||
reason_en = f"{SCAN_AI_PROVIDER_LABEL} city AI timed out after {SCAN_CITY_AI_TIMEOUT_SEC}s"
|
||
reason_zh = f"{SCAN_AI_PROVIDER_LABEL} 城市 AI 在 {SCAN_CITY_AI_TIMEOUT_SEC} 秒内未返回"
|
||
ai_raw = _build_city_ai_fallback(
|
||
ai_input,
|
||
locale=normalized_locale,
|
||
reason=reason_en if normalized_locale == "en-US" else reason_zh,
|
||
)
|
||
generated_at = datetime.utcnow().isoformat() + "Z"
|
||
logger.warning(
|
||
"scan city AI forecast timeout fallback city={} duration_ms={} model={} error={}",
|
||
data.get("name") or city_name,
|
||
duration_ms,
|
||
SCAN_CITY_AI_MODEL,
|
||
exc,
|
||
)
|
||
return {
|
||
"status": "ready",
|
||
"degraded": True,
|
||
"cached": False,
|
||
"model": SCAN_CITY_AI_MODEL,
|
||
"provider": SCAN_AI_PROVIDER,
|
||
"city": data.get("name") or city_name,
|
||
"city_display_name": data.get("display_name") or city_name,
|
||
"generated_at": generated_at,
|
||
"duration_ms": duration_ms,
|
||
"reason": reason_en if normalized_locale == "en-US" else reason_zh,
|
||
"reason_en": reason_en,
|
||
"reason_zh": reason_zh,
|
||
"city_forecast": ai_raw,
|
||
}
|
||
except Exception as exc:
|
||
duration_ms = int((time.time() - started_at) * 1000)
|
||
raw_reason = str(exc)
|
||
empty_ai_content = raw_reason.strip().lower() == "empty ai content"
|
||
reason_en = (
|
||
f"{SCAN_AI_PROVIDER_LABEL} city AI returned no usable text. Retry the city analysis."
|
||
if empty_ai_content
|
||
else raw_reason
|
||
)
|
||
reason_zh = (
|
||
f"{SCAN_AI_PROVIDER_LABEL} 城市 AI 没有返回有效正文,请刷新重试。"
|
||
if empty_ai_content
|
||
else raw_reason
|
||
)
|
||
logger.warning(
|
||
"scan city AI forecast failed city={} duration_ms={} model={} error={}",
|
||
data.get("name") or city_name,
|
||
duration_ms,
|
||
SCAN_CITY_AI_MODEL,
|
||
raw_reason,
|
||
)
|
||
ai_raw = _build_city_ai_fallback(
|
||
ai_input,
|
||
locale=normalized_locale,
|
||
reason=reason_en if normalized_locale == "en-US" else reason_zh,
|
||
)
|
||
generated_at = datetime.utcnow().isoformat() + "Z"
|
||
return {
|
||
"status": "ready",
|
||
"degraded": True,
|
||
"cached": False,
|
||
"model": SCAN_CITY_AI_MODEL,
|
||
"provider": SCAN_AI_PROVIDER,
|
||
"city": data.get("name") or city_name,
|
||
"city_display_name": data.get("display_name") or city_name,
|
||
"generated_at": generated_at,
|
||
"duration_ms": duration_ms,
|
||
"reason": reason_en if normalized_locale == "en-US" else reason_zh,
|
||
"reason_en": reason_en,
|
||
"reason_zh": reason_zh,
|
||
"raw_reason": raw_reason,
|
||
"city_forecast": ai_raw,
|
||
}
|
||
generated_at = datetime.utcnow().isoformat() + "Z"
|
||
_cache_city_ai_payload(
|
||
cache_key,
|
||
data=data,
|
||
generated_at=generated_at,
|
||
ai_raw=ai_raw,
|
||
quick_key=quick_key,
|
||
)
|
||
logger.info(
|
||
"scan city AI forecast complete city={} duration_ms={} model={} confidence={}",
|
||
data.get("name") or city_name,
|
||
int((time.time() - started_at) * 1000),
|
||
SCAN_CITY_AI_MODEL,
|
||
ai_raw.get("confidence") if isinstance(ai_raw, dict) else None,
|
||
)
|
||
return {
|
||
"status": "ready",
|
||
"cached": False,
|
||
"model": SCAN_CITY_AI_MODEL,
|
||
"provider": SCAN_AI_PROVIDER,
|
||
"city": data.get("name") or city_name,
|
||
"city_display_name": data.get("display_name") or city_name,
|
||
"generated_at": generated_at,
|
||
"duration_ms": int((time.time() - started_at) * 1000),
|
||
"city_forecast": ai_raw,
|
||
}
|
||
|
||
|
||
def _build_scan_ai_unavailable_payload(
|
||
payload: Dict[str, Any],
|
||
*,
|
||
status: str,
|
||
reason: str,
|
||
duration_ms: Optional[int] = None,
|
||
) -> Dict[str, Any]:
|
||
return {
|
||
**payload,
|
||
"ai_scan": {
|
||
"status": status,
|
||
"stage": "fallback",
|
||
"model": SCAN_AI_MODEL,
|
||
"cached": False,
|
||
"generated_at": datetime.utcnow().isoformat() + "Z",
|
||
"snapshot_id": payload.get("snapshot_id"),
|
||
"input_rows": len(payload.get("rows") or []),
|
||
"sent_rows": min(len(payload.get("rows") or []), SCAN_AI_MAX_ROWS),
|
||
"duration_ms": duration_ms,
|
||
"timeout_sec": SCAN_AI_TIMEOUT_SEC,
|
||
"cache_ttl_sec": SCAN_AI_CACHE_TTL_SEC,
|
||
"provider": SCAN_AI_PROVIDER,
|
||
"base_url": SCAN_AI_BASE_URL,
|
||
"reason": reason,
|
||
},
|
||
}
|
||
|
||
|
||
def _build_scan_terminal_payload_uncached(
|
||
filters: Dict[str, Any],
|
||
*,
|
||
force_refresh: bool = False,
|
||
) -> Dict[str, Any]:
|
||
cached_entry = get_scan_terminal_cache_entry(filters) or {}
|
||
|
||
try:
|
||
city_names = list(CITIES.keys())
|
||
max_workers = max(1, min(4, len(city_names)))
|
||
city_results: List[Dict[str, Any]] = []
|
||
failed_cities: List[str] = []
|
||
failed_reasons: List[str] = []
|
||
|
||
timed_out = False
|
||
timeout_message: Optional[str] = None
|
||
executor = ThreadPoolExecutor(max_workers=max_workers)
|
||
future_map = {
|
||
executor.submit(
|
||
_scan_city_terminal_rows,
|
||
city_name,
|
||
filters,
|
||
force_refresh=force_refresh,
|
||
): city_name
|
||
for city_name in city_names
|
||
}
|
||
try:
|
||
try:
|
||
completed = as_completed(
|
||
future_map,
|
||
timeout=float(SCAN_TERMINAL_BUILD_TIMEOUT_SEC),
|
||
)
|
||
for future in completed:
|
||
city_name = future_map[future]
|
||
try:
|
||
city_results.append(future.result())
|
||
except Exception as exc:
|
||
failed_cities.append(city_name)
|
||
failed_reasons.append(str(exc))
|
||
logger.warning("scan terminal city failed city={}: {}", city_name, exc)
|
||
except FutureTimeoutError:
|
||
timed_out = True
|
||
timeout_message = (
|
||
f"scan terminal build timed out after "
|
||
f"{SCAN_TERMINAL_BUILD_TIMEOUT_SEC}s"
|
||
)
|
||
failed_reasons.append(timeout_message)
|
||
for future, city_name in future_map.items():
|
||
if not future.done():
|
||
future.cancel()
|
||
failed_cities.append(city_name)
|
||
logger.warning(
|
||
"{}; completed={}/{}",
|
||
timeout_message,
|
||
len(city_results),
|
||
len(city_names),
|
||
)
|
||
finally:
|
||
executor.shutdown(wait=False)
|
||
|
||
if city_names and len(failed_cities) >= len(city_names):
|
||
error_message = failed_reasons[0] if failed_reasons else "all city market scans failed"
|
||
set_scan_terminal_failure_state(filters, error_message=error_message)
|
||
failed_entry = get_scan_terminal_cache_entry(filters) or {}
|
||
success_payload = failed_entry.get("success_payload")
|
||
failed_at = failed_entry.get("last_failed_at")
|
||
if isinstance(success_payload, dict) and success_payload:
|
||
return build_stale_scan_terminal_payload(
|
||
filters=filters,
|
||
success_payload=success_payload,
|
||
error_message=error_message,
|
||
failed_at=failed_at,
|
||
)
|
||
return build_failed_scan_terminal_payload(
|
||
filters=filters,
|
||
error_message=error_message,
|
||
failed_at=failed_at,
|
||
)
|
||
|
||
ranked_result = build_ranked_scan_terminal_result(
|
||
city_results=city_results,
|
||
filters=filters,
|
||
total_city_count=len(city_names),
|
||
failed_city_count=len(failed_cities),
|
||
)
|
||
ranked_rows = ranked_result["ranked_rows"]
|
||
|
||
if timed_out and not ranked_rows:
|
||
success_payload = cached_entry.get("success_payload")
|
||
if isinstance(success_payload, dict) and success_payload.get("rows"):
|
||
return build_stale_scan_terminal_payload(
|
||
filters=filters,
|
||
success_payload=success_payload,
|
||
error_message=timeout_message or "市场扫描快照正在刷新中",
|
||
failed_at=cached_entry.get("last_failed_at"),
|
||
)
|
||
|
||
summary = ranked_result["summary"]
|
||
top_signal = ranked_result["top_signal"]
|
||
payload = {
|
||
"generated_at": datetime.utcnow().isoformat() + "Z",
|
||
"filters": filters,
|
||
"summary": summary,
|
||
"top_signal": top_signal,
|
||
"rows": ranked_rows,
|
||
"status": "partial" if timed_out else "ready",
|
||
"stale": False,
|
||
"stale_reason": timeout_message,
|
||
"last_success_at": None,
|
||
"last_failed_at": None,
|
||
}
|
||
payload["snapshot_id"] = build_scan_terminal_snapshot_id(
|
||
filters,
|
||
ranked_rows,
|
||
summary,
|
||
top_signal,
|
||
)
|
||
|
||
set_cached_scan_terminal_payload(filters, payload)
|
||
return payload
|
||
except Exception as exc:
|
||
error_message = str(exc)
|
||
logger.exception("scan terminal payload build failed: {}", error_message)
|
||
set_scan_terminal_failure_state(filters, error_message=error_message)
|
||
success_payload = cached_entry.get("success_payload")
|
||
failed_entry = get_scan_terminal_cache_entry(filters) or {}
|
||
failed_at = failed_entry.get("last_failed_at")
|
||
if isinstance(success_payload, dict) and success_payload:
|
||
return build_stale_scan_terminal_payload(
|
||
filters=filters,
|
||
success_payload=success_payload,
|
||
error_message=error_message,
|
||
failed_at=failed_at,
|
||
)
|
||
return build_failed_scan_terminal_payload(
|
||
filters=filters,
|
||
error_message=error_message,
|
||
failed_at=failed_at,
|
||
)
|
||
|
||
|
||
def build_scan_terminal_payload(
|
||
raw_filters: Optional[Dict[str, Any]] = None,
|
||
*,
|
||
force_refresh: bool = False,
|
||
) -> Dict[str, Any]:
|
||
filters = _normalize_scan_terminal_filters(raw_filters)
|
||
if not force_refresh:
|
||
cached = get_cached_scan_terminal_payload(filters, ttl_sec=SCAN_TERMINAL_PAYLOAD_TTL_SEC)
|
||
if cached is not None:
|
||
return cached
|
||
|
||
cached_entry = get_scan_terminal_cache_entry(filters) or {}
|
||
success_payload = cached_entry.get("success_payload")
|
||
if isinstance(success_payload, dict) and success_payload:
|
||
started = _start_scan_terminal_background_refresh(filters)
|
||
return build_stale_scan_terminal_payload(
|
||
filters=filters,
|
||
success_payload=success_payload,
|
||
error_message=(
|
||
"正在后台刷新市场扫描快照"
|
||
if started
|
||
else "市场扫描快照正在刷新中"
|
||
),
|
||
failed_at=cached_entry.get("last_failed_at"),
|
||
)
|
||
|
||
return _build_scan_terminal_payload_uncached(filters, force_refresh=force_refresh)
|
||
|
||
|
||
def build_scan_terminal_ai_payload(
|
||
raw_filters: Optional[Dict[str, Any]] = None,
|
||
*,
|
||
snapshot_id: Optional[str] = None,
|
||
) -> Dict[str, Any]:
|
||
ai_started_at = time.time()
|
||
filters = _normalize_scan_terminal_filters(raw_filters)
|
||
payload = build_scan_terminal_payload(filters, force_refresh=False)
|
||
current_snapshot_id = str(payload.get("snapshot_id") or "").strip()
|
||
requested_snapshot_id = str(snapshot_id or "").strip()
|
||
if requested_snapshot_id and current_snapshot_id and requested_snapshot_id != current_snapshot_id:
|
||
return _build_scan_ai_unavailable_payload(
|
||
payload,
|
||
status="snapshot_mismatch",
|
||
reason="scan snapshot changed; refresh the scan before running AI review",
|
||
)
|
||
if not current_snapshot_id:
|
||
return _build_scan_ai_unavailable_payload(
|
||
payload,
|
||
status="no_snapshot",
|
||
reason="no scan snapshot is available for AI review",
|
||
)
|
||
if not payload.get("rows"):
|
||
return _build_scan_ai_unavailable_payload(
|
||
payload,
|
||
status="no_rows",
|
||
reason="no candidate rows are available for AI review",
|
||
)
|
||
if not SCAN_AI_ENABLED:
|
||
return _build_scan_ai_unavailable_payload(
|
||
payload,
|
||
status="disabled",
|
||
reason="POLYWEATHER_SCAN_AI_ENABLED is not enabled",
|
||
)
|
||
if not _scan_ai_api_key():
|
||
return _build_scan_ai_unavailable_payload(
|
||
payload,
|
||
status="missing_key",
|
||
reason=f"{SCAN_AI_API_KEY_ENV_HINT} is not configured",
|
||
)
|
||
|
||
cached = get_cached_scan_ai_result(
|
||
current_snapshot_id,
|
||
filters,
|
||
max_rows=SCAN_AI_MAX_ROWS,
|
||
model=SCAN_AI_MODEL,
|
||
ttl_sec=SCAN_AI_CACHE_TTL_SEC,
|
||
)
|
||
if cached is not None:
|
||
logger.info(
|
||
"scan terminal AI cache hit snapshot={} rows={}",
|
||
current_snapshot_id,
|
||
len(payload.get("rows") or []),
|
||
)
|
||
return merge_scan_ai_result(
|
||
payload,
|
||
cached,
|
||
model=SCAN_AI_MODEL,
|
||
max_rows=SCAN_AI_MAX_ROWS,
|
||
timeout_sec=SCAN_AI_TIMEOUT_SEC,
|
||
cache_ttl_sec=SCAN_AI_CACHE_TTL_SEC,
|
||
base_url=SCAN_AI_BASE_URL,
|
||
cached=True,
|
||
provider=SCAN_AI_PROVIDER,
|
||
duration_ms=0,
|
||
input_rows=len(payload.get("rows") or []),
|
||
)
|
||
|
||
try:
|
||
ai_input = build_scan_ai_prompt(payload, max_rows=SCAN_AI_MAX_ROWS)
|
||
input_meta = ai_input.get("_polyweather_input_meta") if isinstance(ai_input, dict) else {}
|
||
sent_rows = int((input_meta or {}).get("sent_contracts") or 0)
|
||
sent_cities = int((input_meta or {}).get("sent_cities") or 0)
|
||
logger.info(
|
||
"scan terminal AI review start snapshot={} rows={} sent_cities={} sent_contracts={} model={}",
|
||
current_snapshot_id,
|
||
len(payload.get("rows") or []),
|
||
sent_cities,
|
||
sent_rows,
|
||
SCAN_AI_MODEL,
|
||
)
|
||
ai_raw = _call_deepseek_scan_ai(ai_input)
|
||
ai_raw["_polyweather_input_meta"] = input_meta
|
||
set_cached_scan_ai_result(
|
||
current_snapshot_id,
|
||
filters,
|
||
ai_raw,
|
||
max_rows=SCAN_AI_MAX_ROWS,
|
||
model=SCAN_AI_MODEL,
|
||
)
|
||
duration_ms = int((time.time() - ai_started_at) * 1000)
|
||
logger.info(
|
||
"scan terminal AI review complete snapshot={} duration_ms={} recommendations={} vetoed={} downgraded={}",
|
||
current_snapshot_id,
|
||
duration_ms,
|
||
len(_normalize_ai_items(ai_raw.get("recommendations"))),
|
||
len(_normalize_ai_items(ai_raw.get("vetoed"))),
|
||
len(_normalize_ai_items(ai_raw.get("downgraded"))),
|
||
)
|
||
return merge_scan_ai_result(
|
||
payload,
|
||
ai_raw,
|
||
model=SCAN_AI_MODEL,
|
||
max_rows=SCAN_AI_MAX_ROWS,
|
||
timeout_sec=SCAN_AI_TIMEOUT_SEC,
|
||
cache_ttl_sec=SCAN_AI_CACHE_TTL_SEC,
|
||
base_url=SCAN_AI_BASE_URL,
|
||
cached=False,
|
||
provider=SCAN_AI_PROVIDER,
|
||
duration_ms=duration_ms,
|
||
input_rows=len(payload.get("rows") or []),
|
||
)
|
||
except httpx.TimeoutException as exc:
|
||
duration_ms = int((time.time() - ai_started_at) * 1000)
|
||
reason = f"V4 provider timed out after {SCAN_AI_TIMEOUT_SEC}s"
|
||
logger.warning(
|
||
"scan terminal AI review timeout snapshot={} duration_ms={} error={}",
|
||
current_snapshot_id,
|
||
duration_ms,
|
||
exc,
|
||
)
|
||
return _build_scan_ai_unavailable_payload(
|
||
payload,
|
||
status="timeout",
|
||
reason=reason,
|
||
duration_ms=duration_ms,
|
||
)
|
||
except Exception as exc:
|
||
duration_ms = int((time.time() - ai_started_at) * 1000)
|
||
logger.warning(
|
||
"scan terminal AI review failed snapshot={} duration_ms={} error={}",
|
||
current_snapshot_id,
|
||
duration_ms,
|
||
exc,
|
||
)
|
||
return _build_scan_ai_unavailable_payload(
|
||
payload,
|
||
status="failed",
|
||
reason=str(exc),
|
||
duration_ms=duration_ms,
|
||
)
|