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({
- {localModelSupportNote}
- -
- {report
- ? `${isEn ? "Raw METAR" : "原始 METAR"}:${`${airportStation} ${report}`.trim()}`
- : isEn
- ? "Raw METAR is unavailable."
- : "暂无原始 METAR。"}
-
>
) : aiForecast.status === "failed" ? (
@@ -488,20 +533,13 @@ function AiPinnedCityCard({
- {localModelSupportNote}
- -
- {report
- ? `${isEn ? "Raw METAR" : "原始 METAR"}:${`${airportStation} ${report}`.trim()}`
- : isEn
- ? "Raw METAR is unavailable."
- : "暂无原始 METAR。"}
-
>
) : (
{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,
*,