diff --git a/.env.example b/.env.example index 247996a4..f123673d 100644 --- a/.env.example +++ b/.env.example @@ -142,11 +142,12 @@ POLYWEATHER_DEEPSEEK_API_KEY= POLYWEATHER_DEEPSEEK_BASE_URL=https://api.deepseek.com POLYWEATHER_SCAN_AI_MODEL=deepseek-v4-pro POLYWEATHER_SCAN_AI_TIMEOUT_SEC=40 -POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC=45 +POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC=18 +POLYWEATHER_SCAN_CITY_AI_RETRY_ON_STREAM_PARSE_ERROR=false POLYWEATHER_SCAN_AI_CACHE_TTL_SEC=1800 POLYWEATHER_SCAN_AI_MAX_ROWS=40 POLYWEATHER_SCAN_AI_MAX_TOKENS=3200 -POLYWEATHER_SCAN_CITY_AI_MAX_TOKENS=1200 +POLYWEATHER_SCAN_CITY_AI_MAX_TOKENS=900 POLYWEATHER_SCAN_AI_PROXY_TIMEOUT_MS=55000 POLYWEATHER_PREWARM_CITIES=ankara,istanbul,shanghai,beijing,shenzhen,guangzhou,wuhan,chengdu,chongqing,hong kong,taipei,singapore,tokyo,seoul,busan,london,paris,madrid POLYWEATHER_CITY_SUMMARY_CACHE_TTL_SEC=1800 diff --git a/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx b/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx index e9b7f269..8f9cc85d 100644 --- a/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx +++ b/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx @@ -428,11 +428,13 @@ function AiPinnedCityCard({ ? "Deepseek V4 pro is reading the latest airport bulletin..." : "Deepseek V4 pro 正在解读最新机场报文...")}
-- {isEn - ? "The final airport read will appear here shortly." - : "机场报文解读稍后将在这里显示。"} -
+ {!aiForecast.streamText ? ( ++ {isEn + ? "The final airport read will appear here shortly." + : "机场报文解读稍后将在这里显示。"} +
+ ) : null} > ) : aiForecast.status === "ready" && aiCityForecast ? ( <> 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 d4513d5a..f94b0e69 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 @@ -23,6 +23,7 @@ const pendingAiCityForecastRequests = new Map< >(); type AiCityStreamProgress = { + stage?: string | null; message_en?: string | null; message_zh?: string | null; final_judgment_en?: string | null; @@ -358,11 +359,16 @@ export function useAiCityForecast({ cancelled = true; }; } + const initialFallback = buildAiCityFallbackPayload({ detail, isEn, report }); setAiForecast({ status: "loading", - streamText: isEn - ? "DeepSeek V4-Pro is reading the latest airport bulletin..." - : "DeepSeek V4-Pro 正在解读最新机场报文...", + streamText: + (isEn + ? initialFallback.city_forecast?.metar_read_en + : initialFallback.city_forecast?.metar_read_zh) || + (isEn + ? "Reading the latest airport bulletin with model/METAR fallback ready..." + : "已先用最新 METAR 给出兜底解读,正在等待 DeepSeek V4-Pro 补充…"), }); void requestAiCityForecast({ city: detailCityName, @@ -375,7 +381,10 @@ export function useAiCityForecast({ setAiForecast((current) => ({ ...current, status: "loading", - streamText: progressText, + streamText: + progress.stage === "calling_ai" && current.streamText + ? current.streamText + : progressText, })); }, requestKey, diff --git a/web/scan_terminal_service.py b/web/scan_terminal_service.py index 81399d7e..b63674e6 100644 --- a/web/scan_terminal_service.py +++ b/web/scan_terminal_service.py @@ -69,10 +69,13 @@ SCAN_AI_TIMEOUT_SEC = _env_int( ) SCAN_CITY_AI_TIMEOUT_SEC = _env_int( "POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC", - 45, + 18, 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", "1800")), @@ -86,7 +89,7 @@ SCAN_AI_MAX_TOKENS = _env_int( ) SCAN_CITY_AI_MAX_TOKENS = _env_int( "POLYWEATHER_SCAN_CITY_AI_MAX_TOKENS", - 1200, + 900, min_value=800, max_value=64000, ) @@ -1296,7 +1299,7 @@ def _call_deepseek_city_ai(ai_input: Dict[str, Any], *, locale: str = "zh-CN") - "如果峰值窗口尚未到来,不能过早下最终结论;如果峰值窗口已过或实测已创高,需要更重视 METAR 实测。" "所有面向用户的自然语言字段必须同时填写简体中文和英文两套内容:" "_zh 字段写简体中文,_en 字段写英文。前端会按用户界面语言直接切换字段,不能留空。" - "risks 最多 2 条,每条必须包含触发条件或方向来源;reasoning、model_cluster_note 各 1 句,metar_read 可用 2-4 句。" + "risks 最多 2 条,每条必须包含触发条件或方向来源;reasoning、model_cluster_note 各 1 句,metar_read 用 1-2 句。" "只返回 JSON object,不要 Markdown。" ) user_payload = { @@ -1817,39 +1820,49 @@ def stream_scan_city_ai_forecast_payload( } except Exception as exc: retry_reason = str(exc) - 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): + 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 = retry_reason - except Exception as retry_exc: + 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 = str(retry_exc) + degraded_reason = retry_reason ai_raw = _build_city_ai_fallback( ai_input, locale=normalized_locale, - reason=degraded_reason or retry_reason, + reason=retry_reason, raw_content=accumulated, ) generated_at = datetime.utcnow().isoformat() + "Z"