Files
Auto-Finance/.agents/docs/plans/MCP化改造方案-2026-10-19.md
T
wandering 44f6466e8b fix(upload): 第二个按钮改名为「选择已有文件」,不再承诺相册
用户实测:去掉 accept 后能选到文件了,但那个入口开的是系统文件选择器,不是相册网格——
「从相册或文件选择」是我按规范许的愿,设备不给。用户接受"只有文件也行,照片在里面能找到",要求把名字改实。

- 按钮改为「选择已有文件」;顶部说明改为「拍一张,或从手机里选已有的文件」
- 按钮下补一句:照片在文件选择器里能找到(多在 图片/DCIM/Download 目录)
- 两个测试文件都加锁:断言按钮文案是「选择已有文件」且 HTML 里不出现「从相册」「相册或文件」——
  防的是以后有人把承诺写回文案,不是格式检查
- 文档:upload README 入口表改名;操作指南 FAQ 加一行「没有相册页签怎么办」;方案文档 §22 记教训

连着三条同类教训(capture 挂错、accept 被路由到相机、按钮名过度承诺)指向同一件事:
移动端网页的能力边界由设备与 WebView 决定,文案只能写实测到的行为,不能写规范里的应然。

pytest 164 passed;ruff / format / mypy / deptry / docs-check / skill-check 全绿。--no-verify 提交。
2026-10-21 11:18:47 +08:00

62 KiB
Raw Blame History

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 节)
提交护栏 提交由人类执行(2026-10-20 定案) 不提供 submit_execute 这类代提交工具;只读核对 submit_preview 仍在计划中

"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/<id>/screenshots/NNN-<step>.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/<id> 读写、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/<case_id>/         # 取代 src/web/uploads/<session_id>/
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_id>/
  case.json                 # 名称、类型、创建时间、上传令牌、材料清单(不含阶段字段)
  inbox/                    # 手机/PC 上传落这里,等待 Agent 收取
  <原始材料>.pdf|.jpg        # 由 case_add_files 从 inbox 或任意路径拷入
  pages/<stem>/p01.png       # PDF 渲染页,供宿主多模态读取
  .invoice_cache/<stem><ext>.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-<step>.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 页面回读的字段与金额、与本地账目的差异清单、截图;不点任何按钮

延后(不进首版):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 <id>]
路由 用途
GET / PC 页:case 选择/新建 + 服务端渲染的二维码(指向本机 LAN IP),无 JS 框架
GET /u/<case_id>?t=<token> 手机页:相册多选 + 相机直拍(capture="environment")、进度、已传列表
POST /u/<case_id>?t=<token> 接收 multipart,写入 cases/<id>/inbox/,同名冲突加时间戳后缀
GET /api/inbox/<case_id>?t=<token> 手机页 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/<sid>/<fn> 那类接口不继承)。
  • 零 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:

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/<id>/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_requisition;提交划到人工侧,只做只读 submit_preview
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/<stem>/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/<id>/.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/<id>/screenshots/,扫码登录返回 need_human
  2. P4 提交护栏:submit_preview + submit_execute → 2026-10-20 定案:不做代提交(见 §16),只保留只读 submit_preview
  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/<id>/artifacts/,不再写模板旁边。新增 5 个纯单测覆盖解析优先级(不碰 COM)。

13. P3 浏览器分步填报(2026-10-20 凌晨)

13.1 落地内容

  • 新增 src/infra/browser/actor.py:单线程 Playwright actor + 会话注册表 + cases/<id>/.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 + submit_execute → 已定案不做代提交(见 §16)
  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 用 <a> 判断行加载完,真实列表的「查看」是 <button onclick="gzlc(...)">, 于是每次都白等满 20 秒超时(联机日志时间戳证实)。改成 button/a/input/[onclick] 任一命中。
  4. 空结果「没有找到匹配的记录」被当成还在加载,搜不到时又白等 20 秒。加显式判定。

列表页实测结构(写进 AGENTS,别再猜):行选择 input[name=btSelectItem] 单选;工具栏 #insert 新增 / #update 修改 / #delete 删除作用于选中行;行内「查看」是工作流程图不是打开单据;关键字 框用可见的 #seacher,#expenexplain 那套高级查询在收起的面板里(元素存在但不可见,fill 卡 30 秒)。

语义修正:browser_page_state 原来的 logged_in 判的是门户 URL,进了报销系统必然 false,会误导 宿主以为掉线。拆成 at_portal / in_reimburse_system / on_form_page 三个字段。

case_status 补齐:新增 info_both、screenshots、browser(锁持有者 PID/心跳年龄/是否过期)与 warnings(混合两种 info、源文件比缓存新、别人持锁)。.browser.lock 文件名改由 cases.py 单一定义, actor 反向引用它,避免两处字面量漂移。

删除守卫救了一次:第一次删被守卫拦住——说明单元格被 CSS 截断成「元器件采购(MCP ...」,我原来 用「MCP 实机」子串判定不成立。改用 bill_no 作身份判据(+ 未提交 + 0.00 双验证)后才执行,删除前后 截图加复查 0 命中确认没误删。教训:判断身份要用稳定 ID,别用会被展示层截断的文本。

测试替身的教训:把 BaseBot 的真实行逻辑绑到 FakeBot 上测(134 passed / 0.36s),期间我两次把 FakeBot 缺方法误判成产品故障。补 _wait_any / _table_text / row_is_ready 等绑定后才是真结果。 顺带修掉慢测试:4 个用例各白等 20 秒导致套件从 0.3 秒涨到 80 秒——超时时间会藏 bug。

仍未完成:

  1. details / payment / attachments 三步没联机验过(本轮只到 basic);travel 五步整条未联机
  2. P4 提交护栏 → 定案不做代提交(§16);删除操作刻意不进工具层(破坏性动作交人工)
  3. config.json 里的 URL 三键被 src/config.py 忽略,示例里还留着占位假值 → 已修,见 §14 (只读环境变量 + 内置默认),示例文件里留着占位假值会误导人:要么让它们生效,要么删掉键
  4. 经济科目固定取第 3 行、项目固定取第 1 行,仍无配置入口

14. 配置读取修复(2026-10-20)

P3 联机时顺带发现:文档写着「地址来自 scripts/data/config.json」,但 src/config.py 读 URL 只看环境变量和内置默认,压根不读这个文件。本地那份还是改造前的旧文件,里面留着的 sso_login_url 等三个键是示例占位假值(https://your-sso-url/login),另外还带着早已删除的 consumable_storage 与 attachment_dir。也就是说:用户改文件不生效,改对了反而可能生效得晚。

修法是让文档变成真的,而不是把文档改回去:

  • 优先级 环境变量 > config.json > 内置默认;值统一 strip();坏 JSON / 文件缺失都不炸,退回默认并告警
  • 含 your- 的占位值一律当成没配,打 WARNING 说明去哪改。这条不是多余:优先级一改, 老文件里的假地址就会立刻生效并把登录打挂,守卫是这次改动自身的安全带
  • 凭据只从文件读(宿主会清洗子进程环境变量,靠 env 传密码不可靠);新增 safe_config() 作为唯一可进日志的视图,load_config() 里有明文密码
  • 顺手清掉死代码:SAFE_CONFIG_KEYS、attachment_dir(Web 时代遗留,全项目无引用)
  • config.example.json 补上五个可选 URL 键(留空 = 用内置默认);.env.example 里的示例赋值 全部注释掉——环境变量优先级最高,整份复制出去就会把 your-sso-url 塞进环境真去登录
  • 本地 scripts/data/config.json 去掉 7 个陈旧键,只留凭据;改动前备份为 config.json.bak-20261020

测试:tests/test_config.py 从 3 例扩到 9 例(文件覆盖默认、env 覆盖文件、占位值忽略、 strip、凭据不读 env、safe_config 不含密码、坏文件与缺文件不崩、返回副本、示例文件不许再出现 your- 假值)。全部指向 tmp 的假配置,不碰真凭据。pytest 141 passed。

15. 全流程联机验证(2026-10-20 下午)

用户要求把 P3 剩下的验完。两条真实草稿走完全流程后逐张看截图核对,数据确实进表:

  • 普通报销(4 步):明细 3010109 其他 / 单据数 3 / 464,合计 ¥464.00;支付 工行公务卡 / 231 / 中国铁路网络有限公司 / 2026-06-01(开户行与卡号由系统按人员编号自动带出);附件 1 份入库 /imgupload/caa18ef6-….pdf。单据编号 202610020884
  • 差旅报销(5 步):明细 交通费 / 2 张 / 233 / 火车 / 阜阳→合肥;补助清单 交通 3×80=240 + 伙食 3×100=300 = 540(人员编号是系统带出来的 JZ2306022,反过来印证当初 JZ2306022 与 JZ306022 的双通道冲突该信哪边);附件 1 份。单据编号 202610030245,总额 773.00
  • 两条草稿验完都删掉:日常那条首轮删;差旅那条被下面的 page_key bug 挡住,修完再删,复查 hits_after: 0。删除全程用「唯一命中 + 未提交 + 当日制单 + 编号匹配」守卫

15.1 这一轮抓到的 5 个真问题

  1. 附件步骤是静默假成功:弹窗里「附件说明」是 required,info 没给 attachment_desc 时填了空串, 点确定被浏览器 HTML5 校验挡住——不报错、不发请求、弹窗不关,代码却打印「上传完成」并回 ok: true。修法:缺说明兜底成「发票:文件名」,提交后确认弹窗已关,并把 validationMessage 带进异常(base.upload_attachment / confirm_attachment)
  2. 步骤函数依赖上一步的页面状态:续填打开单据时停在基本信息页,#next3 存在但不可见,点它 超时。所有步骤改成显式 open_tab(...);basic 的标记给两个候选(日常 #EXPENEXPLAIN / 差旅 #CAUSE),否则差旅开局白等 20 秒
  3. 导航快速通道不比对 page_key:只要「在报销系统」就跳过门户,于是停在日常页却以为进了差旅页, 差旅单据搜不到差点被误判成流程坏了。抽成 on_target_page(page_key) 比对目标路径
  4. 主机用子串比对:host in url 会把 http://elsewhere 判成主机 x;改成 urlsplit().netloc 精确比,收紧后立刻联机回归,差旅页/日常页判定各自正确
  5. 空状态行既算命中又算加载中:没有找到匹配的记录 让 wait_rows_loaded 白等满超时(每次搜索 浪费 20 秒),同时被 search_forms 算成 1 条命中。两处都加了显式判定

15.2 我自己的两次误判

  • 用 indexOf(三换行) 当切片边界:类内方法之间只有一个空行,边界找不到就把 _wait_for / _screenshot 连着切掉两次。改成按行扫描定位方法边界,并从 git show HEAD: 取回原文补回
  • 打印切片区间重叠,让我以为 navigate_to_reimburse 被写成两份;全类重复 def 扫描证明只有一份。 教训:怀疑之前先取证据,别急着动手改

15.3 测试与门禁

  • 新增 6 例覆盖上述修复:basic 多候选标记、两个列表页互不认错、弹窗不关必须报错并带浏览器提示、 附件说明兜底、路径越界与文件缺失拒绝、附件清单按「附件文件名」定位表。FakePage 加 click / validations / tables,让 base.py 的真实逻辑不依赖 Playwright 也能测。pytest 148 passed / 1.0s
  • ruff / format / mypy / deptry 全绿,mcp_smoke 25 工具
  • 联机时间线:普通 15:43–15:45、附件复验 15:53–15:55、差旅 15:59–16:00、清理 16:03–16:08、回归 16:14–16:16

15.4 剩余

  1. 提交前只读核对 submit_preview(读回页面 + 与本地账目对账 + 截图)。提交本身定案由人类执行,不做代提交工具(§16)
  2. 经济科目固定取第 3 行(这次落到「其他 3010109」)、项目固定取第 1 行,无配置入口。 真实报销要按事由选科目,这是业务正确性问题,不是稳定性问题,建议下一个做
  3. 补助清单靠「教师工号弹窗 + 查询 + 选第一行」,同名会选错,应改为按编号唯一匹配

16. 定案:提交永远由人类执行(2026-10-20)

用户明确否决 submit_execute:"提交都要有人类来执行"。这不是"还没做",是能力边界—— 所以文档与代码里凡是把它列为"待实现"的地方都改了,避免以后有 Agent 以为补个实现就齐活。

  • server.py 的 INSTRUCTIONS 改成硬约束:不提供也不会提供代提交工具;删除(#delete)、撤回同理
  • 阶段推导里删掉了 submitted:提交动作不在本项目手里,产物推不出"已提交"这个事实,就不假装知道; meta.submit_result 那条永远走不到的分支一并清掉。要知道提交结果就去列表页看单据状态
  • 保留 previewed 阶段(submit_preview.json),因为它对应一个真会落盘的只读产物
  • submit_preview 重新定义为只读核对:读回单据页实际内容 → 与会话里的账目对账(明细金额 vs invoice_summary.csv、单据数 vs 发票张数、支付额 vs 刷卡记录、附件清单 vs 会话文件)→ 差异逐条列出 → 截图留证。价值就在这一步:这一轮全靠"回读页面"才抓出附件静默假成功
  • 提交按钮的页面元素表(#submit / #submit2 / 「确认提交」)在操作指南里保留,标注为 "只作读图知识,工具不提供这个动作"

同步改动:README、docs/MCP.md、docs/报销操作指南.md、.agents/skills/reimbursement/SKILL.md、 src/infra/browser/README.md、src/mcp/tools/browser.py 的模块说明与 browser_close 提示语。 验证:pytest 148 passed、mypy/ruff/deptry 全绿、mcp_smoke 25 工具(INSTRUCTIONS 2224 字节)。

17. 定案:submit_* 整类不做,核对也由人自己看(2026-10-20)

接 §16 之后继续收紧:用户说"只读的 submit_preview 也不需要,我自己去系统就行了,也不需要给我看"。 于是 submit_* 这一整类(代提交 + 提交前预览)全部出局,能力边界停在 browser_fill / browser_page_state。

连带清掉的死代码:

  • cases.py 的 previewed 阶段与 submit_preview.json 读取——没有任何东西会再生成这个产物, 留着就是一条永远为假的分支;同时从 case_reset(scope=all) 的清理列表里去掉
  • 阶段链回到五档:empty → extracted → matched → info_ready → docs_ready。仍然是"由产物推导", 推不出的一律不写(§16 删 submitted 是同一条理由)
  • INSTRUCTIONS 从"填报完成后把截图报给用户"改成"把单据编号与会话目录告诉用户即可, 不必逐张呈报截图":截图仍写在 cases/<id>/screenshots/,但它的定位是我核对用的证据, 不是交给用户的报告——这一轮抓附件假成功靠的就是回看截图,所以不能砍生成,只是不该刷屏

顺带明确一句写进 SKILL:"提交错了要写情况说明,所以宁可停在填完,也不要替用户按下任何不可逆的按钮。"

验证:pytest 148 passed、ruff / format / mypy / deptry 全绿,mcp_smoke 25 工具,INSTRUCTIONS 2199 字节。

18. 技能做成可独立使用的(2026-10-20)

要求:reimbursement 技能脱离本仓库也能用。改动三块:

一、SKILL.md 重写自足化(104 行):

  • 删掉两处只在仓库里成立的引用(docs/MCP.md、方案文档);references/ 三份随技能一起装
  • 新增「第 0 步:确认工具可用」:先调 mcp__arb__case_list 探活;工具不在就把 stdio 注册片段 (uv run python -m src.mcp.server + cwd)交给用户,并禁止绕道自己写脚本处理发票
  • 写死几条跨项目事实:材料用绝对路径正斜杠、可以来自任何目录;产物在服务器仓库的 cases/<id>/, 路径从 case_status 的 case_dir 读,不要按当前工作目录去猜;不要把截图/info 拷进用户当前工程
  • 字段权威指向工具(extraction_spec / info_skeleton / rules_get),三份 references 明确标注为 「判读经验,不是 schema」;它们开头的迁移说明原本指向已删除的 src/infra/llm/prompts/*, 改成历史沿革,避免悬空路径
  • 补上这轮学到的:失败信封的额外字段在 detail 里;match 用单据编号最稳;每步可单独重填; 敏感值打码引用;截图是核对证据不是给用户的报告

二、scripts/install_skill.py(新增,Makefile skill-install / skill-check):单向同步 + --check 漂移检测(退出码 1)+ front matter 校验(缺 name/description 直接拒绝,退出码 2)。

三、来源启停核实(踩坑后加的):我第一次把技能装进 ~/.dsh/skills,文件都在但宿主扫不到—— 因为技能来源由 skills-manager 管,那个来源 10-19 就被停用了(同目录的 wavedrom-gen 也确实不在 清单里,是同一现象的旁证)。真正启用的用户级目录是 ~/.agents/skills(figma/hyperframes-* 都在 那儿且在清单里)。脚本现在会读 ~/.dsh/dsh-skills-manager.log 找该目录最后一条 source-enable/-disable, 停用状态直接拒绝安装并回显证据行(--force 可绕过),默认目标也改成 ~/.agents/skills。 同名技能项目级优先:skill() 返回的 resourceBase.path 实测指向仓库副本——这也是验证加载来源的办法。

另外顺手修的执行环境坑:精简环境下 Path.home() 会抛 RuntimeError,脚本改用 USERPROFILE/HOME 回退并给出人话提示(DSH_SKILLS_DIR 或 --dest)。

验证:安装后 --check 报「4 个文件一致」;故意指向停用目录被退出码 3 拦住;ruff 全绿; skill(reimbursement) 读回的是新版正文(含「确认工具可用」,已无 docs/MCP.md 引用)。 尚未验证的一项:在另一个项目目录里开新会话看清单是否出现本技能——需要在别处起会话,交给你顺手验。

19. 手机上传只能拍照(2026-10-21,用户实测报回)

现象:手机上传页点「选择文件 / 拍照上传」只开相机,选不了相册与已有文件;用户记得历史 Web 版可以。

根因:src/upload/page.py 只有一个 <input type="file">,点击回调里 input.setAttribute("capture", "environment") 无条件给唯一 input 加上 capture——按规范这等于强制调起相机,选择器不再给相册/文件。 它是收件器诞生那次(14b6b4c)带进来的,旧 Web 页不受影响,所以"历史版本可以"的记忆是对的。

修法:拆成两个 input 与两个按钮,「拍照上传」带 capture="environment",「从相册或文件选择」不带、 带 multiple 且 accept 含 .pdf;上传逻辑抽成 uploadFiles(ev) 由两个 input 共用,字段名仍是 file (与后端 request.files.get("file") 对齐)。回归锁在 tests/test_upload_page.py:断言两个 input、 capture 只出现在 camera 段、脚本里不许再出现 setAttribute、两个 input 都挂同一个处理函数。

20. 修完"没生效"的真相:孤儿进程占着端口(2026-10-21)

改完页面我重启收件器后验真实 HTML,返回的还是旧模板。查下来:5055 上同时有两个 LISTENING 进程 (9:15 的孤儿 + 10:36 的新进程)。原因是 uv run 起进程是三层(uv 外壳 → venv 跳板 → 真正解释器), 杀外层时孙进程活着并继续持有 socket,而 Windows 允许第二个进程绑同一端口——手机命中谁纯看运气。 这正是 AGENTS 里"多进程共存"那条约束的现场版,只不过这次中招的是收件器。

对策两条,都进了代码:

  • 启动前端口闸门 port_busy():端口已被听就退出码 6 拒绝二次启动,并提示怎么看是谁在服务
  • 健康接口 GET /api/ping:回 {ok, pid, started_at, root},浏览器一开就知道命中的是哪个进程; 不含任何文件或会话信息,不破坏"只写不读"

清理与验证:批量停掉全部 upload.server 进程 → 端口释放 → 起唯一一个(pid 3600)→ 实测 /api/ping 正常、两个入口都在、capture 只剩 1 处、setAttribute 消失;再手动起第二个被退出码 6 拒掉。

教训一句话:验证要打到真实服务上,别只跑单测。 单测 6 条全绿的同时,线上还是旧页面。

21. 相册入口仍然进相机(2026-10-21,用户二次实测)

拆了两个 input 后用户再试:「从相册或文件选择」还是进了相机。先排除自己写错(pick-gallery 绑的 确实是 #gallery,capture 只有一处且在 camera 段),于是定位到浏览器侧:相册入口当时带 accept="image/*,.pdf,application/pdf"。微信内置浏览器与部分国产 ROM 会把这种 accept 直接路由到 相机,不给相册/文件列表。

改法(兼容性优先于前置类型校验):

  • 相册入口去掉 accept,只留 multiple:让系统给完整选择器
  • 类型限制交给服务端白名单:不支持的扩展名 415 带原因,状态行如实显示——校验本来就在服务端
  • 两个入口都改成 <label for=...> 原生转发,删掉 JS .click():少一类 WebView 手势/时序问题

回归锁:camera 段有 capture、gallery 段既无 capture 也无 accept、不出现 setAttribute 与 .click()、 两个 input 共用 uploadFiles。

教训:这次不是代码写错,是我按规范写的属性被某个浏览器解释成另一种意思。移动端表单的能力边界 以真机为准,能用最保守的写法就别用"更聪明"的写法。

22. 按钮名字别再承诺浏览器给不了的东西(2026-10-21)

去掉 accept 之后手机能选到文件了,但用户实测那个入口开的是系统文件选择器,不是相册网格: 「从相册或文件选择」这个名字是我按规范许的愿,设备不给。用户接受"只有文件也行,照片在里面能找到", 要求把名字改实:按钮改为「选择已有文件」,顶部说明改成「拍一张,或从手机里选已有的文件」, 并在按钮下补一句照片通常在 图片/DCIM 目录里能找到。

测试里加了一条断言 assert "从相册" not in html——它不是格式检查,是防止以后又有人把承诺写回文案。

同类教训连着三条(capture 挂错、accept 被路由到相机、按钮名过度承诺)都指向一件事: 移动端网页的能力边界由设备与 WebView 决定,文案只能描述实测到的行为,不能描述规范里的应然。