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