feat: implement scan terminal dashboard system and supporting services

This commit is contained in:
2569718930@qq.com
2026-05-28 08:24:50 +08:00
parent 725727d763
commit 5d784f78b9
27 changed files with 263 additions and 961 deletions
-36
View File
@@ -31,8 +31,6 @@ flowchart LR
| `/api/city/{name}/summary` | GET | 轻量摘要 |
| `/api/city/{name}/detail` | GET | 聚合详情(含 market_scan |
| `/api/history/{name}` | GET | 历史对账 |
| `/api/scan/terminal/ai-city` | POST | 城市决策卡 AI 解读(非流式 JSON) |
| `/api/scan/terminal/ai-city/stream` | POST | 城市决策卡 AI 解读(SSE 流式) |
### `GET /api/city/{name}/detail`
@@ -61,40 +59,6 @@ flowchart LR
- `vertical_profile_signal.heating_setup / suppression_risk / trigger_risk / mixing_strength`
- `taf.signal.peak_window / suppression_level / disruption_level / markers`
### `POST /api/scan/terminal/ai-city/stream`
城市决策卡使用该接口生成“AI 机场报文解读”。前端默认请求 SSE 流,流式展示机场报文解读片段,最终以 `final` 事件返回完整 payload。
请求体:
```json
{
"city": "Buenos Aires",
"force_refresh": false,
"locale": "zh-CN"
}
```
SSE 事件:
- `progress`:阶段性状态,例如开始调用 AI、切换非流式重试。
- `preview`:可显示的预览文本。
- `delta`:模型流式文本增量,前端会从中提取机场报文解读片段。
- `final`:完整 `AiCityForecastPayload`
重点字段:
- `status`:通常为 `ready`;超时或降级时会带 `reason / reason_zh / reason_en`
- `cached`:是否命中后端 AI 缓存。
- `degraded`:是否为降级结果。前端不会把 degraded 结果写入长期 localStorage,但会保留页面内存态,避免切换选项卡后空白。
- `city_forecast.predicted_max`AI 给出的预计最高温中枢候选。
- `city_forecast.range_low / range_high`AI 给出的天气区间。
- `city_forecast.final_judgment_zh / final_judgment_en`:最终判断。
- `city_forecast.metar_read_zh / metar_read_en`:机场报文解读。
- `city_forecast.reasoning_zh / reasoning_en`:把 METAR、DEB、多模型集群与日内风险合并后的推理。
- `city_forecast.model_cluster_note_zh / model_cluster_note_en`:模型集群说明。
- `city_forecast.risks_zh / risks_en`:后续上修或下修触发条件。
### 城市决策卡市场层口径
- 前端会请求完整 `market_scan` / `all_buckets`,而不是只取 lite 结果。
-8
View File
@@ -236,13 +236,6 @@ TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED=true
TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800
TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5
POLYWEATHER_BACKEND_URL=http://polyweather_web:8000
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
```
说明:
@@ -254,7 +247,6 @@ POLYWEATHER_SCAN_CITY_AI_MODEL=mimo-v2.5-pro
- `POLYWEATHER_STATE_STORAGE_MODE` 当前线上推荐直接使用 `sqlite`
- `POLYWEATHER_PAYMENT_RPC_URLS` 支持逗号分隔多个 RPC;如果暂时只用单 RPC,也可以继续只配 `POLYWEATHER_PAYMENT_RPC_URL`
- 机器人市场监控包含 `关键提醒``关注清单`:关键提醒逐城判断并受冷却控制,关注清单每轮先扫描完整城市列表,再按全局 Top N 推送;同一轮已经触发关键提醒的城市不会重复出现在关注清单里。
- `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 分钟)。
说明:
+13 -13
View File
@@ -13,7 +13,7 @@ PolyWeather 的目标与范围在 README/README_ZH 中定义得较清楚:为
**分析层(DEB/趋势/概率/结算口径)**
DEBDynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加权,输出融合预报;运行态仍维护近 14 天 `daily_records` 缓存做当前对账,但长期监督真值与训练特征已经迁到 SQLite 永久表中,并支持基于 WUWeather Underground 口径)四舍五入的结算命中评估。
趋势/概率引擎在 `trend_engine.py` 中实现:综合“集合预报区间→σ/μ→高温窗口→死盘判定→温度桶概率分布→边界提示”等,用于 bot 展示与 web 结构化数据输出。
**城市决策层(Scan Terminal / AI 机场报文解读**:地图点击城市后加入城市决策卡,前端拉取 full detail、多模型区间、最新 METAR,并通过 `/api/scan/terminal/ai-city/stream` 生成城市级 AI 解读。该解读由 `final_judgment``metar_read``reasoning``model_cluster_note``risks` 与原始 METAR 证据组成;最高温中枢优先使用 AI `predicted_max`,再回退到 DEB、多模型中心、日内 pace 或当前实测。
**城市决策层(Scan Terminal / 结构化实况层**:地图点击城市后加入城市决策卡,前端拉取 full detail、多模型区间、最新 METAR,并通过 `/api/city/{name}/detail` 生成城市级 结构化解读。该解读由 `structured_signal``structured_signal``reasoning``structured_signal``risks` 与原始 METAR 证据组成;最高温中枢优先使用 DEB,再回退到 DEB、多模型中心、日内 pace 或当前实测。
**市场层(Polymarket 行情对照)***[v1.7.0 已移除]* 原先从 Gamma API 发现市场、从 CLOB 读取价格/盘口并计算”模型-市场差”,已于 2026-05-23 随 Polymarket 价格拉取层一并删除。当前 `market_scan` 返回空。
**商业化与支付**:订阅(`Pro Monthly 10 USDC`)、积分抵扣、Polygon 链上收款合约(USDC/USDC.e),并提供“事件监听 + 周期确认”的自动补单机制。
**支持的数据集/数据源**:项目不是传统“训练数据集+模型训练”的机器学习仓库;其“数据集”本质是外部实时/预报 API 与站点观测数据。对外部数据的使用需要遵守来源方的访问与速率限制,例如 AviationWeather Data API 明确限制请求频率(含每分钟请求上限/建议降低频率与使用缓存文件)。
@@ -28,8 +28,8 @@ DEBDynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加
从 README、Docker/Compose、入口脚本与核心模块引用关系,可以抽象出如下模块地图(按“运行时组件”与“Python 域模块”两层描述):
| 层级 | 目录/文件 | 角色定位 | 关键说明 |
| ------------- | ------------------------------------------------------------------------ | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 运行时组件 | `frontend/` | Next.js 前端(Vercel | 前端重构报告提到 App Router、Route HandlersBFF)、缓存策略、支付与账户中心等;Scan Terminal 已新增城市决策卡、AI 机场报文解读、页面内存/localStorage 双层缓存、AI stream 小并发队列与完整市场桶映射。 |
| 运行时组件 | `web/app.py` + `web/core.py` + `web/routes.py` + `web/analysis_service.py` + `web/scan_terminal_service.py` | FastAPI 后端 API | 已从单文件入口拆为启动入口、核心上下文、路由层、分析服务层;Scan Terminal 侧提供 `/api/scan/terminal/ai-city/stream`,城市 AI 默认 30s 超时并支持 stream parse failure 的非流式重试。 |
| 运行时组件 | `frontend/` | Next.js 前端(Vercel | 前端重构报告提到 App Router、Route HandlersBFF)、缓存策略、支付与账户中心等;Scan Terminal 已新增城市决策卡、结构化实况层、页面内存/localStorage 双层缓存、structured detail 小并发队列与完整市场桶映射。 |
| 运行时组件 | `web/app.py` + `web/core.py` + `web/routes.py` + `web/analysis_service.py` + `web/scan_terminal_service.py` | FastAPI 后端 API | 已从单文件入口拆为启动入口、核心上下文、路由层、分析服务层;Scan Terminal 侧提供 `/api/city/{name}/detail`,城市结构化分析 默认 30s 超时并支持 stream parse failure 的非流式重试。 |
| 运行时组件 | `bot_listener.py` + `src/bot/*` | Telegram Bot | 入口 `bot_listener.py``start_bot()`,并由 `StartupCoordinator` 启动多个后台 loop。 |
| Python 域模块 | `src/data_collection/*` | 天气采集 + 城市注册 | 采集层已拆为 `weather_sources.py` 编排层 + `open_meteo_cache.py``settlement_sources.py``metar_sources.py``mgm_sources.py``amos_station_sources.py``jma_amedas_sources.py``nws_open_meteo_sources.py``country_networks.py` 等。v1.7.0 已移除 NMC、pogodaiklimat、Meteoblue 数据源。 |
| Python 域模块 | `src/analysis/*` | DEB/趋势/概率/结算口径 | `deb_algorithm.py``trend_engine.py``settlement_rounding.py`。 |
@@ -51,7 +51,7 @@ flowchart TB
subgraph API
FAST[FastAPI<br/>web/app.py]
LLM[City AI stream<br/>OpenAI-compatible provider]
LLM[City structured detail<br/>OpenAI-compatible provider]
end
subgraph Data
@@ -118,8 +118,8 @@ JSON[Legacy JSON files<br/>migration/export/explicit fallback only]
Scan Terminal 的城市决策卡现在承担“从天气分析到市场动作解释”的前端决策层:
1. **地图点击与 pinned city**:免费/付费入口都会先把城市加入决策卡;未付费用户若权限不足,仍应保留卡片承载升级/限制提示,而不是点击后无反馈。
2. **full detail hydration**:卡片请求城市 full detail,拿到 DEB、当前/历史实测、多模型区间与最新 METAR。现阶段 detail hydration 仍偏保守串行,优先保障后端数据源稳定;真正消耗 LLM 的 AI 解读另行限流。
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`
2. **full detail hydration**:卡片请求城市 full detail,拿到 DEB、当前/历史实测、多模型区间与最新 METAR。现阶段 detail hydration 仍偏保守串行,优先保障后端数据源稳定;真正消耗 LLM 的 结构化解读另行限流。
3. **结构化实况层**:前端最多同时保留 2 条城市结构化分析 stream,第三个及以后城市会进入队列并展示排队提示,避免多个 provider stream 同时竞争导致第三城/第四城解析失败。当前临时使用 MiMo:`POLYWEATHER_API_BASE_URL=<backend>``POLYWEATHER_SCAN_TERMINAL_PAYLOAD_TTL_SEC=300`;其他后端城市结构化分析 配置建议为 `POLYWEATHER_SCAN_TERMINAL_BUILD_TIMEOUT_SEC=120``POLYWEATHER_SCAN_TERMINAL_MAX_WORKERS=8``POLYWEATHER_SCAN_TERMINAL_PAYLOAD_TTL_SEC=300`
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`)。
@@ -136,7 +136,7 @@ Scan Terminal 的城市决策卡现在承担“从天气分析到市场动作解
**DEBDynamic Error Balancing**:以最近 N 天各模型的 MAE 计算倒数权重并做加权融合;同时将 `forecasts / actual_high / deb_prediction / mu / prob_snapshot` 写入 `data/daily_records.json`,并提供命中率/MAE/Brier 等统计口径。
**概率引擎**`trend_engine.py` 以集合预报的 p10/p90 推 σ(并考虑历史 MAE floor、风向/云量/压强的 shock_score、以及峰值窗口 time-decay),再用正态近似把连续分布映射为 WU 整数“温度桶概率”。
**推理流水线(在线)**
Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引擎输出结构化结果(μ、概率桶、趋势、死盘/窗口判定、DEB 预测、市场扫描)→ 前端渲染或 bot 消息格式化。对城市决策卡而言,在线推理还会叠加“latest METAR + 多模型区间 + AI city stream + full all_buckets 市场匹配”,最终输出最高温中枢、AI 机场报文解读和模型-市场差。
Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引擎输出结构化结果(μ、概率桶、趋势、死盘/窗口判定、DEB 预测、市场扫描)→ 前端渲染或 bot 消息格式化。对城市决策卡而言,在线推理还会叠加“latest METAR + 多模型区间 + structured city detail + full all_buckets 市场匹配”,最终输出最高温中枢、结构化实况层和模型-市场差。
**检查点(checkpoints**:传统 ML checkpoint 不适用;但项目现已形成两类“业务状态 checkpoint”:
(a)SQLite 运行态存储(当前线上与核心离线链路主路径);(b)SQLite 永久真值/训练特征表(当前监督真值与训练样本长期主存);(c)legacy JSON/JSONL 文件(主要保留给迁移回滚、导出比对与显式回退输入)。当前设计仍支持 `POLYWEATHER_STATE_STORAGE_MODE=file|dual|sqlite`,但对线上部署与离线训练/回填而言,推荐目标状态都已经是 `sqlite`
### 测试、CI/CD 与运维验证
@@ -159,7 +159,7 @@ Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引
**CI 已建立,但组织级质量门禁未必完全收口**:CI 现已覆盖 Python、前端与 Docker build。当前问题不再是“缺 CI”,而是是否把这些 status check 绑定到 `main` 保护策略,以及是否逐步引入更严格的 pre-merge 审查。
**运行态状态/缓存与核心离线链路的 SQLite 收口已完成**`daily_records``telegram_alert_state``probability_training_snapshots``open_meteo` 缓存已经支持并在生产中主读 SQLite,迁移/校验脚本可用;进一步地,在临时移除 `data/*.json` / `data/*.jsonl` 后,训练集导出、概率拟合、评估报告、shadow report 和关键 backfill 脚本已验证仍可运行。当前 legacy 文件路径主要是显式回退入口,而不再是默认主输入。
**第三方服务合规与稳定性风险**
项目强依赖外部 APIOpen-Meteo、AviationWeather、global.amo.go.kr AMOS、NWS、HKO、CWA、Supabase)以及城市 AI providerOpenAI-compatible stream,当前使用 MiMo)。其中 AviationWeather Data API 有明确速率限制;Supabase 明确强调 `service_role`/secret keys 绝不可暴露。若缺乏集中治理(重试/退避/熔断/降级/配额监控/密钥轮换),稳定性与合规不可控。城市 AI 解读已经通过前端 2 并发队列、30s timeout、stream parse retry 与缓存 key 稳定化降低第三/第四城市失败概率,但仍需持续记录 stream duration、cache hit、retry、degraded 与 queue depth。
项目强依赖外部 APIOpen-Meteo、AviationWeather、global.amo.go.kr AMOS、NWS、HKO、CWA、Supabase)以及城市结构化分析 providerOpenAI-compatible stream,当前使用 MiMo)。其中 AviationWeather Data API 有明确速率限制;Supabase 明确强调 `service_role`/secret keys 绝不可暴露。若缺乏集中治理(重试/退避/熔断/降级/配额监控/密钥轮换),稳定性与合规不可控。城市结构化分析 解读已经通过前端 2 并发队列、30s timeout、stream parse retry 与缓存 key 稳定化降低第三/第四城市失败概率,但仍需持续记录 stream duration、cache hit、retry、degraded 与 queue depth。
> **v1.7.0 更新**PolymarketGamma/CLOBAPI 依赖已随市场价格拉取层一并移除。
**许可证/商业使用的潜在冲突点**:仓库自身现为 `AGPL-3.0-only`,但如果未来尝试引入外部神经天气模型,仍需单独核验第三方代码与权重的商用条件:GraphCast 仓库代码 Apache-2.0,但权重使用 CC BY-NC-SA 4.0(非商业),Pangu-Weather 权重同样 BY-NC-SA 且明确禁止商业用途;不加区分地把这些模型用于付费产品会留下法律风险。
@@ -185,7 +185,7 @@ Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引
| 优先级 | 改进项 | 预估工作量 | 主要收益 | 主要风险 | 可执行步骤(建议顺序) |
| ------ | --------------------------------------------------------------------------------------------------------------------------------- | -------------------: | ------------------------------------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 中 | **把最小外部监控继续补深**:从“可告警”提升到“可运营” | 3–7 天 | 不再只知道服务坏没坏,还能看资源趋势、来源 SLA 和支付波动 | 指标过多会带来维护噪音 | 1) 增加节点 CPU/内存/磁盘 → 2) 增加 SQLite/支付体积与事件趋势 → 3) 把 HTTP/来源指标细分到城市/来源维度 → 4) 增加日报或异常摘要 |
| 中 | **城市决策卡 AI 解读可观测性与回放测试** | 3–5 天 | 降低第三/第四/第五城市 AI 解读失败,验证缓存与队列是否真正生效 | 外部 AI stream 仍可能超时或输出截断,若无指标很难复盘 | 1) 记录 city-ai stream status/duration/retry/degraded/cache-hit/queue-depth → 2) 增加固定 METAR + detail + all_buckets fixture → 3) 回归断言 bucket 匹配、模型-市场差、温度单位与缓存 key → 4) 将生产 env 建议同步进部署文档 |
| 中 | **城市决策卡 结构化解读可观测性与回放测试** | 3–5 天 | 降低第三/第四/第五城市结构化分析 解读失败,验证缓存与队列是否真正生效 | 外部 structured detail 仍可能超时或输出截断,若无指标很难复盘 | 1) 记录 city-ai stream status/duration/retry/degraded/cache-hit/queue-depth → 2) 增加固定 METAR + detail + all_buckets fixture → 3) 回归断言 bucket 匹配、模型-市场差、温度单位与缓存 key → 4) 将生产 env 建议同步进部署文档 |
| - | ~~市场层升级为 async + 类型安全~~ | N/A | *[v1.7.0 已移除]* 市场价格拉取层已删除,此改进项不再适用 | - | - |
| 中 | **支付合约从“最小可用”升级到“更强合约防护”** | 1–2 周 | 在已完成的链下审计与容灾之上,进一步收紧链上授权边界 | 合约升级需要重新部署、迁移配置并再次验证 | 1) 维持现有事件重放、SQLite 审计、多 RPC fallback → 2) 升级合约到 SafeERC20 + Pausable → 3) 评估链上 plan/amount/token 绑定或 EIP-712 签名校验 → 4) 迁移后更新 PolygonScan 验证与支付审计文档 |
| 中 | **将 CI 与分支保护/发布流程真正绑定** | 1–3 天 | 让现有 CI 从“存在”变成“强制门禁” | 历史分支/热修流程可能受影响 | 1) GitHub `main` 开启 required checks → 2) 把 release/tag 流程绑定 CI → 3) 明确热修例外流程 |
@@ -194,7 +194,7 @@ Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引
### 文档、测试与贡献流程的具体补强建议(落到仓库层面)
1)**文档体系**:保留现有中文 API/TechDebt 文档的同时,增加三份“高价值”文档:
(a)《运行与配置手册》:按环境(本地/测试/VPS/生产)列必需变量、默认值、敏感等级,并明确城市 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`);(b)《数据源与合规说明》:列出 Open-Meteo、AviationWeather、NWS、HKO、CWA、Supabase 的使用条款要点、速率限制与降级策略(例如 AviationWeather 明确建议降低请求频率并提供 cache 文件)。 (c)《故障排查 Runbook》:429、支付 pending、城市 AI stream timeout/JSON 截断、前端缓存异常、温度桶错配等典型故障处理。
(a)《运行与配置手册》:按环境(本地/测试/VPS/生产)列必需变量、默认值、敏感等级,并明确城市结构化分析 推荐配置(`POLYWEATHER_SCAN_TERMINAL_BUILD_TIMEOUT_SEC=120``POLYWEATHER_SCAN_TERMINAL_MAX_WORKERS=8``POLYWEATHER_SCAN_TERMINAL_PAYLOAD_TTL_SEC=300`);(b)《数据源与合规说明》:列出 Open-Meteo、AviationWeather、NWS、HKO、CWA、Supabase 的使用条款要点、速率限制与降级策略(例如 AviationWeather 明确建议降低请求频率并提供 cache 文件)。 (c)《故障排查 Runbook》:429、支付 pending、城市结构化分析 stream timeout/JSON 截断、前端缓存异常、温度桶错配等典型故障处理。
2**测试金字塔**:在现有 `trend_engine` 单测基础上,补齐:
(a)天气 provider 的“录制回放”测试(VCR 思路:固定响应→确保解析稳定);(b)市场层的契约测试(Gamma/CLOB schema 变更时提前失败);(c)城市决策卡 fixture 测试(固定 `detail/market_scan/all_buckets/METAR` → 断言 bucket mapping、模型-市场差、温度单位、AI 缓存 key 与排队提示);(d)支付链路的本地链集成测试(Hardhat/Anvil + 事件扫描回放)。这些测试能把“外部依赖漂移”尽量转成可控的回归失败。
3**贡献工作流**:引入 `CONTRIBUTING.md`(分支策略、PR 模板、变更日志、版本号策略)、`CODEOWNERS`(核心模块审查人)、`SECURITY.md`(漏洞披露与密钥处理),并把静态检查(ruff/eslint)作为 pre-commit + CI 必过项。
@@ -243,13 +243,13 @@ PolyWeather 的评测应围绕“结算场景”而非传统数值天气预报
| 时间窗 | 里程碑 | 交付物 | 资源/备注 |
| ----------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
| 第 1 周 | 城市决策卡稳定性补强 | city-ai stream/cache/queue 指标;固定 METAR + `all_buckets` fixture;温度桶匹配与模型-市场差回归测试;生产 env 文档化 | 前端为主,后端补指标 |
| 第 2 周 | 市场层与 Scan Terminal 数据回放 | 保存 `bucket_label/bucket_direction/model_market_diff/matching_reason`;支持回放第三/第四/第五城市 AI 解读失败案例 | 用真实失败样本压回归 |
| 第 4–5 周 | 监控深挖与运维日报 | 来源 SLA、城市维度延迟、AI stream 状态、SQLite 体积、支付事件趋势、异常摘要 | 避免指标过多,先覆盖高频故障 |
| 第 2 周 | 市场层与 Scan Terminal 数据回放 | 保存 `bucket_label/bucket_direction/model_market_diff/matching_reason`;支持回放第三/第四/第五城市结构化分析 解读失败案例 | 用真实失败样本压回归 |
| 第 4–5 周 | 监控深挖与运维日报 | 来源 SLA、城市维度延迟、structured detail 状态、SQLite 体积、支付事件趋势、异常摘要 | 避免指标过多,先覆盖高频故障 |
| 第 6 周 | 支付合约与发布门禁升级 | SafeERC20/Pausable 方案评审;CI required checks 与 release/tag 流程绑定;热修例外流程 | 合约升级需单独部署验证 |
### 主要风险与缓解策略
**外部 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。
**外部 API / AI provider 速率限制与格式变更**AviationWeather 明确 rate limit 与建议使用 cache 文件;Open-Meteo 也可能在不同端点策略上变化;OpenAI-compatible city structured detail 可能出现 timeout、stream JSON 截断或并发竞争。缓解:统一“请求预算”与退避/熔断;关键响应做 schema 校验与回放测试;对高频数据优先拉取官方 cache/批量接口(若可用);城市结构化分析 保持小并发队列、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 是付费产品,必须先做法务与授权评审。缓解:只在研究环境评估;商用优先选择可商用权重/购买授权/自研。
+2 -2
View File
@@ -134,7 +134,7 @@
2. **Tab 下划线指示器不够明显**:`2px` 高度 + `opacity: 0.8` 的蓝色下划线容易被忽略。
3. **按钮层级不够清晰**`.scan-primary-button`(蓝紫渐变)、`.scan-ai-button`(青绿渐变)、`.scan-ai-city-icon-button`(蓝色边框半透明)、`.scan-theme-button`(无边框无背景)四种视觉权重混在一起,用户难以判断优先级。
3. **按钮层级不够清晰**`.scan-primary-button`(蓝紫渐变)、`.scan-ai-button`(青绿渐变)、`.scan-city-icon-button`(蓝色边框半透明)、`.scan-theme-button`(无边框无背景)四种视觉权重混在一起,用户难以判断优先级。
4. **空状态/加载状态设计不一致**:
- 地图加载有精美的云/雷达/热力动画
@@ -233,7 +233,7 @@
- Tab 切换缺少 `role="tablist"`/`role="tab"`/`aria-selected`
- 折叠按钮缺少 `aria-expanded`
2. **焦点指示器不可见**:自定义按钮(如 `scan-theme-button`、`scan-ai-city-icon-button`)没有 focus-visible 样式
2. **焦点指示器不可见**:自定义按钮(如 `scan-theme-button`、`scan-city-icon-button`)没有 focus-visible 样式
3. **颜色不是唯一的信息传达方式**:风险等级、Market decision 的色彩编码缺少对应的文字标签或图标补充
+4 -4
View File
@@ -4,7 +4,7 @@
## 一、产品概览
PolyWeather 是一个面向天气衍生品交易者的气象情报平台。核心价值主张:**结合多模型气象预报 + AI 机场报文解读 + Polymarket 市场价格,为交易决策提供一站式证据链。**
PolyWeather 是一个面向天气衍生品交易者的气象情报平台。核心价值主张:**结合多模型气象预报 + 结构化实况层 + Polymarket 市场价格,为交易决策提供一站式证据链。**
### 产品分层
@@ -12,7 +12,7 @@ PolyWeather 是一个面向天气衍生品交易者的气象情报平台。核
|------|------|------|
| 免费 | 交互式全球天气地图 + 城市简报 | 无需登录 |
| Pro 试用 | 3 天全功能 | 注册后自动获得 |
| Pro 订阅 | 城市决策卡(AI 机场报文 + 模型证据 + 市场层)、日内分析、历史对账、未来预报 | 10 USDC/月(积分抵扣最多 3 USDC |
| Pro 订阅 | 城市决策卡(结构化实况 + 模型证据 + 市场层)、日内分析、历史对账、未来预报 | 10 USDC/月(积分抵扣最多 3 USDC |
### 页面结构(9 个路由)
@@ -51,9 +51,9 @@ PolyWeather 是一个面向天气衍生品交易者的气象情报平台。核
## 三、做得好的地方
1. **地图 → 决策卡的自动流转设计** — 点击地图城市自动钉选到分析工作区并切换视图,"零步骤发现"
2. **双语覆盖完整** — 所有 UI 文案、文档、AI 解读都有中英文对照,覆盖率接近 100%
2. **双语覆盖完整** — 所有 UI 文案、文档、结构化解读都有中英文对照,覆盖率接近 100%
3. **数据新鲜度可视化** — DataFreshnessBar 让用户一眼看到 METAR/模型/市场数据的新鲜度
4. **AI 解读的产品化程度高** — 分层展示:快速判断 → 完整解读 → 证据链 → 风险提示
4. **结构化解读的产品化程度高** — 分层展示:快速判断 → 完整解读 → 证据链 → 风险提示
5. **免费层有实际价值** — 地图 + 城市简报不是"空壳",用户可以看真实气象数据
6. **支付链路完整** — 从钱包绑定到链上签约到支付恢复,处理了多种异常情况
7. **空状态有引导文字** — "Click a city on the map" 告诉用户下一步做什么
+1 -2
View File
@@ -37,8 +37,7 @@ PolyWeather Pro 的生产前端工程。
- 右侧详情面板在多日预报仍未补齐时会显示同步占位卡,不再把“只有今天一张卡”的中间态伪装成完整数据
- 日内分析弹窗在 full detail / market scan 同步时会锁住旧内容并显示刷新状态,避免用户短暂看到旧城市或旧日期的数据
- 城市决策卡支持从地图点击城市进入;机会榜和日历仍按 Pro 权限控制,地图探索和城市简报可作为轻量入口
- 城市决策卡的 AI 机场报文解读包括最终判断、METAR 解读、推理说明、模型集群备注、风险提示和原始 METAR
- AI 机场报文解读按 `city + local_date + locale + METAR signature` 做页面内存缓存和 `localStorage` 最终结果缓存;切换选项卡返回时会优先恢复已有内容
- 城市决策卡展示结构化实况、模型区间、市场温度桶和模型-市场差,不再请求 AI 解读
- 市场价格层使用完整 `all_buckets` 匹配温度桶,并把 `模型-市场差` 解释为 `模型概率 - 市场隐含概率`
- 概率区展示当前生产概率引擎输出(legacy 高斯或 EMOS),模型共识只作为辅助参考
- 缓存桶状态与 summary cache hit/miss
@@ -1,75 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const SCAN_AI_PROXY_TIMEOUT_MS = Math.max(
35_000,
Number(process.env.POLYWEATHER_SCAN_AI_PROXY_TIMEOUT_MS || "45000") || 45_000,
);
export const dynamic = "force-dynamic";
export const maxDuration = 60;
export async function POST(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
let body: unknown = {};
try {
body = await req.json();
} catch {
body = {};
}
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), SCAN_AI_PROXY_TIMEOUT_MS);
try {
auth = await buildBackendRequestHeaders(req);
const headers = new Headers(auth.headers);
headers.set("Content-Type", "application/json");
headers.set("Accept", "application/json");
const res = await fetch(`${API_BASE}/api/scan/terminal/ai`, {
method: "POST",
headers,
cache: "no-store",
signal: controller.signal,
body: JSON.stringify(body || {}),
});
if (!res.ok) {
const raw = await res.text();
const response = buildUpstreamErrorResponse(res.status, raw);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data, {
headers: {
"Cache-Control": "no-store",
},
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
const timedOut = controller.signal.aborted;
const response = buildProxyExceptionResponse(error, {
publicMessage: timedOut
? "Scan AI request timed out"
: "Failed to fetch scan AI data",
status: timedOut ? 504 : 500,
});
return auth ? applyAuthResponseCookies(response, auth.response) : response;
} finally {
clearTimeout(timeoutId);
}
}
@@ -1026,41 +1026,41 @@
color: #64748b;
}
.root :global(.scan-terminal.light .scan-ai-city-card),
.root :global(.scan-terminal.light .scan-ai-city-hero) {
.root :global(.scan-terminal.light .scan-city-card),
.root :global(.scan-terminal.light .scan-city-hero) {
background: var(--bg-card);
border-color: var(--border-glass);
}
.root :global(.scan-terminal.light .scan-ai-city-section),
.root :global(.scan-terminal.light .scan-city-section),
.root :global(.scan-terminal.light .scan-ai-decision-band),
.root :global(.scan-terminal.light .scan-ai-market-decision),
.root :global(.scan-terminal.light .scan-ai-market-decision-stats small),
.root :global(.scan-terminal.light .scan-ai-decision-metrics span),
.root :global(.scan-terminal.light .scan-ai-market-bucket),
.root :global(.scan-terminal.light .scan-ai-city-pills span),
.root :global(.scan-terminal.light .scan-ai-city-freshness span),
.root :global(.scan-terminal.light .scan-ai-city-metrics > span) {
.root :global(.scan-terminal.light .scan-city-pills span),
.root :global(.scan-terminal.light .scan-city-freshness span),
.root :global(.scan-terminal.light .scan-city-metrics > span) {
background: var(--bg-secondary);
border-color: var(--border-glass);
}
.root :global(.scan-terminal.light .scan-ai-city-metrics > span.primary) {
.root :global(.scan-terminal.light .scan-city-metrics > span.primary) {
background: #dbeafe;
border-color: rgba(59, 130, 246, 0.28);
}
.root :global(.scan-terminal.light .scan-ai-city-metrics > span.primary b) {
.root :global(.scan-terminal.light .scan-city-metrics > span.primary b) {
color: #1d4ed8;
}
.root :global(.scan-terminal.light .scan-ai-city-metrics small) {
.root :global(.scan-terminal.light .scan-city-metrics small) {
color: #64748b;
}
.root :global(.scan-terminal.light .scan-ai-workspace-head strong),
.root :global(.scan-terminal.light .scan-ai-city-hero h3),
.root :global(.scan-terminal.light .scan-ai-city-metrics b),
.root :global(.scan-terminal.light .scan-city-hero h3),
.root :global(.scan-terminal.light .scan-city-metrics b),
.root :global(.scan-terminal.light .scan-ai-decision-band strong),
.root :global(.scan-terminal.light .scan-ai-decision-metrics b),
.root :global(.scan-terminal.light .scan-ai-market-bucket strong),
@@ -1071,18 +1071,18 @@
}
.root :global(.scan-terminal.light .scan-ai-workspace-head p),
.root :global(.scan-terminal.light .scan-ai-city-section p),
.root :global(.scan-terminal.light .scan-city-section p),
.root :global(.scan-terminal.light .scan-ai-decision-band p),
.root :global(.scan-terminal.light .scan-ai-city-pills span),
.root :global(.scan-terminal.light .scan-ai-city-freshness),
.root :global(.scan-terminal.light .scan-city-pills span),
.root :global(.scan-terminal.light .scan-city-freshness),
.root :global(.scan-terminal.light .scan-ai-decision-metrics span),
.root :global(.scan-terminal.light .scan-ai-market-bucket span),
.root :global(.scan-terminal.light .scan-ai-market-decision span),
.root :global(.scan-terminal.light .scan-ai-market-decision p),
.root :global(.scan-terminal.light .scan-ai-market-decision-stats small),
.root :global(.scan-terminal.light .scan-ai-city-muted),
.root :global(.scan-terminal.light .scan-ai-city-loading),
.root :global(.scan-terminal.light .scan-ai-city-chart-legend),
.root :global(.scan-terminal.light .scan-city-muted),
.root :global(.scan-terminal.light .scan-city-loading),
.root :global(.scan-terminal.light .scan-city-chart-legend),
.root :global(.scan-terminal.light .scan-ai-weather-bullets) {
color: var(--text-muted);
}
@@ -1092,7 +1092,7 @@
color: #64748b;
}
.root :global(.scan-terminal.light .scan-ai-city-chart-placeholder) {
.root :global(.scan-terminal.light .scan-city-chart-placeholder) {
border-color: #cbd5e1;
background: #f8fafc;
color: #64748b;
@@ -1152,25 +1152,25 @@
}
.root :global(.scan-terminal.light .scan-ai-summary-card),
.root :global(.scan-terminal.light .scan-ai-city-card) {
.root :global(.scan-terminal.light .scan-city-card) {
background: rgba(255, 255, 255, 0.92);
border-color: rgba(148, 163, 184, 0.28);
}
.root :global(.scan-terminal.light .scan-ai-summary-card strong),
.root :global(.scan-terminal.light .scan-ai-city-head strong),
.root :global(.scan-terminal.light .scan-city-head strong),
.root :global(.scan-terminal.light .scan-ai-contract b) {
color: var(--text-primary);
}
.root :global(.scan-terminal.light .scan-ai-summary-card p),
.root :global(.scan-terminal.light .scan-ai-city-head p),
.root :global(.scan-terminal.light .scan-city-head p),
.root :global(.scan-terminal.light .scan-ai-contract p),
.root :global(.scan-terminal.light .scan-ai-contract small) {
color: var(--text-muted);
}
.root :global(.scan-terminal.light .scan-ai-city-head) {
.root :global(.scan-terminal.light .scan-city-head) {
background: #f8fafc;
}
@@ -1334,57 +1334,57 @@
}
/* ── AI city card light overrides ── */
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-card),
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-section),
:global(html.light) .root :global(.scan-terminal.light .scan-city-card),
:global(html.light) .root :global(.scan-terminal.light .scan-city-section),
:global(html.light) .root :global(.scan-terminal.light .scan-ai-decision-band),
:global(html.light) .root :global(.scan-terminal.light .scan-ai-market-decision),
:global(html.light) .root :global(.scan-terminal.light .scan-ai-decision-metrics span),
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-pills span),
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-freshness span),
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-metrics > span),
:global(html.light) .root :global(.scan-terminal.light .scan-city-pills span),
:global(html.light) .root :global(.scan-terminal.light .scan-city-freshness span),
:global(html.light) .root :global(.scan-terminal.light .scan-city-metrics > span),
:global(html.light) .root :global(.scan-terminal.light .scan-mobile-decision-metrics span),
:global(html.light) .root :global(.scan-terminal.light .scan-mobile-decision-reason) {
background: #eef7ff;
border-color: rgba(37, 99, 235, 0.16);
}
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-metrics > span.primary) {
:global(html.light) .root :global(.scan-terminal.light .scan-city-metrics > span.primary) {
background: #dbeafe;
border-color: rgba(59, 130, 246, 0.34);
}
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-metrics b) {
:global(html.light) .root :global(.scan-terminal.light .scan-city-metrics b) {
color: var(--text-primary);
}
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-metrics > span.primary b) {
:global(html.light) .root :global(.scan-terminal.light .scan-city-metrics > span.primary b) {
color: #1d4ed8;
}
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-metrics small) {
:global(html.light) .root :global(.scan-terminal.light .scan-city-metrics small) {
color: #64748b;
}
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-card p),
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-card li),
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-card small),
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-card span:not(.scan-ai-city-kicker)) {
:global(html.light) .root :global(.scan-terminal.light .scan-city-card p),
:global(html.light) .root :global(.scan-terminal.light .scan-city-card li),
:global(html.light) .root :global(.scan-terminal.light .scan-city-card small),
:global(html.light) .root :global(.scan-terminal.light .scan-city-card span:not(.scan-city-kicker)) {
color: var(--text-secondary);
}
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-status-tag.green) {
:global(html.light) .root :global(.scan-terminal.light .scan-city-status-tag.green) {
color: #047857;
}
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-status-tag.blue) {
:global(html.light) .root :global(.scan-terminal.light .scan-city-status-tag.blue) {
color: #1d4ed8;
}
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-status-tag.amber) {
:global(html.light) .root :global(.scan-terminal.light .scan-city-status-tag.amber) {
color: #b45309;
}
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-status-tag.red) {
:global(html.light) .root :global(.scan-terminal.light .scan-city-status-tag.red) {
color: #b91c1c;
}
@@ -1398,7 +1398,7 @@
}
:global(html.light) .root :global(.scan-terminal.light .scan-upgrade-announcement-copy strong),
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-mobile-priority b),
:global(html.light) .root :global(.scan-terminal.light .scan-city-mobile-priority b),
:global(html.light) .root :global(.scan-terminal.light .scan-mobile-decision-metrics b),
:global(html.light) .root :global(.scan-terminal.light .scan-mobile-decision-reason),
:global(html.light) .root :global(.scan-terminal.light .scan-mobile-decision-head h3),
@@ -1408,7 +1408,7 @@
:global(html.light) .root :global(.scan-terminal.light .scan-upgrade-announcement-copy p),
:global(html.light) .root :global(.scan-terminal.light .scan-upgrade-announcement li),
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-mobile-priority small),
:global(html.light) .root :global(.scan-terminal.light .scan-city-mobile-priority small),
:global(html.light) .root :global(.scan-terminal.light .scan-mobile-decision-metrics small),
:global(html.light) .root :global(.scan-terminal.light .scan-ai-market-mobile-line span) {
color: var(--text-secondary);
@@ -1419,7 +1419,7 @@
}
:global(html.light) .root :global(.scan-terminal.light .scan-upgrade-announcement li),
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-mobile-priority span),
:global(html.light) .root :global(.scan-terminal.light .scan-city-mobile-priority span),
:global(html.light) .root :global(.scan-terminal.light .scan-ai-decision-why),
:global(html.light) .root :global(.scan-terminal.light .scan-ai-market-mobile-line) {
background: #eef7ff;
@@ -1431,7 +1431,7 @@
}
/* ── Section titles ── */
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-section-title) {
:global(html.light) .root :global(.scan-terminal.light .scan-city-section-title) {
color: #1d4ed8;
}
@@ -1448,11 +1448,11 @@
}
/* ── Scrollbar light overrides ── */
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-body) {
:global(html.light) .root :global(.scan-terminal.light .scan-city-body) {
scrollbar-color: rgba(37, 99, 235, 0.18) transparent;
}
:global(html.light) .root :global(.scan-ai-city-body::-webkit-scrollbar-thumb) {
:global(html.light) .root :global(.scan-city-body::-webkit-scrollbar-thumb) {
background: rgba(37, 99, 235, 0.22);
}
@@ -517,20 +517,20 @@
margin: 0;
}
.root :global(.scan-ai-city-list) {
.root :global(.scan-city-list) {
display: flex;
flex-direction: column;
gap: 12px;
}
.root :global(.scan-ai-city-card) {
.root :global(.scan-city-card) {
border: 1px solid rgba(68, 100, 150, 0.2);
border-radius: 18px;
overflow: hidden;
background: rgba(10, 24, 42, 0.82);
}
.root :global(.scan-ai-city-head) {
.root :global(.scan-city-head) {
display: flex;
align-items: flex-start;
justify-content: space-between;
@@ -539,13 +539,13 @@
background: rgba(15, 34, 57, 0.9);
}
.root :global(.scan-ai-city-head strong) {
.root :global(.scan-city-head strong) {
color: #f2f8ff;
font-size: 17px;
font-weight: 900;
}
.root :global(.scan-ai-city-head p) {
.root :global(.scan-city-head p) {
margin: 6px 0 0;
color: #9fb4d2;
font-size: 13px;
@@ -553,7 +553,7 @@
line-height: 1.45;
}
.root :global(.scan-ai-city-head span) {
.root :global(.scan-city-head span) {
flex: 0 0 auto;
padding: 5px 9px;
border-radius: 999px;
@@ -370,12 +370,12 @@
white-space: normal;
}
.root :global(.scan-ai-city-stack) {
.root :global(.scan-city-stack) {
display: grid;
gap: 18px;
}
.root :global(.scan-ai-city-card) {
.root :global(.scan-city-card) {
overflow: hidden;
border: 1px solid rgba(77, 163, 255, 0.35);
border-radius: 18px;
@@ -390,13 +390,13 @@
background 0.18s ease;
}
.root :global(.scan-ai-city-card.removing) {
.root :global(.scan-city-card.removing) {
pointer-events: none;
opacity: 0;
transform: translateX(44px);
}
.root :global(.scan-ai-city-hero) {
.root :global(.scan-city-hero) {
display: flex;
justify-content: space-between;
align-items: flex-start;
@@ -408,35 +408,35 @@
border-bottom: 1px solid rgba(159, 178, 199, 0.12);
}
.root :global(.scan-ai-city-hero-left) {
.root :global(.scan-city-hero-left) {
min-width: 0;
flex: 1;
}
.root :global(.scan-ai-city-kicker) {
.root :global(.scan-city-kicker) {
color: var(--color-accent-primary);
font-size: 12px;
font-weight: 800;
}
.root :global(.scan-ai-city-hero h3) {
.root :global(.scan-city-hero h3) {
margin: 4px 0 8px;
color: var(--color-text-primary);
font-size: 22px;
line-height: 1.15;
}
.root :global(.scan-ai-city-mobile-priority) {
.root :global(.scan-city-mobile-priority) {
display: none;
}
.root :global(.scan-ai-city-pills) {
.root :global(.scan-city-pills) {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.root :global(.scan-ai-city-pills span) {
.root :global(.scan-city-pills span) {
border: 1px solid rgba(159, 178, 199, 0.14);
border-radius: 10px;
background: rgba(11, 18, 32, 0.58);
@@ -446,14 +446,14 @@
padding: 7px 10px;
}
.root :global(.scan-ai-city-status-tags) {
.root :global(.scan-city-status-tags) {
display: flex;
flex-wrap: wrap;
gap: 7px;
margin: -2px 0 10px;
}
.root :global(.scan-ai-city-status-tag) {
.root :global(.scan-city-status-tag) {
border: 1px solid rgba(159, 178, 199, 0.16);
border-radius: 999px;
background: rgba(15, 23, 42, 0.62);
@@ -464,35 +464,35 @@
padding: 5px 9px;
}
.root :global(.scan-ai-city-status-tag.green) {
.root :global(.scan-city-status-tag.green) {
border-color: rgba(34, 197, 94, 0.34);
background: rgba(34, 197, 94, 0.12);
color: #86efac;
}
.root :global(.scan-ai-city-status-tag.blue) {
.root :global(.scan-city-status-tag.blue) {
border-color: rgba(77, 163, 255, 0.36);
background: rgba(77, 163, 255, 0.13);
color: #9ecbff;
}
.root :global(.scan-ai-city-status-tag.amber) {
.root :global(.scan-city-status-tag.amber) {
border-color: rgba(245, 158, 11, 0.38);
background: rgba(245, 158, 11, 0.13);
color: #fcd34d;
}
.root :global(.scan-ai-city-status-tag.red) {
.root :global(.scan-city-status-tag.red) {
border-color: rgba(248, 113, 113, 0.4);
background: rgba(239, 68, 68, 0.13);
color: #fca5a5;
}
.root :global(.scan-ai-city-status-tag.muted) {
.root :global(.scan-city-status-tag.muted) {
color: #94a3b8;
}
.root :global(.scan-ai-city-freshness) {
.root :global(.scan-city-freshness) {
display: flex;
flex-wrap: wrap;
align-items: center;
@@ -503,7 +503,7 @@
font-weight: 700;
}
.root :global(.scan-ai-city-freshness strong) {
.root :global(.scan-city-freshness strong) {
color: #7d92b2;
font-size: 10px;
font-weight: 800;
@@ -512,7 +512,7 @@
margin-right: 2px;
}
.root :global(.scan-ai-city-freshness span) {
.root :global(.scan-city-freshness span) {
display: inline-flex;
align-items: center;
gap: 2px;
@@ -522,37 +522,37 @@
font-size: 11px;
}
.root :global(.scan-ai-city-freshness b) {
.root :global(.scan-city-freshness b) {
color: #d6e2f0;
font-style: normal;
font-weight: 900;
}
.root :global(.scan-ai-city-freshness em) {
.root :global(.scan-city-freshness em) {
color: var(--color-text-secondary);
font-style: normal;
}
.root :global(.scan-ai-city-freshness span.fresh em),
.root :global(.scan-ai-city-freshness span.green em) {
.root :global(.scan-city-freshness span.fresh em),
.root :global(.scan-city-freshness span.green em) {
color: #86efac;
}
.root :global(.scan-ai-city-freshness span.loading em),
.root :global(.scan-ai-city-freshness span.blue em) {
.root :global(.scan-city-freshness span.loading em),
.root :global(.scan-city-freshness span.blue em) {
color: #9ecbff;
}
.root :global(.scan-ai-city-freshness span.stale em),
.root :global(.scan-ai-city-freshness span.amber em) {
.root :global(.scan-city-freshness span.stale em),
.root :global(.scan-city-freshness span.amber em) {
color: #fcd34d;
}
.root :global(.scan-ai-city-freshness span.red em) {
.root :global(.scan-city-freshness span.red em) {
color: #fca5a5;
}
.root :global(.scan-ai-city-hero-side) {
.root :global(.scan-city-hero-side) {
display: flex;
flex-direction: column;
align-items: flex-end;
@@ -560,13 +560,13 @@
flex-shrink: 0;
}
.root :global(.scan-ai-city-metrics) {
.root :global(.scan-city-metrics) {
display: grid;
grid-template-columns: repeat(3, auto);
gap: 10px;
}
.root :global(.scan-ai-city-metrics > span) {
.root :global(.scan-city-metrics > span) {
display: grid;
gap: 3px;
min-width: 88px;
@@ -577,12 +577,12 @@
background: rgba(11, 18, 32, 0.42);
}
.root :global(.scan-ai-city-metrics > span.primary) {
.root :global(.scan-city-metrics > span.primary) {
border-color: rgba(77, 163, 255, 0.28);
background: rgba(77, 163, 255, 0.09);
}
.root :global(.scan-ai-city-metrics small) {
.root :global(.scan-city-metrics small) {
color: var(--color-text-muted);
font-size: 10px;
font-weight: 800;
@@ -590,26 +590,26 @@
text-transform: uppercase;
}
.root :global(.scan-ai-city-metrics b) {
.root :global(.scan-city-metrics b) {
color: var(--color-text-primary);
font-size: 20px;
font-weight: 900;
line-height: 1.1;
}
.root :global(.scan-ai-city-metrics > span.primary b) {
.root :global(.scan-city-metrics > span.primary b) {
color: #9ecbff;
}
.root :global(.scan-ai-city-actions) {
.root :global(.scan-city-actions) {
display: inline-flex;
justify-content: flex-end;
gap: 8px;
}
.root :global(.scan-ai-city-icon-button),
.root :global(.scan-ai-city-collapse),
.root :global(.scan-ai-city-price-button) {
.root :global(.scan-city-icon-button),
.root :global(.scan-city-collapse),
.root :global(.scan-city-price-button) {
min-height: 36px;
display: inline-flex;
align-items: center;
@@ -624,70 +624,70 @@
transition: background 0.18s ease, border-color 0.18s ease;
}
.root :global(.scan-ai-city-icon-button) {
.root :global(.scan-city-icon-button) {
width: 36px;
justify-content: center;
padding: 0;
}
.root :global(.scan-ai-city-icon-button.danger) {
.root :global(.scan-city-icon-button.danger) {
border-color: rgba(239, 68, 68, 0.34);
background: rgba(239, 68, 68, 0.1);
color: #fca5a5;
}
.root :global(.scan-ai-city-icon-button.danger:hover) {
.root :global(.scan-city-icon-button.danger:hover) {
border-color: rgba(239, 68, 68, 0.52);
background: rgba(239, 68, 68, 0.14);
color: #ff9aa4;
}
.root :global(.scan-ai-city-icon-button:disabled) {
.root :global(.scan-city-icon-button:disabled) {
cursor: wait;
opacity: 0.68;
}
.root :global(.scan-ai-city-icon-button .spin) {
.root :global(.scan-city-icon-button .spin) {
animation: spin 1s linear infinite;
}
.root :global(.scan-ai-city-collapse) {
.root :global(.scan-city-collapse) {
padding: 8px 10px;
}
.root :global(.scan-ai-city-card.collapsed .scan-ai-city-collapse svg) {
.root :global(.scan-city-card.collapsed .scan-city-collapse svg) {
transform: rotate(-90deg);
}
.root :global(.scan-ai-city-price-button) {
.root :global(.scan-city-price-button) {
padding: 9px 12px;
}
.root :global(.scan-ai-city-icon-button:hover),
.root :global(.scan-ai-city-collapse:hover),
.root :global(.scan-ai-city-price-button:hover) {
.root :global(.scan-city-icon-button:hover),
.root :global(.scan-city-collapse:hover),
.root :global(.scan-city-price-button:hover) {
background: rgba(77, 163, 255, 0.18);
border-color: rgba(111, 183, 255, 0.58);
}
.root :global(.scan-ai-city-price-button:disabled) {
.root :global(.scan-city-price-button:disabled) {
cursor: wait;
opacity: 0.62;
}
.root :global(.scan-ai-city-body) {
.root :global(.scan-city-body) {
padding: 18px 18px 36px;
max-height: none;
overflow: visible;
overscroll-behavior: contain;
}
.root :global(.scan-ai-city-body::-webkit-scrollbar) {
.root :global(.scan-city-body::-webkit-scrollbar) {
width: 8px;
}
.root :global(.scan-ai-city-body::-webkit-scrollbar-thumb) {
.root :global(.scan-city-body::-webkit-scrollbar-thumb) {
border-radius: 999px;
background: rgba(77, 163, 255, 0.28);
}
@@ -859,14 +859,14 @@
font-size: 15px;
}
.root :global(.scan-ai-city-analysis-grid) {
.root :global(.scan-city-analysis-grid) {
display: grid;
grid-template-columns: minmax(420px, 1.4fr) minmax(280px, 0.8fr);
gap: 14px;
margin-top: 14px;
}
.root :global(.scan-ai-city-section) {
.root :global(.scan-city-section) {
border: 1px solid rgba(159, 178, 199, 0.12);
border-radius: 16px;
background: rgba(11, 18, 32, 0.38);
@@ -911,7 +911,7 @@
line-height: 1.4;
}
.root :global(.scan-ai-city-section-title) {
.root :global(.scan-city-section-title) {
display: inline-flex;
align-items: center;
gap: 8px;
@@ -921,16 +921,16 @@
margin-bottom: 12px;
}
.root :global(.scan-ai-city-ai-read) {
.root :global(.scan-city-ai-read) {
overflow: hidden;
}
.root :global(.scan-ai-city-ai-read summary::-webkit-details-marker) {
.root :global(.scan-city-ai-read summary::-webkit-details-marker) {
display: none;
}
.root :global(.scan-ai-city-ai-read summary::after) {
.root :global(.scan-city-ai-read summary::after) {
width: 18px;
height: 18px;
display: inline-grid;
@@ -944,15 +944,15 @@
transition: transform 0.18s ease;
}
.root :global(.scan-ai-city-ai-read[open] summary::after) {
.root :global(.scan-city-ai-read[open] summary::after) {
transform: rotate(180deg);
}
.root :global(.scan-ai-city-section-body) {
.root :global(.scan-city-section-body) {
margin-top: 2px;
}
.root :global(.scan-ai-city-section p) {
.root :global(.scan-city-section p) {
margin: 0 0 10px;
color: var(--color-text-secondary);
font-size: 13px;
@@ -1101,17 +1101,17 @@
font-size: 12px;
}
.root :global(.scan-ai-city-chart) {
.root :global(.scan-city-chart) {
height: 260px;
}
.root :global(.scan-ai-city-chart canvas) {
.root :global(.scan-city-chart canvas) {
display: block;
width: 100%;
height: 100%;
}
.root :global(.scan-ai-city-chart-placeholder) {
.root :global(.scan-city-chart-placeholder) {
display: grid;
height: 100%;
place-items: center;
@@ -1123,7 +1123,7 @@
font-weight: 800;
}
.root :global(.scan-ai-city-chart-legend) {
.root :global(.scan-city-chart-legend) {
display: flex;
gap: 14px;
margin-top: 10px;
@@ -1132,20 +1132,20 @@
font-weight: 800;
}
.root :global(.scan-ai-city-chart-legend span) {
.root :global(.scan-city-chart-legend span) {
display: inline-flex;
align-items: center;
gap: 7px;
}
.root :global(.scan-ai-city-chart-legend i) {
.root :global(.scan-city-chart-legend i) {
width: 18px;
height: 3px;
border-radius: 999px;
background: rgba(100, 116, 139, 0.72);
}
.root :global(.scan-ai-city-chart-legend i.forecast) {
.root :global(.scan-city-chart-legend i.forecast) {
background: repeating-linear-gradient(
90deg,
rgba(100, 116, 139, 0.72) 0 6px,
@@ -1153,27 +1153,27 @@
);
}
.root :global(.scan-ai-city-chart-legend i.calibrated) {
.root :global(.scan-city-chart-legend i.calibrated) {
background: #38bdf8;
}
.root :global(.scan-ai-city-chart-legend i.observation) {
.root :global(.scan-city-chart-legend i.observation) {
width: 8px;
height: 8px;
background: #22c55e;
}
.root :global(.scan-ai-city-section.models) {
.root :global(.scan-city-section.models) {
margin-top: 14px;
}
.root :global(.scan-ai-city-section.models .models-section) {
.root :global(.scan-city-section.models .models-section) {
padding: 0;
border: 0;
background: transparent;
}
.root :global(.scan-ai-city-section-head) {
.root :global(.scan-city-section-head) {
display: flex;
justify-content: space-between;
gap: 14px;
@@ -1181,7 +1181,7 @@
margin-bottom: 12px;
}
.root :global(.scan-ai-city-section.market) {
.root :global(.scan-city-section.market) {
margin-top: 14px;
}
@@ -1206,14 +1206,14 @@
}
.root :global(.scan-ai-market-bucket span),
.root :global(.scan-ai-city-muted),
.root :global(.scan-ai-city-loading) {
.root :global(.scan-city-muted),
.root :global(.scan-city-loading) {
color: var(--color-text-secondary);
font-size: 12px;
font-weight: 700;
}
.root :global(.scan-ai-city-loading) {
.root :global(.scan-city-loading) {
padding: 28px;
}
@@ -477,7 +477,7 @@
text-align: right;
}
.root :global(.scan-ai-city-jumpbar) {
.root :global(.scan-city-jumpbar) {
display: flex;
gap: 8px;
margin: 0 0 12px;
@@ -490,11 +490,11 @@
scrollbar-width: none;
}
.root :global(.scan-ai-city-jumpbar::-webkit-scrollbar) {
.root :global(.scan-city-jumpbar::-webkit-scrollbar) {
display: none;
}
.root :global(.scan-ai-city-jumpbar button) {
.root :global(.scan-city-jumpbar button) {
flex: 0 0 auto;
min-height: 32px;
padding: 0 12px;
@@ -508,13 +508,13 @@
white-space: nowrap;
}
.root :global(.scan-ai-city-jumpbar button:hover) {
.root :global(.scan-city-jumpbar button:hover) {
border-color: rgba(111, 183, 255, 0.62);
background: rgba(77, 163, 255, 0.18);
}
.root :global(.scan-ai-city-card:not(.collapsed)) {
.root :global(.scan-city-card:not(.collapsed)) {
overflow: visible;
}
@@ -522,19 +522,19 @@
@keyframes spin { to { transform: rotate(360deg); } }
/* ── City Jumpbar Light Mode Overrides ── */
:global(html.light) .root :global(.scan-ai-city-jumpbar) {
:global(html.light) .root :global(.scan-city-jumpbar) {
background: #ffffff;
border-color: #d8e0ec;
box-shadow: var(--shadow-elevation-1);
}
:global(html.light) .root :global(.scan-ai-city-jumpbar button) {
:global(html.light) .root :global(.scan-city-jumpbar button) {
background: var(--color-bg-input);
border-color: var(--color-border-default);
color: var(--color-text-secondary);
}
:global(html.light) .root :global(.scan-ai-city-jumpbar button:hover) {
:global(html.light) .root :global(.scan-city-jumpbar button:hover) {
background: #e2e8f0;
border-color: var(--color-border-hover);
color: var(--color-text-primary);
@@ -155,15 +155,15 @@
padding: 11px 12px;
}
.root :global(.scan-mobile-decision-card .scan-ai-city-status-tags),
.root :global(.scan-mobile-decision-card .scan-ai-city-freshness),
.root :global(.scan-mobile-decision-card .scan-city-status-tags),
.root :global(.scan-mobile-decision-card .scan-city-freshness),
.root :global(.scan-mobile-decision-card .scan-ai-market-mobile-line),
.root :global(.scan-mobile-decision-folds) {
margin-right: 14px;
margin-left: 14px;
}
.root :global(.scan-mobile-decision-card .scan-ai-city-freshness) {
.root :global(.scan-mobile-decision-card .scan-city-freshness) {
grid-template-columns: 1fr;
max-width: none;
margin-top: 0;
@@ -204,13 +204,13 @@
margin-bottom: 10px;
}
.root :global(.scan-mobile-fold .scan-ai-city-section.models) {
.root :global(.scan-mobile-fold .scan-city-section.models) {
border: 0;
background: transparent;
padding: 0;
}
.root :global(.scan-ai-city-collapse svg) {
.root :global(.scan-city-collapse svg) {
transition: transform 0.18s ease;
}
@@ -300,7 +300,7 @@
.root :global(.scan-forecast-city-head),
.root :global(.scan-forecast-row-main),
.root :global(.scan-ai-brief-grid),
.root :global(.scan-ai-city-analysis-grid),
.root :global(.scan-city-analysis-grid),
.root :global(.scan-ai-decision-band),
.root :global(.scan-ai-evidence-line) {
grid-template-columns: 1fr;
@@ -309,8 +309,8 @@
.root :global(.scan-ai-workspace-head),
.root :global(.scan-mobile-city-list-head),
.root :global(.scan-opportunity-hero),
.root :global(.scan-ai-city-hero),
.root :global(.scan-ai-city-section-head) {
.root :global(.scan-city-hero),
.root :global(.scan-city-section-head) {
flex-direction: column;
align-items: flex-start;
}
@@ -321,7 +321,7 @@
.root :global(.scan-ai-workspace-head p),
.root :global(.scan-mobile-city-list-head p),
.root :global(.scan-ai-city-hero-side) {
.root :global(.scan-city-hero-side) {
text-align: left;
justify-items: start;
}
@@ -424,23 +424,23 @@
padding: 6px 8px;
}
.root :global(.scan-ai-city-hero) {
.root :global(.scan-city-hero) {
padding: 16px;
}
.root :global(.scan-ai-city-hero h3) {
.root :global(.scan-city-hero h3) {
margin-bottom: 10px;
font-size: 24px;
}
.root :global(.scan-ai-city-mobile-priority) {
.root :global(.scan-city-mobile-priority) {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin: 0 0 12px;
}
.root :global(.scan-ai-city-mobile-priority span) {
.root :global(.scan-city-mobile-priority span) {
display: grid;
gap: 4px;
border: 1px solid rgba(77, 163, 255, 0.18);
@@ -449,23 +449,23 @@
padding: 9px;
}
.root :global(.scan-ai-city-mobile-priority small) {
.root :global(.scan-city-mobile-priority small) {
color: var(--color-text-secondary);
font-size: 10px;
font-weight: 900;
}
.root :global(.scan-ai-city-mobile-priority b) {
.root :global(.scan-city-mobile-priority b) {
color: #f3f8ff;
font-size: 13px;
line-height: 1.15;
}
.root :global(.scan-ai-city-pills) {
.root :global(.scan-city-pills) {
display: none;
}
.root :global(.scan-ai-city-freshness) {
.root :global(.scan-city-freshness) {
grid-template-columns: 1fr;
}
@@ -505,7 +505,7 @@
margin-top: 10px;
}
.root :global(.scan-ai-city-ai-read:not([open])) {
.root :global(.scan-city-ai-read:not([open])) {
padding-bottom: 4px;
}
+17 -17
View File
@@ -55,14 +55,14 @@ export const DOCS_PAGES: DocsPage[] = [
id: "core-modules",
title: "你会在页面上看到什么",
blocks: [
{ type: "bullets", items: ["锚点状态:先确认当前机场主站实测、日内已见高点和结算时钟。", "当前节奏:把“此刻应到温度”和“机场实测”放在一张卡里,判断今天跑得快还是慢。", "专业气象结论条:先给今日主判断、置信度、基准/上修/下修路径和下一观测点。", "城市决策卡:从地图进入城市简报,读取 AI 机场报文解读、最高温中枢、市场温度桶和模型-市场差。", "校准模型概率 / 模型区间与分歧:概率层看当前生产概率引擎输出;EMOS / LGBM 只有在评估通过或 shadow 对照时进入解释层,模型区间用于解释分歧。", "气象证据链 / 失效条件 / 确认条件:解释为什么这么判断,以及什么情况会让判断降级。"] },
{ type: "bullets", items: ["锚点状态:先确认当前机场主站实测、日内已见高点和结算时钟。", "当前节奏:把“此刻应到温度”和“机场实测”放在一张卡里,判断今天跑得快还是慢。", "专业气象结论条:先给今日主判断、置信度、基准/上修/下修路径和下一观测点。", "城市决策卡:从地图进入城市简报,读取结构化实况、最高温中枢、市场温度桶和模型-市场差。", "校准模型概率 / 模型区间与分歧:概率层看当前生产概率引擎输出;EMOS / LGBM 只有在评估通过或 shadow 对照时进入解释层,模型区间用于解释分歧。", "气象证据链 / 失效条件 / 确认条件:解释为什么这么判断,以及什么情况会让判断降级。"] },
],
},
{
id: "how-to-read",
title: "如何快速读懂主站",
blocks: [
{ type: "steps", items: ["先看专业气象结论条或城市决策卡,确认今日主判断、最高温中枢和下一观测点。", "再看锚点状态和今日气温预测图,确认机场实测、DEB、峰值窗口和关键档位线。", "接着看 AI 机场报文解读、气象证据链、失效条件和确认条件,判断这个路径有没有被新观测破坏。", "最后看校准模型概率、模型区间、市场温度桶和模型-市场差,判断概率是否已经被市场充分计价。"] },
{ type: "steps", items: ["先看专业气象结论条或城市决策卡,确认今日主判断、最高温中枢和下一观测点。", "再看锚点状态和今日气温预测图,确认机场实测、DEB、峰值窗口和关键档位线。", "接着看气象证据链、失效条件和确认条件,判断这个路径有没有被新观测破坏。", "最后看校准模型概率、模型区间、市场温度桶和模型-市场差,判断概率是否已经被市场充分计价。"] },
],
},
],
@@ -183,21 +183,21 @@ export const DOCS_PAGES: DocsPage[] = [
content: {
"zh-CN": {
title: "城市决策卡",
description: "这页解释地图城市决策卡如何把 AI 机场报文解读、最高温中枢、市场温度桶和模型-市场差组合成可验证判断。",
description: "这页解释地图城市决策卡如何把结构化实况、最高温中枢、市场温度桶和模型-市场差组合成可验证判断。",
sections: [
{
id: "entry-and-permission",
title: "从地图进入决策卡",
blocks: [
{ type: "paragraph", text: "用户可以从地图点击城市进入城市决策卡。机会榜和日历属于 Pro 能力;地图探索和城市简报仍可作为轻量入口使用。" },
{ type: "callout", tone: "info", title: "先天气、后市场", text: "决策卡顶部的天气判断层不读取市场价格,先用 METAR、DEB多模型集合和 AI 解读确定最高温中枢,再把该中枢映射到市场温度桶。" },
{ type: "callout", tone: "info", title: "先天气、后市场", text: "决策卡顶部的天气判断层不读取市场价格,先用结构化实况、DEB多模型集合确定最高温中枢,再把该中枢映射到市场温度桶。" },
],
},
{
id: "ai-airport-read",
title: "AI 机场报文解读包括什么",
id: "structured-observations",
title: "结构化实况包括什么",
blocks: [
{ type: "bullets", items: ["最终判断:预计最高温中枢、上修/下修空间和当前操作口径。", "METAR 解读:报文时间、实测温度、露点/湿度、风向风速、能见度、云量、气压和 NOSIG / TAF 等机场侧信号。", "推理说明:把最新实测、DEB、多模型集群和午后对流/云雨/风向风险合并成日内节奏判断。", "模型集群备注:展示模型数量、模型区间,以及是否集中在 DEB ±2°C 内。", "风险提示:后续 METAR 或路径明显偏离时,说明应如何上调或下修。", "原始 METAR:保留原始报文,便于人工复核。"] },
{ type: "bullets", items: ["实测锚点:当前温度、当日已见高点、观测时间和数据新鲜度。", "模型区间:DEB、多模型范围,以及模型是否明显分散。", "日内节奏:把实测路径、峰值窗口和目标温度桶放在一起对比。", "市场映射:把最高温中枢映射到 YES/NO 温度桶,并计算模型-市场差。"] },
],
},
{
@@ -213,29 +213,29 @@ export const DOCS_PAGES: DocsPage[] = [
id: "cache-behavior",
title: "为什么切换选项卡后不应重新空白加载",
blocks: [
{ type: "paragraph", text: "城市决策卡会用 city + local_date + locale + METAR signature 作为 AI 解读缓存键。METAR signature 优先使用原始报文,缺失时回退到报文时间、观测时间和温度。" },
{ type: "bullets", items: ["页面内存缓存:保存 loading 状态、流式进度、机场报文解读片段和最终结果;从其他选项卡切回时优先恢复旧内容。", "浏览器 localStorage:保存最终成功的 AI payload,默认 TTL 为 1 小时。", "后端 AI 缓存:不再把 local_time 放入缓存键,避免同一报文因当前时间变化反复失效。", "市场扫描缓存:完整 all_buckets 结果按城市和日期缓存,默认 TTL 为 10 分钟。"] },
{ type: "paragraph", text: "城市决策卡复用城市详情、市场扫描和图表数据缓存,不再单独请求 AI 解读。" },
{ type: "bullets", items: ["城市详情缓存:保存实况、模型、概率和结算上下文。", "市场扫描缓存:完整 all_buckets 结果按城市和日期缓存,默认 TTL 为 10 分钟。", "前端图表缓存:切换城市或选项卡时优先复用已加载的结构化数据。"] },
],
},
],
},
"en-US": {
title: "City Decision Cards",
description: "How the city card combines the AI airport read, expected-high center, market bucket mapping, and model-market difference into a verifiable decision.",
description: "How the city card combines structured observations, expected-high center, market bucket mapping, and model-market difference into a verifiable decision.",
sections: [
{
id: "entry-and-permission",
title: "Opening a card from the map",
blocks: [
{ type: "paragraph", text: "Users can click a city on the map to open its city decision card. The opportunity board and calendar are Pro surfaces; map exploration and city briefs remain the lightweight entry point." },
{ type: "callout", tone: "info", title: "Weather first, market second", text: "The weather decision layer does not use market price input. It first sets the expected-high center from METAR, DEB, the model cluster, and AI reasoning, then maps that center to the relevant market bucket." },
{ type: "callout", tone: "info", title: "Weather first, market second", text: "The weather decision layer does not use market price input. It first sets the expected-high center from structured observations, DEB, and the model cluster, then maps that center to the relevant market bucket." },
],
},
{
id: "ai-airport-read",
title: "What the AI airport read contains",
id: "structured-observations",
title: "What structured observations contain",
blocks: [
{ type: "bullets", items: ["Final judgment: expected-high center, upside/downside room, and the working decision.", "METAR read: report time, observed temperature, dew point / humidity, wind, visibility, clouds, pressure, and NOSIG / TAF airport-side signals.", "Reasoning: combines live observations, DEB, the model cluster, and convective / cloud / wind risks into an intraday pace read.", "Model-cluster note: model count, model range, and whether the cluster sits within DEB ±2°C.", "Risk notes: how later METAR/path breaks should raise or lower the high center.", "Raw METAR: preserved so the read can be manually audited."] },
{ type: "bullets", items: ["Observation anchor: current temperature, daily high so far, observation time, and freshness.", "Model range: DEB, multi-model range, and whether the model cluster is dispersed.", "Intraday pace: live path, peak window, and target temperature bucket in one comparison.", "Market mapping: maps the expected-high center to YES/NO buckets and calculates the model-market difference."] },
],
},
{
@@ -249,10 +249,10 @@ export const DOCS_PAGES: DocsPage[] = [
},
{
id: "cache-behavior",
title: "Why tab switching should not blank the AI read",
title: "Why tab switching should not blank the card",
blocks: [
{ type: "paragraph", text: "The card keys AI reads by city + local_date + locale + METAR signature. The signature prefers the raw report and falls back to report time, observation time, and temperature." },
{ type: "bullets", items: ["In-page memory cache: stores loading state, stream progress, airport-read snippets, and final results so returning from another tab restores prior content first.", "Browser localStorage: stores final successful AI payloads for one hour by default.", "Backend AI cache: excludes local_time from the key so the same report does not expire merely because the current clock changed.", "Market-scan cache: stores full all_buckets results by city and date for 10 minutes by default."] },
{ type: "paragraph", text: "The card reuses city detail, market scan, and chart-data caches. It no longer makes a separate AI-read request." },
{ type: "bullets", items: ["City detail cache: stores observations, models, probabilities, and settlement context.", "Market-scan cache: stores full all_buckets results by city and date for 10 minutes by default.", "Frontend chart cache: reuses already loaded structured data when switching cities or tabs."] },
],
},
],
-63
View File
@@ -640,27 +640,6 @@ export interface ScanOpportunityRow {
ai_confidence?: string | null;
ai_reason_zh?: string | null;
ai_reason_en?: string | null;
ai_model_cluster_note?: string | null;
ai_city_thesis_zh?: string | null;
ai_city_thesis_en?: string | null;
ai_city_confidence?: string | null;
ai_city_model_cluster_note?: string | null;
ai_predicted_max?: number | null;
ai_predicted_low?: number | null;
ai_predicted_high?: number | null;
ai_forecast_unit?: string | null;
ai_forecast_confidence?: string | null;
ai_peak_window_zh?: string | null;
ai_peak_window_en?: string | null;
ai_airport_metar_read_zh?: string | null;
ai_airport_metar_read_en?: string | null;
ai_forecast_reason_zh?: string | null;
ai_forecast_reason_en?: string | null;
ai_forecast_match?: "core" | "edge" | "outside" | "watch" | string | null;
ai_forecast_match_reason_zh?: string | null;
ai_forecast_match_reason_en?: string | null;
ai_watchlist_reason_zh?: string | null;
ai_watchlist_reason_en?: string | null;
v4_metar_decision?: "approve" | "veto" | "downgrade" | "watchlist" | string | null;
v4_metar_reason_zh?: string | null;
v4_metar_reason_en?: string | null;
@@ -668,47 +647,6 @@ export interface ScanOpportunityRow {
export interface PrimarySignal extends ScanOpportunityRow {}
export interface ScanAiWatchlistItem {
row_id: string;
reason?: string | null;
reason_zh?: string | null;
reason_en?: string | null;
}
export interface ScanAiReview {
status?: "ready" | "disabled" | "missing_key" | "failed" | "no_rows" | "no_snapshot" | "snapshot_mismatch" | string;
stage?: "completed" | "fallback" | string | null;
model?: string | null;
cached?: boolean;
generated_at?: string | null;
snapshot_id?: string | null;
input_rows?: number | null;
sent_rows?: number | null;
sent_cities?: number | null;
sent_contracts?: number | null;
duration_ms?: number | null;
timeout_sec?: number | null;
cache_ttl_sec?: number | null;
provider?: string | null;
base_url?: string | null;
finish_reason?: string | null;
usage?: {
prompt_tokens?: number | null;
completion_tokens?: number | null;
total_tokens?: number | null;
prompt_cache_hit_tokens?: number | null;
prompt_cache_miss_tokens?: number | null;
} | null;
reason?: string | null;
summary_zh?: string | null;
summary_en?: string | null;
watchlist?: ScanAiWatchlistItem[] | null;
recommended_count?: number | null;
vetoed_count?: number | null;
downgraded_count?: number | null;
watchlist_count?: number | null;
}
export interface ScanTerminalResponse {
generated_at: string;
snapshot_id?: string | null;
@@ -733,7 +671,6 @@ export interface ScanTerminalResponse {
};
top_signal?: PrimarySignal | null;
rows: ScanOpportunityRow[];
ai_scan?: ScanAiReview | null;
}
export interface IntradayMeteorologySignal {
+10 -10
View File
@@ -96,12 +96,12 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"future.judgement": "判断",
"future.confidence": "置信度",
"future.maxPrecip": "最大降水概率",
"future.ai": "机场报文解读",
"future.noAi": "暂无机场报文解读,当前以结构化气象与模型数据为主。",
"future.ai": "结构化实况",
"future.noAi": "暂无结构化实况,当前以模型数据为主。",
"future.weatherGov": "weather.gov 文本",
"future.risk": "结算与偏差风险",
"future.climate": "当地气候主要受什么影响",
"future.chartLegendEmpty": "暂无机场报文或小时级实测数据",
"future.chartLegendEmpty": "暂无小时级实测数据",
"confidence.high": "高",
"confidence.medium": "中",
@@ -114,8 +114,8 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"section.noProb": "暂无概率数据",
"section.models": "多模型预报",
"section.noModels": "暂无多模型预报",
"section.ai": "机场报文解读",
"section.aiEmpty": "暂无机场报文解读,当前以结构化气象与模型数据为主。",
"section.ai": "结构化实况",
"section.aiEmpty": "暂无结构化实况,当前以模型数据为主。",
"section.risk": "数据偏差风险",
"section.noRiskProfile": "暂无风险档案",
"section.airport": "机场",
@@ -278,14 +278,14 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"future.judgement": "Judgement",
"future.confidence": "Confidence",
"future.maxPrecip": "Max Precip Probability",
"future.ai": "Airport METAR Narrative",
"future.ai": "Structured Observations",
"future.noAi":
"No airport bulletin narrative is available. Structured meteorological and model data are used as baseline.",
"No structured observations are available. Model data is used as baseline.",
"future.weatherGov": "weather.gov text",
"future.risk": "Settlement & Deviation Risk",
"future.climate": "What Mainly Drives Local Climate",
"future.chartLegendEmpty":
"No METAR bulletin or hourly observations available",
"No hourly observations available",
"confidence.high": "High",
"confidence.medium": "Medium",
@@ -298,9 +298,9 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"section.noProb": "No probability data available",
"section.models": "Multi-model Forecast",
"section.noModels": "No multi-model forecast available",
"section.ai": "Airport METAR Narrative",
"section.ai": "Structured Observations",
"section.aiEmpty":
"No airport bulletin narrative is available. Structured meteorological data are currently used.",
"No structured observations are available. Model data is currently used.",
"section.risk": "Data Deviation Risk",
"section.noRiskProfile": "No risk profile available",
"section.airport": "Airport",
-1
View File
@@ -169,6 +169,5 @@ export const config = {
"/api/payments/:path*",
"/api/system/:path*",
"/api/city/:path*/detail:path*",
"/api/scan/terminal/ai:path*",
],
};
-19
View File
@@ -3,7 +3,6 @@ from __future__ import annotations
from datetime import datetime, timezone, timedelta
from typing import Any, Dict, List, Optional, Tuple
from src.analysis.metar_narrator import describe_metar_report
from src.analysis.trend_engine import analyze_weather_trend
from src.data_collection.city_registry import ALIASES, CITY_REGISTRY
from src.data_collection.city_risk_profiles import get_city_risk_profile
@@ -578,23 +577,5 @@ def build_city_query_report(
for line in feature_str.split("\n"):
if line.strip():
msg_lines.append(f"- {line.strip()}")
metar_narrative = describe_metar_report(
raw_metar=str(amos_raw_metar or primary_current.get("raw_metar") or metar_current.get("raw_metar") or ""),
temp_symbol=temp_symbol,
fallback={
"icao": metar.get("icao"),
"station_name": metar.get("station_name"),
"temp": cur_temp,
"wind_speed_kt": _sf(primary_current.get("wind_speed_kt")),
"wind_dir": _sf(primary_current.get("wind_dir")),
"altimeter": _sf(primary_current.get("altimeter")),
"wx_desc": primary_current.get("wx_desc"),
"clouds": primary_current.get("clouds", []),
},
)
if metar_narrative:
msg_lines.append("\n🛰️ <b>机场报文解读</b>:")
msg_lines.append(metar_narrative)
msg_lines.append(f"\n💸 本次消耗 <b>{city_query_cost}</b> 积分。")
return "\n".join(msg_lines)
-307
View File
@@ -1,307 +0,0 @@
from __future__ import annotations
import re
from typing import Any, Dict, Iterable, Optional, Tuple
_WIND_TOKEN_RE = re.compile(r"^(VRB|\d{3})(\d{2,3})(G(\d{2,3}))?KT$")
_WIND_VAR_RE = re.compile(r"^(\d{3})V(\d{3})$")
_TEMP_DEW_RE = re.compile(r"^(M?\d{2}|//)/(M?\d{2}|//)$")
_PRESSURE_Q_RE = re.compile(r"^Q(\d{4})$")
_PRESSURE_A_RE = re.compile(r"^A(\d{4})$")
_CLOUD_RE = re.compile(r"^(FEW|SCT|BKN|OVC|VV|SKC|CLR|NSC)(\d{3})?$")
_WX_CODE_RE = re.compile(r"^[-+]?([A-Z]{2,})$")
_WIND_DIR_16 = [
"北方",
"北偏东北方向",
"东北方向",
"东偏东北方向",
"东方",
"东偏东南方向",
"东南方向",
"南偏东南方向",
"南方",
"南偏西南方向",
"西南方向",
"西偏西南方向",
"西方",
"西偏西北方向",
"西北方向",
"北偏西北方向",
]
_CLOUD_DESC = {
"CLR": "晴空",
"SKC": "晴空",
"NSC": "晴空",
"FEW": "少云",
"SCT": "多变云天",
"BKN": "多云",
"OVC": "阴天",
"VV": "低云压顶",
}
_WEATHER_DESC = {
"RA": "有降雨",
"DZ": "有毛毛雨",
"SN": "有降雪",
"TS": "有雷暴",
"TSRA": "有雷阵雨",
"FG": "有雾",
"BR": "有轻雾",
"HZ": "有霾",
"SHRA": "有阵雨",
"FZRA": "有冻雨",
}
def _safe_float(value: Any) -> Optional[float]:
if value is None:
return None
try:
return float(value)
except Exception:
return None
def _parse_metar_signed_temp(raw: str) -> Optional[float]:
if raw in {"", "//"}:
return None
sign = -1.0 if raw.startswith("M") else 1.0
value = raw[1:] if raw.startswith("M") else raw
try:
return sign * float(int(value))
except Exception:
return None
def _pick_station(tokens: Iterable[str]) -> str:
token_list = list(tokens)
if not token_list:
return ""
first = token_list[0]
if first in {"METAR", "SPECI"} and len(token_list) >= 2:
first = token_list[1]
if re.fullmatch(r"[A-Z]{4}", first):
return first
return ""
def _direction_desc(direction_deg: float) -> str:
idx = int(((direction_deg % 360) + 11.25) // 22.5) % 16
return _WIND_DIR_16[idx]
def _wind_level_desc(ms: float) -> str:
if ms < 0.3:
return "静风"
if ms < 1.6:
return "软风"
if ms < 3.4:
return "轻风"
if ms < 5.5:
return "微风"
if ms < 8.0:
return "和风"
if ms < 10.8:
return "清劲风"
if ms < 13.9:
return "强风"
if ms < 17.2:
return "疾风"
return "大风"
def _format_temp(temp: float, symbol: str) -> str:
rounded = round(temp, 1)
if abs(rounded - round(rounded)) < 0.05:
body = str(int(round(rounded)))
else:
body = f"{rounded:.1f}"
if rounded > 0:
body = f"+{body}"
return f"{body}{symbol}"
def _format_ms(ms: float) -> str:
rounded = round(ms, 1)
if abs(rounded - round(rounded)) < 0.05:
return str(int(round(rounded)))
return f"{rounded:.1f}"
def _pressure_desc(hpa: float) -> str:
hp = round(hpa)
if hp < 1000:
return f"偏低气压({hp} hPa)"
if hp > 1030:
return f"偏高气压({hp} hPa)"
return f"在正常范围内的大气压({hp} hPa)"
def _best_cloud_code(tokens: Iterable[str], fallback_clouds: Any) -> str:
best = ""
rank = {"CLR": 0, "SKC": 0, "NSC": 0, "FEW": 1, "SCT": 2, "BKN": 3, "OVC": 4, "VV": 5}
best_rank = -1
for token in tokens:
m = _CLOUD_RE.match(token)
if not m:
continue
code = m.group(1)
score = rank.get(code, -1)
if score > best_rank:
best_rank = score
best = code
if best:
return best
if isinstance(fallback_clouds, list):
for row in fallback_clouds:
if not isinstance(row, dict):
continue
code = str(row.get("cover") or "").upper().strip()
if not code:
continue
score = rank.get(code, -1)
if score > best_rank:
best_rank = score
best = code
return best
def describe_metar_report(
raw_metar: str,
temp_symbol: str = "°C",
fallback: Optional[Dict[str, Any]] = None,
) -> str:
"""
Convert METAR bulletin into deterministic human-language description.
Style is inspired by rp5 bulletin narration: temperature, cloud, pressure, wind.
"""
fallback = fallback or {}
raw = str(raw_metar or "").strip().upper()
tokens = [token for token in raw.split() if token]
if not tokens and not fallback:
return ""
station = _pick_station(tokens) or str(fallback.get("icao") or "").upper().strip()
station_name = str(fallback.get("station_name") or "").strip()
wind_dir = _safe_float(fallback.get("wind_dir"))
wind_kt = _safe_float(fallback.get("wind_speed_kt"))
wind_var: Optional[Tuple[float, float]] = None
for token in tokens:
m = _WIND_TOKEN_RE.match(token)
if not m:
continue
dir_token = m.group(1)
spd_token = m.group(2)
if dir_token != "VRB":
wind_dir = _safe_float(dir_token)
wind_kt = _safe_float(spd_token)
break
for token in tokens:
mv = _WIND_VAR_RE.match(token)
if mv:
left = _safe_float(mv.group(1))
right = _safe_float(mv.group(2))
if left is not None and right is not None:
wind_var = (left, right)
break
temp_c = None
for token in tokens:
tm = _TEMP_DEW_RE.match(token)
if tm:
temp_c = _parse_metar_signed_temp(tm.group(1))
break
fallback_temp = _safe_float(fallback.get("temp"))
if temp_c is None and fallback_temp is not None:
temp_c = fallback_temp if temp_symbol == "°C" else (fallback_temp - 32.0) * 5.0 / 9.0
pressure_hpa = None
for token in tokens:
qm = _PRESSURE_Q_RE.match(token)
if qm:
pressure_hpa = _safe_float(qm.group(1))
break
am = _PRESSURE_A_RE.match(token)
if am:
inhg = _safe_float(am.group(1))
if inhg is not None:
pressure_hpa = (inhg / 100.0) * 33.8639
break
if pressure_hpa is None:
altim = _safe_float(fallback.get("altimeter"))
if altim is not None:
pressure_hpa = altim * 33.8639 if altim < 200 else altim
cloud_code = _best_cloud_code(tokens, fallback.get("clouds"))
cloud_desc = _CLOUD_DESC.get(cloud_code, "")
wx_desc = ""
wx_raw = str(fallback.get("wx_desc") or "").upper().strip()
if wx_raw:
for key, value in _WEATHER_DESC.items():
if key in wx_raw:
wx_desc = value
break
if not wx_desc:
for token in tokens:
if not _WX_CODE_RE.match(token):
continue
for key, value in _WEATHER_DESC.items():
if key in token:
wx_desc = value
break
if wx_desc:
break
station_label = ""
if station:
station_label = f"{station} 机场"
elif station_name:
station_label = station_name
else:
station_label = "机场"
parts = []
if temp_c is not None:
display_temp = temp_c if temp_symbol == "°C" else temp_c * 9.0 / 5.0 + 32.0
parts.append(f"{station_label} {_format_temp(display_temp, temp_symbol)}")
else:
parts.append(station_label)
if cloud_desc:
parts.append(cloud_desc)
if pressure_hpa is not None:
parts.append(_pressure_desc(pressure_hpa))
if wind_kt is not None:
wind_ms = float(wind_kt) * 0.514444
wind_level = _wind_level_desc(wind_ms)
if wind_dir is not None:
wind_sentence = (
f"{_direction_desc(wind_dir)}吹来的{wind_level}"
f"({_format_ms(wind_ms)}米/秒)"
)
else:
wind_sentence = f"{wind_level}({_format_ms(wind_ms)}米/秒)"
if wind_var is not None:
left, right = wind_var
wind_sentence += (
f",风向在{_direction_desc(left)}{_direction_desc(right)}之间摆动"
)
parts.append(wind_sentence)
if wx_desc:
parts.append(wx_desc)
if "NOSIG" in tokens:
parts.append("短时无显著变化")
text = "".join([p for p in parts if str(p or "").strip()])
return f"{text}" if text else ""
+3 -6
View File
@@ -284,14 +284,11 @@ def _build_ai_prompt(
def _call_ai(prompt: str) -> Optional[str]:
api_key = os.getenv("POLYWEATHER_SCAN_AI_API_KEY", "")
api_key = os.getenv("DAILY_REPORT_AI_API_KEY", "")
base_url = os.getenv(
"POLYWEATHER_SCAN_AI_BASE_URL", "https://token-plan-cn.xiaomimimo.com/v1"
)
model = os.getenv(
"DAILY_REPORT_AI_MODEL",
os.getenv("POLYWEATHER_SCAN_AI_MODEL", "mimo-v2.5-pro"),
"DAILY_REPORT_AI_BASE_URL", "https://token-plan-cn.xiaomimimo.com/v1"
)
model = os.getenv("DAILY_REPORT_AI_MODEL", "mimo-v2.5-pro")
if not api_key:
logger.warning("daily_weather_report: AI API key not configured")
+2 -2
View File
@@ -18,9 +18,9 @@ def test_refresh_policy_cadences_are_layered():
def test_backend_defaults_use_refresh_policy():
import src.data_collection.weather_sources as weather_sources
import web.services.city_runtime as city_runtime
import web.services.scan_ai_config as scan_ai_config
import web.services.scan_terminal_config as scan_terminal_config
assert scan_ai_config.SCAN_TERMINAL_PAYLOAD_TTL_SEC == SCAN_ROWS_REFRESH_SEC
assert scan_terminal_config.SCAN_TERMINAL_PAYLOAD_TTL_SEC == SCAN_ROWS_REFRESH_SEC
assert city_runtime.CITY_FULL_CACHE_TTL_SEC == OBSERVATION_REFRESH_SEC
assert city_runtime.CITY_PANEL_CACHE_TTL_SEC == SCAN_ROWS_REFRESH_SEC
assert city_runtime.CITY_MARKET_CACHE_TTL_SEC == SCAN_ROWS_REFRESH_SEC
+10 -1
View File
@@ -6,6 +6,16 @@ from web.scan_terminal_payloads import (
build_stale_scan_terminal_payload,
)
from web.scan_terminal_ranker import build_ranked_scan_terminal_result
from web.routers.scan import router as scan_router
def test_scan_router_does_not_expose_terminal_ai_endpoint():
routes = {
getattr(route, "path", None): getattr(route, "methods", set())
for route in scan_router.routes
}
assert "/api/scan/terminal/ai" not in routes
def test_normalize_scan_terminal_filters_clamps_and_swaps_bounds():
@@ -149,4 +159,3 @@ def test_metar_gate_vetoes_yes_when_observed_breaks_above_bucket():
assert row["ai_decision"] == "veto"
assert "越过目标桶上沿" in row["ai_reason_zh"]
-6
View File
@@ -6,7 +6,6 @@ from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from web.services.scan_api import (
get_scan_terminal_ai_payload,
get_scan_terminal_overview_payload,
get_scan_terminal_payload,
)
@@ -54,11 +53,6 @@ async def scan_terminal(
)
@router.post("/api/scan/terminal/ai")
async def scan_terminal_ai(request: Request):
return await get_scan_terminal_ai_payload(request)
@router.post("/api/scan/terminal/overview")
async def scan_terminal_overview(request: Request):
return await get_scan_terminal_overview_payload(request)
+1 -1
View File
@@ -12,7 +12,7 @@ from loguru import logger
from web.analysis_service import _analyze
from web.core import CITIES
from web.services.scan_ai_config import (
from web.services.scan_terminal_config import (
SCAN_TERMINAL_BUILD_TIMEOUT_SEC,
SCAN_TERMINAL_MAX_WORKERS,
SCAN_TERMINAL_PAYLOAD_TTL_SEC,
+1 -44
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import os
import time
from datetime import datetime
from typing import Any, Dict, Iterator, Optional
from typing import Any, Dict, Optional
from fastapi import APIRouter, BackgroundTasks, HTTPException
from loguru import logger
@@ -59,49 +59,6 @@ router = APIRouter()
_CACHE_DB = DBManager()
def build_scan_terminal_ai_payload(
raw_filters: Optional[Dict[str, Any]] = None,
*,
snapshot_id: Optional[str] = None,
) -> Dict[str, Any]:
return {
"available": False,
"status": "disabled",
"reason": "scan AI has been removed",
"snapshot_id": snapshot_id,
"rows": [],
}
def build_scan_city_ai_forecast_payload(
city: str,
*,
force_refresh: bool = False,
locale: str = "zh-CN",
) -> Dict[str, Any]:
return {
"available": False,
"status": "disabled",
"reason": "city AI has been removed",
"city": city,
"locale": locale,
"force_refresh": force_refresh,
}
def stream_scan_city_ai_forecast_payload(
city: str,
*,
force_refresh: bool = False,
locale: str = "zh-CN",
) -> Iterator[str]:
payload = build_scan_city_ai_forecast_payload(
city,
force_refresh=force_refresh,
locale=locale,
)
yield f"data: {payload}\n\n"
_DEB_RECENT_LOOKBACK = 7
_DEB_RECENT_MIN_SAMPLES = 3
_daily_record_repo = DailyRecordRepository()
-153
View File
@@ -1,153 +0,0 @@
"""Scan terminal and AI configuration constants.
Extracted from scan_terminal_service.py to keep the module leaner.
Re-exported from the original module for backward compatibility.
"""
from __future__ import annotations
import os
import threading
from typing import Any, Dict, Optional
from src.utils.refresh_policy import SCAN_ROWS_REFRESH_SEC
_SCAN_CITY_AI_CACHE_LOCK = threading.Lock()
_SCAN_CITY_AI_CACHE: Dict[str, Dict[str, Any]] = {}
def _env_int(
name: str,
default: int,
*,
min_value: int,
max_value: Optional[int] = None,
) -> int:
try:
value = int(os.getenv(name, str(default)))
except Exception:
value = int(default)
value = max(int(min_value), value)
if max_value is not None:
value = min(int(max_value), value)
return value
SCAN_TERMINAL_PAYLOAD_TTL_SEC = min(
SCAN_ROWS_REFRESH_SEC,
max(10, int(os.getenv("POLYWEATHER_SCAN_TERMINAL_PAYLOAD_TTL_SEC", str(SCAN_ROWS_REFRESH_SEC)))),
)
SCAN_TERMINAL_BUILD_TIMEOUT_SEC = max(
8,
int(os.getenv("POLYWEATHER_SCAN_TERMINAL_BUILD_TIMEOUT_SEC", "120")),
)
SCAN_TERMINAL_MAX_WORKERS = _env_int(
"POLYWEATHER_SCAN_TERMINAL_MAX_WORKERS",
8,
min_value=1,
max_value=12,
)
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"}
SCAN_AI_TIMEOUT_SEC = _env_int(
"POLYWEATHER_SCAN_AI_TIMEOUT_SEC",
40,
min_value=10,
max_value=120,
)
SCAN_CITY_AI_TIMEOUT_SEC = _env_int(
"POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC",
30,
min_value=10,
max_value=120,
)
SCAN_CITY_AI_RETRY_ON_STREAM_PARSE_ERROR = str(
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,
int(os.getenv("POLYWEATHER_SCAN_AI_CACHE_TTL_SEC", "3600")),
)
SCAN_AI_MAX_ROWS = _env_int("POLYWEATHER_SCAN_AI_MAX_ROWS", 40, min_value=1)
SCAN_AI_MAX_TOKENS = _env_int(
"POLYWEATHER_SCAN_AI_MAX_TOKENS",
3200,
min_value=600,
max_value=64000,
)
SCAN_CITY_AI_MAX_TOKENS = _env_int(
"POLYWEATHER_SCAN_CITY_AI_MAX_TOKENS",
800,
min_value=400,
max_value=64000,
)
SCAN_CITY_AI_STREAM_MAX_TOKENS = _env_int(
"POLYWEATHER_SCAN_CITY_AI_STREAM_MAX_TOKENS",
min(SCAN_CITY_AI_MAX_TOKENS, 800),
min_value=400,
max_value=64000,
)
+1 -34
View File
@@ -4,33 +4,12 @@ from __future__ import annotations
from typing import Any, Dict
from fastapi import HTTPException, Request
from fastapi import Request
from fastapi.concurrency import run_in_threadpool
import web.routes as legacy_routes
def _boolish(value: Any) -> bool:
return str(value or "false").lower() in {"1", "true", "yes", "on"}
async def _json_body_or_empty(request: Request) -> Dict[str, Any]:
try:
body = await request.json()
except Exception:
body = {}
if not isinstance(body, dict):
raise HTTPException(status_code=400, detail="Invalid JSON body")
return body
def _extract_required_city(body: Dict[str, Any]) -> str:
city = str(body.get("city") or "").strip()
if not city:
raise HTTPException(status_code=400, detail="city is required")
return city
async def get_scan_terminal_payload(
request: Request,
*,
@@ -70,17 +49,5 @@ async def get_scan_terminal_payload(
)
async def get_scan_terminal_ai_payload(request: Request) -> Dict[str, Any]:
legacy_routes._assert_entitlement(request)
body = await _json_body_or_empty(request)
filters = body.get("filters") if isinstance(body.get("filters"), dict) else {}
snapshot_id = str(body.get("snapshot_id") or "").strip() or None
return await run_in_threadpool(
legacy_routes.build_scan_terminal_ai_payload,
filters,
snapshot_id=snapshot_id,
)
async def get_scan_terminal_overview_payload(request: Request) -> Dict[str, Any]:
return {"overview": [], "available": False}
+41
View File
@@ -0,0 +1,41 @@
"""Scan terminal configuration constants."""
from __future__ import annotations
import os
from typing import Optional
from src.utils.refresh_policy import SCAN_ROWS_REFRESH_SEC
def _env_int(
name: str,
default: int,
*,
min_value: int,
max_value: Optional[int] = None,
) -> int:
try:
value = int(os.getenv(name, str(default)))
except Exception:
value = int(default)
value = max(int(min_value), value)
if max_value is not None:
value = min(int(max_value), value)
return value
SCAN_TERMINAL_PAYLOAD_TTL_SEC = min(
SCAN_ROWS_REFRESH_SEC,
max(10, int(os.getenv("POLYWEATHER_SCAN_TERMINAL_PAYLOAD_TTL_SEC", str(SCAN_ROWS_REFRESH_SEC)))),
)
SCAN_TERMINAL_BUILD_TIMEOUT_SEC = max(
8,
int(os.getenv("POLYWEATHER_SCAN_TERMINAL_BUILD_TIMEOUT_SEC", "120")),
)
SCAN_TERMINAL_MAX_WORKERS = _env_int(
"POLYWEATHER_SCAN_TERMINAL_MAX_WORKERS",
8,
min_value=1,
max_value=12,
)