Files
PolyWeather/web/services/market_overview_api.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

197 lines
7.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Market overview — AI summary of all scan terminal rows, cached 10 min."""
from __future__ import annotations
import hashlib
import json
import threading
import time
from datetime import datetime
from typing import Any, Dict, List
from loguru import logger
from web.scan_city_ai_helpers import _safe_float
from web.scan_terminal_service import (
SCAN_AI_BASE_URL,
SCAN_CITY_AI_MODEL,
SCAN_CITY_AI_TIMEOUT_SEC,
_scan_ai_api_key,
)
_OVERVIEW_CACHE: Dict[str, Dict[str, Any]] = {}
_OVERVIEW_CACHE_LOCK = threading.Lock()
_OVERVIEW_MAX_TOKENS = 600
_OVERVIEW_CACHE_TTL_SEC = 600
def _build_overview_ai_request(
rows: List[Dict[str, Any]],
locale: str,
) -> Dict[str, Any]:
cities = []
for row in rows:
if not isinstance(row, dict):
continue
city = row.get("city") or row.get("name") or ""
if not city:
continue
model_cluster = row.get("model_cluster") if isinstance(row.get("model_cluster"), dict) else {}
sources = model_cluster.get("sources") if isinstance(model_cluster.get("sources"), list) else []
values = [
_safe_float(s.get("value"))
for s in sources
if isinstance(s, dict) and _safe_float(s.get("value")) is not None
]
deb_val = _safe_float(row.get("deb_prediction") or (row.get("deb") or {}).get("prediction"))
cities.append(
{
"city": str(city),
"display_name": row.get("display_name") or str(city),
"local_date": row.get("local_date", ""),
"deb": deb_val,
"model_min": min(values) if values else None,
"model_max": max(values) if values else None,
"model_count": len(values),
"current_temp": _safe_float(row.get("current_temp") or (row.get("current") or {}).get("temp")),
"max_so_far": _safe_float(row.get("current_max_so_far") or row.get("max_so_far") or (row.get("current") or {}).get("max_so_far")),
"risk_level": row.get("risk_level", ""),
"temp_unit": row.get("temp_unit") or row.get("temp_symbol") or "°C",
}
)
system_prompt = (
"你是 PolyWeather 的天气市场概览员。基于全部城市的扫描数据,写一段今日市场概览。"
"用 3-5 句概括:整体模型一致性、最值得关注的城市(模型分歧大或实测偏离集群)、异常信号。"
"highlights 最多 5 个城市,每个城市一句话点出关键信号。"
"只返回 JSON object,不要 Markdown。所有 *_zh 字段写简体中文,*_en 字段写英文。"
)
task = (
"Return JSON: overview_zh, overview_en, highlights (array of {city, note_zh, note_en}, max 5). "
"overview: 3-5 sentences covering model consensus, top divergence cities, anomalies. "
"highlights: per-city one-sentence signal. Keep compact."
)
return {
"model": SCAN_CITY_AI_MODEL,
"temperature": 0.3,
"max_tokens": _OVERVIEW_MAX_TOKENS,
"response_format": {"type": "json_object"},
"messages": [
{"role": "system", "content": system_prompt},
{
"role": "user",
"content": json.dumps(
{
"locale": locale,
"task": task,
"city_count": len(cities),
"cities": cities,
},
ensure_ascii=False,
default=str,
),
},
],
}
def _cache_key(rows: List[Dict[str, Any]], locale: str) -> str:
finger = {
"city_ids": sorted(
row.get("city") or row.get("name") or ""
for row in rows
if isinstance(row, dict)
),
"locale": locale,
}
raw = json.dumps(finger, sort_keys=True, ensure_ascii=False, default=str)
return "overview:" + hashlib.sha256(raw.encode("utf-8")).hexdigest()
def build_market_overview_payload(
rows: List[Dict[str, Any]],
*,
locale: str = "zh-CN",
force_refresh: bool = False,
) -> Dict[str, Any]:
if not rows:
return {"overview_zh": "", "overview_en": "", "highlights": [], "generated_at": None}
key = _cache_key(rows, locale)
if not force_refresh:
with _OVERVIEW_CACHE_LOCK:
cached = _OVERVIEW_CACHE.get(key)
if cached and cached.get("expires_at", 0) >= time.time():
return cached["payload"]
api_key = _scan_ai_api_key()
if not api_key:
return {
"overview_zh": "AI 概览不可用(未配置 API Key",
"overview_en": "AI overview unavailable (API key not configured)",
"highlights": [],
"generated_at": datetime.utcnow().isoformat() + "Z",
}
import httpx
request_json = _build_overview_ai_request(rows, locale)
generated_at = datetime.utcnow().isoformat() + "Z"
started = time.perf_counter()
try:
response = httpx.post(
f"{SCAN_AI_BASE_URL}/chat/completions",
json=request_json,
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
timeout=min(SCAN_CITY_AI_TIMEOUT_SEC, 15),
)
response.raise_for_status()
result = response.json()
content = ((result.get("choices") or [{}])[0].get("message") or {}).get("content") or "{}"
parsed = json.loads(content) if isinstance(content, str) else content
if not isinstance(parsed, dict):
raise ValueError("AI returned non-dict overview")
payload: Dict[str, Any] = {
"overview_zh": str(parsed.get("overview_zh") or parsed.get("overview_en") or ""),
"overview_en": str(parsed.get("overview_en") or parsed.get("overview_zh") or ""),
"highlights": [
{
"city": str(h.get("city", "")),
"note_zh": str(h.get("note_zh", "")),
"note_en": str(h.get("note_en", "")),
}
for h in (parsed.get("highlights") if isinstance(parsed.get("highlights"), list) else [])
if isinstance(h, dict)
][:5],
"generated_at": generated_at,
}
except Exception as exc:
logger.warning("Market overview AI failed: {}", exc)
payload = {
"overview_zh": "市场概览暂时无法生成,请稍后刷新。",
"overview_en": "Market overview temporarily unavailable, please refresh later.",
"highlights": [],
"generated_at": generated_at,
}
duration_ms = int((time.perf_counter() - started) * 1000)
logger.info(
"market_overview cities={} locale={} duration_ms={} cached={}",
len(rows),
locale,
duration_ms,
False,
)
entry = {"expires_at": time.time() + _OVERVIEW_CACHE_TTL_SEC, "payload": payload}
with _OVERVIEW_CACHE_LOCK:
_OVERVIEW_CACHE[key] = entry
return payload