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";
payload?: AiCityForecastPayload | 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>(
task: () => Promise<T>,
signal: AbortSignal,
callbacks?: {
onQueued?: () => void;
onStart?: () => void;
},
): Promise<T> {
const run = aiCityFetchQueue.then(async () => {
if (signal.aborted) {
throw new DOMException("The AI city request was aborted.", "AbortError");
return new Promise<T>((resolve, reject) => {
let started = false;
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") {
@@ -684,13 +817,13 @@ function AiPinnedCityCard({
if (!aiForecastKey) return;
let cancelled = false;
const controller = new AbortController();
setAiForecast({ status: "loading" });
setAiForecast({ status: "loading", streamText: null, streamRaw: "" });
enqueueAiCityFetch(
() =>
fetch("/api/scan/terminal/ai-city", {
fetch("/api/scan/terminal/ai-city/stream", {
method: "POST",
headers: {
Accept: "application/json",
Accept: "text/event-stream",
"Content-Type": "application/json",
},
cache: "no-store",
@@ -698,18 +831,18 @@ function AiPinnedCityCard({
body: JSON.stringify({
city: detailCityName,
force_refresh: aiRefreshToken > 0,
locale: "zh-CN",
locale,
}),
}).then(async (response) => {
if (!response.ok) {
let detail = "";
let detailMessage = "";
try {
const errorPayload = await response.json();
const message = String(errorPayload?.error || "").trim();
const rawDetail = String(errorPayload?.detail || "").trim();
const elapsed = Number(errorPayload?.elapsed_ms);
const timeout = Number(errorPayload?.timeout_ms);
detail = [
detailMessage = [
message,
rawDetail,
Number.isFinite(elapsed) && Number.isFinite(timeout)
@@ -719,13 +852,108 @@ function AiPinnedCityCard({
.filter(Boolean)
.join(" · ");
} 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,
{
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) => {
if (!cancelled) {
@@ -742,7 +970,7 @@ function AiPinnedCityCard({
cancelled = true;
controller.abort();
};
}, [aiForecastKey, aiRefreshToken, detailCityName]);
}, [aiForecastKey, aiRefreshToken, detailCityName, locale]);
const aiCityForecast = aiForecast.payload?.city_forecast || null;
const localizedFinalJudgment =
@@ -911,11 +1139,21 @@ function AiPinnedCityCard({
{isEn ? "AI airport weather read" : "AI 机场报文解读"}
</div>
{aiForecast.status === "loading" ? (
<p>
{isEn
? "Deepseek V4 pro is reading the latest airport bulletin..."
: "Deepseek V4 pro 正在解读最新机场报文..."}
</p>
<>
<p className={aiForecast.streamText ? "scan-ai-weather-summary" : undefined}>
{aiForecast.streamText ||
(isEn
? "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 ? (
<>
<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 import APIRouter, BackgroundTasks, HTTPException, Request
from fastapi.responses import PlainTextResponse
from fastapi.responses import PlainTextResponse, StreamingResponse
from loguru import logger
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_terminal_ai_payload,
build_scan_terminal_payload,
stream_scan_city_ai_forecast_payload,
)
from web.core import (
AnalyticsEventRequest,
@@ -1777,3 +1778,36 @@ async def scan_terminal_ai_city(request: Request):
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 ThreadPoolExecutor, as_completed
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
from typing import Any, Dict, Iterator, List, Optional
import httpx
from loguru import logger
@@ -385,6 +385,18 @@ def _extract_provider_content(data: Any) -> str:
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]:
if not isinstance(data, dict):
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()
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(
city: str,
*,
@@ -1525,7 +1886,7 @@ def build_scan_city_ai_forecast_payload(
normalized_locale,
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:
with _SCAN_CITY_AI_CACHE_LOCK:
cached = _SCAN_CITY_AI_CACHE.get(cache_key)
@@ -1662,14 +2023,12 @@ def build_scan_city_ai_forecast_payload(
"raw_reason": raw_reason,
}
generated_at = datetime.utcnow().isoformat() + "Z"
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") or city_name,
"city_display_name": data.get("display_name") or city_name,
"payload": ai_raw,
}
_cache_city_ai_payload(
cache_key,
data=data,
generated_at=generated_at,
ai_raw=ai_raw,
)
logger.info(
"scan city AI forecast complete city={} duration_ms={} model={} confidence={}",
data.get("name") or city_name,