From c3f092fc28f4f2116e33177780174f2126920a58 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Mon, 27 Apr 2026 09:51:09 +0800 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + tests/test_web_observability.py | 30 ++++++++++++++++++ web/scan_terminal_service.py | 54 ++++++++++++++++++++++----------- 3 files changed, 68 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d736a267..a832aca8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` 这类重复单位展示 - 决策卡展示文案将“概率差”收口为“模型-市场差”,明确口径为 `模型概率 - 市场隐含概率` diff --git a/tests/test_web_observability.py b/tests/test_web_observability.py index 8bf71ab9..2e2474bf 100644 --- a/tests/test_web_observability.py +++ b/tests/test_web_observability.py @@ -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( { diff --git a/web/scan_terminal_service.py b/web/scan_terminal_service.py index 8b58533f..cc356c5b 100644 --- a/web/scan_terminal_service.py +++ b/web/scan_terminal_service.py @@ -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_zh 和 metar_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_zh、metar_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: