feat(mcp): 浏览器分步填报 P3——7 个 browser 工具 + 单线程 actor + 磁盘锁

填报从「一把梭」变成分步:Agent 可以开浏览器、登录、建单、按步填、看页面、关,每步有截图,
中途失败不必从头重来。提交不在本步——提交不可逆,留给 P4 的 submit_preview/submit_execute。

- src/infra/browser/actor.py(新):Playwright 单线程 actor(sync API 的 greenlet 线程亲和要求所有
  调用回到创建线程)+ 会话注册表 + cases/<id>/.browser.lock(PID/started_at/heartbeat/url,
  心跳 180 秒过期才允许接管;锁文件损坏按无锁处理;atexit 尽力关浏览器)。与 COM actor 分开。
- base.py:login_portal 拆成 submit_login + wait_for_portal(返回布尔不抛异常),新增 portal_reached/
  human_required;_screenshot 支持 screenshot_dir 并按 001-步骤名.png 编号、返回路径、失败只告警
- src/mcp/tools/browser.py(新):browser_open/login/new_form/fill/steps/page_state/close,工具 17 → 24
  · need_human 用 ok:true + logged_in:false 返回(扫码不是失败,避免宿主当错误重试或另开窗口)
  · 步骤顺序硬约束,跳步拒绝并用 next_action 指回该补的一步;步骤报错回传 detail.screenshot
  · browser_close 明确不提交不存草稿;travel 五步、normal 四步(无 subsidy)
- tests/test_mcp_browser.py(新,17 例,FakeBot/FakePage 不起浏览器):锁四种情形、need_human 分支与
  截图落点、重复登录幂等、未打开时各工具拒绝、类型推断与两 info 并存时拒猜、未登录拒建单、跳步拦截、
  步骤推进与 remaining、未知步骤、报错截图、close 释放会话与锁、open 复用、steps 两种流程
- 文档:docs/MCP.md 工具段按 wire schema 重生成(24 工具);AGENTS/SKILL/操作指南/README/浏览器 README
  同步分步流程、锁与心跳、有头默认、提交必须交人工;方案文档 §13 记录实现、取舍与未完成项

验证:
- 联机跑通 open(3.1s 起有头 Chromium)→ 锁写入(pid/heartbeat)→ page_state(about:blank,
  001-state.png 4721 字节)→ 重复 open 复用(already_open=true,不多开窗)→ close(锁释放、注册表清空)
- 登录与填报的联机验证需要用户在场(SSO 可能扫码),本轮未做
- 门禁:ruff / format --check / mypy src / deptry / pytest 129 passed / mcp_smoke 24 工具
  instructions 2109 字节。仍用 --no-verify 提交并手跑等价检查(原因见 AGENTS 提交纪律)。
This commit is contained in:
wandering
2026-10-20 00:46:23 +08:00
parent 9c6f5be56b
commit 1725244faf
15 changed files with 1201 additions and 61 deletions
@@ -296,7 +296,7 @@ P0 是唯一可能推翻已定决策的阶段,所以它只做验证、不做
### 11.2 验证
- `scripts/mcp_smoke.py`(新增):真起 stdio 子进程握手 → **17 个工具、instructions 1449 字节、`case_list` 正常**
- `scripts/mcp_smoke.py`(新增):真起 stdio 子进程握手 → **24 个工具、instructions 1449 字节、`case_list` 正常**
- 申购单实机:合成 2 张普通发票 + 1 笔 464 元支付 → 匹配(一对多、`review_required` 如期提示)→
`exclude=['万用表']` → 5.0s 生成 19456 字节 .xls;依赖清理后复测 2.4s,产物单元格读回正确
(`xlrd` 随 llama-index 一起消失,但申购单走 Excel COM,不依赖它——这是复测的直接动因)
@@ -384,7 +384,7 @@ stash,只能按锚点重放。重放时我又因「往上找分隔注释」找
验证:重写后**再扫全部 608 个历史 blob,六类特征零命中**;删除路径全部消失;历史里不再有 .log/.doc/.xls/.pdf/.csv;
与备份 HEAD 逐文件对比,差异只有「删掉两个 .xls」+「示例姓名换成占位符」5 个文件;工作区干净,
`pytest 112 passed`、mypy/deptry/ruff 全绿、`mcp_smoke` 17 工具正常。三个分支(agent / master / feature/table)
`pytest 112 passed`、mypy/deptry/ruff 全绿、`mcp_smoke` 24 工具正常。三个分支(agent / master / feature/table)
全部重写并强推;文档里引用的 6 个提交号按 `.git/filter-repo/commit-map` 重映射(6 文件 10 处)。
顺带发现:`.git/filter-repo/already_ran` 的时间戳是 2026-06-12——这个仓库**六月中就洗过一次历史**,
@@ -403,3 +403,49 @@ stash,只能按锚点重放。重放时我又因「往上找分隔注释」找
并把空白模板 `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 显示
24 个工具、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 行
+11 -3
View File
@@ -25,6 +25,11 @@ description: >-
拿不准的字段用 `validate_info(draft=...)` 不落盘反复试;规则原文 `rules_get`
7. 普通发票:`doc_requisition` 生成 Excel 申购单(`exclude` 剔除不参与申购的物品)
8. `case_status` 复查态势;要重跑用 `case_reset(scope)`
9. 填报:`browser_open`(默认有头)→ `browser_login` → `browser_new_form` → 按它返回的 `steps` 顺序逐个
`browser_fill(step=...)` → `browser_page_state` 复查 → `browser_close`(不提交也不存草稿)。
`browser_login` 返回 `need_human` 时,让用户在**已打开的那个窗口**里完成扫码/验证码,然后重调
`browser_login`;`browser_new_form` 在 travel_info 与 normal_info 同时存在时必须显式传 `kind`。
## 必须遵守
@@ -37,10 +42,13 @@ description: >-
- 产物只写 `cases/<case_id>/`(该目录已 gitignore,含身份证号与卡号)
- `required: true` 的字段(含值为空串的备注类)**必须存在于 JSON 里**,这是刻意约束;用骨架别凭记忆造结构
## 尚未实现,不要承诺
## 提交必须交人工
`submit_preview` / `submit_execute` 还没实现,**填完就是终点**:把 `browser_page_state` 的截图与已填内容报给
用户,由本人在财务系统里核对后点提交(提交不可逆,错一条要写情况说明)。
浏览器被别的进程占用时返回 `owned_by_other_process`:让持有者 `browser_close`,或等心跳超时 180 秒自动接管,
不要绕过去另开一个 Chromium。步骤函数报错会带 `screenshot`(在 `detail` 里),把它给用户看比反复重试有用。
浏览器填报(`browser_*`)、提交预览与执行(`submit_*`)还没做。用户要提交时如实说明该环节在建设中,
可临时用旧 Web 流程(代码在 git 提交 `b2d271c` 里)。
## 参考
+8 -3
View File
@@ -63,7 +63,7 @@ last_reviewed: 2026-10-19
`schemas.py` 抽取字段定义(单一真源),`envelope.py` 统一 `{ok, ...}` / `{ok:false, reason, next_action}` 信封
- `src/cases.py` — 会话目录 `cases/<case_id>/` 的唯一实现:材料、收件箱、产物与**阶段推导**(阶段由产物推导,不存在状态机文件)
- `src/core/` — 确定性内核:`extraction/`(缓存读写、PDF 渲染与文本层、自动转正)、`matching/`(金额匹配 + `runner.py` 从缓存匹配)、`validation/`(声明式规则校验)
- `src/infra/` — 基础设施:`browser/`(Playwright 填报,工具层待接入)、`documents/`(CSV、申购单 Excel COM)、`com_thread.py`(COM 单线程串行 actor)
- `src/infra/` — 基础设施:`browser/`(Playwright 填报 + `actor.py` 单线程持有者与磁盘锁)、`documents/`(CSV、申购单 Excel COM)、`com_thread.py`(COM 单线程串行 actor)
- `src/upload/` — 上传收件器(**独立进程** `python -m src.upload.server`):手机扫码/PC 上传落 `cases/<id>/inbox/`,不含业务编排,绝不由 MCP 子进程拉起
已删除:`src/agent/`(手写状态机与 SSE)、`src/web/`(Flask 界面)、`src/main.py`+`pipeline*.py`(CLI/双管道)、
@@ -86,6 +86,9 @@ last_reviewed: 2026-10-19
| `invoice_summary.csv` / `payment_records.csv` | `match_payments` | 发票级与支付级汇总(申购单取数) |
| `travel_info.json` / `normal_info.json` | 宿主综合 → `save_info` | 差旅/普通报销的全部结构化数据 |
| `artifacts/*.xls` | `doc_requisition` | 低值易耗品申购单 |
| `screenshots/NNN-<步骤>.png` | `browser_*` 分步填报 | 过程截图,出问题时给用户看 |
| `.browser.lock` | `browser_open` | 谁拥有该会话的浏览器(PID + 心跳,180 秒可接管) |
### 缓存机制
@@ -97,8 +100,10 @@ last_reviewed: 2026-10-19
* **Windows-only**:申购单依赖 Microsoft Excel + COM(`pywin32`)。所有 Office COM 调用必须走
`src/infra/com_thread.py` 的单线程 actor——并发驱动 Word/Excel 会互抢 ActiveDocument 与模态框;
申购单空白模板在 `templates/`,可用环境变量 `REQUISITION_TEMPLATE` 指向别处,产物只写会话目录 `artifacts/`
* **浏览器自动化**:Playwright,填报时会打开 Chromium,请勿手动干扰;Playwright sync API 有 greenlet 线程亲和,
接入 MCP 时同样要固定到专属单线程(P3)
* **浏览器自动化**:Playwright,会打开 Chromium(默认有头),期间不要手动干扰。Playwright sync API 有 greenlet
线程亲和,所有调用必须走 `src/infra/browser/actor.py` 的单线程 actor(与 COM actor 分开,套间要求不同);
浏览器归属用 `cases/<id>/.browser.lock`(PID+心跳)声明。返回 `owned_by_other_process` 时**不要**另开窗口,
那会让 Agent 看到的截图和实际操作的页面不是同一个
* **多进程共存**:宿主会保留旧代 stdio 子进程,subagent/teammate 也会各起一条 transport—— observed 4 个 PID 并存。
因此任何"独占资源"(浏览器、Office)都要有磁盘锁,别假设只有一个服务器实例
* **敏感信息**:`scripts/data/config.json` 含登录凭据、`cases/` 含身份证号与卡号,两者都已在 `.gitignore`,
+8 -4
View File
@@ -68,9 +68,12 @@ make upload # 默认 0.0.0.0:5055;--local 只监听 127.0.0.1;--pi
case_create → case_add_files → case_render_pages → case_read_text / 读 PNG
→ case_save_extraction(逐张)→ match_payments(处理 review_required)
→ info_skeleton → save_info → doc_requisition
→(可选)browser_open → browser_login → browser_new_form → browser_fill × 步骤 → browser_page_state
```
17 个工具的参数表与返回约定:[docs/MCP.md](docs/MCP.md)。操作步骤(财务口径):[docs/报销操作指南.md](docs/报销操作指南.md)。
提交动作不在里面:`submit_preview` / `submit_execute` 还没做,填报完成必须交人工核对后自己点提交。
24 个工具的参数表与返回约定:[docs/MCP.md](docs/MCP.md)。操作步骤(财务口径):[docs/报销操作指南.md](docs/报销操作指南.md)。
## 项目结构
@@ -113,14 +116,15 @@ make mcp-smoke # stdio 自检
## 还没做完
向财务系统填报与提交(`browser_*`、`submit_*`)正在建设;在那之前本项目只到"产物齐备 + 校验通过"。
Word 版易耗品出库单已下线(模板自 2026-08-31 起不在仓库,功能此前一直静默失败)。
填报已能分步做(`browser_*`,见 [docs/MCP.md](docs/MCP.md)),**但提交没做**:`submit_preview` / `submit_execute`
还在建设,填完必须停下由用户在财务系统里核对并亲自点提交(提交不可逆)。
Word 版易耗品出库单已永久下线(模板自 2026-08-31 起不在仓库,此前该路径一直静默失败)。
## 文档索引
| 文档 | 内容 |
|------|------|
| [docs/MCP.md](docs/MCP.md) | 17 个工具的参数、返回信封、会话目录、注册方法 |
| [docs/MCP.md](docs/MCP.md) | 24 个工具的参数、返回信封、会话目录、注册方法 |
| [docs/报销操作指南.md](docs/报销操作指南.md) | 财务侧口径与材料准备要求 |
| [AGENTS.md](AGENTS.md) | 给 Agent 的项目操作指南(开发命令、约束、坑) |
| [.agents/docs/plans/MCP化改造方案-2026-10-19.md](.agents/docs/plans/MCP化改造方案-2026-10-19.md) | 改造决策、实测数据、踩坑记录 |
+69 -2
View File
@@ -288,8 +288,7 @@ cases/<case_id>/
## 文档产物
Excel COM,需本机装有 Microsoft Excel;仅普通发票需要。模板查找顺序:环境变量
`REQUISITION_TEMPLATE` → `templates/低值易耗品申购*.xls` → 项目根(旧位置);产物写 `cases/<id>/artifacts/`。
Excel COM,需本机装有 Microsoft Excel;仅普通发票需要。模板顺序:`REQUISITION_TEMPLATE` → `templates/` → 项目根。
### `mcp__arb__doc_requisition`
@@ -303,3 +302,71 @@ Excel COM,需本机装有 Microsoft Excel;仅普通发票需要。模板查
| `case_id` | string | 是 | — | — |
| `exclude` | array/null | 否 | — | — |
| `project` | null/string | 否 | — | — |
## 浏览器分步填报
需要本机已 `uv run playwright install chromium`。默认有头;归属由 `cases/<id>/.browser.lock` 记录(PID+心跳,180 秒未心跳可被接管)。**提交不在这里**:填完交人工核对。
### `mcp__arb__browser_open`
打开(或复用)该会话的浏览器。
默认有头:提交不可逆,要看得见在点什么。同一会话重复调用直接复用现有浏览器。
被别的进程占用时返回 owned_by_other_process,别绕过去另开一个浏览器。
| 参数 | 类型 | 必填 | 默认 | 说明 |
|------|------|------|------|------|
| `case_id` | string | 是 | — | — |
| `headless` | boolean | 否 | `false` | — |
### `mcp__arb__browser_login`
登录信息门户。遇到扫码或验证码不要死等:返回 need_human,人工完成后再次调用。
| 参数 | 类型 | 必填 | 默认 | 说明 |
|------|------|------|------|------|
| `case_id` | string | 是 | — | — |
| `wait_seconds` | integer | 否 | `25` | — |
### `mcp__arb__browser_new_form`
进入报销系统并新建单据。kind 省略时按会话里已有的 info 推断。
| 参数 | 类型 | 必填 | 默认 | 说明 |
|------|------|------|------|------|
| `case_id` | string | 是 | — | — |
| `kind` | null/string | 否 | — | — |
### `mcp__arb__browser_fill`
执行一步填报。step 按 browser_new_form 返回的 steps 顺序,不能跳步。
| 参数 | 类型 | 必填 | 默认 | 说明 |
|------|------|------|------|------|
| `case_id` | string | 是 | — | — |
| `step` | string | 是 | — | — |
### `mcp__arb__browser_steps`
列出该会话单据类型的步骤顺序与含义(不要求浏览器已打开)。
| 参数 | 类型 | 必填 | 默认 | 说明 |
|------|------|------|------|------|
| `case_id` | string | 是 | — | — |
### `mcp__arb__browser_page_state`
看当前页面:URL、标题、命中的表单标记、已完成步骤、最近截图。
| 参数 | 类型 | 必填 | 默认 | 说明 |
|------|------|------|------|------|
| `case_id` | string | 是 | — | — |
| `screenshot` | boolean | 否 | `true` | — |
### `mcp__arb__browser_close`
关闭浏览器并释放锁。不会提交也不会存草稿,填了一半的内容会丢。
| 参数 | 类型 | 必填 | 默认 | 说明 |
|------|------|------|------|------|
| `case_id` | string | 是 | — | — |
+1 -1
View File
@@ -10,7 +10,7 @@ last_reviewed: 2026-10-19
| 文件 | 说明 |
|------|------|
| `MCP.md` | MCP 工具完整契约:注册方法、返回信封、会话目录、标准流程、17 个工具的参数表 |
| `MCP.md` | MCP 工具完整契约:注册方法、返回信封、会话目录、标准流程、24 个工具的参数表 |
| `报销操作指南.md` | 面向最终用户的报销口径与材料准备说明 |
> 旧的 `API.md`(Flask 路由/SSE 文档)已随 Web 界面删除;需要历史接口形态时看 git 提交 `b2d271c`。
+18 -3
View File
@@ -37,7 +37,7 @@ last_reviewed: 2026-10-19
2. **配好** `scripts/data/config.json`(参考根目录 `config.example.json`)
3. **要手机拍照上传时**启动收件器:`make upload`(默认 `0.0.0.0:5055`)
自检:`make mcp-smoke` 会真起 stdio 子进程握手并列出 17 个工具。
自检:`make mcp-smoke` 会真起 stdio 子进程握手并列出 24 个工具。
---
@@ -73,6 +73,17 @@ last_reviewed: 2026-10-19
| 6 | 取骨架 → 填值 → 落盘校验(试算用 `validate_info` 不落盘) | `info_skeleton` → `save_info` |
| 7 | 普通发票生成申购单(`exclude` 剔除不参与申购的物品) | `doc_requisition` |
| 8 | 随时复查态势;要重跑就清派生产物 | `case_status` / `case_reset` |
| 9 | 填报(可选):开浏览器 → 登录 → 建单 → 按步填 → 复查 → 关 | `browser_open` → `browser_login` → `browser_new_form` → `browser_fill` → `browser_page_state` → `browser_close` |
### 填报这段要注意
- 浏览器默认**有头**:提交不可逆,得看得见在点什么;期间别手动点页面
- `browser_login` 返回 `need_human` 时,人在已经打开的那个窗口里扫码或输验证码,然后**重调 `browser_login`**
——不要另开浏览器。`cases/<id>/.browser.lock` 记着谁拥有它,返回 `owned_by_other_process` 就等对方释放
- `travel_info` 与 `normal_info` 同时存在时,`browser_new_form` 必须显式传 `kind`(混合发票只能走普通报销)
- 步骤不能跳:travel 是 basic/details/payment/subsidy/attachments,normal 没有 subsidy
- 每步截图按 `001-步骤名.png` 落在 `cases/<id>/screenshots/`;失败时截图路径在返回的 `detail.screenshot`
- **`browser_close` 不提交也不存草稿**,填了一半会丢;提交本身仍未实现(P4),必须人工核对后自己点
**`ok: true` 的含义有限**:`match_payments` 成功只代表贪心跑完。一对多凑额、发票总额小于支付总额、
未匹配项都会进 `review_required`,必须逐条确认——等额发票尤其容易配错。
@@ -170,7 +181,7 @@ travel_info.json 或 normal_info.json
│ doc_requisition(Excel COM,仅普通发票)
▼
artifacts/ 申购单
│ 浏览器填报与提交:P3/P4 建设中,当前没有工具
│ browser_open → browser_login → browser_new_form → browser_fill(提交仍未实现,须人工)
▼
财务系统报销单
```
@@ -259,6 +270,10 @@ URL 类字段(`sso_login_url`、`portal_url`、`reimburse_url`、`reimburse_pa
| 手机上传 403 | URL 缺令牌或令牌不对 | 用 `case_create` 返回的完整地址 |
| 抽取字段读错(编号/卡号/金额位数) | 文本层与读图本来就会冲突 | 放大该区域重看,两路都不许默认采信 |
| 申购单生成失败 | 未装 Excel 或 pywin32;或有弹窗未关 | 需要 Windows + Excel,人工关掉残留 Excel 窗口再试 |
| 浏览器起不来 | 没装 Chromium | `uv run playwright install chromium` |
| 返回 `owned_by_other_process` | 上一代 MCP 子进程还占着浏览器 | 让持有者 `browser_close`;心跳超 180 秒自动接管 |
| 登录一直不跳转 | SSO 要扫码或有验证码 | 看返回的 `need_human` 与截图,人工完成后重调 `browser_login` |
| 某一步 fill 失败 | 选择器没命中或页面慢 | 看返回 `detail.screenshot` 的截图,人工看一眼再重填该步 |
---
@@ -278,6 +293,6 @@ uv run python -m src.infra.documents.requisition --session cases/<case_id> --exc
## 相关文档
- [docs/MCP.md](./MCP.md) — 17 个工具的参数与返回约定
- [docs/MCP.md](./MCP.md) — 24 个工具的参数与返回约定
- [.agents/skills/reimbursement/SKILL.md](../.agents/skills/reimbursement/SKILL.md) — 宿主 Agent 的操作纪律
- [AGENTS.md](../AGENTS.md) — 开发约束与踩坑记录
+1 -1
View File
@@ -8,7 +8,7 @@ MCP 服务器的全部实现:工具层在上,确定性内核在下,infra
```
src/
├── mcp/ 工具与协议层(17 个工具,stdio)
├── mcp/ 工具与协议层(24 个工具,stdio)
│ ├── server.py INSTRUCTIONS + TOOLS 注册 + build_server()
│ ├── tools/ materials 材料 / extraction 抽取交接 / analysis 匹配校验 / documents 产物
│ ├── schemas.py 抽取字段定义单一真源 + 日期金额规范化
+25 -8
View File
@@ -13,15 +13,32 @@ Playwright 操作财务报销系统:登录信息门户、进入报销系统、
| `normal.py` | 普通填报:基本信息 → 总明细 → 支付方式 → 附件 |
| `__init__.py` | `run_bot(config, travel_info, normal_info)`;`run_bot_web(config, work_dir)` 从会话缓存加载后执行 |
## 现状与计划
## 两种用法
现有入口是**一次性跑完**的编排函数,适合手工验证,不适合 Agent 分步介入:中途出错只能整体重来,
也没法在填到一半时先看一眼。P3 计划拆成 `browser_open / login / new_form / fill(step) / page_state / close`
分步工具,过程截图进 `cases/<id>/screenshots/NNN-<step>.png`,并用 `.browser.lock`(PID + 心跳)防止多代进程
抢同一个浏览器;P4 再加提交前预览与一次性 token 护栏(提交不可逆)。
- **MCP 分步工具**(推荐):`browser_open → browser_login → browser_new_form → browser_fill(step) →
`browser_page_state → browser_close`,实现在 `src/mcp/tools/browser.py`
- `run_bot()` / `run_bot_web()`:一把梭编排,只留给手工验证与回归;新代码别用——中途失败只能整体重来
步骤函数(`fill_travel_info` / `add_travel_items` / `fill_normal_payment` 等)是两种用法共用的底座,
**出错必须回抛**(`log.error` 之后要 `raise`)。吞异常会让上层以为填好了——本项目已经因为静默失败
丢了 7 周的出库单产物(方案文档 §11.4),不要重犯。
## 线程与进程约束
- Playwright sync 的 browser/page 只能在创建它的线程里用;MCP 是多线程调用,所以一律经
`actor.run_browser()` 提交到专属单线程执行(与 COM actor 分开,套间要求不同)
- 浏览器归属写在 `cases/<id>/.browser.lock`(PID + 心跳;180 秒未更新可被别的进程接管)。
宿主可能同时跑多代 MCP 子进程(重连窗口、subagent/teammate 各一条 transport),
各开一个 Chromium 会让 Agent 看到的截图不是它正在操作的那个页面
- `actor.py` 的 atexit 会尽力关浏览器并释放锁;被强杀时锁文件会留下,靠心跳过期接管
## 计划中的 P4
`submit_preview`(快照 + 摘要 + 一次性 token)与 `submit_execute`(token 一次性、摘要变更即失效)。
在那之前,填报完成必须停下交人工核对提交。
## 注意
- 会打开 Chromium,自动化期间不要手动点页面
- 调试截图落在 `images/`(已 gitignore,不进仓库)
- 步骤函数最好返回「填了什么 / 什么失败」的结构,P3 可直接复用
- 会打开 Chromium(默认有头),自动化期间不要手动点页面
- 分步填报的截图写 `cases/<id>/screenshots/NNN-<步骤名>.png`;只有走 `run_bot()` 才写
`images/debug_*.png`(已 gitignore)
+165
View File
@@ -0,0 +1,165 @@
"""浏览器会话的常驻持有者与独占锁
两件事必须同时成立,填报才可信:
1. **线程亲和**:Playwright 同步 API 底层是 greenlet,browser/page 对象必须在创建它的那个线程里使用。
MCP 服务器是多线程的(每个工具调用可能在不同线程),所以所有浏览器操作统一提交到这个单线程 actor。
与 COM 的 actor 分开:COM 套间要求不同,混用会踩坑。
2. **进程独占**:宿主可能同时存在多代 MCP 子进程(重连窗口、subagent/teammate 各一条 transport,
实测 4 个 PID 并存)。每个进程各开一个 Chromium,Agent 看到的截图就不是它正在操作的那个页面。
"""
from __future__ import annotations
import atexit
import json
import os
import threading
import time
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import Any
from ... import get_logger
log = get_logger("browser.actor")
LOCK_FILENAME = ".browser.lock"
# 心跳超过这个秒数没更新就视为持有者已死,可被接管
STALE_AFTER_SECONDS = 180
_executor: ThreadPoolExecutor | None = None
_lock = threading.Lock()
# case_id -> {"bot": BaseBot, "steps": [str, ...], "kind": str|None}
_sessions: dict[str, dict[str, Any]] = {}
def _pool() -> ThreadPoolExecutor:
global _executor
if _executor is None:
_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="playwright")
return _executor
def run_browser[T](fn: Callable[..., T], *args: Any, **kwargs: Any) -> T:
"""在专用 Playwright 线程上串行执行 fn 并等待结果。"""
with _lock:
return _pool().submit(fn, *args, **kwargs).result()
# ------------------------------------------------------------------
# 会话注册表(只在 actor 线程里读写,不加锁也不竞争)
# ------------------------------------------------------------------
def session(case_id: str) -> dict[str, Any] | None:
"""取会话的浏览器上下文;未打开返回 None。"""
return _sessions.get(case_id)
def put_session(case_id: str, bot: Any, kind: str | None = None) -> dict[str, Any]:
ctx: dict[str, Any] = {"bot": bot, "steps": [], "kind": kind, "opened_at": time.time()}
_sessions[case_id] = ctx
return ctx
def drop_session(case_id: str) -> dict[str, Any] | None:
return _sessions.pop(case_id, None)
def list_sessions() -> list[str]:
return sorted(_sessions)
def mark_step(case_id: str, step: str) -> None:
ctx = _sessions.get(case_id)
if ctx is not None and step not in ctx["steps"]:
ctx["steps"].append(step)
# ------------------------------------------------------------------
# 磁盘锁
# ------------------------------------------------------------------
def lock_path(case_dir: Path) -> Path:
return case_dir / LOCK_FILENAME
def read_lock(case_dir: Path) -> dict[str, Any] | None:
"""读锁文件;损坏或不存在返回 None(损坏不阻断,按无锁处理)。"""
path = lock_path(case_dir)
if not path.exists():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception as exc:
log.warning("浏览器锁文件损坏,按无锁处理: %s (%s)", path, exc)
return None
return data if isinstance(data, dict) else None
def _write_lock(case_dir: Path, url: str = "") -> dict[str, Any]:
payload = {
"pid": os.getpid(),
"started_at": round(time.time(), 3),
"heartbeat": round(time.time(), 3),
"url": url,
}
lock_path(case_dir).write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
return payload
def acquire(case_dir: Path, url: str = "") -> tuple[bool, dict[str, Any]]:
"""尝试取得该会话的浏览器所有权。返回 (是否取得, 锁信息或占用者信息)。"""
held = read_lock(case_dir)
if held is not None:
other_pid = int(held.get("pid", -1))
age = time.time() - float(held.get("heartbeat", 0))
if other_pid != os.getpid() and age < STALE_AFTER_SECONDS:
return False, {**held, "age_seconds": round(age, 1), "stale": False}
if other_pid != os.getpid():
log.warning("接管过期浏览器锁(原持有 pid=%s,%.0fs 无心跳)", other_pid, age)
return True, _write_lock(case_dir, url)
def touch(case_dir: Path, url: str = "") -> None:
"""刷心跳:每次浏览器工具调用都调一下。"""
if read_lock(case_dir) is None:
return
_write_lock(case_dir, url)
def release(case_dir: Path) -> None:
held = read_lock(case_dir)
if held is not None and int(held.get("pid", -1)) == os.getpid():
try:
lock_path(case_dir).unlink()
except OSError:
pass
# ------------------------------------------------------------------
# 收尾
# ------------------------------------------------------------------
def close_all() -> None:
"""尽力关闭所有浏览器并释放锁(进程退出时调用)。"""
for case_id in list(_sessions):
ctx = _sessions.pop(case_id, None)
if not ctx:
continue
bot = ctx.get("bot")
work_dir = getattr(bot, "work_dir", None)
try:
if bot is not None:
bot.close()
except Exception as exc:
log.warning("关闭浏览器失败 %s: %s", case_id, exc)
if isinstance(work_dir, Path):
release(work_dir)
atexit.register(close_all)
+63 -29
View File
@@ -38,6 +38,9 @@ class BaseBot:
self.config = config
self.headless = headless
self.work_dir: Path | None = None
# 设了就把截图按 001-步骤名.png 编号写进这里;不设保持旧行为(src/images/debug_*.png)
self.screenshot_dir: Path | None = None
self._shot_seq = 0
self.browser: Any = None
self.context: Any = None
self.page: Any = None
@@ -74,12 +77,44 @@ class BaseBot:
# ---------------------------------------------------------------
def login_portal(self) -> None:
"""登录信息门户"""
log.info("登录信息门户...")
"""登录信息门户;一把梭版本,等价于 submit_login 后等门户出现。"""
self.submit_login()
if not self.wait_for_portal(30):
self._screenshot("portal_timeout")
raise TimeoutError("登录超时,未跳转到信息门户")
PORTAL_MARKERS = ("tyrz.fynu.edu.cn/zs-uip", "tyrz.fynu.edu.cn/oshall", "portal")
def portal_reached(self) -> bool:
"""是否已落到信息门户;可反复调用判断登录是否完成。"""
try:
url = self.page.url or ""
except Exception:
return False
return any(marker in url for marker in self.PORTAL_MARKERS)
def human_required(self) -> bool:
"""页面是否卡在需要人工的地方(扫码、验证码、仍在 SSO 登录页)。"""
try:
if self.portal_reached():
return False
for selector in (
'text="微信扫码登录"',
'img[src*="qrcode"]',
'img[src*="captcha"]',
'input[placeholder*="验证码"]',
):
if self.page.query_selector(selector):
return True
return "login" in (self.page.url or "").lower()
except Exception:
return False
def submit_login(self) -> None:
"""填工号密码、勾同意、点登录;不等跳转。"""
log.info("登录信息门户...")
self.page.goto(self.config["sso_login_url"], wait_until="domcontentloaded")
self._wait_for('text="微信扫码登录"', timeout=5000)
try:
self.page.fill(
'input[placeholder*="工号"], input[placeholder*="学号"]',
@@ -88,41 +123,27 @@ class BaseBot:
self.page.fill('input[placeholder*="密码"]', self.config["password"])
except Exception:
log.warning("未找到登录输入框,可能已登录")
try:
checkbox = self.page.query_selector('input[type="checkbox"]')
if checkbox and not checkbox.is_checked():
checkbox.click()
except Exception:
pass
for selector in ['button:has-text("登录")', 'input[value="登录"]', 'text="登录"']:
for selector in ('button:has-text("登录")', 'input[value="登录"]', 'text="登录"'):
try:
self.page.click(selector, timeout=3000)
break
except Exception:
continue
self._wait_for_portal()
def _wait_for_portal(self) -> None:
"""等待跳转到统一信息平台"""
for _ in range(30):
self.page.wait_for_timeout(1000)
url = self.page.url
if any(
kw in url
for kw in (
"tyrz.fynu.edu.cn/zs-uip",
"tyrz.fynu.edu.cn/oshall",
"portal",
)
):
def wait_for_portal(self, timeout: int = 30) -> bool:
"""等待门户跳转;返回是否成功,不抛异常——分步填报靠它判 need_human。"""
for _ in range(timeout):
if self.portal_reached():
self._screenshot("portal_loaded")
return
log.error("等待门户跳转超时")
self._screenshot("portal_timeout")
raise TimeoutError("登录超时,未跳转到信息门户")
return True
self.page.wait_for_timeout(1000)
return self.portal_reached()
# ---------------------------------------------------------------
# 导航
@@ -197,7 +218,20 @@ class BaseBot:
def _wait_for(self, selector: str, timeout: int | None = None) -> None:
self.page.wait_for_selector(selector, timeout=timeout)
def _screenshot(self, name: str) -> None:
img_dir = Path(__file__).parent.parent.parent / "images"
img_dir.mkdir(exist_ok=True)
self.page.screenshot(path=str(img_dir / f"debug_{name}.png"))
def _screenshot(self, name: str) -> str:
"""截图并返回落盘路径;截图失败只告警,别盖掉真正的填报错误。"""
self._shot_seq += 1
if self.screenshot_dir is not None:
img_dir = Path(self.screenshot_dir)
img_dir.mkdir(parents=True, exist_ok=True)
target = img_dir / f"{self._shot_seq:03d}-{name}.png"
else:
img_dir = Path(__file__).parent.parent.parent / "images"
img_dir.mkdir(exist_ok=True)
target = img_dir / f"debug_{name}.png"
try:
self.page.screenshot(path=str(target))
except Exception as exc:
log.warning("截图失败 %s: %s", target.name, exc)
return ""
return str(target)
+1 -1
View File
@@ -4,7 +4,7 @@ last_reviewed: 2026-10-19
# src/mcp — 工具与协议层
MCP 服务器的全部对外面。17 个工具的参数表在 `docs/MCP.md`(那份表由运行时 schema 生成,与代码同源)。
MCP 服务器的全部对外面。24 个工具的参数表在 `docs/MCP.md`(那份表由运行时 schema 生成,与代码同源)。
| 文件 | 职责 |
|------|------|
+19 -4
View File
@@ -13,11 +13,11 @@ from typing import Any
from mcp.server import MCPServer
from .tools import analysis, documents, extraction, materials
from .tools import analysis, browser, documents, extraction, materials
SERVER_NAME = "auto-reimbursement"
INSTRUCTIONS = """财务报销自动化工具集(发票抽取交接 → 匹配 → 校验 → 文档 → 浏览器填报 → 提交)。
INSTRUCTIONS = """财务报销自动化工具集(发票抽取交接 → 匹配 → 校验 → 文档 → 浏览器填报;提交由人工完成)。
当前已实现(可直接调用):
1. mcp__arb__case_create 建会话;mcp__arb__case_add_files 拷入发票/支付截图/申请单
@@ -38,9 +38,17 @@ INSTRUCTIONS = """财务报销自动化工具集(发票抽取交接 → 匹配
人工核对后要改单张票据的某个字段,用 mcp__arb__case_patch_extraction(只传要改的键,会回显 from/to)。
7. 每步之后用 mcp__arb__case_status 复查态势;阶段由产物推导,没有内部状态机。
mcp__arb__case_reset(scope) 可清派生产物重跑;mcp__arb__case_list 看全部会话。
8. 浏览器分步填报:mcp__arb__browser_open(默认有头 Chromium;同一会话重复调用会复用现有浏览器)→
mcp__arb__browser_login(页面停在扫码或验证码时返回 need_human,请用户在窗口里完成后重调本工具)→
mcp__arb__browser_new_form(kind 省略时按会话里的 info 推断;travel_info 与 normal_info 同时存在必须显式传)→
按它返回的 steps 顺序逐个 mcp__arb__browser_fill(step=...)(travel: basic/details/payment/subsidy/attachments,
normal 没有 subsidy)→ mcp__arb__browser_page_state 复查 → mcp__arb__browser_close(不提交也不存草稿)。
浏览器归属写在 cases/<id>/.browser.lock(PID + 心跳):返回 owned_by_other_process 时不要另开窗口,
让持有者 close 或等心跳超时 180 秒后自动接管。截图按 001-步骤名.png 落在 cases/<id>/screenshots/。
尚未实现(**现在不要调用,也不要在回答里承诺**):Word 出库单与 Excel 申购单、浏览器填报、
提交预览与执行。需要这些步骤时告诉用户该环节还在改造中,可暂用旧的 Web 流程,不要自己拼 HTML 或猜接口。
尚未实现(**现在不要调用,也不要在回答里承诺**):提交预览与提交执行(submit_preview / submit_execute)。
填报完成后必须停下:把 browser_page_state 的截图与已填内容报给用户,由用户在财务系统里核对并亲自点提交。
Word 出库单功能已下线(模板自 2026-08 起不在仓库),不要再提;Excel 申购单见第 6 条。
硬约束:不得回显密码;不得臆造财务数据,拿不准的字段标出来问用户;产物只写 cases/<case_id>/。
"""
@@ -63,6 +71,13 @@ TOOLS: list[Callable[..., dict[str, Any]]] = [
analysis.rules_get,
analysis.info_skeleton,
documents.doc_requisition,
browser.browser_open,
browser.browser_login,
browser.browser_new_form,
browser.browser_fill,
browser.browser_page_state,
browser.browser_steps,
browser.browser_close,
]
+437
View File
@@ -0,0 +1,437 @@
"""分步浏览器填报工具
取代旧的「一把梭」run_bot:Agent 可以开浏览器、登录、建单、按步填、看页面、关,
每步都有截图与明确状态,中途失败不必从头再来。提交不在这里——提交不可逆,属于 P4 的
submit_preview / submit_execute(带一次性 token)。
所有 Playwright 调用都在 src/infra/browser/actor.py 的单线程上执行;浏览器归属用
cases/<id>/.browser.lock 记录(PID + 心跳),防止多代 MCP 子进程各开一个 Chromium。
"""
from __future__ import annotations
import time
from pathlib import Path
from typing import Any
from ... import get_logger
from ...cases import CACHE_DIR_NAME, CaseStore
from ...config import load_config
from ...infra.browser import actor
from ..envelope import fail, ok
log = get_logger("mcp.browser")
_store = CaseStore()
# 每一步对应 infra/browser 里的一个步骤函数;顺序不能跳(页面状态是连续的)
STEP_ORDER: dict[str, list[str]] = {
"travel": ["basic", "details", "payment", "subsidy", "attachments"],
"normal": ["basic", "details", "payment", "attachments"],
}
STEP_LABEL: dict[str, str] = {
"basic": "基本信息",
"details": "报销明细",
"payment": "支付方式",
"subsidy": "补助清单",
"attachments": "附件上传",
}
PAGE_KEY = {"travel": "travel_page", "normal": "reimburse_page"}
INFO_FILE = {"travel": "travel_info.json", "normal": "normal_info.json"}
def _case_dir(case_id: str) -> Path | None:
return _store.resolve(case_id)
def _require_open(case_id: str) -> dict[str, Any] | None:
return actor.session(case_id)
def _load_info(d: Path, kind: str) -> dict[str, Any] | None:
import json
path = d / CACHE_DIR_NAME / INFO_FILE[kind]
if not path.exists():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception as exc:
log.warning("读取 %s 失败: %s", path.name, exc)
return None
return data if isinstance(data, dict) else None
def _infer_kind(d: Path) -> tuple[str | None, bool]:
"""按会话里已有的 info 推断单据类型;返回 (类型, 是否两种都有)。"""
has_travel = (d / CACHE_DIR_NAME / INFO_FILE["travel"]).exists()
has_normal = (d / CACHE_DIR_NAME / INFO_FILE["normal"]).exists()
if has_travel and has_normal:
return None, True
if has_travel:
return "travel", False
if has_normal:
return "normal", False
return None, False
def browser_open(case_id: str, headless: bool = False) -> dict[str, Any]:
"""打开(或复用)该会话的浏览器。
默认有头:提交不可逆,要看得见在点什么。同一会话重复调用直接复用现有浏览器。
被别的进程占用时返回 owned_by_other_process,别绕过去另开一个浏览器。
"""
d = _case_dir(case_id)
if d is None:
return fail(f"会话不存在: {case_id}")
got, info = actor.acquire(d)
if not got:
return fail(
"浏览器被其他进程占用",
next_action=f"让持有者调 mcp__arb__browser_close;心跳超过 {actor.STALE_AFTER_SECONDS} 秒未更新即可自动接管",
owned_by={"pid": info.get("pid"), "age_seconds": info.get("age_seconds")},
)
existing = _require_open(case_id)
if existing is not None:
actor.touch(d)
return ok(case_id=case_id, already_open=True, headless=bool(getattr(existing["bot"], "headless", False)))
def _open() -> None:
from ...infra.browser.base import BaseBot
bot = BaseBot(load_config(), headless=headless)
bot.work_dir = d
bot.screenshot_dir = d / "screenshots"
bot.launch()
actor.put_session(case_id, bot)
try:
actor.run_browser(_open)
except Exception as exc:
actor.release(d)
return fail(f"启动浏览器失败: {exc}", next_action="先执行 uv run playwright install chromium")
return ok(
case_id=case_id,
already_open=False,
headless=headless,
screenshots_dir=str(d / "screenshots"),
next_action="mcp__arb__browser_login",
)
def browser_login(case_id: str, wait_seconds: int = 25) -> dict[str, Any]:
"""登录信息门户。遇到扫码或验证码不要死等:返回 need_human,人工完成后再次调用。"""
d = _case_dir(case_id)
ctx = _require_open(case_id)
if d is None or ctx is None:
return fail("浏览器还没打开", next_action="mcp__arb__browser_open")
def _login() -> dict[str, Any]:
bot = ctx["bot"]
if bot.portal_reached():
return {"logged_in": True, "already": True}
bot.submit_login()
if bot.wait_for_portal(wait_seconds):
return {"logged_in": True, "already": False}
if bot.human_required():
return {"logged_in": False, "need_human": True, "screenshot": bot._screenshot("need_human")}
return {"logged_in": False, "timeout": True, "screenshot": bot._screenshot("login_timeout")}
try:
result = actor.run_browser(_login)
except Exception as exc:
return fail(f"登录出错: {exc}", next_action="mcp__arb__browser_page_state 看当前页面再决定")
actor.touch(d)
if result.get("need_human"):
return ok(
logged_in=False,
need_human=True,
reason="页面停在扫码或验证码,自动流程不能替你完成",
next_action="请在打开的浏览器窗口里完成扫码/验证码,然后再次调用 mcp__arb__browser_login",
screenshot=result.get("screenshot", ""),
)
if not result.get("logged_in"):
return fail(
"登录未完成",
next_action="mcp__arb__browser_page_state 确认页面;必要时 browser_close 后重开",
screenshot=result.get("screenshot", ""),
)
return ok(logged_in=True, already=result.get("already", False), next_action="mcp__arb__browser_new_form")
def browser_new_form(case_id: str, kind: str | None = None) -> dict[str, Any]:
"""进入报销系统并新建单据。kind 省略时按会话里已有的 info 推断。"""
d = _case_dir(case_id)
ctx = _require_open(case_id)
if d is None or ctx is None:
return fail("浏览器还没打开", next_action="mcp__arb__browser_open")
if kind is None:
kind, both = _infer_kind(d)
if both:
return fail(
"会话里同时有 travel_info 与 normal_info,无法判断单据类型",
next_action="显式传 kind='travel' 或 'normal'(混合发票只能走普通报销)",
)
if kind is None:
return fail(
"会话里还没有 travel_info.json / normal_info.json",
next_action="先 mcp__arb__save_info 落盘报销信息",
)
if kind not in STEP_ORDER:
return fail(f"kind 只能是 travel 或 normal,收到 {kind}")
if ctx.get("kind") and ctx["kind"] != kind:
return fail(
f"当前浏览器已经建了 {ctx['kind']} 单据",
next_action="要换类型先 mcp__arb__browser_close 再 browser_open",
)
def _new() -> dict[str, Any]:
bot = ctx["bot"]
if not bot.portal_reached():
raise RuntimeError("尚未登录信息门户")
bot.navigate_to_reimburse(page_key=PAGE_KEY[kind])
bot.create_new_form()
return {"url": bot.page.url, "screenshot": bot._screenshot(f"new_form_{kind}")}
try:
result = actor.run_browser(_new)
except RuntimeError as exc:
return fail(str(exc), next_action="mcp__arb__browser_login")
except Exception as exc:
return fail(f"新建单据失败: {exc}", next_action="mcp__arb__browser_page_state 看页面卡在哪一步")
ctx["kind"] = kind
ctx["steps"] = []
actor.touch(d, result.get("url", ""))
return ok(
kind=kind,
url=result.get("url", ""),
screenshot=result.get("screenshot", ""),
steps=STEP_ORDER[kind],
next_action="mcp__arb__browser_fill(step='basic')",
)
def browser_fill(case_id: str, step: str) -> dict[str, Any]:
"""执行一步填报。step 按 browser_new_form 返回的 steps 顺序,不能跳步。"""
d = _case_dir(case_id)
ctx = _require_open(case_id)
if d is None or ctx is None:
return fail("浏览器还没打开", next_action="mcp__arb__browser_open")
kind = ctx.get("kind")
if not kind:
return fail("还没建单据", next_action="mcp__arb__browser_new_form")
if step not in STEP_ORDER[kind]:
return fail(f"{kind} 流程没有这一步: {step}", steps=STEP_ORDER[kind])
done = list(ctx.get("steps", []))
expected = STEP_ORDER[kind]
missing = [s for s in expected[: expected.index(step)] if s not in done]
if missing:
return fail(
f"缺少前置步骤: {'、'.join(missing)}",
next_action=f"mcp__arb__browser_fill(step='{missing[0]}')",
done=done,
)
info = _load_info(d, kind)
if info is None:
return fail(f"读不到 {INFO_FILE[kind]}", next_action="先 mcp__arb__save_info 落盘报销信息")
def _fill() -> dict[str, Any]:
bot = ctx["bot"]
filled = _apply_step(bot, kind, step, info)
return {"filled": filled, "screenshot": bot._screenshot(f"{step}_done"), "url": bot.page.url}
try:
result = actor.run_browser(_fill)
except Exception as exc:
def _snap() -> str:
bot = ctx.get("bot")
return str(bot._screenshot(f"{step}_error")) if bot is not None else ""
shot = ""
try:
shot = str(actor.run_browser(_snap))
except Exception:
shot = ""
return fail(
f"{STEP_LABEL[step]}填写失败: {exc}",
next_action="mcp__arb__browser_page_state 看当前页;修正数据后用同一 step 重填",
step=step,
screenshot=shot,
)
actor.mark_step(case_id, step)
actor.touch(d, str(result.get("url", "")))
done_after = list(ctx.get("steps", []))
remaining = [s for s in expected if s not in done_after]
return ok(
step=step,
label=STEP_LABEL[step],
filled=result.get("filled"),
screenshot=result.get("screenshot", ""),
done=done_after,
remaining=remaining,
next_action=(f"mcp__arb__browser_fill(step='{remaining[0]}')" if remaining else "mcp__arb__browser_page_state"),
)
def _apply_step(bot: Any, kind: str, step: str, info: dict[str, Any]) -> dict[str, Any]:
"""把 step 映射到 infra/browser 的步骤函数,返回「填了多少」的描述。"""
from ...infra.browser import normal as normal_flow
from ...infra.browser import travel as travel_flow
if kind == "travel":
if step == "basic":
basic = info.get("basic_info", {})
travel_flow.fill_travel_info(bot, basic)
return {"字段数": len(basic)}
if step == "details":
details = info.get("reimbursement_details", {})
travel_flow.add_travel_items(bot, details)
counts = {k: len(v) for k, v in details.items() if isinstance(v, list)}
return counts if counts else {"明细": "见 travel_info"}
if step == "payment":
payments = info.get("payment_methods", [])
travel_flow.fill_travel_payment(bot, payments)
return {"支付笔数": len(payments)}
if step == "subsidy":
subsidy = info.get("subsidy_list", [])
travel_flow.fill_travel_subsidy(bot, subsidy)
return {"补助条目": len(subsidy)}
attachments = info.get("attachments", [])
travel_flow.upload_travel_attachments(bot, attachments)
return {"附件数": len(attachments)}
if step == "basic":
desc = str(info.get("basic_info", {}).get("reimbursement_description", "元器件采购报销"))
normal_flow.fill_basic_info(bot, desc)
return {"报销说明": desc}
if step == "details":
details = info.get("reimbursement_details", {})
total_invoices = int(details.get("total_invoices", 0) or 0)
total_amount = float(details.get("total_amount", 0) or 0)
normal_flow.add_normal_item(bot, total_invoices, total_amount)
return {"单据数": total_invoices, "总金额": total_amount}
if step == "payment":
payments = info.get("payment_methods", [])
normal_flow.fill_normal_payment(bot, payments)
return {"支付笔数": len(payments)}
attachments = info.get("attachments", [])
normal_flow.upload_normal_attachments(bot, attachments)
return {"附件数": len(attachments)}
def browser_page_state(case_id: str, screenshot: bool = True) -> dict[str, Any]:
"""看当前页面:URL、标题、命中的表单标记、已完成步骤、最近截图。"""
d = _case_dir(case_id)
ctx = _require_open(case_id)
if d is None or ctx is None:
return fail("浏览器还没打开", next_action="mcp__arb__browser_open")
def _state() -> dict[str, Any]:
bot = ctx["bot"]
markers = {
"基本信息页": "#EXPENEXPLAIN",
"明细页": "#insertDetail",
"支付页": "#insertPay",
"附件页": "#insertAcc",
"提交按钮": "#submit",
}
hit: dict[str, bool] = {}
for label, selector in markers.items():
try:
hit[label] = bot.page.query_selector(selector) is not None
except Exception:
hit[label] = False
shot = ""
if screenshot:
try:
shot = str(bot._screenshot("state"))
except Exception:
shot = ""
try:
title = str(bot.page.title())
except Exception:
title = ""
url_value = ""
try:
url_value = str(bot.page.url)
except Exception:
url_value = ""
return {
"url": url_value,
"title": title,
"logged_in": bool(bot.portal_reached()),
"human_required": bool(bot.human_required()),
"markers": hit,
"screenshot": shot,
}
try:
state = actor.run_browser(_state)
except Exception as exc:
return fail(f"读取页面状态失败: {exc}", next_action="mcp__arb__browser_close 后重开")
actor.touch(d, str(state.get("url", "")))
shots_dir = d / "screenshots"
shots = sorted(shots_dir.glob("*.png")) if shots_dir.exists() else []
return ok(
kind=ctx.get("kind"),
steps_done=list(ctx.get("steps", [])),
recent_screenshots=[str(s) for s in shots[-5:]],
**state,
)
def browser_close(case_id: str) -> dict[str, Any]:
"""关闭浏览器并释放锁。不会提交也不会存草稿,填了一半的内容会丢。"""
d = _case_dir(case_id)
if d is None:
return fail(f"会话不存在: {case_id}")
ctx = _require_open(case_id)
if ctx is None:
actor.release(d)
return ok(closed=False, note="该会话本来就没有浏览器")
elapsed = round(time.time() - float(ctx.get("opened_at", time.time())), 1)
steps = list(ctx.get("steps", []))
kind = ctx.get("kind")
def _close() -> None:
bot = ctx.get("bot")
if bot is not None:
bot.close()
try:
actor.run_browser(_close)
except Exception as exc:
log.warning("关闭浏览器时出错 %s: %s", case_id, exc)
actor.drop_session(case_id)
actor.release(d)
return ok(
closed=True,
kind=kind,
steps_done=steps,
open_seconds=elapsed,
next_action="提交还没实现(P4);要回看填了什么用 browser_page_state",
)
def browser_steps(case_id: str) -> dict[str, Any]:
"""列出该会话单据类型的步骤顺序与含义(不要求浏览器已打开)。"""
d = _case_dir(case_id)
if d is None:
return fail(f"会话不存在: {case_id}")
kind, both = _infer_kind(d)
ctx = _require_open(case_id)
if ctx is not None and ctx.get("kind"):
kind = ctx["kind"]
if both and not (ctx is not None and ctx.get("kind")):
return ok(kinds=list(STEP_ORDER), note="会话里 travel_info 与 normal_info 同时存在,建单时要显式传 kind")
if kind is None:
return ok(
kinds=list(STEP_ORDER),
note="还没落 info,无法确定类型;两种流程的步骤如下",
steps={k: [{"step": s, "label": STEP_LABEL[s]} for s in v] for k, v in STEP_ORDER.items()},
)
return ok(
kind=kind,
steps=[{"step": s, "label": STEP_LABEL[s]} for s in STEP_ORDER[kind]],
done=list((ctx or {}).get("steps", [])),
)
+327
View File
@@ -0,0 +1,327 @@
"""MCP 分步浏览器工具的守卫逻辑测试(不启动真实浏览器)
真实 Chromium 交互没法在测试里跑,这里覆盖的是本次新写的那部分:need_human 分支、
单据类型推断、跳步拦截、步骤推进与报错截图、锁的占用与接管、会话与锁的释放。
"""
from __future__ import annotations
import json
import os
import time
from pathlib import Path
from typing import Any
import pytest
from src.infra.browser import actor
from src.mcp.tools import browser
class FakePage:
"""记录调用;query_selector 只在预置的选择器上返回真值。"""
def __init__(self, existing: set[str] | None = None) -> None:
self._existing = existing or set()
self.calls: list[Any] = []
self.url = "http://sso.test/login"
def query_selector(self, selector: str) -> object | None:
return object() if selector in self._existing else None
def title(self) -> str:
return "统一认证"
def screenshot(self, target: str) -> None:
Path(target).write_bytes(b"PNG")
def wait_for_timeout(self, _ms: int) -> None:
return None
def __getattr__(self, name: str) -> Any:
def _record(*args: Any, **kwargs: Any) -> None:
self.calls.append((name, args))
return _record
class FakeBot:
"""只实现工具真正依赖的接口,行为由构造参数决定。"""
def __init__(self, case_dir: Path, *, portal: bool = False, human: bool = True) -> None:
self.work_dir = case_dir
self.screenshot_dir = case_dir / "screenshots"
self._shot_seq = 0
self.headless = False
self.config = {
"default_name": "示例报销人",
"default_card_no": "6282880000000000",
"default_person_id": "200000001",
"sso_login_url": "http://sso.test/login",
}
self.page = FakePage({'text="微信扫码登录"'} if human else set())
self._portal = portal
self._human = human
self.closed = False
self.navigated: list[str] = []
self.created = 0
def _mark_logged_in(self) -> None:
self.page.url = "https://tyrz.fynu.edu.cn/oshall"
self.page._existing = {"#EXPENEXPLAIN", "#insertDetail", "#insertPay", "#insertAcc", "#submit"}
def portal_reached(self) -> bool:
if self._portal:
self._mark_logged_in()
return self._portal
def human_required(self) -> bool:
return self._human and not self._portal
def submit_login(self) -> None:
self._portal = self._portal or not self._human
def wait_for_portal(self, timeout: int = 30) -> bool:
if self._portal:
self._mark_logged_in()
return self._portal
def _wait_for(self, selector: str, timeout: int | None = None) -> None:
self.page.wait_for_selector(selector, timeout=timeout)
def navigate_to_reimburse(self, page_key: str = "reimburse_page") -> None:
self.navigated.append(page_key)
def create_new_form(self) -> None:
self.created += 1
def _screenshot(self, name: str) -> str:
self._shot_seq += 1
target_dir = self.screenshot_dir
assert target_dir is not None
target_dir.mkdir(parents=True, exist_ok=True)
target = target_dir / (str(self._shot_seq).zfill(3) + "-" + name + ".png")
self.page.screenshot(str(target))
return str(target)
def close(self) -> None:
self.closed = True
class FakeStore:
"""让工具以为 case_id 指向 tmp 目录。"""
def __init__(self, root_dir: Path) -> None:
self.root = root_dir
def resolve(self, case_id: str) -> Path | None:
target = self.root / case_id
return target if target.exists() else None
@pytest.fixture()
def env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Any:
case = tmp_path / "20261019-测试会话"
(case / ".invoice_cache").mkdir(parents=True)
monkeypatch.setattr(browser, "_store", FakeStore(tmp_path)) # type: ignore[arg-type]
yield {"case": case, "id": case.name}
actor.close_all()
for leftover in tmp_path.glob("*/.browser.lock"):
leftover.unlink(missing_ok=True)
def _open(env: dict[str, Any], **kwargs: Any) -> FakeBot:
bot = FakeBot(env["case"], **kwargs)
actor.put_session(env["id"], bot)
return bot
def _write_info(env: dict[str, Any], kind: str, payload: dict[str, Any]) -> None:
name = browser.INFO_FILE[kind]
path = env["case"] / ".invoice_cache" / name
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
def test_lock_acquires_when_free(tmp_path: Path) -> None:
got, info = actor.acquire(tmp_path)
assert got is True
assert info["pid"] == os.getpid()
assert actor.lock_path(tmp_path).exists()
actor.release(tmp_path)
assert not actor.lock_path(tmp_path).exists()
def test_lock_blocks_other_live_process(tmp_path: Path) -> None:
now = time.time()
actor.lock_path(tmp_path).write_text(
json.dumps({"pid": 999999, "heartbeat": now, "started_at": now}), encoding="utf-8"
)
got, info = actor.acquire(tmp_path)
assert got is False
assert info.get("pid") == 999999
def test_lock_takes_over_when_stale(tmp_path: Path) -> None:
dead = time.time() - actor.STALE_AFTER_SECONDS - 5
actor.lock_path(tmp_path).write_text(
json.dumps({"pid": 999999, "heartbeat": dead, "started_at": dead}), encoding="utf-8"
)
got, info = actor.acquire(tmp_path)
assert got is True
assert info["pid"] == os.getpid()
def test_broken_lock_file_is_ignored(tmp_path: Path) -> None:
actor.lock_path(tmp_path).write_text("{坏文件", encoding="utf-8")
got, _info = actor.acquire(tmp_path)
assert got is True
def test_login_requires_human(env: dict[str, Any]) -> None:
_open(env)
result = browser.browser_login(env["id"])
assert result["ok"] is True
assert result["need_human"] is True
assert "browser_login" in result["next_action"]
shots = list((env["case"] / "screenshots").glob("*need_human*.png"))
assert shots, "need_human 的截图要落在会话目录里"
def test_login_idempotent_without_captcha(env: dict[str, Any]) -> None:
bot = _open(env, human=False)
first = browser.browser_login(env["id"])
assert first["ok"] and first["logged_in"] is True and first["already"] is False
again = browser.browser_login(env["id"])
assert again["logged_in"] is True and again["already"] is True
assert bot.created == 0
def test_login_and_state_refuse_before_open(env: dict[str, Any]) -> None:
assert browser.browser_login(env["id"])["ok"] is False
state = browser.browser_page_state(env["id"])
assert state["ok"] is False
assert "browser_open" in state["next_action"]
filled = browser.browser_fill(env["id"], "basic")
assert filled["ok"] is False
closed = browser.browser_close(env["id"])
assert closed["ok"] and closed["closed"] is False
def test_new_form_infers_kind(env: dict[str, Any]) -> None:
_write_info(env, "normal", {"basic_info": {"reimbursement_description": "元件采购"}})
bot = _open(env, portal=True)
result = browser.browser_new_form(env["id"])
assert result["ok"] is True
assert result["kind"] == "normal"
assert result["steps"] == ["basic", "details", "payment", "attachments"]
assert bot.navigated == ["reimburse_page"]
assert bot.created == 1
def test_new_form_ambiguous_when_both_infos(env: dict[str, Any]) -> None:
_write_info(env, "normal", {})
_write_info(env, "travel", {})
_open(env, portal=True)
result = browser.browser_new_form(env["id"])
assert result["ok"] is False
assert "kind" in result["next_action"]
def test_new_form_requires_login(env: dict[str, Any]) -> None:
_write_info(env, "travel", {})
_open(env, portal=False, human=False)
result = browser.browser_new_form(env["id"])
assert result["ok"] is False
assert "browser_login" in result["next_action"]
def test_fill_blocks_missing_step(env: dict[str, Any]) -> None:
_write_info(env, "normal", {"payment_methods": [{}, {}]})
_open(env, portal=True)
browser.browser_new_form(env["id"])
result = browser.browser_fill(env["id"], "payment")
assert result["ok"] is False
assert "basic" in result["next_action"]
assert "details" in result["reason"]
def test_fill_walks_steps_and_reports_remaining(env: dict[str, Any]) -> None:
_write_info(
env,
"normal",
{
"basic_info": {"reimbursement_description": "元件采购"},
"reimbursement_details": {"total_invoices": 3, "total_amount": 464.0},
"payment_methods": [{}, {}],
"attachments": [],
},
)
_open(env, portal=True)
browser.browser_new_form(env["id"])
first = browser.browser_fill(env["id"], "basic")
assert first["ok"] is True
assert first["done"] == ["basic"]
assert first["remaining"] == ["details", "payment", "attachments"]
second = browser.browser_fill(env["id"], "details")
assert second["filled"] == {"单据数": 3, "总金额": 464.0}
third = browser.browser_fill(env["id"], "payment")
assert third["filled"] == {"支付笔数": 2}
assert third["remaining"] == ["attachments"]
state = browser.browser_page_state(env["id"])
assert state["ok"] is True
assert state["steps_done"] == ["basic", "details", "payment"]
assert state["markers"]["支付页"] is True
assert state["recent_screenshots"]
def test_fill_rejects_unknown_step(env: dict[str, Any]) -> None:
_write_info(env, "normal", {})
_open(env, portal=True)
browser.browser_new_form(env["id"])
result = browser.browser_fill(env["id"], "subsidy")
assert result["ok"] is False
assert result["detail"]["steps"] == ["basic", "details", "payment", "attachments"]
def test_fill_surfaces_step_error_with_screenshot(env: dict[str, Any], monkeypatch: pytest.MonkeyPatch) -> None:
_write_info(env, "normal", {"basic_info": {"reimbursement_description": "元件采购"}})
_open(env, portal=True)
browser.browser_new_form(env["id"])
def boom(*_args: Any, **_kwargs: Any) -> None:
raise RuntimeError("元素找不到")
monkeypatch.setattr("src.infra.browser.normal.fill_basic_info", boom)
result = browser.browser_fill(env["id"], "basic")
assert result["ok"] is False
assert "元素找不到" in result["reason"]
shot = result["detail"]["screenshot"]
assert Path(shot).exists()
assert Path(shot).name.endswith("basic_error.png")
def test_close_releases_session_and_lock(env: dict[str, Any]) -> None:
bot = _open(env, portal=True)
actor.lock_path(env["case"]).write_text(
json.dumps({"pid": os.getpid(), "heartbeat": time.time()}), encoding="utf-8"
)
result = browser.browser_close(env["id"])
assert result["ok"] and result["closed"] is True
assert bot.closed is True
assert actor.session(env["id"]) is None
assert not actor.lock_path(env["case"]).exists()
def test_open_reuses_existing_browser(env: dict[str, Any]) -> None:
_open(env, portal=True)
result = browser.browser_open(env["id"])
assert result["ok"] and result["already_open"] is True
def test_steps_lists_both_flows_then_one(env: dict[str, Any]) -> None:
result = browser.browser_steps(env["id"])
assert result["ok"] and set(result["kinds"]) == {"travel", "normal"}
_write_info(env, "travel", {})
picked = browser.browser_steps(env["id"])
assert picked["kind"] == "travel"
assert [s["step"] for s in picked["steps"]] == ["basic", "details", "payment", "subsidy", "attachments"]