Prevent dangling TAF fragments in city AI reads

City AI can return a partially streamed JSON string when the provider truncates output. The fallback previously kept an unfinished clause such as '但TAF显示', which made the forecast explanation look broken even though earlier evidence was usable.

Constraint: Provider JSON can be truncated after useful fields have already streamed

Rejected: Drop all partial AI text | would lose valid METAR interpretation already returned before truncation

Confidence: high

Scope-risk: narrow

Tested: pytest city AI truncation regression tests

Tested: npm run build

Not-tested: Live DeepSeek provider response
This commit is contained in:
2569718930@qq.com
2026-04-27 08:28:00 +08:00
parent e4cf570820
commit 4eb50ce880
3 changed files with 179 additions and 8 deletions
@@ -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: {
+84
View File
@@ -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
+92 -5
View File
@@ -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