feat: implement AI city scanning terminal with streaming SSE support and concurrency-limited request queueing

This commit is contained in:
2569718930@qq.com
2026-04-26 07:58:19 +08:00
parent 922431d730
commit 763f131850
4 changed files with 734 additions and 33 deletions
@@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export const dynamic = "force-dynamic";
export const maxDuration = 90;
export async function POST(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
let body: unknown = {};
try {
body = await req.json();
} catch {
body = {};
}
const requestBody =
body && typeof body === "object" ? (body as Record<string, unknown>) : {};
const auth = await buildBackendRequestHeaders(req);
const headers = new Headers(auth.headers);
headers.set("Content-Type", "application/json");
headers.set("Accept", "text/event-stream");
try {
const res = await fetch(`${API_BASE}/api/scan/terminal/ai-city/stream`, {
method: "POST",
headers,
cache: "no-store",
body: JSON.stringify(requestBody),
});
if (!res.ok || !res.body) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
{ status: res.status === 402 || res.status === 403 ? res.status : 502 },
);
return applyAuthResponseCookies(response, auth.response);
}
const response = new NextResponse(res.body, {
status: 200,
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-store, no-transform",
"X-Accel-Buffering": "no",
},
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
const response = NextResponse.json(
{
error: "Failed to stream city AI data",
detail: String(error),
city: requestBody.city,
},
{ status: 500 },
);
return applyAuthResponseCookies(response, auth.response);
}
}
@@ -91,22 +91,155 @@ type AiCityForecastState = {
status: "idle" | "loading" | "ready" | "failed"; status: "idle" | "loading" | "ready" | "failed";
payload?: AiCityForecastPayload | null; payload?: AiCityForecastPayload | null;
error?: string | null; error?: string | null;
streamText?: string | null;
streamRaw?: string | null;
}; };
let aiCityFetchQueue: Promise<unknown> = Promise.resolve(); const AI_CITY_MAX_CONCURRENT = 2;
let aiCityActiveCount = 0;
const aiCityPendingQueue: Array<() => void> = [];
function createAiCityAbortError() {
return new DOMException("The AI city request was aborted.", "AbortError");
}
function drainAiCityFetchQueue() {
while (aiCityActiveCount < AI_CITY_MAX_CONCURRENT && aiCityPendingQueue.length) {
const next = aiCityPendingQueue.shift();
next?.();
}
}
function enqueueAiCityFetch<T>( function enqueueAiCityFetch<T>(
task: () => Promise<T>, task: () => Promise<T>,
signal: AbortSignal, signal: AbortSignal,
callbacks?: {
onQueued?: () => void;
onStart?: () => void;
},
): Promise<T> { ): Promise<T> {
const run = aiCityFetchQueue.then(async () => { return new Promise<T>((resolve, reject) => {
if (signal.aborted) { let started = false;
throw new DOMException("The AI city request was aborted.", "AbortError"); let queuedStart: (() => void) | null = null;
const cleanup = () => {
signal.removeEventListener("abort", handleAbort);
};
const removeQueuedStart = () => {
if (!queuedStart) return;
const index = aiCityPendingQueue.indexOf(queuedStart);
if (index >= 0) {
aiCityPendingQueue.splice(index, 1);
}
queuedStart = null;
};
const finishActive = () => {
aiCityActiveCount = Math.max(0, aiCityActiveCount - 1);
cleanup();
drainAiCityFetchQueue();
};
const handleAbort = () => {
if (started) return;
removeQueuedStart();
cleanup();
reject(createAiCityAbortError());
drainAiCityFetchQueue();
};
const start = () => {
queuedStart = null;
if (signal.aborted) {
cleanup();
reject(createAiCityAbortError());
drainAiCityFetchQueue();
return;
}
started = true;
aiCityActiveCount += 1;
callbacks?.onStart?.();
task()
.then(resolve, reject)
.finally(finishActive);
};
signal.addEventListener("abort", handleAbort, { once: true });
queuedStart = start;
if (aiCityActiveCount < AI_CITY_MAX_CONCURRENT) {
start();
} else {
callbacks?.onQueued?.();
aiCityPendingQueue.push(start);
} }
return task();
}); });
aiCityFetchQueue = run.catch(() => undefined); }
return run;
function parseSseBlock(block: string): { event: string; data: unknown } | null {
const lines = block.split(/\r?\n/);
let event = "message";
const dataLines: string[] = [];
for (const line of lines) {
if (line.startsWith("event:")) {
event = line.slice("event:".length).trim() || "message";
} else if (line.startsWith("data:")) {
dataLines.push(line.slice("data:".length).trimStart());
}
}
if (!dataLines.length) return null;
const raw = dataLines.join("\n");
try {
return { event, data: JSON.parse(raw) };
} catch {
return { event, data: raw };
}
}
function decodeJsonStringFragment(fragment: string) {
const safe = fragment.replace(/\\$/g, "");
try {
return JSON.parse(`"${safe.replace(/"/g, '\\"')}"`) as string;
} catch {
return safe
.replace(/\\"/g, '"')
.replace(/\\n/g, "\n")
.replace(/\\r/g, "\r")
.replace(/\\t/g, "\t")
.replace(/\\\\/g, "\\");
}
}
function extractStreamingJsonField(raw: string, field: string) {
const keyIndex = raw.indexOf(`"${field}"`);
if (keyIndex < 0) return "";
const colonIndex = raw.indexOf(":", keyIndex);
if (colonIndex < 0) return "";
const quoteIndex = raw.indexOf('"', colonIndex + 1);
if (quoteIndex < 0) return "";
let end = raw.length;
let escaped = false;
for (let i = quoteIndex + 1; i < raw.length; i += 1) {
const char = raw[i];
if (escaped) {
escaped = false;
continue;
}
if (char === "\\") {
escaped = true;
continue;
}
if (char === '"') {
end = i;
break;
}
}
return decodeJsonStringFragment(raw.slice(quoteIndex + 1, end)).trim();
}
function extractStreamingAirportRead(raw: string, locale: string) {
const primaryField = locale === "en-US" ? "metar_read_en" : "metar_read_zh";
const fallbackField = locale === "en-US" ? "metar_read_zh" : "metar_read_en";
return (
extractStreamingJsonField(raw, primaryField) ||
extractStreamingJsonField(raw, fallbackField)
);
} }
function formatShortDate(value?: string | null, locale = "zh-CN") { function formatShortDate(value?: string | null, locale = "zh-CN") {
@@ -684,13 +817,13 @@ function AiPinnedCityCard({
if (!aiForecastKey) return; if (!aiForecastKey) return;
let cancelled = false; let cancelled = false;
const controller = new AbortController(); const controller = new AbortController();
setAiForecast({ status: "loading" }); setAiForecast({ status: "loading", streamText: null, streamRaw: "" });
enqueueAiCityFetch( enqueueAiCityFetch(
() => () =>
fetch("/api/scan/terminal/ai-city", { fetch("/api/scan/terminal/ai-city/stream", {
method: "POST", method: "POST",
headers: { headers: {
Accept: "application/json", Accept: "text/event-stream",
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
cache: "no-store", cache: "no-store",
@@ -698,18 +831,18 @@ function AiPinnedCityCard({
body: JSON.stringify({ body: JSON.stringify({
city: detailCityName, city: detailCityName,
force_refresh: aiRefreshToken > 0, force_refresh: aiRefreshToken > 0,
locale: "zh-CN", locale,
}), }),
}).then(async (response) => { }).then(async (response) => {
if (!response.ok) { if (!response.ok) {
let detail = ""; let detailMessage = "";
try { try {
const errorPayload = await response.json(); const errorPayload = await response.json();
const message = String(errorPayload?.error || "").trim(); const message = String(errorPayload?.error || "").trim();
const rawDetail = String(errorPayload?.detail || "").trim(); const rawDetail = String(errorPayload?.detail || "").trim();
const elapsed = Number(errorPayload?.elapsed_ms); const elapsed = Number(errorPayload?.elapsed_ms);
const timeout = Number(errorPayload?.timeout_ms); const timeout = Number(errorPayload?.timeout_ms);
detail = [ detailMessage = [
message, message,
rawDetail, rawDetail,
Number.isFinite(elapsed) && Number.isFinite(timeout) Number.isFinite(elapsed) && Number.isFinite(timeout)
@@ -719,13 +852,108 @@ function AiPinnedCityCard({
.filter(Boolean) .filter(Boolean)
.join(" · "); .join(" · ");
} catch { } catch {
detail = ""; detailMessage = "";
} }
throw new Error(detail ? `HTTP ${response.status} · ${detail}` : `HTTP ${response.status}`); throw new Error(detailMessage ? `HTTP ${response.status} · ${detailMessage}` : `HTTP ${response.status}`);
} }
return response.json() as Promise<AiCityForecastPayload>; const contentType = response.headers.get("content-type") || "";
if (!response.body || !contentType.includes("text/event-stream")) {
return response.json() as Promise<AiCityForecastPayload>;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let rawStream = "";
let finalPayload: AiCityForecastPayload | null = null;
const handleBlock = (block: string) => {
const message = parseSseBlock(block);
if (!message || !message.data || typeof message.data !== "object") {
return;
}
const data = message.data as Record<string, unknown>;
if (message.event === "progress") {
const progressText =
String(locale === "en-US" ? data.message_en || "" : data.message_zh || "").trim() ||
String(data.message || "").trim();
if (progressText && !cancelled) {
setAiForecast((current) =>
current.status === "loading"
? { ...current, streamText: current.streamText || progressText }
: current,
);
}
} else if (message.event === "delta") {
const content = String(data.content || "");
if (!content) return;
rawStream += content;
const airportRead = extractStreamingAirportRead(rawStream, locale);
if (!cancelled) {
setAiForecast((current) =>
current.status === "loading"
? {
...current,
streamRaw: rawStream,
streamText: airportRead || current.streamText || null,
}
: current,
);
}
} else if (message.event === "final") {
finalPayload = data as AiCityForecastPayload;
}
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const blocks = buffer.split(/\n\n|\r\n\r\n/);
buffer = blocks.pop() || "";
for (const block of blocks) {
handleBlock(block);
}
}
buffer += decoder.decode();
if (buffer.trim()) {
handleBlock(buffer);
}
if (!finalPayload) {
throw new Error("AI stream ended before final payload");
}
return finalPayload;
}), }),
controller.signal, controller.signal,
{
onQueued: () => {
if (cancelled) return;
setAiForecast((current) =>
current.status === "loading"
? {
...current,
streamText: isEn
? "Waiting for the AI airport read queue..."
: "正在等待 AI 机场报文解读队列...",
}
: current,
);
},
onStart: () => {
if (cancelled) return;
setAiForecast((current) =>
current.status === "loading"
? {
...current,
streamText: current.streamRaw
? current.streamText
: isEn
? "Connecting to DeepSeek V4-Pro for airport bulletin streaming..."
: "正在连接 DeepSeek V4-Pro,准备流式解读机场报文...",
}
: current,
);
},
},
) )
.then((payload) => { .then((payload) => {
if (!cancelled) { if (!cancelled) {
@@ -742,7 +970,7 @@ function AiPinnedCityCard({
cancelled = true; cancelled = true;
controller.abort(); controller.abort();
}; };
}, [aiForecastKey, aiRefreshToken, detailCityName]); }, [aiForecastKey, aiRefreshToken, detailCityName, locale]);
const aiCityForecast = aiForecast.payload?.city_forecast || null; const aiCityForecast = aiForecast.payload?.city_forecast || null;
const localizedFinalJudgment = const localizedFinalJudgment =
@@ -911,11 +1139,21 @@ function AiPinnedCityCard({
{isEn ? "AI airport weather read" : "AI 机场报文解读"} {isEn ? "AI airport weather read" : "AI 机场报文解读"}
</div> </div>
{aiForecast.status === "loading" ? ( {aiForecast.status === "loading" ? (
<p> <>
{isEn <p className={aiForecast.streamText ? "scan-ai-weather-summary" : undefined}>
? "Deepseek V4 pro is reading the latest airport bulletin..." {aiForecast.streamText ||
: "Deepseek V4 pro 正在解读最新机场报文..."} (isEn
</p> ? "Deepseek V4 pro is reading the latest airport bulletin..."
: "Deepseek V4 pro 正在解读最新机场报文...")}
</p>
{aiForecast.streamText ? (
<p className="scan-ai-city-muted">
{isEn
? "Streaming airport read; final forecast is still being completed..."
: "机场报文解读正在流式输出,最终预报结论仍在补全..."}
</p>
) : null}
</>
) : aiForecast.status === "ready" && aiCityForecast ? ( ) : aiForecast.status === "ready" && aiCityForecast ? (
<> <>
<p className="scan-ai-weather-summary"> <p className="scan-ai-weather-summary">
+35 -1
View File
@@ -7,7 +7,7 @@ from typing import Optional
from fastapi.concurrency import run_in_threadpool from fastapi.concurrency import run_in_threadpool
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
from fastapi.responses import PlainTextResponse from fastapi.responses import PlainTextResponse, StreamingResponse
from loguru import logger from loguru import logger
from src.analysis.deb_algorithm import load_history from src.analysis.deb_algorithm import load_history
@@ -36,6 +36,7 @@ from web.scan_terminal_service import (
build_scan_city_ai_forecast_payload, build_scan_city_ai_forecast_payload,
build_scan_terminal_ai_payload, build_scan_terminal_ai_payload,
build_scan_terminal_payload, build_scan_terminal_payload,
stream_scan_city_ai_forecast_payload,
) )
from web.core import ( from web.core import (
AnalyticsEventRequest, AnalyticsEventRequest,
@@ -1777,3 +1778,36 @@ async def scan_terminal_ai_city(request: Request):
locale=locale, locale=locale,
) )
@router.post("/api/scan/terminal/ai-city/stream")
async def scan_terminal_ai_city_stream(request: Request):
_assert_entitlement(request)
try:
body = await request.json()
except Exception:
body = {}
if not isinstance(body, dict):
raise HTTPException(status_code=400, detail="Invalid JSON body")
city = str(body.get("city") or "").strip()
if not city:
raise HTTPException(status_code=400, detail="city is required")
force_refresh = str(body.get("force_refresh") or "false").lower() in {
"1",
"true",
"yes",
"on",
}
locale = str(body.get("locale") or "zh-CN").strip()
return StreamingResponse(
stream_scan_city_ai_forecast_payload(
city,
force_refresh=force_refresh,
locale=locale,
),
media_type="text/event-stream",
headers={
"Cache-Control": "no-store",
"X-Accel-Buffering": "no",
},
)
+369 -10
View File
@@ -9,7 +9,7 @@ import hashlib
from concurrent.futures import TimeoutError as FutureTimeoutError from concurrent.futures import TimeoutError as FutureTimeoutError
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional from typing import Any, Dict, Iterator, List, Optional
import httpx import httpx
from loguru import logger from loguru import logger
@@ -385,6 +385,18 @@ def _extract_provider_content(data: Any) -> str:
return str(message.get("content") or "") return str(message.get("content") or "")
def _extract_provider_stream_delta(data: Any) -> str:
if not isinstance(data, dict):
return ""
choices = data.get("choices") or []
if not choices or not isinstance(choices[0], dict):
return ""
delta = choices[0].get("delta") or {}
if not isinstance(delta, dict):
return ""
return str(delta.get("content") or "")
def _provider_response_meta(data: Any) -> Dict[str, Any]: def _provider_response_meta(data: Any) -> Dict[str, Any]:
if not isinstance(data, dict): if not isinstance(data, dict):
return {} return {}
@@ -1492,6 +1504,355 @@ def _scan_city_ai_cache_key(ai_input: Dict[str, Any]) -> str:
return "city-ai:" + hashlib.sha256(raw.encode("utf-8")).hexdigest() return "city-ai:" + hashlib.sha256(raw.encode("utf-8")).hexdigest()
def _city_forecast_cache_key(city_name: str) -> str:
return f"city_forecast:{SCAN_CITY_AI_PROMPT_VERSION}:{city_name.lower()}"
def _sse_event(event: str, payload: Dict[str, Any]) -> str:
return (
f"event: {event}\n"
f"data: {json.dumps(payload, ensure_ascii=False, default=str)}\n\n"
)
def _build_city_ai_stream_request(
ai_input: Dict[str, Any],
*,
locale: str,
) -> Dict[str, Any]:
normalized_locale = _normalize_locale(locale)
system_prompt = (
"你是 PolyWeather 的城市最高温与机场 METAR 解读员。"
"只返回一个紧凑 JSON object,不要 Markdown。"
"必须先写 metar_read_zh 和 metar_read_en 字段,便于前端流式显示机场报文解读;"
"然后写 final_judgment_zh/final_judgment_en、predicted_max、range_low、range_high、unit、confidence、"
"reasoning_zh/reasoning_en、risks_zh/risks_en、model_cluster_note_zh/model_cluster_note_en。"
"METAR 解读必须具体说明报文时间、温度、风向风速、云量/天气/能见度/露点中与温度路径相关的因素;"
"涉及风时要说明当前风向对机场最高温路径倾向增温、降温还是中性,并给出理由。"
"所有 *_zh 字段写简体中文,所有 *_en 字段写英文,不得留空。"
"不要写交易建议、BUY/SELL、Kelly 或套利。"
)
return {
"model": SCAN_AI_MODEL,
"temperature": 0.2,
"max_tokens": SCAN_CITY_AI_MAX_TOKENS,
"response_format": {"type": "json_object"},
"stream": True,
"messages": [
{"role": "system", "content": system_prompt},
{
"role": "user",
"content": json.dumps(
{
"locale": normalized_locale,
"task": (
"Return JSON keys in this exact order: metar_read_zh, metar_read_en, "
"final_judgment_zh, final_judgment_en, predicted_max, range_low, range_high, "
"unit, confidence, reasoning_zh, reasoning_en, risks_zh, risks_en, "
"model_cluster_note_zh, model_cluster_note_en. Keep it compact."
),
"city_snapshot": ai_input,
},
ensure_ascii=False,
),
},
],
}
def _cache_city_ai_payload(
cache_key: str,
*,
data: Dict[str, Any],
generated_at: str,
ai_raw: Dict[str, Any],
) -> None:
with _SCAN_CITY_AI_CACHE_LOCK:
_SCAN_CITY_AI_CACHE[cache_key] = {
"expires_at": time.time() + SCAN_AI_CACHE_TTL_SEC,
"generated_at": generated_at,
"city": data.get("name"),
"city_display_name": data.get("display_name"),
"payload": ai_raw,
}
def _build_city_ai_result_payload(
*,
data: Dict[str, Any],
generated_at: str,
started_at: float,
ai_raw: Dict[str, Any],
cached: bool = False,
degraded: bool = False,
reason: Optional[str] = None,
reason_zh: Optional[str] = None,
reason_en: Optional[str] = None,
) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"status": "ready",
"cached": cached,
"model": SCAN_AI_MODEL,
"provider": "deepseek",
"city": data.get("name"),
"city_display_name": data.get("display_name"),
"generated_at": generated_at,
"duration_ms": int((time.time() - started_at) * 1000),
"city_forecast": ai_raw,
}
if degraded:
payload["degraded"] = True
if reason:
payload["reason"] = reason
if reason_zh:
payload["reason_zh"] = reason_zh
if reason_en:
payload["reason_en"] = reason_en
return payload
def stream_scan_city_ai_forecast_payload(
city: str,
*,
force_refresh: bool = False,
locale: str = "zh-CN",
) -> Iterator[str]:
started_at = time.time()
city_name = _normalize_city_key(city)
normalized_locale = _normalize_locale(locale)
if not city_name:
yield _sse_event("final", {"status": "failed", "reason": "city is required"})
return
if city_name not in CITIES:
reason_en = f"Unknown city: {city_name}"
reason_zh = f"未知城市:{city_name}"
yield _sse_event(
"final",
{
"status": "failed",
"model": SCAN_AI_MODEL,
"provider": "deepseek",
"city": city_name,
"city_display_name": str(city or "").strip() or city_name,
"reason": reason_en if normalized_locale == "en-US" else reason_zh,
"reason_en": reason_en,
"reason_zh": reason_zh,
},
)
return
cache_key = _city_forecast_cache_key(city_name)
if not force_refresh:
with _SCAN_CITY_AI_CACHE_LOCK:
cached = _SCAN_CITY_AI_CACHE.get(cache_key)
if cached and cached.get("expires_at", 0) >= time.time():
yield _sse_event(
"final",
{
"status": "ready",
"cached": True,
"model": SCAN_AI_MODEL,
"provider": "deepseek",
"city": cached.get("city") or city_name,
"city_display_name": cached.get("city_display_name") or city_name,
"generated_at": cached.get("generated_at"),
"duration_ms": 0,
"city_forecast": cached.get("payload"),
},
)
return
yield _sse_event(
"progress",
{
"stage": "loading_city",
"message_zh": "正在读取城市实况、模型和最新机场报文…",
"message_en": "Loading city observations, model cluster and latest airport bulletin…",
},
)
data = _analyze(
city_name,
force_refresh=False,
include_llm_commentary=False,
detail_mode="full",
)
ai_input = _build_city_ai_prompt(data)
yield _sse_event(
"progress",
{
"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…",
},
)
if not SCAN_AI_ENABLED:
yield _sse_event(
"final",
{
"status": "disabled",
"model": SCAN_AI_MODEL,
"provider": "deepseek",
"city": data.get("name") or city_name,
"city_display_name": data.get("display_name") or city_name,
"reason": "POLYWEATHER_SCAN_AI_ENABLED is not enabled",
},
)
return
if not str(os.getenv("POLYWEATHER_DEEPSEEK_API_KEY") or "").strip():
yield _sse_event(
"final",
{
"status": "missing_key",
"model": SCAN_AI_MODEL,
"provider": "deepseek",
"city": data.get("name") or city_name,
"city_display_name": data.get("display_name") or city_name,
"reason": "POLYWEATHER_DEEPSEEK_API_KEY is not configured",
},
)
return
request_json = _build_city_ai_stream_request(ai_input, locale=normalized_locale)
timeout = httpx.Timeout(
timeout=float(SCAN_CITY_AI_TIMEOUT_SEC),
connect=min(8.0, float(SCAN_CITY_AI_TIMEOUT_SEC)),
read=float(SCAN_CITY_AI_TIMEOUT_SEC),
write=10.0,
pool=5.0,
)
headers = {
"Authorization": f"Bearer {os.getenv('POLYWEATHER_DEEPSEEK_API_KEY')}",
"Content-Type": "application/json",
}
accumulated = ""
last_meta: Dict[str, Any] = {}
try:
logger.info(
"scan city AI stream request city={} locale={} input_bytes={} timeout_sec={}",
ai_input.get("city"),
normalized_locale,
len(json.dumps(request_json, ensure_ascii=False, default=str).encode("utf-8")),
SCAN_CITY_AI_TIMEOUT_SEC,
)
with httpx.Client(timeout=timeout) as client:
with client.stream(
"POST",
f"{SCAN_AI_BASE_URL}/chat/completions",
headers=headers,
json=request_json,
) as response:
response.raise_for_status()
for line in response.iter_lines():
text = str(line or "").strip()
if not text or not text.startswith("data:"):
continue
payload_text = text[5:].strip()
if payload_text == "[DONE]":
break
try:
chunk = json.loads(payload_text)
except Exception:
continue
last_meta = _provider_response_meta(chunk) or last_meta
delta = _extract_provider_stream_delta(chunk)
if delta:
accumulated += delta
yield _sse_event(
"delta",
{
"content": delta,
"raw_length": len(accumulated),
},
)
try:
ai_raw = _extract_ai_json_object(accumulated)
if isinstance(ai_raw, dict):
ai_raw["_polyweather_meta"] = {
**last_meta,
"streamed": True,
}
except Exception as exc:
ai_raw = _build_city_ai_fallback(
ai_input,
locale=normalized_locale,
reason=str(exc),
raw_content=accumulated,
)
generated_at = datetime.utcnow().isoformat() + "Z"
_cache_city_ai_payload(
cache_key,
data=data,
generated_at=generated_at,
ai_raw=ai_raw,
)
yield _sse_event(
"final",
_build_city_ai_result_payload(
data=data,
generated_at=generated_at,
started_at=started_at,
ai_raw=ai_raw,
),
)
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} 秒内未返回"
logger.warning(
"scan city AI stream timeout fallback city={} duration_ms={} model={} error={}",
data.get("name") or city_name,
duration_ms,
SCAN_AI_MODEL,
exc,
)
ai_raw = _build_city_ai_fallback(
ai_input,
locale=normalized_locale,
reason=reason_en if normalized_locale == "en-US" else reason_zh,
raw_content=accumulated,
)
generated_at = datetime.utcnow().isoformat() + "Z"
yield _sse_event(
"final",
_build_city_ai_result_payload(
data=data,
generated_at=generated_at,
started_at=started_at,
ai_raw=ai_raw,
degraded=True,
reason=reason_en if normalized_locale == "en-US" else reason_zh,
reason_en=reason_en,
reason_zh=reason_zh,
),
)
except Exception as exc:
reason = str(exc)
logger.warning(
"scan city AI stream failed city={} model={} error={}",
data.get("name") or city_name,
SCAN_AI_MODEL,
reason,
)
yield _sse_event(
"final",
{
"status": "failed",
"model": SCAN_AI_MODEL,
"provider": "deepseek",
"city": data.get("name") or city_name,
"city_display_name": data.get("display_name") or city_name,
"duration_ms": int((time.time() - started_at) * 1000),
"reason": reason,
"reason_en": reason,
"reason_zh": reason,
"raw_reason": reason,
},
)
def build_scan_city_ai_forecast_payload( def build_scan_city_ai_forecast_payload(
city: str, city: str,
*, *,
@@ -1525,7 +1886,7 @@ def build_scan_city_ai_forecast_payload(
normalized_locale, normalized_locale,
SCAN_AI_MODEL, SCAN_AI_MODEL,
) )
cache_key = f"city_forecast:{SCAN_CITY_AI_PROMPT_VERSION}:{city_name.lower()}" cache_key = _city_forecast_cache_key(city_name)
if not force_refresh: if not force_refresh:
with _SCAN_CITY_AI_CACHE_LOCK: with _SCAN_CITY_AI_CACHE_LOCK:
cached = _SCAN_CITY_AI_CACHE.get(cache_key) cached = _SCAN_CITY_AI_CACHE.get(cache_key)
@@ -1662,14 +2023,12 @@ def build_scan_city_ai_forecast_payload(
"raw_reason": raw_reason, "raw_reason": raw_reason,
} }
generated_at = datetime.utcnow().isoformat() + "Z" generated_at = datetime.utcnow().isoformat() + "Z"
with _SCAN_CITY_AI_CACHE_LOCK: _cache_city_ai_payload(
_SCAN_CITY_AI_CACHE[cache_key] = { cache_key,
"expires_at": time.time() + SCAN_AI_CACHE_TTL_SEC, data=data,
"generated_at": generated_at, generated_at=generated_at,
"city": data.get("name") or city_name, ai_raw=ai_raw,
"city_display_name": data.get("display_name") or city_name, )
"payload": ai_raw,
}
logger.info( logger.info(
"scan city AI forecast complete city={} duration_ms={} model={} confidence={}", "scan city AI forecast complete city={} duration_ms={} model={} confidence={}",
data.get("name") or city_name, data.get("name") or city_name,