From 5b32e71f5d21eed6f7dfb869cf00f19ba1b22f0e Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Wed, 29 Apr 2026 18:52:55 +0800 Subject: [PATCH] This version of Antigravity is no longer supported. Please upgrade to receive the latest features. --- .env.example | 17 +++- docs/CONFIGURATION_ZH.md | 8 ++ docs/deep-research-report.md | 8 +- tests/test_scan_terminal_modules.py | 8 +- web/scan_city_ai_prompt.py | 2 +- web/scan_city_ai_provider.py | 2 +- web/scan_terminal_ai_merge.py | 3 +- web/scan_terminal_service.py | 145 +++++++++++++++++++--------- 8 files changed, 133 insertions(+), 60 deletions(-) diff --git a/.env.example b/.env.example index f1ca17c8..e5cbfb5e 100644 --- a/.env.example +++ b/.env.example @@ -136,12 +136,19 @@ POLYWEATHER_GROQ_COMMENTARY_MODEL=openai/gpt-oss-20b POLYWEATHER_GROQ_COMMENTARY_TIMEOUT_SEC=8 POLYWEATHER_GROQ_COMMENTARY_CACHE_TTL_SEC=1800 -# Optional DeepSeek V4-Pro market scan review for Pro users +# Optional OpenAI-compatible market scan review for Pro users +# Temporary default provider: MiMo via https://token-plan-cn.xiaomimimo.com/v1. POLYWEATHER_SCAN_AI_ENABLED=false -POLYWEATHER_DEEPSEEK_API_KEY= -POLYWEATHER_DEEPSEEK_BASE_URL=https://api.deepseek.com -POLYWEATHER_SCAN_AI_MODEL=deepseek-v4-flash -POLYWEATHER_SCAN_CITY_AI_MODEL=deepseek-v4-flash +POLYWEATHER_SCAN_AI_API_KEY= +POLYWEATHER_SCAN_AI_PROVIDER=mimo +POLYWEATHER_SCAN_AI_PROVIDER_LABEL=MiMo +POLYWEATHER_SCAN_AI_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1 +POLYWEATHER_SCAN_AI_MODEL=MiMo-V2.5-Pro +POLYWEATHER_SCAN_CITY_AI_MODEL=MiMo-V2.5-Pro +# Backward-compatible legacy DeepSeek variables are still read if the generic +# POLYWEATHER_SCAN_AI_* variables are unset. +# POLYWEATHER_DEEPSEEK_API_KEY= +# POLYWEATHER_DEEPSEEK_BASE_URL=https://api.deepseek.com POLYWEATHER_SCAN_AI_TIMEOUT_SEC=18 POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC=30 POLYWEATHER_SCAN_CITY_AI_RETRY_ON_STREAM_PARSE_ERROR=false diff --git a/docs/CONFIGURATION_ZH.md b/docs/CONFIGURATION_ZH.md index 6ee3c888..bea1a409 100644 --- a/docs/CONFIGURATION_ZH.md +++ b/docs/CONFIGURATION_ZH.md @@ -270,6 +270,13 @@ POLYWEATHER_GROQ_COMMENTARY_ENABLED=false POLYWEATHER_GROQ_COMMENTARY_MODEL=openai/gpt-oss-20b POLYWEATHER_GROQ_COMMENTARY_TIMEOUT_SEC=8 POLYWEATHER_GROQ_COMMENTARY_CACHE_TTL_SEC=1800 +POLYWEATHER_SCAN_AI_ENABLED=false +POLYWEATHER_SCAN_AI_API_KEY=... +POLYWEATHER_SCAN_AI_PROVIDER=mimo +POLYWEATHER_SCAN_AI_PROVIDER_LABEL=MiMo +POLYWEATHER_SCAN_AI_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1 +POLYWEATHER_SCAN_AI_MODEL=MiMo-V2.5-Pro +POLYWEATHER_SCAN_CITY_AI_MODEL=MiMo-V2.5-Pro ``` 说明: @@ -281,6 +288,7 @@ POLYWEATHER_GROQ_COMMENTARY_CACHE_TTL_SEC=1800 - `POLYWEATHER_STATE_STORAGE_MODE` 当前线上推荐直接使用 `sqlite`。 - `POLYWEATHER_PAYMENT_RPC_URLS` 支持逗号分隔多个 RPC;如果暂时只用单 RPC,也可以继续只配 `POLYWEATHER_PAYMENT_RPC_URL`。 - 机器人市场监控当前以 `关注清单` 为主,按固定间隔主动推送。 +- `POLYWEATHER_SCAN_AI_*` 走 OpenAI-compatible `/chat/completions`;当前临时默认 MiMo,可用 `POLYWEATHER_SCAN_AI_BASE_URL` 与 `POLYWEATHER_SCAN_AI_MODEL` 随时切回其他兼容 provider。 - `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC` 表示主动推送间隔,默认 `1800` 秒(30 分钟)。 - `POLYMARKET_WALLET_ACTIVITY_ENABLED` 已退役,保留为 `false` 即可,不建议再启用钱包异动监听。 - `POLYWEATHER_DASHBOARD_PREWARM_ENABLED=true` 时,建议同时启用独立 worker 或 bot 内嵌预热线程。 diff --git a/docs/deep-research-report.md b/docs/deep-research-report.md index 20fc671f..55dc74ad 100644 --- a/docs/deep-research-report.md +++ b/docs/deep-research-report.md @@ -54,7 +54,7 @@ flowchart TB subgraph API FAST[FastAPI
web/app.py] - LLM[City AI stream
DeepSeek-compatible provider] + LLM[City AI stream
OpenAI-compatible provider] end subgraph Data @@ -124,7 +124,7 @@ Scan Terminal 的城市决策卡现在承担“从天气分析到市场动作解 1. **地图点击与 pinned city**:免费/付费入口都会先把城市加入决策卡;未付费用户若权限不足,仍应保留卡片承载升级/限制提示,而不是点击后无反馈。 2. **full detail hydration**:卡片请求城市 full detail,拿到 DEB、当前/历史实测、多模型区间与最新 METAR。现阶段 detail hydration 仍偏保守串行,优先保障后端数据源稳定;真正消耗 LLM 的 AI 解读另行限流。 -3. **AI 机场报文解读**:前端最多同时保留 2 条城市 AI stream,第三个及以后城市会进入队列并展示排队提示,避免多个 DeepSeek stream 同时竞争导致第三城/第四城解析失败。后端城市 AI 配置建议为 `POLYWEATHER_SCAN_CITY_AI_MODEL=deepseek-v4-flash`、`POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC=30`、`POLYWEATHER_SCAN_CITY_AI_MAX_TOKENS=900`、`POLYWEATHER_SCAN_CITY_AI_RETRY_ON_STREAM_PARSE_ERROR=true`。 +3. **AI 机场报文解读**:前端最多同时保留 2 条城市 AI stream,第三个及以后城市会进入队列并展示排队提示,避免多个 provider stream 同时竞争导致第三城/第四城解析失败。当前临时使用 MiMo:`POLYWEATHER_SCAN_AI_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1`、`POLYWEATHER_SCAN_CITY_AI_MODEL=MiMo-V2.5-Pro`;其他后端城市 AI 配置建议为 `POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC=30`、`POLYWEATHER_SCAN_CITY_AI_MAX_TOKENS=900`、`POLYWEATHER_SCAN_CITY_AI_RETRY_ON_STREAM_PARSE_ERROR=true`。 4. **缓存策略**:页面内存缓存保留 loading/stream/final 状态,切换选项卡返回时不应空白重拉;localStorage 持久化最终成功、非 degraded 的 payload;后端 city AI cache key 已移除当前 `local_time` 干扰,主要按城市、日期与 METAR signature 失效。 5. **市场桶匹配**:城市市场扫描必须使用 full `all_buckets`,按温度 exact/range/“or higher”/“or lower” 方向严格匹配;不再用宽松 ±8°C fallback,以避免拿到 16°C 之类错误桶。前端展示统一使用“模型-市场差”,即 `model_probability - market_implied_probability`,并修复温度单位重复渲染(如 `31°°C`)。 @@ -166,7 +166,7 @@ Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引 **运行态状态/缓存与核心离线链路的 SQLite 收口已完成**:`daily_records`、`telegram_alert_state`、`probability_training_snapshots`、`open_meteo` 缓存已经支持并在生产中主读 SQLite,迁移/校验脚本可用;进一步地,在临时移除 `data/*.json` / `data/*.jsonl` 后,训练集导出、概率拟合、评估报告、shadow report 和关键 backfill 脚本已验证仍可运行。当前 legacy 文件路径主要是显式回退入口,而不再是默认主输入。 **历史真值治理已从设计缺陷修复到可追溯运行**:`daily_records` 继续作为近 14 天运行态缓存,但已经不再承担长期监督真值职责;项目新增了永久真值表、真值 revision 审计表和长期训练特征表,并为 `Taipei` / `Shenzhen` 补上了历史页面回填链路。当前风险已不再是“监督真值会不会继续被 14 天裁剪吞掉”,而是“历史长期特征能否持续积累到足够支撑 EMOS/LGBM 重新评估”。 **第三方服务合规与稳定性风险**: -项目强依赖外部 API(Open-Meteo、AviationWeather、NWS、HKO、CWA、Polymarket、Supabase)以及城市 AI provider(DeepSeek-compatible stream)。其中 AviationWeather Data API 有明确速率限制;Polymarket 官方说明 Gamma/Data/CLOB 三套 API 分属不同域,CLOB 交易端点需鉴权且策略可能变化;Supabase 明确强调 `service_role`/secret keys 绝不可暴露。若缺乏集中治理(重试/退避/熔断/降级/配额监控/密钥轮换),稳定性与合规不可控。城市 AI 解读已经通过前端 2 并发队列、30s timeout、stream parse retry 与缓存 key 稳定化降低第三/第四城市失败概率,但仍需持续记录 stream duration、cache hit、retry、degraded 与 queue depth。 +项目强依赖外部 API(Open-Meteo、AviationWeather、NWS、HKO、CWA、Polymarket、Supabase)以及城市 AI provider(OpenAI-compatible stream,当前临时 MiMo)。其中 AviationWeather Data API 有明确速率限制;Polymarket 官方说明 Gamma/Data/CLOB 三套 API 分属不同域,CLOB 交易端点需鉴权且策略可能变化;Supabase 明确强调 `service_role`/secret keys 绝不可暴露。若缺乏集中治理(重试/退避/熔断/降级/配额监控/密钥轮换),稳定性与合规不可控。城市 AI 解读已经通过前端 2 并发队列、30s timeout、stream parse retry 与缓存 key 稳定化降低第三/第四城市失败概率,但仍需持续记录 stream duration、cache hit、retry、degraded 与 queue depth。 **可观测性最小闭环已完成,但监控深度仍待加强**:项目现在已有 `/healthz`、`/api/system/status`、`/metrics`,并已补齐 Prometheus 抓取、Alertmanager 规则、Grafana 面板、Telegram relay 与巡检脚本。与此同时,`/ops` 已经逐步演进为后台管理台而不只是状态页:除支付、会员、用户与 EMOS 门禁外,还新增了训练数据治理卡片、城市覆盖矩阵,以及 `/ops/truth-history` 这种可直接查询 `actual_high / settlement_source / station_code / truth_version / updated_by / updated_at` 的真值表浏览页。当前缺口不再是“有没有外部监控”,而是节点级资源、数据库体积趋势、更细粒度支付指标、按城市/来源拆分的业务 SLA,以及是否需要进一步补 `truth revision` 明细页、趋势图和运营日报。 **EMOS 已完成工程接入,但未完成生产发布**:EMOS/CRPS 校准、shadow 观测、rollout report、上线门禁都已实现;当前真实门禁结果为 `hold`,阻塞原因是 shadow bucket brier 明显退化。因此概率引擎标准化并非未做,而是“工程完成、发布未通过”。 **许可证/商业使用的潜在冲突点**:仓库自身现为 `AGPL-3.0-only`,但如果未来尝试引入外部神经天气模型,仍需单独核验第三方代码与权重的商用条件:GraphCast 仓库代码 Apache-2.0,但权重使用 CC BY-NC-SA 4.0(非商业),Pangu-Weather 权重同样 BY-NC-SA 且明确禁止商业用途;不加区分地把这些模型用于付费产品会留下法律风险。 @@ -263,7 +263,7 @@ PolyWeather 的评测应围绕“结算场景”而非传统数值天气预报 ### 主要风险与缓解策略 -**外部 API / AI provider 速率限制与格式变更**:AviationWeather 明确 rate limit 与建议使用 cache 文件;Open-Meteo 也可能在不同端点策略上变化;DeepSeek-compatible city AI stream 可能出现 timeout、stream JSON 截断或并发竞争。缓解:统一“请求预算”与退避/熔断;关键响应做 schema 校验与回放测试;对高频数据优先拉取官方 cache/批量接口(若可用);城市 AI 保持小并发队列、30s timeout、stream parse retry、页面内存缓存与 degraded fallback。 +**外部 API / AI provider 速率限制与格式变更**:AviationWeather 明确 rate limit 与建议使用 cache 文件;Open-Meteo 也可能在不同端点策略上变化;OpenAI-compatible city AI stream 可能出现 timeout、stream JSON 截断或并发竞争。缓解:统一“请求预算”与退避/熔断;关键响应做 schema 校验与回放测试;对高频数据优先拉取官方 cache/批量接口(若可用);城市 AI 保持小并发队列、30s timeout、stream parse retry、页面内存缓存与 degraded fallback。 **密钥泄露与权限滥用**:Supabase 明确强调 `service_role` 属高权限密钥,绝不可出现在前端或公开环境。缓解:密钥分级、CI secret scan、运行时最小权限、日志脱敏。 **支付链路最终一致性与链上不确定性**:链上事件索引延迟、RPC 不稳定、交易确认数不足都会导致误判。当前项目已经补齐“事件监听 + 确认补单”双路径、事件重放脚本、SQLite 审计事件与多 RPC fallback;现阶段的主要剩余风险不再是“没有防护”,而是链上合约仍为最小实现,owner 为单地址管理,且没有 pause 开关与 SafeERC20。 **引入外部神经天气模型的商业合规风险**:GraphCast/Pangu-Weather 的权重许可均带非商业限制(CC BY-NC-SA/BY-NC-SA);若 PolyWeather 是付费产品,必须先做法务与授权评审。缓解:只在研究环境评估;商用优先选择可商用权重/购买授权/自研。 diff --git a/tests/test_scan_terminal_modules.py b/tests/test_scan_terminal_modules.py index 7988970d..4a17719a 100644 --- a/tests/test_scan_terminal_modules.py +++ b/tests/test_scan_terminal_modules.py @@ -181,11 +181,12 @@ def test_merge_scan_ai_result_applies_city_forecast_and_metadata(): merged = merge_scan_ai_result( payload, ai_raw, - model="deepseek-v4-flash", + model="MiMo-V2.5-Pro", max_rows=40, timeout_sec=40, cache_ttl_sec=1800, - base_url="https://api.deepseek.com", + base_url="https://token-plan-cn.xiaomimimo.com/v1", + provider="mimo", duration_ms=123, input_rows=1, ) @@ -197,3 +198,6 @@ def test_merge_scan_ai_result_applies_city_forecast_and_metadata(): assert merged["ai_scan"]["sent_cities"] == 1 assert merged["ai_scan"]["sent_rows"] == 1 assert merged["ai_scan"]["duration_ms"] == 123 + assert merged["ai_scan"]["model"] == "MiMo-V2.5-Pro" + assert merged["ai_scan"]["provider"] == "mimo" + assert merged["ai_scan"]["base_url"] == "https://token-plan-cn.xiaomimimo.com/v1" diff --git a/web/scan_city_ai_prompt.py b/web/scan_city_ai_prompt.py index c94b4775..9a0eb48e 100644 --- a/web/scan_city_ai_prompt.py +++ b/web/scan_city_ai_prompt.py @@ -41,7 +41,7 @@ def build_city_ai_request_json( observation_label_zh = context["read_label_zh"] observation_instruction = context["instruction_zh"] system_prompt = ( - "你是 PolyWeather 的 DeepSeek 城市最高温预测员。你必须直接阅读用户给出的城市 JSON," + "你是 PolyWeather 的城市最高温 AI 预测员。你必须直接阅读用户给出的城市 JSON," "判断该城市今日最高温路径。不要写套利、交易、BUY YES/NO、价格、edge 或 Kelly。" f"你的核心输出是:最终最高温点估计、置信区间、置信度、最终判断、{observation_label_zh}、判断依据和风险。" "必须综合 DEB 最终融合值、全部天气模型预测、实测序列、最新观测、峰值窗口、当地时间、季节背景。" diff --git a/web/scan_city_ai_provider.py b/web/scan_city_ai_provider.py index df864e8b..5bb34e0e 100644 --- a/web/scan_city_ai_provider.py +++ b/web/scan_city_ai_provider.py @@ -35,7 +35,7 @@ def _call_deepseek_city_ai( timeout_sec: int, ) -> Dict[str, Any]: if not api_key: - raise RuntimeError("POLYWEATHER_DEEPSEEK_API_KEY is not configured") + raise RuntimeError("scan AI API key is not configured") normalized_locale = _normalize_locale(locale) timeout = httpx.Timeout( timeout=float(timeout_sec), diff --git a/web/scan_terminal_ai_merge.py b/web/scan_terminal_ai_merge.py index 4221da45..50df832a 100644 --- a/web/scan_terminal_ai_merge.py +++ b/web/scan_terminal_ai_merge.py @@ -84,6 +84,7 @@ def merge_scan_ai_result( cache_ttl_sec: int, base_url: str, cached: bool = False, + provider: str = "openai-compatible", duration_ms: Optional[int] = None, input_rows: Optional[int] = None, ) -> Dict[str, Any]: @@ -229,7 +230,7 @@ def merge_scan_ai_result( "duration_ms": duration_ms, "timeout_sec": timeout_sec, "cache_ttl_sec": cache_ttl_sec, - "provider": "deepseek", + "provider": provider, "base_url": base_url, "summary_zh": ai_raw.get("summary_zh"), "summary_en": ai_raw.get("summary_en"), diff --git a/web/scan_terminal_service.py b/web/scan_terminal_service.py index 89941320..39e5ce1f 100644 --- a/web/scan_terminal_service.py +++ b/web/scan_terminal_service.py @@ -95,17 +95,68 @@ SCAN_TERMINAL_BUILD_TIMEOUT_SEC = max( 8, int(os.getenv("POLYWEATHER_SCAN_TERMINAL_BUILD_TIMEOUT_SEC", "22")), ) -SCAN_AI_MODEL = str( - os.getenv("POLYWEATHER_SCAN_AI_MODEL") or "deepseek-v4-flash" -).strip() -SCAN_CITY_AI_MODEL = str( - os.getenv("POLYWEATHER_SCAN_CITY_AI_MODEL") - or os.getenv("POLYWEATHER_SCAN_AI_MODEL") - or "deepseek-v4-flash" -).strip() -SCAN_AI_BASE_URL = str( - os.getenv("POLYWEATHER_DEEPSEEK_BASE_URL") or "https://api.deepseek.com" -).strip().rstrip("/") +DEFAULT_SCAN_AI_MODEL = "MiMo-V2.5-Pro" +DEFAULT_SCAN_AI_BASE_URL = "https://token-plan-cn.xiaomimimo.com/v1" +SCAN_AI_API_KEY_ENV_HINT = ( + "POLYWEATHER_SCAN_AI_API_KEY " + "(or POLYWEATHER_MIMO_API_KEY / POLYWEATHER_DEEPSEEK_API_KEY)" +) + + +def _env_str(*names: str, default: str = "") -> str: + for name in names: + value = str(os.getenv(name) or "").strip() + if value: + return value + return str(default).strip() + + +def _scan_ai_api_key() -> str: + return _env_str( + "POLYWEATHER_SCAN_AI_API_KEY", + "POLYWEATHER_MIMO_API_KEY", + "POLYWEATHER_DEEPSEEK_API_KEY", + ) + + +def _infer_scan_ai_provider(base_url: str, model: str) -> str: + text = f"{base_url} {model}".lower() + if "xiaomimimo" in text or "mimo" in text: + return "mimo" + if "deepseek" in text: + return "deepseek" + return "openai-compatible" + + +def _scan_ai_provider_label(provider: str) -> str: + normalized = provider.strip().lower() + if normalized == "mimo": + return "MiMo" + if normalized == "deepseek": + return "DeepSeek" + return "AI provider" + + +SCAN_AI_MODEL = _env_str("POLYWEATHER_SCAN_AI_MODEL", default=DEFAULT_SCAN_AI_MODEL) +SCAN_CITY_AI_MODEL = _env_str( + "POLYWEATHER_SCAN_CITY_AI_MODEL", + "POLYWEATHER_SCAN_AI_MODEL", + default=SCAN_AI_MODEL or DEFAULT_SCAN_AI_MODEL, +) +SCAN_AI_BASE_URL = _env_str( + "POLYWEATHER_SCAN_AI_BASE_URL", + "POLYWEATHER_MIMO_BASE_URL", + "POLYWEATHER_DEEPSEEK_BASE_URL", + default=DEFAULT_SCAN_AI_BASE_URL, +).rstrip("/") +SCAN_AI_PROVIDER = _env_str( + "POLYWEATHER_SCAN_AI_PROVIDER", + default=_infer_scan_ai_provider(SCAN_AI_BASE_URL, SCAN_CITY_AI_MODEL), +) +SCAN_AI_PROVIDER_LABEL = _env_str( + "POLYWEATHER_SCAN_AI_PROVIDER_LABEL", + default=_scan_ai_provider_label(SCAN_AI_PROVIDER), +) SCAN_AI_ENABLED = str( os.getenv("POLYWEATHER_SCAN_AI_ENABLED") or "false" ).strip().lower() in {"1", "true", "yes", "on"} @@ -182,9 +233,9 @@ def _start_scan_terminal_background_refresh(filters: Dict[str, Any]) -> bool: def _call_deepseek_scan_ai(ai_input: Dict[str, Any]) -> Dict[str, Any]: - api_key = str(os.getenv("POLYWEATHER_DEEPSEEK_API_KEY") or "").strip() + api_key = _scan_ai_api_key() if not api_key: - raise RuntimeError("POLYWEATHER_DEEPSEEK_API_KEY is not configured") + raise RuntimeError(f"{SCAN_AI_API_KEY_ENV_HINT} is not configured") system_prompt = ( "你是 PolyWeather 的付费 V4-Pro 城市最高温预测员。你只能基于用户提供的 JSON 快照做判断," @@ -357,7 +408,7 @@ def _call_deepseek_city_ai(ai_input: Dict[str, Any], *, locale: str = "zh-CN") - return _call_deepseek_city_ai_provider( ai_input, locale=locale, - api_key=str(os.getenv("POLYWEATHER_DEEPSEEK_API_KEY") or "").strip(), + api_key=_scan_ai_api_key(), base_url=SCAN_AI_BASE_URL, model=SCAN_CITY_AI_MODEL, max_tokens=SCAN_CITY_AI_MAX_TOKENS, @@ -470,7 +521,7 @@ def _build_city_ai_result_payload( "status": "ready", "cached": cached, "model": SCAN_CITY_AI_MODEL, - "provider": "deepseek", + "provider": SCAN_AI_PROVIDER, "city": data.get("name"), "city_display_name": data.get("display_name"), "generated_at": generated_at, @@ -508,7 +559,7 @@ def stream_scan_city_ai_forecast_payload( { "status": "failed", "model": SCAN_CITY_AI_MODEL, - "provider": "deepseek", + "provider": SCAN_AI_PROVIDER, "city": city_name, "city_display_name": str(city or "").strip() or city_name, "reason": reason_en if normalized_locale == "en-US" else reason_zh, @@ -544,7 +595,7 @@ def stream_scan_city_ai_forecast_payload( "status": "ready", "cached": True, "model": SCAN_CITY_AI_MODEL, - "provider": "deepseek", + "provider": SCAN_AI_PROVIDER, "city": cached.get("city") or city_name, "city_display_name": cached.get("city_display_name") or city_name, "generated_at": cached.get("generated_at"), @@ -561,14 +612,14 @@ def stream_scan_city_ai_forecast_payload( observation_anchor = ai_input.get("observation_anchor") if isinstance(ai_input.get("observation_anchor"), dict) else {} is_airport_metar = observation_anchor.get("is_airport_metar") is not False calling_message_zh = ( - "DeepSeek 正在快速增强机场报文解读…" + f"{SCAN_AI_PROVIDER_LABEL} 正在快速增强机场报文解读…" if is_airport_metar - else "DeepSeek 正在快速增强香港天文台观测解读…" + else f"{SCAN_AI_PROVIDER_LABEL} 正在快速增强香港天文台观测解读…" ) calling_message_en = ( - "DeepSeek is adding a fast airport-bulletin enhancement…" + f"{SCAN_AI_PROVIDER_LABEL} is adding a fast airport-bulletin enhancement…" if is_airport_metar - else "DeepSeek is adding a fast HKO observation enhancement…" + else f"{SCAN_AI_PROVIDER_LABEL} is adding a fast HKO observation enhancement…" ) yield _sse_event( "preview", @@ -600,23 +651,23 @@ def stream_scan_city_ai_forecast_payload( { "status": "disabled", "model": SCAN_CITY_AI_MODEL, - "provider": "deepseek", + "provider": SCAN_AI_PROVIDER, "city": data.get("name") or city_name, "city_display_name": data.get("display_name") or city_name, "reason": "POLYWEATHER_SCAN_AI_ENABLED is not enabled", }, ) return - if not str(os.getenv("POLYWEATHER_DEEPSEEK_API_KEY") or "").strip(): + if not _scan_ai_api_key(): yield _sse_event( "final", { "status": "missing_key", "model": SCAN_CITY_AI_MODEL, - "provider": "deepseek", + "provider": SCAN_AI_PROVIDER, "city": data.get("name") or city_name, "city_display_name": data.get("display_name") or city_name, - "reason": "POLYWEATHER_DEEPSEEK_API_KEY is not configured", + "reason": f"{SCAN_AI_API_KEY_ENV_HINT} is not configured", }, ) return @@ -630,7 +681,7 @@ def stream_scan_city_ai_forecast_payload( pool=5.0, ) headers = { - "Authorization": f"Bearer {os.getenv('POLYWEATHER_DEEPSEEK_API_KEY')}", + "Authorization": f"Bearer {_scan_ai_api_key()}", "Content-Type": "application/json", } accumulated = "" @@ -758,8 +809,8 @@ def stream_scan_city_ai_forecast_payload( ) except httpx.TimeoutException as exc: duration_ms = int((time.time() - started_at) * 1000) - reason_en = f"DeepSeek city AI timed out after {SCAN_CITY_AI_TIMEOUT_SEC}s" - reason_zh = f"DeepSeek 城市 AI 在 {SCAN_CITY_AI_TIMEOUT_SEC} 秒内未返回" + reason_en = f"{SCAN_AI_PROVIDER_LABEL} city AI timed out after {SCAN_CITY_AI_TIMEOUT_SEC}s" + reason_zh = f"{SCAN_AI_PROVIDER_LABEL} 城市 AI 在 {SCAN_CITY_AI_TIMEOUT_SEC} 秒内未返回" logger.warning( "scan city AI stream timeout fallback city={} duration_ms={} model={} error={}", data.get("name") or city_name, @@ -800,7 +851,7 @@ def stream_scan_city_ai_forecast_payload( { "status": "failed", "model": SCAN_CITY_AI_MODEL, - "provider": "deepseek", + "provider": SCAN_AI_PROVIDER, "city": data.get("name") or city_name, "city_display_name": data.get("display_name") or city_name, "duration_ms": int((time.time() - started_at) * 1000), @@ -827,7 +878,7 @@ def build_scan_city_ai_forecast_payload( return { "status": "failed", "model": SCAN_CITY_AI_MODEL, - "provider": "deepseek", + "provider": SCAN_AI_PROVIDER, "city": city_name, "city_display_name": str(city or "").strip() or city_name, "reason": ( @@ -866,7 +917,7 @@ def build_scan_city_ai_forecast_payload( "status": "ready", "cached": True, "model": SCAN_CITY_AI_MODEL, - "provider": "deepseek", + "provider": SCAN_AI_PROVIDER, "city": cached.get("city") or city_name, "city_display_name": cached.get("city_display_name") or city_name, "generated_at": cached.get("generated_at"), @@ -883,24 +934,24 @@ def build_scan_city_ai_forecast_payload( return { "status": "disabled", "model": SCAN_CITY_AI_MODEL, - "provider": "deepseek", + "provider": SCAN_AI_PROVIDER, "city": data.get("name") or city_name, "city_display_name": data.get("display_name") or city_name, "reason": "POLYWEATHER_SCAN_AI_ENABLED is not enabled", } - if not str(os.getenv("POLYWEATHER_DEEPSEEK_API_KEY") or "").strip(): + if not _scan_ai_api_key(): logger.warning( - "scan city AI forecast missing DeepSeek key city={} model={}", + "scan city AI forecast missing provider key city={} model={}", data.get("name") or city_name, SCAN_CITY_AI_MODEL, ) return { "status": "missing_key", "model": SCAN_CITY_AI_MODEL, - "provider": "deepseek", + "provider": SCAN_AI_PROVIDER, "city": data.get("name") or city_name, "city_display_name": data.get("display_name") or city_name, - "reason": "POLYWEATHER_DEEPSEEK_API_KEY is not configured", + "reason": f"{SCAN_AI_API_KEY_ENV_HINT} is not configured", } try: @@ -918,8 +969,8 @@ def build_scan_city_ai_forecast_payload( ai_raw = _call_deepseek_city_ai(ai_input, locale=normalized_locale) except httpx.TimeoutException as exc: duration_ms = int((time.time() - started_at) * 1000) - reason_en = f"DeepSeek city AI timed out after {SCAN_CITY_AI_TIMEOUT_SEC}s" - reason_zh = f"DeepSeek 城市 AI 在 {SCAN_CITY_AI_TIMEOUT_SEC} 秒内未返回" + reason_en = f"{SCAN_AI_PROVIDER_LABEL} city AI timed out after {SCAN_CITY_AI_TIMEOUT_SEC}s" + reason_zh = f"{SCAN_AI_PROVIDER_LABEL} 城市 AI 在 {SCAN_CITY_AI_TIMEOUT_SEC} 秒内未返回" ai_raw = _build_city_ai_fallback( ai_input, locale=normalized_locale, @@ -938,7 +989,7 @@ def build_scan_city_ai_forecast_payload( "degraded": True, "cached": False, "model": SCAN_CITY_AI_MODEL, - "provider": "deepseek", + "provider": SCAN_AI_PROVIDER, "city": data.get("name") or city_name, "city_display_name": data.get("display_name") or city_name, "generated_at": generated_at, @@ -953,12 +1004,12 @@ def build_scan_city_ai_forecast_payload( raw_reason = str(exc) empty_ai_content = raw_reason.strip().lower() == "empty ai content" reason_en = ( - "DeepSeek city AI returned no usable text. Retry the city analysis." + f"{SCAN_AI_PROVIDER_LABEL} city AI returned no usable text. Retry the city analysis." if empty_ai_content else raw_reason ) reason_zh = ( - "DeepSeek 城市 AI 没有返回有效正文,请刷新重试。" + f"{SCAN_AI_PROVIDER_LABEL} 城市 AI 没有返回有效正文,请刷新重试。" if empty_ai_content else raw_reason ) @@ -980,7 +1031,7 @@ def build_scan_city_ai_forecast_payload( "degraded": True, "cached": False, "model": SCAN_CITY_AI_MODEL, - "provider": "deepseek", + "provider": SCAN_AI_PROVIDER, "city": data.get("name") or city_name, "city_display_name": data.get("display_name") or city_name, "generated_at": generated_at, @@ -1009,7 +1060,7 @@ def build_scan_city_ai_forecast_payload( "status": "ready", "cached": False, "model": SCAN_CITY_AI_MODEL, - "provider": "deepseek", + "provider": SCAN_AI_PROVIDER, "city": data.get("name") or city_name, "city_display_name": data.get("display_name") or city_name, "generated_at": generated_at, @@ -1039,7 +1090,7 @@ def _build_scan_ai_unavailable_payload( "duration_ms": duration_ms, "timeout_sec": SCAN_AI_TIMEOUT_SEC, "cache_ttl_sec": SCAN_AI_CACHE_TTL_SEC, - "provider": "deepseek", + "provider": SCAN_AI_PROVIDER, "base_url": SCAN_AI_BASE_URL, "reason": reason, }, @@ -1250,11 +1301,11 @@ def build_scan_terminal_ai_payload( status="disabled", reason="POLYWEATHER_SCAN_AI_ENABLED is not enabled", ) - if not str(os.getenv("POLYWEATHER_DEEPSEEK_API_KEY") or "").strip(): + if not _scan_ai_api_key(): return _build_scan_ai_unavailable_payload( payload, status="missing_key", - reason="POLYWEATHER_DEEPSEEK_API_KEY is not configured", + reason=f"{SCAN_AI_API_KEY_ENV_HINT} is not configured", ) cached = get_cached_scan_ai_result( @@ -1279,6 +1330,7 @@ def build_scan_terminal_ai_payload( cache_ttl_sec=SCAN_AI_CACHE_TTL_SEC, base_url=SCAN_AI_BASE_URL, cached=True, + provider=SCAN_AI_PROVIDER, duration_ms=0, input_rows=len(payload.get("rows") or []), ) @@ -1323,6 +1375,7 @@ def build_scan_terminal_ai_payload( cache_ttl_sec=SCAN_AI_CACHE_TTL_SEC, base_url=SCAN_AI_BASE_URL, cached=False, + provider=SCAN_AI_PROVIDER, duration_ms=duration_ms, input_rows=len(payload.get("rows") or []), )