From e6dbef1ecebc2dd2a98b02ef72304d8f2fe97568 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Sun, 26 Apr 2026 01:55:30 +0800 Subject: [PATCH] feat: implement ScanTerminalDashboard component and associated CSS module for PolyWeather map interface --- .env.example | 4 +- .../app/api/scan/terminal/ai-city/route.ts | 77 ++++++ .../components/dashboard/Dashboard.module.css | 47 +++- .../dashboard/ScanTerminalDashboard.tsx | 167 +++++++++++- .../subscription/UnlockProOverlay.tsx | 4 +- web/routes.py | 26 ++ web/scan_terminal_service.py | 243 +++++++++++++++++- 7 files changed, 546 insertions(+), 22 deletions(-) create mode 100644 frontend/app/api/scan/terminal/ai-city/route.ts diff --git a/.env.example b/.env.example index acf4d526..3b5ac51e 100644 --- a/.env.example +++ b/.env.example @@ -128,11 +128,11 @@ POLYWEATHER_GROQ_COMMENTARY_MODEL=openai/gpt-oss-20b POLYWEATHER_GROQ_COMMENTARY_TIMEOUT_SEC=8 POLYWEATHER_GROQ_COMMENTARY_CACHE_TTL_SEC=1800 -# Optional DeepSeek V4-Flash market scan review for Pro users +# Optional DeepSeek V4-Pro market scan review for Pro users POLYWEATHER_SCAN_AI_ENABLED=false POLYWEATHER_DEEPSEEK_API_KEY= POLYWEATHER_DEEPSEEK_BASE_URL=https://api.deepseek.com -POLYWEATHER_SCAN_AI_MODEL=deepseek-v4-flash +POLYWEATHER_SCAN_AI_MODEL=deepseek-v4-pro POLYWEATHER_SCAN_AI_TIMEOUT_SEC=40 POLYWEATHER_SCAN_AI_CACHE_TTL_SEC=120 POLYWEATHER_SCAN_AI_MAX_ROWS=40 diff --git a/frontend/app/api/scan/terminal/ai-city/route.ts b/frontend/app/api/scan/terminal/ai-city/route.ts new file mode 100644 index 00000000..4ab87390 --- /dev/null +++ b/frontend/app/api/scan/terminal/ai-city/route.ts @@ -0,0 +1,77 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + applyAuthResponseCookies, + buildBackendRequestHeaders, +} from "@/lib/backend-auth"; + +const API_BASE = process.env.POLYWEATHER_API_BASE_URL; +const SCAN_AI_PROXY_TIMEOUT_MS = Math.max( + 35_000, + Number(process.env.POLYWEATHER_SCAN_AI_PROXY_TIMEOUT_MS || "45000") || 45_000, +); + +export const dynamic = "force-dynamic"; +export const maxDuration = 60; + +export async function POST(req: NextRequest) { + if (!API_BASE) { + return NextResponse.json( + { error: "POLYWEATHER_API_BASE_URL is not configured" }, + { status: 500 }, + ); + } + + let body: unknown = {}; + try { + body = await req.json(); + } catch { + body = {}; + } + + let auth: Awaited> | null = null; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), SCAN_AI_PROXY_TIMEOUT_MS); + + try { + auth = await buildBackendRequestHeaders(req); + const headers = new Headers(auth.headers); + headers.set("Content-Type", "application/json"); + headers.set("Accept", "application/json"); + const res = await fetch(`${API_BASE}/api/scan/terminal/ai-city`, { + method: "POST", + headers, + cache: "no-store", + signal: controller.signal, + body: JSON.stringify(body || {}), + }); + if (!res.ok) { + const raw = await res.text(); + const response = NextResponse.json( + { error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) }, + { status: res.status === 402 || res.status === 403 ? res.status : 502 }, + ); + return applyAuthResponseCookies(response, auth.response); + } + const data = await res.json(); + const response = NextResponse.json(data, { + headers: { + "Cache-Control": "no-store", + }, + }); + return applyAuthResponseCookies(response, auth.response); + } catch (error) { + const timedOut = controller.signal.aborted; + const response = NextResponse.json( + { + error: timedOut + ? "City AI request timed out" + : "Failed to fetch city AI data", + detail: String(error), + }, + { status: timedOut ? 504 : 500 }, + ); + return auth ? applyAuthResponseCookies(response, auth.response) : response; + } finally { + clearTimeout(timeoutId); + } +} diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css index dafd2b26..a67c3336 100644 --- a/frontend/components/dashboard/Dashboard.module.css +++ b/frontend/components/dashboard/Dashboard.module.css @@ -11235,6 +11235,42 @@ line-height: 1.6; } +.root :global(.scan-ai-weather-summary) { + color: #e6edf3; + font-weight: 850; +} + +.root :global(.scan-ai-weather-bullets) { + display: grid; + gap: 8px; + margin: 10px 0 0; + padding-left: 18px; + color: #9fb2c7; + font-size: 13px; + font-weight: 700; + line-height: 1.6; +} + +.root :global(.scan-ai-weather-bullets li::marker) { + color: #4da3ff; +} + +.root :global(.scan-ai-raw-metar) { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid rgba(77, 163, 255, 0.12); + color: #6b7a90; + font-family: + ui-monospace, + SFMono-Regular, + Menlo, + Monaco, + Consolas, + "Liberation Mono", + monospace; + font-size: 12px; +} + .root :global(.scan-ai-city-chart) { height: 260px; } @@ -12948,7 +12984,8 @@ .root :global(.scan-terminal.light .scan-ai-city-hero-side > strong), .root :global(.scan-terminal.light .scan-ai-decision-band strong), .root :global(.scan-terminal.light .scan-ai-decision-metrics b), -.root :global(.scan-terminal.light .scan-ai-market-bucket strong) { +.root :global(.scan-terminal.light .scan-ai-market-bucket strong), +.root :global(.scan-terminal.light .scan-ai-weather-summary) { color: #0f172a; } @@ -12960,10 +12997,16 @@ .root :global(.scan-terminal.light .scan-ai-market-bucket span), .root :global(.scan-terminal.light .scan-ai-city-muted), .root :global(.scan-terminal.light .scan-ai-city-loading), -.root :global(.scan-terminal.light .scan-ai-city-chart-legend) { +.root :global(.scan-terminal.light .scan-ai-city-chart-legend), +.root :global(.scan-terminal.light .scan-ai-weather-bullets) { color: #475569; } +.root :global(.scan-terminal.light .scan-ai-raw-metar) { + border-top-color: #e2e8f0; + color: #64748b; +} + .root :global(.scan-terminal.light .scan-ai-log-panel) { border-color: rgba(35, 72, 118, 0.12); background: linear-gradient(180deg, rgba(255, 255, 255, 0.8), rgba(246, 250, 255, 0.9)); diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index f726459d..271ff35a 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -60,6 +60,34 @@ type AiPinnedCity = { displayName?: string | null; addedAt: number; }; +type AiCityForecastPayload = { + status?: string | null; + reason?: string | null; + model?: string | null; + provider?: string | null; + city_forecast?: { + predicted_max?: number | string | null; + range_low?: number | string | null; + range_high?: number | string | null; + unit?: string | null; + confidence?: string | null; + final_judgment_zh?: string | null; + final_judgment_en?: string | null; + metar_read_zh?: string | null; + metar_read_en?: string | null; + reasoning_zh?: string | null; + reasoning_en?: string | null; + risks_zh?: string[] | null; + risks_en?: string[] | null; + model_cluster_note_zh?: string | null; + model_cluster_note_en?: string | null; + } | null; +}; +type AiCityForecastState = { + status: "idle" | "loading" | "ready" | "failed"; + payload?: AiCityForecastPayload | null; + error?: string | null; +}; function formatShortDate(value?: string | null, locale = "zh-CN") { const text = String(value || "").trim(); @@ -583,11 +611,85 @@ function AiPinnedCityCard({ ? "Waiting for intraday observations to compare against the DEB path." : "等待更多日内实测,用来对照 DEB 预测路径。"); const report = detail?.current?.raw_metar || detail?.airport_current?.raw_metar || ""; - const weatherLine = - detail?.current?.wx_desc || - detail?.airport_current?.wx_desc || - detail?.airport_primary?.wx_desc || + const airportStation = + detail?.risk?.icao || + detail?.current?.station_code || + detail?.airport_current?.station_code || + detail?.airport_primary?.station_code || ""; + const [aiForecast, setAiForecast] = useState({ + status: "idle", + }); + const detailCityName = detail?.name || item.cityName; + const aiForecastKey = detail + ? `${normalizeCityKey(detailCityName)}:${detail.local_date || ""}:${report || ""}` + : ""; + + useEffect(() => { + if (!aiForecastKey || collapsed) return; + let cancelled = false; + setAiForecast({ status: "loading" }); + fetch("/api/scan/terminal/ai-city", { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + cache: "no-store", + body: JSON.stringify({ + city: detailCityName, + force_refresh: false, + }), + }) + .then(async (response) => { + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + return response.json() as Promise; + }) + .then((payload) => { + if (!cancelled) { + setAiForecast({ payload, status: "ready" }); + } + }) + .catch((error) => { + if (!cancelled) { + setAiForecast({ error: String(error), status: "failed" }); + } + }); + return () => { + cancelled = true; + }; + }, [aiForecastKey, collapsed, detailCityName]); + + const aiCityForecast = aiForecast.payload?.city_forecast || null; + const localizedFinalJudgment = + (isEn ? aiCityForecast?.final_judgment_en : aiCityForecast?.final_judgment_zh) || + (isEn ? aiCityForecast?.reasoning_en : aiCityForecast?.reasoning_zh) || + ""; + const localizedMetarRead = + (isEn ? aiCityForecast?.metar_read_en : aiCityForecast?.metar_read_zh) || + ""; + const localizedReasoning = + (isEn ? aiCityForecast?.reasoning_en : aiCityForecast?.reasoning_zh) || + ""; + const localizedModelNote = + (isEn + ? aiCityForecast?.model_cluster_note_en + : aiCityForecast?.model_cluster_note_zh) || ""; + const localizedRisksRaw = + (isEn ? aiCityForecast?.risks_en : aiCityForecast?.risks_zh) || []; + const localizedRisks = Array.isArray(localizedRisksRaw) + ? localizedRisksRaw + : localizedRisksRaw + ? [String(localizedRisksRaw)] + : []; + const aiBullets = [ + localizedMetarRead, + localizedReasoning !== localizedFinalJudgment ? localizedReasoning : "", + localizedModelNote, + ...localizedRisks, + ].filter((line) => String(line || "").trim()); const collapseId = `ai-city-body-${normalizeCityKey(item.cityName) || item.addedAt}`; @@ -687,17 +789,54 @@ function AiPinnedCityCard({
- {isEn ? "Airport / climate read" : "机场报文与天气解读"} + {isEn ? "AI airport weather read" : "AI 机场报文解读"}
-

{weatherLine || (isEn ? "No weather text decoded yet." : "暂无天气文本解读。")}

-

- {report - ? `${detail.risk?.icao || detail.current?.station_code || ""} ${report}`.trim() - : isEn - ? "No raw METAR available." - : "暂无原始 METAR 报文。"} -

-

{paceView?.kicker || ""}

+ {aiForecast.status === "loading" ? ( +

+ {isEn + ? "Deepseek V4 flash is reading the latest airport bulletin..." + : "Deepseek V4 flash 正在解读最新机场报文..."} +

+ ) : aiForecast.status === "ready" && aiCityForecast ? ( + <> +

+ {localizedFinalJudgment || + (isEn ? "AI read returned without a final sentence." : "AI 已返回,但缺少最终判断。")} +

+
    + {aiBullets.map((line, index) => ( +
  • {line}
  • + ))} +
+

+ {report + ? `${isEn ? "Raw METAR" : "原始 METAR"}:${`${airportStation} ${report}`.trim()}` + : isEn + ? "Raw METAR: unavailable." + : "原始 METAR:暂无。"} +

+ + ) : aiForecast.status === "ready" ? ( +

+ {aiForecast.payload?.reason || + (isEn + ? "AI read is unavailable for this city right now." + : "该城市暂时没有可用的 AI 解读。")} +

+ ) : aiForecast.status === "failed" ? ( +

+ {isEn + ? "AI read failed. The raw METAR remains below as fallback context." + : "AI 解读失败。下方仅保留原始 METAR 作为兜底上下文。"} + {aiForecast.error ? ` ${aiForecast.error}` : ""} +

+ ) : ( +

+ {isEn + ? "Waiting for AI to read the latest airport bulletin." + : "等待 AI 解读最新机场报文。"} +

+ )}
diff --git a/frontend/components/subscription/UnlockProOverlay.tsx b/frontend/components/subscription/UnlockProOverlay.tsx index 1edc3f4e..9ee47eaa 100644 --- a/frontend/components/subscription/UnlockProOverlay.tsx +++ b/frontend/components/subscription/UnlockProOverlay.tsx @@ -56,13 +56,13 @@ type UnlockProOverlayProps = { const FEATURES = { "zh-CN": [ - "市场扫描台 + V4-Flash 深度复核", + "市场扫描台 + V4-Pro 深度复核", "今日日内机场报文规则分析(含高温时段)", "历史对账 + 未来日期分析", "全平台智能气象推送", ], "en-US": [ - "Market Scan Terminal + V4-Flash review", + "Market Scan Terminal + V4-Pro review", "Intraday METAR rule-based analysis with peak-time window", "Historical reconciliation + future-date analysis", "Cross-platform alerts", diff --git a/web/routes.py b/web/routes.py index 1ad1788b..9ef8766a 100644 --- a/web/routes.py +++ b/web/routes.py @@ -33,6 +33,7 @@ from web.analysis_service import ( _build_city_summary_payload, ) from web.scan_terminal_service import ( + build_scan_city_ai_forecast_payload, build_scan_terminal_ai_payload, build_scan_terminal_payload, ) @@ -1749,3 +1750,28 @@ async def scan_terminal_ai(request: Request): snapshot_id=snapshot_id, ) + +@router.post("/api/scan/terminal/ai-city") +async def scan_terminal_ai_city(request: Request): + _assert_entitlement(request) + try: + body = await request.json() + except Exception: + body = {} + if not isinstance(body, dict): + raise HTTPException(status_code=400, detail="Invalid JSON body") + city = str(body.get("city") or "").strip() + if not city: + raise HTTPException(status_code=400, detail="city is required") + force_refresh = str(body.get("force_refresh") or "false").lower() in { + "1", + "true", + "yes", + "on", + } + return await run_in_threadpool( + build_scan_city_ai_forecast_payload, + city, + force_refresh=force_refresh, + ) + diff --git a/web/scan_terminal_service.py b/web/scan_terminal_service.py index 1af62594..ec5715e6 100644 --- a/web/scan_terminal_service.py +++ b/web/scan_terminal_service.py @@ -22,6 +22,8 @@ _SCAN_TERMINAL_CACHE: Dict[str, Dict[str, Any]] = {} _SCAN_TERMINAL_REFRESHING: set[str] = set() _SCAN_TERMINAL_AI_CACHE_LOCK = threading.Lock() _SCAN_TERMINAL_AI_CACHE: Dict[str, Dict[str, Any]] = {} +_SCAN_CITY_AI_CACHE_LOCK = threading.Lock() +_SCAN_CITY_AI_CACHE: Dict[str, Dict[str, Any]] = {} SCAN_TERMINAL_PAYLOAD_TTL_SEC = max( 5, int(os.getenv("POLYWEATHER_SCAN_TERMINAL_PAYLOAD_TTL_SEC", "30")), @@ -31,7 +33,7 @@ SCAN_TERMINAL_BUILD_TIMEOUT_SEC = max( int(os.getenv("POLYWEATHER_SCAN_TERMINAL_BUILD_TIMEOUT_SEC", "22")), ) SCAN_AI_MODEL = str( - os.getenv("POLYWEATHER_SCAN_AI_MODEL") or "deepseek-v4-flash" + os.getenv("POLYWEATHER_SCAN_AI_MODEL") or "deepseek-v4-pro" ).strip() SCAN_AI_BASE_URL = str( os.getenv("POLYWEATHER_DEEPSEEK_BASE_URL") or "https://api.deepseek.com" @@ -731,7 +733,7 @@ def _call_deepseek_scan_ai(ai_input: Dict[str, Any]) -> Dict[str, Any]: raise RuntimeError("POLYWEATHER_DEEPSEEK_API_KEY is not configured") system_prompt = ( - "你是 PolyWeather 的付费 V4-Flash 城市最高温预测员。你只能基于用户提供的 JSON 快照做判断," + "你是 PolyWeather 的付费 V4-Pro 城市最高温预测员。你只能基于用户提供的 JSON 快照做判断," "不得编造城市、价格、概率、盘口或天气数据。输入已经按城市分组,每城包含 DEB、" "多个天气模型预测值 model_cluster.sources、METAR 实测序列、机场原始报文和候选合约。" "你的首要任务不是分析套利,也不是推荐 BUY YES/NO,而是预测该城市今日最终最高温是多少。" @@ -805,6 +807,243 @@ def _call_deepseek_scan_ai(ai_input: Dict[str, Any]) -> Dict[str, Any]: 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) + 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_v1", + "task": "predict_city_daily_high_and_read_metar", + "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"), + "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"), + }, + "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"), + }, + "deb": { + "prediction": ((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), + "weights_info": ((data.get("deb") or {}).get("weights_info") if isinstance(data.get("deb"), dict) else None), + }, + "model_cluster": { + "sources": [ + {"model": str(name), "value": value} + for name, value in (models or {}).items() + if _safe_float(value) is not None + ], + "model_count": len(model_values), + "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": data.get("taf") or {}, + "vertical_profile_signal": data.get("vertical_profile_signal") or {}, + "intraday_meteorology": data.get("intraday_meteorology") or {}, + "hourly": data.get("hourly") or {}, + "metar_today_obs": _compact_observation_points(data.get("metar_today_obs"), 36), + "metar_recent_obs": _compact_observation_points(data.get("metar_recent_obs"), 12), + "settlement_today_obs": _compact_observation_points(data.get("settlement_today_obs"), 36), + } + + +def _call_deepseek_city_ai(ai_input: Dict[str, Any]) -> Dict[str, Any]: + api_key = str(os.getenv("POLYWEATHER_DEEPSEEK_API_KEY") or "").strip() + if not api_key: + raise RuntimeError("POLYWEATHER_DEEPSEEK_API_KEY is not configured") + + system_prompt = ( + "你是 PolyWeather 的 Deepseek V4-Pro 城市最高温预测员。你必须直接阅读用户给出的城市 JSON," + "判断该城市今日最高温路径。不要写套利、交易、BUY YES/NO、价格、edge 或 Kelly。" + "你的核心输出是:最终最高温点估计、置信区间、置信度、最终判断、机场报文/METAR 解读、判断依据和风险。" + "必须综合 DEB 最终融合值、全部天气模型预测、METAR 实测序列、最新机场报文、峰值窗口、当地时间、季节背景。" + "如果实测温度与 DEB 预测走势出现偏差,要明确说明偏差方向和可能修正。" + "你可以基于城市、时间、季节、机场位置、风向/风速、云、能见度、露点等判断风或天气是否可能影响温度路径," + "但必须使用“可能”“倾向”“需要确认”等非绝对表达。" + "如果峰值窗口尚未到来,不能过早下最终结论;如果峰值窗口已过或实测已创高,需要更重视 METAR 实测。" + "只返回 JSON object,不要 Markdown。" + ) + user_payload = { + "task": ( + "Return strict JSON with: 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. " + "Keep final_judgment one short decision sentence. metar_read should explain the latest airport bulletin " + "and how wind/cloud/visibility/dewpoint may affect the temperature path." + ), + "city_snapshot": ai_input, + } + 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_AI_MODEL, + "temperature": 0.2, + "max_tokens": min(max(SCAN_AI_MAX_TOKENS, 1200), 2400), + "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 _scan_city_ai_cache_key(ai_input: Dict[str, Any]) -> str: + key_payload = { + "city": ai_input.get("city"), + "local_date": ai_input.get("local_date"), + "local_time": ai_input.get("local_time"), + "deb": (ai_input.get("deb") or {}).get("prediction") if isinstance(ai_input.get("deb"), dict) else None, + "metar": (ai_input.get("airport_current") or {}).get("raw_metar") if isinstance(ai_input.get("airport_current"), dict) else None, + "obs": ai_input.get("metar_today_obs") or ai_input.get("metar_recent_obs") or [], + } + raw = json.dumps(key_payload, sort_keys=True, ensure_ascii=False, default=str) + return "city-ai:" + hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +def build_scan_city_ai_forecast_payload( + city: str, + *, + force_refresh: bool = False, +) -> Dict[str, Any]: + started_at = time.time() + city_name = str(city or "").strip() + if not city_name: + return {"status": "failed", "reason": "city is required"} + data = _analyze( + city_name, + force_refresh=force_refresh, + include_llm_commentary=False, + detail_mode="full", + ) + ai_input = _build_city_ai_prompt(data) + cache_key = _scan_city_ai_cache_key(ai_input) + if not force_refresh: + with _SCAN_CITY_AI_CACHE_LOCK: + cached = _SCAN_CITY_AI_CACHE.get(cache_key) + if cached and cached.get("expires_at", 0) >= time.time(): + return { + "status": "ready", + "cached": True, + "model": SCAN_AI_MODEL, + "provider": "deepseek", + "city": data.get("name") or city_name, + "city_display_name": data.get("display_name") or city_name, + "generated_at": cached.get("generated_at"), + "duration_ms": 0, + "city_forecast": cached.get("payload"), + } + + if not SCAN_AI_ENABLED: + return { + "status": "disabled", + "model": SCAN_AI_MODEL, + "provider": "deepseek", + "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 str(os.getenv("POLYWEATHER_DEEPSEEK_API_KEY") or "").strip(): + return { + "status": "missing_key", + "model": SCAN_AI_MODEL, + "provider": "deepseek", + "city": data.get("name") or city_name, + "city_display_name": data.get("display_name") or city_name, + "reason": "POLYWEATHER_DEEPSEEK_API_KEY is not configured", + } + + ai_raw = _call_deepseek_city_ai(ai_input) + generated_at = datetime.utcnow().isoformat() + "Z" + with _SCAN_CITY_AI_CACHE_LOCK: + _SCAN_CITY_AI_CACHE[cache_key] = { + "expires_at": time.time() + SCAN_AI_CACHE_TTL_SEC, + "generated_at": generated_at, + "payload": ai_raw, + } + return { + "status": "ready", + "cached": False, + "model": SCAN_AI_MODEL, + "provider": "deepseek", + "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 _normalize_ai_items(raw_items: Any) -> List[Dict[str, Any]]: if not isinstance(raw_items, list): return []