# M4 迁移验证设计(GovDoc-SaaS → CHSAnalyzer,发 v1.0)

> **状态**: 待人类门审批
> **依据**: ROADMAP §5、ARCHITECTURE §11、migrations/{govdoc-saas,chsanalyzer}.md;用户拍板(2026-07-22)四项见 §1。
> **一句话**: 把 polygateway 真实合并进两个项目本体仓库,以"删治理代码 → 换库 import → 原测试全绿 + 真实冒烟 + 真实回归"完成库的最终验收,随后发 v1.0.0 至 Gitea PyPI。

## 1. 输入:用户拍板与既定事实

| 决策项 | 拍板结果(2026-07-22) |
|---|---|
| 仓库与写权限 | 开放 reference/ 修改权限,直接在其中实施;实施前三仓库已更新到最新(CHS 拉到 7eb8482,GovDoc/VT 本已最新) |
| 迁移顺序 | 先 GovDoc-SaaS,后 CHSAnalyzer;**Video-Tree-TRM5 项目已放弃,不迁移** |
| Q1 打包分发 | Gitea PyPI 包注册(gitea.iomgaa.online,版本接口实测可达;Gitea ≥1.17 内置 PyPI registry) |
| 验收方式 | 原测试全绿 + 真实冒烟 + 真实回归;回归具体方案后续与用户商量(§9 给出候选框架) |

**VT 放弃的连带处置**: ROADMAP/ARCHITECTURE §11.2/migrations/video-tree-trm5.md 标注放弃(§15);v1.0 验收标准由"三项目"改"两项目"。库侧**不删减**任何已交付公共 API(OcrTextPort 文本端点、多逻辑角色 from_env 等 VT 倒推的能力已被 soak 与公共承诺消费,回收属任务外变更)。

## 2. 范围与总验收

**做**: ① GovDoc 全量迁移(删 llm/ 六文件 + embedding 换 EmbeddingClient + 装配净新增);② CHS 全量迁移(删 governance/limiter/scripts/provider_gate/invokers/selector/streaming + judge 收编 + OCR 换端口);③ 每项目缺口回补(触发库小版本);④ v1.0.0 打包发布至 Gitea 并以正式依赖形态回装验证;⑤ 五处文档同步。

**不做**: VT 迁移;任务外重构(两项目的业务代码只动迁移文档标注的改写点);库能力删减;CHS 生产部署切换(交付到"feature 分支验收通过 + 部署切换手册",实际上线由用户执行)。

**总验收**: 两项目各自 `make test`(GovDoc 为 `make ci`)在其 conda 环境全绿(skip 剖面不劣于迁移前基线)+ 冒烟/回归证据落 `tests/outputs/`(库仓库)+ 独立 verifier 清零 + Gitea 上可 `pip install polygateway==1.0.0`。

## 3. 事实基线与漂移核对(2026-07-22 实测)

| 事实 | 值 |
|---|---|
| GovDoc | conda `GovDoc-SaaS`(Py3.11,本机尚无该环境);`make ci` = check+test+test-core;29 个测试文件;远端 GitHub;快照与迁移文档零漂移 |
| CHS | conda `chs`(Py3.13,本机尚无);`make test` = pytest --cov;134 个测试文件;markers: requires_db/requires_redis 无则 skip;远端 GitHub;**有漂移,见下** |
| 库 | v0.1.0,extras: redis/postgres/structured/sdk;SQLite 遥测零 extra |
| Gitea | https://gitea.iomgaa.online 可达;发布与回装需用户提供 Gitea token(凭据先读后用) |

**CHS 漂移**(5ac9f59→7eb8482,135 文件 +9818/−1853,治理关键文件仅 44+/83−):
- positioning 子系统重构为纯图像模板分类(`TemplateVascularPositioner`),**position worker 不再消费 VLM**;`PositionCallScheduler` 端口与 `load_single_vlm_capacity` 已删除。
- 影响修订三条:迁移文档 §2"load_single_vlm_capacity 保留改读库聚合"作废;§6-S3"VLM 灰度先切 position worker"作废(VLM 消费点收敛为 extractors.py/classifiers.py,同属 pipeline worker,灰度只剩一段);调用点清单中 vascular_positioner.py:329 一行作废。
- governance/invokers/limiter/scripts/selector/streaming/errors/judge/contracts_limiter **零实质变更**(仅格式化);迁移文档其余证据**语义有效,行号以实施时实测为准**(tracking/config/container/ports 有小幅行号漂移)。

## 4. 决策 1:迁移工作区机制

| 方案 | 内容 | 优点 | 缺点 |
|---|---|---|---|
| A 直接改 reference/(用户原话方向) | 放开 pre-tool-guard 对 reference/ 的写拦截(文件工具 + Bash 的 checkout/sed/rm 等),CLAUDE.md 铁律措辞同步改;在 reference/<proj> 内切 feature 分支实施 | 与用户直觉一致;路径最少 | ① 钩子里 `git checkout` 也在拦截清单,A 必须动钩子,硬边界整体弱化(误删保护一并失去);② 分支切走后**旧版治理代码从工作区消失**,而迁移全程要逐段对照旧实现验证 shim 等价性,只能靠 `git show` 翻历史;③ 迁移文档/ARCHITECTURE 的 file:line 证据在工作区失效 |
| **B git worktree(推荐)** | 保持 reference/ 工作区只读与钩子零改动;`git -C reference/<proj> worktree add /Users/yuchengzhang/Projects/m4-worktrees/<proj> -b feat/polygateway-migration`;实施全部发生在 worktree(路径不含 reference/,钩子天然放行;`worktree`/`commit`/`push` 子命令均不在拦截清单) | ① 硬边界一行不改;② reference/ 本体停在 main:旧版代码、蓝本、file:line 证据全程可对照——这正是"迁移前先做旧版行为审计"的物理保障;③ commit/push 走同一 origin,与 A 的 git 效果完全相同;④ 项目自带 `using-git-worktrees` skill,惯例现成 | ① 多一个目录,且在 PolyGateway 工作目录之外——**前置**: 需把 `/Users/yuchengzhang/Projects/m4-worktrees/` 加入工具写权限(settings `additionalDirectories` 或人类门口头授权),否则实施期逐次询问;② 措辞澄清: worktree 的 add/commit/push 会向 `reference/<proj>/.git` 写入 refs 与对象,"只读"实质收窄为"工作区文件与 main 检出不变"——本设计将此收窄视为可接受并明示 |
| C 外部独立 clone | 在项目外重新 clone 两仓库实施 | 与 B 同等隔离 | 双份仓库易漂移(reference/ 更新后 clone 不同步);B 是 C 的严格上位 |

**推荐 B**。它交付的正是用户要的东西(在项目本体仓库的 feature 分支上真实迁移、可 push),但不付出硬边界与蓝本对照的代价;用户拍板 A 的实质诉求是"能改真仓库",B 完全满足。若人类门坚持 A,则钩子改法为:reference/ 写拦截整段替换为"仅拦 rm -rf 类危险命令",CLAUDE.md §1/§5 的"只读勿改"措辞改为"M4 起为迁移工作区;蓝本对照以 git 历史为准"。

## 5. 决策 2:依赖形态(两阶段)

| 方案 | 内容 | 权衡 |
|---|---|---|
| **两阶段(推荐)** | **开发期**: 两项目 conda 环境 `pip install -e /Users/yuchengzhang/Projects/PolyGateway[extras]`(editable,缺口回补即时生效);pyproject 先写 `polygateway[extras]>=0.1` 声明依赖关系。**发布期**(两项目验收全过后): 库 bump `1.0.0` → `python -m build` → twine 上传 Gitea → 新建一次性干净环境从 `--index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/` 回装并重跑两项目测试 → 项目 pyproject 定稿 `polygateway[...]==1.0.*` 并在 README/Makefile 写明 index 安装命令 | 迭代快(缺口回补不经发版);发布验证真实(干净环境回装);唯一成本是发布期多一轮回装测试 |
| 全程 Gitea wheel | 每次库改动都 build+upload+install | 缺口回补一次一发版,迭代极慢;否决 |
| git+https 依赖 | pyproject 写 `polygateway @ git+https://...` | 可行但放弃了已拍板的 pip index 价值(版本可枚举、锁版本、无需 git 凭据);仅作 Gitea PyPI 故障时的退路 |

extras 分配: GovDoc → `polygateway[redis,structured]`(SQLite 遥测零 extra);CHS → `polygateway[redis,postgres,structured]`。

## 6. 决策 3:Q6 CHS judge 迁移路径

judge(`core/eval/judge.py`,快照后零变更)默认 provider=anthropic,同步裸调 SDK。

| 方案 | 内容 | 权衡 |
|---|---|---|
| **走实验室 OpenAI 兼容中转(推荐)** | judge 配 `JUDGE__{PROVIDER}__1__*` 指向实验室中转网关,claude 模型名经中转;`_call_llm` 改同步桥接,解析换库 `JsonRepairStrategy`。**桥接方案钉死**: 每次调用一个完整 `asyncio.run(...)`,client 的构造(from_env)与 `aclose()` 都在该协程内完成——httpx 连接池绑定事件循环,跨 `asyncio.run` 复用常驻 client 会触发 "Event loop is closed"(经典坑);judge 是低频评估路径,放弃连接复用换正确性。备选(judge 调用量大时): 常驻单线程事件循环 + `run_coroutine_threadsafe` | 零库改动;**前置确认**: 中转网关是否代理 judge 所需 claude 模型(人类门时确认,不通则回退方案 3) |
| 库新增 Anthropic 原生 transport | D2 有端口预留 | 新子系统 = 新公共承诺 + 独立设计与测试,为单一消费点开新承诺违反 YAGNI;M4 内否决,留 D2 储备 |
| judge 暂缓收编 | judge.py 保持裸调,标注技术债 | 违背"全量迁移"验收口径;仅作中转不可用时的降级,需用户显式同意 |

## 7. GovDoc 迁移方案(migrations/govdoc-saas.md §6 修订版)

前置: 创建 conda 环境 `GovDoc-SaaS`(Py3.11)→ `make install` → 基线 `make ci` 留存(含 skip 剖面)。

| # | 步骤 | 验证 | 回滚 |
|---|---|---|---|
| G1 | worktree + feature 分支;editable 装 `polygateway[redis,structured]` | `import polygateway` | 分支起点 |
| G2 | 装配 spike: `from_env()` + AgentLoop 真实调用一次(实验室网关),校验 `isinstance(client, LLMProvider)` 与响应字段 | spike 输出 + 遥测落库 | 丢弃 spike |
| G3 | `.env`/`.env.example` 改键(平铺单源 → `LLM__{PROVIDER}__1__*`;新增 namespace 等;凭据先读 .env 真实值再写) | `from_env()` 无缺配置报错 | commit |
| G4 | 装配进 api lifespan 与 arq worker 入口(显式 `retryable_exceptions=(TransientError, AllSourcesExhausted)` + `aclose()`) | govdoc 业务测试绿;注错验证步级重试可触发 | commit |
| G5 | `retrieval/embedding.py` 删除换 `EmbeddingClient`(映射: batch_size→`EMBED__BATCH_SIZE`、dimension→`EMBED__EXPECTED_DIM`、on_usage→遥测) | retrieval 相关测试绿 | commit |
| G6 | 删 llm/ 六文件 + test_breaker.py;改写 types.py(re-export)/protocols.py(删 TelemetryRecorder)/test_imports.py;docagent-core pyproject 移除 redis extra、根 pyproject 加 polygateway;**Makefile install 行同步改**(现硬编码 `docagent-core[redis,dev]`,extra 删除后引用失效) | `make ci` 全绿 + import-linter 过 | commit |
| G7 | 验收: 验收公式逐项核对 + 冒烟/回归(§9)+ 独立 verifier | ci 输出 + verifier 报告 | — |

GovDoc 特有事实(迁移文档 §1): 装配层从未存在、`GovernedLLMClient` 零生产调用方、零异常捕获点——业务代码对治理层消费零改动,风险≈0;缓存 key 全变无成本(未上生产)。

## 8. CHS 迁移方案(migrations/chsanalyzer.md §6 漂移修订版)

前置: 创建 conda 环境 `chs`(Py3.13)→ 按 requirements 安装 → 基线 `make test` 留存 skip 剖面(本机无 CHS 专用 Postgres/Redis 时 requires_db/requires_redis 用例 skip 属基线的一部分,迁移后不得劣化)。

| # | 步骤 | 验证 | 回滚 |
|---|---|---|---|
| C1 | worktree + feature 分支;editable 装 `polygateway[redis,postgres,structured]`;基线留存 | `make test` 基线 | 分支起点 |
| C2 | `PgwVlmProvider` shim(bytes+instruction → content 数组 → chat → `ProviderOutcome`)+ G1 异常翻译 shim(库 `GatewayUnavailableError` → `ProviderUnavailableError`,或 tracking.py 直接 except 库异常——实施时按改动面最小者定,二选一在 plan 钉死) | shim 单测(含字段映射与 per_source_reasons 透传) | commit |
| C3 | **VLM scope 切换**(漂移修订: position worker 已不消费 VLM,灰度只剩 pipeline worker 一段,原 S3 两步并一步): container 治理装配换库工厂,extractors/classifiers 经 shim 走库;灰度期 CacheMW 先关(迁移文档 §8 裁决) | 提取/分类单测 + integration;双实现对拍一批样本 | 装配开关切回 legacy |
| C4 | **OCR scope 切换**: `table_locator.py` 改写消费 `OcrLayoutResult`(首个 type=="table" 元素 + int 四元组,约 5 行) | extract_table integration 全绿 | commit |
| C5 | judge 收编(§6 方案落地) | test_eval_judge 绿 + 真实 judge 调用一次 | 独立文件单独回滚 |
| C6 | 清场: §2 删除清单执行、errors.py Provider 族删除、tracking.py 定稿、拆开关;config.py 治理解析段删除换 from_env | **原测试全绿(skip 剖面≥基线)** | C5 前 commit |
| C7 | 验收: 冒烟/回归(§9)+ 独立 verifier | 报告落盘 | — |

CHS 特有约束: 遥测后端配 `PGW_TELEMETRY_BACKEND=postgres` 指 PG 实例 **polygateway 库**(严禁触碰 chs_prod 等在用库);本机验证用实验室 Redis **db3**;生产切换(新旧 Redis key 前缀不同、整 scope 原子切换、低峰执行)只交付手册不代执行。

## 9. 回归验收框架(候选,细节与用户商量后定)

| 项目 | 冒烟(已定) | 回归候选 |
|---|---|---|
| GovDoc | G2 的 spike: AgentLoop 真实调用 + 遥测/缓存命中实测 | 无旧遥测基线(骨架期),**建议冒烟即回归**;可加"重跑同请求缓存命中率非零"一条 |
| CHS | 单张真实样本走完整提取 pipeline(worktree 内,治理走库) | (a)**小批对拍(建议)**: 选 20-50 张真实样本,旧版(reference/ main)与新版(worktree)各跑提取,对比结构化结果一致率 + 错误率/延迟(新版读库遥测,旧版读运行日志);(b) testing 环境 docker 部署跑 e2e;(c) 只冒烟不批跑。**先行写死两条**: 新旧两轮**串行执行**(并行则对同一真实上游 RPM 双份消耗,同"禁止双栈并跑"精神);旧版跑批同样只用实验室 Redis db3 与 PG polygateway 库,不触生产后端。样本量、一致率阈值、跑批环境届时拍板 |

## 10. v1.0 发布清单(两项目验收全过后)

① 库版本 bump `1.0.0` + 汇总 changelog + **库自身 `make ci` 重跑全绿(发布门)**;② `python -m build` 出 wheel+sdist;③ twine 上传 Gitea PyPI(token 用户提供,写 `~/.pypirc` 或环境变量,不入任何仓库);④ 一次性干净 conda 环境从 Gitea index 回装,重跑两项目测试套件绿;⑤ 两项目 pyproject 定稿 `==1.0.*` 并记录安装命令;⑥ 打 git tag `v1.0.0`;⑦ 文档同步(§15)。发布后库仓库亦推送至 Gitea 托管(用户提及"托管到 gitea",与 GitHub 双远端或迁移,人类门确认)。

## 11. 旧版行为审计

迁移类设计的审计已由两份迁移文档 §7 承载(逐条"保留/替换/修复/有意放弃",GovDoc 17 条、CHS 26 条),本设计**继承其全部结论不重复**;漂移带来的增量修订仅 §3 三条(均为"作废"而非语义变化)。实施时每步对照迁移文档审计表执行,任何新发现的未登记行为差异按流程回写迁移文档后再动代码。

## 12. 非功能维度

- **并发与取消**: 治理层行为全部由库承载(M1-M3 已验收);项目侧新增物仅 shim(纯映射,无状态)与装配代码。CHS judge 桥接按 §6 钉死: client 构造与 aclose 均在单次 `asyncio.run` 协程内,杜绝 httpx 连接池跨事件循环复用;同步 runner 线程无嵌套循环风险(实施时以 `asyncio.get_running_loop()` 探测断言钉住)。CancelledError 穿透:shim 不含 try/except 包裹,天然穿透。
- **降级方向**: 继承库铁律(缓存/遥测静默、限流/熔断报错);GovDoc B15 步级重试作为业务侧兜底显式保留(G4 注入库异常类型,静默失效风险以注错测试钉死)。
- **幂等与重复**: 迁移步骤均以 commit 为回滚点、可重入(重复执行 editable install/改键无副作用);Gitea 同版本重复上传会被 409 拒绝,重发布必须 bump 版本。
- **持久化与原子性**: 项目侧不新增持久化;CHS 生产切换的 Redis key 前缀切换风险(短暂限流清零)已在迁移文档 §8 论述,手册中标注低峰执行。

## 13. 错误处理与测试策略

- 缺口回补的判定口径: 迁移中任何"库能力不足以让原测试通过/原行为复现"即边界缺口 → 回补进库(走库侧常规红绿流程 + 小版本)→ 项目侧重验;禁止在项目侧用变通代码掩盖库缺口。
- 项目侧新增代码(shim/装配)的失败路径: 全部落库四分类语义,shim 不吞不改异常类型;每个 shim 有先失败后通过的单测证据(测试结果门照旧)。
- 测试基线纪律: 迁移前基线的 pass/skip 剖面留档,并同步留存**预期测试增删清单**(G6/C6 删除的自测文件导致分母变化,核对时按清单修正);迁移后全绿且 skip 不增;两项目测试在各自 conda 环境跑,不与库 soak 并跑(Redis db3 FLUSHDB 冲突)。

## 14. 风险

| 风险 | 缓解 |
|---|---|
| 隐性行为依赖迁移中才暴露(ROADMAP 已预警) | 迁移文档审计表逐条对照 + worktree 下旧版代码全程可对照 + 每步 commit 回滚 |
| CHS 本机测试环境与生产差异(db/redis skip 剖面) | 基线剖面留档对齐;requires_db/redis 用例若需真实后端,用实验室资源(PG polygateway 库/Redis db3)非生产库 |
| 中转网关不支持 judge 的 claude 模型 | 人类门确认;不支持则 §6 方案 3 降级(用户显式同意) |
| Gitea PyPI 首次发布路径不通(版本 26.4.0 为 CalVer,包注册细节未实测) | 发布前先用 0.1.0 做一次试发布+回装演练;不通则退 git+https 依赖(§5) |
| 两项目 conda 环境从零创建的依赖地狱 | 各项目 CLAUDE.md/requirements 为准;环境创建失败即停,与用户对齐 |

## 15. 文档同步清单(实施尾声执行)

① ROADMAP §1/§5: M4 范围改两项目、VT 标注放弃(2026-07-22 用户拍板)、状态推进;② ARCHITECTURE §11.2: 标注 VT 已放弃不迁移(能力倒推记录保留);§13 Q1/Q6 落拍板结果;③ migrations/video-tree-trm5.md 头部标注放弃;④ migrations/chsanalyzer.md: 漂移修订(§3)与实施结果回写;migrations/govdoc-saas.md: extras 名更正(文中 `[redis,telemetry-sqlite]` 不存在,实为 `[redis,structured]`)、"不迁 embedding 待 Q3"已被拍板推翻(G5 迁移)、实施结果回写;⑤ CLAUDE.md: 若人类门选方案 A 则改 reference/ 铁律措辞;选 B 则钩子与铁律条文零改动,但"只读"语义收窄(§4-B ②)在 CLAUDE.md reference/ 条目加一行括注;⑥ 库 README/发布说明: Gitea 安装命令;⑦ **迁移合并后 reference/ 本体收尾同步**: 用户在各项目远端合并 feature 分支后,`git -C reference/<proj> pull --ff-only`(不在钩子拦截清单)把本体推进到迁移后 main,并移除 worktree(`git worktree remove`)——蓝本自此指向迁移后代码,属预期终态。

## 16. 被否决的备选汇总

外部独立 clone(§4-C,双份漂移);全程 wheel 分发与 git+https(§5);Anthropic 原生 transport 与 judge 暂缓(§6);为 VT 放弃回收库能力(§1,任务外变更)。
