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:
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
- 城市决策卡新增 AI 机场报文解读缓存说明:页面内存缓存保留 loading / 流式片段 / 最终结果,`localStorage` 保存最终成功 payload,后端 AI 缓存不再因 `local_time` 变化失效
|
- 城市决策卡新增 AI 机场报文解读缓存说明:页面内存缓存保留 loading / 流式片段 / 最终结果,`localStorage` 保存最终成功 payload,后端 AI 缓存不再因 `local_time` 变化失效
|
||||||
- 城市决策卡兜底文案明确标记“快速证据模式”,避免在 DeepSeek 未完整返回时误写成“AI 机场报文解读正常”
|
- 城市决策卡兜底文案明确标记“快速证据模式”,避免在 DeepSeek 未完整返回时误写成“AI 机场报文解读正常”
|
||||||
|
- 城市决策卡流式 AI 解读改为只请求 METAR/官方观测核心解读与判断依据,最高温中枢、模型一致性和风险清单由后端规则补齐,减少等待时间
|
||||||
- 城市决策卡市场层改用完整 `all_buckets` 并严格识别 exact / range / or higher / or lower 温度桶方向,避免最高温中枢错配到不合理尾部桶
|
- 城市决策卡市场层改用完整 `all_buckets` 并严格识别 exact / range / or higher / or lower 温度桶方向,避免最高温中枢错配到不合理尾部桶
|
||||||
- 温度桶标签统一规范化 `C/F/°C/°F`,修复 `31°°C` 这类重复单位展示
|
- 温度桶标签统一规范化 `C/F/°C/°F`,修复 `31°°C` 这类重复单位展示
|
||||||
- 决策卡展示文案将“概率差”收口为“模型-市场差”,明确口径为 `模型概率 - 市场隐含概率`
|
- 决策卡展示文案将“概率差”收口为“模型-市场差”,明确口径为 `模型概率 - 市场隐含概率`
|
||||||
|
|||||||
@@ -135,6 +135,36 @@ def test_city_ai_fallback_reasoning_identifies_fast_evidence_mode():
|
|||||||
assert "AI 增强可作为后续补充" not in payload["reasoning_zh"]
|
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():
|
def test_city_ai_partial_json_trims_dangling_taf_clause():
|
||||||
payload = scan_terminal_service._build_city_ai_fallback(
|
payload = scan_terminal_service._build_city_ai_fallback(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ SCAN_CITY_AI_TIMEOUT_SEC = _env_int(
|
|||||||
max_value=120,
|
max_value=120,
|
||||||
)
|
)
|
||||||
SCAN_CITY_AI_RETRY_ON_STREAM_PARSE_ERROR = str(
|
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"}
|
).strip().lower() in {"1", "true", "yes", "on"}
|
||||||
SCAN_AI_CACHE_TTL_SEC = max(
|
SCAN_AI_CACHE_TTL_SEC = max(
|
||||||
30,
|
30,
|
||||||
@@ -94,11 +94,17 @@ SCAN_AI_MAX_TOKENS = _env_int(
|
|||||||
)
|
)
|
||||||
SCAN_CITY_AI_MAX_TOKENS = _env_int(
|
SCAN_CITY_AI_MAX_TOKENS = _env_int(
|
||||||
"POLYWEATHER_SCAN_CITY_AI_MAX_TOKENS",
|
"POLYWEATHER_SCAN_CITY_AI_MAX_TOKENS",
|
||||||
900,
|
650,
|
||||||
min_value=800,
|
min_value=300,
|
||||||
max_value=64000,
|
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 = [
|
CITY_AI_REQUIRED_FIELDS = [
|
||||||
"metar_read_zh",
|
"metar_read_zh",
|
||||||
@@ -118,6 +124,13 @@ CITY_AI_REQUIRED_FIELDS = [
|
|||||||
"model_cluster_note_en",
|
"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]:
|
def _safe_float(value: Any) -> Optional[float]:
|
||||||
try:
|
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(
|
def _complete_city_ai_payload(
|
||||||
ai_raw: Dict[str, Any],
|
ai_raw: Dict[str, Any],
|
||||||
ai_input: 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 "官方观测解读"))
|
read_label = str(observation_anchor.get("read_label_zh") or ("机场报文解读" if is_airport_metar else "官方观测解读"))
|
||||||
source_instruction = str(observation_anchor.get("instruction_zh") or "")
|
source_instruction = str(observation_anchor.get("instruction_zh") or "")
|
||||||
system_prompt = (
|
system_prompt = (
|
||||||
f"你是 PolyWeather 的城市最高温与{role_label}。"
|
f"你是 PolyWeather 的{role_label}。"
|
||||||
"只返回一个紧凑 JSON object,不要 Markdown。"
|
"只返回一个紧凑 JSON object,不要 Markdown。"
|
||||||
f"必须先写 metar_read_zh 和 metar_read_en 字段,便于前端流式显示{read_label};"
|
f"只解读最新观测/报文对今日最高温路径的影响,必须只写 metar_read_zh、metar_read_en、reasoning_zh、reasoning_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"{source_instruction}"
|
f"{source_instruction}"
|
||||||
"观测解读必须具体说明观测/报文时间、温度、风向风速、云量/天气/能见度/露点中与温度路径相关的因素;"
|
"观测解读必须具体说明观测/报文时间、温度、风向风速、云量/天气/能见度/露点中与温度路径相关的因素;每个字段最多 1 句。"
|
||||||
"涉及风时要说明当前风向对站点最高温路径倾向增温、降温还是中性,并给出理由。"
|
"涉及风时要说明当前风向对站点最高温路径倾向增温、降温还是中性,并给出理由。"
|
||||||
"如果 observation_anchor.is_airport_metar 为 false,不得使用 METAR、TAF、机场报文等称谓。"
|
"如果 observation_anchor.is_airport_metar 为 false,不得使用 METAR、TAF、机场报文等称谓。"
|
||||||
"所有 *_zh 字段写简体中文,所有 *_en 字段写英文,不得留空。"
|
"所有 *_zh 字段写简体中文,所有 *_en 字段写英文,不得留空。"
|
||||||
@@ -1922,7 +1943,7 @@ def _build_city_ai_stream_request(
|
|||||||
return {
|
return {
|
||||||
"model": SCAN_CITY_AI_MODEL,
|
"model": SCAN_CITY_AI_MODEL,
|
||||||
"temperature": 0.2,
|
"temperature": 0.2,
|
||||||
"max_tokens": SCAN_CITY_AI_MAX_TOKENS,
|
"max_tokens": SCAN_CITY_AI_STREAM_MAX_TOKENS,
|
||||||
"response_format": {"type": "json_object"},
|
"response_format": {"type": "json_object"},
|
||||||
"stream": True,
|
"stream": True,
|
||||||
"messages": [
|
"messages": [
|
||||||
@@ -1933,14 +1954,12 @@ def _build_city_ai_stream_request(
|
|||||||
{
|
{
|
||||||
"locale": normalized_locale,
|
"locale": normalized_locale,
|
||||||
"task": (
|
"task": (
|
||||||
"Return JSON keys in this exact order: metar_read_zh, metar_read_en, "
|
"Return JSON keys in this exact order: metar_read_zh, metar_read_en, reasoning_zh, reasoning_en. "
|
||||||
"final_judgment_zh, final_judgment_en, predicted_max, range_low, range_high, "
|
"Do not return final_judgment, predicted_max, ranges, risks or model_cluster_note. Keep it compact. "
|
||||||
"unit, confidence, reasoning_zh, reasoning_en, risks_zh, risks_en, "
|
|
||||||
"model_cluster_note_zh, model_cluster_note_en. Keep it compact. "
|
|
||||||
"Return exactly one JSON object and no markdown."
|
"Return exactly one JSON object and no markdown."
|
||||||
),
|
),
|
||||||
"required_json_keys": CITY_AI_REQUIRED_FIELDS,
|
"required_json_keys": CITY_AI_STREAM_PROVIDER_FIELDS,
|
||||||
"json_example": _city_ai_response_example(
|
"json_example": _city_ai_stream_response_example(
|
||||||
str(ai_input.get("temp_symbol") or "°C")
|
str(ai_input.get("temp_symbol") or "°C")
|
||||||
),
|
),
|
||||||
"city_snapshot": ai_input,
|
"city_snapshot": ai_input,
|
||||||
@@ -2159,10 +2178,11 @@ def stream_scan_city_ai_forecast_payload(
|
|||||||
last_meta: Dict[str, Any] = {}
|
last_meta: Dict[str, Any] = {}
|
||||||
try:
|
try:
|
||||||
logger.info(
|
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"),
|
ai_input.get("city"),
|
||||||
normalized_locale,
|
normalized_locale,
|
||||||
len(json.dumps(request_json, ensure_ascii=False, default=str).encode("utf-8")),
|
len(json.dumps(request_json, ensure_ascii=False, default=str).encode("utf-8")),
|
||||||
|
request_json.get("max_tokens"),
|
||||||
SCAN_CITY_AI_TIMEOUT_SEC,
|
SCAN_CITY_AI_TIMEOUT_SEC,
|
||||||
)
|
)
|
||||||
with httpx.Client(timeout=timeout) as client:
|
with httpx.Client(timeout=timeout) as client:
|
||||||
|
|||||||
Reference in New Issue
Block a user