feat: implement scan_terminal_service for market data analysis and caching

This commit is contained in:
2569718930@qq.com
2026-04-26 05:03:49 +08:00
parent 963b2098c0
commit 7e3146d612
+269 -16
View File
@@ -16,6 +16,7 @@ from loguru import logger
from web.analysis_service import _analyze, _build_city_market_scan_payload
from web.core import CITIES
from src.data_collection.city_registry import ALIASES
_SCAN_TERMINAL_CACHE_LOCK = threading.Lock()
_SCAN_TERMINAL_CACHE: Dict[str, Dict[str, Any]] = {}
@@ -88,6 +89,12 @@ def _normalize_locale(value: Any) -> str:
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 _normalize_scan_terminal_filters(
raw_filters: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
@@ -334,6 +341,153 @@ def _extract_ai_json_object(raw_text: str) -> Dict[str, Any]:
raise ValueError("AI content is not a JSON object")
def _truncate_ai_text(value: Any, limit: int = 800) -> str:
text = re.sub(r"\s+", " ", str(value or "")).strip()
if len(text) <= limit:
return text
return text[: max(0, limit - 1)].rstrip() + ""
def _extract_provider_content(data: Any) -> str:
if not isinstance(data, dict):
return ""
choices = data.get("choices") or []
if not choices or not isinstance(choices[0], dict):
return ""
message = choices[0].get("message") or {}
if not isinstance(message, dict):
return ""
return str(message.get("content") or "")
def _provider_response_meta(data: Any) -> Dict[str, Any]:
if not isinstance(data, dict):
return {}
choices = data.get("choices") or []
first = choices[0] if choices and isinstance(choices[0], dict) else {}
return {
"usage": data.get("usage"),
"finish_reason": first.get("finish_reason"),
}
def _format_ai_temperature(value: Any, unit: str) -> Optional[str]:
numeric = _safe_float(value)
if numeric is None:
return None
return f"{numeric:.1f}{unit or ''}"
def _city_ai_model_cluster_note(ai_input: Dict[str, Any], *, locale: str) -> str:
cluster = ai_input.get("model_cluster") if isinstance(ai_input.get("model_cluster"), dict) else {}
sources = cluster.get("sources") if isinstance(cluster.get("sources"), list) else []
unit = str(ai_input.get("temp_symbol") or "")
values = [
_safe_float(item.get("value"))
for item in sources
if isinstance(item, dict) and _safe_float(item.get("value")) is not None
]
count = len(values)
deb_value = _safe_float((ai_input.get("deb") or {}).get("prediction") if isinstance(ai_input.get("deb"), dict) else None)
if locale == "en-US":
if count <= 0:
return "No usable model cluster was returned; rely on DEB and METAR only."
if count <= 2:
return f"Only {count} model source(s) are available, so model support is thin and should be treated as context."
range_text = f"{min(values):.1f}{unit} to {max(values):.1f}{unit}"
if deb_value is None:
return f"{count} model sources cluster between {range_text}; DEB support cannot be cross-checked."
supporting = sum(1 for value in values if abs(value - deb_value) <= 2.0)
return f"{supporting}/{count} model sources sit within 2{unit} of DEB; model range is {range_text}."
if count <= 0:
return "没有可用的多模型集合,只能把 DEB 与 METAR 作为主要依据。"
if count <= 2:
return f"当前只有 {count} 个模型来源,模型支撑偏薄,只能作为辅助上下文。"
range_text = f"{min(values):.1f}{unit} ~ {max(values):.1f}{unit}"
if deb_value is None:
return f"{count} 个模型集中在 {range_text},但无法与 DEB 做一致性校验。"
supporting = sum(1 for value in values if abs(value - deb_value) <= 2.0)
return f"{supporting}/{count} 个模型落在 DEB ±2{unit} 内;模型区间为 {range_text}"
def _build_city_ai_fallback(
ai_input: Dict[str, Any],
*,
locale: str,
reason: str,
raw_content: str = "",
provider_data: Any = None,
) -> Dict[str, Any]:
unit = str(ai_input.get("temp_symbol") or "")
cluster = ai_input.get("model_cluster") if isinstance(ai_input.get("model_cluster"), dict) else {}
values = [
_safe_float(item.get("value"))
for item in (cluster.get("sources") if isinstance(cluster.get("sources"), list) else [])
if isinstance(item, dict) and _safe_float(item.get("value")) is not None
]
deb_value = _safe_float((ai_input.get("deb") or {}).get("prediction") if isinstance(ai_input.get("deb"), dict) else None)
current_temp = _safe_float(
(ai_input.get("airport_current") or {}).get("temp")
if isinstance(ai_input.get("airport_current"), dict)
else None
)
predicted = deb_value
if predicted is None and values:
predicted = sum(values) / len(values)
if predicted is None:
predicted = current_temp
range_low = min(values) if values else predicted
range_high = max(values) if values else predicted
city = str(ai_input.get("city_display_name") or ai_input.get("city") or "this city")
airport_current = ai_input.get("airport_current") if isinstance(ai_input.get("airport_current"), dict) else {}
station = str(airport_current.get("station_code") or "")
raw_metar = str(airport_current.get("raw_metar") or "").strip()
metar_temp = _format_ai_temperature(airport_current.get("temp"), unit)
obs_time = str(airport_current.get("report_time") or airport_current.get("obs_time") or "").strip()
model_note_zh = _city_ai_model_cluster_note(ai_input, locale="zh-CN")
model_note_en = _city_ai_model_cluster_note(ai_input, locale="en-US")
content_preview = _truncate_ai_text(raw_content, 1000)
reason_preview = _truncate_ai_text(reason, 260)
if content_preview:
metar_zh = f"DeepSeek V4-Pro 返回了非结构化解读,系统已保留摘要:{content_preview}"
metar_en = f"DeepSeek V4-Pro returned non-JSON analysis; preserved summary: {content_preview}"
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'}; later reports are needed to confirm the path."
else:
metar_zh = "当前没有可用的原始 METAR 正文,暂以 DEB 与多模型路径为主。"
metar_en = "No raw METAR text is available, so DEB and the model cluster carry the read."
predicted_text = _format_ai_temperature(predicted, unit) or "--"
final_zh = f"{city} 预计最高温暂以 {predicted_text} 附近为中枢;AI 输出格式异常,已降级为模型/METAR 兜底判断。"
final_en = f"{city} daily high is centered near {predicted_text}; AI output was not strict JSON, so this is a model/METAR fallback."
reasoning_zh = f"DEB、多模型集合和最新 METAR 仍可用于判断方向;原始失败原因:{reason_preview or 'AI 输出不是 JSON object'}"
reasoning_en = f"DEB, the model cluster and latest METAR still support a directional read; raw failure: {reason_preview or 'AI output was not a JSON object'}."
return {
"predicted_max": predicted,
"range_low": range_low,
"range_high": range_high,
"unit": unit,
"confidence": "low",
"final_judgment_zh": final_zh,
"final_judgment_en": final_en,
"metar_read_zh": metar_zh,
"metar_read_en": metar_en,
"reasoning_zh": reasoning_zh,
"reasoning_en": reasoning_en,
"risks_zh": ["DeepSeek V4-Pro 本次没有返回严格 JSON,需刷新重试确认。"],
"risks_en": ["DeepSeek V4-Pro did not return strict JSON; refresh to confirm."],
"model_cluster_note_zh": model_note_zh,
"model_cluster_note_en": model_note_en,
"_polyweather_meta": {
**_provider_response_meta(provider_data),
"fallback": True,
"fallback_reason": reason_preview,
"raw_content_preview": content_preview,
"raw_metar": _truncate_ai_text(raw_metar, 1000),
},
}
def _scan_ai_cache_key(snapshot_id: str, filters: Dict[str, Any]) -> str:
raw = json.dumps(
{
@@ -979,17 +1133,13 @@ def _call_deepseek_city_ai(ai_input: Dict[str, Any], *, locale: str = "zh-CN") -
)
response.raise_for_status()
data = response.json()
content = (
((data.get("choices") or [{}])[0].get("message") or {}).get("content")
if isinstance(data, dict)
else None
)
content = _extract_provider_content(data)
if not str(content or "").strip():
logger.warning(
"scan city AI provider returned empty content city={} locale={} finish_reason={} retrying_without_json_mode=true",
ai_input.get("city"),
normalized_locale,
((data.get("choices") or [{}])[0] or {}).get("finish_reason") if isinstance(data, dict) else None,
_provider_response_meta(data).get("finish_reason"),
)
retry_payload = dict(request_json)
retry_payload.pop("response_format", None)
@@ -1025,17 +1175,105 @@ def _call_deepseek_city_ai(ai_input: Dict[str, Any], *, locale: str = "zh-CN") -
)
response.raise_for_status()
data = response.json()
content = (
((data.get("choices") or [{}])[0].get("message") or {}).get("content")
if isinstance(data, dict)
else None
content = _extract_provider_content(data)
try:
parsed = _extract_ai_json_object(str(content or ""))
except ValueError as exc:
preview = _truncate_ai_text(content, 700)
logger.warning(
"scan city AI provider returned non-json city={} locale={} finish_reason={} content_preview={}",
ai_input.get("city"),
normalized_locale,
_provider_response_meta(data).get("finish_reason"),
preview,
)
repair_payload = {
"model": SCAN_AI_MODEL,
"temperature": 0.0,
"max_tokens": min(max(SCAN_CITY_AI_MAX_TOKENS, 700), 1200),
"response_format": {"type": "json_object"},
"messages": [
{
"role": "system",
"content": (
"You repair PolyWeather AI output into one strict JSON object. "
"Do not add facts that are not present in the original city snapshot or previous assistant content. "
"Return only JSON, no markdown."
),
},
{
"role": "user",
"content": json.dumps(
{
"locale": normalized_locale,
"primary_language": primary_language,
"required_schema": [
"predicted_max",
"range_low",
"range_high",
"unit",
"confidence",
"final_judgment_zh",
"final_judgment_en",
"metar_read_zh",
"metar_read_en",
"reasoning_zh",
"reasoning_en",
"risks_zh",
"risks_en",
"model_cluster_note_zh",
"model_cluster_note_en",
],
"previous_error": str(exc),
"previous_assistant_content": _truncate_ai_text(content, 5000),
"city_snapshot": ai_input,
"instruction": (
f"Primary UI language is {primary_language}. "
"Make final_judgment one direct sentence about today's high temperature. "
"metar_read must interpret the latest airport bulletin and possible temperature-path impact. "
"model_cluster_note must mention available model count/range and whether it supports DEB. "
"Keep the JSON compact."
),
},
ensure_ascii=False,
),
},
],
}
try:
repair_response = client.post(
f"{SCAN_AI_BASE_URL}/chat/completions",
headers=headers,
json=repair_payload,
)
repair_response.raise_for_status()
repair_data = repair_response.json()
repair_content = _extract_provider_content(repair_data)
parsed = _extract_ai_json_object(str(repair_content or ""))
if isinstance(parsed, dict):
parsed["_polyweather_meta"] = {
**_provider_response_meta(repair_data),
"repaired_from_non_json": True,
"original_finish_reason": _provider_response_meta(data).get("finish_reason"),
"original_content_preview": preview,
}
return parsed
except Exception as repair_exc:
logger.warning(
"scan city AI provider json repair failed city={} locale={} error={}",
ai_input.get("city"),
normalized_locale,
repair_exc,
)
return _build_city_ai_fallback(
ai_input,
locale=normalized_locale,
reason=str(exc),
raw_content=str(content or ""),
provider_data=data,
)
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"),
}
parsed["_polyweather_meta"] = _provider_response_meta(data)
return parsed
@@ -1059,10 +1297,25 @@ def build_scan_city_ai_forecast_payload(
locale: str = "zh-CN",
) -> Dict[str, Any]:
started_at = time.time()
city_name = str(city or "").strip()
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_AI_MODEL,
"provider": "deepseek",
"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,