feat: implement ScanTerminalDashboard UI and backend service for AI-driven city weather forecasting
This commit is contained in:
@@ -6,8 +6,8 @@ import {
|
|||||||
|
|
||||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||||
const SCAN_AI_PROXY_TIMEOUT_MS = Math.max(
|
const SCAN_AI_PROXY_TIMEOUT_MS = Math.max(
|
||||||
85_000,
|
10_000,
|
||||||
Number(process.env.POLYWEATHER_SCAN_AI_PROXY_TIMEOUT_MS || "85000") || 85_000,
|
Number(process.env.POLYWEATHER_SCAN_AI_PROXY_TIMEOUT_MS || "55000") || 55_000,
|
||||||
);
|
);
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|||||||
@@ -93,6 +93,22 @@ type AiCityForecastState = {
|
|||||||
error?: string | null;
|
error?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let aiCityFetchQueue: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
|
function enqueueAiCityFetch<T>(
|
||||||
|
task: () => Promise<T>,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<T> {
|
||||||
|
const run = aiCityFetchQueue.then(async () => {
|
||||||
|
if (signal.aborted) {
|
||||||
|
throw new DOMException("The AI city request was aborted.", "AbortError");
|
||||||
|
}
|
||||||
|
return task();
|
||||||
|
});
|
||||||
|
aiCityFetchQueue = run.catch(() => undefined);
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
function formatShortDate(value?: string | null, locale = "zh-CN") {
|
function formatShortDate(value?: string | null, locale = "zh-CN") {
|
||||||
const text = String(value || "").trim();
|
const text = String(value || "").trim();
|
||||||
if (!text) return "--";
|
if (!text) return "--";
|
||||||
@@ -667,57 +683,64 @@ function AiPinnedCityCard({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!aiForecastKey) return;
|
if (!aiForecastKey) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
const controller = new AbortController();
|
||||||
setAiForecast({ status: "loading" });
|
setAiForecast({ status: "loading" });
|
||||||
fetch("/api/scan/terminal/ai-city", {
|
enqueueAiCityFetch(
|
||||||
method: "POST",
|
() =>
|
||||||
headers: {
|
fetch("/api/scan/terminal/ai-city", {
|
||||||
Accept: "application/json",
|
method: "POST",
|
||||||
"Content-Type": "application/json",
|
headers: {
|
||||||
},
|
Accept: "application/json",
|
||||||
cache: "no-store",
|
"Content-Type": "application/json",
|
||||||
body: JSON.stringify({
|
},
|
||||||
city: detailCityName,
|
cache: "no-store",
|
||||||
force_refresh: aiRefreshToken > 0,
|
signal: controller.signal,
|
||||||
locale: "zh-CN",
|
body: JSON.stringify({
|
||||||
}),
|
city: detailCityName,
|
||||||
})
|
force_refresh: aiRefreshToken > 0,
|
||||||
.then(async (response) => {
|
locale: "zh-CN",
|
||||||
if (!response.ok) {
|
}),
|
||||||
let detail = "";
|
}).then(async (response) => {
|
||||||
try {
|
if (!response.ok) {
|
||||||
const errorPayload = await response.json();
|
let detail = "";
|
||||||
const message = String(errorPayload?.error || "").trim();
|
try {
|
||||||
const rawDetail = String(errorPayload?.detail || "").trim();
|
const errorPayload = await response.json();
|
||||||
const elapsed = Number(errorPayload?.elapsed_ms);
|
const message = String(errorPayload?.error || "").trim();
|
||||||
const timeout = Number(errorPayload?.timeout_ms);
|
const rawDetail = String(errorPayload?.detail || "").trim();
|
||||||
detail = [
|
const elapsed = Number(errorPayload?.elapsed_ms);
|
||||||
message,
|
const timeout = Number(errorPayload?.timeout_ms);
|
||||||
rawDetail,
|
detail = [
|
||||||
Number.isFinite(elapsed) && Number.isFinite(timeout)
|
message,
|
||||||
? `elapsed ${Math.round(elapsed / 1000)}s / timeout ${Math.round(timeout / 1000)}s`
|
rawDetail,
|
||||||
: "",
|
Number.isFinite(elapsed) && Number.isFinite(timeout)
|
||||||
]
|
? `elapsed ${Math.round(elapsed / 1000)}s / timeout ${Math.round(timeout / 1000)}s`
|
||||||
.filter(Boolean)
|
: "",
|
||||||
.join(" · ");
|
]
|
||||||
} catch {
|
.filter(Boolean)
|
||||||
detail = "";
|
.join(" · ");
|
||||||
|
} catch {
|
||||||
|
detail = "";
|
||||||
|
}
|
||||||
|
throw new Error(detail ? `HTTP ${response.status} · ${detail}` : `HTTP ${response.status}`);
|
||||||
}
|
}
|
||||||
throw new Error(detail ? `HTTP ${response.status} · ${detail}` : `HTTP ${response.status}`);
|
return response.json() as Promise<AiCityForecastPayload>;
|
||||||
}
|
}),
|
||||||
return response.json() as Promise<AiCityForecastPayload>;
|
controller.signal,
|
||||||
})
|
)
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setAiForecast({ payload, status: "ready" });
|
setAiForecast({ payload, status: "ready" });
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setAiForecast({ error: String(error), status: "failed" });
|
setAiForecast({ error: String(error), status: "failed" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
controller.abort();
|
||||||
};
|
};
|
||||||
}, [aiForecastKey, aiRefreshToken, detailCityName]);
|
}, [aiForecastKey, aiRefreshToken, detailCityName]);
|
||||||
|
|
||||||
|
|||||||
+231
-40
@@ -25,6 +25,25 @@ _SCAN_TERMINAL_AI_CACHE_LOCK = threading.Lock()
|
|||||||
_SCAN_TERMINAL_AI_CACHE: Dict[str, Dict[str, Any]] = {}
|
_SCAN_TERMINAL_AI_CACHE: Dict[str, Dict[str, Any]] = {}
|
||||||
_SCAN_CITY_AI_CACHE_LOCK = threading.Lock()
|
_SCAN_CITY_AI_CACHE_LOCK = threading.Lock()
|
||||||
_SCAN_CITY_AI_CACHE: Dict[str, Dict[str, Any]] = {}
|
_SCAN_CITY_AI_CACHE: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _env_int(
|
||||||
|
name: str,
|
||||||
|
default: int,
|
||||||
|
*,
|
||||||
|
min_value: int,
|
||||||
|
max_value: Optional[int] = None,
|
||||||
|
) -> int:
|
||||||
|
try:
|
||||||
|
value = int(os.getenv(name, str(default)))
|
||||||
|
except Exception:
|
||||||
|
value = int(default)
|
||||||
|
value = max(int(min_value), value)
|
||||||
|
if max_value is not None:
|
||||||
|
value = min(int(max_value), value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
SCAN_TERMINAL_PAYLOAD_TTL_SEC = max(
|
SCAN_TERMINAL_PAYLOAD_TTL_SEC = max(
|
||||||
5,
|
5,
|
||||||
int(os.getenv("POLYWEATHER_SCAN_TERMINAL_PAYLOAD_TTL_SEC", "30")),
|
int(os.getenv("POLYWEATHER_SCAN_TERMINAL_PAYLOAD_TTL_SEC", "30")),
|
||||||
@@ -42,29 +61,34 @@ SCAN_AI_BASE_URL = str(
|
|||||||
SCAN_AI_ENABLED = str(
|
SCAN_AI_ENABLED = str(
|
||||||
os.getenv("POLYWEATHER_SCAN_AI_ENABLED") or "false"
|
os.getenv("POLYWEATHER_SCAN_AI_ENABLED") or "false"
|
||||||
).strip().lower() in {"1", "true", "yes", "on"}
|
).strip().lower() in {"1", "true", "yes", "on"}
|
||||||
SCAN_AI_TIMEOUT_SEC = max(
|
SCAN_AI_TIMEOUT_SEC = _env_int(
|
||||||
30,
|
"POLYWEATHER_SCAN_AI_TIMEOUT_SEC",
|
||||||
int(os.getenv("POLYWEATHER_SCAN_AI_TIMEOUT_SEC", "40")),
|
40,
|
||||||
|
min_value=10,
|
||||||
|
max_value=120,
|
||||||
)
|
)
|
||||||
SCAN_CITY_AI_TIMEOUT_SEC = max(
|
SCAN_CITY_AI_TIMEOUT_SEC = _env_int(
|
||||||
75,
|
"POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC",
|
||||||
int(os.getenv("POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC", "75")),
|
45,
|
||||||
|
min_value=10,
|
||||||
|
max_value=120,
|
||||||
)
|
)
|
||||||
SCAN_AI_CACHE_TTL_SEC = max(
|
SCAN_AI_CACHE_TTL_SEC = max(
|
||||||
30,
|
30,
|
||||||
int(os.getenv("POLYWEATHER_SCAN_AI_CACHE_TTL_SEC", "1800")),
|
int(os.getenv("POLYWEATHER_SCAN_AI_CACHE_TTL_SEC", "1800")),
|
||||||
)
|
)
|
||||||
SCAN_AI_MAX_ROWS = max(
|
SCAN_AI_MAX_ROWS = _env_int("POLYWEATHER_SCAN_AI_MAX_ROWS", 40, min_value=1)
|
||||||
1,
|
SCAN_AI_MAX_TOKENS = _env_int(
|
||||||
int(os.getenv("POLYWEATHER_SCAN_AI_MAX_ROWS", "40")),
|
"POLYWEATHER_SCAN_AI_MAX_TOKENS",
|
||||||
|
3200,
|
||||||
|
min_value=600,
|
||||||
|
max_value=64000,
|
||||||
)
|
)
|
||||||
SCAN_AI_MAX_TOKENS = max(
|
SCAN_CITY_AI_MAX_TOKENS = _env_int(
|
||||||
600,
|
"POLYWEATHER_SCAN_CITY_AI_MAX_TOKENS",
|
||||||
int(os.getenv("POLYWEATHER_SCAN_AI_MAX_TOKENS", "3200")),
|
1200,
|
||||||
)
|
min_value=800,
|
||||||
SCAN_CITY_AI_MAX_TOKENS = max(
|
max_value=64000,
|
||||||
8192,
|
|
||||||
int(os.getenv("POLYWEATHER_SCAN_CITY_AI_MAX_TOKENS", "8192")),
|
|
||||||
)
|
)
|
||||||
SCAN_CITY_AI_PROMPT_VERSION = "city-airport-read-v3"
|
SCAN_CITY_AI_PROMPT_VERSION = "city-airport-read-v3"
|
||||||
|
|
||||||
@@ -450,6 +474,8 @@ def _build_city_ai_fallback(
|
|||||||
content_preview = _truncate_ai_text(raw_content, 1000)
|
content_preview = _truncate_ai_text(raw_content, 1000)
|
||||||
looks_like_truncated_json = bool(content_preview.startswith("{") and not content_preview.rstrip().endswith("}"))
|
looks_like_truncated_json = bool(content_preview.startswith("{") and not content_preview.rstrip().endswith("}"))
|
||||||
reason_preview = _truncate_ai_text(reason, 260)
|
reason_preview = _truncate_ai_text(reason, 260)
|
||||||
|
reason_lower = str(reason or "").lower()
|
||||||
|
timed_out = "timeout" in reason_lower or "timed out" in reason_lower or "超时" in str(reason or "")
|
||||||
if content_preview and not looks_like_truncated_json:
|
if content_preview and not looks_like_truncated_json:
|
||||||
metar_zh = f"DeepSeek V4-Pro 返回了非结构化解读,系统已保留摘要:{content_preview}"
|
metar_zh = f"DeepSeek V4-Pro 返回了非结构化解读,系统已保留摘要:{content_preview}"
|
||||||
metar_en = f"DeepSeek V4-Pro returned non-JSON analysis; preserved summary: {content_preview}"
|
metar_en = f"DeepSeek V4-Pro returned non-JSON analysis; preserved summary: {content_preview}"
|
||||||
@@ -463,10 +489,24 @@ def _build_city_ai_fallback(
|
|||||||
metar_zh = "当前没有可用的原始 METAR 正文,暂以 DEB 与多模型路径为主。"
|
metar_zh = "当前没有可用的原始 METAR 正文,暂以 DEB 与多模型路径为主。"
|
||||||
metar_en = "No raw METAR text is available, so DEB and the model cluster carry the read."
|
metar_en = "No raw METAR text is available, so DEB and the model cluster carry the read."
|
||||||
predicted_text = _format_ai_temperature(predicted, unit) or "--"
|
predicted_text = _format_ai_temperature(predicted, unit) or "--"
|
||||||
final_zh = f"{city} 预计最高温暂以 {predicted_text} 附近为中枢;AI 输出格式异常,已降级为模型/METAR 兜底判断。"
|
if timed_out:
|
||||||
final_en = f"{city} daily high is centered near {predicted_text}; AI output was not strict JSON, so this is a model/METAR fallback."
|
final_zh = f"{city} 预计最高温暂以 {predicted_text} 附近为中枢;DeepSeek V4-Pro 超时,已降级为模型/METAR 兜底判断。"
|
||||||
|
final_en = f"{city} daily high is centered near {predicted_text}; DeepSeek V4-Pro timed out, so this is a model/METAR fallback."
|
||||||
|
else:
|
||||||
|
final_zh = f"{city} 预计最高温暂以 {predicted_text} 附近为中枢;AI 输出格式异常,已降级为模型/METAR 兜底判断。"
|
||||||
|
final_en = f"{city} daily high is centered near {predicted_text}; AI output was not strict JSON, so this is a model/METAR fallback."
|
||||||
reasoning_zh = f"DEB、多模型集合和最新 METAR 仍可用于判断方向;原始失败原因:{reason_preview or 'AI 输出不是 JSON object'}。"
|
reasoning_zh = f"DEB、多模型集合和最新 METAR 仍可用于判断方向;原始失败原因:{reason_preview or 'AI 输出不是 JSON object'}。"
|
||||||
reasoning_en = f"DEB, the model cluster and latest METAR still support a directional read; raw failure: {reason_preview or 'AI output was not a JSON object'}."
|
reasoning_en = f"DEB, the model cluster and latest METAR still support a directional read; raw failure: {reason_preview or 'AI output was not a JSON object'}."
|
||||||
|
risks_zh = (
|
||||||
|
["DeepSeek V4-Pro 本次超时,需刷新重试确认 AI 细节。"]
|
||||||
|
if timed_out
|
||||||
|
else ["DeepSeek V4-Pro 本次没有返回严格 JSON,需刷新重试确认。"]
|
||||||
|
)
|
||||||
|
risks_en = (
|
||||||
|
["DeepSeek V4-Pro timed out; refresh to confirm the AI details."]
|
||||||
|
if timed_out
|
||||||
|
else ["DeepSeek V4-Pro did not return strict JSON; refresh to confirm."]
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"predicted_max": predicted,
|
"predicted_max": predicted,
|
||||||
"range_low": range_low,
|
"range_low": range_low,
|
||||||
@@ -479,13 +519,14 @@ def _build_city_ai_fallback(
|
|||||||
"metar_read_en": metar_en,
|
"metar_read_en": metar_en,
|
||||||
"reasoning_zh": reasoning_zh,
|
"reasoning_zh": reasoning_zh,
|
||||||
"reasoning_en": reasoning_en,
|
"reasoning_en": reasoning_en,
|
||||||
"risks_zh": ["DeepSeek V4-Pro 本次没有返回严格 JSON,需刷新重试确认。"],
|
"risks_zh": risks_zh,
|
||||||
"risks_en": ["DeepSeek V4-Pro did not return strict JSON; refresh to confirm."],
|
"risks_en": risks_en,
|
||||||
"model_cluster_note_zh": model_note_zh,
|
"model_cluster_note_zh": model_note_zh,
|
||||||
"model_cluster_note_en": model_note_en,
|
"model_cluster_note_en": model_note_en,
|
||||||
"_polyweather_meta": {
|
"_polyweather_meta": {
|
||||||
**_provider_response_meta(provider_data),
|
**_provider_response_meta(provider_data),
|
||||||
"fallback": True,
|
"fallback": True,
|
||||||
|
"fallback_kind": "timeout" if timed_out else "non_json",
|
||||||
"looks_like_truncated_json": looks_like_truncated_json,
|
"looks_like_truncated_json": looks_like_truncated_json,
|
||||||
"fallback_reason": reason_preview,
|
"fallback_reason": reason_preview,
|
||||||
"raw_content_preview": content_preview,
|
"raw_content_preview": content_preview,
|
||||||
@@ -635,6 +676,143 @@ def _compact_observation_points(raw_points: Any, limit: int = 24) -> List[Dict[s
|
|||||||
return sorted_points[-max(1, int(limit)) :]
|
return sorted_points[-max(1, int(limit)) :]
|
||||||
|
|
||||||
|
|
||||||
|
def _compact_ai_text(value: Any, limit: int = 700) -> Optional[str]:
|
||||||
|
text = _truncate_ai_text(value, limit).strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
|
def _compact_hourly_context(raw_hourly: Any) -> Dict[str, Any]:
|
||||||
|
if not isinstance(raw_hourly, dict):
|
||||||
|
return {}
|
||||||
|
times = raw_hourly.get("times") or raw_hourly.get("time") or []
|
||||||
|
temps = raw_hourly.get("temps") or raw_hourly.get("temperature_2m") or []
|
||||||
|
radiation = raw_hourly.get("radiation") or raw_hourly.get("shortwave_radiation") or []
|
||||||
|
if not isinstance(times, list) or not isinstance(temps, list):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
points: List[Dict[str, Any]] = []
|
||||||
|
for idx, raw_time in enumerate(times):
|
||||||
|
temp = _safe_float(temps[idx] if idx < len(temps) else None)
|
||||||
|
if temp is None:
|
||||||
|
continue
|
||||||
|
time_text = str(raw_time or "").strip()
|
||||||
|
if "T" in time_text:
|
||||||
|
time_text = time_text.split("T", 1)[1][:5]
|
||||||
|
elif len(time_text) > 5:
|
||||||
|
time_text = time_text[:5]
|
||||||
|
point: Dict[str, Any] = {"time": time_text, "temp": temp}
|
||||||
|
rad = _safe_float(radiation[idx] if isinstance(radiation, list) and idx < len(radiation) else None)
|
||||||
|
if rad is not None:
|
||||||
|
point["radiation"] = rad
|
||||||
|
points.append(point)
|
||||||
|
|
||||||
|
if not points:
|
||||||
|
return {}
|
||||||
|
max_point = max(points, key=lambda item: _safe_float(item.get("temp")) or -999.0)
|
||||||
|
sample_indexes = {
|
||||||
|
idx
|
||||||
|
for idx in range(len(points))
|
||||||
|
if idx % 2 == 0 or idx >= len(points) - 4 or points[idx] is max_point
|
||||||
|
}
|
||||||
|
samples = [points[idx] for idx in sorted(sample_indexes)][-14:]
|
||||||
|
return {
|
||||||
|
"sample_count": len(points),
|
||||||
|
"forecast_hourly_max": max_point,
|
||||||
|
"samples": samples,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _compact_taf_context(raw_taf_data: Any) -> Dict[str, Any]:
|
||||||
|
if not isinstance(raw_taf_data, dict):
|
||||||
|
return {}
|
||||||
|
signal = raw_taf_data.get("signal") if isinstance(raw_taf_data.get("signal"), dict) else {}
|
||||||
|
source = signal or raw_taf_data
|
||||||
|
raw_taf = raw_taf_data.get("raw_taf") or source.get("raw_taf")
|
||||||
|
compact: Dict[str, Any] = {
|
||||||
|
"available": bool(source.get("available") or raw_taf),
|
||||||
|
"raw_taf": _compact_ai_text(raw_taf, 900),
|
||||||
|
"issue_time": raw_taf_data.get("issue_time") or source.get("issue_time"),
|
||||||
|
"valid_time_from": raw_taf_data.get("valid_time_from") or source.get("valid_time_from"),
|
||||||
|
"valid_time_to": raw_taf_data.get("valid_time_to") or source.get("valid_time_to"),
|
||||||
|
"peak_window": source.get("peak_window"),
|
||||||
|
"suppression_level": source.get("suppression_level"),
|
||||||
|
"disruption_level": source.get("disruption_level"),
|
||||||
|
"wind_shift": source.get("wind_shift"),
|
||||||
|
"wind_regimes": source.get("wind_regimes"),
|
||||||
|
"summary_zh": _compact_ai_text(source.get("summary_zh"), 260),
|
||||||
|
"summary_en": _compact_ai_text(source.get("summary_en"), 260),
|
||||||
|
}
|
||||||
|
segments = source.get("segments") if isinstance(source.get("segments"), list) else []
|
||||||
|
markers = source.get("markers") if isinstance(source.get("markers"), list) else []
|
||||||
|
if segments:
|
||||||
|
compact["segments"] = segments[:3]
|
||||||
|
if markers:
|
||||||
|
compact["markers"] = markers[:4]
|
||||||
|
return {key: value for key, value in compact.items() if value not in (None, "", [])}
|
||||||
|
|
||||||
|
|
||||||
|
def _compact_vertical_context(raw_vertical: Any) -> Dict[str, Any]:
|
||||||
|
if not isinstance(raw_vertical, dict):
|
||||||
|
return {}
|
||||||
|
keys = [
|
||||||
|
"source",
|
||||||
|
"window_start",
|
||||||
|
"window_end",
|
||||||
|
"suppression_risk",
|
||||||
|
"trigger_risk",
|
||||||
|
"mixing_strength",
|
||||||
|
"shear_risk",
|
||||||
|
"heating_setup",
|
||||||
|
"heating_score",
|
||||||
|
"summary_zh",
|
||||||
|
"summary_en",
|
||||||
|
]
|
||||||
|
compact: Dict[str, Any] = {}
|
||||||
|
for key in keys:
|
||||||
|
value = raw_vertical.get(key)
|
||||||
|
if isinstance(value, str):
|
||||||
|
value = _compact_ai_text(value, 280)
|
||||||
|
if value not in (None, "", []):
|
||||||
|
compact[key] = value
|
||||||
|
return compact
|
||||||
|
|
||||||
|
|
||||||
|
def _compact_intraday_context(raw_intraday: Any) -> Dict[str, Any]:
|
||||||
|
if not isinstance(raw_intraday, dict):
|
||||||
|
return {}
|
||||||
|
compact: Dict[str, Any] = {}
|
||||||
|
for key in [
|
||||||
|
"headline",
|
||||||
|
"headline_en",
|
||||||
|
"confidence",
|
||||||
|
"base_case_bucket",
|
||||||
|
"upside_bucket",
|
||||||
|
"downside_bucket",
|
||||||
|
"next_observation_time",
|
||||||
|
"peak_window",
|
||||||
|
]:
|
||||||
|
value = raw_intraday.get(key)
|
||||||
|
if isinstance(value, str):
|
||||||
|
value = _compact_ai_text(value, 220)
|
||||||
|
if value not in (None, "", []):
|
||||||
|
compact[key] = value
|
||||||
|
signals = raw_intraday.get("signal_contributions")
|
||||||
|
if isinstance(signals, list):
|
||||||
|
compact["signal_contributions"] = [
|
||||||
|
{
|
||||||
|
"label": item.get("label"),
|
||||||
|
"label_en": item.get("label_en"),
|
||||||
|
"direction": item.get("direction"),
|
||||||
|
"strength": item.get("strength"),
|
||||||
|
"summary": _compact_ai_text(item.get("summary"), 180),
|
||||||
|
"summary_en": _compact_ai_text(item.get("summary_en"), 180),
|
||||||
|
}
|
||||||
|
for item in signals[:4]
|
||||||
|
if isinstance(item, dict)
|
||||||
|
]
|
||||||
|
return compact
|
||||||
|
|
||||||
|
|
||||||
def _build_metar_decision_context(data: Dict[str, Any]) -> Dict[str, Any]:
|
def _build_metar_decision_context(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
today_obs = _compact_observation_points(data.get("metar_today_obs"), 36)
|
today_obs = _compact_observation_points(data.get("metar_today_obs"), 36)
|
||||||
recent_obs = _compact_observation_points(data.get("metar_recent_obs"), 12)
|
recent_obs = _compact_observation_points(data.get("metar_recent_obs"), 12)
|
||||||
@@ -1052,13 +1230,20 @@ def _build_city_ai_prompt(data: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
"station_code": airport_current.get("station_code"),
|
"station_code": airport_current.get("station_code"),
|
||||||
"station_label": airport_current.get("station_label"),
|
"station_label": airport_current.get("station_label"),
|
||||||
},
|
},
|
||||||
"taf": data.get("taf") or {},
|
"taf": _compact_taf_context(data.get("taf")),
|
||||||
"vertical_profile_signal": data.get("vertical_profile_signal") or {},
|
"vertical_profile_signal": _compact_vertical_context(
|
||||||
"intraday_meteorology": data.get("intraday_meteorology") or {},
|
data.get("vertical_profile_signal")
|
||||||
"hourly": data.get("hourly") or {},
|
),
|
||||||
"metar_today_obs": _compact_observation_points(data.get("metar_today_obs"), 36),
|
"intraday_meteorology": _compact_intraday_context(
|
||||||
"metar_recent_obs": _compact_observation_points(data.get("metar_recent_obs"), 12),
|
data.get("intraday_meteorology")
|
||||||
"settlement_today_obs": _compact_observation_points(data.get("settlement_today_obs"), 36),
|
),
|
||||||
|
"hourly": _compact_hourly_context(data.get("hourly")),
|
||||||
|
"metar_today_obs": _compact_observation_points(data.get("metar_today_obs"), 18),
|
||||||
|
"metar_recent_obs": _compact_observation_points(data.get("metar_recent_obs"), 8),
|
||||||
|
"settlement_today_obs": _compact_observation_points(
|
||||||
|
data.get("settlement_today_obs"),
|
||||||
|
18,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1115,9 +1300,7 @@ def _call_deepseek_city_ai(ai_input: Dict[str, Any], *, locale: str = "zh-CN") -
|
|||||||
request_json = {
|
request_json = {
|
||||||
"model": SCAN_AI_MODEL,
|
"model": SCAN_AI_MODEL,
|
||||||
"temperature": 0.2,
|
"temperature": 0.2,
|
||||||
"max_tokens": min(max(SCAN_CITY_AI_MAX_TOKENS, 8192), 64000),
|
"max_tokens": SCAN_CITY_AI_MAX_TOKENS,
|
||||||
"thinking": {"type": "enabled"},
|
|
||||||
"reasoning_effort": "high",
|
|
||||||
"response_format": {"type": "json_object"},
|
"response_format": {"type": "json_object"},
|
||||||
"messages": [
|
"messages": [
|
||||||
{"role": "system", "content": system_prompt},
|
{"role": "system", "content": system_prompt},
|
||||||
@@ -1204,7 +1387,7 @@ def _call_deepseek_city_ai(ai_input: Dict[str, Any], *, locale: str = "zh-CN") -
|
|||||||
repair_payload = {
|
repair_payload = {
|
||||||
"model": SCAN_AI_MODEL,
|
"model": SCAN_AI_MODEL,
|
||||||
"temperature": 0.0,
|
"temperature": 0.0,
|
||||||
"max_tokens": min(max(SCAN_CITY_AI_MAX_TOKENS, 4096), 64000),
|
"max_tokens": min(max(SCAN_CITY_AI_MAX_TOKENS, 1200), 64000),
|
||||||
"response_format": {"type": "json_object"},
|
"response_format": {"type": "json_object"},
|
||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
@@ -1415,27 +1598,35 @@ def build_scan_city_ai_forecast_payload(
|
|||||||
ai_raw = _call_deepseek_city_ai(ai_input, locale=normalized_locale)
|
ai_raw = _call_deepseek_city_ai(ai_input, locale=normalized_locale)
|
||||||
except httpx.TimeoutException as exc:
|
except httpx.TimeoutException as exc:
|
||||||
duration_ms = int((time.time() - started_at) * 1000)
|
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} 秒内未返回"
|
||||||
|
ai_raw = _build_city_ai_fallback(
|
||||||
|
ai_input,
|
||||||
|
locale=normalized_locale,
|
||||||
|
reason=reason_en if normalized_locale == "en-US" else reason_zh,
|
||||||
|
)
|
||||||
|
generated_at = datetime.utcnow().isoformat() + "Z"
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"scan city AI forecast timeout city={} duration_ms={} model={} error={}",
|
"scan city AI forecast timeout fallback city={} duration_ms={} model={} error={}",
|
||||||
data.get("name") or city_name,
|
data.get("name") or city_name,
|
||||||
duration_ms,
|
duration_ms,
|
||||||
SCAN_AI_MODEL,
|
SCAN_AI_MODEL,
|
||||||
exc,
|
exc,
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"status": "timeout",
|
"status": "ready",
|
||||||
|
"degraded": True,
|
||||||
|
"cached": False,
|
||||||
"model": SCAN_AI_MODEL,
|
"model": SCAN_AI_MODEL,
|
||||||
"provider": "deepseek",
|
"provider": "deepseek",
|
||||||
"city": data.get("name") or city_name,
|
"city": data.get("name") or city_name,
|
||||||
"city_display_name": data.get("display_name") or city_name,
|
"city_display_name": data.get("display_name") or city_name,
|
||||||
|
"generated_at": generated_at,
|
||||||
"duration_ms": duration_ms,
|
"duration_ms": duration_ms,
|
||||||
"reason": (
|
"reason": reason_en if normalized_locale == "en-US" else reason_zh,
|
||||||
f"DeepSeek V4-Pro timed out after {SCAN_CITY_AI_TIMEOUT_SEC}s"
|
"reason_en": reason_en,
|
||||||
if normalized_locale == "en-US"
|
"reason_zh": reason_zh,
|
||||||
else f"DeepSeek V4-Pro 在 {SCAN_CITY_AI_TIMEOUT_SEC} 秒内未返回"
|
"city_forecast": ai_raw,
|
||||||
),
|
|
||||||
"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} 秒内未返回",
|
|
||||||
}
|
}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
duration_ms = int((time.time() - started_at) * 1000)
|
duration_ms = int((time.time() - started_at) * 1000)
|
||||||
|
|||||||
Reference in New Issue
Block a user