From 5d784f78b9d4acf598e16ba3e4737de824b55a02 Mon Sep 17 00:00:00 2001
From: "2569718930@qq.com" <2569718930@qq.com>
Date: Thu, 28 May 2026 08:24:50 +0800
Subject: [PATCH] feat: implement scan terminal dashboard system and supporting
services
---
docs/API_ZH.md | 36 --
docs/CONFIGURATION_ZH.md | 8 -
docs/deep-research-report.md | 26 +-
docs/frontend-ui-design-review.md | 4 +-
docs/product-review-jun-2026.md | 8 +-
frontend/README.md | 3 +-
frontend/app/api/scan/terminal/ai/route.ts | 75 -----
.../dashboard/ScanTerminal.module.css | 90 ++---
.../dashboard/ScanTerminalBoard.module.css | 12 +-
.../dashboard/ScanTerminalCard.module.css | 160 ++++-----
.../dashboard/ScanTerminalList.module.css | 16 +-
.../dashboard/ScanTerminalMobile.module.css | 36 +-
frontend/content/docs/docs.ts | 34 +-
frontend/lib/dashboard-types.ts | 63 ----
frontend/lib/i18n.ts | 20 +-
frontend/middleware.ts | 1 -
src/analysis/city_query_service.py | 19 --
src/analysis/metar_narrator.py | 307 ------------------
src/utils/daily_weather_report.py | 9 +-
tests/test_refresh_policy.py | 4 +-
tests/test_scan_terminal_modules.py | 11 +-
web/routers/scan.py | 6 -
web/scan_terminal_service.py | 2 +-
web/services/city_runtime.py | 45 +--
web/services/scan_ai_config.py | 153 ---------
web/services/scan_api.py | 35 +-
web/services/scan_terminal_config.py | 41 +++
27 files changed, 263 insertions(+), 961 deletions(-)
delete mode 100644 frontend/app/api/scan/terminal/ai/route.ts
delete mode 100644 src/analysis/metar_narrator.py
delete mode 100644 web/services/scan_ai_config.py
create mode 100644 web/services/scan_terminal_config.py
diff --git a/docs/API_ZH.md b/docs/API_ZH.md
index 3df65161..30aefc1d 100644
--- a/docs/API_ZH.md
+++ b/docs/API_ZH.md
@@ -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 结果。
diff --git a/docs/CONFIGURATION_ZH.md b/docs/CONFIGURATION_ZH.md
index a4248ecd..c327cd47 100644
--- a/docs/CONFIGURATION_ZH.md
+++ b/docs/CONFIGURATION_ZH.md
@@ -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 分钟)。
说明:
diff --git a/docs/deep-research-report.md b/docs/deep-research-report.md
index 0f4bbc37..4d495c98 100644
--- a/docs/deep-research-report.md
+++ b/docs/deep-research-report.md
@@ -13,7 +13,7 @@ PolyWeather 的目标与范围在 README/README_ZH 中定义得较清楚:为
**分析层(DEB/趋势/概率/结算口径)**:
DEB(Dynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加权,输出融合预报;运行态仍维护近 14 天 `daily_records` 缓存做当前对账,但长期监督真值与训练特征已经迁到 SQLite 永久表中,并支持基于 WU(Weather 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 @@ DEB(Dynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加
从 README、Docker/Compose、入口脚本与核心模块引用关系,可以抽象出如下模块地图(按“运行时组件”与“Python 域模块”两层描述):
| 层级 | 目录/文件 | 角色定位 | 关键说明 |
| ------------- | ------------------------------------------------------------------------ | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| 运行时组件 | `frontend/` | Next.js 前端(Vercel) | 前端重构报告提到 App Router、Route Handlers(BFF)、缓存策略、支付与账户中心等;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 Handlers(BFF)、缓存策略、支付与账户中心等;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
web/app.py]
- LLM[City AI stream
OpenAI-compatible provider]
+ LLM[City structured detail
OpenAI-compatible provider]
end
subgraph Data
@@ -118,8 +118,8 @@ JSON[Legacy JSON files
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=`、`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 的城市决策卡现在承担“从天气分析到市场动作解
**DEB(Dynamic 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 文件路径主要是显式回退入口,而不再是默认主输入。
**第三方服务合规与稳定性风险**:
-项目强依赖外部 API(Open-Meteo、AviationWeather、global.amo.go.kr AMOS、NWS、HKO、CWA、Supabase)以及城市 AI provider(OpenAI-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。
+项目强依赖外部 API(Open-Meteo、AviationWeather、global.amo.go.kr AMOS、NWS、HKO、CWA、Supabase)以及城市结构化分析 provider(OpenAI-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 更新**:Polymarket(Gamma/CLOB)API 依赖已随市场价格拉取层一并移除。
**许可证/商业使用的潜在冲突点**:仓库自身现为 `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 是付费产品,必须先做法务与授权评审。缓解:只在研究环境评估;商用优先选择可商用权重/购买授权/自研。
diff --git a/docs/frontend-ui-design-review.md b/docs/frontend-ui-design-review.md
index c8a0d097..97efee8d 100644
--- a/docs/frontend-ui-design-review.md
+++ b/docs/frontend-ui-design-review.md
@@ -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 的色彩编码缺少对应的文字标签或图标补充
diff --git a/docs/product-review-jun-2026.md b/docs/product-review-jun-2026.md
index 0003f243..57d5eccf 100644
--- a/docs/product-review-jun-2026.md
+++ b/docs/product-review-jun-2026.md
@@ -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" 告诉用户下一步做什么
diff --git a/frontend/README.md b/frontend/README.md
index e321e5f1..eec8aa94 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -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
diff --git a/frontend/app/api/scan/terminal/ai/route.ts b/frontend/app/api/scan/terminal/ai/route.ts
deleted file mode 100644
index 847537de..00000000
--- a/frontend/app/api/scan/terminal/ai/route.ts
+++ /dev/null
@@ -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> | 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);
- }
-}
diff --git a/frontend/components/dashboard/ScanTerminal.module.css b/frontend/components/dashboard/ScanTerminal.module.css
index 06836132..7fab81cd 100644
--- a/frontend/components/dashboard/ScanTerminal.module.css
+++ b/frontend/components/dashboard/ScanTerminal.module.css
@@ -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);
}
diff --git a/frontend/components/dashboard/ScanTerminalBoard.module.css b/frontend/components/dashboard/ScanTerminalBoard.module.css
index 2669dfd3..f6a83703 100644
--- a/frontend/components/dashboard/ScanTerminalBoard.module.css
+++ b/frontend/components/dashboard/ScanTerminalBoard.module.css
@@ -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;
diff --git a/frontend/components/dashboard/ScanTerminalCard.module.css b/frontend/components/dashboard/ScanTerminalCard.module.css
index 2b6d4a27..eced7870 100644
--- a/frontend/components/dashboard/ScanTerminalCard.module.css
+++ b/frontend/components/dashboard/ScanTerminalCard.module.css
@@ -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;
}
diff --git a/frontend/components/dashboard/ScanTerminalList.module.css b/frontend/components/dashboard/ScanTerminalList.module.css
index 98991636..0b16f44d 100644
--- a/frontend/components/dashboard/ScanTerminalList.module.css
+++ b/frontend/components/dashboard/ScanTerminalList.module.css
@@ -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);
diff --git a/frontend/components/dashboard/ScanTerminalMobile.module.css b/frontend/components/dashboard/ScanTerminalMobile.module.css
index 5e688be3..1dfbee98 100644
--- a/frontend/components/dashboard/ScanTerminalMobile.module.css
+++ b/frontend/components/dashboard/ScanTerminalMobile.module.css
@@ -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;
}
diff --git a/frontend/content/docs/docs.ts b/frontend/content/docs/docs.ts
index 4ac31e11..41c885fb 100644
--- a/frontend/content/docs/docs.ts
+++ b/frontend/content/docs/docs.ts
@@ -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."] },
],
},
],
diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts
index 540d66bb..8c4d0cc4 100644
--- a/frontend/lib/dashboard-types.ts
+++ b/frontend/lib/dashboard-types.ts
@@ -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 {
diff --git a/frontend/lib/i18n.ts b/frontend/lib/i18n.ts
index 1ad463e6..6cb7a72e 100644
--- a/frontend/lib/i18n.ts
+++ b/frontend/lib/i18n.ts
@@ -96,12 +96,12 @@ const MESSAGES: Record> = {
"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> = {
"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> = {
"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> = {
"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",
diff --git a/frontend/middleware.ts b/frontend/middleware.ts
index 2b127101..f5aac208 100644
--- a/frontend/middleware.ts
+++ b/frontend/middleware.ts
@@ -169,6 +169,5 @@ export const config = {
"/api/payments/:path*",
"/api/system/:path*",
"/api/city/:path*/detail:path*",
- "/api/scan/terminal/ai:path*",
],
};
diff --git a/src/analysis/city_query_service.py b/src/analysis/city_query_service.py
index f42edf34..af7a84bd 100644
--- a/src/analysis/city_query_service.py
+++ b/src/analysis/city_query_service.py
@@ -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🛰️ 机场报文解读:")
- msg_lines.append(metar_narrative)
-
msg_lines.append(f"\n💸 本次消耗 {city_query_cost} 积分。")
return "\n".join(msg_lines)
diff --git a/src/analysis/metar_narrator.py b/src/analysis/metar_narrator.py
deleted file mode 100644
index 3228f80d..00000000
--- a/src/analysis/metar_narrator.py
+++ /dev/null
@@ -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 ""
diff --git a/src/utils/daily_weather_report.py b/src/utils/daily_weather_report.py
index b6f940a9..1a58e902 100644
--- a/src/utils/daily_weather_report.py
+++ b/src/utils/daily_weather_report.py
@@ -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")
diff --git a/tests/test_refresh_policy.py b/tests/test_refresh_policy.py
index 0fa6c3bd..d81807b3 100644
--- a/tests/test_refresh_policy.py
+++ b/tests/test_refresh_policy.py
@@ -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
diff --git a/tests/test_scan_terminal_modules.py b/tests/test_scan_terminal_modules.py
index 92fb0b5a..3cf15b9b 100644
--- a/tests/test_scan_terminal_modules.py
+++ b/tests/test_scan_terminal_modules.py
@@ -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"]
-
diff --git a/web/routers/scan.py b/web/routers/scan.py
index 5ef3141d..a9ea7ff5 100644
--- a/web/routers/scan.py
+++ b/web/routers/scan.py
@@ -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)
diff --git a/web/scan_terminal_service.py b/web/scan_terminal_service.py
index 56716104..de8361e7 100644
--- a/web/scan_terminal_service.py
+++ b/web/scan_terminal_service.py
@@ -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,
diff --git a/web/services/city_runtime.py b/web/services/city_runtime.py
index a12b8605..f7765cfc 100644
--- a/web/services/city_runtime.py
+++ b/web/services/city_runtime.py
@@ -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()
diff --git a/web/services/scan_ai_config.py b/web/services/scan_ai_config.py
deleted file mode 100644
index a897ebfc..00000000
--- a/web/services/scan_ai_config.py
+++ /dev/null
@@ -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,
-)
diff --git a/web/services/scan_api.py b/web/services/scan_api.py
index c100f467..446be67f 100644
--- a/web/services/scan_api.py
+++ b/web/services/scan_api.py
@@ -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}
diff --git a/web/services/scan_terminal_config.py b/web/services/scan_terminal_config.py
new file mode 100644
index 00000000..fde058da
--- /dev/null
+++ b/web/services/scan_terminal_config.py
@@ -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,
+)