Speed up streamed airport bulletin reads

The city card only needs the provider to interpret the latest METAR or official observation. Deterministic fields such as the high-temperature center, model cluster note and fallback risks are already available server-side, so the stream request now asks DeepSeek for only the observation read and concise reasoning.

Constraint: City cards still need a complete payload for both Chinese and English UI modes

Rejected: Keep generating the full decision schema in the stream | too much model output for every card

Rejected: Retry failed streams by default | it can double latency and the fallback can use partial streamed text

Confidence: high

Scope-risk: moderate

Tested: pytest tests/test_web_observability.py::test_city_ai_stream_request_only_asks_provider_for_observation_read tests/test_web_observability.py::test_city_ai_fallback_reasoning_identifies_fast_evidence_mode tests/test_web_observability.py::test_city_ai_partial_json_trims_dangling_taf_clause tests/test_web_observability.py::test_city_ai_schema_completion_trims_dangling_taf_clause -q

Tested: npm run build
This commit is contained in:
2569718930@qq.com
2026-04-27 09:51:09 +08:00
parent ee2a5338bb
commit c3f092fc28
3 changed files with 68 additions and 17 deletions
+1
View File
@@ -4,6 +4,7 @@
- 城市决策卡新增 AI 机场报文解读缓存说明:页面内存缓存保留 loading / 流式片段 / 最终结果,`localStorage` 保存最终成功 payload,后端 AI 缓存不再因 `local_time` 变化失效
- 城市决策卡兜底文案明确标记“快速证据模式”,避免在 DeepSeek 未完整返回时误写成“AI 机场报文解读正常”
- 城市决策卡流式 AI 解读改为只请求 METAR/官方观测核心解读与判断依据,最高温中枢、模型一致性和风险清单由后端规则补齐,减少等待时间
- 城市决策卡市场层改用完整 `all_buckets` 并严格识别 exact / range / or higher / or lower 温度桶方向,避免最高温中枢错配到不合理尾部桶
- 温度桶标签统一规范化 `C/F/°C/°F`,修复 `31°°C` 这类重复单位展示
- 决策卡展示文案将“概率差”收口为“模型-市场差”,明确口径为 `模型概率 - 市场隐含概率`
+30
View File
@@ -135,6 +135,36 @@ def test_city_ai_fallback_reasoning_identifies_fast_evidence_mode():
assert "AI 增强可作为后续补充" not in payload["reasoning_zh"]
def test_city_ai_stream_request_only_asks_provider_for_observation_read():
request_payload = scan_terminal_service._build_city_ai_stream_request(
{
"city": "Tokyo",
"city_display_name": "Tokyo",
"temp_symbol": "°C",
"deb": {"prediction": 17.8},
"model_cluster": {"sources": [{"value": 17.0}, {"value": 17.8}]},
"observation_anchor": {
"is_airport_metar": True,
"read_label_zh": "机场报文解读",
},
"airport_current": {
"station_code": "RJTT",
"temp": 16.0,
"report_time": "21:30Z",
"raw_metar": "RJTT 262130Z AUTO 00000KT 9999 FEW030 16/10 Q1015",
},
},
locale="zh-CN",
)
user_payload = request_payload["messages"][1]["content"]
assert request_payload["stream"] is True
assert request_payload["max_tokens"] <= 650
assert request_payload["max_tokens"] < scan_terminal_service.SCAN_AI_MAX_TOKENS
assert '"required_json_keys": ["metar_read_zh", "metar_read_en", "reasoning_zh", "reasoning_en"]' in user_payload
assert "Do not return final_judgment" in user_payload
def test_city_ai_partial_json_trims_dangling_taf_clause():
payload = scan_terminal_service._build_city_ai_fallback(
{
+37 -17
View File
@@ -79,7 +79,7 @@ SCAN_CITY_AI_TIMEOUT_SEC = _env_int(
max_value=120,
)
SCAN_CITY_AI_RETRY_ON_STREAM_PARSE_ERROR = str(
os.getenv("POLYWEATHER_SCAN_CITY_AI_RETRY_ON_STREAM_PARSE_ERROR") or "true"
os.getenv("POLYWEATHER_SCAN_CITY_AI_RETRY_ON_STREAM_PARSE_ERROR") or "false"
).strip().lower() in {"1", "true", "yes", "on"}
SCAN_AI_CACHE_TTL_SEC = max(
30,
@@ -94,11 +94,17 @@ SCAN_AI_MAX_TOKENS = _env_int(
)
SCAN_CITY_AI_MAX_TOKENS = _env_int(
"POLYWEATHER_SCAN_CITY_AI_MAX_TOKENS",
900,
min_value=800,
650,
min_value=300,
max_value=64000,
)
SCAN_CITY_AI_PROMPT_VERSION = "city-observation-read-v5"
SCAN_CITY_AI_STREAM_MAX_TOKENS = _env_int(
"POLYWEATHER_SCAN_CITY_AI_STREAM_MAX_TOKENS",
min(SCAN_CITY_AI_MAX_TOKENS, 650),
min_value=300,
max_value=64000,
)
SCAN_CITY_AI_PROMPT_VERSION = "city-observation-read-v6"
CITY_AI_REQUIRED_FIELDS = [
"metar_read_zh",
@@ -118,6 +124,13 @@ CITY_AI_REQUIRED_FIELDS = [
"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:
@@ -778,6 +791,15 @@ def _city_ai_response_example(unit: str) -> Dict[str, Any]:
}
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.",
}
def _complete_city_ai_payload(
ai_raw: Dict[str, Any],
ai_input: Dict[str, Any],
@@ -1907,13 +1929,12 @@ def _build_city_ai_stream_request(
read_label = str(observation_anchor.get("read_label_zh") or ("机场报文解读" if is_airport_metar else "官方观测解读"))
source_instruction = str(observation_anchor.get("instruction_zh") or "")
system_prompt = (
f"你是 PolyWeather 的城市最高温与{role_label}"
f"你是 PolyWeather 的{role_label}"
"只返回一个紧凑 JSON object,不要 Markdown。"
f"必须写 metar_read_zhmetar_read_en 字段,便于前端流式显示{read_label}"
"然后写 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。"
f"只解读最新观测/报文对今日最高温路径的影响,必须写 metar_read_zhmetar_read_en、reasoning_zh、reasoning_en 四个字段,便于前端快速显示{read_label}"
"最高温数值、模型一致性和风险清单由后端规则补齐,不要生成这些字段。"
f"{source_instruction}"
"观测解读必须具体说明观测/报文时间、温度、风向风速、云量/天气/能见度/露点中与温度路径相关的因素;"
"观测解读必须具体说明观测/报文时间、温度、风向风速、云量/天气/能见度/露点中与温度路径相关的因素;每个字段最多 1 句。"
"涉及风时要说明当前风向对站点最高温路径倾向增温、降温还是中性,并给出理由。"
"如果 observation_anchor.is_airport_metar 为 false,不得使用 METAR、TAF、机场报文等称谓。"
"所有 *_zh 字段写简体中文,所有 *_en 字段写英文,不得留空。"
@@ -1922,7 +1943,7 @@ def _build_city_ai_stream_request(
return {
"model": SCAN_CITY_AI_MODEL,
"temperature": 0.2,
"max_tokens": SCAN_CITY_AI_MAX_TOKENS,
"max_tokens": SCAN_CITY_AI_STREAM_MAX_TOKENS,
"response_format": {"type": "json_object"},
"stream": True,
"messages": [
@@ -1933,14 +1954,12 @@ def _build_city_ai_stream_request(
{
"locale": normalized_locale,
"task": (
"Return JSON keys in this exact order: 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. Keep it compact. "
"Return JSON keys in this exact order: metar_read_zh, metar_read_en, reasoning_zh, reasoning_en. "
"Do not return final_judgment, predicted_max, ranges, risks or model_cluster_note. Keep it compact. "
"Return exactly one JSON object and no markdown."
),
"required_json_keys": CITY_AI_REQUIRED_FIELDS,
"json_example": _city_ai_response_example(
"required_json_keys": CITY_AI_STREAM_PROVIDER_FIELDS,
"json_example": _city_ai_stream_response_example(
str(ai_input.get("temp_symbol") or "°C")
),
"city_snapshot": ai_input,
@@ -2159,10 +2178,11 @@ def stream_scan_city_ai_forecast_payload(
last_meta: Dict[str, Any] = {}
try:
logger.info(
"scan city AI stream request city={} locale={} input_bytes={} timeout_sec={}",
"scan city AI stream request city={} locale={} input_bytes={} max_tokens={} timeout_sec={}",
ai_input.get("city"),
normalized_locale,
len(json.dumps(request_json, ensure_ascii=False, default=str).encode("utf-8")),
request_json.get("max_tokens"),
SCAN_CITY_AI_TIMEOUT_SEC,
)
with httpx.Client(timeout=timeout) as client: