feat: implement scan terminal service with AI-powered city analysis and caching support

This commit is contained in:
2569718930@qq.com
2026-04-26 13:50:46 +08:00
parent d42cdf6b51
commit f9ad34d7c0
3 changed files with 127 additions and 19 deletions
@@ -155,7 +155,7 @@ function AiPinnedCityCard({
const { aiForecast, refreshAiForecast } = useAiCityForecast({
detail,
detailCityName,
enabled: Boolean(detail && !collapsed),
enabled: Boolean(detail),
isEn,
locale,
report,
@@ -163,7 +163,7 @@ function AiPinnedCityCard({
const { marketScan, marketStatus } = useCityMarketScan({
detail,
detailCityName,
enabled: Boolean(detail && !collapsed),
enabled: Boolean(detail),
});
const aiCityForecast = aiForecast.payload?.city_forecast || null;
@@ -572,7 +572,6 @@ export function AiPinnedForecastView({
items.forEach((item) => {
const stableKey = normalizeCityKey(item.cityName) || item.cityName;
if (!knownCityKeysRef.current.has(stableKey)) {
next.add(stableKey);
changed = true;
}
});
@@ -82,6 +82,16 @@ function writeCachedPayload<T>(key: string, payload: T) {
}
}
function removeCachedPayload(key: string) {
const storage = getStorage();
if (!storage) return;
try {
storage.removeItem(key);
} catch {
// Ignore privacy-mode failures; the next network request can still proceed.
}
}
function parseAiCityStreamBlock(block: string): AiCityStreamEvent | null {
const eventLines = block
.split(/\r?\n/)
@@ -369,10 +379,17 @@ export function useAiCityForecast({
)
: null;
if (cachedPayload) {
setAiForecast({ payload: cachedPayload, status: "ready" });
return () => {
cancelled = true;
};
if (
cachedPayload.status === "ready" &&
!cachedPayload.degraded &&
cachedPayload.city_forecast
) {
setAiForecast({ payload: cachedPayload, status: "ready" });
return () => {
cancelled = true;
};
}
removeCachedPayload(cacheKey);
}
const initialFallback = buildAiCityFallbackPayload({ detail, isEn, report });
setAiForecast({
+104 -12
View File
@@ -392,6 +392,90 @@ def _extract_ai_json_object(raw_text: str) -> Dict[str, Any]:
raise ValueError("AI content is not a JSON object")
def _decode_json_string_fragment(fragment: str) -> str:
safe = str(fragment or "")
while safe.endswith("\\"):
safe = safe[:-1]
try:
return str(json.loads(f'"{safe}"'))
except Exception:
return (
safe.replace('\\"', '"')
.replace("\\n", "\n")
.replace("\\r", "\r")
.replace("\\t", "\t")
.replace("\\\\", "\\")
)
def _extract_json_string_field_from_fragment(raw_text: str, field: str) -> str:
"""Best-effort extraction from a streamed/incomplete JSON object.
DeepSeek may stream useful fields first but still end with truncated JSON.
In that case the UI should keep the AI text already received instead of
replacing it with the deterministic DEB/METAR fallback.
"""
text = str(raw_text or "")
match = re.search(rf'"{re.escape(field)}"\s*:\s*"', text)
if not match:
return ""
idx = match.end()
chars: List[str] = []
escaped = False
while idx < len(text):
char = text[idx]
idx += 1
if escaped:
chars.append("\\" + char)
escaped = False
continue
if char == "\\":
escaped = True
continue
if char == '"':
break
chars.append(char)
if escaped:
chars.append("\\")
return _decode_json_string_fragment("".join(chars)).strip()
def _extract_json_number_field_from_fragment(raw_text: str, field: str) -> Optional[float]:
text = str(raw_text or "")
match = re.search(rf'"{re.escape(field)}"\s*:\s*(-?\d+(?:\.\d+)?)', text)
if not match:
return None
return _safe_float(match.group(1))
def _extract_city_ai_partial_fields(raw_text: str) -> Dict[str, Any]:
text = str(raw_text or "")
if not text.strip():
return {}
out: Dict[str, Any] = {}
for field in (
"metar_read_zh",
"metar_read_en",
"final_judgment_zh",
"final_judgment_en",
"reasoning_zh",
"reasoning_en",
"model_cluster_note_zh",
"model_cluster_note_en",
"confidence",
"unit",
):
value = _extract_json_string_field_from_fragment(text, field)
if value:
out[field] = value
for field in ("predicted_max", "range_low", "range_high"):
value = _extract_json_number_field_from_fragment(text, field)
if value is not None:
out[field] = value
return out
def _truncate_ai_text(value: Any, limit: int = 800) -> str:
text = re.sub(r"\s+", " ", str(value or "")).strip()
if len(text) <= limit:
@@ -519,11 +603,15 @@ def _build_city_ai_fallback(
model_note_zh = _city_ai_model_cluster_note(ai_input, locale="zh-CN")
model_note_en = _city_ai_model_cluster_note(ai_input, locale="en-US")
content_preview = _truncate_ai_text(raw_content, 1000)
partial_ai = _extract_city_ai_partial_fields(raw_content)
looks_like_truncated_json = bool(content_preview.startswith("{") and not content_preview.rstrip().endswith("}"))
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 partial_ai.get("metar_read_zh") or partial_ai.get("metar_read_en"):
metar_zh = str(partial_ai.get("metar_read_zh") or partial_ai.get("metar_read_en") or "").strip()
metar_en = str(partial_ai.get("metar_read_en") or partial_ai.get("metar_read_zh") or "").strip()
elif content_preview and not looks_like_truncated_json:
metar_zh = f"机场报文快速解读已先完成;AI 补充摘要:{content_preview}"
metar_en = f"The fast airport-bulletin read is available; AI supplemental summary: {content_preview}"
elif content_preview:
@@ -536,22 +624,25 @@ def _build_city_ai_fallback(
metar_zh = "当前没有可用的原始 METAR 正文,暂以 DEB 与多模型路径为主。"
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 "--"
if timed_out:
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 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 = "DEB、多模型集合和最新 METAR 已足够给出当前方向判断;AI 增强可作为后续补充,不阻塞本轮读数。"
reasoning_en = "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 "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."
risks_zh = ["后续 METAR 若明显偏离模型路径,需及时修正最高温中枢。"]
risks_en = ["If later METAR reports diverge from the model path, revise the daily-high center promptly."]
return {
"predicted_max": predicted,
"range_low": range_low,
"range_high": range_high,
"unit": unit,
"confidence": "low",
"predicted_max": partial_ai.get("predicted_max", predicted),
"range_low": partial_ai.get("range_low", range_low),
"range_high": partial_ai.get("range_high", range_high),
"unit": partial_ai.get("unit") or unit,
"confidence": partial_ai.get("confidence") or ("medium" if partial_ai else "low"),
"final_judgment_zh": final_zh,
"final_judgment_en": final_en,
"metar_read_zh": metar_zh,
@@ -560,15 +651,16 @@ def _build_city_ai_fallback(
"reasoning_en": reasoning_en,
"risks_zh": risks_zh,
"risks_en": risks_en,
"model_cluster_note_zh": model_note_zh,
"model_cluster_note_en": model_note_en,
"model_cluster_note_zh": partial_ai.get("model_cluster_note_zh") or model_note_zh,
"model_cluster_note_en": partial_ai.get("model_cluster_note_en") or model_note_en,
"_polyweather_meta": {
**_provider_response_meta(provider_data),
"fallback": True,
"fallback_kind": "timeout" if timed_out else "non_json",
"fallback_kind": "partial_ai_json" if partial_ai else "timeout" if timed_out else "non_json",
"looks_like_truncated_json": looks_like_truncated_json,
"fallback_reason": reason_preview,
"raw_content_preview": content_preview,
"partial_ai_fields": sorted(partial_ai.keys()),
"raw_metar": _truncate_ai_text(raw_metar, 1000),
},
}