diff --git a/VERSION b/VERSION index 80d81e4e..69e7dc3e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.74.5 +0.74.6 diff --git a/aiprovider/Dockerfile b/aiprovider/Dockerfile index b0693425..36068fc0 100644 --- a/aiprovider/Dockerfile +++ b/aiprovider/Dockerfile @@ -1,4 +1,4 @@ -# syntax=docker/dockerfile:1.7 +# Use BuildKit's bundled frontend to avoid a separate Docker Hub fetch. ARG PYTHON_IMAGE=python:3.14-slim ARG UV_IMAGE=ghcr.io/astral-sh/uv:latest diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6ad701b5..fa3af824 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,23 @@ This project follows the repository versioning rule: - `improvement` -> `+0.0.1`(bugfix + 小功能混合) - `bugfix` -> `+0.0.1` +## [0.74.6] — 2026-09-16 + +Released: 2026-09-16 + +### Highlights +- AI Provider 构建前自动检测主机代理和直连路径,减少终端网络正常但 Docker 镜像拉取超时的问题。 +- 运维错误原因与脚本诊断共用一份对照表,统一错误编号、原因和处理建议。 + +### Added / Fixed / Improved +- 已有 Compose v2 时不再因构建或启动失败回退 v1;Dockerfile 改用 BuildKit 内置解析器,减少一次外部镜像下载。 +- 新增 --no-build 启动选项,明确复用本地 AI Provider 镜像,缺少镜像时报错且不改写构建指纹。 +- 自动代理检测尊重 NO_PROXY,仅修改 Planet 管理的本地 Docker 配置;更新前备份和校验,必要时重启并恢复原有容器,失败时回滚。 +- 中英文运维表覆盖网络、证书、权限、限流和启动故障;未知错误明确保留未归类状态,并要求新确认原因补表与回归用例。 +- 修复详细日志模式丢失构建失败退出码的问题,补齐代理切换、Compose 选择、错误分类及文档一致性验证,并收录算力与资源态势实施计划。 + +--- + ## [0.74.5] — 2026-09-13 Released: 2026-09-13 diff --git a/docs/HARNESS.md b/docs/HARNESS.md index a813e322..54dd52ed 100644 --- a/docs/HARNESS.md +++ b/docs/HARNESS.md @@ -86,7 +86,7 @@ git diff --unified=0 HEAD -- | Backend Rules | `scripts/harness/backend-rules-check.sh` | Checks backend app Python for direct `print()`, `breakpoint()`, and `pdb.set_trace()` debug calls so service code uses structured logging. | | Frontend Rules | `scripts/harness/frontend-rules-check.sh` | Checks Bun-only scripts, admin route manifest coherence, literal internal route links, admin search route targets, frontend debug output, native button safety, icon-button accessibility, no nested Cards, no AntD/Space layout primitives, ConnectionTestInput usage, admin/docs shell height-chain sizing, same-category style owner warnings, viewport-scaled font sizes, zero letter spacing, and high-signal UI rule warnings. | | Docs Consistency | `scripts/harness/docs-consistency-check.sh` | Checks frontend Docs metadata against backend Gatekeeper metadata, public Docs registration, full technical-doc bilingual file pairs, public doc links, readable link titles, language-scoped technical links, README/project-context admin stack drift, supported credential collector contracts, manual console route coverage against the actual admin manifest, documented UI route drift, documented `?section=` deep-link validity against the actual admin section config in technical docs and active plan docs, and the harness rules-coverage notes. | -| Quick | `scripts/harness/quick-check.sh` | Runs doctor, whitespace diff check, shell syntax checks, security scan, backend/frontend/doc consistency checks, and CI backend smoke tests. | +| Quick | `scripts/harness/quick-check.sh` | Runs doctor, whitespace diff check, shell syntax checks, isolated Docker bootstrap/proxy and startup tests, error-catalog consistency tests, security scan, backend/frontend/doc consistency checks, and CI backend smoke tests. | | Full | `scripts/harness/validate.sh` | Runs quick check, frontend Bun install/build, Playwright route smoke, optional Helm checks, and opt-in Docker image smoke builds. | Docker image smoke builds are expensive and are off by default: diff --git a/docs/plans/README.md b/docs/plans/README.md index eca6d505..191f6a5d 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -22,6 +22,7 @@ 当前重点入口: +- [算力与资源态势:展示、采集与 AI 迭代实施计划](compute-resource-intelligence-plan.md) - [控制台 i18n 接入计划](/home/ray/dev/linkong/planet/docs/plans/admin-console-i18n-plan.md) - [Earth Mobile Drawer UI Plan](/home/ray/dev/linkong/planet/docs/plans/earth-mobile-drawer-ui-plan.md) - [Earth Compute Center BGP Style Plan](/home/ray/dev/linkong/planet/docs/plans/earth-compute-center-bgp-style-plan.md) diff --git a/docs/plans/compute-resource-intelligence-plan.md b/docs/plans/compute-resource-intelligence-plan.md new file mode 100644 index 00000000..33fd9ce5 --- /dev/null +++ b/docs/plans/compute-resource-intelligence-plan.md @@ -0,0 +1,592 @@ +# 算力与资源态势:展示、采集与 AI 迭代实施计划 + +状态:待实施。2026-09-14 已完成当前代码边界、主要公开数据入口和历史指标口径核查;本计划不代表采集器、评分服务或 AI 迭代已经上线。面向产品、开发、数据研究与运维人员,作为此专题后续实现的设计依据。 + +## 0. 当前执行方向与范围 + +平台长期定位是“信息展示 + 决策辅助”。当前实施重点是把全球算力对比作为第一个成熟专题做清楚:地图显示资源在哪里,图表说明规模和相对位置怎样变化,证据说明数字从哪里来,AI 帮助阅读变化。 + +采用“专题先交付,公共能力按复用需要沉淀”的顺序。第 7—9 节的完整 AI 迭代、模型实验与通用持久化是后续路线,不是首版展示的前置条件;第 6 节总分保持实验性,不能为了首屏有大数字而生成不完整排名。 + +| 当前要做 | 当前完成形态 | 后续才扩展 | +| --- | --- | --- | +| 算力数据可信 | 修复既有采集,国家/设施/精度/年份不混用 | 大规模自动来源发现、完整证据图谱 | +| 全球分布与国家对比 | 现有算力中心地球 + 可比参与方 + 明确样本覆盖 | 制造、矿产、电力和贸易的跨主题关系 | +| 历史展示 | 两张核对后的图、时间选择、已知事件轨道 | 复杂预测、不确定性传播与可训练参数 | +| 证据与解释 | 来源、参考年、关键字段;基于数据快照的简短 AI 说明 | 自动补证、模型候选、回测、影子运行与提升 | + +首版验收路径:打开算力专题 → 看全球公开设施分布 → 选中国和美国 → 同时看算力与创新历史 → 点一个变化或设施 → 查看出处及解释。此路径稳定后,才扩大评价维度。 + +## 1. 要交付什么 + +在智能星球现有算力中心图层上增加“算力与资源态势”模式,并在控制台提供对应的数据管理和模型实验工作区。核心问题是:各方拥有什么资源、资源如何形成可用能力、相对位置如何变化、变化有何证据,以及未来什么约束可能改变格局。 + +首批比较中国和美国;数据结构从第一天支持多方。随后纳入欧洲、日本、韩国、印度及其他数据合格的参与方。地区汇总与成员国不得重复计入全球分母。所有国家和地区字段使用现有 `normalize_country()` 和规范字典,不以网页原文或 AI 判断覆盖项目标签。 + +交付三个相互连接的产品能力: + +| 能力 | 用户获得的结果 | 首版边界 | +| --- | --- | --- | +| 历史观测 | 算力绝对规模、全球份额、创新指数与分差、设施时间线 | 使用可验证观测;缺失年份可见,不插值冒充事实 | +| 变化解释 | 哪些设施、资源、数据修订造成指标变化,每项贡献和证据 | 数学贡献可复算;原因解释区分事实、假设与未解问题 | +| 模型改进 | AI 发现缺口、补证、提出改进,经过验证产生新版本 | 固定工作流先行;不能由大模型直接决定国家分数 | + +第一版先交付可信的事实与回放,不以总分完整作为上线前提。完整创新指数分项、有效算力参数和项目交付数据不足时,继续展示已完成的观测能力,综合评分显示“数据不足”,不生成示意国家排名。 + +不在第一版承诺全球所有设施的完整清单、精确的国家真实算力、每日变化的国家创新能力、可验证的国家综合实力真值,或自动预测某年必然追平。 + +## 2. 当前项目基础与必须补齐的边界 + +以下结论来自当前文件检查,未对生产数据库、凭证可用性或线上采集结果做完整审计。 + +| 已有入口 | 已确认能力 | 本专题需要补齐 | +| --- | --- | --- | +| [采集基类](../../backend/app/services/collectors/base.py)、[数据作业](../../backend/app/services/data_jobs.py) | 采集、转换、任务状态、快照、取消与重试基础 | 文档型数据源、质量门槛、领域规范化、失败保留正式快照 | +| [Epoch 采集器](../../backend/app/services/collectors/epoch_ai.py) | GPU 集群采集入口 | 改读公开 CSV;去掉解析失败返回示例记录;单位、精度、状态和时间字段校正 | +| [TOP500 采集器](../../backend/app/services/collectors/top500.py) | 榜单与详情解析 | 去掉示例回退、固定参考日期、排名作为身份;按当期表头解析单位 | +| [采集记录](../../backend/app/models/collected_data.py)、[快照](../../backend/app/models/data_snapshot.py) | `entity_key`、快照关联、前一记录和变化摘要 | 原始证据版本、发表时间、有效时间、指标注册和可重放的观测集合 | +| [算力位置服务](../../backend/app/services/compute_center_locations.py)、[GeoJSON](../../backend/app/api/v1/visualization.py) | 设施定位、位置置信信息、未定位记录 | 国家统计与地图可定位集合分离;匿名设施不能猜坐标 | +| [算力中心渲染](../../frontend/public/earth/js/compute-centers.js)、[Earth 壳](../../frontend/src/pages/Earth/Earth.tsx) | Three.js 交互层;Earth 由 iframe 加载独立页面 | 历史时点、国家比较、双图、关系层和解释面板 | +| [图层适配器](../../backend/app/services/earth_layer_adapters.py)、[数据库监听](../../backend/app/services/earth_db_change_listener.py) | 通过 PostgreSQL outbox 驱动缓存与 `earth_updates` | 正式指标快照发布事件;草稿与实验结果不改变默认地球 | +| [AI Client](../../backend/app/services/ai_client.py)、[提示词注册](../../backend/app/ai_tasks/prompts.py) | 全局 provider 配置、模型调用、任务提示词及运维覆盖 | 算力专题的固定工作流、结构化输出验证、预算与审计 | +| [搜索工具](../../backend/app/services/ai_tools/web_search.py)、[网页取证](../../backend/app/services/ai_tools/web_fetch.py) | 搜索和基础 HTML 正文提取 | PDF/表格提取、引用位置、下载时大小限制、URL 与重定向的访问限制 | +| [证据辅助函数](../../backend/app/services/ai_tools/evidence_store.py) | 摘要、去重和内容哈希规范化 | 该文件不是持久化证据库,需补建不可变原文存储和证据记录 | + +沿用[数据作业与 Outbox 架构](../technical/zh/data-job-earth-sync-architecture.md)及[轻量 Agent 编排计划](agents-light-orchestrator-websearch-plan.md):PostgreSQL 负责可靠账本,Redis 负责缓存;不另起 Kafka、Celery 或另一个独立调度真相源。`aiprovider` 保持协议适配,业务取证、估计、工具权限和模型发布全部由后端负责。 + +当前 `JobType` 只有采集、清理和 Earth 刷新。新增分析、实验工作流需要显式扩展任务类型、worker 分发和取消策略,不能把现有采集队列描述成已经支持通用 Agent。 + +## 3. 页面与地球怎样展示 + +### 3.1 默认工作区 + +保留 `/earth` 主入口,在现有算力中心工具中增加专题模式,避免增加一个与地球无关联的新大屏。进入后使用以下概念布局;这是规划线框,不是现有页面截图。 + +```text +┌ 时间/参考期 · 中国/美国/其他参与方 · 指标口径 · 观测/情景 ┐ +│ │ +│ 3D 地球:设施、资源、已证实关系 国家对比面板 │ +│ 绝对量/份额 │ +│ 变化贡献 │ +│ 证据与缺口 │ +├────────────── 历史时间轴 / 事件轨道 ────────────────────┤ +│ 算力规模与份额图 综合创新指数与分差图 │ +└───────────────────────────────────────────────────────┘ +``` + +桌面默认同时显示两张历史图,用相同国家配色,但各自保留单位和实际观测年份。图表高度优先保证刻度、来源标识与数据点可读,不能通过压缩字体维持布局。较窄屏幕把图表移入可切换的详情区,国家和设施详情使用抽屉;不叠加多个遮挡地图的浮层。 + +沿用项目一屏高度链和主题。桌面在 1366×768、1920×1080,移动端在约 390×844 验证,另测 125%/150% 浏览器缩放。页面根与中间 flex 容器分别正确设置高度和 `min-height: 0`,仅详情、长表和证据正文内部滚动。 + +### 3.2 视图职责 + +| 视图 | 主内容 | 点击或切换后的行为 | +| --- | --- | --- | +| 国家总览 | 可比算力绝对量、份额、子分、实际参考期;总分合格才出现 | 选中参与方,定位地球并联动两图;不重设全局评分尺度 | +| 算力趋势 | 绝对规模、全球份额、双边份额三种独立视图 | 同一来源口径内切换;不把百分比轴和 FLOP/s 轴混合 | +| 创新趋势 | 各方指数;只有分差时只显示中美分差 | 切换年度,查看分项与报告版本,不能反推缺失的单国得分 | +| 变化账单 | 新投产、扩建、退役、效率变化、资料补录、方法修订 | 点选贡献定位资产;逐步展示输入、计算和证据 | +| 设施详情 | 设备、精度、投产状态、功率、运营方、地点精度、历史版本 | 展开相关公告、设备来源及不确定字段 | +| 资源关系 | 已证实的芯片供给、所有权、电力接入或网络连接 | 选一类关系再显示,不能让所有关系默认铺满地球 | +| 情景工作区 | 并网推迟、交付变化、效率假设及结果区间 | 独立 scenario ID;退出恢复正式快照 | +| 控制台研究区 | 数据缺口、待核验证据、采集任务、模型实验和版本 | 使用已有采集与 AI 设置入口;Playground 仍用于调试 | + +### 3.3 两张图进入产品的明确规则 + +总算力图以核对后的报告观测为起点:2020 年美国/中国为 36%/31%,2021 年 34%/33%,2022 年 34%/33%,2023 年 41%/31%。原始来源和报告版本见第 15 节。2024—2025 年暂不填值;2026 年 42%/33% 只保存为待核实主张,不能接到正式历史曲线上。 + +创新图使用报道明确披露的 2023、2024、2025 年分差 22.02、19.96、17.95 分。报告发表于 2026 年,必须区分报告年份与参考年份。完整分项、年度得分和方法修订仍需采集。图中是综合创新指数,不是纯算力或纯产出。 + +原始主张、报告估计、已验证记录和未来情景用文字标签区分。年度点之间的视觉连线只辅助阅读,不进入计算;不能把连线插值写成月度实测。正式指标没有新观测时保留参考期并提示新鲜度,不让刷新网页造成“国家实力实时跳动”。 + +默认“历史回顾”允许查看最新证据重述后的过去;“当时可知”只使用在截止时点已经发表且可证明可获得的资料。每张图同时展示参考期与知识截止时间。已选 2025 年而指标最新只到 2023 年时,必须显示“最新观测:2023”,或在严格同年模式显示缺失。 + +### 3.4 地球编码与交互 + +- 节点面积按同口径能力编码;跨度过大时可切换对数档位并明示图例。国家颜色只用于身份,不能把颜色本身当作领先或落后。 +- 已投产用实心,建设或情景用空心/虚线;置信不足使用独立标记,不能把低置信透明度误读为小算力。 +- 来源只到国家或区域时,保留在国家/区域统计和未定位列表;不画在首都,不把行政区域中心当作设施坐标。 +- 国家总量与可见设施合计可以不同,分别显示覆盖范围。不得只统计有坐标的节点,也不得把宏观缺口按现有节点比例摊分。 +- Epoch 当前公开版本对中国部分集群匿名化并取整;匿名记录只能按允许的粒度使用。新增官方公告可作为独立证据,不能依据匿名数值猜出设施身份或制造地点。 +- 供应、所有权和网络关系采用不同图例,显示关系来源与有效期;商业供应关系不等同于实际运输路线。 +- 全国可汇总吞吐与最大单集群能力分别展示;地理分散设备不能合并成一个训练集群。 +- 时间回放使用稳定实体 ID 更新已有节点。隐藏图层、变更时点或情景时同步清理 hover、locked、tooltip 和 selection;请求过期结果不得覆盖新时点。 +- 使用现有 Three.js Interactable、图标和实例化能力。新增壳层必须遵循现有深度顺序,在远距约 50% 缩放检查闪烁;不另建悬浮球面掩盖深度问题。 + +## 4. 数据需要补充采集什么 + +### 4.1 P0:决定首版可信度的数据 + +“已核对入口”只表示资料或文档可查,不表示接口凭证、完整历史和生产连通性已经验证。以下频率是 Planet 建议检查频率,不是来源承诺的更新频率。 + +| ID / 优先级 | 数据与来源 | 必需字段/粒度 | 采集方式与频率 | 当前缺口与采用条件 | +| --- | --- | --- | --- | --- | +| D01 / P0 | 信通院历年算力报告 | 国家×参考年×口径;绝对规模、全球总量/份额、单位、精度、估算方法、版本 | 已取得报告先受控导入;月度检查新版;PDF 表格提取后核验 | 已核对 2020—2023 份额及部分绝对量;补 2024 以后和其他国家;跨版本方法改变时断开序列 | +| D02 / P0 | Epoch GPU Clusters | 集群/版本;设备数、型号、16/8/32-bit 性能、所在地、状态、投产/退役时间、功率、扩建关系 | 官方公开 CSV,每日条件抓取,按内容哈希去重 | 改造现有 HTML 解析;处理匿名、取整、缺坐标和覆盖偏差;不能声称完整全国清单 | +| D03 / P0 | TOP500 / 可选 Green500 | 稳定系统 ID×榜单期;Rmax/Rpeak、FP64、功率、机构、名次、列表日期 | 每周检查新榜单;按发布期导入历史与详情 | 修复 rank 作为 source ID、固定日期和单位问题;榜单消失不等于退役;不当成 AI 总算力 | +| D04 / P0 | 全球 AI 创新指数原报告及发布材料 | 国家×参考年×指标;单国总分、分项、权重、标准化、成员范围、方法版本 | 文档导入+月度更新检查 | 当前只具备部分新闻转述分差;优先获得分项和年度可比性证据;不能用差值合成各国历史得分 | +| D05 / P0 | 已有位置维表与公开设施公告 | 实体别名、运营方、国家、地点、精度、采用依据、有效期 | 复用位置管线,新增/变化时触发 | 全国汇总独立于定位成败;不把同址不同分期误合并,也不把扩建当第二个全量集群 | + +首版最低闭环:D01 和 D04 的已验证历史可先支撑双图,D02/D03/D05 支撑设施地图;两者不互相伪装为同一覆盖范围。若 D04 分项不可取得,创新总分仍作为独立观测,O 分与综合 S 不发布。 + +首版可以先受控导入已核实的年度数据,不必先完成整套 PDF 自动解析和报告发现。D04 原报告分项继续作为补数任务,不阻塞已经核对的创新分差图;原始摘录、出处、年份和未核实范围必须随数据保存。 + +### 4.2 P1/P2:有效能力、增长与资源关系 + +| ID / 优先级 | 补充数据与候选来源 | 粒度与核心字段 | 方式/频率 | 对模型的作用及限制 | +| --- | --- | --- | --- | --- | +| D06 / P1 | Epoch 芯片销售、所有权、硬件数据;厂商规格 | 芯片型号×时间;规格、销售/交付、持有主体、数量、性能换算、估计区间 | 官方数据文件与规格文档;周查/季度快照 | 校验硬件供给;设计者、所有者、实际所在地与使用权分开;不能把出货和已部署重复加总 | +| D07 / P1 | MLPerf Training / Inference、可复现厂商测试 | 配置×任务×软件版本;模型、质量门槛、batch、上下文、时延、吞吐、能耗、可用状态 | 官方结果导入;月查新版本 | 校准特定任务的性能系数;不同 benchmark 版本/质量/负载不直连;实验室成绩不等同生产利用率 | +| D08 / P1 | IEA、电力统计机构、EIA、并网与运营方资料 | 国家/地区/项目×时点;发电、用电、接入 MW、PUE、并网日期、可用负荷 | 国家统计月/季;报告年;项目公告日查 | 电力作为设施约束;国家发电量、用电 TWh 不能直接转换机房可用 MW;中国与美国地方数据可得性不同 | +| D09 / P1 | 项目运营方、许可部门、官方采购和工程进展 | 项目×阶段;公告、开工、设备交付、并网、验收、部分投产、撤销、金额与范围 | 已注册来源每日检查,重要项目每周核验 | 建立投产与延期标签;最终失败项目也保留,避免只收集成功项目;单纯新闻未更新不判定失败 | +| D10 / P1 | 公司投资者关系、SEC EDGAR、境内及其他市场法定披露 | 法人/分部×财季;CapEx、已付款/指引、租赁、合同、地域/业务归属 | 官方 API/报告;日查新披露,按季归档 | 用于资金与项目约束;总 CapEx 不是 AI 投资;合并报表、融资租赁、设备与机房成本须防重复 | +| D11 / P1 | 创新报告分项、公开模型评测、OpenAlex;HF 作为辅助 | 机构/国家×年/月;研发成果、质量归一化产出、可比模型能力、应用指标 | 原报告年;评测周;研究元数据月 | 重建不含重复资源项的 O;合作成果使用明确分摊;不从作者姓名推断国籍;下载量不代表实际使用或产业产出 | +| D12 / P2 | 晶圆制造、先进封装、HBM、关键设备的正式披露 | 工厂/供应方×工艺/产品×季度;已投产能力、良率范围、交付、客户与依赖 | 公司/监管原件;月查、季度快照 | 建立瓶颈和替代关系;晶圆数不能无依据换成 GPU 数;总行业份额不能归给某集群 | +| D13 / P2 | USGS、各国统计、UN Comtrade | 商品×国家×年/月;产量、储量、加工量、贸易流、HS 版本和单位 | 年度报告、月/年贸易数据 | 扩展矿产与资源依赖;储量、开采和加工分开;宽泛服务器税号不能反推高端芯片数量 | +| D14 / P2 | 既有海缆、PeeringDB、BGP 与已证实连接资料 | 设施/网络/关系×有效期;容量声明、连接、依赖、故障 | 复用现有产品,按其真实更新节奏 | 提供连通性和韧性背景;BGP 可见性不是带宽,海缆容量不是集群内部互联性能 | + +EIA、SEC、Epoch、MLCommons、IEA、USGS 已核对资料入口;UN Comtrade 门户可见,但本次未验证可调用 API,接入要单独完成认证、额度、字段和历史测试。OpenAlex 需在接入时按最新访问规则配置,不假定无限制匿名访问。所有付费、登录和受限数据只列为可选增强,不作为首版隐藏前提。 + +### 4.3 首批采购与补数优先顺序 + +1. 先完成现有 Epoch/TOP500 的真实性与时间口径修复,避免新增来源扩大错误。 +2. 导入信通院历史、创新分差和原始出处;追索创新原报告分项及最新版总算力数据。 +3. 建立中美重点设施及项目阶段台账,并记录未知量。重点设施用于高影响解释,不冒充全国抽样无偏估计。 +4. 用官方规格与 MLPerf 建立少量明确工作负载的换算表;其余硬件维持理论值或范围。 +5. 补项目级并网、交付、资金与退役记录,积累可校准的真实标签。 +6. 最后扩展制造、HBM、矿产与多方关系;每个新主题必须有对应指标和展示消费者,不只收集而不使用。 + +## 5. 采集到正式数据的流水线 + +```mermaid +flowchart LR + A[注册的数据源] --> B[原文与内容哈希] + B --> C[确定性解析或 AI 提取候选] + C --> D[单位/时间/实体/引用校验] + D --> E[版本化有效观测] + D --> F[冲突与待核实队列] + E --> G[确定性指标计算] + G --> H[正式结果快照] + H --> I[Outbox 与缓存更新] + I --> J[地球/双图/变化账单] +``` + +### 5.1 数据源合同 + +每个数据源保存来源机构、域名与下载入口、文档类型、凭证配置引用、检查频率、预期粒度、单位与精度、更新时间语义、空结果语义、历史范围、使用许可、维护负责人和解析器版本。授权文件或数据保存在运行数据存储,不提交到仓库;公开 UI 只展示允许公开的摘录或链接。 + +API/CSV 优先;HTML/PDF 采用固定解析;必须用 AI 时先产生候选,核验后保存确定性映射。扫描件 OCR 必须保留页号、表格区域和原图,数值不能只靠模型复述。下载器在流式读取时限制实际字节量,不能下载完整文件后才截断;按域名限速,遵守来源规则。 + +### 5.2 事实键与时间 + +观测键至少包括实体、指标、单位、精度、工作负载、地理范围、归属口径、参考期及来源版本。历史至少区分: + +| 时间 | 含义 | +| --- | --- | +| `reference_start/end` 或 `effective_at` | 数据描述的年度、季度或设施事件发生时间 | +| `published_at` | 资料何时首次公开;不知道就保持未知 | +| `retrieved_at` | Planet 何时取得该版本 | +| `recorded_at` / `superseded_at` | 系统何时采用、替换该观测 | + +抓取日期不代替投产日期;报告年份不代替参考年份;机构估计、实测、公告、模型估计和人工情景分别标识。只有年份时保留年份精度,不能伪造 1 月 1 日投产;回放按时间范围或“当年”事件显示。 + +### 5.3 合并与质量门槛 + +- 稳定身份优先使用官方系统 ID、项目 ID、法人 ID 和可信来源链接。TOP500 排名只作为观测属性。自动实体合并必须可撤销,保存别名和合并理由。 +- 同一集群的全量扩建公告、一期/二期、重复转载与另一个数据集的同一设备只计一次。芯片交付和机房投产分别保留,不能合并为两个已投产资产。 +- 国家级统计、企业所有权统计、设施地理统计是不同视图。国家公司在海外租用算力不得同时作为两国境内部署能力;全球成员集合固定且版本化。 +- 转换单位前先核验浮点精度、稀疏/稠密、峰值/实测和时间量纲。FLOP 与 FLOP/s、TWh 与 MW 均不得互换。 +- 解析为空、字段突然缺失、总量异常下降或源结构改变,进入失败/隔离状态,保留上次正式结果。缺榜、缺行或匿名化不直接触发实体退役。 +- 多条转载归到同一 `source_family`;媒体数量不构成多份独立证据。冲突先检查口径,再保存多个主张及采用规则,不能简单取平均。 +- 缺坐标不影响已知国家统计;未知国家不强行分配。数据覆盖率可描述已登记实体或必填字段的完整率;无法知道总体时不声称“覆盖全球百分之多少”。 +- 一位有效数字等取整值保留取整范围;这是观测精度范围,不冒充统计置信区间。 + +首批历史导入先双人或规则加人工抽查核验高影响记录,保存一套回归样本。后续同版本确定性解析通过固定门槛可自动采用;新来源、新语义或无法解释的大幅变化进入候选队列。 + +## 6. 数学模型如何工作 + +### 6.1 分清事实、估计和评价 + +模型分三层:观测层保存来源说了什么;估计层回答可用能力、交付时间和不确定性;评价层按明确价值取向组合指标。国家综合实力没有一个可直接取得的标签真值,因此不能声称“AI 找到了客观最优的国家评分权重”。 + +保留 v2 的模型草案: + +\[ +S_{c,t}=0.40H_{c,t}+0.30O_{c,t}+0.20G_{c,t}+0.10R_{c,t} +\] + +H 是资源能力,O 是不重复的创新与应用表现,G 是扩张潜力,R 是韧性;四项均为 0—100 的版本化子分。S 称为“综合态势分”,包含未来潜力,不能称作已投产算力、胜率或国家实力百分比。权重是默认设计参数,需通过敏感性分析和评价目的评审后冻结。 + +### 6.2 资源能力 H 与两类算力模式 + +总算力模式使用 D01 的同口径绝对规模;AI 训练模式使用明确工作负载的有效能力;推理模式另选固定模型、质量、上下文与时延要求。不同模式不共用没有物理依据的换算,也不比较彼此总分。 + +\[ +C^{eff}_{c,t,w}=\sum_{j\in deployed(c,t)} C^{peak}_{j,t,p}\,u_{j,t,w} +\] + +`p` 是固定精度,`w` 是参考工作负载;`u` 由可比测试、已知运行约束或经校准的估计支持。没有依据时只发布理论能力,或者明示区间;不能统一假设某国芯片只有另一个国家的固定折扣。 + +内存、互联、软件和电力会影响同一个有效利用系数,不得分别随意乘一串折扣重复惩罚。若已知场地供电上限,可用场地总功率除以 PUE 约束 IT 负荷,再按同配置效率估计可运行容量;已在利用系数中计入的限电不再扣一次。总吞吐与可用于单次大训练的最大连续集群另列。 + +国家宏观估计和公开集群样本并列校验,不直接把二者相加。若 H 只能覆盖已观测设施,分数标题和参与方排名必须明确限定为这个样本,不外推为全国真实能力。 + +### 6.3 创新与应用 O + +完整创新指数 I 单独显示。取得 D04 分项后,建立指标归属表,逐一标注资源、扩张、韧性或创新;只把不重叠的研发质量、模型/科研成果与应用表现用于 O。不是简单执行“创新总分减去算力分”,因为不同分项的权重和标准化可能不同。 + +备用指标可来自 D11,但新增指标意味着新定义和新版本,不能偷偷替换原报告。论文数量须去重、处理合作分摊及引用年龄;模型结果要冻结评测版本;应用指标明确平台样本。专利、下载、融资和模型排行榜均不是彼此的等价替代。 + +\[ +D^I_t=I_{US,t}-I_{CN,t},\qquad v^I_t=(D^I_{t-1}-D^I_t)/\Delta years +\] + +2023—2025 年披露分差缩小 4.07 分,约为起点分差的 18.5%;不能解释为中国能力提高 18.5%,也不能据此推断算法效率提升。只有差值不能恢复各国绝对得分。跨年方法不一致时分段显示,不能外推追平时间。 + +### 6.4 扩张潜力 G + +\[ +G^{raw}_{c,t,h}=\sum_{j\in pipeline(c,t)}P(T_j\leq t+h\mid X_{j,\leq t})\,\Delta C^{eff}_j +\] + +默认 `h=12 个月`。X 只含当时可知的项目状态、设备交付、并网、建设和资金证据。分期投产拆分未交付增量;已投产部分进入 H 后从剩余 G 扣除。公告金额不直接转成已投产能力。 + +初期采用明确的保守/基准/乐观情景,没有历史标签就不提供伪精确概率。标签积累后可比较阶段条件概率、校准的逻辑回归或生存分析;处理尚未到期项目的右删失、取消与部分交付,不能把所有未完成记录标成失败。 + +### 6.5 韧性 R + +\[ +R_{c,t}=100\sum_s q_s\,\min(1,C^{eff}_{c,t,s}/C^{eff}_{c,t,base}) +\] + +s 是对各方一致定义的冲击情景,例如特定供应类别中断或并网延迟;q 为公开、固定的情景权重,除非有概率依据,否则不称为发生概率。基准能力为零或未知时不计算此比值。 + +关系图用于识别依赖、替代和共同故障源。没有替代来源证据就标为未知;政策公告通常改变未来供应或可用性假设,不自动删除境内现有芯片存量。 + +### 6.6 标准化与发布条件 + +数量型正向指标可采用固定基期映射: + +\[ +N(x;a,b)=100\,clip\left(\frac{\ln(1+x/a)}{\ln(1+b/a)},0,1\right),\quad a,b>0 +\] + +a、b 与 x 单位一致,来自有覆盖说明的固定参考面板,存入模型版本。成本型、强度型和比例型指标各自定义映射。不得按每日领先国家重定标,也不能因加入一个国家导致全部历史分数被静默重写。 + +关键输入缺失时 `S=null`,保留可用子分,不填零或自动重分配权重。O、G 或 R 没有可比输入时综合分不上线;原始图表和证据浏览仍可上线。样本少、匿名取整或来源偏差反映在不确定性和适用范围中,不直接扣国家实力分。 + +不确定性计算分开保存观测精度、参数估计和情景假设。只有有依据的输入分布才运行 Monte Carlo;共享芯片规格、同一公告、同一供应链的误差必须相关采样。无法量化的覆盖偏差直接披露,不能靠窄置信区间掩盖。排名用“无法区分”处理不稳健结果,不展示过多小数制造精确感。 + +### 6.7 变化与归因 + +\[ +g_{c,t}=C_{c,t}/C_{c,t-1}-1,\quad p_{c,t}=C_{c,t}/\sum_kC_{k,t},\quad b_{CN,t}=C_{CN,t}/(C_{CN,t}+C_{US,t}) +\] + +全球分母必须完整定义;双边份额不得标为全球份额。按报告披露值,2022—2023 年中国总算力从 302 增至 435 EFlops,约增长 44%,份额从 33% 降至 31%。这是“绝对增长、相对份额下降”,不是存量消失。算力与创新现有历史窗口不同,不能拼成同一时期的因果结论。 + +同权重、同版本下,分项账单严格满足: + +\[ +\Delta S=0.40\Delta H+0.30\Delta O+0.20\Delta G+0.10\Delta R +\] + +分项内非线性与交互影响可使用固定分组的 Shapley 分解;必须保存分组、基线和近似误差,不能把任意计算顺序产生的贡献当唯一解释。数学归因只解释模型输出,不证明宏观因果。 + +正式发布中始终分开:现实变化、补录旧事实、来源修订、模型/基期修订。版本升级用“同一输入、不同模型”的桥接结果展示,不能把换模型造成的跳变计入国家增长。 + +## 7. 项目内 AI 如何参与 + +### 7.1 运行方式 + +复用控制台已经配置的模型,通过 `AIProviderClient` 调用;不要求新的模型供应商,也不把供应商名称写死在业务代码中。第一阶段采用后端固定步骤工作流,各角色是任务模板,可以由同一个模型执行,不需要首先建设复杂的多 Agent 协商系统。 + +在现有提示词注册表中新增以下拟议 task key;提示词版本与运维覆盖的有效内容哈希进入每次运行记录。 + +| 任务模板 | 输入 | 允许输出 | 关键约束 | +| --- | --- | --- | --- | +| `compute.sources.discover` | 指标缺口、许可范围、已注册来源 | 来源候选、适用指标、采集建议 | 先找原始出处;新域名不直接进入正式定时采集 | +| `compute.evidence.extract` | 原文分块、页码、表格、目标 schema | 带证据定位的字段候选 | 未出现的数值返回缺失;原文中的指令只是待分析内容 | +| `compute.evidence.review` | 候选、对照记录、单位与实体规则 | 冲突类型、支持/反驳证据、待核验项 | 第二个 AI 的同意不是独立事实证明 | +| `compute.gaps.prioritize` | 缺失、来源健康、参数敏感性、采集成本 | 下一步补证清单 | 优先可能影响结论且可查证的缺口,不以国家倾向排序 | +| `compute.change.explain` | 已计算的贡献账单、事实版本 | 有引用的可读解释 | 不能修改计算结果;事实、估计、假设分别表达 | +| `compute.model.propose` | 误差报告、数据质量问题、模型配置 | 有边界的参数/映射/新特征提案 | 必须说明可证伪假设、预期改善、影响范围和回退方案 | +| `compute.model.review` | 候选方案、独立评估结果、影响报告 | 发布建议与限制 | 无权改变测试标签、门槛、保留集或自己批准自己 | + +结构化结果由后端 Pydantic schema 校验。当前 AI 返回以文本为主,需要新增结果解析与受限重试;JSON 不合格、引用不匹配、单位冲突时运行失败或保留候选,不能将自然语言当作正式数据。 + +### 7.2 工具与权限 + +允许工具包括:参数化只读指标查询、已注册来源搜索/抓取、文档段落读取、规则化单位转换、实体候选查询、固定估计器运行、创建缺口任务、保存提案、提交实验任务。 + +AI 不直接写正式分数、覆盖原始证据、执行任意 SQL/Python/Shell、修改发布门槛或扩大自己的工具权限。生成的采集映射必须通过回放样本后才保存;新增可执行采集器代码属于普通开发和发布流程,不在生产 Agent 内 `eval`。 + +网页抓取需校验协议、主机、解析后的目标地址及每次重定向,阻止访问内网、回环和元数据端点;使用专门外部抓取边界,不能把内部业务 API 和外部取证 URL 混用。AI 只得到所需公开证据或经授权的业务摘要,凭证由后端设置解析,不进入提示词、引用或日志。 + +### 7.3 用户能看见的 AI 结果 + +每份分析展示三部分:已验证事实、模型解释、仍缺哪些证据。每条数值和关键结论可展开来源。用户可以标记“实体合并错误”“单位/年份错误”“证据不支持”“假设不合理”,形成结构化反馈;收藏、点赞和是否喜欢国家排名不能成为事实标签。 + +“AI 发现数据不足”可以是成功结果。模型不可用时,确定性采集、计算、双图和地球继续工作,解释面板保留上次结果并标注版本与时间。 + +## 8. 数学模型如何自我迭代 + +### 8.1 自动迭代分成三类 + +| 层次 | 可以怎样改进 | 自动程度 | +| --- | --- | --- | +| 证据和数据 | 补充来源、修正字段、发现重复和过期、改进解析映射 | 已核准来源与已验证规则可自动运行;新语义及高影响冲突先进入候选 | +| 可验证的估计参数 | 项目延期分布、特定硬件/任务效率、记录错误率 | 通过冻结评估、影子运行和预先配置的边界后,可以自动发布小范围参数更新 | +| 评价定义 | H/O/G/R 权重、指标含义、基期、国家范围和价值取向 | 默认生成提案,由模型负责人确认版本;不是机器从不存在的“国家实力真值”中自动学习 | + +此处是产品建议的默认发布策略,不是在本次计划工作中申请执行权限。未来可配置自动发布的参数白名单,但不能用“开启自动迭代”笼统授权所有定义变化。 + +### 8.2 迭代闭环 + +```mermaid +flowchart TD + A[新事实/人工纠错/成熟预测结果] --> B[确定性误差与质量评估] + B --> C[AI 提出可证伪改进] + C --> D[生成候选配置与锁定实验] + D --> E[按时间回测及国家分组评估] + E --> F{是否通过预设门槛} + F -->|否| G[记录失败原因并保留现行版本] + F -->|是| H[影子运行:结果不改变正式排名] + H --> I[按发布策略评审或受限自动提升] + I --> J[发布不可变版本与变动桥接] + J --> K[监测退化并可原子回滚] + K --> B +``` + +每次提案只改变一种主要因素,或者明确作为一个联合实验;不能同时换来源、标签、权重和评估方法后宣称单个参数有效。失败提案也保存,避免只展示成功实验。 + +提案记录至少包括:问题、证据 ID、基线版本、拟议配置差异、适用实体/任务、可检验目标、锁定数据截止时间、主要评估指标、停止条件和回滚版本。可以提出“将并网阶段纳入交付预测”,不能只提出“让某国得分更合理”。 + +### 8.3 真实反馈标签从哪里来 + +| 估计对象 | 可观察标签 | 评估方式 | +| --- | --- | --- | +| 文档字段提取 | 人工核对原文的数值、单位、年份、实体和引用位置 | 字段精确率/召回率、关键错误、引用支持率,按语言/文档类型分组 | +| 实体合并 | 官方 ID、项目连续记录或人工确认的同一/不同实体 | 错误合并率和遗漏合并率;高影响错误单列 | +| 项目按期投产 | 后续正式验收/投产/取消记录,保留观测截止 | Brier score、可靠性图;右删失与失访不能计作失败 | +| 新增容量 | 未来实际确认的设备与容量,具有同口径 | 绝对误差、对数误差;同时报告项目等权与容量加权结果 | +| 任务性能系数 | 未参与拟合的可复现实测配置 | 相同任务质量下的预测误差与区间覆盖 | +| 区间预测 | 后续实际值是否落入范围及区间宽度 | 覆盖率与区间评分一起评价,避免靠无限放宽范围“提高准确率” | + +不使用 AI 自己生成的摘要作事实标签,不用现行总分拟合下一版总分,不把另一个复合指数直接当国家实力真值。完整创新指数可做外部一致性讨论,但若其包含输入指标或与 O 重叠,就不能作为独立验证集。 + +### 8.4 回测与数据泄漏控制 + +预测训练、验证和保留集按真实可获得时间滚动切分,同一项目的后续阶段、同一公告转载和同一扩建链必须归组,避免分散进训练和测试。每个特征都要求 `published_at <= forecast_cutoff`;仅今天抓到而无法证明当时可得的资料,不参加“当时可知”回测。 + +新模型先与简单基线比较:维持上次状态、沿用公告日期、同阶段历史交付中位数。不得只与一个刻意弱的旧模型比较。参数、标准化锚点和来源质量估计只从训练窗口取得。 + +大模型的训练记忆可能知道后来结果,所以历史回测中的预测器必须是只读取冻结特征的确定性估计器;不能让 LLM 凭记忆补旧事实。提案生成与真实保留集隔离,实际前瞻影子运行仍是必要证据。 + +按国家、硬件代际、项目规模、来源语言分别评估,并按项目/运营方/时间块进行重采样,不能把高度相关的重复记录当独立样本。小样本报告区间与不足,不为满足仪表盘而宣称显著改进。 + +### 8.5 建议发布门槛 + +以下是实施阶段的初始门槛草案,需在 M0 用试点数据冻结。它们是最低操作条件,不保证统计有效性,AI 无权自行降低。 + +| 门槛 | 初始建议 | +| --- | --- | +| 硬正确性 | 单位、时间穿越、重复计数、采集失败保留正式值等必测案例全部通过 | +| 提取评估 | 至少 200 条人工核验字段、覆盖中英文及主要文档类型;报告精确率/召回率及区间;关键数值/单位错误不得进入自动采用集 | +| 预测参数自动提升资格 | 至少 50 个有可验证结果的独立项目、覆盖至少 3 个滚动窗口;每个主要参与方至少 10 个,不足则继续影子运行或人工评估 | +| 改善幅度 | 预先指定主要损失相对现行版和简单基线至少改善 5%;按依赖结构重采样的损失差区间须支持改善 | +| 分组保护 | 主要参与方损失退化不超过预先约定范围,初始建议 2%;不能以平均改善掩盖一个国家明显恶化 | +| 区间质量 | 不仅检查覆盖,还检查宽度与评分;对无法量化的来源覆盖偏差单列说明 | +| 影子观察 | 至少连续 4 周稳定运行,并达到所需成熟标签数;12 个月预测不因观察满 4 周就算验证完成 | +| 试验节制 | 初始每月最多 3 个主要候选,锁定真实保留集;多次搜索造成的选择偏差要通过新的前瞻样本再次检查 | + +标签暂时不足时,系统可以自动补数、解释、积累预测与结果,但不发布“自我学习后的更准模型”。这不会阻塞第一版事实产品上线。 + +### 8.6 一个完整迭代例子 + +以下是流程示例,不是已发生的项目事实或评估成绩。 + +系统发现一批已订购芯片的项目未如期投产;确定性评估记录预测误差。AI 查询证据后提出:现有交付模型只使用芯片到货时间,遗漏电力接入阶段。它提交一个新增“并网是否完成”特征的候选。 + +后端按项目原始发表时间构造训练数据,用固定估计器拟合;锁定保留集检验总体和中美分组误差。即便论文或第二个 AI 都认为这个想法合理,只要结果未通过门槛就不发布。通过后先影子运行;正式发布时展示受影响的 G 分、预计容量和模型版本变化,已投产 H 不因推测而下降。 + +若后续发现并网字段提取错误导致退化,回滚活动模型指针,保存新证据和失败实验。不能删除失败记录或改写过去已发布的预测。 + +## 9. 后端数据与实现结构 + +首版采用最小领域实现:沿用 `CollectedData`、`DataSnapshot` 和位置维表,新增经过 schema 校验的国家指标记录与聚合服务。数值口径、参考期、发表时间、来源链接、原文哈希和证据定位可先作为受约束的元数据保存;必要的原件存放在许可允许的运行数据目录。只有现有表确实无法表达查询或历史约束时才增加专用表。 + +先冻结面向专题的 API 契约,地球与图表只消费该契约。将来拆出指标观测、证据、实验等表时,通过服务层迁移,不让前端依赖临时 JSON 或物理表结构。首版不以第 9.1 节全部表建完作为开工条件。 + +### 9.1 拟议持久化对象 + +下表为待新增的逻辑对象,可按最终数据库设计合并有限的小表;不要与现有采集、位置和任务状态形成并行真相源。 + +| 对象 | 保存内容 | 关联和约束 | +| --- | --- | --- | +| `evidence_documents` | 原文存储引用、原始/提取哈希、来源、发表/抓取时间、许可、提取版本 | 内容不可变;全文不在高频列表接口返回 | +| `metric_definitions` | 粒度、单位、精度、工作负载、归属、方向、适用范围和版本 | 一个稳定 metric ID;模型版本引用具体定义版本 | +| `metric_observations` | 数值/范围、参考期、知识时间、状态、来源版本、取整和缺失原因 | 链接原采集记录;追加式修订,不复制整份原始 JSON | +| `observation_evidence` | 观测与证据的支持/反驳关系、页号、表格/段落、短摘录 | 多对多;来源家族避免转载重复投票 | +| `compute_entities` / `resource_relations` | 稳定实体、别名、分期关系、所有权/供应/连接、有效期 | 复用现有位置维表;不另存一套会漂移的正式坐标 | +| `model_versions` | 指标集合、权重、锚点、参数、代码版本、情景与发布状态 | 不可变版本;活动指针独立且原子切换 | +| `model_runs` / `model_results` | 输入清单哈希、版本、截止时点、随机种子、环境、国家结果、分项和不确定性 | 结果可重放;实验、情景与正式运行分开 | +| `model_proposals` / `model_evaluations` | 配置差异、假设、冻结评估方案、损失、分组结果、决定与回退目标 | 发布必须引用通过的评估,保存失败实验 | +| `agent_runs` | task key、提示词有效哈希、模型、工具调用、引用、预算、结构化输出、终态 | 链接现有任务账本,Agent 自身不拥有另一套调度状态 | + +重放保留输入观测 ID/版本清单或不可变清单文件,不能只保存一段 SQL 然后查询已被更改的数据。记录计算实现版本、依赖环境和随机种子;LLM 输出保存为证据,不要求重新调用模型才能复算数值。 + +历史删除与保留策略需要显式区分:普通缓存可清理;被正式模型版本引用的观测及许可允许保存的证据受保留保护。来源撤回或许可变更时标记可访问状态并保留允许保留的哈希和元数据,不强行公开原文。 + +### 9.2 待新增模块与现有边界 + +| 拟议模块 | 职责 | +| --- | --- | +| `backend/app/services/compute_intelligence/observations.py` | 规范化、有效观测选择和数据缺口 | +| `.../metrics.py`、`.../scoring.py` | 纯数值计算、单位/指标注册、标准化和总分 | +| `.../forecasting.py`、`.../evaluation.py` | 固定估计器、时间切分、基线、分组指标与实验 | +| `.../workflows.py`、`.../proposals.py` | 固定 AI 工作流、结构化候选、预算和发布规则 | +| `.../publication.py` | 正式快照、变动桥接、活动版本和 outbox | +| `backend/app/api/v1/compute_intelligence.py` | 参数化只读查询和后台任务入口 | +| `frontend/public/earth/js/compute-intelligence.js` | 专题 UI 状态、图表与现有图层联动,消费后端计算结果 | +| 控制台专题页与现有采集/AI 设置页签 | 证据核验、缺口、模型实验、版本回看 | + +具体文件在实现时保持单一职责,优先复用已有服务,不为目录结构创建空文件。新来源继续注册到现有采集器体系及 datasource 配置;新提示词放入现有 `default_prompts.json`,不散落在 renderer 或 `aiprovider`。 + +### 9.3 拟议 API + +下列接口尚不存在,名称在 M0 与当前路由约定核对后冻结。 + +| 方法与路径(统一前缀 `/api/v1/compute-intelligence`) | 返回内容 | +| --- | --- | +| `GET /overview` | 所选参与方、口径、参考期与知识截止下的结果、适用范围及覆盖说明 | +| `GET /series` | 指定指标的有界历史序列,含缺失、版本、实际观测时间与来源引用 | +| `GET /entities/{id}` | 设施、分期、资源关系和历史观测 | +| `GET /changes` | 数据或模型贡献账单,数据库侧分页 | +| `GET /evidence/{id}` | 根据权限和许可返回证据摘要、定位与允许展示的内容 | +| `GET /models`、`GET /runs/{id}` | 模型版本、输入清单、计算结果与评估摘要 | +| `POST /recompute`、`POST /agents/run` | 建立任务并立即返回任务 ID,允许取消和查看进度 | +| `POST /proposals/{id}/evaluate` | 创建冻结数据与方案的评估任务 | +| `POST /models/{id}/promote`、`POST /models/{id}/rollback` | 按角色及发布策略原子切换活动版本,记录理由和影响 | + +查询参数固定包含 `metric_profile`、`entities`、`reference_at`、`knowledge_cutoff`、`model_version` 和可选 `scenario_id`。前端不接收任意表达式或 SQL,不自行补算另一套总分。 + +### 9.4 发布与性能 + +后台完整计算并通过质量门槛后,在事务中发布结果快照和活动指针,通过既有 outbox 唤醒刷新。新 adapter 显式声明国家面板/历史图的刷新范围;设施变化才触发相应 `computeCenters` 更新。草稿证据、失败实验和影子运行不改变默认地球。 + +缓存键包含口径、实体集合、参考期、知识截止、数据清单哈希、模型与情景版本。WebSocket 只通知结果版本与必要增量,客户端拒绝较旧响应;不能每次国家 hover 都重算模型或执行 LLM。 + +聚合、过滤、排序和分页在数据库完成;预计算国家年度/月度可用序列,地图按分辨率聚合节点。时间滑动去抖并复用已取快照,播放使用相邻状态差量。建议试点验收目标为缓存查询 P95 小于 1 秒、切换已缓存时点小于 500 毫秒,声明基准机器、数据规模和网络条件;未实测前不作为已达到性能。 + +## 10. 刷新、成本和失败处理 + +| 工作 | 建议节奏 | 触发条件与成本控制 | +| --- | --- | --- | +| CSV/API 检查 | 日或来源允许的更慢频率 | ETag/Last-Modified/哈希去重,无内容变化不调用 AI | +| 年报与指数发现 | 月度,发布季可提高 | 找到新版本再下载和提取,不把月检写成月度指标 | +| 重点项目证据 | 日查、周复核 | 去重后仅高影响变化进入提取队列 | +| 指标重算 | 有效观测或模型版本改变时 | 只重算受影响实体/时点/指标依赖 | +| AI 解释 | 有意义的结果变化或用户请求 | 相同输入哈希复用,不因页面打开重复生成 | +| 数据缺口排序 | 每周 | 敏感性、覆盖风险、可获得性和预计采集成本共同决定 | +| 参数候选实验 | 月度或成熟标签达到门槛 | 限制候选数量;无足够标签不强制产出升级 | + +试点默认每工作流最多 3 次搜索、8 个页面抓取、4 次 LLM 调用、1 次结构化修复重试,最多同时运行 2 个 AI 工作流;均为可配置预算,不是第三方接口能力承诺。按全局模型配置记录 token/费用,设置日预算和单任务截止时间,超额进入延后或停止状态,不无限自递归。 + +失败处理:采集失败保留上次正式值和新鲜度;AI 不可用仍提供确定性结果;引用失效保留许可允许的原件和哈希;长期资料缺口显示缺失并降低结论覆盖范围;新源冲突进入待核验;模型退化恢复前一活动版本。不能用重试或回滚删除已提交有效证据。 + +## 11. 分期实施与依赖 + +工作量是规划估算,按一名熟悉项目的全栈开发者、持续可用的数据核验支持和现有环境可用计算;不是交付承诺。开发时间不包括等待付费授权、原始报告或 12 个月预测结果成熟的时间。 + +### 11.1 当前优先交付:展示专题 + +| 顺序 | 修改方向 | 验收内容 | 估计开发工作日 | +| --- | --- | --- | ---: | +| A1 修复数据与最小合同 | Epoch CSV、TOP500 日期/身份/单位、空结果保护;保存历史指标和来源 | 能得到真实当前快照与核验后的年度序列,不要求宏观指标全到最新年 | 2—3 | +| A2 国家对比与历史接口 | 国家汇总、公开样本与宏观统计分离、双图数据、证据摘要 | 绝对量/份额/创新分差有各自定义;缺失与时点可见 | 2—3 | +| A3 地球与图表展示 | 专题模式、国家面板、双图、时间联动、设施/证据详情 | 完成第 0 节完整浏览路径,并通过桌面/移动/缩放验证 | 3—5 | +| A4 可选 AI 阅读辅助 | 复用现有 client 和任务提示词,针对冻结快照生成带引用说明 | 事实与推断分开;可关闭;失败不影响地球与图表 | 1—2 | + +A1—A3 是当前必须交付的闭环,约 7—11 个开发工作日;A4 可在闭环稳定后增加。实际数据源不通、现有采集回归问题或主题布局复杂度会改变估算。实现时首先验证 A1,而不是先开发通用 Agent 框架或完整综合评分。 + +### 11.2 后续平台能力路线 + +下表是完整能力建设的工作包估算,与 A 路线存在复用和重叠,不应重复相加。A 路线完成后先盘点已具备的 M0—M2 能力,只实施剩余部分。 + +| 阶段 | 工作包 | 交付物与通过条件 | 估计开发工作日 | +| --- | --- | --- | ---: | +| M0 合同与试点 | 固定口径、成员、来源许可、历史时间语义、角色权限、质量/评估门槛 | 指标注册表、数据源接入清单、黄金核验样本;所有未知明确登记 | 3—5 | +| M1 可信数据 | 修复 Epoch/TOP500;证据与指标观测;导入两组历史;稳定身份与失败保护 | 可查询、可追溯、可重放的历史 API;无示例数据进入正式结果 | 7—10 | +| M2 可见产品 | 地球专题、双图、国家对比、变化账单、证据抽屉和控制台缺口页 | 中美历史回放闭环;移动/缩放和旧图层回归通过;综合分可保持未就绪 | 7—10 | +| M3 资源与潜力 | 芯片、实测、项目、电力/资金台账,场景与分项估计 | H 与可用 G/R 子项具备来源;O 仅在分项齐备后发布;总分门槛满足才开放 | 8—12 | +| M4 AI 工作流 | 固定提取/核验/补证/解释流程、反馈和预算;扩展可靠任务类型 | AI 只能产生有引用候选与解释,失败不会污染正式数据 | 7—10 | +| M5 模型迭代 | 真实标签、时间回测、候选评估、影子运行、发布桥接与回滚 | 能证明某个可观察估计目标改善;不要求每轮必有新模型 | 8—12 | + +从零完整建设上述广义平台能力约 40—59 个开发工作日,包含比 A 路线更完整的证据和实验治理;它不是当前展示专题的工期。影子期至少四周,但不成熟的预测标签会延长自动提升资格等待期。 + +依赖顺序:M0 → M1 → M2;M3 的项目台账应尽早开始积累标签;M4 依赖 M1 的可信证据结构;M5 依赖 M3 的标签和 M4 的审计工作流。多方、矿产和复杂供应链在 M2 之后按数据质量逐个扩展,不等全部主题齐备再上线。 + +## 12. 验收与测试矩阵 + +| 范围 | 必测案例 | 通过标准 | +| --- | --- | --- | +| 来源真实性 | HTML 改版、空 CSV、404、部分下载、示例字符串 | 正式数据不被示例或空结果替换;错误原因可见 | +| 单位与精度 | T/P/E 换算,FP64/FP16/FP8,稀疏/稠密,MW/TWh,FLOP/FLOP/s | 不同口径拒绝合并;可比转换有可复算结果 | +| 时间与版本 | 固定抓取日误作投产日、2026 报告回填 2023、未知发布日期 | 严格截止查询无未来信息;历史回顾明确重述 | +| 实体与去重 | 排名换位、扩建、名称变化、匿名化、重复转载、跨源同集群 | 身份稳定;不重复计数;匿名或缺榜不自动退役 | +| 统计与地理 | 缺坐标、未知国家、欧盟与成员国、跨境所有权 | 国家统计不依赖坐标;分母不重叠;归属视图不混用 | +| 两张历史图 | 已核验年份值、缺失年份、2026 待核实主张 | 只展示有证据的观测;不能伪造平直历史或默认预测 | +| 综合评分 | 缺 O/G/R、权重和不为 1、标准化越界、国家集合变化 | 非法配置拒绝;缺失总分为 null;固定版本不静默漂移 | +| 变化账单 | 新投产、补录、源修订、换模型 | 分项贡献之和满足计算容差;类型和版本差异可解释 | +| AI 输出 | 格式错误、虚构引用、原文含指令、模型超时、重复任务 | 输出隔离;工具边界有效;任务幂等并能取消 | +| 模型评估 | 标签穿越、同项目跨集、过拟合小样本、分组退化 | 不通过门槛不发布;保留失败实验 | +| 发布与回滚 | 两个并发发布、计算中断、旧 WS/HTTP 响应 | 原子版本;半成品不可见;客户端不回滚到旧时点 | +| 地球渲染 | 远距/近距、时间播放、层隐藏、节点锁定、场景退出 | 无闪烁和悬空 tooltip;实例复用,不每帧重新建纹理 | +| UI | 桌面、移动、125%/150% 缩放、键盘、无数据/低置信/加载状态 | 一屏工作区无意外全局滚动;关键标签不靠颜色区分 | + +实现阶段按变更执行后端精确测试、`scripts/harness/quick-check.sh`、Bun 前端构建和渲染 smoke;地球改变不能只凭构建通过。保持现有公共页面、鉴权、管理路由、安全导航及其他地球图层的回归证据。性能阈值用记录过的基准规模验证,不能把开发空数据测试称为生产验收。 + +## 13. 文档、发布与完成定义 + +每阶段 PR 和部署使用已有项目流程,功能旗标分别控制专题 UI、估计分项、AI 工作流与自动参数提升。先开放读取与事实浏览,再启用模型实验;关闭新旗标应回到原有算力图层而不丢历史数据。 + +发生真实用户工作流变化时更新中英文手册与快速入门;采集、AI、作业与位置变化更新相应后端技术文档;控制台、Earth、图层顺序和样式变化更新对应 context 与规范。新增公共技术文档需要同步中英文和 Docs 注册;本文件是内部中文计划,不注册为已实现的公共手册。 + +当前展示专题完成定义:用户完成第 0 节浏览路径,可以从国家指标变化回到出处和所用计算版本;重新计算得到同一数值;知道哪些资料尚缺。两张图应和国家、设施、时间、证据联动,不能只是贴在地球旁的静态图片。 + +后续决策辅助与模型迭代完成定义:情景的假设与结果可复算;AI 的一次成功改进有独立验证目标、评估记录和可回滚版本。只有一段 AI 分析或未经校验的总分,不代表完成决策辅助能力。 + +## 14. 风险与预先决定的退路 + +| 风险 | 产品和实施处理 | +| --- | --- | +| 无法取得创新原报告分项 | 保留完整指数独立观测;O 和总分不发布,不伪造拆分 | +| 中国或其他地区公开设施更少 | 显示宏观统计与设施样本两个覆盖视图,扩充本地语言原始来源,不把保密或匿名化当能力下降 | +| 硬件真实利用率不可知 | 理论量先行,明确任务和区间;不把实验室 benchmark 当生产实测 | +| 来源改版、匿名化或许可证变化 | 版本化下载、质量隔离、许可范围内保存证据,必要时停更单一来源 | +| 资金、电力、芯片相互重复计分 | 固定指标归属与约束链;不把投入、存量、产出和事件各加一次 | +| 训练标签不足或结果多年后才成熟 | 先提供情景与证据改进,延后参数自动提升资格 | +| 模型迎合既定国家排名 | 预先定义目标和门槛,评价权重单独评审,不以用户喜好或另一个 AI 打分训练 | +| 已有采集/定位状态被新服务覆盖 | 复用当前真相源,使用明确字段所有者、发布快照和回归测试 | + +## 15. 来源与相关文档 + +以下链接支持数据口径、来源可得性和架构选择;除明确标注的历史值外,本计划的字段、频率、权重、工期和发布门槛均是拟议设计。 + +- [信通院 2021 年版算力白皮书(镜像)](https://pdf.dfcfw.com/pdf/H3_AP202109271518811781_1.pdf):2020 年总算力份额。 +- [信通院 2022 年版白皮书(镜像)](https://13115299.s21i.faiusr.com/61/1/ABUIABA9GAAgvq2fmwYozLrrpwU.pdf):正文第 13—14 页,2021 年份额与口径。 +- [信通院 2023 年版白皮书(镜像)](https://www.ahchanye.com/wp-content/uploads/2023/09/2023092815342684.pdf):正文第 13 页的 2022 年份额,以及 302 EFlops 总规模。 +- [信通院 2024 年版蓝皮书(镜像)](https://pdf.dfcfw.com/pdf/H3_AP202502051642799257_1.pdf):正文第 10 页的 2023 年份额及后续规模说明。 +- [科技日报关于创新指数报告的报道(新浪转载)](https://finance.sina.com.cn/tech/roll/2026-07-17/doc-iniickaz8224252.shtml):分差与综合指标维度;未替代原报告分项。 +- [2026 年 42%/33% 候选出处](https://www.mornai.cn/news/gpu/ai-computing-power-never-sleeps/):待核实商业文章,不作为正式统计。 +- [Epoch GPU 集群公开数据](https://epoch.ai/data/gpu-clusters)、[字段与下载说明](https://epoch.ai/data/gpu-clusters-documentation):CSV、地理字段、覆盖与公开版本限制。 +- [Epoch 芯片所有权](https://epoch.ai/data/ai-chip-owners):所有权不等于使用权或所在地,交付与投产有时间差。 +- [MLPerf Training](https://mlcommons.org/benchmarks/training/)、[MLPerf Inference Datacenter](https://mlcommons.org/benchmarks/inference-datacenter/):固定任务、质量及测试配置。 +- [IEA Energy and AI](https://www.iea.org/reports/energy-and-ai)、[EIA 开放数据](https://www.eia.gov/opendata/):能源资料入口,不能直接替代设施并网证据。 +- [SEC EDGAR API 说明](https://www.sec.gov/search-filings/edgar-application-programming-interfaces):公司提交与财务数据访问;需注意财季与标签口径。 +- [USGS 矿产概要](https://www.usgs.gov/centers/national-minerals-information-center/mineral-commodity-summaries)、[UN Comtrade 门户](https://comtradeplus.un.org/):矿产与贸易候选数据源。 +- [OpenAlex 帮助与数据说明](https://help.openalex.org/):研究元数据候选来源;接入时核验访问与归属规则。 +- [业务架构与数据流转](../technical/zh/platform-data-flows.md)、[后端采集器](../technical/zh/backend-collectors.md)、[AI Provider 边界](../technical/zh/agents-aiprovider.md)。 +- [智能星球前端上下文](../technical/zh/earth-frontend-context.md)、[渲染图层顺序](../technical/zh/earth-render-layer-order.md)、[图层视觉规范](../technical/zh/earth-layer-style-reference.md)。 +- [轻量 Agent 编排](agents-light-orchestrator-websearch-plan.md)、[态势感知基础计划](agents-situational-awareness-foundation-plan.md):复用总体方向,不重复建设 provider 内业务编排。 diff --git a/docs/technical/en/ops-runbook.md b/docs/technical/en/ops-runbook.md index 8ba7afab..06c55170 100644 --- a/docs/technical/en/ops-runbook.md +++ b/docs/technical/en/ops-runbook.md @@ -274,6 +274,15 @@ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/" default = true ``` +Start and restart use fingerprint-based build detection by default. To explicitly reuse a local image for one command, add --no-build: + +```bash +./planet.sh start --no-build +./planet.sh restart -a --no-build +``` + +This option affects only AI Provider and keeps other startup checks enabled. A missing local image is an error, and the old image is never stamped as containing current code. When Compose v2 is available, builds, startup and build-capability checks use v2 only and preserve its failure. Compose v1 is considered only when v2 is unavailable. + Diagnose slow builds: | Symptom | Common cause | Fix | @@ -291,6 +300,66 @@ When SMTP is unset, `POST /api/v1/auth/register` returns `503 EMAIL_PROVIDER_NOT One-time codes are stored in Redis under `otp:{purpose}:{email}` with a 600-second TTL. The key is invalidated after 5 invalid attempts. Resend cooldown is 60 seconds, enforced via `otp_rate:{purpose}:{email}`. +## Error Cause Catalog + +Every planet.sh log_error retains its context and reads its diagnostic code, cause and remedy directly from the Chinese table. AI Provider build failures also inspect the full build log. Rows are matched in order using case-insensitive literal fragments separated by semicolons; specific errors precede summaries. Codes are diagnostic identifiers, not process exit codes; failure still returns a nonzero status. Matching fragments below intentionally retain the Chinese shell messages. + +For each newly confirmed cause, update both language tables with a stable code, distinguishing evidence and a regression case before integrating it into the script. Unverified failures remain P_UNKNOWN; the script must not append guessed causes to documentation. scripts/lib/error-diagnostics.zsh reads the table. Do not put vertical bars in cells. scripts/harness/test_error_diagnostics.py checks classification, bilingual codes and runtime wording. + + +| Error code | Matching fragments (semicolon-separated) | Cause / established failure | Remedy | +| --- | --- | --- | --- | +| P_PROXY_EXTERNAL | PLANET_PROXY_EXTERNAL | Docker proxy settings belong to another configuration source or have been edited manually. | Inspect daemon.json, systemd proxy settings and the Planet ownership record before changing ownership; existing settings are preserved. | +| P_PROXY_CHANGED | PLANET_PROXY_CONFIG_CHANGED | Docker proxy settings changed after detection. | Wait for concurrent configuration work to finish and retry without overwriting it. | +| P_PROXY_ROLLBACK | PLANET_PROXY_ROLLBACK_FAILED | Docker or previously running containers could not be restored after a failed proxy update. | Inspect daemon and container logs; the original configuration was restored. Use daemon.json.planet-proxy.bak for manual recovery if necessary. | +| P_PROXY_CONFIG | PLANET_PROXY_CONFIG_FAILED;Docker 构建代理检测失败;Docker 构建代理更新失败 | Automatic proxy detection, validation or service update failed. | Check Python 3, curl, Docker and sudo access; updates attempt rollback and proxy credentials are excluded from error output. | +| P_PROXY_NO_ROUTE | PLANET_PROXY_NO_ROUTE | No configured host proxy could reach the required build registries, and direct probes also failed. | Restore a working proxy or repair direct networking, DNS and registry addresses; disabling builds does not repair connectivity. | +| P_REGISTRY_RATE_LIMIT | 429 Too Many Requests;toomanyrequests;pull rate limit | The registry responded but imposed a request-rate or image-pull quota limit; consult the response for the specific limit. | Avoid repeated retries and follow upstream retry guidance. For anonymous pull quotas, check docker login identity and allowance; do not mistake throttling for an unreachable proxy. | +| P_DNS | no such host;temporary failure in name resolution;could not resolve host | Name resolution failed; the log alone does not identify the DNS configuration or upstream fault. | Check DNS and proxy resolution in the Docker environment; compare shell and daemon resolution. | +| P_TLS_CERT | x509:;certificate verify failed;certificate signed by unknown authority | TLS certificate validation failed. | Check time, certificate chain and proxy CA; install trusted certificates without disabling verification. | +| P_PROXY_AUTH | proxy authentication required;407 proxy | The proxy requires authentication and rejected the request. | Check daemon proxy credentials and permissions; keep credentials out of the repository and logs. | +| P_NETWORK_TIMEOUT | i/o timeout;tls handshake timeout;context deadline exceeded;deadlineexceeded | A connection or TLS handshake timed out; the log alone cannot distinguish proxy, DNS and IPv6 faults. | Compare direct and proxied requests; check daemon proxy settings, DNS and IPv6 routes. Shell proxy settings do not configure the daemon. | +| P_CONNECTION_REFUSED | connection refused | The destination refused the connection; its listener, address or port may be wrong. | Identify whether the target is a proxy, database or registry, then check its listener and service state. | +| P_REGISTRY_AUTH | pull access denied;unauthorized:;insufficient_scope;denied: requested access | Registry access was denied or the current identity cannot pull the image. | Verify the image name, repository permissions and docker login identity. | +| P_IMAGE_TAG | manifest unknown;manifest not found | The registry cannot find the requested manifest or tag. | Check PYTHON_IMAGE, UV_IMAGE and other tags, including architecture support. | +| P_DISK_FULL | no space left on device | Disk space or inodes are exhausted. | Check df -h, df -i and docker system df; remove only confirmed disposable data, never database volumes as a troubleshooting shortcut. | +| P_DOCKER_SOCKET | permission denied while trying to connect;刷新组权限后仍无法访问 Docker socket | The current user cannot access the Docker socket. | Check socket ownership and docker group membership; run ./planet.sh init for permissions and open a new terminal. | +| P_DOCKER_SERVICE | 没有可用的 docker.service;Docker Engine 启动失败;cannot connect to the docker daemon;无法连接 daemon | Docker is not ready or the client cannot reach the current endpoint. | Check systemctl status docker, docker context ls and journalctl -u docker.service; connection failure does not prove Docker is absent. | +| P_DOCKER_DESKTOP | 检测到 Docker Desktop,但当前 WSL | Docker Desktop or its WSL integration is unavailable. | Start Docker Desktop and enable WSL Integration for this distribution. | +| P_DOCKER_ENDPOINT | 当前 Docker 使用其他 context 或远程/rootless endpoint | The selected remote or rootless Docker endpoint is unavailable. | Check docker context ls, DOCKER_HOST and the target service; do not replace it with a local engine automatically. | +| P_BUILDX | 未检测到 docker buildx;buildx 0.17;buildx >=;buildx v;当前 docker compose 不支持 build;安装后 Docker CLI、Compose v2 | Docker build plugins are missing, too old or unable to provide build support. | Check docker buildx version and docker compose version; install or upgrade the plugins required by the project. | +| P_COMPOSE_MISSING | 未检测到可用的 Docker Compose | No usable Compose command was found. | Install the Compose v2 plugin and verify docker compose version; a v2 operation failure must not trigger v1 fallback. | +| P_LOCAL_IMAGE_MISSING | 已指定 --no-build,但本地没有 AI Provider 镜像 | Builds were disabled but the AI Provider image is not present locally. | Build or import the image before using --no-build; the option never builds automatically. | +| P_SUDO | 缺少 sudo | The privilege escalation tool required for dependency setup is unavailable. | Ask an administrator to install and authorize sudo, or provision the dependencies in advance. | +| P_UNSUPPORTED_OS | Docker 自动安装目前支持;未识别系统包管理器 | The current system is outside the automatic installation support scope. | Install dependencies through supported system procedures and retry. | +| P_LOCKFILE_CHANGED | 修改了 uv.lock | Dependency preparation unexpectedly changed the lockfile. | Check manifest and lockfile consistency; use frozen installation without implicit lockfile updates. | +| P_DEPENDENCIES | 安装失败;安装后仍不可用;安装完成后仍未找到;未找到 .venv/bin/python;自动安装后仍无法解析运行时;未找到 Vite Bun 入口;缺少 mediapipe/opencv-python;仍无法导入 mediapipe/opencv-python;需要 openssl | A required dependency failed to install, is missing or is unavailable in the active environment. | Inspect the installer log, network, package sources and PATH; use Bun for frontend and the project uv environment for Python. | +| P_ARGUMENT | 未知参数;非法端口;需要端口号;需要逗号分隔;--motion-agent-mode 需要;--motion-agent-wsl-usbipd-busid 需要;用法: ./planet.sh | The command or an argument does not match the supported format. | Check ./planet.sh usage and correct the arguments before retrying. | +| P_PORT | 地址已被占用;端口仍不可用;清理失败,请检查占用进程;port is already allocated;address already in use | The requested port is occupied or cannot be bound in the host environment. | Check ss and Windows Get-NetTCPConnection; identify the owner before changing ports or stopping the service. | +| P_CAMERA | live 模式缺少可用摄像头;未找到可打开并能读帧的摄像头 | Motion Agent cannot capture frames from a usable camera. | Check hardware, permissions and WSL USB forwarding; use --non-motion-agent or explicit dry-run when live capture is not needed. | +| P_DB_CONNECTION | 后端数据库连接检查失败 | The backend database connection or published-port check failed. | Inspect the probe output and verify DATABASE_URL, credentials, database name and port; container health alone is insufficient. | +| P_DB_START | 数据库启动失败;数据库重启失败;PostgreSQL 启动失败;数据库初始化失败 | Database startup, health checks or initialization did not complete. | Inspect PostgreSQL and Redis logs and the specific database error; do not troubleshoot by deleting volumes. | +| P_BACKEND_START | 后端进程已退出;后端启动失败 | The backend exited or did not pass its health check. | Use ./planet.sh log -b and resolve import, configuration, database or application initialization errors first. | +| P_AI_START | AI Provider 启动失败 | AI Provider did not start or pass its health check. | Use ./planet.sh log -a and check runtime configuration, ports and container exit details. | +| P_FRONTEND_START | 前端启动失败 | The frontend did not pass its startup health check. | Use ./planet.sh log -f and check Bun, dependencies, the Vite entry point and ports. | +| P_MOTION_START | Motion Agent 启动失败 | Motion Agent failed to start. | Use ./planet.sh log -m and check dependencies, cameras and input mode. | +| P_ACCOUNT_INPUT | 用户名不能为空;密码不能为空;密码长度不能少于;两次输入的密码不一致 | User creation input failed validation. | Supply a username and matching passwords that satisfy the minimum length. | +| P_HTTP_HEALTH | 不可访问: | The specified HTTP endpoint failed its access check. | Check the URL, listener, firewall and local or LAN routing; check certificate trust for HTTPS. | +| P_COMPOSE_FAILED | Docker Compose 执行失败;docker-compose v1 执行失败 | The selected Compose command failed without a more specific classified cause. | Inspect the preceding original error; repair an installed Compose v2 instead of installing v1 as a fallback. | +| P_BUILD_FAILED | AI Provider 镜像构建失败;failed to solve | Image build failed without evidence matching a known specific cause. | Inspect the earliest specific error in aiprovider_build.log and add the confirmed cause and a regression case to this catalog. | +| P_UNKNOWN | — | Unclassified; the available evidence does not establish the cause. | Retain the error and command; after verifying the root cause, update both language catalogs and add a regression case. | + + +### Docker Daemon Proxy and Build Networking + +Shell HTTP_PROXY / HTTPS_PROXY settings do not automatically configure a running Docker daemon. When the shell can reach a registry through its proxy but Docker pulls time out, compare direct requests, proxied requests and daemon settings. HTTP 401, 403 and 429 establish a registry response, not pull authorization or remaining quota. Verify authentication and rate limits with the actual build; these responses must not cause a reachable proxy to be disabled. + +Before an actual AI Provider build, the script reads HTTPS_PROXY, HTTP_PROXY and ALL_PROXY, including lowercase forms. It validates HTTP/HTTPS candidates against the registries selected by PYTHON_IMAGE and UV_IMAGE, respecting NO_PROXY. A working proxy is configured for the local Linux Docker Engine. Missing or unusable proxies cause a direct probe and removal of stale Planet-managed proxy settings. If neither route works, P_PROXY_NO_ROUTE stops the build. A host without a proxy and a daemon already using direct access receives no proxy configuration. Fingerprint cache hits and --no-build skip probing. This is not a background monitor: proxy availability is checked on the next actual build. + +scripts/docker_proxy.py manages the proxies section of /etc/docker/daemon.json and a root-only ownership record at /etc/docker/planet-proxy-state.json. Administrator settings, systemd proxy settings and manually edited proxies are not overwritten. Unchanged settings need neither elevation nor a restart. Updates use the existing sudo flow, preserve unrelated Docker settings, save daemon.json.planet-proxy.bak, validate configuration, restart Docker and start previously running containers. Failures trigger rollback; check container health after recovery. Credentials travel through environment variables or restricted files and are excluded from logs. Docker Desktop, remote and rootless daemons retain their own settings without local daemon.json changes. Proxy addresses come from the environment, never a machine-specific port in the repository. + +The AI Provider Dockerfile uses the bundled BuildKit frontend to avoid a separate docker/dockerfile image fetch. Python and uv base images and package downloads still require network access. --no-build explicitly reuses a local image; it does not repair build networking. Build errors are recorded in ${XDG_STATE_HOME:-$HOME/.local/state}/planet/aiprovider_build.log. Verify restored services with ./planet.sh health. + ## Troubleshooting Order ```bash diff --git a/docs/technical/zh/ops-runbook.md b/docs/technical/zh/ops-runbook.md index c5d47b4a..fd3acad1 100644 --- a/docs/technical/zh/ops-runbook.md +++ b/docs/technical/zh/ops-runbook.md @@ -274,6 +274,15 @@ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/" default = true ``` +启动和重启默认按指纹判断是否需要构建。需要明确复用已有镜像时,可对本次命令增加 --no-build: + +```bash +./planet.sh start --no-build +./planet.sh restart -a --no-build +``` + +该选项只影响 AI Provider,不会关闭其他服务的启动检查;缺少本地镜像时直接报错,也不会把旧镜像的指纹更新成当前代码。已有 Compose v2 时,构建、启动和能力检查只使用 v2,失败直接保留错误;只有未检测到 v2 时才考虑 v1。 + 构建较慢时按层排查: | 现象 | 常见原因 | 处理方式 | @@ -291,6 +300,66 @@ default = true OTP 一次性验证码走 Redis,key 格式 `otp:{purpose}:{email}`,TTL 600 秒。错误尝试 5 次后该 key 失效;重发冷却 60 秒,由 `otp_rate:{purpose}:{email}` 控制。 +## 错误原因对照表 + +`planet.sh` 的所有 `log_error` 输出保留现场信息,并直接从下表读取错误编号、原因和处理建议;AI Provider 构建失败还会匹配完整构建日志。匹配按表中顺序进行,不区分英文大小写,分号分隔多个字面关键片段,具体错误优先于汇总错误。错误编号不是进程退出码,失败仍返回非零状态。 + +确认新的故障原因时,必须更新中英文表,补充稳定编号、可辨识的日志片段和回归用例,再接入脚本。未确认的错误归入 `P_UNKNOWN`,不得自动把未知日志当成已验证原因写入手册。表由 `scripts/lib/error-diagnostics.zsh` 读取,不要在单元格中使用竖线;`scripts/harness/test_error_diagnostics.py` 验证匹配、双语编号和输出一致性。 + + +| 错误编号 | 匹配片段(分号分隔) | 原因/已确认的故障现象 | 处理建议 | +| --- | --- | --- | --- | +| P_PROXY_EXTERNAL | PLANET_PROXY_EXTERNAL | 当前 Docker 代理由其他配置管理,或已被人工修改,不能安全自动覆盖。 | 检查 daemon.json、systemd 代理及 Planet 管理记录,确认归属后再调整;脚本保留现有配置。 | +| P_PROXY_CHANGED | PLANET_PROXY_CONFIG_CHANGED | 检测后 Docker 代理配置发生了变化。 | 等待其他配置操作结束后重试,避免覆盖并发修改。 | +| P_PROXY_ROLLBACK | PLANET_PROXY_ROLLBACK_FAILED | 自动代理更新失败后,Docker 或原有容器未能完成恢复。 | 检查 Docker 服务日志和容器状态;原配置已还原,必要时依据 daemon.json.planet-proxy.bak 手动恢复服务。 | +| P_PROXY_CONFIG | PLANET_PROXY_CONFIG_FAILED;Docker 构建代理检测失败;Docker 构建代理更新失败 | 自动代理检测、配置校验或服务更新失败。 | 检查 Python 3、curl、Docker 状态和 sudo 权限;配置更新会尝试回滚,代理凭据不会写入错误输出。 | +| P_PROXY_NO_ROUTE | PLANET_PROXY_NO_ROUTE | 未找到能访问构建所需仓库的主机代理,直连探测也未通过。 | 恢复可用代理或修复直连网络、DNS及仓库地址后重试;不要把禁止构建当成网络修复。 | +| P_REGISTRY_RATE_LIMIT | 429 Too Many Requests;toomanyrequests;pull rate limit | 镜像仓库已响应,但请求频率或拉取配额触发限流;具体限制需依据仓库响应确认。 | 停止反复重试,按上游提示等待;若为匿名拉取配额,核对 docker login 身份及额度。不要把限流误判为代理不可达。 | +| P_DNS | no such host;temporary failure in name resolution;could not resolve host | 域名解析失败,尚不能确定是 DNS 配置还是上游解析异常。 | 检查 Docker 所在环境的 DNS 和代理解析;对比终端与 Docker 的解析结果。 | +| P_TLS_CERT | x509:;certificate verify failed;certificate signed by unknown authority | TLS 证书校验失败。 | 检查系统时间、证书链和代理 CA;安装可信 CA,不要关闭证书校验。 | +| P_PROXY_AUTH | proxy authentication required;407 proxy | 代理要求认证,当前请求未通过认证。 | 检查 Docker 服务的代理凭据和代理端权限,不要把凭据写入仓库或日志。 | +| P_NETWORK_TIMEOUT | i/o timeout;tls handshake timeout;context deadline exceeded;deadlineexceeded | 网络连接或 TLS 握手超时,单凭日志不能认定是代理、DNS 或 IPv6 中的哪一项。 | 对比直连与代理请求;检查 Docker 服务自身的代理、DNS 和 IPv6 路由,终端代理不等于 Docker 服务代理。 | +| P_CONNECTION_REFUSED | connection refused | 目标地址拒绝连接,服务可能未监听或地址、端口配置不匹配。 | 检查被拒绝的目标是代理、数据库还是镜像仓库,再确认监听端口和服务状态。 | +| P_REGISTRY_AUTH | pull access denied;unauthorized:;insufficient_scope;denied: requested access | 镜像仓库拒绝访问或当前身份无拉取权限。 | 核对镜像名、仓库权限及 docker login 使用的身份。 | +| P_IMAGE_TAG | manifest unknown;manifest not found | 镜像仓库中找不到指定的镜像清单或标签。 | 核对 PYTHON_IMAGE、UV_IMAGE 或其他镜像标签,确认目标架构受支持。 | +| P_DISK_FULL | no space left on device | 磁盘空间或 inode 不足。 | 检查 df -h、df -i 和 docker system df;确认用途后定向清理,不要删除数据库卷。 | +| P_DOCKER_SOCKET | permission denied while trying to connect;刷新组权限后仍无法访问 Docker socket | 当前用户无法访问 Docker socket。 | 检查 socket 属组和 docker 组成员资格;执行 ./planet.sh init 配置权限后重新打开终端。 | +| P_DOCKER_SERVICE | 没有可用的 docker.service;Docker Engine 启动失败;cannot connect to the docker daemon;无法连接 daemon | Docker 服务未就绪,或客户端无法连接当前 endpoint。 | 检查 systemctl status docker、docker context ls 和 journalctl -u docker.service;不要把连接失败当成未安装。 | +| P_DOCKER_DESKTOP | 检测到 Docker Desktop,但当前 WSL | Docker Desktop 或当前 WSL 集成不可用。 | 启动 Docker Desktop,并启用当前发行版的 WSL Integration。 | +| P_DOCKER_ENDPOINT | 当前 Docker 使用其他 context 或远程/rootless endpoint | 当前远程或 rootless Docker endpoint 不可用。 | 检查 docker context ls、DOCKER_HOST 和目标服务;不要自动替换成本地引擎。 | +| P_BUILDX | 未检测到 docker buildx;buildx 0.17;buildx >=;buildx v;当前 docker compose 不支持 build;安装后 Docker CLI、Compose v2 | Docker 构建插件缺失、版本不足或构建能力不可用。 | 检查 docker buildx version 和 docker compose version,按项目要求安装或升级相应插件。 | +| P_COMPOSE_MISSING | 未检测到可用的 Docker Compose | 未发现可用的 Compose 命令。 | 安装 Compose v2 插件并验证 docker compose version;已有 v2 执行失败时不回退 v1。 | +| P_LOCAL_IMAGE_MISSING | 已指定 --no-build,但本地没有 AI Provider 镜像 | 禁止构建时,本地没有可复用的 AI Provider 镜像。 | 先成功构建或导入镜像,再使用 --no-build;该选项不会自动构建。 | +| P_SUDO | 缺少 sudo | 自动安装或配置系统依赖所需的提权工具不可用。 | 由管理员安装 sudo 并授予必要权限,或预先安装依赖。 | +| P_UNSUPPORTED_OS | Docker 自动安装目前支持;未识别系统包管理器 | 当前系统不在脚本自动安装的支持范围内。 | 按系统官方方式安装依赖,再重新执行脚本。 | +| P_LOCKFILE_CHANGED | 修改了 uv.lock | 依赖准备意外修改了锁文件。 | 检查依赖清单与锁文件的一致性;新环境使用 frozen 安装,不要隐式更新锁文件。 | +| P_DEPENDENCIES | 安装失败;安装后仍不可用;安装完成后仍未找到;未找到 .venv/bin/python;自动安装后仍无法解析运行时;未找到 Vite Bun 入口;缺少 mediapipe/opencv-python;仍无法导入 mediapipe/opencv-python;需要 openssl | 必需依赖安装失败、缺失或未进入当前运行环境。 | 查看对应安装日志,检查网络、软件源和 PATH;前端使用 Bun,Python 使用项目 uv 环境。 | +| P_ARGUMENT | 未知参数;非法端口;需要端口号;需要逗号分隔;--motion-agent-mode 需要;--motion-agent-wsl-usbipd-busid 需要;用法: ./planet.sh | 命令或参数不符合脚本支持的格式。 | 查看 ./planet.sh 用法,修正参数和值后重试。 | +| P_PORT | 地址已被占用;端口仍不可用;清理失败,请检查占用进程;port is already allocated;address already in use | 请求的端口被占用,或在宿主机/外部环境中不可绑定。 | 检查 ss 和 Windows Get-NetTCPConnection,确认占用者后调整端口或停止对应服务。 | +| P_CAMERA | live 模式缺少可用摄像头;未找到可打开并能读帧的摄像头 | Motion Agent 无法取得可用摄像头画面。 | 检查设备、权限和 WSL USB 转发;无需摄像头时使用 --non-motion-agent 或明确选择 dry-run。 | +| P_DB_CONNECTION | 后端数据库连接检查失败 | 后端实际数据库连接或发布端口检查未通过。 | 查看连接探测的具体输出,核对 DATABASE_URL、凭据、库名和端口;容器健康不等于后端能连接。 | +| P_DB_START | 数据库启动失败;数据库重启失败;PostgreSQL 启动失败;数据库初始化失败 | 数据库启动、健康检查或初始化未完成。 | 检查 PostgreSQL、Redis 容器日志及具体数据库错误;不要通过删除数据卷排障。 | +| P_BACKEND_START | 后端进程已退出;后端启动失败 | 后端进程退出或未通过健康检查。 | 查看 ./planet.sh log -b,优先处理导入、配置、数据库连接或应用初始化错误。 | +| P_AI_START | AI Provider 启动失败 | AI Provider 容器未正常启动或未通过健康检查。 | 查看 ./planet.sh log -a,检查运行配置、端口和容器退出原因。 | +| P_FRONTEND_START | 前端启动失败 | 前端未通过启动健康检查。 | 查看 ./planet.sh log -f,检查 Bun、依赖、Vite 入口和端口占用。 | +| P_MOTION_START | Motion Agent 启动失败 | Motion Agent 未正常启动。 | 查看 ./planet.sh log -m,检查依赖、摄像头和输入模式。 | +| P_ACCOUNT_INPUT | 用户名不能为空;密码不能为空;密码长度不能少于;两次输入的密码不一致 | 创建用户时输入不满足校验要求。 | 按提示重新输入用户名及满足长度要求且一致的密码。 | +| P_HTTP_HEALTH | 不可访问: | 指定 HTTP 端点未通过访问检查。 | 检查目标 URL、服务监听、防火墙和本机/局域网路由;HTTPS 还需检查证书信任。 | +| P_COMPOSE_FAILED | Docker Compose 执行失败;docker-compose v1 执行失败 | 所选 Compose 命令失败,尚未识别更具体原因。 | 查看命令前面的原始错误;已有 Compose v2 时修复其错误,不安装 v1 作为回退。 | +| P_BUILD_FAILED | AI Provider 镜像构建失败;failed to solve | 镜像构建失败,现有证据未匹配已知的具体原因。 | 查看 aiprovider_build.log 中最早的具体错误,确认原因后补充本表和回归用例。 | +| P_UNKNOWN | — | 尚未归类,不能从现有证据确认原因。 | 保留完整错误和执行命令;确认根因后补充本表、中英文说明及回归用例。 | + + +### Docker 服务代理与构建网络 + +终端的 HTTP_PROXY / HTTPS_PROXY 不会自动配置已经运行的 Docker 服务。若终端通过代理能访问镜像仓库,而 Docker 拉取超时,应分别验证代理连接、直连和 Docker 服务的实际代理设置。探测收到 HTTP 401、403 或 429 表示仓库已响应,不等于已获得镜像拉取权限或剩余额度;认证及限流仍由实际构建验证,不能据此把可达代理切换掉。 + +实际构建 AI Provider 镜像前,脚本自动读取当前环境的 HTTPS_PROXY、HTTP_PROXY、ALL_PROXY(含小写形式),依次验证 HTTP/HTTPS 代理能否访问 PYTHON_IMAGE、UV_IMAGE 对应的仓库,并尊重 NO_PROXY。有可用代理才为本地 Linux Docker Engine 设置代理;没有代理或代理不可用时测试直连并清除脚本管理的旧代理。两种路径都不可用时,用 P_PROXY_NO_ROUTE 明确停止。没有代理且 Docker 原本也是直连时,不新增代理配置。指纹命中跳过构建或使用 --no-build 时,不做联网探测;这不是后台监控,代理启停在下一次实际构建时检测。 + +`scripts/docker_proxy.py` 管理 `/etc/docker/daemon.json` 的 proxies,归属记录保存在仅 root 可读写的 `/etc/docker/planet-proxy-state.json`。不覆盖管理员配置、systemd 代理或人工修改过的代理。配置相同不提权、不重启;需要修改时使用现有 sudo 流程,保留其他 Docker 设置,备份到 daemon.json.planet-proxy.bak,校验后重启 Docker 并启动原先运行的容器。失败时回滚;恢复后应检查容器健康。代理凭据只经环境或受限文件传递,不输出到日志。Docker Desktop、远程和 rootless Docker 沿用自身设置,不修改本机 daemon.json。代理地址从环境读取,不硬编码某台机器的端口。 + +AI Provider Dockerfile 使用 BuildKit 内置解析器,避免额外拉取 docker/dockerfile 解析器镜像;Python、uv 基础镜像和依赖下载仍需网络。--no-build 只用于明确复用本地镜像,不是构建网络错误的修复。构建错误日志位于 `${XDG_STATE_HOME:-$HOME/.local/state}/planet/aiprovider_build.log`;重启恢复后的服务可用 ./planet.sh health 检查。 + ## 故障排查顺序 ```bash diff --git a/docs/version-history.md b/docs/version-history.md index 4f019f76..194a9937 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -16,12 +16,13 @@ ## Current Version - `main` 当前主线历史推导到:`0.16.5` -- `dev` 当前开发分支历史推导到:`0.74.5` +- `dev` 当前开发分支历史推导到:`0.74.6` ## Timeline | Version | Type | Branch | Commit | Summary | | --- | --- | --- | --- | --- | +| `0.74.6` | improvement | `dev` | `v0.74.6` | 自动检测 Docker 构建代理与直连,统一运维错误诊断,支持跳过 AI Provider 构建并修复 Compose 回退与失败退出状态 | | `0.74.5` | improvement | `dev` | `v0.74.5` | 恢复模型预设持久化,统一官方目录刷新与连通性检查,修复 M3 思考模式和 OpenCode 协议路由 | | `0.74.4` | improvement | `dev` | `v0.74.4` | Earth 全量渲染与船舶增量更新优化,直播目录搜索和分页,定位队列状态恢复、模型目录保存及启动提速 | | `0.74.3` | improvement | `dev` | `v0.74.3` | Ubuntu / WSL 初始化自动准备 Docker 及用户权限,修正启动诊断,并在建表前核对数据库端口、实际连接和认证 | diff --git a/frontend/package.json b/frontend/package.json index 087042ae..19132af3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.74.5", + "version": "0.74.6", "private": true, "packageManager": "bun@1", "dependencies": { diff --git a/planet.sh b/planet.sh index 057f876e..224ff9f5 100755 --- a/planet.sh +++ b/planet.sh @@ -146,6 +146,7 @@ PLANET_EMPTY_UV_CONFIG_FILE="$PLANET_STATE_DIR/uv.empty.toml" PLANET_TUNA_UV_CONFIG_FILE="$PLANET_STATE_DIR/uv.tuna.toml" PLANET_UV_CONFIG_FILE="${PLANET_UV_CONFIG_FILE:-}" AI_PROVIDER_RECREATE_REQUIRED=0 +AI_PROVIDER_NO_BUILD=0 START_RUN_ACTIVE=0 START_RUN_COMPLETED=0 STARTED_BACKEND_THIS_RUN=0 @@ -230,6 +231,8 @@ prepare_uv_build_config() { prepare_uv_build_config source "$SCRIPT_DIR/scripts/lib/docker-bootstrap.zsh" +source "$SCRIPT_DIR/scripts/lib/docker-proxy.zsh" +source "$SCRIPT_DIR/scripts/lib/error-diagnostics.zsh" # Shell / Docker helpers is_pid() { @@ -378,13 +381,6 @@ write_port_state() { report_missing_compose() { clear_wait_spinner log_error "未检测到可用的 Docker Compose" - log_note '优先使用 `docker compose`' - log_note "如果当前机器仍只装有旧版 `docker-compose`,也请确认该命令可执行" - log_note "推荐安装 Docker Compose 插件:" - log_note ' mkdir -p ~/.docker/cli-plugins' - log_note ' curl -SL https://github.com/docker/compose/releases/download/v2.40.3/docker-compose-linux-x86_64 -o ~/.docker/cli-plugins/docker-compose' - log_note ' chmod +x ~/.docker/cli-plugins/docker-compose' - log_note ' docker compose version' return 1 } @@ -395,15 +391,13 @@ log_buildx_diagnostics_if_needed() { current_buildx_version="$(buildx_version)" if [ -z "$current_buildx_version" ]; then - log_note "未检测到 docker buildx,AI Provider 镜像构建依赖 buildx。" - log_note "请先执行: docker buildx version" + report_error_reason "未检测到 docker buildx" return 0 fi if ! buildx_meets_minimum "$current_buildx_version" "$required_buildx_version"; then log_note "检测到 docker buildx 当前版本为 v${current_buildx_version},但 compose build 需要 v${required_buildx_version} 或更高。" - log_note "可先升级 buildx 插件,再重新执行 ./planet.sh start" - log_note "请先执行: docker buildx version" + report_error_reason "buildx v${current_buildx_version} 低于要求" fi } @@ -416,11 +410,8 @@ compose_up() { if docker compose "${args[@]}"; then return 0 fi - if ! compose_v1_available; then - log_error "Docker Compose 执行失败: docker compose ${arg_text}" - return 1 - fi - log_warn "docker compose 执行失败,回退到 docker-compose v1" + log_error "Docker Compose 执行失败: docker compose ${arg_text}" + return 1 fi if compose_v1_available; then @@ -429,7 +420,7 @@ compose_up() { return 0 fi clear_wait_spinner - log_error "docker compose 与 docker-compose v1 均执行失败" + log_error "docker-compose v1 执行失败" log_note "最后尝试的命令: docker-compose ${arg_text}" return 1 fi @@ -439,10 +430,12 @@ compose_up() { compose_supports_build() { if compose_available; then - docker compose build --help >/dev/null 2>&1 && return 0 + docker compose build --help >/dev/null 2>&1 + return $? fi if compose_v1_available; then - docker-compose build --help >/dev/null 2>&1 && return 0 + docker-compose build --help >/dev/null 2>&1 + return $? fi return 1 } @@ -705,6 +698,7 @@ log_halt() { log_error() { log_line "fail" "$RED" "$1" + report_error_reason "$1" "${2:-/dev/null}" } log_note() { @@ -1376,12 +1370,20 @@ write_ai_provider_runtime_env_file() { export PLANET_AI_PROVIDER_RUNTIME_ENV_FILE } +compose_up_ai_provider() { + local args=(up -d) + if [ "${AI_PROVIDER_NO_BUILD:-0}" -eq 1 ]; then + args+=(--no-build) + fi + compose_up "${args[@]}" aiprovider +} + recreate_ai_provider_container() { local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}" remove_ai_provider_containers - if compose_up up -d aiprovider >/dev/null 2>&1; then + if compose_up_ai_provider >/dev/null 2>&1; then return 0 fi @@ -1392,6 +1394,16 @@ ensure_ai_provider_image_current() { local current_fingerprint="" local previous_fingerprint="" + if [ "${AI_PROVIDER_NO_BUILD:-0}" -eq 1 ]; then + if ! ai_provider_image_exists; then + log_error "已指定 --no-build,但本地没有 AI Provider 镜像: ${AI_PROVIDER_IMAGE_NAME}" + exit 1 + fi + log_note "跳过 AI Provider 镜像构建,使用本地镜像: ${AI_PROVIDER_IMAGE_NAME}" + AI_PROVIDER_RECREATE_REQUIRED=0 + return 0 + fi + prepare_uv_build_config current_fingerprint="$(compute_ai_provider_build_fingerprint)" @@ -1415,6 +1427,8 @@ ensure_ai_provider_image_current() { AI_PROVIDER_BUILD_FINGERPRINT="$current_fingerprint" export AI_PROVIDER_BUILD_FINGERPRINT + prepare_docker_build_proxy || exit 1 + if ! compose_supports_build; then log_error "当前 docker compose 不支持 build,请检查 Docker / Compose 环境" exit 1 @@ -1422,9 +1436,9 @@ ensure_ai_provider_image_current() { : > "$AI_PROVIDER_BUILD_LOG_FILE" - if ! build_ai_provider_image_with_fallback; then + if ! build_ai_provider_image; then clear_wait_spinner - log_error "AI Provider 镜像构建失败" + log_error "AI Provider 镜像构建失败" "$AI_PROVIDER_BUILD_LOG_FILE" tail -20 "$AI_PROVIDER_BUILD_LOG_FILE" 2>/dev/null || true log_buildx_diagnostics_if_needed exit 1 @@ -1435,26 +1449,17 @@ ensure_ai_provider_image_current() { AI_PROVIDER_RECREATE_REQUIRED=1 } -build_ai_provider_image_with_fallback() { +build_ai_provider_image() { if compose_available; then set_wait_detail "使用 docker compose 构建 AI Provider 镜像" - if run_ai_provider_build_command "docker compose"; then - return 0 - fi - if compose_v1_available; then - log_warn "docker compose 构建失败,回退到 docker-compose v1" - else - log_warn "docker compose 构建失败,当前环境未安装 docker-compose v1,无法继续回退" - return 1 - fi + run_ai_provider_build_command "docker compose" + return $? fi if compose_v1_available; then set_wait_detail "使用 docker-compose v1 构建 AI Provider 镜像" - if run_ai_provider_build_command "docker-compose"; then - return 0 - fi - return 1 + run_ai_provider_build_command "docker-compose" + return $? fi report_missing_compose @@ -1462,13 +1467,18 @@ build_ai_provider_image_with_fallback() { run_ai_provider_build_command() { local compose_command="$1" + local compose_args=("${(@s: :)compose_command}") if [ "$VERBOSE" -eq 1 ]; then - run_command_with_spinner "构建 AI Provider 镜像" sh -c "${compose_command} build aiprovider 2>&1 | tee \"$AI_PROVIDER_BUILD_LOG_FILE\"" + run_command_with_spinner "构建 AI Provider 镜像" zsh -o pipefail -c \ + 'log_file="$1"; shift; "$@" 2>&1 | tee "$log_file"' \ + planet-build "$AI_PROVIDER_BUILD_LOG_FILE" "${compose_args[@]}" build aiprovider return $? fi - run_command_with_spinner "构建 AI Provider 镜像" sh -c "${compose_command} build aiprovider > \"$AI_PROVIDER_BUILD_LOG_FILE\" 2>&1" + run_command_with_spinner "构建 AI Provider 镜像" zsh -c \ + 'log_file="$1"; shift; "$@" > "$log_file" 2>&1' \ + planet-build "$AI_PROVIDER_BUILD_LOG_FILE" "${compose_args[@]}" build aiprovider } install_uv_if_needed() { @@ -2572,7 +2582,7 @@ start_ai_provider_service() { return 0 fi fi - elif docker start "$AI_PROVIDER_CONTAINER_NAME" >/dev/null 2>&1 || compose_up up -d aiprovider >/dev/null 2>&1; then + elif docker start "$AI_PROVIDER_CONTAINER_NAME" >/dev/null 2>&1 || compose_up_ai_provider >/dev/null 2>&1; then if wait_for_http "http://localhost:${ai_provider_port}/health" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider"; then return 0 fi @@ -3396,6 +3406,7 @@ parse_service_args() { BACKEND_PORT_REQUESTED=0 FRONTEND_PORT_REQUESTED=0 AI_PROVIDER_REQUESTED=0 + AI_PROVIDER_NO_BUILD=0 MOTION_AGENT_REQUESTED=1 MOTION_AGENT_EXPLICIT_REQUESTED=0 MOTION_AGENT_DISABLED=0 @@ -3439,6 +3450,10 @@ parse_service_args() { shift 1 fi ;; + --no-build) + AI_PROVIDER_NO_BUILD=1 + shift 1 + ;; -m|--motion-agent) MOTION_AGENT_REQUESTED=1 MOTION_AGENT_EXPLICIT_REQUESTED=1 @@ -4706,6 +4721,7 @@ case "$1" in *) log_error "用法: ./planet.sh {init|start|stop|destroy|restart|createuser|health|log}" log_note "全局参数: -v, --verbose 在状态提示之间增量输出命令日志" + log_note "start / restart 可加 --no-build:AI Provider 仅使用本地已有镜像,缺少镜像时报错" log_note "init 首次初始化空项目: 同步 uv/bun 依赖、生成缺失 env、启动数据库并写入默认数据" log_note "start 启动服务,默认包含 Motion Agent;可选: -b <后端端口> -f <前端端口> -a --non-motion-agent -m/--motion-agent --motion-agent-port <端口> --motion-agent-mode auto|single|dual_redundant|single_fallback --motion-agent-camera-indexes 0,1 --motion-agent-camera-urls rtsp://... --motion-agent-wsl-usbipd --motion-agent-wsl-usbipd-busid --motion-agent-dry-run --allow-lan --verbose" log_note "stop 停止服务" diff --git a/pyproject.toml b/pyproject.toml index d4710a99..b4d91090 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.74.5" +version = "0.74.6" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [ diff --git a/rules.md b/rules.md index d205c5d8..d9cb5c28 100644 --- a/rules.md +++ b/rules.md @@ -166,6 +166,7 @@ Always. - `rules.md` is the active repository rule source. Keep durable constraints here instead of duplicating them across prompt files. - Use `.codex/skills/` for specialized Codex workflows such as cleanup, docs, goal-driven work, and release. - Do not add new legacy harness entry points when an existing skill or rule module can carry the same instruction. +- Keep confirmed operational failure causes in the error catalog in `docs/technical/{zh,en}/ops-runbook.md`. `planet.sh` diagnostics must read causes and remedies from this catalog. Add a stable error code, matching evidence, and a regression case when fixing a previously unclassified cause; unknown failures must remain explicitly unclassified until verified. - If a harness rule is no longer true for the current toolchain, update or delete it in the same cleanup pass. ### Git diff --git a/scripts/docker_proxy.py b/scripts/docker_proxy.py new file mode 100644 index 00000000..39f01ce7 --- /dev/null +++ b/scripts/docker_proxy.py @@ -0,0 +1,237 @@ +"""Select a reachable build route and manage only Planet-owned daemon proxy settings.""" + +from __future__ import annotations + +import concurrent.futures +import fcntl +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import tempfile +from urllib.parse import urlsplit + +CONFIG = Path("/etc/docker/daemon.json") +STATE = Path("/etc/docker/planet-proxy-state.json") +PROXY_KEYS = ("http-proxy", "https-proxy", "no-proxy") +PROXY_ENV = ("HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy") +PROXY_SCHEMES = ("http", "https") +DEFAULT_NO_PROXY = "localhost,127.0.0.1,::1" +CONFIG_ERROR = "PLANET_PROXY_CONFIG_FAILED" +COMMAND_TIMEOUT_SECONDS = 60 +CONNECT_TIMEOUT_SECONDS = 3 +REQUEST_TIMEOUT_SECONDS = 8 +PROBE_PROCESS_TIMEOUT_SECONDS = 10 + + +class ProxyError(Exception): + pass + + +def run(args: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + return subprocess.run( + args, text=True, capture_output=True, check=True, timeout=COMMAND_TIMEOUT_SECONDS, **kwargs + ) + + +def daemon_proxies() -> dict[str, str]: + info = json.loads(run(["docker", "info", "--format", "{{json .}}"]).stdout) + values = dict( + zip(PROXY_KEYS, (info.get("HttpProxy"), info.get("HttpsProxy"), info.get("NoProxy"))) + ) + return {key: value for key, value in values.items() if value} + + +def registry_urls(environment: dict[str, str]) -> list[str]: + dockerfile = Path(__file__).resolve().parents[1] / "aiprovider/Dockerfile" + defaults = dict(re.findall(r"^ARG (PYTHON_IMAGE|UV_IMAGE)=(.+)$", dockerfile.read_text(), re.M)) + registries = set() + for key in ("PYTHON_IMAGE", "UV_IMAGE"): + image = environment.get(key) or defaults[key] + prefix = image.split("/")[0] + registry = ( + prefix + if "/" in image and ("." in prefix or ":" in prefix or prefix == "localhost") + else "docker.io" + ) + registries.add("registry-1.docker.io" if registry == "docker.io" else registry) + return [f"https://{registry}/v2/" for registry in sorted(registries)] + + +def probe_url(url: str, proxy: str, no_proxy: str = "") -> bool: + environment = { + key: value + for key, value in os.environ.items() + if key.lower() not in ("http_proxy", "https_proxy", "all_proxy", "no_proxy") + } + if proxy: + environment.update(http_proxy=proxy, https_proxy=proxy) + try: + result = subprocess.run( + [ + "curl", + "-q", + "--silent", + "--output", + "/dev/null", + "--write-out", + "%{http_code}", + "--noproxy", + no_proxy if proxy else "", + "--connect-timeout", + str(CONNECT_TIMEOUT_SECONDS), + "--max-time", + str(REQUEST_TIMEOUT_SECONDS), + url, + ], + env=environment, + text=True, + capture_output=True, + timeout=PROBE_PROCESS_TIMEOUT_SECONDS, + ) + return result.returncode == 0 and ( + result.stdout.startswith("2") or result.stdout in ("401", "403", "429") + ) + except (OSError, subprocess.TimeoutExpired): + return False + + +def reachable(urls: list[str], proxy: str, no_proxy: str = "") -> bool: + with concurrent.futures.ThreadPoolExecutor(max_workers=len(urls)) as pool: + return all(pool.map(lambda url: probe_url(url, proxy, no_proxy), urls)) + + +def select_route(environment: dict[str, str], urls: list[str]) -> dict[str, object]: + candidates = list(dict.fromkeys(environment[key] for key in PROXY_ENV if environment.get(key))) + no_proxy = environment.get("NO_PROXY") or environment.get("no_proxy") or DEFAULT_NO_PROXY + for proxy in candidates: + if urlsplit(proxy).scheme not in PROXY_SCHEMES: + continue + if reachable(urls, proxy, no_proxy): + return { + "desired": dict(zip(PROXY_KEYS, (proxy, proxy, no_proxy))), + "mode": "proxy", + "reachable": True, + } + return {"desired": {}, "mode": "direct", "reachable": reachable(urls, "")} + + +def atomic_write(path: Path, contents: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + try: + with os.fdopen(descriptor, "wb") as output: + output.write(contents) + os.replace(temporary, path) + finally: + Path(temporary).unlink(missing_ok=True) + + +def encode(value: dict[str, object]) -> bytes: + return (json.dumps(value, indent=2) + "\n").encode() + + +def validate_plan(plan: dict[str, object]) -> None: + for field in ("current", "desired"): + values = plan.get(field) + if not isinstance(values, dict) or set(values) - set(PROXY_KEYS): + raise ProxyError(CONFIG_ERROR) + if any(not isinstance(value, str) or "\n" in value for value in values.values()): + raise ProxyError(CONFIG_ERROR) + for key in ("http-proxy", "https-proxy"): + value = plan["desired"].get(key, "") + if value and urlsplit(value).scheme not in PROXY_SCHEMES: + raise ProxyError(CONFIG_ERROR) + + +def restore_containers(containers: list[str]) -> None: + if containers: + run(["docker", "start", *containers]) + + +def apply_plan(plan: dict[str, object], config: Path = CONFIG, state: Path = STATE) -> None: + validate_plan(plan) + current = daemon_proxies() + if current != plan["current"]: + raise ProxyError("PLANET_PROXY_CONFIG_CHANGED") + if current == plan["desired"]: + return + original = config.read_bytes() if config.exists() else None + data = json.loads(original) if original else {} + owned = json.loads(state.read_text()) if state.exists() else None + configured = {key: value for key, value in data.get("proxies", {}).items() if value} + if (configured or current) and ( + not owned or owned.get("managed") != configured or current != configured + ): + raise ProxyError("PLANET_PROXY_EXTERNAL") + if plan["desired"]: + data["proxies"] = plan["desired"] + else: + data.pop("proxies", None) + containers = run(["docker", "ps", "-q"]).stdout.split() + if original is not None: + atomic_write(config.with_suffix(".json.planet-proxy.bak"), original) + atomic_write(config, encode(data)) + restart_requested = False + try: + run(["dockerd", "--validate", "--config-file", str(config)]) + restart_requested = True + run(["systemctl", "restart", "docker"]) + restore_containers(containers) + if daemon_proxies() != plan["desired"]: + raise ProxyError(CONFIG_ERROR) + atomic_write(state, encode({"managed": plan["desired"]})) + except Exception as error: + rollback(config, original, containers, restart_requested) + raise ProxyError(CONFIG_ERROR) from error + + +def rollback( + config: Path, original: bytes | None, containers: list[str], restart_requested: bool +) -> None: + if original is None: + config.unlink(missing_ok=True) + else: + atomic_write(config, original) + if not restart_requested: + return + try: + run(["systemctl", "restart", "docker"]) + restore_containers(containers) + except Exception as error: + raise ProxyError("PLANET_PROXY_ROLLBACK_FAILED") from error + + +def main() -> None: + action = sys.argv[1] + if action == "plan": + plan = select_route(dict(os.environ), registry_urls(dict(os.environ))) + plan["current"] = daemon_proxies() + json.dump(plan, sys.stdout) + elif action == "status": + plan = json.load(sys.stdin) + changed = int(plan["current"] != plan["desired"]) + print(plan["mode"], changed, int(plan["reachable"])) + elif action == "apply": + if os.geteuid() != 0: + raise ProxyError(CONFIG_ERROR) + CONFIG.parent.mkdir(parents=True, exist_ok=True) + with (CONFIG.parent / "planet-proxy.lock").open("a") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + apply_plan(json.load(sys.stdin)) + else: + raise ProxyError(CONFIG_ERROR) + + +if __name__ == "__main__": + try: + main() + except ProxyError as error: + sys.stderr.write(str(error) + "\n") + sys.exit(1) + except Exception: + # Subprocess output and proxy URLs may contain credentials. + sys.stderr.write(CONFIG_ERROR + "\n") + sys.exit(1) diff --git a/scripts/harness/quick-check.sh b/scripts/harness/quick-check.sh index 91adeca6..cbd29284 100755 --- a/scripts/harness/quick-check.sh +++ b/scripts/harness/quick-check.sh @@ -14,8 +14,12 @@ main() { harness_run git diff --check harness_run zsh -n planet.sh harness_run zsh -n scripts/lib/docker-bootstrap.zsh + harness_run zsh -n scripts/lib/docker-proxy.zsh + harness_run zsh -n scripts/lib/error-diagnostics.zsh harness_run "$uv_bin" run --frozen --project "$ROOT_DIR" python scripts/harness/test_docker_bootstrap.py + harness_run "$uv_bin" run --frozen --project "$ROOT_DIR" python scripts/harness/test_docker_proxy.py harness_run "$uv_bin" run --frozen --project "$ROOT_DIR" python scripts/harness/test_database_startup.py + harness_run "$uv_bin" run --frozen --project "$ROOT_DIR" python scripts/harness/test_error_diagnostics.py harness_run bash -n scripts/bootstrap-dev.sh harness_run bash -n scripts/harness/lib.sh harness_run bash -n scripts/harness/doctor.sh diff --git a/scripts/harness/test_database_startup.py b/scripts/harness/test_database_startup.py index b921f5b8..61206279 100644 --- a/scripts/harness/test_database_startup.py +++ b/scripts/harness/test_database_startup.py @@ -43,6 +43,74 @@ def run_shell(functions: list[str], setup: str, action: str) -> subprocess.Compl ) +class ComposeSelectionTests(unittest.TestCase): + def test_operations_use_v1_only_when_v2_is_unavailable(self) -> None: + operations = ( + ("compose_up", "up -d postgres"), + ("compose_supports_build", "build --help"), + ("build_ai_provider_image", "build aiprovider"), + ) + for function, arguments in operations: + for v2_available, v1_available in ( + (True, True), + (True, False), + (False, True), + (False, False), + ): + for command_status in (0, 1): + with self.subTest( + function=function, + v2=v2_available, + v1=v1_available, + command_status=command_status, + ): + with tempfile.TemporaryDirectory() as directory: + calls_file = Path(directory) / "calls" + action = function + if function == "compose_up": + action += " " + arguments + result = run_shell( + [function], + f""" + compose_available() {{ return {int(not v2_available)}; }} + compose_v1_available() {{ return {int(not v1_available)}; }} + docker() {{ + echo "docker $*" >> '{calls_file}' + echo ORIGINAL_ERROR >&2 + return {command_status} + }} + docker-compose() {{ + echo "docker-compose $*" >> '{calls_file}' + return {command_status} + }} + run_ai_provider_build_command() {{ + echo "$1 build aiprovider" >> '{calls_file}' + echo ORIGINAL_ERROR >&2 + return {command_status} + }} + set_wait_detail() {{ :; }} + clear_wait_spinner() {{ :; }} + log_error() {{ echo "$*"; }} + log_note() {{ echo "$*"; }} + log_warn() {{ echo "$*"; }} + report_missing_compose() {{ echo MISSING_COMPOSE; return 1; }} + """, + f"if {action}; then exit 0; else exit $?; fi", + ) + calls = ( + calls_file.read_text().splitlines() if calls_file.exists() else [] + ) + command = "docker compose" if v2_available else "docker-compose" + available = v2_available or v1_available + self.assertEqual(calls, [f"{command} {arguments}"] if available else []) + self.assertEqual(result.returncode, command_status if available else 1) + self.assertNotIn("回退", result.stdout) + if available: + self.assertNotIn("MISSING_COMPOSE", result.stdout) + if v2_available and function != "compose_supports_build": + self.assertIn("ORIGINAL_ERROR", result.stderr) + + class DatabaseLifecycleTests(unittest.TestCase): def test_ai_start_uses_host_readiness_without_waiting_for_docker_probe_schedule(self) -> None: for recreate in (0, 1): diff --git a/scripts/harness/test_docker_proxy.py b/scripts/harness/test_docker_proxy.py new file mode 100644 index 00000000..346fba3c --- /dev/null +++ b/scripts/harness/test_docker_proxy.py @@ -0,0 +1,236 @@ +"""Automatic proxy routing and rollback tests; never restart the host Docker daemon.""" + +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +from test_database_startup import run_shell + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "scripts")) + +import docker_proxy as proxy # noqa: E402 + +URLS = ["https://registry.example/v2/"] +PROXIES = { + "http-proxy": "http://localhost:1234", + "https-proxy": "http://localhost:1234", + "no-proxy": "localhost,127.0.0.1,::1", +} + + +class DockerProxyTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory(prefix="planet-proxy-test-") + self.addCleanup(self.temporary.cleanup) + self.config = Path(self.temporary.name) / "daemon.json" + self.state = Path(self.temporary.name) / "proxy-state.json" + + def test_no_host_proxy_uses_direct_without_adding_configuration(self) -> None: + with patch.object(proxy, "reachable", return_value=True) as probe: + plan = proxy.select_route({}, URLS) + self.assertEqual(plan, {"desired": {}, "mode": "direct", "reachable": True}) + probe.assert_called_once_with(URLS, "") + plan["current"] = {} + with ( + patch.object(proxy, "daemon_proxies", return_value={}), + patch.object(proxy, "run") as run, + ): + proxy.apply_plan(plan, self.config, self.state) + run.assert_not_called() + self.assertFalse(self.config.exists()) + + def test_proxy_is_selected_only_after_registry_probe(self) -> None: + with patch.object(proxy, "reachable", return_value=True) as probe: + plan = proxy.select_route({"https_proxy": PROXIES["https-proxy"]}, URLS) + self.assertEqual(plan["desired"], PROXIES) + probe.assert_called_once_with(URLS, PROXIES["https-proxy"], PROXIES["no-proxy"]) + + def test_dead_proxy_falls_back_to_direct(self) -> None: + with patch.object(proxy, "reachable", side_effect=[False, True]) as probe: + plan = proxy.select_route({"HTTPS_PROXY": PROXIES["https-proxy"]}, URLS) + self.assertEqual(plan["desired"], {}) + self.assertTrue(plan["reachable"]) + self.assertEqual(probe.call_count, 2) + + def test_both_routes_failing_is_not_reported_as_connected(self) -> None: + with patch.object(proxy, "reachable", return_value=False): + plan = proxy.select_route({"HTTP_PROXY": PROXIES["http-proxy"]}, URLS) + self.assertEqual(plan["mode"], "direct") + self.assertFalse(plan["reachable"]) + + def test_no_proxy_rules_are_preserved_in_probe_and_config(self) -> None: + with patch.object(proxy, "reachable", return_value=True) as probe: + plan = proxy.select_route( + {"HTTPS_PROXY": PROXIES["https-proxy"], "NO_PROXY": "*"}, URLS + ) + self.assertEqual(plan["desired"]["no-proxy"], "*") + probe.assert_called_once_with(URLS, PROXIES["https-proxy"], "*") + + def test_actual_image_registry_overrides_are_probed(self) -> None: + urls = proxy.registry_urls( + { + "PYTHON_IMAGE": "internal.example:5000/python:3", + "UV_IMAGE": "internal.example:5000/uv:latest", + } + ) + self.assertEqual(urls, ["https://internal.example:5000/v2/"]) + + def test_only_actual_builds_prepare_proxy_and_failed_preparation_stops_build(self) -> None: + for no_build, fingerprint, proxy_status, expected_calls in ( + (1, "old", 0, []), + (0, "new", 0, []), + (0, "old", 0, ["PROXY_CHECK", "BUILD"]), + (0, "old", 1, ["PROXY_CHECK"]), + ): + with self.subTest(no_build=no_build, fingerprint=fingerprint, status=proxy_status): + result = run_shell( + ["ensure_ai_provider_image_current"], + f""" + AI_PROVIDER_NO_BUILD={no_build} + AI_PROVIDER_BUILD_LOG_FILE=/dev/null + prepare_uv_build_config() {{ :; }} + compute_ai_provider_build_fingerprint() {{ echo new; }} + read_ai_provider_build_stamp() {{ echo old; }} + ai_provider_image_exists() {{ return 0; }} + read_ai_provider_image_fingerprint() {{ echo {fingerprint}; }} + write_ai_provider_build_stamp() {{ :; }} + set_wait_detail() {{ :; }} + log_note() {{ :; }} + log_success() {{ :; }} + compose_supports_build() {{ return 0; }} + prepare_docker_build_proxy() {{ echo PROXY_CHECK; return {proxy_status}; }} + build_ai_provider_image() {{ echo BUILD; }} + """, + "ensure_ai_provider_image_current", + ) + self.assertEqual(result.stdout.splitlines(), expected_calls) + self.assertEqual(result.returncode, proxy_status) + + def fake_run(self, args: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + output = "postgres-id\nredis-id\n" if args == ["docker", "ps", "-q"] else "" + return subprocess.CompletedProcess(args, 0, output, "") + + def test_add_proxy_preserves_other_settings_and_restores_containers(self) -> None: + original = b'{"log-driver": "local"}\n' + self.config.write_bytes(original) + with ( + patch.object(proxy, "daemon_proxies", side_effect=[{}, PROXIES]), + patch.object(proxy, "run", side_effect=self.fake_run) as run, + ): + proxy.apply_plan({"current": {}, "desired": PROXIES}, self.config, self.state) + self.assertEqual( + json.loads(self.config.read_text()), {"log-driver": "local", "proxies": PROXIES} + ) + self.assertEqual(json.loads(self.state.read_text())["managed"], PROXIES) + self.assertEqual(self.config.with_suffix(".json.planet-proxy.bak").read_bytes(), original) + self.assertEqual(self.state.stat().st_mode & 0o777, 0o600) + run.assert_any_call(["docker", "start", "postgres-id", "redis-id"]) + + def test_remove_owned_proxy_when_host_proxy_disappears(self) -> None: + self.config.write_text(json.dumps({"proxies": PROXIES, "log-driver": "local"})) + self.state.write_text(json.dumps({"managed": PROXIES})) + with ( + patch.object(proxy, "daemon_proxies", side_effect=[PROXIES, {}]), + patch.object(proxy, "run", side_effect=self.fake_run), + ): + proxy.apply_plan({"current": PROXIES, "desired": {}}, self.config, self.state) + self.assertEqual(json.loads(self.config.read_text()), {"log-driver": "local"}) + + def test_unchanged_proxy_does_not_restart_docker(self) -> None: + with ( + patch.object(proxy, "daemon_proxies", return_value=PROXIES), + patch.object(proxy, "run") as run, + ): + proxy.apply_plan({"current": PROXIES, "desired": PROXIES}, self.config, self.state) + run.assert_not_called() + + def test_user_managed_proxy_is_not_overwritten(self) -> None: + original = json.dumps({"proxies": PROXIES}) + self.config.write_text(original) + with ( + patch.object(proxy, "daemon_proxies", return_value=PROXIES), + patch.object(proxy, "run") as run, + self.assertRaisesRegex(proxy.ProxyError, "PLANET_PROXY_EXTERNAL"), + ): + proxy.apply_plan({"current": PROXIES, "desired": {}}, self.config, self.state) + run.assert_not_called() + self.assertEqual(self.config.read_text(), original) + + def test_external_edit_of_managed_config_is_not_overwritten(self) -> None: + changed = {**PROXIES, "https-proxy": "http://different.example:8080"} + self.config.write_text(json.dumps({"proxies": changed})) + self.state.write_text(json.dumps({"managed": PROXIES})) + with ( + patch.object(proxy, "daemon_proxies", return_value=changed), + self.assertRaisesRegex(proxy.ProxyError, "PLANET_PROXY_EXTERNAL"), + ): + proxy.apply_plan({"current": changed, "desired": {}}, self.config, self.state) + + def test_restart_failure_restores_original_config_and_containers(self) -> None: + original = b'{"log-driver": "local"}\n' + self.config.write_bytes(original) + restarts = 0 + + def fail_first_restart(args: list[str]) -> subprocess.CompletedProcess[str]: + nonlocal restarts + if args == ["systemctl", "restart", "docker"]: + restarts += 1 + if restarts == 1: + raise subprocess.CalledProcessError(1, args) + return self.fake_run(args) + + with ( + patch.object(proxy, "daemon_proxies", return_value={}), + patch.object(proxy, "run", side_effect=fail_first_restart) as run, + self.assertRaisesRegex(proxy.ProxyError, "PLANET_PROXY_CONFIG_FAILED"), + ): + proxy.apply_plan({"current": {}, "desired": PROXIES}, self.config, self.state) + self.assertEqual(self.config.read_bytes(), original) + self.assertFalse(self.state.exists()) + self.assertEqual(restarts, 2) + run.assert_any_call(["docker", "start", "postgres-id", "redis-id"]) + + def test_probe_does_not_put_proxy_credentials_in_command_arguments(self) -> None: + with patch.object( + proxy.subprocess, "run", return_value=subprocess.CompletedProcess([], 0, "401") + ) as run: + self.assertTrue(proxy.probe_url(URLS[0], "http://user:secret@proxy.example:1234")) + self.assertNotIn("secret", " ".join(run.call_args.args[0])) + self.assertIn("secret", run.call_args.kwargs["env"]["https_proxy"]) + + def test_registry_rejection_does_not_mark_the_proxy_unreachable(self) -> None: + for status in ("401", "403", "429"): + with ( + self.subTest(status=status), + patch.object( + proxy.subprocess, "run", return_value=subprocess.CompletedProcess([], 0, status) + ), + ): + self.assertTrue(proxy.probe_url(URLS[0], PROXIES["https-proxy"])) + + def test_validation_failure_restores_config_without_restarting_docker(self) -> None: + original = b'{"log-driver": "local"}\n' + self.config.write_bytes(original) + + def fail_validation(args: list[str]) -> subprocess.CompletedProcess[str]: + if args[0] == "dockerd": + raise subprocess.CalledProcessError(1, args) + return self.fake_run(args) + + with ( + patch.object(proxy, "daemon_proxies", return_value={}), + patch.object(proxy, "run", side_effect=fail_validation) as run, + self.assertRaisesRegex(proxy.ProxyError, "PLANET_PROXY_CONFIG_FAILED"), + ): + proxy.apply_plan({"current": {}, "desired": PROXIES}, self.config, self.state) + self.assertEqual(self.config.read_bytes(), original) + self.assertFalse(any(call.args[0][0] == "systemctl" for call in run.call_args_list)) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/harness/test_error_diagnostics.py b/scripts/harness/test_error_diagnostics.py new file mode 100644 index 00000000..565e57fd --- /dev/null +++ b/scripts/harness/test_error_diagnostics.py @@ -0,0 +1,152 @@ +"""Verify catalog-driven diagnostics without touching Docker or live services.""" + +import os +from pathlib import Path +import re +import shlex +import subprocess +import tempfile +import unittest + +from test_database_startup import run_shell, shell_function + +ROOT = Path(__file__).resolve().parents[2] +MODULE = ROOT / "scripts/lib/error-diagnostics.zsh" + + +def catalog(language: str) -> dict[str, list[str]]: + text = (ROOT / f"docs/technical/{language}/ops-runbook.md").read_text() + entries = {} + for line in text.splitlines(): + if line.startswith("| P_"): + fields = [field.strip() for field in line.split("|")[1:-1]] + assert len(fields) == 4, line + assert fields[0] not in entries, fields[0] + entries[fields[0]] = fields[1:] + return entries + + +class ErrorDiagnosticsTests(unittest.TestCase): + def diagnose(self, message: str, evidence: str = "") -> list[str]: + with tempfile.TemporaryDirectory() as folder: + log = Path(folder) / "build.log" + log.write_text(evidence) + result = subprocess.run( + [ + "zsh", + "-f", + "-c", + f""" + SCRIPT_DIR={shlex.quote(str(ROOT))} + source {shlex.quote(str(MODULE))} + planet_error_record "$1" "$2" + """, + "test-diagnostics", + message, + str(log), + ], + text=True, + capture_output=True, + timeout=10, + check=True, + ) + return result.stdout.strip().split("\t") + + def test_bilingual_catalogs_have_identical_codes_and_matching_fragments(self) -> None: + zh, en = catalog("zh"), catalog("en") + self.assertGreater(len(zh), 0) + self.assertEqual(list(zh), list(en)) + self.assertEqual(list(zh)[-1], "P_UNKNOWN") + for code, (signals, reason, action) in zh.items(): + with self.subTest(code=code): + self.assertEqual(signals, en[code][0]) + self.assertTrue(reason and action) + self.assertNotEqual(reason, en[code][1]) + + def test_every_recorded_fragment_returns_the_exact_catalog_wording(self) -> None: + for code, (signals, reason, action) in catalog("zh").items(): + for signal in signals.split(";"): + with self.subTest(code=code, signal=signal): + self.assertEqual(self.diagnose(signal), [code, reason, action]) + + def test_specific_build_evidence_precedes_generic_summary(self) -> None: + for evidence, code in ( + ( + 'Head "https://registry-1.docker.io/v2/test": dial tcp [::1]:443: i/o timeout', + "P_NETWORK_TIMEOUT", + ), + ( + 'Head "https://ghcr.io/v2/test": net/http: TLS handshake timeout', + "P_NETWORK_TIMEOUT", + ), + ("failed to solve: lookup registry.example: no such host", "P_DNS"), + ("X509: certificate signed by unknown authority", "P_TLS_CERT"), + ( + "failed to solve: unexpected status from HEAD request: 429 Too Many Requests", + "P_REGISTRY_RATE_LIMIT", + ), + ("new unexpected build error", "P_BUILD_FAILED"), + ): + with self.subTest(code=code): + result = self.diagnose("AI Provider 镜像构建失败", evidence + " TOKEN_SENTINEL") + self.assertEqual(result[0], code) + self.assertNotIn("TOKEN_SENTINEL", " ".join(result)) + + def test_unknown_error_does_not_claim_a_network_or_proxy_cause(self) -> None: + self.assertEqual(self.diagnose("an unrecognized failure")[0], "P_UNKNOWN") + + def test_existing_literal_shell_errors_have_catalog_entries(self) -> None: + for path in (ROOT / "planet.sh", ROOT / "scripts/lib/docker-bootstrap.zsh"): + for message in re.findall(r'^\s*log_error "([^"\n]+)"', path.read_text(), re.M): + message = re.sub(r"\$\{[^}]+\}|\$[0-9]+", "", message) + if not message.strip(): + continue # Dynamic retry failures are classified using their runtime message. + with self.subTest(path=path.name, message=message): + self.assertNotEqual(self.diagnose(message)[0], "P_UNKNOWN") + + def test_log_error_reads_cause_and_remedy_from_the_table(self) -> None: + result = subprocess.run( + ["zsh", "-f"], + input=f""" + SCRIPT_DIR={shlex.quote(str(ROOT))} + source {shlex.quote(str(MODULE))} + log_line() {{ echo "$3"; }} + log_note() {{ echo "$1"; }} + {shell_function('log_error')} + log_error 'new failure' + """, + text=True, + capture_output=True, + timeout=10, + check=True, + ) + _, reason, action = catalog("zh")["P_UNKNOWN"] + self.assertIn(f"原因 [P_UNKNOWN]: {reason}", result.stdout) + self.assertIn(f"处理: {action}", result.stdout) + + def test_verbose_build_preserves_failure_and_literal_log_path(self) -> None: + with tempfile.TemporaryDirectory() as folder: + binary = Path(folder) / "docker" + binary.write_text("#!/bin/sh\nprintf 'TLS handshake timeout\\n'\nexit 17\n") + binary.chmod(0o755) + for verbose in (0, 1): + with self.subTest(verbose=verbose): + log = Path(folder) / "build ' quoted $(not-a-command).log" + result = run_shell( + ["run_ai_provider_build_command"], + f""" + PATH={shlex.quote(folder + ':' + os.environ['PATH'])} + VERBOSE={verbose} + AI_PROVIDER_BUILD_LOG_FILE={shlex.quote(str(log))} + run_command_with_spinner() {{ shift; "$@"; }} + """, + 'if run_ai_provider_build_command "docker compose"; ' + "then exit 0; else exit $?; fi", + ) + self.assertEqual(result.returncode, 17, result.stderr) + self.assertEqual(log.read_text(), "TLS handshake timeout\n") + self.assertNotIn("not-a-command", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/lib/docker-proxy.zsh b/scripts/lib/docker-proxy.zsh new file mode 100644 index 00000000..d25f5786 --- /dev/null +++ b/scripts/lib/docker-proxy.zsh @@ -0,0 +1,50 @@ +#!/usr/bin/env zsh + +prepare_docker_build_proxy() { + # Desktop and remote/rootless daemons are managed in their own environment. + docker_uses_local_engine || return 0 + docker_desktop_present && return 0 + + local python_bin="$(command -v python3)" + local helper="$SCRIPT_DIR/scripts/docker_proxy.py" + local plan_file error_file + local mode changed connected status_text + if [ -z "$python_bin" ] || ! plan_file="$(mktemp "$PLANET_STATE_DIR/docker-proxy.XXXXXX")"; then + log_error "Docker 构建代理检测失败" + return 1 + fi + error_file="${plan_file}.error" + chmod 600 "$plan_file" + : > "$error_file" + chmod 600 "$error_file" + { + if ! "$python_bin" "$helper" plan > "$plan_file" 2> "$error_file"; then + log_error "Docker 构建代理检测失败" "$error_file" + return 1 + fi + if ! status_text="$("$python_bin" "$helper" status < "$plan_file" 2> "$error_file")"; then + log_error "Docker 构建代理检测失败" "$error_file" + return 1 + fi + read -r mode changed connected <<< "$status_text" + if [ "$changed" -eq 1 ]; then + docker_require_sudo || return 1 + log_note "更新 Docker 构建代理配置,重启后恢复原先运行的容器" + if ! docker_as_root "$python_bin" "$helper" apply < "$plan_file" 2> "$error_file"; then + log_error "Docker 构建代理更新失败" "$error_file" + return 1 + fi + fi + if [ "$connected" -ne 1 ]; then + log_error "PLANET_PROXY_NO_ROUTE" + return 1 + fi + if [ "$mode" = proxy ]; then + log_note "Docker 构建使用已验证可用的主机代理" + else + log_note "未发现可用主机代理,Docker 构建使用直连" + fi + } always { + rm -f -- "$plan_file" "$error_file" + } +} diff --git a/scripts/lib/error-diagnostics.zsh b/scripts/lib/error-diagnostics.zsh new file mode 100644 index 00000000..297eda21 --- /dev/null +++ b/scripts/lib/error-diagnostics.zsh @@ -0,0 +1,61 @@ +#!/usr/bin/env zsh + +# The operator-facing table is also the runtime source of diagnostic wording. +planet_error_record() { + local message="$1" + local evidence_file="${2:-/dev/null}" + local catalog="$SCRIPT_DIR/docs/technical/zh/ops-runbook.md" + [ -r "$catalog" ] || return 1 + [ -r "$evidence_file" ] || evidence_file=/dev/null + + awk -F '|' -v message="$message" ' + function trim(value) { + sub(/^[[:space:]]+/, "", value) + sub(/[[:space:]]+$/, "", value) + return value + } + NR == FNR { + if ($0 == "") active = 1 + if ($0 == "") active = 0 + if (active && trim($2) ~ /^P_[A-Z_]+$/) { + count++ + codes[count] = trim($2) + signals[count] = tolower(trim($3)) + reasons[count] = trim($4) + actions[count] = trim($5) + if (codes[count] == "P_UNKNOWN") fallback = count + } + next + } + { evidence = evidence "\n" tolower($0) } + END { + evidence = tolower(message) "\n" evidence + selected = fallback + for (i = 1; i <= count; i++) { + if (i == fallback) continue + size = split(signals[i], patterns, ";") + for (j = 1; j <= size; j++) { + pattern = trim(patterns[j]) + if (length(pattern) && index(evidence, pattern)) { + selected = i + break + } + } + if (selected != fallback) break + } + if (!selected) exit 1 + printf "%s\t%s\t%s\n", codes[selected], reasons[selected], actions[selected] + } + ' "$catalog" "$evidence_file" +} + +report_error_reason() { + local record code reason action + if ! record="$(planet_error_record "$1" "${2:-/dev/null}")"; then + log_note "无法读取运维错误对照表,请检查 docs/technical/zh/ops-runbook.md" + return 0 + fi + IFS=$'\t' read -r code reason action <<< "$record" + log_note "原因 [${code}]: ${reason}" + log_note "处理: ${action}" +} diff --git a/uv.lock b/uv.lock index 478869c4..f97e4f99 100644 --- a/uv.lock +++ b/uv.lock @@ -757,7 +757,7 @@ wheels = [ [[package]] name = "planet" -version = "0.74.5" +version = "0.74.6" source = { virtual = "." } dependencies = [ { name = "aiofiles" },