feat: implement AI city forecast streaming service and UI component for scan terminal

This commit is contained in:
2569718930@qq.com
2026-04-26 12:58:33 +08:00
parent 670c0328c8
commit dacefec39d
4 changed files with 67 additions and 42 deletions
+3 -2
View File
@@ -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
@@ -428,11 +428,13 @@ function AiPinnedCityCard({
? "Deepseek V4 pro is reading the latest airport bulletin..."
: "Deepseek V4 pro 正在解读最新机场报文...")}
</p>
<p className="scan-ai-city-muted">
{isEn
? "The final airport read will appear here shortly."
: "机场报文解读稍后将在这里显示。"}
</p>
{!aiForecast.streamText ? (
<p className="scan-ai-city-muted">
{isEn
? "The final airport read will appear here shortly."
: "机场报文解读稍后将在这里显示。"}
</p>
) : null}
</>
) : aiForecast.status === "ready" && aiCityForecast ? (
<>
@@ -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,
+44 -31
View File
@@ -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"