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 30838686..74748333 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 @@ -14,7 +14,7 @@ import type { CityDetail, MarketScan } from "@/lib/dashboard-types"; import { extractStreamingAirportRead } from "./ai-city-stream"; import { normalizeCityKey } from "./decision-utils"; -const AI_CITY_FORECAST_CACHE_PREFIX = "polyWeather_aiCityForecast_v4"; +const AI_CITY_FORECAST_CACHE_PREFIX = "polyWeather_aiCityForecast_v5"; const AI_CITY_FORECAST_CACHE_TTL_MS = 60 * 60 * 1000; const AI_CITY_FORECAST_MAX_CONCURRENT_STREAMS = 2; const CITY_MARKET_SCAN_CACHE_PREFIX = "polyWeather_cityMarketScan_v3"; @@ -384,8 +384,8 @@ function buildAiCityFallbackPayload({ const metarEn = rawMetar ? `Latest METAR shows ${currentText}; use it as the live anchor while later reports confirm the path.` : `Use ${currentText} and the model path for now while waiting for the next ${bulletinEn}.`; - const reasonZh = `DEB、多模型集合和最新${sourceZh}已足够给出当前方向判断;DeepSeek 增强可作为后续补充。`; - const reasonEn = `DEB, the model cluster and latest ${sourceEn} are enough for the current directional read; DeepSeek enhancement can be added later.`; + const reasonZh = `DEB、多模型集合和最新${sourceZh}已足够给出当前方向判断;页面会在 DeepSeek 返回后合并完整机场报文解读。`; + const reasonEn = `DEB, the model cluster and latest ${sourceEn} are enough for the current directional read; the page will merge the full airport-bulletin read when DeepSeek returns.`; return { city_forecast: { diff --git a/tests/test_web_observability.py b/tests/test_web_observability.py index 6e951b4a..90ead482 100644 --- a/tests/test_web_observability.py +++ b/tests/test_web_observability.py @@ -133,6 +133,90 @@ def test_city_ai_fallback_reasoning_says_ai_read_is_normal(): assert "AI 增强可作为后续补充" not in payload["reasoning_zh"] +def test_city_ai_partial_json_trims_dangling_taf_clause(): + payload = scan_terminal_service._build_city_ai_fallback( + { + "city_display_name": "London", + "temp_symbol": "°C", + "deb": {"prediction": 24.3}, + "model_cluster": { + "sources": [ + {"value": 22.3}, + {"value": 23.1}, + {"value": 24.3}, + {"value": 26.3}, + ] + }, + "observation_anchor": { + "is_airport_metar": True, + "station_code": "EGLL", + }, + "airport_current": { + "station_code": "EGLL", + "temp": 21.0, + "report_time": "09:00Z", + "raw_metar": "EGLL 270900Z 34004KT CAVOK 21/09 Q1016", + }, + }, + locale="zh-CN", + reason="AI content is not a JSON object", + raw_content=( + '{"metar_read_zh":"最新METAR报文09:00观测温度21°C,西北风4节(340°),' + 'CAVOK(能见度良好,无重要云)。当前西北风弱,趋向增温但影响有限;' + 'TAF预示10-11点转南风(18012KT),南风可能带来凉爽海风抑制升温。",' + '"reasoning_zh":"DEB预测24.3°C,多数模型集中在23-26°C,' + '当前09时实测21°C处于快速升温路径,但TAF显示' + ), + ) + + assert "但TAF显示" not in payload["reasoning_zh"] + assert payload["reasoning_zh"].endswith("。") + assert "当前09时实测21°C处于快速升温路径" in payload["reasoning_zh"] + + +def test_city_ai_schema_completion_trims_dangling_taf_clause(): + payload = scan_terminal_service._complete_city_ai_payload( + { + "predicted_max": 24.3, + "range_low": 22.3, + "range_high": 26.3, + "unit": "°C", + "confidence": "medium", + "final_judgment_zh": "London 最高温中枢暂看24°C附近。", + "final_judgment_en": "London high is centered near 24°C.", + "metar_read_zh": "最新METAR报文09:00观测温度21°C,西北风4节,CAVOK。", + "metar_read_en": "The latest METAR shows 21°C at 09:00 with northwesterly wind and CAVOK.", + "reasoning_zh": "当前09时实测21°C处于快速升温路径,但TAF显示", + "reasoning_en": "The 09:00 observation is on a fast warming path, but TAF shows", + "risks_zh": ["后续METAR若升温放缓,需要下修。"], + "risks_en": ["If later METAR warming slows, revise lower."], + "model_cluster_note_zh": "4/4 个模型落在 DEB ±2°C 内。", + "model_cluster_note_en": "4/4 models sit within 2°C of DEB.", + }, + { + "city_display_name": "London", + "temp_symbol": "°C", + "deb": {"prediction": 24.3}, + "model_cluster": {"sources": [{"value": 22.3}, {"value": 26.3}]}, + "observation_anchor": {"is_airport_metar": True, "station_code": "EGLL"}, + "airport_current": { + "station_code": "EGLL", + "temp": 21.0, + "report_time": "09:00Z", + "raw_metar": "EGLL 270900Z 34004KT CAVOK 21/09 Q1016", + }, + }, + locale="zh-CN", + ) + + assert payload["reasoning_zh"] == "当前09时实测21°C处于快速升温路径。" + assert payload["reasoning_en"] == "The 09:00 observation is on a fast warming path." + assert payload["_polyweather_meta"]["trimmed_incomplete_fields"] == [ + "reasoning_en", + "reasoning_zh", + ] + + def test_cities_endpoint_uses_denver_display_name_for_aurora_market(): response = client.get("/api/cities") assert response.status_code == 200 diff --git a/web/scan_terminal_service.py b/web/scan_terminal_service.py index d5c1057c..3ee35318 100644 --- a/web/scan_terminal_service.py +++ b/web/scan_terminal_service.py @@ -98,7 +98,7 @@ SCAN_CITY_AI_MAX_TOKENS = _env_int( min_value=800, max_value=64000, ) -SCAN_CITY_AI_PROMPT_VERSION = "city-observation-read-v4" +SCAN_CITY_AI_PROMPT_VERSION = "city-observation-read-v5" CITY_AI_REQUIRED_FIELDS = [ "metar_read_zh", @@ -408,7 +408,7 @@ def _decode_json_string_fragment(fragment: str) -> str: ) -def _extract_json_string_field_from_fragment(raw_text: str, field: str) -> str: +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. @@ -419,10 +419,11 @@ def _extract_json_string_field_from_fragment(raw_text: str, field: str) -> str: text = str(raw_text or "") match = re.search(rf'"{re.escape(field)}"\s*:\s*"', text) if not match: - return "" + return "", False idx = match.end() chars: List[str] = [] escaped = False + closed = False while idx < len(text): char = text[idx] idx += 1 @@ -434,11 +435,75 @@ def _extract_json_string_field_from_fragment(raw_text: str, field: str) -> str: escaped = True continue if char == '"': + closed = True break chars.append(char) if escaped: chars.append("\\") - return _decode_json_string_fragment("".join(chars)).strip() + return _decode_json_string_fragment("".join(chars)).strip(), closed + + +def _extract_json_string_field_from_fragment(raw_text: str, field: str) -> str: + value, _closed = _extract_json_string_field_fragment(raw_text, field) + return value + + +_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]: @@ -466,7 +531,9 @@ def _extract_city_ai_partial_fields(raw_text: str) -> Dict[str, Any]: "confidence", "unit", ): - value = _extract_json_string_field_from_fragment(text, field) + 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"): @@ -731,16 +798,36 @@ def _complete_city_ai_payload( ) completed: List[str] = [] out = dict(ai_raw) + trimmed: List[str] = [] for field in CITY_AI_REQUIRED_FIELDS: value = out.get(field) + if isinstance(value, str) and field in _CITY_AI_TEXT_FIELDS: + clean_value = _strip_incomplete_ai_sentence(value) + if clean_value != value: + trimmed.append(field) + value = clean_value + out[field] = clean_value if value is None or value == "" or value == []: out[field] = fallback.get(field) completed.append(field) + for field in ("risks_zh", "risks_en"): + value = out.get(field) + if isinstance(value, list): + cleaned_risks = [] + for item in value: + clean_item = _strip_incomplete_ai_sentence(item) + if clean_item: + cleaned_risks.append(clean_item) + if cleaned_risks != value: + trimmed.append(field) + out[field] = cleaned_risks or fallback.get(field) meta = out.get("_polyweather_meta") if not isinstance(meta, dict): meta = {} if completed: meta["schema_completed_fields"] = completed + if trimmed: + meta["trimmed_incomplete_fields"] = sorted(set(trimmed)) out["_polyweather_meta"] = meta return out