feat: implement AI city forecast streaming service and UI component for scan terminal
This commit is contained in:
+3
-2
@@ -142,11 +142,12 @@ POLYWEATHER_DEEPSEEK_API_KEY=
|
|||||||
POLYWEATHER_DEEPSEEK_BASE_URL=https://api.deepseek.com
|
POLYWEATHER_DEEPSEEK_BASE_URL=https://api.deepseek.com
|
||||||
POLYWEATHER_SCAN_AI_MODEL=deepseek-v4-pro
|
POLYWEATHER_SCAN_AI_MODEL=deepseek-v4-pro
|
||||||
POLYWEATHER_SCAN_AI_TIMEOUT_SEC=40
|
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_CACHE_TTL_SEC=1800
|
||||||
POLYWEATHER_SCAN_AI_MAX_ROWS=40
|
POLYWEATHER_SCAN_AI_MAX_ROWS=40
|
||||||
POLYWEATHER_SCAN_AI_MAX_TOKENS=3200
|
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_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_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
|
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 is reading the latest airport bulletin..."
|
||||||
: "Deepseek V4 pro 正在解读最新机场报文...")}
|
: "Deepseek V4 pro 正在解读最新机场报文...")}
|
||||||
</p>
|
</p>
|
||||||
<p className="scan-ai-city-muted">
|
{!aiForecast.streamText ? (
|
||||||
{isEn
|
<p className="scan-ai-city-muted">
|
||||||
? "The final airport read will appear here shortly."
|
{isEn
|
||||||
: "机场报文解读稍后将在这里显示。"}
|
? "The final airport read will appear here shortly."
|
||||||
</p>
|
: "机场报文解读稍后将在这里显示。"}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : aiForecast.status === "ready" && aiCityForecast ? (
|
) : aiForecast.status === "ready" && aiCityForecast ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const pendingAiCityForecastRequests = new Map<
|
|||||||
>();
|
>();
|
||||||
|
|
||||||
type AiCityStreamProgress = {
|
type AiCityStreamProgress = {
|
||||||
|
stage?: string | null;
|
||||||
message_en?: string | null;
|
message_en?: string | null;
|
||||||
message_zh?: string | null;
|
message_zh?: string | null;
|
||||||
final_judgment_en?: string | null;
|
final_judgment_en?: string | null;
|
||||||
@@ -358,11 +359,16 @@ export function useAiCityForecast({
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const initialFallback = buildAiCityFallbackPayload({ detail, isEn, report });
|
||||||
setAiForecast({
|
setAiForecast({
|
||||||
status: "loading",
|
status: "loading",
|
||||||
streamText: isEn
|
streamText:
|
||||||
? "DeepSeek V4-Pro is reading the latest airport bulletin..."
|
(isEn
|
||||||
: "DeepSeek V4-Pro 正在解读最新机场报文...",
|
? 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({
|
void requestAiCityForecast({
|
||||||
city: detailCityName,
|
city: detailCityName,
|
||||||
@@ -375,7 +381,10 @@ export function useAiCityForecast({
|
|||||||
setAiForecast((current) => ({
|
setAiForecast((current) => ({
|
||||||
...current,
|
...current,
|
||||||
status: "loading",
|
status: "loading",
|
||||||
streamText: progressText,
|
streamText:
|
||||||
|
progress.stage === "calling_ai" && current.streamText
|
||||||
|
? current.streamText
|
||||||
|
: progressText,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
requestKey,
|
requestKey,
|
||||||
|
|||||||
@@ -69,10 +69,13 @@ SCAN_AI_TIMEOUT_SEC = _env_int(
|
|||||||
)
|
)
|
||||||
SCAN_CITY_AI_TIMEOUT_SEC = _env_int(
|
SCAN_CITY_AI_TIMEOUT_SEC = _env_int(
|
||||||
"POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC",
|
"POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC",
|
||||||
45,
|
18,
|
||||||
min_value=10,
|
min_value=10,
|
||||||
max_value=120,
|
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(
|
SCAN_AI_CACHE_TTL_SEC = max(
|
||||||
30,
|
30,
|
||||||
int(os.getenv("POLYWEATHER_SCAN_AI_CACHE_TTL_SEC", "1800")),
|
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(
|
SCAN_CITY_AI_MAX_TOKENS = _env_int(
|
||||||
"POLYWEATHER_SCAN_CITY_AI_MAX_TOKENS",
|
"POLYWEATHER_SCAN_CITY_AI_MAX_TOKENS",
|
||||||
1200,
|
900,
|
||||||
min_value=800,
|
min_value=800,
|
||||||
max_value=64000,
|
max_value=64000,
|
||||||
)
|
)
|
||||||
@@ -1296,7 +1299,7 @@ def _call_deepseek_city_ai(ai_input: Dict[str, Any], *, locale: str = "zh-CN") -
|
|||||||
"如果峰值窗口尚未到来,不能过早下最终结论;如果峰值窗口已过或实测已创高,需要更重视 METAR 实测。"
|
"如果峰值窗口尚未到来,不能过早下最终结论;如果峰值窗口已过或实测已创高,需要更重视 METAR 实测。"
|
||||||
"所有面向用户的自然语言字段必须同时填写简体中文和英文两套内容:"
|
"所有面向用户的自然语言字段必须同时填写简体中文和英文两套内容:"
|
||||||
"_zh 字段写简体中文,_en 字段写英文。前端会按用户界面语言直接切换字段,不能留空。"
|
"_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。"
|
"只返回 JSON object,不要 Markdown。"
|
||||||
)
|
)
|
||||||
user_payload = {
|
user_payload = {
|
||||||
@@ -1817,39 +1820,49 @@ def stream_scan_city_ai_forecast_payload(
|
|||||||
}
|
}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
retry_reason = str(exc)
|
retry_reason = str(exc)
|
||||||
yield _sse_event(
|
if SCAN_CITY_AI_RETRY_ON_STREAM_PARSE_ERROR:
|
||||||
"progress",
|
yield _sse_event(
|
||||||
{
|
"progress",
|
||||||
"stage": "retry_non_stream",
|
{
|
||||||
"message_zh": "流式内容为空或 JSON 不完整,正在改用非流式严格 JSON 重试…",
|
"stage": "retry_non_stream",
|
||||||
"message_en": "Stream content was empty or incomplete JSON; retrying with a strict non-stream request…",
|
"message_zh": "流式内容为空或 JSON 不完整,正在改用非流式严格 JSON 重试…",
|
||||||
"raw_length": len(accumulated),
|
"message_en": "Stream content was empty or incomplete JSON; retrying with a strict non-stream request…",
|
||||||
"reason": retry_reason,
|
"raw_length": len(accumulated),
|
||||||
},
|
"reason": retry_reason,
|
||||||
)
|
},
|
||||||
try:
|
)
|
||||||
ai_raw = _call_deepseek_city_ai(ai_input, locale=normalized_locale)
|
try:
|
||||||
if isinstance(ai_raw, dict):
|
ai_raw = _call_deepseek_city_ai(ai_input, locale=normalized_locale)
|
||||||
meta = ai_raw.get("_polyweather_meta")
|
if isinstance(ai_raw, dict):
|
||||||
if not isinstance(meta, dict):
|
meta = ai_raw.get("_polyweather_meta")
|
||||||
meta = {}
|
if not isinstance(meta, dict):
|
||||||
ai_raw["_polyweather_meta"] = {
|
meta = {}
|
||||||
**meta,
|
ai_raw["_polyweather_meta"] = {
|
||||||
"streamed": False,
|
**meta,
|
||||||
"stream_retry_non_stream": True,
|
"streamed": False,
|
||||||
"stream_retry_reason": retry_reason,
|
"stream_retry_non_stream": True,
|
||||||
"stream_raw_length": len(accumulated),
|
"stream_retry_reason": retry_reason,
|
||||||
}
|
"stream_raw_length": len(accumulated),
|
||||||
if _is_city_ai_fallback(ai_raw):
|
}
|
||||||
|
if _is_city_ai_fallback(ai_raw):
|
||||||
|
degraded = True
|
||||||
|
degraded_reason = retry_reason
|
||||||
|
except Exception as retry_exc:
|
||||||
degraded = True
|
degraded = True
|
||||||
degraded_reason = retry_reason
|
degraded_reason = str(retry_exc)
|
||||||
except Exception as 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 = True
|
||||||
degraded_reason = str(retry_exc)
|
degraded_reason = retry_reason
|
||||||
ai_raw = _build_city_ai_fallback(
|
ai_raw = _build_city_ai_fallback(
|
||||||
ai_input,
|
ai_input,
|
||||||
locale=normalized_locale,
|
locale=normalized_locale,
|
||||||
reason=degraded_reason or retry_reason,
|
reason=retry_reason,
|
||||||
raw_content=accumulated,
|
raw_content=accumulated,
|
||||||
)
|
)
|
||||||
generated_at = datetime.utcnow().isoformat() + "Z"
|
generated_at = datetime.utcnow().isoformat() + "Z"
|
||||||
|
|||||||
Reference in New Issue
Block a user