Files
PolyWeather/web/scan_city_ai_helpers.py
T
2569718930@qq.com b122e7cbae Stabilize the decision workspace data boundaries
The scan terminal had grown into overlapping CSS, request-state, AI-provider, and city-card data responsibilities. This refactor separates those boundaries without changing product behavior: CSS modules are split by surface, city AI prompt/provider/fallback logic is isolated, and scan terminal request state now has reusable RemoteData adapters plus business-state tests.

Constraint: Preserve existing global scan-terminal class names and API responses during the refactor

Constraint: No new dependencies; keep this as a file-boundary cleanup

Rejected: Introduce React Query now | higher migration risk than the requested lightweight query-client path

Rejected: Rewrite AI stream behavior | progressive/fallback states are product-sensitive and were only adapter-split

Confidence: high

Scope-risk: moderate

Reversibility: clean

Directive: Keep AI stream state changes covered by business snapshots before changing fallback/cache wording

Tested: npm run test:business; npx tsc --noEmit; npm run build; python pytest -q; ruff check; py_compile targeted city AI modules

Not-tested: Live DeepSeek provider network replay and browser visual QA
2026-04-28 14:45:34 +08:00

289 lines
9.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import json
import re
from typing import Any, Dict, List, Optional
CITY_AI_REQUIRED_FIELDS = [
"metar_read_zh",
"metar_read_en",
"final_judgment_zh",
"final_judgment_en",
"predicted_max",
"range_low",
"range_high",
"unit",
"confidence",
"reasoning_zh",
"reasoning_en",
"risks_zh",
"risks_en",
"model_cluster_note_zh",
"model_cluster_note_en",
]
CITY_AI_STREAM_PROVIDER_FIELDS = [
"metar_read_zh",
"metar_read_en",
"reasoning_zh",
"reasoning_en",
]
def _safe_float(value: Any) -> Optional[float]:
try:
if value is None or value == "":
return None
return float(value)
except Exception:
return None
def _extract_ai_json_object(raw_text: str) -> Dict[str, Any]:
text = str(raw_text or "").strip()
if not text:
raise ValueError("empty AI content")
try:
parsed = json.loads(text)
if isinstance(parsed, dict):
return parsed
except Exception:
pass
start = text.find("{")
end = text.rfind("}")
if start >= 0 and end > start:
parsed = json.loads(text[start : end + 1])
if isinstance(parsed, dict):
return parsed
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_fragment(raw_text: str, field: str) -> tuple[str, bool]:
"""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 "", False
idx = match.end()
chars: List[str] = []
escaped = False
closed = 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 == '"':
closed = True
break
chars.append(char)
if escaped:
chars.append("\\")
return _decode_json_string_fragment("".join(chars)).strip(), closed
_CITY_AI_TEXT_FIELDS = {
"metar_read_zh",
"metar_read_en",
"final_judgment_zh",
"final_judgment_en",
"reasoning_zh",
"reasoning_en",
"model_cluster_note_zh",
"model_cluster_note_en",
}
_INCOMPLETE_TAIL_RE = re.compile(
r"(?:(?:[,;]\s*)?(?:但|但是|不过|然而|而且|并且|因为|由于|若|如果|but|however|although|because|if|while|and)\s*)?"
r"(?:TAF|METAR|报文|机场预报|机场报文)?\s*(?:显示|提示|预示|表明|show(?:s)?|indicate(?:s)?|suggest(?:s)?)?\s*$",
re.IGNORECASE,
)
def _strip_incomplete_ai_sentence(value: Any) -> str:
"""Remove dangling provider fragments such as "但TAF显示".
The city AI endpoint can fall back to partially streamed JSON. If the stream
stops in the middle of a string, keeping the whole fragment produces broken
UI text. Prefer the last complete sentence/clause; if the whole field is too
incomplete, let schema completion use the deterministic fallback.
"""
text = re.sub(r"\s+", " ", str(value or "")).strip()
if not text:
return ""
if re.search(r"[。!?.!?]$", text):
return text
stripped = _INCOMPLETE_TAIL_RE.sub("", text).rstrip(" ,;:")
if stripped != text:
text = stripped.strip()
if not text:
return ""
if re.search(r"[。!?.!?]$", text):
return text
return text.rstrip(" ,;:") + ("." if re.search(r"[A-Za-z]$", text) else "。")
# If the provider stopped after a semicolon/comma-delimited clause, keep the
# complete preceding clause instead of showing a dangling tail.
for punct in ("", ";", "。", "", "", ".", "!", "?"):
idx = text.rfind(punct)
if idx >= 0:
candidate = text[: idx + 1].strip()
if len(candidate) >= 8:
return candidate.rstrip(";") + ("." if punct == ";" else "。" if punct == "" else "")
# If no dangling connector is visible, keep the provider text and add only
# terminal punctuation. This avoids replacing otherwise useful long reads
# just because the JSON string missed its closing quote.
return text.rstrip(" ,;:") + ("." if re.search(r"[A-Za-z]$", text) else "。")
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, closed = _extract_json_string_field_fragment(text, field)
if value and field in _CITY_AI_TEXT_FIELDS and not closed:
value = _strip_incomplete_ai_sentence(value)
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:
return text
return text[: max(0, limit - 1)].rstrip() + "…"
def _extract_provider_content(data: Any) -> str:
if not isinstance(data, dict):
return ""
choices = data.get("choices") or []
if not choices or not isinstance(choices[0], dict):
return ""
message = choices[0].get("message") or {}
if not isinstance(message, dict):
return ""
return str(message.get("content") or "")
def _extract_provider_stream_delta(data: Any) -> str:
if not isinstance(data, dict):
return ""
choices = data.get("choices") or []
if not choices or not isinstance(choices[0], dict):
text = data.get("text") or data.get("content")
return str(text or "")
delta = choices[0].get("delta") or {}
if isinstance(delta, dict):
content = delta.get("content")
if content:
return str(content)
message = choices[0].get("message") or {}
if isinstance(message, dict):
content = message.get("content")
if content:
return str(content)
text = choices[0].get("text") or data.get("text") or data.get("content")
return str(text or "")
def _provider_response_meta(data: Any) -> Dict[str, Any]:
if not isinstance(data, dict):
return {}
choices = data.get("choices") or []
first = choices[0] if choices and isinstance(choices[0], dict) else {}
return {
"usage": data.get("usage"),
"finish_reason": first.get("finish_reason"),
}
def _city_ai_response_example(unit: str) -> Dict[str, Any]:
return {
"metar_read_zh": f"最新观测显示 37.0{unit or '°C'},观测时间 04:30Z;风和云量暂未显示强降温信号,后续观测用于确认升温路径。",
"metar_read_en": f"The latest observation shows 37.0{unit or '°C'} at 04:30Z; wind and cloud signals do not yet show a strong cooling break, so later observations should confirm the warming path.",
"final_judgment_zh": f"预计最高温暂以 43.0{unit or '°C'} 附近为中枢。",
"final_judgment_en": f"The expected daily high is centered near 43.0{unit or '°C'}.",
"predicted_max": 43.0,
"range_low": 42.0,
"range_high": 44.0,
"unit": unit or "°C",
"confidence": "medium",
"reasoning_zh": "DEB 与多数模型集中在同一温区,最新观测仍处于上午升温路径。",
"reasoning_en": "DEB and most models cluster in the same temperature band, while the latest observation remains on a morning warming path.",
"risks_zh": ["若后续观测升温放缓或云雨增强,需要下调中枢。"],
"risks_en": ["If later observations show slower warming or stronger cloud/rain, revise the center lower."],
"model_cluster_note_zh": "7/8 个模型落在 DEB ±2°C 内,模型支撑较集中。",
"model_cluster_note_en": "7/8 models are within DEB ±2°C, so model support is clustered.",
}
def _city_ai_stream_response_example(unit: str) -> Dict[str, Any]:
return {
"metar_read_zh": f"最新 METAR 显示 37.0{unit or '°C'},报文时间 04:30Z;风和云量暂未显示强降温信号,后续报文用于确认升温路径。",
"metar_read_en": f"The latest METAR shows 37.0{unit or '°C'} at 04:30Z; wind and cloud signals do not yet show a strong cooling break, so later reports should confirm the warming path.",
"reasoning_zh": "当前观测仍贴近上午升温路径,若后续风向转为海风或云雨增强,再下修最高温中枢。",
"reasoning_en": "The latest observation still fits the morning warming path; revise the daily-high center lower if later wind turns onshore or cloud/rain strengthens.",
}