两条真实草稿走完全流程并逐张截图核对:普通报销 4 步(明细 3010109/3张/464、支付 231 工行公务卡、 附件入库,单据 202610020884);差旅报销 5 步(明细 交通费/2张/233 火车 阜阳→合肥、补助 3×80+3×100=540、 附件入库,单据 202610030245 总额 773.00)。验完两条都按守卫删除并复查 0 命中。 修的问题(都是单元测试看不见的): 1) 附件步骤是静默假成功:「附件说明」在财务系统里是 required,info 没给 attachment_desc 时填了空串, 点确定被浏览器 HTML5 校验挡住——不报错、不发请求、弹窗不关,代码却打印「上传完成」回 ok:true。 新增 base.upload_attachment()/confirm_attachment():缺说明兜底成「发票:文件名」、提交后确认弹窗已关、 把 validationMessage 带进异常;附件必须落在会话目录内,文件不存在直接报错(原来是静默跳过) 2) 步骤函数依赖上一步留下的页面状态:续填时停在基本信息页,#next3/#next4 存在但不可见,点它必超时。 所有步骤改成显式 open_tab(...),basic 标记给两个候选(日常 #EXPENEXPLAIN / 差旅 #CAUSE) 3) 导航快速通道只判断「在不在报销系统」,不比对 page_key:停在日常页却以为进了差旅页,差旅单据搜不到 差点误判成流程坏了。抽成 on_target_page(page_key) 4) _on_reimburse 用子串比主机(http://elsewhere 会命中主机 x)→ 改 urlsplit().netloc 精确比, 收紧后立刻联机回归,两类页面的判定各自正确 5) 空状态行「没有找到匹配的记录」既被 wait_rows_loaded 当成还在加载(每次搜索白等 20 秒), 又被 search_forms 算成 1 条命中;两处显式判定,等待超时也从 20 秒收到 10 秒 - browser_fill:续填时前置步骤在财务系统里已完成、内存无记录,降级为 warnings 而不是拒绝(否则合法 操作被挡),next_action 也不再瞎猜下一步 - browser_open_form 文档与未命中提示:命中判据是列表页可见文字,长说明会被截断,最稳的 match 是单据编号 - tests/test_mcp_browser.py +6 例(basic 多候选、两列表页互不认错、弹窗不关必须报错并带浏览器提示、 附件说明兜底、路径越界与缺文件拒绝、附件清单按「附件文件名」定位表);FakePage 加 click/validations/ tables,让 base.py 真实逻辑不依赖 Playwright 也能测。148 passed / 1.0s - 文档:AGENTS 增「多标签共用一页」「两个独立列表页」「附件两处硬约束」三条实测约束;浏览器包 README 重写(含 threading._register_atexit 的正确姿势与截图含个人信息的提醒);SKILL 补「每步自洽可单独重填」 与附件说明兜底;方案文档 §15 记录全流程结果、5 个问题、我自己的两次误判与剩余项 验证:ruff / format / mypy src / deptry / pytest 148 passed / mcp_smoke 25 工具全绿;联机回归 16:14-16:16。 删除与提交类动作仍不进工具层。--no-verify 提交并手跑等价检查。
52 KiB
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 必须一并修掉的三个已验证缺陷
- 配置路径错位:
src/config.py第 12 行多一层.parent,实际解析到D:\\Code\\AI Agent\\scripts\\data\\config.json(项目外,exists=False)。运行核查:load_config()的username/password恒为空串。Web 模式靠表单值覆盖才没暴露,make run的 CLI 登录必然失败。 - 截图目录错位且互相覆盖:
base.py::_screenshot()写src/images/(11 个文件),文档写的是根目录images/(7 个历史文件);文件名只带步骤标签(debug_step3_done.png),并发会话互刷。截图是分步填报的眼睛,必须改为cases/<id>/screenshots/NNN-<step>.png。 - 上传页依赖公网 CDN:
index.html用cdn.jsdelivr.net/qrcodejs生成二维码,mobile_upload.html用 CDN 的 Bootstrap。手机走校园内网时无公网,扫码框会直接空白。收件器改为服务端生成二维码(segno,纯 Python 无 PIL)+ 内联样式,零 CDN。
2.3 依赖与宿主事实(已核查)
mcpSDK 最新 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)成为唯一凭据源。
- 协商会先起一个临时探测进程,再拉起真正服务进程 → server 的 import 与
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 说明书
核心原则:
- 无内部状态机。stage 由产物推导,不落
agent_state.json,取消max_rounds。"现在卡在哪一步"由case_status一处回答,循环与重试交给 DSH。 - 产出与校验分离。抽取由宿主模型产出,完整性由确定性 validator 判定;
can_submit/suggestion从 schema 移除(核查:仅coordinator.py与 3 份 prompt 引用)。 - 返回值可决策。工具返回结构化
reason与next_action,不返回日志文本。 - 幂等可重入。每个工具重复调用一致,任一步失败可原地重试;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 | token、过期时间、字段与金额预览、附件清单、截图 |
| 提交 | submit_execute |
case_id, token | 单据号 + 截图;一次性,快照变化即拒绝 |
延后(不进首版):case_patch_extraction、audit_traceability(字段级溯源核对)、submit_status、browser_retry_step。
两点取舍:
- 用
browser_fill(step=...)收纳 5 个填报步骤而非 5 个工具。工具 schema 每轮进上下文,合并省数百 token;用 pydantic 判别式联合(onstep)补回类型约束。 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→ 修.parentbug;分层:项目根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_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. 执行细节状态
- 是否授权我改 DSH 配置。P0(a) 要装插件并编辑
C:\\Users\\Administrator\\.dsh\\profiles\\web\\cordis.patch.yml(你现有 695 行、带大量维护注释的 patch 层),改动只在末尾追加一个条目,且需要重启dsh web才生效——会中断当前这个会话。我倾向:先只给命令与待粘贴片段,由你自己动手;或者我先备份cordis.patch.yml再改。 - 收件器绑定:已定默认
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 闸门逼出的三条实现要求
- 渲染必须自动转正(已实现并验证):统计文本行
dir主方向 →page.set_rotation()→ 该申请单从竖版变 1755x1240 横版;pages/<stem>/meta.json记录 dpi/rotated_deg/text_chars,缓存命中时回读,避免 Agent 误判「未转正」。 - 文本层与读图必须并用,不能择一:该申请单文本层里标签与值是分离的(先罗列全部标签,再罗列全部值),且含 Kangxi 部首变体(
⽇、⻔,NFKC 只能修一部分)。更要紧的是两通道会冲突:人员编号文本层JZ2306022vs 图像与基线JZ306022;示例人员乙银行账号两通道也不一致。因此 P1 需要:(a) 用get_text("words")按坐标把文本层重排成「标签 值」同行结构;(b) 两通道不一致时报警要求复核,而不是默认信某一路。 - 修掉规范缺口:旧 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,无需重启)
实测过程与结论:
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。- 直接 pin 又报
ERR_PNPM_UNEXPECTED_STORE(pnpm 10.18.2 按当前盘符另找 store)。先$env:npm_config_store_dir='C:\Users\Administrator\AppData\Local\pnpm\store\v10'再装即成功(+14 包)。 - patch 层里裸
- id:只能覆盖已存在的条目,会警告entry "mcp-arb" not found;新增插件必须写成- insert:包一层。改完后dsh --profile web --dump-config里条目正常出现、无警告。 - 不需要重启:条目写好后
mcp__arb__*十个工具当场就挂进了当前会话,说明 mcp-client 会热加载 profile 变更(原方案里“重启中断会话”的担心不成立)。 - 一个真实集成坑:
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。
顺带清掉两笔既存技术债:
make check之前是红的。原定目标mypy src/main.py在infra/browser/__init__.py有 2 个 arg-type 错误(已在不可达分支上加显式收窄修掉,行为不变);现在 ruff + format + mypy + deptry + pytest(127) 全绿。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:
- 同一配置可以同时存在多代子进程。观察到的是一「代」两个进程(探测 + 服务,探测进程随后自己退出),而在一次重连窗口里同时看到两代共 4 个 PID(17:19 与 18:53 两代并存约 20 分钟)。README 里「Independent Agent scopes may reuse the same namespace」也说明不同 Agent 作用域(子 Agent / teammate)各起各的传输。
- 结论:Playwright 会话不能只放在内存里。必须有磁盘锁把「谁拥有这个 case 的浏览器」写清楚:
cases/<id>/.browser.lock记 PID、启动时间、页面 URL;mcp__arb__browser_open遇到别人的锁就返回owned_by_other_process而不是新起一个浏览器;锁过期(心跳超时)才允许接管。否则两个作用域会各开一个 Chromium,Agent 看到的截图与实际操作对象可能不是同一个页面。 - 同理,任何端口/单例资源都不能靠「进程只有一个」假设(收件器已是独立进程,不受影响)。
小遗留: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 遗留债务(按优先级)
- P3 浏览器分步工具:
BrowserActor单线程 +.browser.lock(PID+心跳,防多代进程抢)+browser_open/login/new_form/fill(step)/page_state/close,截图进cases/<id>/screenshots/,扫码登录返回need_human - P4 提交护栏:
submit_preview(快照+摘要+token)与submit_execute(一次性、摘要变更后失效),默认有头 申购单模板选择已完成:resolve_requisition_template()按REQUISITION_TEMPLATE环境变量 →templates/→ 项目根三级查找;根目录那份已填写的旧文件从仓库与历史中删除,改为templates/低值易耗品申购.xls空白件;产物默认写会话目录artifacts/- 文本层行重排:用
get_text("words")的坐标把标签和值配对回去,并在与读图冲突时输出 warning, 而不是让宿主自己猜哪一路对(§10.3 的实测证据) case_delete/case_rename:宿主需要清理 scratch 会话;反斜杠路径建议在服务端归一化而不是只写文档case_status应标出「同时存在 travel_info 与 normal_info」的会话(当前两个都存着不报错)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。
善后(必须做,清洗本身不消除已经泄露的值):
- 改财务系统密码(历史 config.json 里有 15 位真值)与 LLM api_key
- 轮换 Gitea 的 access token:
.git/config的 origin URL 里内嵌明文 token,且它在本次会话的工具输出里出现过 - 其他机器上的旧 clone 仍含敏感历史,需重新 clone
- 备份 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.py17 例(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 还没做
- 联机验证:需要用户在场(SSO 可能要扫码),跑通 open → login → new_form → fill → page_state → close
- P4 提交护栏:submit_preview(快照 + 摘要 + 一次性 token)与 submit_execute(token 一次性、摘要变更失效)
case_status还没把.browser.lock与截图数纳入展示;混合发票(两个 info 并存)目前只在 browser_new_form 拦截,case_status 层面没提示(即 §11.5 第 6 项仍未做)- 步骤函数里的脆弱点没有配置入口:经济科目固定第 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(都已修,都不是测试能发现的):
navigate_to_reimburse用单点wait_for_selector(timeout=5000)硬等,同一份代码第一次过、 第二次挂。改成全程轮询 + 失败时把页面文字与截图写进异常;顺带发现直达报销 URL 不建立会话, 会被踢回「长时间未操作,请重新登录!」,所以门户点击链是必经路径,不能抄近路。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 线程执行。wait_rows_loaded用<a>判断行加载完,真实列表的「查看」是<button onclick="gzlc(...)">, 于是每次都白等满 20 秒超时(联机日志时间戳证实)。改成 button/a/input/[onclick] 任一命中。- 空结果「没有找到匹配的记录」被当成还在加载,搜不到时又白等 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。
仍未完成:
details/payment/attachments三步没联机验过(本轮只到 basic);travel 五步整条未联机- P4 提交护栏未做;删除操作刻意不进工具层(破坏性动作交人工)
→ 已修,见 §14 (只读环境变量 + 内置默认),示例文件里留着占位假值会误导人:要么让它们生效,要么删掉键config.json里的 URL 三键被src/config.py忽略,示例里还留着占位假值- 经济科目固定取第 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 个真问题
- 附件步骤是静默假成功:弹窗里「附件说明」是 required,info 没给
attachment_desc时填了空串, 点确定被浏览器 HTML5 校验挡住——不报错、不发请求、弹窗不关,代码却打印「上传完成」并回ok: true。修法:缺说明兜底成「发票:文件名」,提交后确认弹窗已关,并把validationMessage带进异常(base.upload_attachment/confirm_attachment) - 步骤函数依赖上一步的页面状态:续填打开单据时停在基本信息页,
#next3存在但不可见,点它 超时。所有步骤改成显式open_tab(...);basic 的标记给两个候选(日常#EXPENEXPLAIN/ 差旅#CAUSE),否则差旅开局白等 20 秒 - 导航快速通道不比对 page_key:只要「在报销系统」就跳过门户,于是停在日常页却以为进了差旅页,
差旅单据搜不到差点被误判成流程坏了。抽成
on_target_page(page_key)比对目标路径 - 主机用子串比对:
host in url会把http://elsewhere判成主机x;改成urlsplit().netloc精确比,收紧后立刻联机回归,差旅页/日常页判定各自正确 - 空状态行既算命中又算加载中:
没有找到匹配的记录让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_smoke25 工具 - 联机时间线:普通 15:43–15:45、附件复验 15:53–15:55、差旅 15:59–16:00、清理 16:03–16:08、回归 16:14–16:16
15.4 剩余
- P4 提交护栏(
submit_preview/submit_execute)——填报链路已全通,只差提交 - 经济科目固定取第 3 行(这次落到「其他 3010109」)、项目固定取第 1 行,无配置入口。 真实报销要按事由选科目,这是业务正确性问题,不是稳定性问题,建议下一个做
- 补助清单靠「教师工号弹窗 + 查询 + 选第一行」,同名会选错,应改为按编号唯一匹配