From fdf001a80114cc67bd0550172feeec56752bdbf5 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Sun, 26 Apr 2026 04:11:20 +0800 Subject: [PATCH] feat: implement ScanTerminalDashboard component and scan_terminal_service for real-time market opportunity monitoring --- .../dashboard/ScanTerminalDashboard.tsx | 89 ++++++++++++++----- web/scan_terminal_service.py | 88 +++++++++++++++--- 2 files changed, 144 insertions(+), 33 deletions(-) diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index e9ca80af..642193bb 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -64,6 +64,9 @@ type AiPinnedCity = { type AiCityForecastPayload = { status?: string | null; reason?: string | null; + reason_zh?: string | null; + reason_en?: string | null; + raw_reason?: string | null; model?: string | null; provider?: string | null; city_forecast?: { @@ -584,11 +587,12 @@ function AiPinnedCityCard({ item.cityName; const tempSymbol = detail?.temp_symbol || row?.temp_symbol || "°C"; const modelView = detail ? getModelView(detail, detail.local_date) : null; - const modelValues = modelView - ? Object.values(modelView.models || {}) - .map((value) => Number(value)) - .filter((value) => Number.isFinite(value)) + const modelEntries = modelView + ? Object.entries(modelView.models || {}) + .map(([name, value]) => [name, Number(value)] as const) + .filter(([, value]) => Number.isFinite(value)) : []; + const modelValues = modelEntries.map(([, value]) => value); const modelMin = modelValues.length ? Math.min(...modelValues) : null; const modelMax = modelValues.length ? Math.max(...modelValues) : null; const paceView = detail ? getTodayPaceView(detail, locale as "zh-CN" | "en-US") : null; @@ -682,6 +686,21 @@ function AiPinnedCityCard({ (isEn ? aiCityForecast?.model_cluster_note_en : aiCityForecast?.model_cluster_note_zh) || ""; + const modelPreview = modelEntries + .slice(0, 4) + .map(([name, value]) => `${name} ${formatTemperatureValue(value, tempSymbol, { digits: 1 })}`) + .join(isEn ? " / " : " / "); + const localModelSupportNote = modelEntries.length + ? isEn + ? modelEntries.length <= 2 + ? `Model support is sparse: only ${modelEntries.length} sources are available${modelPreview ? ` (${modelPreview})` : ""}, so the read should lean more on DEB path and METAR.` + : `Model support: ${modelEntries.length} sources cluster between ${modelRange}; ${modelPreview}.` + : modelEntries.length <= 2 + ? `多模型支撑偏少:当前只有 ${modelEntries.length} 个模型${modelPreview ? `(${modelPreview})` : ""},需要更重视 DEB 路径和 METAR 实测。` + : `多模型支撑:${modelEntries.length} 个模型集中在 ${modelRange},代表模型为 ${modelPreview}。` + : isEn + ? "Model support is unavailable, so this city must rely on DEB path and METAR observations." + : "暂无可用多模型支撑,需要主要参考 DEB 路径和 METAR 实测。"; const localizedRisksRaw = (isEn ? aiCityForecast?.risks_en : aiCityForecast?.risks_zh) || []; const localizedRisks = Array.isArray(localizedRisksRaw) @@ -692,9 +711,13 @@ function AiPinnedCityCard({ const aiBullets = [ localizedMetarRead, localizedReasoning !== localizedFinalJudgment ? localizedReasoning : "", - localizedModelNote, + localizedModelNote || localModelSupportNote, ...localizedRisks, ].filter((line) => String(line || "").trim()); + const fallbackAiReason = + (isEn ? aiForecast.payload?.reason_en : aiForecast.payload?.reason_zh) || + aiForecast.payload?.reason || + ""; const collapseId = `ai-city-body-${normalizeCityKey(item.cityName) || item.addedAt}`; @@ -840,23 +863,47 @@ function AiPinnedCityCard({

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

- {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 本次解读超时,可稍后重试;城市数据和右侧简报不会被刷新。" - : aiForecast.payload?.reason || - (isEn - ? "AI read is unavailable for this city right now." - : "该城市暂时没有可用的 AI 解读。")} -

+ <> +

+ {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 本次解读超时,可稍后重试;城市数据和右侧简报不会被刷新。" + : fallbackAiReason || + (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 + ? "AI read failed. Model support and the raw METAR remain as fallback context." + : "AI 解读失败。下方保留多模型支撑和原始 METAR 作为兜底上下文。"} + {aiForecast.error ? ` ${aiForecast.error}` : ""} +

+ + ) : (

{isEn diff --git a/web/scan_terminal_service.py b/web/scan_terminal_service.py index 67590674..f07ec47a 100644 --- a/web/scan_terminal_service.py +++ b/web/scan_terminal_service.py @@ -933,7 +933,9 @@ def _call_deepseek_city_ai(ai_input: Dict[str, Any], *, locale: str = "zh-CN") - "reasoning_zh, reasoning_en, risks_zh, risks_en, model_cluster_note_zh, model_cluster_note_en. " f"Primary output language is {primary_language}; the UI will read fields ending with {primary_suffix}. " "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. Keep the whole JSON compact." + "and how wind/cloud/visibility/dewpoint may affect the temperature path. model_cluster_note must state " + "how many model sources are available, whether they support DEB, and whether the sample is too sparse. " + "Keep the whole JSON compact." ), "city_snapshot": ai_input, } @@ -965,22 +967,69 @@ def _call_deepseek_city_ai(ai_input: Dict[str, Any], *, locale: str = "zh-CN") - request_json.get("max_tokens"), SCAN_CITY_AI_TIMEOUT_SEC, ) + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } 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", - }, + headers=headers, json=request_json, ) 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 = ( + ((data.get("choices") or [{}])[0].get("message") or {}).get("content") + if isinstance(data, dict) + else None + ) + 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, + ) + retry_payload = dict(request_json) + retry_payload.pop("response_format", None) + retry_payload["temperature"] = 0.1 + retry_payload["messages"] = [ + { + "role": "system", + "content": ( + system_prompt + + " 这次重试必须返回一个紧凑 JSON object,不要解释,不要空回复。" + + " If you cannot infer a field, still return the field with a cautious sentence." + ), + }, + { + "role": "user", + "content": json.dumps( + { + **user_payload, + "retry_reason": "previous provider response had empty message.content", + "task": ( + user_payload["task"] + + " The previous response had empty content. Return only one compact JSON object now." + ), + }, + ensure_ascii=False, + ), + }, + ] + response = client.post( + f"{SCAN_AI_BASE_URL}/chat/completions", + headers=headers, + json=retry_payload, + ) + 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"] = { @@ -1118,12 +1167,24 @@ def build_scan_city_ai_forecast_payload( } except Exception as exc: duration_ms = int((time.time() - started_at) * 1000) + 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." + if empty_ai_content + else raw_reason + ) + reason_zh = ( + "DeepSeek V4-Pro 没有返回有效正文,请刷新重试。" + if empty_ai_content + else raw_reason + ) logger.warning( "scan city AI forecast failed city={} duration_ms={} model={} error={}", data.get("name") or city_name, duration_ms, SCAN_AI_MODEL, - exc, + raw_reason, ) return { "status": "failed", @@ -1132,7 +1193,10 @@ def build_scan_city_ai_forecast_payload( "city": data.get("name") or city_name, "city_display_name": data.get("display_name") or city_name, "duration_ms": duration_ms, - "reason": str(exc), + "reason": reason_en if normalized_locale == "en-US" else reason_zh, + "reason_en": reason_en, + "reason_zh": reason_zh, + "raw_reason": raw_reason, } generated_at = datetime.utcnow().isoformat() + "Z" with _SCAN_CITY_AI_CACHE_LOCK: