From d42cdf6b5125020034e0784927753a742c0bd76e Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Sun, 26 Apr 2026 13:43:55 +0800 Subject: [PATCH] feat: implement AI city forecast streaming service and UI components for scan terminal --- .env.example | 1 + .../scan-terminal/AiPinnedForecastView.tsx | 8 +- .../dashboard/scan-terminal/types.ts | 2 + .../scan-terminal/use-ai-city-card-data.ts | 36 +++++++-- web/scan_terminal_service.py | 78 ++++++++++--------- 5 files changed, 77 insertions(+), 48 deletions(-) diff --git a/.env.example b/.env.example index f123673d..236f9f5f 100644 --- a/.env.example +++ b/.env.example @@ -141,6 +141,7 @@ POLYWEATHER_SCAN_AI_ENABLED=false POLYWEATHER_DEEPSEEK_API_KEY= POLYWEATHER_DEEPSEEK_BASE_URL=https://api.deepseek.com POLYWEATHER_SCAN_AI_MODEL=deepseek-v4-pro +POLYWEATHER_SCAN_CITY_AI_MODEL=deepseek-v4-flash POLYWEATHER_SCAN_AI_TIMEOUT_SEC=40 POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC=18 POLYWEATHER_SCAN_CITY_AI_RETRY_ON_STREAM_PARSE_ERROR=false diff --git a/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx b/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx index 8f9cc85d..a82690f3 100644 --- a/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx +++ b/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx @@ -425,8 +425,8 @@ function AiPinnedCityCard({

{aiForecast.streamText || (isEn - ? "Deepseek V4 pro is reading the latest airport bulletin..." - : "Deepseek V4 pro 正在解读最新机场报文...")} + ? "DeepSeek is reading the latest airport bulletin..." + : "DeepSeek 正在解读最新机场报文...")}

{!aiForecast.streamText ? (

@@ -460,8 +460,8 @@ function AiPinnedCityCard({

{aiForecast.payload?.status === "timeout" ? isEn - ? "Deepseek V4-Pro timed out. You can retry; city data and the right briefing were not refreshed." - : "Deepseek V4-Pro 本次解读超时,可稍后重试;城市数据和右侧简报不会被刷新。" + ? "DeepSeek enhancement timed out. You can retry; city data and the right briefing were not refreshed." + : "DeepSeek 增强本次超时,可稍后重试;城市数据和右侧简报不会被刷新。" : fallbackAiReason || (isEn ? "AI read is unavailable for this city right now." diff --git a/frontend/components/dashboard/scan-terminal/types.ts b/frontend/components/dashboard/scan-terminal/types.ts index 4e6980e4..60af7732 100644 --- a/frontend/components/dashboard/scan-terminal/types.ts +++ b/frontend/components/dashboard/scan-terminal/types.ts @@ -9,6 +9,8 @@ export type AiCityForecastPayload = { reason_zh?: string | null; reason_en?: string | null; raw_reason?: string | null; + degraded?: boolean | null; + cached?: boolean | null; model?: string | null; provider?: string | null; city_forecast?: { diff --git a/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts b/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts index c0b87af8..b0ba4a4d 100644 --- a/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts +++ b/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts @@ -11,6 +11,7 @@ import { fetchBackendApi, } from "@/lib/backend-api"; import type { CityDetail, MarketScan } from "@/lib/dashboard-types"; +import { extractStreamingAirportRead } from "./ai-city-stream"; import { normalizeCityKey } from "./decision-utils"; const AI_CITY_FORECAST_CACHE_PREFIX = "polyWeather_aiCityForecast_v2"; @@ -106,6 +107,7 @@ function parseAiCityStreamBlock(block: string): AiCityStreamEvent | null { async function readAiCityForecastStream( response: Response, + locale: string, onProgress?: (progress: AiCityStreamProgress) => void, ) { if (!response.body) { @@ -115,6 +117,7 @@ async function readAiCityForecastStream( const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; + let accumulatedRaw = ""; let finalPayload: AiCityForecastPayload | null = null; const consumeBlock = (block: string) => { @@ -130,10 +133,26 @@ async function readAiCityForecastStream( return; } if (event === "delta") { + const content = String(data.content || ""); const rawLength = Number(data.raw_length); - onProgress?.({ + if (content) { + accumulatedRaw += content; + } + const streamingAirportRead = extractStreamingAirportRead( + accumulatedRaw, + locale, + ); + const progress: AiCityStreamProgress = { raw_length: Number.isFinite(rawLength) ? rawLength : null, - }); + }; + if (streamingAirportRead) { + if (locale === "en-US") { + progress.metar_read_en = streamingAirportRead; + } else { + progress.metar_read_zh = streamingAirportRead; + } + } + onProgress?.(progress); } }; @@ -214,7 +233,7 @@ function requestAiCityForecast({ : `HTTP ${response.status}`, ); } - return readAiCityForecastStream(response, onProgress); + return readAiCityForecastStream(response, locale, onProgress); }) .finally(() => { pendingAiCityForecastRequests.delete(requestKey); @@ -235,8 +254,8 @@ function getAiCityStreamProgressText(progress: AiCityStreamProgress, isEn: boole const rawLength = Number(progress.raw_length); if (Number.isFinite(rawLength) && rawLength > 0) { return isEn - ? `DeepSeek V4-Pro is streaming the airport bulletin read... ${Math.round(rawLength)} chars received.` - : `DeepSeek V4-Pro 正在流式解读机场报文... 已收到 ${Math.round(rawLength)} 字符。`; + ? `DeepSeek is streaming the airport-bulletin enhancement... ${Math.round(rawLength)} chars received.` + : `DeepSeek 正在流式增强机场报文解读... 已收到 ${Math.round(rawLength)} 字符。`; } return ""; } @@ -364,7 +383,7 @@ export function useAiCityForecast({ : initialFallback.city_forecast?.metar_read_zh) || (isEn ? "Reading the latest airport bulletin with model/METAR fallback ready..." - : "已先用最新 METAR 给出兜底解读,正在等待 DeepSeek V4-Pro 补充…"), + : "已先用最新 METAR 给出兜底解读,正在等待 DeepSeek 补充…"), }); void requestAiCityForecast({ city: detailCityName, @@ -397,7 +416,9 @@ export function useAiCityForecast({ isEn, report, }); - writeCachedPayload(cacheKey, usablePayload); + if (usablePayload.status === "ready" && !usablePayload.degraded) { + writeCachedPayload(cacheKey, usablePayload); + } setAiForecast({ payload: usablePayload, status: "ready" }); } }) @@ -409,7 +430,6 @@ export function useAiCityForecast({ isEn, report, }); - writeCachedPayload(cacheKey, fallbackPayload); setAiForecast({ payload: fallbackPayload, status: "ready" }); } }); diff --git a/web/scan_terminal_service.py b/web/scan_terminal_service.py index a11a9d85..cf312db4 100644 --- a/web/scan_terminal_service.py +++ b/web/scan_terminal_service.py @@ -55,6 +55,11 @@ SCAN_TERMINAL_BUILD_TIMEOUT_SEC = max( SCAN_AI_MODEL = str( os.getenv("POLYWEATHER_SCAN_AI_MODEL") or "deepseek-v4-pro" ).strip() +SCAN_CITY_AI_MODEL = str( + os.getenv("POLYWEATHER_SCAN_CITY_AI_MODEL") + or os.getenv("POLYWEATHER_SCAN_AI_MODEL") + or "deepseek-v4-flash" +).strip() SCAN_AI_BASE_URL = str( os.getenv("POLYWEATHER_DEEPSEEK_BASE_URL") or "https://api.deepseek.com" ).strip().rstrip("/") @@ -1217,7 +1222,7 @@ def _call_deepseek_scan_ai(ai_input: Dict[str, Any]) -> Dict[str, Any]: "Content-Type": "application/json", }, json={ - "model": SCAN_AI_MODEL, + "model": SCAN_CITY_AI_MODEL, "temperature": 0.1, "max_tokens": SCAN_AI_MAX_TOKENS, "response_format": {"type": "json_object"}, @@ -1342,7 +1347,7 @@ def _call_deepseek_city_ai(ai_input: Dict[str, Any], *, locale: str = "zh-CN") - normalized_locale = _normalize_locale(locale) system_prompt = ( - "你是 PolyWeather 的 Deepseek V4-Pro 城市最高温预测员。你必须直接阅读用户给出的城市 JSON," + "你是 PolyWeather 的 DeepSeek 城市最高温预测员。你必须直接阅读用户给出的城市 JSON," "判断该城市今日最高温路径。不要写套利、交易、BUY YES/NO、价格、edge 或 Kelly。" "你的核心输出是:最终最高温点估计、置信区间、置信度、最终判断、机场报文/METAR 解读、判断依据和风险。" "必须综合 DEB 最终融合值、全部天气模型预测、METAR 实测序列、最新机场报文、峰值窗口、当地时间、季节背景。" @@ -1389,7 +1394,7 @@ def _call_deepseek_city_ai(ai_input: Dict[str, Any], *, locale: str = "zh-CN") - pool=5.0, ) request_json = { - "model": SCAN_AI_MODEL, + "model": SCAN_CITY_AI_MODEL, "temperature": 0.2, "max_tokens": SCAN_CITY_AI_MAX_TOKENS, "response_format": {"type": "json_object"}, @@ -1476,7 +1481,7 @@ def _call_deepseek_city_ai(ai_input: Dict[str, Any], *, locale: str = "zh-CN") - preview, ) repair_payload = { - "model": SCAN_AI_MODEL, + "model": SCAN_CITY_AI_MODEL, "temperature": 0.0, "max_tokens": min(max(SCAN_CITY_AI_MAX_TOKENS, 1200), 64000), "response_format": {"type": "json_object"}, @@ -1580,6 +1585,7 @@ def _scan_city_ai_cache_key(ai_input: Dict[str, Any]) -> str: key_payload = { "prompt_version": SCAN_CITY_AI_PROMPT_VERSION, "schema_version": ai_input.get("schema_version"), + "model": SCAN_CITY_AI_MODEL, "city": ai_input.get("city"), "local_date": ai_input.get("local_date"), "local_time": ai_input.get("local_time"), @@ -1620,7 +1626,7 @@ def _build_city_ai_stream_request( "不要写交易建议、BUY/SELL、Kelly 或套利。" ) return { - "model": SCAN_AI_MODEL, + "model": SCAN_CITY_AI_MODEL, "temperature": 0.2, "max_tokens": SCAN_CITY_AI_MAX_TOKENS, "response_format": {"type": "json_object"}, @@ -1691,7 +1697,7 @@ def _build_city_ai_result_payload( payload: Dict[str, Any] = { "status": "ready", "cached": cached, - "model": SCAN_AI_MODEL, + "model": SCAN_CITY_AI_MODEL, "provider": "deepseek", "city": data.get("name"), "city_display_name": data.get("display_name"), @@ -1729,7 +1735,7 @@ def stream_scan_city_ai_forecast_payload( "final", { "status": "failed", - "model": SCAN_AI_MODEL, + "model": SCAN_CITY_AI_MODEL, "provider": "deepseek", "city": city_name, "city_display_name": str(city or "").strip() or city_name, @@ -1765,7 +1771,7 @@ def stream_scan_city_ai_forecast_payload( { "status": "ready", "cached": True, - "model": SCAN_AI_MODEL, + "model": SCAN_CITY_AI_MODEL, "provider": "deepseek", "city": cached.get("city") or city_name, "city_display_name": cached.get("city_display_name") or city_name, @@ -1799,8 +1805,8 @@ def stream_scan_city_ai_forecast_payload( "stage": "calling_ai", "city": data.get("name") or city_name, "city_display_name": data.get("display_name") or city_name, - "message_zh": "DeepSeek V4-Pro 开始流式解读机场报文…", - "message_en": "DeepSeek V4-Pro is streaming the airport bulletin read…", + "message_zh": "DeepSeek 正在快速增强机场报文解读…", + "message_en": "DeepSeek is adding a fast airport-bulletin enhancement…", }, ) @@ -1809,7 +1815,7 @@ def stream_scan_city_ai_forecast_payload( "final", { "status": "disabled", - "model": SCAN_AI_MODEL, + "model": SCAN_CITY_AI_MODEL, "provider": "deepseek", "city": data.get("name") or city_name, "city_display_name": data.get("display_name") or city_name, @@ -1822,7 +1828,7 @@ def stream_scan_city_ai_forecast_payload( "final", { "status": "missing_key", - "model": SCAN_AI_MODEL, + "model": SCAN_CITY_AI_MODEL, "provider": "deepseek", "city": data.get("name") or city_name, "city_display_name": data.get("display_name") or city_name, @@ -1967,13 +1973,13 @@ def stream_scan_city_ai_forecast_payload( ) except httpx.TimeoutException as exc: duration_ms = int((time.time() - started_at) * 1000) - reason_en = f"DeepSeek V4-Pro timed out after {SCAN_CITY_AI_TIMEOUT_SEC}s" - reason_zh = f"DeepSeek V4-Pro 在 {SCAN_CITY_AI_TIMEOUT_SEC} 秒内未返回" + reason_en = f"DeepSeek city AI timed out after {SCAN_CITY_AI_TIMEOUT_SEC}s" + reason_zh = f"DeepSeek 城市 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_AI_MODEL, + SCAN_CITY_AI_MODEL, exc, ) ai_raw = _build_city_ai_fallback( @@ -2001,14 +2007,14 @@ def stream_scan_city_ai_forecast_payload( logger.warning( "scan city AI stream failed city={} model={} error={}", data.get("name") or city_name, - SCAN_AI_MODEL, + SCAN_CITY_AI_MODEL, reason, ) yield _sse_event( "final", { "status": "failed", - "model": SCAN_AI_MODEL, + "model": SCAN_CITY_AI_MODEL, "provider": "deepseek", "city": data.get("name") or city_name, "city_display_name": data.get("display_name") or city_name, @@ -2035,7 +2041,7 @@ def build_scan_city_ai_forecast_payload( if city_name not in CITIES: return { "status": "failed", - "model": SCAN_AI_MODEL, + "model": SCAN_CITY_AI_MODEL, "provider": "deepseek", "city": city_name, "city_display_name": str(city or "").strip() or city_name, @@ -2052,7 +2058,7 @@ def build_scan_city_ai_forecast_payload( city_name, force_refresh, normalized_locale, - SCAN_AI_MODEL, + SCAN_CITY_AI_MODEL, ) data = _analyze( city_name, @@ -2069,12 +2075,12 @@ def build_scan_city_ai_forecast_payload( logger.info( "scan city AI forecast cache hit city={} model={}", cached.get("city") or city_name, - SCAN_AI_MODEL, + SCAN_CITY_AI_MODEL, ) return { "status": "ready", "cached": True, - "model": SCAN_AI_MODEL, + "model": SCAN_CITY_AI_MODEL, "provider": "deepseek", "city": cached.get("city") or city_name, "city_display_name": cached.get("city_display_name") or city_name, @@ -2087,11 +2093,11 @@ def build_scan_city_ai_forecast_payload( logger.warning( "scan city AI forecast disabled city={} model={}", data.get("name") or city_name, - SCAN_AI_MODEL, + SCAN_CITY_AI_MODEL, ) return { "status": "disabled", - "model": SCAN_AI_MODEL, + "model": SCAN_CITY_AI_MODEL, "provider": "deepseek", "city": data.get("name") or city_name, "city_display_name": data.get("display_name") or city_name, @@ -2101,11 +2107,11 @@ def build_scan_city_ai_forecast_payload( logger.warning( "scan city AI forecast missing DeepSeek key city={} model={}", data.get("name") or city_name, - SCAN_AI_MODEL, + SCAN_CITY_AI_MODEL, ) return { "status": "missing_key", - "model": SCAN_AI_MODEL, + "model": SCAN_CITY_AI_MODEL, "provider": "deepseek", "city": data.get("name") or city_name, "city_display_name": data.get("display_name") or city_name, @@ -2117,7 +2123,7 @@ def build_scan_city_ai_forecast_payload( "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_AI_MODEL, + SCAN_CITY_AI_MODEL, bool( (ai_input.get("airport_current") or {}).get("raw_metar") if isinstance(ai_input.get("airport_current"), dict) @@ -2127,8 +2133,8 @@ def build_scan_city_ai_forecast_payload( 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"DeepSeek V4-Pro timed out after {SCAN_CITY_AI_TIMEOUT_SEC}s" - reason_zh = f"DeepSeek V4-Pro 在 {SCAN_CITY_AI_TIMEOUT_SEC} 秒内未返回" + reason_en = f"DeepSeek city AI timed out after {SCAN_CITY_AI_TIMEOUT_SEC}s" + reason_zh = f"DeepSeek 城市 AI 在 {SCAN_CITY_AI_TIMEOUT_SEC} 秒内未返回" ai_raw = _build_city_ai_fallback( ai_input, locale=normalized_locale, @@ -2139,14 +2145,14 @@ def build_scan_city_ai_forecast_payload( "scan city AI forecast timeout fallback city={} duration_ms={} model={} error={}", data.get("name") or city_name, duration_ms, - SCAN_AI_MODEL, + SCAN_CITY_AI_MODEL, exc, ) return { "status": "ready", "degraded": True, "cached": False, - "model": SCAN_AI_MODEL, + "model": SCAN_CITY_AI_MODEL, "provider": "deepseek", "city": data.get("name") or city_name, "city_display_name": data.get("display_name") or city_name, @@ -2162,12 +2168,12 @@ def build_scan_city_ai_forecast_payload( raw_reason = str(exc) empty_ai_content = raw_reason.strip().lower() == "empty ai content" reason_en = ( - "DeepSeek V4-Pro returned no usable text. Retry the city analysis." + "DeepSeek city AI returned no usable text. Retry the city analysis." if empty_ai_content else raw_reason ) reason_zh = ( - "DeepSeek V4-Pro 没有返回有效正文,请刷新重试。" + "DeepSeek 城市 AI 没有返回有效正文,请刷新重试。" if empty_ai_content else raw_reason ) @@ -2175,7 +2181,7 @@ def build_scan_city_ai_forecast_payload( "scan city AI forecast failed city={} duration_ms={} model={} error={}", data.get("name") or city_name, duration_ms, - SCAN_AI_MODEL, + SCAN_CITY_AI_MODEL, raw_reason, ) ai_raw = _build_city_ai_fallback( @@ -2188,7 +2194,7 @@ def build_scan_city_ai_forecast_payload( "status": "ready", "degraded": True, "cached": False, - "model": SCAN_AI_MODEL, + "model": SCAN_CITY_AI_MODEL, "provider": "deepseek", "city": data.get("name") or city_name, "city_display_name": data.get("display_name") or city_name, @@ -2211,13 +2217,13 @@ def build_scan_city_ai_forecast_payload( "scan city AI forecast complete city={} duration_ms={} model={} confidence={}", data.get("name") or city_name, int((time.time() - started_at) * 1000), - SCAN_AI_MODEL, + SCAN_CITY_AI_MODEL, ai_raw.get("confidence") if isinstance(ai_raw, dict) else None, ) return { "status": "ready", "cached": False, - "model": SCAN_AI_MODEL, + "model": SCAN_CITY_AI_MODEL, "provider": "deepseek", "city": data.get("name") or city_name, "city_display_name": data.get("display_name") or city_name,