From ef8ef833b9fe840bae08c1d7de0fe5e27a2e0ff5 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Sun, 26 Apr 2026 14:03:13 +0800 Subject: [PATCH] feat: implement AI-driven METAR summary service and dashboard UI components --- .env.example | 3 + frontend/app/api/ai/metar-summary/route.ts | 69 ++++++ .../scan-terminal/AiPinnedForecastView.tsx | 106 +++++--- .../dashboard/scan-terminal/types.ts | 17 ++ .../scan-terminal/use-ai-city-card-data.ts | 232 ++++++++++++++++++ web/routes.py | 19 ++ web/scan_terminal_service.py | 204 ++++++++++++++- 7 files changed, 614 insertions(+), 36 deletions(-) create mode 100644 frontend/app/api/ai/metar-summary/route.ts diff --git a/.env.example b/.env.example index 236f9f5f..fa11a761 100644 --- a/.env.example +++ b/.env.example @@ -142,13 +142,16 @@ POLYWEATHER_DEEPSEEK_API_KEY= POLYWEATHER_DEEPSEEK_BASE_URL=https://api.deepseek.com POLYWEATHER_SCAN_AI_MODEL=deepseek-v4-pro POLYWEATHER_SCAN_CITY_AI_MODEL=deepseek-v4-flash +POLYWEATHER_METAR_SUMMARY_AI_MODEL=deepseek-v4-flash POLYWEATHER_SCAN_AI_TIMEOUT_SEC=40 POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC=18 +POLYWEATHER_METAR_SUMMARY_AI_TIMEOUT_SEC=8 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=900 +POLYWEATHER_METAR_SUMMARY_AI_MAX_TOKENS=160 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 diff --git a/frontend/app/api/ai/metar-summary/route.ts b/frontend/app/api/ai/metar-summary/route.ts new file mode 100644 index 00000000..a6bf714f --- /dev/null +++ b/frontend/app/api/ai/metar-summary/route.ts @@ -0,0 +1,69 @@ +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 = 30; + +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) : {}; + 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/ai/metar-summary`, { + 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 METAR summary", + detail: String(error), + }, + { status: 500 }, + ); + return applyAuthResponseCookies(response, auth.response); + } +} diff --git a/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx b/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx index 03a58336..caddb80b 100644 --- a/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx +++ b/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx @@ -16,6 +16,7 @@ import { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSigna import type { AiPinnedCity } from "@/components/dashboard/scan-terminal/types"; import { useAiCityForecast, + useAiMetarSummary, useCityMarketScan, } from "@/components/dashboard/scan-terminal/use-ai-city-card-data"; import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types"; @@ -152,6 +153,20 @@ function AiPinnedCityCard({ detail?.airport_primary?.station_code || ""; const detailCityName = detail?.name || item.cityName; + const debText = + debNumber != null + ? formatTemperatureValue(debNumber, tempSymbol, { digits: 1 }) + : ""; + const { metarSummary } = useAiMetarSummary({ + airport: airportStation, + city: displayName, + deb: debText, + enabled: Boolean(detail && report), + isEn, + locale, + metar: report, + modelRange, + }); const { aiForecast, refreshAiForecast } = useAiCityForecast({ detail, detailCityName, @@ -249,7 +264,6 @@ function AiPinnedCityCard({ ? [String(localizedRisksRaw)] : []; const aiBullets = [ - localizedMetarRead, localizedReasoning !== localizedFinalJudgment ? localizedReasoning : "", localizedModelNote || localModelSupportNote, ...localizedRisks, @@ -420,21 +434,66 @@ function AiPinnedCityCard({
{isEn ? "Evidence · AI airport read" : "证据 · AI 机场报文解读"}
+ {metarSummary.status === "loading" ? ( + <> +

+ {metarSummary.streamText || + (isEn + ? "Streaming the lightweight METAR read..." + : "轻量 METAR 解读正在流式输出…")} +

+

+ {isEn + ? "This read is independent from the full city review below." + : "这一步已和下方完整城市分析解耦,不等待完整 JSON。"} +

+ + ) : metarSummary.status === "ready" && metarSummary.streamText ? ( +

{metarSummary.streamText}

+ ) : metarSummary.status === "failed" ? ( +

+ {localizedMetarRead || + (isEn + ? "Lightweight AI METAR read is unavailable; raw METAR remains below." + : "轻量 AI METAR 解读暂不可用;下方保留原始 METAR。")} +

+ ) : ( +

+ {report + ? isEn + ? "Waiting for lightweight AI to read the latest METAR." + : "等待轻量 AI 解读最新 METAR。" + : isEn + ? "Raw METAR is unavailable." + : "暂无原始 METAR。"} +

+ )} +

+ {report + ? `${isEn ? "Raw METAR" : "原始 METAR"}:${`${airportStation} ${report}`.trim()}` + : isEn + ? "Raw METAR: unavailable." + : "原始 METAR:暂无。"} +

+ +
+
+ {isEn ? "Complete city AI review" : "完整城市 AI 判断"} +
{aiForecast.status === "loading" ? ( <>

- {aiForecast.streamText || + {localizedFinalJudgment || + aiForecast.streamText || (isEn - ? "DeepSeek is reading the latest airport bulletin..." - : "DeepSeek 正在解读最新机场报文...")} + ? "Generating the full city review in the background..." + : "后台正在生成完整城市分析…")} +

+

+ {isEn + ? "This heavier JSON review can finish after the airport read." + : "这部分是较重的 JSON 分析,可以晚于机场报文解读完成。"}

- {!aiForecast.streamText ? ( -

- {isEn - ? "The final airport read will appear here shortly." - : "机场报文解读稍后将在这里显示。"} -

- ) : null} ) : aiForecast.status === "ready" && aiCityForecast ? ( <> @@ -447,13 +506,6 @@ function AiPinnedCityCard({
  • {line}
  • ))} -

    - {report - ? `${isEn ? "Raw METAR" : "原始 METAR"}:${`${airportStation} ${report}`.trim()}` - : isEn - ? "Raw METAR: unavailable." - : "原始 METAR:暂无。"} -

    ) : aiForecast.status === "ready" ? ( <> @@ -469,13 +521,6 @@ function AiPinnedCityCard({

    ) : aiForecast.status === "failed" ? ( @@ -488,20 +533,13 @@ function AiPinnedCityCard({

    ) : (

    {isEn - ? "Waiting for AI to read the latest airport bulletin." - : "等待 AI 解读最新机场报文。"} + ? "The complete city review is queued in the background." + : "完整城市分析正在后台排队。"}

    )}
    diff --git a/frontend/components/dashboard/scan-terminal/types.ts b/frontend/components/dashboard/scan-terminal/types.ts index 60af7732..5736a817 100644 --- a/frontend/components/dashboard/scan-terminal/types.ts +++ b/frontend/components/dashboard/scan-terminal/types.ts @@ -39,3 +39,20 @@ export type AiCityForecastState = { streamRaw?: string | null; }; +export type AiMetarSummaryPayload = { + status?: string | null; + summary?: string | null; + reason?: string | null; + degraded?: boolean | null; + duration_ms?: number | null; + model?: string | null; + provider?: string | null; +}; + +export type AiMetarSummaryState = { + status: "idle" | "loading" | "ready" | "failed"; + payload?: AiMetarSummaryPayload | null; + error?: string | null; + streamText?: string | null; +}; + diff --git a/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts b/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts index 0afe3850..a6e31380 100644 --- a/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts +++ b/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts @@ -4,6 +4,8 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import type { AiCityForecastPayload, AiCityForecastState, + AiMetarSummaryPayload, + AiMetarSummaryState, } from "@/components/dashboard/scan-terminal/types"; import { useDashboardStore } from "@/hooks/useDashboardStore"; import { @@ -22,6 +24,10 @@ const pendingAiCityForecastRequests = new Map< string, Promise >(); +const pendingMetarSummaryRequests = new Map< + string, + Promise +>(); type AiCityStreamProgress = { stage?: string | null; @@ -39,6 +45,13 @@ type AiCityStreamEvent = { event: string; }; +type AiMetarSummaryProgress = { + message_en?: string | null; + message_zh?: string | null; + content?: string | null; + raw_length?: number | null; +}; + function getStorage() { if (typeof window === "undefined") return null; try { @@ -270,6 +283,118 @@ function getAiCityStreamProgressText(progress: AiCityStreamProgress, isEn: boole return ""; } +async function readMetarSummaryStream( + response: Response, + onProgress?: (progress: AiMetarSummaryProgress) => void, +) { + if (!response.body) { + return response.json() as Promise; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let accumulated = ""; + let finalPayload: AiMetarSummaryPayload | null = null; + + const consumeBlock = (block: string) => { + const parsed = parseAiCityStreamBlock(block); + if (!parsed) return; + const { data, event } = parsed; + if (event === "final") { + finalPayload = data as AiMetarSummaryPayload; + return; + } + if (event === "progress") { + onProgress?.(data as AiMetarSummaryProgress); + return; + } + if (event === "delta") { + const content = String(data.content || ""); + if (content) { + accumulated += content; + } + onProgress?.({ + content: accumulated, + raw_length: Number(data.raw_length), + }); + } + }; + + for (;;) { + const { done, value } = await reader.read(); + buffer += decoder.decode(value || new Uint8Array(), { stream: !done }); + const blocks = buffer.split(/\r?\n\r?\n/); + buffer = blocks.pop() || ""; + blocks.forEach(consumeBlock); + if (done) break; + } + if (buffer.trim()) { + consumeBlock(buffer); + } + return ( + finalPayload || { + status: accumulated ? "ready" : "failed", + summary: accumulated, + } + ); +} + +function requestMetarSummary({ + airport, + city, + deb, + locale, + metar, + modelRange, + onProgress, + requestKey, +}: { + airport: string; + city: string; + deb: string; + locale: string; + metar: string; + modelRange: string; + onProgress?: (progress: AiMetarSummaryProgress) => void; + requestKey: string; +}) { + const pending = pendingMetarSummaryRequests.get(requestKey); + if (pending) return pending; + + const request = buildBrowserBackendHeaders({ + Accept: "text/event-stream", + "Content-Type": "application/json", + }) + .then((headers) => + fetchBackendApi("/api/ai/metar-summary", { + method: "POST", + headers, + cache: "no-store", + body: JSON.stringify({ + airport, + city, + deb, + locale, + metar, + model_range: modelRange, + }), + }), + ) + .then(async (response) => { + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + return readMetarSummaryStream(response, onProgress); + }) + .finally(() => { + pendingMetarSummaryRequests.delete(requestKey); + }); + + pendingMetarSummaryRequests.set(requestKey, request); + return request; +} + function buildAiCityFallbackPayload({ detail, error, @@ -337,6 +462,113 @@ function buildAiCityFallbackPayload({ }; } +export function useAiMetarSummary({ + airport, + city, + deb, + enabled = true, + isEn, + locale, + metar, + modelRange, +}: { + airport: string; + city: string; + deb: string; + enabled?: boolean; + isEn: boolean; + locale: string; + metar: string; + modelRange: string; +}) { + const [metarSummary, setMetarSummary] = useState({ + status: "idle", + }); + + const requestKey = useMemo( + () => + metar + ? [ + "metar-summary", + normalizeCityKey(city) || city, + airport, + locale, + deb, + modelRange, + metar, + ].join(":") + : "", + [airport, city, deb, locale, metar, modelRange], + ); + + useEffect(() => { + if (!enabled || !requestKey || !metar) { + setMetarSummary({ status: "idle" }); + return; + } + let cancelled = false; + setMetarSummary({ + status: "loading", + streamText: isEn + ? "Reading current METAR with a lightweight AI pass..." + : "正在用轻量 AI 快速解读当前 METAR…", + }); + void requestMetarSummary({ + airport, + city, + deb, + locale, + metar, + modelRange, + onProgress: (progress) => { + if (cancelled) return; + const text = String( + progress.content || + (isEn ? progress.message_en : progress.message_zh) || + "", + ).trim(); + if (!text) return; + setMetarSummary((current) => ({ + ...current, + status: "loading", + streamText: text, + })); + }, + requestKey, + }) + .then((payload) => { + if (cancelled) return; + const summary = String(payload?.summary || "").trim(); + if (summary) { + setMetarSummary({ + payload, + status: "ready", + streamText: summary, + }); + } else { + setMetarSummary({ + payload, + status: "failed", + error: String(payload?.reason || payload?.status || "empty response"), + }); + } + }) + .catch((error) => { + if (!cancelled) { + setMetarSummary({ + status: "failed", + error: String(error), + }); + } + }); + return () => { + cancelled = true; + }; + }, [airport, city, deb, enabled, isEn, locale, metar, modelRange, requestKey]); + + return { metarSummary }; +} + export function useAiCityForecast({ detail, detailCityName, diff --git a/web/routes.py b/web/routes.py index 1317f27e..e6ad87a4 100644 --- a/web/routes.py +++ b/web/routes.py @@ -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_metar_summary_payload, stream_scan_city_ai_forecast_payload, ) from web.core import ( @@ -1811,3 +1812,21 @@ async def scan_terminal_ai_city_stream(request: Request): }, ) + +@router.post("/api/ai/metar-summary") +async def ai_metar_summary_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") + return StreamingResponse( + stream_metar_summary_payload(body), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-store", + "X-Accel-Buffering": "no", + }, + ) diff --git a/web/scan_terminal_service.py b/web/scan_terminal_service.py index 955a67f5..21cf571c 100644 --- a/web/scan_terminal_service.py +++ b/web/scan_terminal_service.py @@ -98,6 +98,24 @@ SCAN_CITY_AI_MAX_TOKENS = _env_int( min_value=800, max_value=64000, ) +METAR_SUMMARY_AI_MODEL = str( + os.getenv("POLYWEATHER_METAR_SUMMARY_AI_MODEL") + or os.getenv("POLYWEATHER_SCAN_CITY_AI_MODEL") + or os.getenv("POLYWEATHER_SCAN_AI_MODEL") + or "deepseek-v4-flash" +).strip() +METAR_SUMMARY_AI_TIMEOUT_SEC = _env_int( + "POLYWEATHER_METAR_SUMMARY_AI_TIMEOUT_SEC", + 8, + min_value=3, + max_value=30, +) +METAR_SUMMARY_AI_MAX_TOKENS = _env_int( + "POLYWEATHER_METAR_SUMMARY_AI_MAX_TOKENS", + 160, + min_value=80, + max_value=1000, +) SCAN_CITY_AI_PROMPT_VERSION = "city-airport-read-v3" CITY_AI_REQUIRED_FIELDS = [ @@ -627,14 +645,25 @@ def _build_city_ai_fallback( if partial_ai.get("final_judgment_zh") or partial_ai.get("final_judgment_en"): final_zh = str(partial_ai.get("final_judgment_zh") or partial_ai.get("final_judgment_en") or "").strip() final_en = str(partial_ai.get("final_judgment_en") or partial_ai.get("final_judgment_zh") or "").strip() + elif partial_ai: + final_zh = f"{city} 预计最高温暂以 {predicted_text} 附近为中枢;AI 已先完成机场报文解读,最高温结论结合 DEB、多模型与最新 METAR 校准。" + final_en = f"{city} daily high is centered near {predicted_text}; AI has already read the airport bulletin, with the high calibrated against DEB, the model cluster and latest METAR." elif timed_out: final_zh = f"{city} 预计最高温暂以 {predicted_text} 附近为中枢;当前已先用 DEB、多模型和 METAR 快速证据模式判断。" final_en = f"{city} daily high is centered near {predicted_text}; the current read uses the fast DEB/model/METAR evidence mode." else: final_zh = f"{city} 预计最高温暂以 {predicted_text} 附近为中枢;当前已先用 DEB、多模型和 METAR 快速证据模式判断。" final_en = f"{city} daily high is centered near {predicted_text}; the current read uses the fast DEB/model/METAR evidence mode." - reasoning_zh = str(partial_ai.get("reasoning_zh") or "").strip() or "DEB、多模型集合和最新 METAR 已足够给出当前方向判断;AI 增强可作为后续补充,不阻塞本轮读数。" - reasoning_en = str(partial_ai.get("reasoning_en") or "").strip() or "DEB, the model cluster and latest METAR are enough for the current directional read; AI enhancement can be added later without blocking this card." + reasoning_zh = str(partial_ai.get("reasoning_zh") or "").strip() or ( + "AI 机场报文解读已用于校准日内节奏;DEB 与多模型集合继续约束最高温中枢,后续 METAR 用于确认是否需要上调或下修。" + if partial_ai + else "DEB、多模型集合和最新 METAR 已足够给出当前方向判断;AI 增强可作为后续补充,不阻塞本轮读数。" + ) + reasoning_en = str(partial_ai.get("reasoning_en") or "").strip() or ( + "The AI airport-bulletin read is already used to calibrate the intraday pace; DEB and the model cluster still constrain the high-temperature center, while later METAR reports confirm whether to revise it." + if partial_ai + else "DEB, the model cluster and latest METAR are enough for the current directional read; AI enhancement can be added later without blocking this card." + ) risks_zh = ["后续 METAR 若明显偏离模型路径,需及时修正最高温中枢。"] risks_en = ["If later METAR reports diverge from the model path, revise the daily-high center promptly."] return { @@ -1750,6 +1779,177 @@ def _build_city_ai_stream_request( } +def stream_metar_summary_payload(body: Dict[str, Any]) -> Iterator[str]: + """Stream a tiny METAR-only AI read. + + This intentionally does not share the full city-review prompt. The goal is + first-token speed for the "AI airport read" section, while the heavier city + JSON review continues separately. + """ + + started_at = time.time() + normalized_locale = _normalize_locale(str(body.get("locale") or "zh-CN")) + city = str(body.get("city") or "").strip() + airport = str(body.get("airport") or body.get("station") or "").strip() + metar = str(body.get("metar") or "").strip() + model_range = str(body.get("model_range") or "").strip() + deb = str(body.get("deb") or "").strip() + + if not metar: + yield _sse_event( + "final", + { + "status": "failed", + "model": METAR_SUMMARY_AI_MODEL, + "provider": "deepseek", + "summary": "", + "reason": "metar is required", + "duration_ms": int((time.time() - started_at) * 1000), + }, + ) + return + + yield _sse_event( + "progress", + { + "stage": "calling_ai", + "message_zh": "DeepSeek 正在快速解读当前 METAR…", + "message_en": "DeepSeek is quickly reading the current METAR…", + }, + ) + + if not SCAN_AI_ENABLED: + yield _sse_event( + "final", + { + "status": "disabled", + "model": METAR_SUMMARY_AI_MODEL, + "provider": "deepseek", + "summary": "", + "reason": "POLYWEATHER_SCAN_AI_ENABLED is not enabled", + "duration_ms": int((time.time() - started_at) * 1000), + }, + ) + return + if not str(os.getenv("POLYWEATHER_DEEPSEEK_API_KEY") or "").strip(): + yield _sse_event( + "final", + { + "status": "missing_key", + "model": METAR_SUMMARY_AI_MODEL, + "provider": "deepseek", + "summary": "", + "reason": "POLYWEATHER_DEEPSEEK_API_KEY is not configured", + "duration_ms": int((time.time() - started_at) * 1000), + }, + ) + return + + is_en = normalized_locale == "en-US" + system_prompt = ( + "You are PolyWeather's fast airport-bulletin module. " + "Use only the current METAR, DEB and model range. " + "Do not output JSON. Do not repeat the full METAR. Do not predict market prices. " + "Keep the answer within 80 Chinese characters or 45 English words." + if is_en + else "你是 PolyWeather 的机场报文快速解读模块。" + "请用中文用1到2句话解读当前 METAR 对今日最高温判断的影响。" + "要求:只基于当前 METAR、DEB 和模型区间;不输出 JSON;不要复述完整报文;" + "不要预测市场价格;不超过80个中文字。" + ) + user_prompt = ( + f"City: {city or 'unknown'}\n" + f"Airport: {airport or 'unknown'}\n" + f"METAR: {metar}\n" + f"DEB: {deb or 'unknown'}\n" + f"Model range: {model_range or 'unknown'}" + ) + request_json = { + "model": METAR_SUMMARY_AI_MODEL, + "temperature": 0.15, + "max_tokens": METAR_SUMMARY_AI_MAX_TOKENS, + "stream": True, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + } + timeout = httpx.Timeout( + timeout=float(METAR_SUMMARY_AI_TIMEOUT_SEC), + connect=min(5.0, float(METAR_SUMMARY_AI_TIMEOUT_SEC)), + read=float(METAR_SUMMARY_AI_TIMEOUT_SEC), + write=5.0, + pool=3.0, + ) + headers = { + "Authorization": f"Bearer {os.getenv('POLYWEATHER_DEEPSEEK_API_KEY')}", + "Content-Type": "application/json", + } + accumulated = "" + try: + logger.info( + "metar summary stream request city={} airport={} model={} timeout_sec={}", + city, + airport, + METAR_SUMMARY_AI_MODEL, + METAR_SUMMARY_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 + delta = _extract_provider_stream_delta(chunk) + if delta: + accumulated += delta + yield _sse_event( + "delta", + { + "content": delta, + "raw_length": len(accumulated), + }, + ) + summary = _truncate_ai_text(accumulated, 260) + yield _sse_event( + "final", + { + "status": "ready" if summary else "empty", + "model": METAR_SUMMARY_AI_MODEL, + "provider": "deepseek", + "summary": summary, + "duration_ms": int((time.time() - started_at) * 1000), + }, + ) + except Exception as exc: + summary = _truncate_ai_text(accumulated, 260) + yield _sse_event( + "final", + { + "status": "ready" if summary else "failed", + "degraded": bool(summary), + "model": METAR_SUMMARY_AI_MODEL, + "provider": "deepseek", + "summary": summary, + "reason": str(exc), + "duration_ms": int((time.time() - started_at) * 1000), + }, + ) + + def _cache_city_ai_payload( cache_key: str, *,