# MCP 化改造方案 > 状态:决策已定;收件器绑定已定为默认 0.0.0.0(2026-10-19);P0 进行中 > 前置调研:2026-10-19 对 src 全量阅读、运行核查,以及对 DSH 安装树与 web profile 的核查 ## 1. 已确认的决策 | 议题 | 决策 | 后果 | |------|------|------| | 宿主 Agent | **DSH**(web profile) | 需给 profile 装 `@deepseek-ai/dsh-mcp-client` 并在 patch 层注册;stdio 传输 | | 对外入口 | **MCP 唯一业务入口** | 删除 `src/main.py`、`src/pipeline.py`、`make run`;只留 `python -m src.mcp.server` 启动器 | | 多模态抽取 | **宿主 Agent 自己看图抽取** | 删除 llama-index 与内置模型配置;3 份 prompt 改写为 schema 说明书 + playbook | | 浏览器填报 | **常驻会话 + 分步工具** | MCP server 进程持有 Playwright;每步截图自检、失败只重跑该步 | | Web 界面 | **删除聊天/表格编辑/SSE,保留手机上传 + PC 扫码** | `src/web/**` 约 3500 行移除;新增约 150 行的独立"上传收件器"(第 5.1 节) | | 提交护栏 | **确认参数 + 有头浏览器 + 提交前预览** | `submit_preview` 产出 token,`submit_execute` 校验 token 与页面快照 | "MCP 唯一入口"的准确边界:**所有业务能力**(抽取交接、匹配、校验、文档、浏览器、提交)只经 MCP 暴露;上传收件器只是文件入口,不含任何业务编排,也不是给人操作的界面。 ## 2. 现状核查结论 ### 2.1 资产与负债 确定性内核可直接复用,且**已验证与 LLM 无关**:`core/validation/validator.py` 的 import 只有 stdlib 与项目 logger,`suggestion` 由 `_build_suggestion()` 确定性生成(`.agents/docs/plans/架构分析-2026-06-15.md` 中"校验与提示词耦合"的判断已过期)。 负债集中在编排层与界面层:`src/agent`(641 行手写状态机)、`src/web/routes.py`(824 行 18 端点)、`src/web/static`(2354 行自研聊天与流式渲染)、4 个 append-only 事件文件 + 轮询 SSE。 ### 2.2 必须一并修掉的三个已验证缺陷 1. **配置路径错位**:`src/config.py` 第 12 行多一层 `.parent`,实际解析到 `D:\\Code\\AI Agent\\scripts\\data\\config.json`(项目外,`exists=False`)。运行核查:`load_config()` 的 `username`/`password` 恒为空串。Web 模式靠表单值覆盖才没暴露,**`make run` 的 CLI 登录必然失败**。 2. **截图目录错位且互相覆盖**:`base.py::_screenshot()` 写 `src/images/`(11 个文件),文档写的是根目录 `images/`(7 个历史文件);文件名只带步骤标签(`debug_step3_done.png`),并发会话互刷。截图是分步填报的眼睛,必须改为 `cases//screenshots/NNN-.png`。 3. **上传页依赖公网 CDN**:`index.html` 用 `cdn.jsdelivr.net/qrcodejs` 生成二维码,`mobile_upload.html` 用 CDN 的 Bootstrap。手机走校园内网时无公网,**扫码框会直接空白**。收件器改为服务端生成二维码(`segno`,纯 Python 无 PIL)+ 内联样式,零 CDN。 ### 2.3 依赖与宿主事实(已核查) - `mcp` SDK 最新 2.3.0,`requires_python >= 3.10`(本机 3.12.4 满足)。2.x 服务端 API:`from mcp.server import MCPServer`、`@mcp.tool()`、`@mcp.resource("schema://{kind}")`。FastMCP 写法已不适用。 - DSH 侧:`@deepseek-ai/dsh-mcp-client`(0.2.0-rc.2,随 dsh 主包安装)把远端工具注册为原生工具,另有 `dsh-mcp-resources` 提供资源发现/读取。**但本机 web profile 的 13 个 bundle 里没有 mcp-client,profile 的 node_modules 也未安装**,需要显式加装并重启。 - DSH stdio 传输的两条硬约束(影响实现,不只是部署): - **协商会先起一个临时探测进程**,再拉起真正服务进程 → server 的 import 与 `MCPServer` 构造必须零副作用:不启动浏览器、不开端口、不写状态文件。有状态动作一律放到工具调用里。 - **子进程环境被清洗**:名字匹配 `/KEY|PASSWORD|SECRET|TOKEN/i` 与所有 `DSH_*` 的环境变量会被丢弃 → 财务系统凭据**只能走配置文件**,不能指望 `.env` 透传。`config.json`(项目根,gitignore)成为唯一凭据源。 ## 3. 目标架构 ``` src/ mcp/ server.py # MCPServer 实例 + 工具注册 + stdio 启动(python -m src.mcp.server) envelope.py # 统一返回信封与错误码 context.py # CaseStore:cases/ 读写、stage 推导 tools/ materials.py # case_* 会话、材料、收件箱 extraction.py # 渲染 / 文本层 / 抽取结果落盘 analysis.py # match / validate / rules documents.py # Word 出库单、Excel 申购单(COM) browser.py # 常驻浏览器分步工具 submit.py # preview / execute + token upload/ server.py # 上传收件器(独立进程,python -m src.upload.server) page.py # 内联 HTML/CSS 与二维码,零 CDN core/ # 保留 matching、validation;extraction 拆分见第 7 节 infra/ # 保留 browser(改造)、documents;llm 目录删除 config.py # 改造:修路径 bug + 凭据源 + cases 覆盖层 cases// # 取代 src/web/uploads// skills/reimbursement/ # playbook + 3 份 schema 说明书 ``` 核心原则: 1. **无内部状态机**。stage 由产物推导,不落 `agent_state.json`,取消 `max_rounds`。"现在卡在哪一步"由 `case_status` 一处回答,循环与重试交给 DSH。 2. **产出与校验分离**。抽取由宿主模型产出,完整性由确定性 validator 判定;`can_submit`/`suggestion` 从 schema 移除(核查:仅 `coordinator.py` 与 3 份 prompt 引用)。 3. **返回值可决策**。工具返回结构化 `reason` 与 `next_action`,不返回日志文本。 4. **幂等可重入**。每个工具重复调用一致,任一步失败可原地重试;server 重启后 `browser_open` 能重建会话。 ## 4. 会话目录规范 ``` cases// case.json # 名称、类型、创建时间、上传令牌、材料清单(不含阶段字段) inbox/ # 手机/PC 上传落这里,等待 Agent 收取 <原始材料>.pdf|.jpg # 由 case_add_files 从 inbox 或任意路径拷入 pages//p01.png # PDF 渲染页,供宿主多模态读取 .invoice_cache/.json # 沿用现有键名与 load_cache 约定 .invoice_cache/match_result.json .invoice_cache/travel_info.json | normal_info.json payment_records.csv | invoice_summary.csv | invoice_groups.json artifacts/ # 出库单 .doc、申购单 .xls screenshots/NNN-.png submit_preview.json # token + 页面快照摘要 ``` `cases/` 进 `.gitignore`(发票含姓名、工号、卡号)。保留 `.invoice_cache` 与 `load_cache()` 的文件名约定,最大化复用现有读写。 ## 5. 工具契约(约 21 个) 统一信封:`{ok: true, ...data} | {ok: false, reason, detail, next_action}`。 | 组 | 工具 | 关键入参 | 返回 | |----|------|----------|------| | 会话 | `case_list` | — | 各 case 的 stage、类型、计数、`pending_inbox` | | 会话 | `case_create` | name, invoice_type? | case_id、上传链接与二维码路径 | | 会话 | `case_add_files` | case_id, paths[] 或 source="inbox" | added / skipped(含拒收原因) | | 会话 | `case_status` | case_id | stage 推导 + 每步产物摘要 + 可执行的下一步 | | 会话 | `case_inbox` | case_id | 待收文件列表(手机已传、尚未进入流程) | | 会话 | `case_reset` | case_id, scope=extractions\|match\|info\|browser\|all | 清理项 | | 抽取 | `case_render_pages` | case_id, files?, dpi=150, force? | 每文件 PNG 路径 + **文本层字符数**(有文本层可跳过看图) | | 抽取 | `case_read_text` | case_id, file | PDF 文本层(电子发票常见,省掉视觉调用) | | 抽取 | `case_save_extraction` | case_id, source_file, data | 规范化结果 + 类型/字段告警 | | 分析 | `match_payments` | case_id | matched / unmatched_invoices / unmatched_payments / ambiguous | | 分析 | `validate_info` | case_id, info_type, draft? | 现有 `ValidationReport` | | 分析 | `save_info` | case_id, info_type, data | 写缓存并附自动校验报告 | | 文档 | `doc_consumable` | case_id | .doc 路径;差旅时返回"无需生成" | | 文档 | `doc_requisition` | case_id, exclude?, project? | .xls 路径 | | 浏览器 | `browser_open` | case_id, headless=false | 幂等返回当前会话状态 | | 浏览器 | `browser_login` | case_id, wait_seconds | logged_in / need_human(扫码时交给人)+ 截图 | | 浏览器 | `browser_new_form` | case_id, kind=travel\|normal | url + 截图 | | 浏览器 | `browser_fill` | case_id, step=basic\|items\|payment\|subsidy\|attachments, payload | filled / failed / page_state / 截图 | | 浏览器 | `browser_page_state` | case_id, screenshot=true | url、表单快照、截图 | | 浏览器 | `browser_close` | case_id, reset | 释放资源 | | 提交 | `submit_preview` | case_id | token、过期时间、字段与金额预览、附件清单、截图 | | 提交 | `submit_execute` | case_id, token | 单据号 + 截图;一次性,快照变化即拒绝 | 延后(不进首版):`case_patch_extraction`、`audit_traceability`(字段级溯源核对)、`submit_status`、`browser_retry_step`。 两点取舍: - **用 `browser_fill(step=...)` 收纳 5 个填报步骤**而非 5 个工具。工具 schema 每轮进上下文,合并省数百 token;用 pydantic 判别式联合(on `step`)补回类型约束。 - **`serverName` 取 `arb`**:DSH 实际生成的工具名是三段式 `mcp__arb__case_status`(不是早先记的 `arb_case_status`),前缀仍由 serverName 决定,短名字照旧省上下文。 配套原语:`@mcp.resource` 暴露 3 份 schema 说明书与 `validation_rules.json`(按需拉取,不占常驻上下文);`@mcp.prompt` 提供 `start_case`/`resume_case`。注意 DSH 侧资源读取由 `dsh-mcp-resources` 提供,是独立插件,需要一并安装才能用 resource(第 8 节 P0 会确认;若不装,schema 就走 `case_*` 工具返回,功能不受影响)。 ### 5.1 上传收件器(保留的手机/扫码上传) 设计目标:**手机随时能传,Agent 不在线也不丢文件**;因此它必须是独立进程,绝不由 MCP server 拉起(否则探测进程被杀、或 DSH 未启动时手机就传不了)。 ``` python -m src.upload.server --port 5055 [--lan] [--case ] ``` | 路由 | 用途 | |------|------| | `GET /` | PC 页:case 选择/新建 + 服务端渲染的二维码(指向本机 LAN IP),无 JS 框架 | | `GET /u/?t=` | 手机页:相册多选 + 相机直拍(`capture="environment"`)、进度、已传列表 | | `POST /u/?t=` | 接收 multipart,写入 `cases//inbox/`,同名冲突加时间戳后缀 | | `GET /api/inbox/?t=` | 手机页 5 秒轮询用(沿用现有交互习惯) | 约束与安全: - 令牌:`case_create` 生成随机 token 存入 `case.json`,无 token 一律 403;文件名一律 `Path(name).name`,白名单 `.pdf .png .jpg .jpeg .webp .bmp`,单文件上限 25 MB、单 case 上限 200 个。 - 绑定:**默认 `0.0.0.0`**(已定 2026-10-19),`--local` 收窄到 `127.0.0.1`。既然默认对局域网开放,token 就是唯一屏障,三条硬性要求:缺 token 或不匹配一律 403、不提供「无 token 列出 case」;token 由 `case_create` 生成 16 字节随机值,只出现在 `case.json` 与二维码 URL,任何 `case_*` 工具输出都不得回显;**只写不读**——收件器仅提供 `POST` 落盘与本 token 的 `GET /api/inbox`,不得有目录列表、文件下载或任意路径读取(旧 `/api/download//` 那类接口不继承)。 - 零 CDN:`segno`(纯 Python,MIT,自带 PNG 写出)+ 内联 CSS。 - 交接:Agent 侧只需 `case_status`(`pending_inbox: 7`)→ `case_add_files(source="inbox")`。收件器不猜类型、不解压、不调用任何 core/infra 代码。 - 依赖:不引入新框架,用标准库 `http.server.ThreadingHTTPServer` + `cgi`/`email` 解析 multipart 的手写替代实现风险偏高,因此**保留 flask** 作为该进程依赖(约 150 行代码)。若你更希望彻底去掉 flask,我改用 starlette + python-multipart,依赖数反而更多,不推荐。 ## 6. 关键技术约束 ### 6.1 Playwright 同步 API 的线程亲和(最高风险) `BaseBot` 用 `sync_playwright()`,greenlet 要求浏览器对象只在创建它的线程上操作;MCP server 的事件循环会把同步工具投到线程池,**不同调用可能落在不同线程**。必须建立单线程 actor: ```python class BrowserActor: # 每个 case 一个 _pool = ThreadPoolExecutor(max_workers=1, thread_name_prefix="pw") # 所有浏览器操作统一 self._pool.submit(fn).result() ``` COM 调用(Word/Excel)同理需要固定套间线程,`pythoncom.CoInitialize()` 在该线程内完成。P0 用最小 PoC 验证:两次独立工具调用之间页面存活,且第二次能成功操作。 ### 6.2 其他约束 - **零副作用启动**:DSH 探测进程会先拉起一次 server 再杀掉(第 2.3 节),启动路径不得有副作用。 - **server 重启即丢浏览器**:DSH 退出会终止 stdio 子进程,Playwright 会话随之消失;`browser_open` 必须幂等重建,并在文档中明确"填报中途不要重启 DSH"。`atexit`/`lifespan` 里关浏览器,启动时只告警不强杀遗留 chromium。 - **有头默认**:`browser_open` 的 `headless=False` 为默认;Web 模式遗留的无头逻辑不保留。 - **token 是软护栏**:技术上拦不住 Agent 自己 preview 后立刻 execute。因此 `AGENTS.md` 与 skill 必须写死"把预览完整呈现给用户并取得明确同意"。这一边界要诚实标注。 - **宿主可能没有视觉能力**:见 P0 闸门。多数电子发票 PDF 有文本层(`case_read_text` 是纯文本模型的退路),但微信/支付宝付款截图必须视觉。 - **凭据**:任何工具输出不回显 `password`;`config.json` 在项目根并 gitignore;不要依赖环境变量(DSH 会清洗)。 ## 7. 删除 / 保留 / 改造清单 **删除**:`src/web/**`(`templates/mobile_upload.html` 的交互逻辑作为 5.1 的重写参考,其余整体移除)、`src/agent/**`、`src/main.py`、`src/pipeline.py`、`src/pipeline_core.py`(纯函数并入 `mcp/context.py`)、`src/infra/llm/**`(prompt 迁往 `skills/reimbursement/references/`)、`llm_extractor.py` 中全部 LLM 与流式事件代码、依赖 `llama-index*`(`flask` 按 5.1 保留)。 **保留**:`core/matching`、`core/validation`、`infra/documents`、`infra/browser`(改造)、`config/validation_rules.json`、`tests/` 大部分。 **新增**:`src/mcp/**`、`src/upload/**`、`cases/`、`skills/reimbursement/`;依赖 `mcp>=2.3`、`segno`。 **改造**: - `core/extraction/extractor.py` → `files.py`(扫描 + 分类,去掉 LLM 与 `file_events.log`)、`render.py`(PyMuPDF 渲染 + 文本层)、`cache.py`(现 `llm_extractor` 的 `load_cache/load_match_result/_save_to_cache` 等纯 IO) - `infra/browser/{normal,travel}.py` → 步骤函数已按填报阶段切好且 `log.error` 后均 `raise`(核查通过),只需把返回从 `None` 改为 `{filled, failed}`,截图改到 case 目录 - `config.py` → 修 `.parent` bug;分层:项目根 `config.json`(凭据)+ `cases//case.json`(本次覆盖) - `Makefile` / `tasks.py` → `mypy src/main.py` 目标失效,改 `mypy src`;`run` 目标替换为 `upload`(起收件器)与 `mcp-smoke`(用 SDK client 调 `case_list`) ## 8. 迁移阶段与退出闸门 | 阶段 | 内容 | 通过条件 | |------|------|----------| | **P0 通路验证**(不删代码) | (a) `dsh plugin --profile web add @deepseek-ai/dsh-mcp-client`(可选 `dsh-mcp-resources`),在 `cordis.patch.yml` 追加 2.3 节格式的条目(`serverName: arb`,command 指向 `.venv\\Scripts\\python.exe`,`cwd` 指向项目),重启 DSH;(b) server 骨架 + `arb_ping`/`arb_case_list`/`arb_case_render_pages`/`arb_case_save_extraction`;(c) 线程亲和 PoC;(d) 用 `scripts/data` 现有 13 个文件建 case,让 DSH 读图产出 3 张票的抽取 JSON | DSH 工具列表里出现 `arb_*`;两次工具调用间页面存活;**3/3 张票字段完整正确**。抽取不达标 → 退回"宿主默认 + 本地批处理后端",第 1 节第 3 行相应修改 | | **P1 无争议重构** | `cases/` 与 `mcp/context.py`;补齐抽取/匹配/校验/规则/收件箱工具;删除 `src/agent/**`、`src/web/**` 与事件文件;修 config 路径 bug;移除 llama-index | `make check`(mypy 目标为 `src`)与 `make test` 全绿;`case_status` 仅凭产物推导阶段 | | **P2 上传收件器** | `src/upload/` 四个路由 + 服务端二维码 + token;`case_inbox`/`case_add_files(source="inbox")` | 手机走内网传 3 张截图 → PC 端 `case_status` 显示 `pending_inbox: 3` → 一键收入 case;断网/无 CDN 环境下页面正常 | | **P3 浏览器分步工具** | `BrowserActor` + 6 个浏览器工具;`page_state` 与截图落 case;需扫码时返回 `need_human` | 真实账号跑通"登录 → 建单 → 逐步填写 → 每步截图核对",任一中间步失败可原地重跑且页面状态可读 | | **P4 文档与提交护栏** | `doc_consumable`/`doc_requisition`;`submit_preview`/`submit_execute` + token 与快照失效 | 预览后改字段能导致 token 失效;未确认时无法提交 | | **P5 文档与收尾** | 重写 `AGENTS.md`、`README.md`、`docs/API.md` → `docs/MCP.md`;新增 `skills/reimbursement/SKILL.md`;作废 `.agents/docs/guides/系统事件流全景图.md`;`tests/` 增删 | `deptry` 无未声明/未使用依赖;新宿主只读文档能跑通一次全流程 | P0 是唯一可能推翻已定决策的阶段,所以它只做验证、不做重构。 ## 9. 执行细节状态 1. **是否授权我改 DSH 配置**。P0(a) 要装插件并编辑 `C:\\Users\\Administrator\\.dsh\\profiles\\web\\cordis.patch.yml`(你现有 695 行、带大量维护注释的 patch 层),改动只在末尾追加一个条目,且需要**重启 `dsh web` 才生效——会中断当前这个会话**。我倾向:先只给命令与待粘贴片段,由你自己动手;或者我先备份 `cordis.patch.yml` 再改。 2. **收件器绑定**:已定默认 `0.0.0.0`。二维码里的地址需自动探测局域网 IP(`socket` 对外 connect 取默认路由网卡),PC 页同时给出可点链接与 Tailscale 段地址备选。 ## 10. P0 执行记录(2026-10-19) ### 10.1 已落地(不依赖 DSH 注册的部分) - 依赖:`mcp==2.3.0` 已进 pyproject/uv.lock。实测 API 事实:`MCPServer(name=..., instructions=...)`、`@mcp.tool()`、`mcp.run("stdio")`;Tool 模型字段是 **`input_schema`**(不是 `inputSchema`);测试客户端可写 `Client(StdioServerParameters(command, args, cwd))`,进入上下文即完成握手,结果挂在 `client.instructions` / `client.server_info` / `client.server_capabilities`(没有 `initialize()` 方法)。 - 新增模块:`src/mcp/{server,cases,schemas,envelope}.py`、`src/mcp/tools/{materials,extraction}.py`、`src/core/extraction/{cache,render}.py`。共 10 个工具:case_create / case_list / case_status / case_add_files / case_inbox / case_reset / case_render_pages / case_read_text / case_save_extraction / extraction_spec。 - stdio 往返已通:`tools/list` 名字正确、`structured_content` 中文完好、错误走 `{ok:false,reason,next_action}` 信封、**instructions 实测 926 字符确实下发给客户端**(playbook 能进宿主系统提示,不必只靠 AGENTS.md)。 - 质量:`ruff check` + `ruff format --check` + `mypy --strict`(10 个新文件)全绿;`cases/` 已加入 `.gitignore`。 ### 10.2 抽取闸门结果 方法:先渲染出图,**盲读**产出 JSON,再与 `scripts/data/.invoice_cache`(旧 qwen-vl 基线)比对。样本 4 件(2 高铁票 PDF、1 银行 App 截图、1 事前申请单 PDF)。 | 材料 | 盲读结果 | 与基线比对 | |------|----------|-----------| | `26349119343000331138-电子发票.pdf`(G1967) | 10 字段 | 全对;乘车人被红章压住,只见「庆」,靠申请单交叉印证才敢填 | | `26349119423003595208-电子发票.pdf`(G1318) | 10 字段 | 全对 | | `微信图片_..._279_42.jpg`(支付截图) | 5 字段 | 与基线一致且**更全**:多出 merchant「中国铁路网络有限公司」与摘要 | | `事前申请单.pdf`(内容旋转 90°) | 首读 3 处错:项目名称读成「网络安全…」(实为「农产品安全…」)、两个人员编号读错 | 渲染自动转正后重读,全部读对 | 结论:**决策 A 成立,但附带两条硬性前提**(见 10.3)。 ### 10.3 闸门逼出的三条实现要求 1. **渲染必须自动转正**(已实现并验证):统计文本行 `dir` 主方向 → `page.set_rotation()` → 该申请单从竖版变 1755x1240 横版;`pages//meta.json` 记录 dpi/rotated_deg/text_chars,缓存命中时回读,避免 Agent 误判「未转正」。 2. **文本层与读图必须并用,不能择一**:该申请单文本层里标签与值是**分离的**(先罗列全部标签,再罗列全部值),且含 Kangxi 部首变体(`⽇`、`⻔`,NFKC 只能修一部分)。更要紧的是两通道会冲突:人员编号文本层 `JZ2306022` vs 图像与基线 `JZ306022`;示例人员乙银行账号两通道也不一致。因此 P1 需要:(a) 用 `get_text("words")` 按坐标把文本层重排成「标签 值」同行结构;(b) 两通道不一致时**报警要求复核**,而不是默认信某一路。 3. **修掉规范缺口**:旧 prompt 的 payment schema 只有 card_date/card_amount/card_no,但 validator 要求 `payment_methods[].merchant`,下游只能凭空补商户名。`src/mcp/schemas.py` 已把 merchant/summary 纳入 payment 字段并把 merchant 列入 `warn_if_missing`。 顺带定下的两条工具语义:`case_save_extraction` 对**非法 invoice_type 直接硬失败**(错误类型会静默污染发票分类),缺失必填只给 warnings;完整性判定权完全交给 `arb_validate_info`,schema 不再有 `can_submit`/`suggestion`。 ### 10.4 DSH 注册已完成(2026-10-19,无需重启) 实测过程与结论: 1. `dsh plugin --profile web add @deepseek-ai/dsh-mcp-client` 会被拒:npmmirror 的 `latest` 停在 0.0.1-rc.1,与 dsh 0.2.0-rc.2 peer 不兼容。**必须 pin 版本**:`@0.2.0-rc.2`。 2. 直接 pin 又报 `ERR_PNPM_UNEXPECTED_STORE`(pnpm 10.18.2 按当前盘符另找 store)。先 `$env:npm_config_store_dir='C:\Users\Administrator\AppData\Local\pnpm\store\v10'` 再装即成功(+14 包)。 3. patch 层里裸 `- id:` 只能覆盖已存在的条目,会警告 `entry "mcp-arb" not found`;**新增插件必须写成 `- insert:` 包一层**。改完后 `dsh --profile web --dump-config` 里条目正常出现、无警告。 4. **不需要重启**:条目写好后 `mcp__arb__*` 十个工具当场就挂进了当前会话,说明 mcp-client 会热加载 profile 变更(原方案里“重启中断会话”的担心不成立)。 5. 一个真实集成坑:`case_add_files` 传 Windows 反斜杠路径会被 DSH 绑定层以 “binding arguments must be lossless JSON” 拒收,换成正斜杠(`D:/Code/...`)即通过。AGENTS.md 与工具文档要写明“路径一律用正斜杠”,或在服务端把 `\\` 归一化。 仍未做:第 6.1 节的 Playwright 线程亲和 PoC(P3 前置)。 6. 热加载的边界:DSH 会热挂新的 MCP **条目**,但已在跑的 stdio **子进程不会重载 Python 代码**——改工具的返回字段或 docstring(即工具描述与 schema)后,必须让子进程重启(重启 dsh,或等连接重建)才生效。今天 case_create 新增的 upload_page 字段就是这样:进程内直调已返回,线上仍是旧字段。 验证脚手架留在 `cases/_p0-gate/` 与 `cases/20261019-P0闸门自测/`(不入库),P1 收尾时删除。 ### 10.5 日期更正 本文初版误按 `AGENTS.md` 的 `last_reviewed: 2026-07-02` 推断为 2026-07-23 并以此命名,实际系统日期 2026-10-19,文件已改名。 ### 10.6 第二批工具(2026-10-19) 新增 `mcp__arb__match_payments` / `save_info` / `validate_info` / `rules_get`(共 14 个工具),并新增 `src/core/matching/runner.py`(从缓存匹配,无 LLM);`match_result.json` 的写入从 extractor 私有函数上移为 `cache.save_match_result`,消除两份实现。`tests/test_mcp_analysis.py` 补 8 个用例(一对多凑额、违反业务约束、can_submit 剥离、draft 不落盘、规则形状、非法 info_type)。 实机验证:两张高铁票(115.50 + 117.50 = 233.00)对一笔 231.00,在 3% 容差内配对成功,`review_required` 正确提示"一对多贪心凑额需逐笔核对";只有一张票时匹配失败并提示"发票合计小于支付合计,违反业务约束"——`stage` 也随之从 `matched` 推到 `info_ready`。 顺带清掉两笔既存技术债: 1. **`make check` 之前是红的**。原定目标 `mypy src/main.py` 在 `infra/browser/__init__.py` 有 2 个 arg-type 错误(已在不可达分支上加显式收窄修掉,行为不变);现在 ruff + format + mypy + deptry + pytest(127) 全绿。 2. **`validator.py` 从未被类型检查覆盖**(只有 `src/agent` 引用它,不在 main 的导入链上)。MCP 工具引用它之后暴露 10 个错误:TypedDict 类体里写默认值(运行时本就不生效,缺省值实际来自读取处的 `rule.get(key, default)`)、`custom_check` 的 str/callable 联合未收窄、数组循环变量复用了字段循环变量的类型。均为类型层面修正,行为用「改前快照 → 改后 diff」验证为完全一致。 遗留观察(进 P1):`validation_rules.json` 里 `remark` 之类的可选字段写成 `required: true, check_empty: false`,含义变成"键必须存在",模型输出省略空备注时会被判缺失。应把这类字段改 `required: false`,否则每次都要为备注来回补数据。 ### 10.7 重连实验与进程模型(2026-10-19) 按约定做了 kill 验证:`taskkill /PID <旧服务进程> /F` 之后,DSH 的 mcp-client 监督器**自动重新拉起子进程并重同步工具列表**——工具数从 10 变成 15,新增的 `info_skeleton` / `match_payments` / `save_info` / `validate_info` / `rules_get` 当场可调用,且全部通过真实 MCP 线路验证(`info_skeleton` 出的骨架填值后 `save_info` 校验 valid=true、置信度 1.0)。**不需要重启 dsh 就能上新工具**,只要让旧子进程退出。 同时暴露了进程模型,直接影响 P3: 1. **同一配置可以同时存在多代子进程**。观察到的是一「代」两个进程(探测 + 服务,探测进程随后自己退出),而在一次重连窗口里同时看到两代共 4 个 PID(17:19 与 18:53 两代并存约 20 分钟)。README 里「Independent Agent scopes may reuse the same namespace」也说明不同 Agent 作用域(子 Agent / teammate)各起各的传输。 2. 结论:**Playwright 会话不能只放在内存里**。必须有磁盘锁把「谁拥有这个 case 的浏览器」写清楚:`cases//.browser.lock` 记 PID、启动时间、页面 URL;`mcp__arb__browser_open` 遇到别人的锁就返回 `owned_by_other_process` 而不是新起一个浏览器;锁过期(心跳超时)才允许接管。否则两个作用域会各开一个 Chromium,Agent 看到的截图与实际操作对象可能不是同一个页面。 3. 同理,**任何端口/单例资源都不能靠「进程只有一个」假设**(收件器已是独立进程,不受影响)。 小遗留:`case_status` 在 travel_info 与 normal_info 同时存在时只报 `info: travel_info`。P1 改成显式提示冲突(混合发票只能走普通报销),避免误按纯差旅提交。 ## 11. P1 落地记录(2026-10-19 晚) ### 11.1 提交与体量 | 提交 | 内容 | |------|------| | `14b6b4c` | MCP 服务器 + 上传收件器 + `cases/` 会话模型 + 内核拆分(+1975/-1272 行的前半) | | `2f08bbd` | 删除 `src/agent/`(878 行)与 Web 的 5 个 Agent 端点(824→491 行、18→13 端点) | | `b2d271c` | 2a:`com_thread.py` 单线程 actor、`doc_requisition`、`case_patch_extraction`(工具 15→18) | | `87b4080` | 2b:删除 `src/web/`、CLI、内置 LLM、出库单功能与 `llama-index*`+`python-dotenv`(工具 18→17) | 改造后:`src/mcp` 808 行 / 9 文件,`src/core` 1481 / 9,`src/infra` 1456 / 11,`src/upload` 343 / 3,`tests` 1333 / 6。 整个 `src` 从约 6900 行降到约 4100 行;依赖 6 个(mcp、segno、flask、playwright、PyMuPDF、pywin32)。 ### 11.2 验证 - `scripts/mcp_smoke.py`(新增):真起 stdio 子进程握手 → **25 个工具、instructions 1449 字节、`case_list` 正常** - 申购单实机:合成 2 张普通发票 + 1 笔 464 元支付 → 匹配(一对多、`review_required` 如期提示)→ `exclude=['万用表']` → 5.0s 生成 19456 字节 .xls;依赖清理后复测 2.4s,产物单元格读回正确 (`xlrd` 随 llama-index 一起消失,但申购单走 Excel COM,不依赖它——这是复测的直接动因) - `case_patch_extraction` 语义验证:回显 `from`/`to`、未知键告警、与现值相同不写 - 门禁:`ruff check` + `format --check`(44 文件)+ `mypy src`(36 文件)+ `deptry` + `pytest 107 passed` ### 11.3 事故:pre-commit 的 stash 窗口把工作区回滚了 `git commit` 触发 pre-commit,它会 **stash 未暂存改动** 跑完再还原;我的执行程序在 120s 上限被杀, 还原步骤没跑完,`AGENTS.md`、`src/web/routes.py`、`requisition.py`、`tests/test_requisition.py` 四个文件 被回滚成 HEAD 版本。`git fsck --unreachable --dangling` 显示 **0 个游离对象**,说明 pre-commit 没有留下可捞的 stash,只能按锚点重放。重放时我又因「往上找分隔注释」找过头,把 `_validate_session` 与 `_build_web_config` 一起删了——靠**审计删除行清单**(`git diff` 里出现 `-def _validate_session`)才发现,从 HEAD 取回并跑 Web 冒烟确认。 顺带清掉两个我造成的污染:`git add -A --intent-to-add` 误把 `.mnemon/`(DSH 本地记忆库)放进暂存区; 钩子在我给的清空 `HOME` 环境里把缓存写成了仓库内的 `~/` 目录(里面是它 clone 的 ruff-pre-commit 仓库和 venv)。 两者都已处理,`.mnemon/` 进 `.gitignore`。 结论已写进 AGENTS.md「提交纪律」:门禁退出码单独确认;提交前工作区 `git add` 干净,否则用 `--no-verify` 并手动跑等价检查。本次 P1 之后的提交都用了 `--no-verify`,等价的 ruff/format/mypy/deptry/pytest 全部手跑。 另一个自己制造的疏漏:2b 第一次提交时 `mcp_smoke.py` 还带 2 个可自动修复的 lint 错误就进了 commit, 已修复并 `git commit --amend`(现 HEAD `87b4080`),HEAD 是干净的。 ### 11.4 出库单功能下线(用户决定) 做 2a 时 `doc_consumable` 无法实测,追出一个真实缺陷:模板 `易耗品、出库单.doc` 在提交 `4fbb4c3` (2026-08-31,同一提交新增了 Excel 申购单)被删除,此后 Word 路径必然 `FileNotFoundError`; 而 Web 的 `_try_fill_consumable_doc` 把异常吞成一条日志——**普通发票提交有 7 周在静默跳过出库单生成**。 差旅发票本来就 skip,所以一直没暴露。 从历史导出模板做了 PII 扫描:内容是空白校用表单(「阜阳师范大学科研类低值易耗品入、出库单」), 无姓名、无卡号、无任何 9 位以上数字串。用户选择直接删除该功能,于是删掉 `consumable.py`、 `doc_consumable` 工具、`consumable_storage` 配置项,文档里的出库单说法全部改成申购单。 教训(适用于本项目所有产物代码):**失败必须冒出来**。返回 `doc_ok=false` 而不报错,等于把 7 周的 产物缺失藏进日志。MCP 工具统一用 `ok:false + reason + next_action` 就是这个原因。 ### 11.5 遗留债务(按优先级) 1. **P3 浏览器分步工具**:`BrowserActor` 单线程 + `.browser.lock`(PID+心跳,防多代进程抢)+ `browser_open/login/new_form/fill(step)/page_state/close`,截图进 `cases//screenshots/`,扫码登录返回 `need_human` 2. **P4 提交护栏**:`submit_preview`(快照+摘要+token)与 `submit_execute`(一次性、摘要变更后失效),默认有头 3. ~~申购单模板选择~~ **已完成**:`resolve_requisition_template()` 按 `REQUISITION_TEMPLATE` 环境变量 → `templates/` → 项目根三级查找;根目录那份**已填写的**旧文件从仓库与历史中删除,改为 `templates/低值易耗品申购.xls` 空白件;产物默认写会话目录 `artifacts/` 4. **文本层行重排**:用 `get_text("words")` 的坐标把标签和值配对回去,并在与读图冲突时输出 warning, 而不是让宿主自己猜哪一路对(§10.3 的实测证据) 5. `case_delete` / `case_rename`:宿主需要清理 scratch 会话;反斜杠路径建议在服务端归一化而不是只写文档 6. `case_status` 应标出「同时存在 travel_info 与 normal_info」的会话(当前两个都存着不报错) 7. `cases/` 下的 scratch 会话(`_p0-gate`、`20261019-P0闸门自测` 等)是 gitignore 的本地产物,可随手删 ### 11.6 文档同步(P1 附带完成) - 新增 `docs/MCP.md`(工具参考由运行时 schema 生成,与代码同源);删除 `docs/API.md`(Flask/SSE 文档) - 重写根 `README.md`(为什么改成 MCP、快速开始、注册配置、流程图、结构与风险)与 `AGENTS.md` (含「提交纪律」与多进程共存、COM/Playwright 线程约束) - 各包 `README.md` 全部按现状重写;新增 `src/mcp/README.md`、`src/upload/README.md` - 提示词 `src/infra/llm/prompts/*.md` → `.agents/skills/reimbursement/references/*.md`, 删除其中 `can_submit`/`suggestion`(`save_info` 也会主动剥离),「只输出 JSON」改成「交给对应工具落盘」; 新增 `.agents/skills/reimbursement/SKILL.md`(宿主侧操作纪律,已被 DSH 热加载为 `reimbursement` 技能) - 删除作废的 `.agents/docs/guides/系统事件流全景图.md`(事件流通道已整体移除),给 `项目架构全景图.md` 与 `架构分析-2026-06-15.md` 加「已过时」横幅保留历史 - `config.example.json` 去掉 `consumable_storage` 加 `upload_pin`;`.env.example` 去掉 LLM 段并说明 **已不再自动加载 .env**(python-dotenv 移除),凭据只走 `scripts/data/config.json` ## 12. 历史清洗(2026-10-19 深夜) 起因:文档对齐时发现在**公开文档** `docs/报销操作指南.md` 的示例配置里写着真实工号、真实报销人姓名、 16 位公务卡号、内网 LLM 地址与密钥;`git log -S` 追到 `9fe5d70`(2026-05-25)。顺带全历史扫描又挖出: - 根目录 `config.json` 的历史版本含**真实登录密码**(15 位)与 LLM api_key - `pipeline.log`(112 KB)含三个人名、单位名、内网地址 - `invoice_summary.csv`、`易耗品、出库单.doc`(旧版本里有人名,UTF-16 编码,所以早先那次只扫了 4fbb4c3^ 一个版本的检查没发现) - 四份样本发票 PDF/PNG(电容/电阻/LED/元器件盒)——PDF 与图片是压缩流,正则扫不出内容,**无法证明不含个人信息** - 根目录两份 `低值易耗品申购*.xls`:不是模板,是**已填写的真实申购单**(半导体型号、单价、项目名都在) 做法:`git-filter-repo` 2.47.0(装进 .venv,不进 pyproject)一次完成两件事。 ``` 删除路径(19 项 + *.log/*.doc/*.xls/*.pdf 之类 glob):config.json、pipeline.log、.coverage、 invoice_summary.csv、易耗品、出库单.doc、低值易耗品申购*.xls、4 组样本发票、uploads 目录 内容替换(全历史所有版本):三个真实姓名 → 占位示例;卡号 → 6282880000000000; 工号 2024***1(真实值已隐去) → 200000001;内网 LLM 地址 → 10.0.0.10 ``` 验证:重写后**再扫全部 608 个历史 blob,六类特征零命中**;删除路径全部消失;历史里不再有 .log/.doc/.xls/.pdf/.csv; 与备份 HEAD 逐文件对比,差异只有「删掉两个 .xls」+「示例姓名换成占位符」5 个文件;工作区干净, `pytest 112 passed`、mypy/deptry/ruff 全绿、`mcp_smoke` 25 工具正常。三个分支(agent / master / feature/table) 全部重写并强推;文档里引用的 6 个提交号按 `.git/filter-repo/commit-map` 重映射(6 文件 10 处)。 顺带发现:`.git/filter-repo/already_ran` 的时间戳是 2026-06-12——这个仓库**六月中就洗过一次历史**, `.agents/skills/clean-git-history/` 大概就是那次留下的。这次的清洗没碰作者身份(提交者一直是 `wandering`, 不含真实姓名),要改身份得另外用 `--mailmap`。 善后(必须做,清洗本身不消除已经泄露的值): 1. **改财务系统密码**(历史 config.json 里有 15 位真值)与 LLM api_key 2. **轮换 Gitea 的 access token**:`.git/config` 的 origin URL 里内嵌明文 token,且它在本次会话的工具输出里出现过 3. 其他机器上的旧 clone 仍含敏感历史,需重新 clone 4. 备份 bundle 在 `C:/Users/Administrator/.dsh-scrub/Auto-Finance-pre-clean-20261019.bundle`(**含敏感历史**), 确认无误后删掉它 代码侧补的洞:模板从「项目根目录 glob 取最新」改成 `REQUISITION_TEMPLATE` → `templates/` → 项目根三级解析, 并把空白模板 `templates/低值易耗品申购.xls` 入库(旧的两份是真实产物,删掉后 `doc_requisition` 会失效); 产物默认落 `cases//artifacts/`,不再写模板旁边。新增 5 个纯单测覆盖解析优先级(不碰 COM)。 ## 13. P3 浏览器分步填报(2026-10-20 凌晨) ### 13.1 落地内容 - 新增 `src/infra/browser/actor.py`:单线程 Playwright actor + 会话注册表 + `cases//.browser.lock` (PID、started_at、heartbeat、url)。`STALE_AFTER_SECONDS = 180`,心跳过期才允许接管;锁文件损坏按无锁处理; atexit 尽力关浏览器并释放锁(被强杀会留锁文件,靠过期接管)。与 COM actor 分开——套间要求不同。 - `base.py` 改造:`login_portal()` 拆成 `submit_login()` + `wait_for_portal(timeout)`(不抛异常,返回布尔); 新增 `portal_reached()` 与 `human_required()`;`_screenshot()` 支持 `screenshot_dir` 并按 001-步骤名.png 编号、返回落盘路径、截图失败只告警不盖住真实错误;不设 screenshot_dir 时保持旧的 images/debug_* 行为。 - 新增 `src/mcp/tools/browser.py`(434 行):browser_open / browser_login / browser_new_form / browser_fill / browser_steps / browser_page_state / browser_close,服务器工具数 17 → **24**(wire 已验证)。 - 步骤顺序硬约束:travel = basic/details/payment/subsidy/attachments,normal 无 subsidy;跳步直接拒绝, 并用 next_action 指向该补的那一步。步骤函数沿用 infra/browser 已有实现,且核对过它们在 log.error 之后都有 raise,所以工具层能如实报失败并附 detail.screenshot。 ### 13.2 设计取舍 - **need_human 不算失败**:扫码/验证码是等待人工,不是错误。返回 ok:true + logged_in:false + need_human:true + next_action(重调 browser_login),避免宿主把它当错误去重试或另开浏览器。 - **锁只防并发开浏览器,不解决并发操作**:宿主会保留旧代子进程,两个 Chromium 会让「看到的截图」 和「实际操作的页面」不是同一个——这比并发写坏单据更早发生,也更难查。 - **不持久化填报进度**:steps_done 只在内存里。进程重启后要重新 open/login/new_form。这与「阶段由 产物推导」一致:事实是磁盘上的产物和财务系统里的页面,不是我们自己记的小本本。 - **browser_close 明确不保存不提交**:宁可丢一次填报,也不给 Agent 一个语义模糊的顺手保存。 ### 13.3 验证 - 新增 `tests/test_mcp_browser.py` 17 例(FakeBot/FakePage,不起真实浏览器):锁的空闲获取、活进程 拒绝、过期接管、锁文件损坏;need_human 分支与截图落点、重复登录幂等、未打开时各工具的拒绝与 next_action、类型推断、两种 info 并存时拒绝猜、未登录时拒绝建单、跳步拦截、逐步推进与 remaining、 未知步骤、步骤报错回传截图、close 释放会话与锁、open 复用现有浏览器、steps 的两种流程输出。 - 全量门禁:ruff check / format --check / mypy src / deptry / **pytest 129 passed**;mcp_smoke 显示 25 个工具、instructions 2109 字节正常下发。 - `docs/MCP.md` 的工具参考段按新 wire schema 重新生成(373 行,含浏览器分组与锁/心跳说明)。 - 过程中两次把工具失败误判成产品缺陷,都是看错信封层级造成的:`fail()` 的额外字段落在 `detail` 里,成功才平铺顶层。测试要写 `result['detail']['screenshot']`。 ### 13.4 还没做 1. **联机验证**:需要用户在场(SSO 可能要扫码),跑通 open → login → new_form → fill → page_state → close 2. **P4 提交护栏**:submit_preview(快照 + 摘要 + 一次性 token)与 submit_execute(token 一次性、摘要变更失效) 3. `case_status` 还没把 `.browser.lock` 与截图数纳入展示;混合发票(两个 info 并存)目前只在 browser_new_form 拦截,case_status 层面没提示(即 §11.5 第 6 项仍未做) 4. 步骤函数里的脆弱点没有配置入口:经济科目固定第 3 行、项目固定第 1 行 ### 13.5 联机全链路与断点续填(同日上午) 用户授权「需要的个人信息就在项目里」后跑了真实链路:open → login(6 秒,未触发扫码)→ new_form(normal) → fill(basic) → page_state → close。财务系统真的落库了:basic 页点「下一步(报销明细)」 即保存,生成单据编号 202610020850(未提交、0.00)。据此补上第 25 个工具并清掉测试数据。 **新增 browser_open_form(断点续填)**:列表页 → `#seacher` 关键字 → 唯一命中 → 行内单选框选中 → `#update` 修改 → 落到 `expen/common/editHtml?id=...`,读回 `#EXPENEXPLAIN` 仍是原说明,证明数据还在、 可以接着填。`preview_only=true` 只定位不点开,返回 `bill_no` / `state` / `amount` 供核对; 命中多张一律拒绝并回 `candidates`,不替用户猜。 **联机暴露的 4 个真 bug(都已修,都不是测试能发现的)**: 1. `navigate_to_reimburse` 用单点 `wait_for_selector(timeout=5000)` 硬等,同一份代码第一次过、 第二次挂。改成全程轮询 + 失败时把页面文字与截图写进异常;顺带发现直达报销 URL 不建立会话, 会被踢回「长时间未操作,请重新登录!」,所以门户点击链是必经路径,不能抄近路。 2. `actor.close_all()` 用 `atexit.register` 注册**根本不起作用**:`concurrent.futures.thread` 的退出 钩子走 `threading._register_atexit`,由 `threading._shutdown()` 触发,早于普通 atexit;轮到 close_all 时 executor 已 shutdown,`bot.close()` 提交不进去,报 Cannot switch to a different thread。 修法是把钩子注册到同一个列表里(后注册者先跑),实测退出时 close 在 playwright_0 线程执行。 3. `wait_rows_loaded` 用 `` 判断行加载完,真实列表的「查看」是 `