From d46e0c2e810a3266a2e0391f947234f3a2f876c8 Mon Sep 17 00:00:00 2001
From: "2569718930@qq.com" <2569718930@qq.com>
Date: Mon, 27 Apr 2026 00:19:52 +0800
Subject: [PATCH] feat: implement AI-powered pinned city forecast dashboard
with real-time METAR and market analysis integration
---
.env.example | 9 +-
frontend/app/api/ai/metar-summary/route.ts | 69 ------
.../dashboard/ScanTerminalDashboard.tsx | 12 +
.../scan-terminal/AiPinnedForecastView.tsx | 121 ++++-----
.../dashboard/scan-terminal/types.ts | 18 --
.../scan-terminal/use-ai-city-card-data.ts | 232 ------------------
web/routes.py | 20 --
web/scan_terminal_service.py | 191 +-------------
8 files changed, 66 insertions(+), 606 deletions(-)
delete mode 100644 frontend/app/api/ai/metar-summary/route.ts
diff --git a/.env.example b/.env.example
index fa11a761..f1ca17c8 100644
--- a/.env.example
+++ b/.env.example
@@ -140,18 +140,15 @@ POLYWEATHER_GROQ_COMMENTARY_CACHE_TTL_SEC=1800
POLYWEATHER_SCAN_AI_ENABLED=false
POLYWEATHER_DEEPSEEK_API_KEY=
POLYWEATHER_DEEPSEEK_BASE_URL=https://api.deepseek.com
-POLYWEATHER_SCAN_AI_MODEL=deepseek-v4-pro
+POLYWEATHER_SCAN_AI_MODEL=deepseek-v4-flash
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_AI_TIMEOUT_SEC=18
+POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC=30
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
deleted file mode 100644
index a6bf714f..00000000
--- a/frontend/app/api/ai/metar-summary/route.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-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/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx
index 50cd0ed8..32bc954b 100644
--- a/frontend/components/dashboard/ScanTerminalDashboard.tsx
+++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx
@@ -412,6 +412,17 @@ function ScanTerminalScreen() {
);
}, []);
+ const refreshAiPinnedCityDetail = useCallback(
+ async (cityName: string) => {
+ const key = normalizeCityKey(cityName);
+ if (key) {
+ aiFullHydrationRef.current.delete(key);
+ }
+ await store.ensureCityDetail(cityName, true, "full");
+ },
+ [store.ensureCityDetail],
+ );
+
useEffect(() => {
aiPinnedCities.forEach((item) => {
const key = normalizeCityKey(item.cityName);
@@ -527,6 +538,7 @@ function ScanTerminalScreen() {
rows={timeSortedRows}
detailsByName={store.cityDetailsByName}
locale={locale}
+ onRefreshCityDetail={refreshAiPinnedCityDetail}
onRemoveCity={removeAiPinnedCity}
/>
);
diff --git a/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx b/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx
index caddb80b..b3b39cff 100644
--- a/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx
+++ b/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx
@@ -16,7 +16,6 @@ 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";
@@ -91,6 +90,7 @@ function AiPinnedCityCard({
locale,
collapsed,
removing,
+ onRefreshCityDetail,
onRemove,
onToggleCollapsed,
}: {
@@ -100,6 +100,7 @@ function AiPinnedCityCard({
locale: string;
collapsed: boolean;
removing?: boolean;
+ onRefreshCityDetail: (cityName: string) => Promise;
onRemove: () => void;
onToggleCollapsed: () => void;
}) {
@@ -153,20 +154,7 @@ 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 [refreshingDetail, setRefreshingDetail] = useState(false);
const { aiForecast, refreshAiForecast } = useAiCityForecast({
detail,
detailCityName,
@@ -180,6 +168,7 @@ function AiPinnedCityCard({
detailCityName,
enabled: Boolean(detail),
});
+ const isRefreshing = refreshingDetail || aiForecast.status === "loading";
const aiCityForecast = aiForecast.payload?.city_forecast || null;
const localizedFinalJudgmentRaw =
@@ -264,6 +253,7 @@ function AiPinnedCityCard({
? [String(localizedRisksRaw)]
: [];
const aiBullets = [
+ localizedMetarRead,
localizedReasoning !== localizedFinalJudgment ? localizedReasoning : "",
localizedModelNote || localModelSupportNote,
...localizedRisks,
@@ -309,13 +299,24 @@ function AiPinnedCityCard({
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
- refreshAiForecast();
+ if (refreshingDetail) return;
+ setRefreshingDetail(true);
+ void onRefreshCityDetail(detailCityName)
+ .catch(() => {})
+ .finally(() => {
+ refreshAiForecast();
+ setRefreshingDetail(false);
+ });
}}
aria-label={isEn ? `Refresh ${displayName} analysis` : `刷新 ${displayName} 深度分析`}
- title={isEn ? "Refresh analysis" : "刷新深度分析"}
- disabled={aiForecast.status === "loading"}
+ title={
+ isEn
+ ? "Refresh city data, chart and AI analysis"
+ : "刷新城市数据、温度走势图和 AI 分析"
+ }
+ disabled={isRefreshing}
>
-
+
{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" ? (
<>
{localizedFinalJudgment ||
aiForecast.streamText ||
(isEn
- ? "Generating the full city review in the background..."
- : "后台正在生成完整城市分析…")}
+ ? "DeepSeek is reading the airport bulletin and city context..."
+ : "DeepSeek 正在统一解读机场报文和城市上下文…")}
{isEn
- ? "This heavier JSON review can finish after the airport read."
- : "这部分是较重的 JSON 分析,可以晚于机场报文解读完成。"}
+ ? "One v4-flash stream now drives both the airport read and city judgment."
+ : "现在由 v4-flash 一条流同时生成机场报文解读和城市判断。"}
>
) : aiForecast.status === "ready" && aiCityForecast ? (
@@ -506,6 +461,13 @@ function AiPinnedCityCard({
{line}
))}
+
+ {report
+ ? `${isEn ? "Raw METAR" : "原始 METAR"}:${`${airportStation} ${report}`.trim()}`
+ : isEn
+ ? "Raw METAR: unavailable."
+ : "原始 METAR:暂无。"}
+
>
) : aiForecast.status === "ready" ? (
<>
@@ -521,6 +483,13 @@ function AiPinnedCityCard({
{localModelSupportNote}
+
+ {report
+ ? `${isEn ? "Raw METAR" : "原始 METAR"}:${`${airportStation} ${report}`.trim()}`
+ : isEn
+ ? "Raw METAR is unavailable."
+ : "暂无原始 METAR。"}
+
>
) : aiForecast.status === "failed" ? (
@@ -533,13 +502,20 @@ function AiPinnedCityCard({
{localModelSupportNote}
+
+ {report
+ ? `${isEn ? "Raw METAR" : "原始 METAR"}:${`${airportStation} ${report}`.trim()}`
+ : isEn
+ ? "Raw METAR is unavailable."
+ : "暂无原始 METAR。"}
+
>
) : (
{isEn
- ? "The complete city review is queued in the background."
- : "完整城市分析正在后台排队。"}
+ ? "Waiting for AI to read the latest airport bulletin."
+ : "等待 AI 解读最新机场报文。"}
)}
@@ -575,12 +551,14 @@ export function AiPinnedForecastView({
rows,
detailsByName,
locale,
+ onRefreshCityDetail,
onRemoveCity,
}: {
items: AiPinnedCity[];
rows: ScanOpportunityRow[];
detailsByName: Record;
locale: string;
+ onRefreshCityDetail: (cityName: string) => Promise;
onRemoveCity: (cityName: string) => void;
}) {
const isEn = locale === "en-US";
@@ -697,6 +675,7 @@ export function AiPinnedForecastView({
locale={locale}
collapsed={!isKnownCity || collapsedCities.has(stableKey)}
removing={removingCities.has(stableKey)}
+ onRefreshCityDetail={onRefreshCityDetail}
onRemove={() => removeCityWithMotion(item, stableKey)}
onToggleCollapsed={() => {
setCollapsedCities((current) => {
diff --git a/frontend/components/dashboard/scan-terminal/types.ts b/frontend/components/dashboard/scan-terminal/types.ts
index 5736a817..c140097d 100644
--- a/frontend/components/dashboard/scan-terminal/types.ts
+++ b/frontend/components/dashboard/scan-terminal/types.ts
@@ -38,21 +38,3 @@ export type AiCityForecastState = {
streamText?: string | null;
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 a6e31380..0afe3850 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,8 +4,6 @@ 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 {
@@ -24,10 +22,6 @@ const pendingAiCityForecastRequests = new Map<
string,
Promise
>();
-const pendingMetarSummaryRequests = new Map<
- string,
- Promise
->();
type AiCityStreamProgress = {
stage?: string | null;
@@ -45,13 +39,6 @@ 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 {
@@ -283,118 +270,6 @@ 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,
@@ -462,113 +337,6 @@ 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 e6ad87a4..bcdb01d6 100644
--- a/web/routes.py
+++ b/web/routes.py
@@ -36,7 +36,6 @@ 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,22 +1810,3 @@ async def scan_terminal_ai_city_stream(request: Request):
"X-Accel-Buffering": "no",
},
)
-
-
-@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 21cf571c..bb5329b6 100644
--- a/web/scan_terminal_service.py
+++ b/web/scan_terminal_service.py
@@ -53,7 +53,7 @@ SCAN_TERMINAL_BUILD_TIMEOUT_SEC = max(
int(os.getenv("POLYWEATHER_SCAN_TERMINAL_BUILD_TIMEOUT_SEC", "22")),
)
SCAN_AI_MODEL = str(
- os.getenv("POLYWEATHER_SCAN_AI_MODEL") or "deepseek-v4-pro"
+ os.getenv("POLYWEATHER_SCAN_AI_MODEL") or "deepseek-v4-flash"
).strip()
SCAN_CITY_AI_MODEL = str(
os.getenv("POLYWEATHER_SCAN_CITY_AI_MODEL")
@@ -98,24 +98,6 @@ 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 = [
@@ -1779,177 +1761,6 @@ 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,
*,