feat: implement scan terminal dashboard system and supporting services
This commit is contained in:
@@ -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 结果。
|
||||
|
||||
@@ -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,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<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 的城市决策卡现在承担“从天气分析到市场动作解
|
||||
**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 是付费产品,必须先做法务与授权评审。缓解:只在研究环境评估;商用优先选择可商用权重/购买授权/自研。
|
||||
|
||||
@@ -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,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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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."] },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -169,6 +169,5 @@ export const config = {
|
||||
"/api/payments/:path*",
|
||||
"/api/system/:path*",
|
||||
"/api/city/:path*/detail:path*",
|
||||
"/api/scan/terminal/ai:path*",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ""
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user