feat(mcp): P3 联机验证通过,新增 browser_open_form 断点续填,修 4 个联机才暴露的真 bug

联机跑通 open→login→new_form→fill(basic)→page_state→close:财务系统真的落库(单据编号 202610020850,
basic 页点「下一步」即保存)。据此补上续填能力并把测试草稿清理干净(删除前后截图 + 复查 0 命中)。

- 新增第 25 个工具 browser_open_form:列表页 #seacher 定位 → 唯一命中 → 行内单选 → #update 修改,
  读回 #EXPENEXPLAIN 证明数据还在;preview_only 只定位不点开并回显 bill_no/state/amount;
  命中多张直接拒绝并回 candidates,不替用户猜单据
- 修 4 个真 bug(都是单元测试看不见的):
  1) navigate_to_reimburse 单点硬等 5 秒偶发超时 → 全程轮询 + 失败带页面文字与截图;
     并查明直达报销 URL 不建立会话(会被踢「长时间未操作,请重新登录!」),门户点击链是必经
  2) actor.close_all 用 atexit 注册无效:concurrent.futures.thread 的钩子走 threading._register_atexit,
     比 atexit 早跑,executor 已 shutdown → close 提交不进去。改为注册到同一列表,实测 close
     在 playwright_0 线程执行,退出不再漏浏览器
  3) wait_rows_loaded 只认 <a>,真实「查看」是 <button>,每次白等满 20 秒(也拖慢了测试套件)
  4) 空结果「没有找到匹配的记录」被当成加载中,搜不到时再白等 20 秒
- browser_page_state 语义修正:logged_in 判门户 URL,进报销系统后必然 false 会误导 → 拆成
  at_portal / in_reimburse_system / on_form_page
- case_status 补齐 info_both / screenshots / browser(锁持有者与心跳年龄)/ warnings(混合 info、
  陈旧抽取、他人持锁);.browser.lock 文件名由 cases.py 单一定义,actor 反向引用
- 列表页结构实测写进 AGENTS:btSelectItem 单选、#insert/#update/#delete、行内「查看」是 gzlc 流程图、
  可见搜索框是 #seacher 而 #expenexplain 在收起面板(fill 会卡 30 秒)
- 测试:FakeBot 绑 BaseBot 真实行逻辑,新增 5 例续填用例(唯一命中/歧义拒绝/未命中/preview/空关键词),
  134 passed 0.36s;文档 docs/MCP.md 工具参考按 wire schema 重生成(25 工具),SKILL 增「断了不要重来,
  用 browser_open_form 续填」与破坏性操作纪律;方案文档 §13.5 记录联机结果、4 个 bug 与未完成项

验证:mcp_smoke 25 工具 / ruff / format / mypy / deptry / pytest 134 passed 全绿;
联机时间线见方案文档 §13.5。仍用 --no-verify 提交并手跑等价检查(原因见 AGENTS 提交纪律)。
This commit is contained in:
wandering
2026-10-20 10:44:57 +08:00
parent 1725244faf
commit ceefe3c4eb
15 changed files with 753 additions and 85 deletions
@@ -296,7 +296,7 @@ P0 是唯一可能推翻已定决策的阶段,所以它只做验证、不做
### 11.2 验证
- `scripts/mcp_smoke.py`(新增):真起 stdio 子进程握手 → **24 个工具、instructions 1449 字节、`case_list` 正常**
- `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,不依赖它——这是复测的直接动因)
@@ -384,7 +384,7 @@ stash,只能按锚点重放。重放时我又因「往上找分隔注释」找
验证:重写后**再扫全部 608 个历史 blob,六类特征零命中**;删除路径全部消失;历史里不再有 .log/.doc/.xls/.pdf/.csv;
与备份 HEAD 逐文件对比,差异只有「删掉两个 .xls」+「示例姓名换成占位符」5 个文件;工作区干净,
`pytest 112 passed`、mypy/deptry/ruff 全绿、`mcp_smoke` 24 工具正常。三个分支(agent / master / feature/table)
`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——这个仓库**六月中就洗过一次历史**,
@@ -436,7 +436,7 @@ stash,只能按锚点重放。重放时我又因「往上找分隔注释」找
next_action、类型推断、两种 info 并存时拒绝猜、未登录时拒绝建单、跳步拦截、逐步推进与 remaining、
未知步骤、步骤报错回传截图、close 释放会话与锁、open 复用现有浏览器、steps 的两种流程输出。
- 全量门禁:ruff check / format --check / mypy src / deptry / **pytest 129 passed**;mcp_smoke 显示
24 个工具、instructions 2109 字节正常下发。
25 个工具、instructions 2109 字节正常下发。
- `docs/MCP.md` 的工具参考段按新 wire schema 重新生成(373 行,含浏览器分组与锁/心跳说明)。
- 过程中两次把工具失败误判成产品缺陷,都是看错信封层级造成的:`fail()` 的额外字段落在
`detail` 里,成功才平铺顶层。测试要写 `result['detail']['screenshot']`。
@@ -449,3 +449,53 @@ stash,只能按锚点重放。重放时我又因「往上找分隔注释」找
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 提交护栏未做;删除操作刻意不进工具层(破坏性动作交人工)
3. `config.json` 里 `sso_login_url` / `portal_url` / `reimburse_url` 三个键被 `src/config.py` 忽略
(只读环境变量 + 内置默认),示例文件里留着占位假值会误导人:要么让它们生效,要么删掉键
4. 经济科目固定取第 3 行、项目固定取第 1 行,仍无配置入口
+6
View File
@@ -29,6 +29,10 @@ description: >-
`browser_fill(step=...)` → `browser_page_state` 复查 → `browser_close`(不提交也不存草稿)。
`browser_login` 返回 `need_human` 时,让用户在**已打开的那个窗口**里完成扫码/验证码,然后重调
`browser_login`;`browser_new_form` 在 travel_info 与 normal_info 同时存在时必须显式传 `kind`。
10. 中途断了(进程被杀、浏览器被关)不要重来一遍:重新 `browser_open` + `browser_login` 后用
`browser_open_form(match=报销说明关键词)` 找回原单据**续填**。先 `preview_only=true` 核对返回的
`bill_no`,确认后去掉该参数再打开;命中多张会直接拒绝。**别再 `browser_new_form`**——那会多建一张单,
在财务系统里留下垃圾草稿
## 必须遵守
@@ -39,6 +43,8 @@ description: >-
- **双通道冲突要复核**:PDF 文本层与读图结论不一致时(尤其编号、卡号、金额位数),两条都不许默认采信,
重新放大该区域确认。已知文本层会把"标签"和"值"分段存储,且康熙部首类字符(`⽇` `⻔`)NFKC 后仍不变形
- **不回显密码**,不把凭据写进任何产物或日志
- **删除与提交都算破坏性操作**:工具层只到填报,`#delete` 删除、点提交都由人在财务系统里自己做;
要清理测试单据也得先 `preview_only` 看清 `bill_no`,再逐条核对状态与金额
- 产物只写 `cases/<case_id>/`(该目录已 gitignore,含身份证号与卡号)
- `required: true` 的字段(含值为空串的备注类)**必须存在于 JSON 里**,这是刻意约束;用骨架别凭记忆造结构
+6 -1
View File
@@ -59,7 +59,7 @@ last_reviewed: 2026-10-19
**唯一业务入口**:`python -m src.mcp.server`(stdio)。编排(读图判断、缺料追问、是否提交)由宿主 Agent 完成,
本项目**不调用任何大模型**,只提供确定性工具与内核。
- `src/mcp/` — 工具与协议层:`server.py` 注册与启动,`tools/` 按 materials / extraction / analysis / documents 分模块,
- `src/mcp/` — 工具与协议层:`server.py` 注册与启动,`tools/` 按 materials / extraction / analysis / documents / browser 分模块,
`schemas.py` 抽取字段定义(单一真源),`envelope.py` 统一 `{ok, ...}` / `{ok:false, reason, next_action}` 信封
- `src/cases.py` — 会话目录 `cases/<case_id>/` 的唯一实现:材料、收件箱、产物与**阶段推导**(阶段由产物推导,不存在状态机文件)
- `src/core/` — 确定性内核:`extraction/`(缓存读写、PDF 渲染与文本层、自动转正)、`matching/`(金额匹配 + `runner.py` 从缓存匹配)、`validation/`(声明式规则校验)
@@ -104,6 +104,11 @@ last_reviewed: 2026-10-19
线程亲和,所有调用必须走 `src/infra/browser/actor.py` 的单线程 actor(与 COM actor 分开,套间要求不同);
浏览器归属用 `cases/<id>/.browser.lock`(PID+心跳)声明。返回 `owned_by_other_process` 时**不要**另开窗口,
那会让 Agent 看到的截图和实际操作的页面不是同一个
* **财务系统列表页结构(联机实测,别再猜)**:行选择是 `input[name=btSelectItem]` **单选框**,工具栏
`#insert` 新增 / `#update` 修改 / `#delete` 删除都作用于选中行;行内那个「查看」按钮是 `gzlc()`
**工作流程图,不是打开单据**。关键字搜索用右上角可见的 `#seacher`;`#expenexplain` 那套高级查询
在收起的面板里(元素存在但不可见,`fill` 会卡 30 秒)。表格是 AJAX 后加载的,用 `wait_rows_loaded()`
等,别拍脑袋 `wait_for_timeout`。直达报销 URL 不建立会话,会被踢回「长时间未操作,请重新登录!」
* **多进程共存**:宿主会保留旧代 stdio 子进程,subagent/teammate 也会各起一条 transport—— observed 4 个 PID 并存。
因此任何"独占资源"(浏览器、Office)都要有磁盘锁,别假设只有一个服务器实例
* **敏感信息**:`scripts/data/config.json` 含登录凭据、`cases/` 含身份证号与卡号,两者都已在 `.gitignore`,
+2 -2
View File
@@ -73,7 +73,7 @@ case_create → case_add_files → case_render_pages → case_read_text / 读 PN
提交动作不在里面:`submit_preview` / `submit_execute` 还没做,填报完成必须交人工核对后自己点提交。
24 个工具的参数表与返回约定:[docs/MCP.md](docs/MCP.md)。操作步骤(财务口径):[docs/报销操作指南.md](docs/报销操作指南.md)。
25 个工具的参数表与返回约定:[docs/MCP.md](docs/MCP.md)。操作步骤(财务口径):[docs/报销操作指南.md](docs/报销操作指南.md)。
## 项目结构
@@ -124,7 +124,7 @@ Word 版易耗品出库单已永久下线(模板自 2026-08-31 起不在仓库
| 文档 | 内容 |
|------|------|
| [docs/MCP.md](docs/MCP.md) | 24 个工具的参数、返回信封、会话目录、注册方法 |
| [docs/MCP.md](docs/MCP.md) | 25 个工具的参数、返回信封、会话目录、注册方法 |
| [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) | 改造决策、实测数据、踩坑记录 |
+36 -20
View File
@@ -103,11 +103,10 @@ cases/<case_id>/
# 工具参考
以下内容由运行中的服务器 schema 自动生成,与代码同源(`scripts/mcp_smoke.py` 会打印同一份清单)。
以下内容由运行中的服务器 schema 自动生成,与代码同源(`scripts/mcp_smoke.py` 打印同一份清单)。
## 会话与材料
每个报销任务是一个会话目录 `cases/<case_id>/`,阶段由产物推导。
每个报销任务是一个会话目录 cases/<case_id>/,阶段由产物推导。
### `mcp__arb__case_create`
@@ -163,7 +162,7 @@ cases/<case_id>/
| 参数 | 类型 | 必填 | 默认 | 说明 |
|------|------|------|------|------|
| `case_id` | string | 是 | — | — |
| `scope` | string | 否 | `"extractions"` | — |
| `scope` | string | 否 | "extractions" | — |
## 抽取交接
@@ -180,8 +179,8 @@ cases/<case_id>/
|------|------|------|------|------|
| `case_id` | string | 是 | — | — |
| `files` | array/null | 否 | — | — |
| `dpi` | integer | 否 | `150` | — |
| `force` | boolean | 否 | `false` | — |
| `dpi` | integer | 否 | 150 | — |
| `force` | boolean | 否 | false | — |
### `mcp__arb__case_read_text`
@@ -228,7 +227,7 @@ cases/<case_id>/
## 匹配与校验
匹配与校验都是确定性代码;结论以 `validate_info` 为准,不要自己宣布可提交。
匹配与校验都是确定性代码;结论以 validate_info 为准,不要自己宣布可提交。
### `mcp__arb__match_payments`
@@ -288,7 +287,7 @@ cases/<case_id>/
## 文档产物
Excel COM,需本机装有 Microsoft Excel;仅普通发票需要。模板顺序:`REQUISITION_TEMPLATE` → `templates/` → 项目根。
Excel COM,需本机装有 Microsoft Excel;仅普通发票需要。模板顺序:REQUISITION_TEMPLATE → templates/ → 项目根。
### `mcp__arb__doc_requisition`
@@ -305,7 +304,7 @@ Excel COM,需本机装有 Microsoft Excel;仅普通发票需要。模板顺
## 浏览器分步填报
需要本机已 `uv run playwright install chromium`。默认有头;归属由 `cases/<id>/.browser.lock` 记录(PID+心跳,180 秒未心跳可被接管)。**提交不在这里**:填完交人工核对。
需先 `uv run playwright install chromium`。默认**有头**;归属由 `cases/<id>/.browser.lock` 记录 (PID + 心跳,180 秒未心跳可被接管)。中途断了不必重来:用 `browser_open_form` 从列表页找回原单据 续填,**不要再 `browser_new_form` 重复建单**。提交不在这里,填完交人工核对。
### `mcp__arb__browser_open`
@@ -317,7 +316,7 @@ Excel COM,需本机装有 Microsoft Excel;仅普通发票需要。模板顺
| 参数 | 类型 | 必填 | 默认 | 说明 |
|------|------|------|------|------|
| `case_id` | string | 是 | — | — |
| `headless` | boolean | 否 | `false` | — |
| `headless` | boolean | 否 | false | — |
### `mcp__arb__browser_login`
@@ -326,7 +325,7 @@ Excel COM,需本机装有 Microsoft Excel;仅普通发票需要。模板顺
| 参数 | 类型 | 必填 | 默认 | 说明 |
|------|------|------|------|------|
| `case_id` | string | 是 | — | — |
| `wait_seconds` | integer | 否 | `25` | — |
| `wait_seconds` | integer | 否 | 25 | — |
### `mcp__arb__browser_new_form`
@@ -337,6 +336,21 @@ Excel COM,需本机装有 Microsoft Excel;仅普通发票需要。模板顺
| `case_id` | string | 是 | — | — |
| `kind` | null/string | 否 | — | — |
### `mcp__arb__browser_open_form`
从列表页找回已存在的单据接着填(中途断了不必重来)。
match 用报销说明里的关键词,必须在当前列表页唯一命中;命中多张直接失败,
让宿主把关键词收紧——财务系统里点错别人的单据是不能接受的后果。preview_only=true
只搜索定位、不点开单据,用来先确认是哪一张。
| 参数 | 类型 | 必填 | 默认 | 说明 |
|------|------|------|------|------|
| `case_id` | string | 是 | — | — |
| `match` | string | 是 | — | — |
| `kind` | null/string | 否 | — | — |
| `preview_only` | boolean | 否 | false | — |
### `mcp__arb__browser_fill`
执行一步填报。step 按 browser_new_form 返回的 steps 顺序,不能跳步。
@@ -346,6 +360,17 @@ Excel COM,需本机装有 Microsoft Excel;仅普通发票需要。模板顺
| `case_id` | string | 是 | — | — |
| `step` | string | 是 | — | — |
### `mcp__arb__browser_page_state`
看当前页面:URL、标题、命中的表单标记、已完成步骤、最近截图。
at_portal 只表示在信息门户;进了报销系统要用 in_reimburse_system 与 on_form_page 判断。
| 参数 | 类型 | 必填 | 默认 | 说明 |
|------|------|------|------|------|
| `case_id` | string | 是 | — | — |
| `screenshot` | boolean | 否 | true | — |
### `mcp__arb__browser_steps`
列出该会话单据类型的步骤顺序与含义(不要求浏览器已打开)。
@@ -354,15 +379,6 @@ Excel COM,需本机装有 Microsoft Excel;仅普通发票需要。模板顺
|------|------|------|------|------|
| `case_id` | string | 是 | — | — |
### `mcp__arb__browser_page_state`
看当前页面:URL、标题、命中的表单标记、已完成步骤、最近截图。
| 参数 | 类型 | 必填 | 默认 | 说明 |
|------|------|------|------|------|
| `case_id` | string | 是 | — | — |
| `screenshot` | boolean | 否 | `true` | — |
### `mcp__arb__browser_close`
关闭浏览器并释放锁。不会提交也不会存草稿,填了一半的内容会丢。
+1 -1
View File
@@ -10,7 +10,7 @@ last_reviewed: 2026-10-19
| 文件 | 说明 |
|------|------|
| `MCP.md` | MCP 工具完整契约:注册方法、返回信封、会话目录、标准流程、24 个工具的参数表 |
| `MCP.md` | MCP 工具完整契约:注册方法、返回信封、会话目录、标准流程、25 个工具的参数表 |
| `报销操作指南.md` | 面向最终用户的报销口径与材料准备说明 |
> 旧的 `API.md`(Flask 路由/SSE 文档)已随 Web 界面删除;需要历史接口形态时看 git 提交 `b2d271c`。
+5 -2
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 子进程握手并列出 24 个工具。
自检:`make mcp-smoke` 会真起 stdio 子进程握手并列出 25 个工具。
---
@@ -84,6 +84,9 @@ last_reviewed: 2026-10-19
- 步骤不能跳:travel 是 basic/details/payment/subsidy/attachments,normal 没有 subsidy
- 每步截图按 `001-步骤名.png` 落在 `cases/<id>/screenshots/`;失败时截图路径在返回的 `detail.screenshot`
- **`browser_close` 不提交也不存草稿**,填了一半会丢;提交本身仍未实现(P4),必须人工核对后自己点
- 中途断了不必重来:重新开浏览器登录后用 `browser_open_form`(先 `preview_only=true` 核对**单据编号**,
命中多张会直接拒绝)从列表页找回原单续填;**别再 `browser_new_form`**,那会多建一张单
- 列表页那个「查看」按钮是看审批流程图,不是打开单据;要接着填是选中行后点「修改」
**`ok: true` 的含义有限**:`match_payments` 成功只代表贪心跑完。一对多凑额、发票总额小于支付总额、
未匹配项都会进 `review_required`,必须逐条确认——等额发票尤其容易配错。
@@ -293,6 +296,6 @@ uv run python -m src.infra.documents.requisition --session cases/<case_id> --exc
## 相关文档
- [docs/MCP.md](./MCP.md) — 24 个工具的参数与返回约定
- [docs/MCP.md](./MCP.md) — 25 个工具的参数与返回约定
- [.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/ 工具与协议层(24 个工具,stdio)
├── mcp/ 工具与协议层(25 个工具,stdio)
│ ├── server.py INSTRUCTIONS + TOOLS 注册 + build_server()
│ ├── tools/ materials 材料 / extraction 抽取交接 / analysis 匹配校验 / documents 产物
│ ├── schemas.py 抽取字段定义单一真源 + 日期金额规范化
+50 -3
View File
@@ -25,6 +25,8 @@ INBOX_DIR_NAME = "inbox"
PAGES_DIR_NAME = "pages"
ARTIFACTS_DIR_NAME = "artifacts"
SCREENSHOT_DIR_NAME = "screenshots"
# 浏览器归属锁;谁拥有这个会话的浏览器(见 infra/browser/actor.py)
BROWSER_LOCK_FILENAME = ".browser.lock"
CASE_FILE = "case.json"
UPLOAD_TOKEN_BYTES = 16
@@ -215,14 +217,30 @@ class CaseStore:
extractions["missing"] = missing
extractions["stale"] = stale
pending_inbox = len(self.inbox(d))
info_key = "travel_info" if (d / CACHE_DIR_NAME / "travel_info.json").exists() else None
if info_key is None and (d / CACHE_DIR_NAME / "normal_info.json").exists():
info_key = "normal_info"
has_travel = (d / CACHE_DIR_NAME / "travel_info.json").exists()
has_normal = (d / CACHE_DIR_NAME / "normal_info.json").exists()
info_key = "travel_info" if has_travel else ("normal_info" if has_normal else None)
artifacts = (
sorted(p.name for p in (d / ARTIFACTS_DIR_NAME).glob("*")) if (d / ARTIFACTS_DIR_NAME).is_dir() else []
)
preview = (d / "submit_preview.json").exists()
submitted = bool(self.meta(d).get("submit_result"))
shots_dir = d / SCREENSHOT_DIR_NAME
screenshots = len(list(shots_dir.glob("*.png"))) if shots_dir.is_dir() else 0
browser = self.browser_lock_state(d)
warnings: list[str] = []
if has_travel and has_normal:
warnings.append(
"travel_info 与 normal_info 同时存在:browser_new_form 必须显式传 kind;"
"混合发票按财务口径只能走普通报销"
)
if stale:
warnings.append("源文件比缓存新,这些票据要重抽:" + "、".join(stale))
if browser and not browser["mine"] and not browser["stale"]:
warnings.append(
f"浏览器被 PID {browser['pid']} 持有({browser['age_seconds']} 秒前有心跳);"
"不要另开窗口,等它释放或心跳过期"
)
stage = "empty"
if submitted:
stage = "submitted"
@@ -247,8 +265,37 @@ class CaseStore:
"extractions": extractions,
"matched": (d / CACHE_DIR_NAME / "match_result.json").exists(),
"info": info_key,
"info_both": has_travel and has_normal,
"artifacts": artifacts,
"screenshots": screenshots,
"browser": browser,
"pending_inbox": pending_inbox,
"warnings": warnings,
}
def browser_lock_state(self, d: Path) -> dict[str, Any] | None:
"""读 cases/<id>/.browser.lock;没有或损坏返回 None。不依赖 browser 包,避免循环导入。"""
lock = d / BROWSER_LOCK_FILENAME
if not lock.exists():
return None
try:
data = json.loads(lock.read_text(encoding="utf-8"))
except Exception:
return {"pid": None, "age_seconds": None, "stale": True, "mine": False, "broken": True}
if not isinstance(data, dict):
return {"pid": None, "age_seconds": None, "stale": True, "mine": False, "broken": True}
import os as _os
import time as _time
heartbeat = float(data.get("heartbeat") or 0)
age = round(_time.time() - heartbeat, 1) if heartbeat else None
pid = data.get("pid")
return {
"pid": pid,
"age_seconds": age,
"stale": bool(age is not None and age > 180),
"mine": pid == _os.getpid(),
"url": str(data.get("url") or ""),
}
+37 -9
View File
@@ -22,26 +22,51 @@ from pathlib import Path
from typing import Any
from ... import get_logger
from ...cases import BROWSER_LOCK_FILENAME
log = get_logger("browser.actor")
LOCK_FILENAME = ".browser.lock"
# 文件名归 cases.py 定义(会话目录布局的唯一真相),这里只是沿用
LOCK_FILENAME = BROWSER_LOCK_FILENAME
# 心跳超过这个秒数没更新就视为持有者已死,可被接管
STALE_AFTER_SECONDS = 180
_executor: ThreadPoolExecutor | None = None
_lock = threading.Lock()
_atexit_registered = False
# case_id -> {"bot": BaseBot, "steps": [str, ...], "kind": str|None}
_sessions: dict[str, dict[str, Any]] = {}
def _pool() -> ThreadPoolExecutor:
global _executor
global _executor, _atexit_registered
if _executor is None:
_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="playwright")
_register_shutdown_hook()
return _executor
def _register_shutdown_hook() -> None:
"""把 close_all 挂到 executor 自己的退出机制之前。
踩过的坑:钩子写在模块底部用 atexit.register 是无效的——
concurrent.futures.thread 用的是 threading._register_atexit,它由
threading._shutdown() 触发,而解释器收尾顺序是先 threading._shutdown() 再跑 atexit,
等 close_all 轮到执行时 executor 已经 shutdown,close() 提交不进去,浏览器留成孤儿。
所以要注册到同一个列表里(后注册者先跑),并在这里注册而不是模块导入时
——那时 executor 还没建(线程也还没起),提前注册反而更乱。
"""
global _atexit_registered
if _atexit_registered:
return
hook = getattr(threading, "_register_atexit", None)
if callable(hook):
hook(close_all)
else: # 兜底:非 CPython 或该私有 API 变了,至少把锁放掉
atexit.register(close_all)
_atexit_registered = True
def run_browser[T](fn: Callable[..., T], *args: Any, **kwargs: Any) -> T:
"""在专用 Playwright 线程上串行执行 fn 并等待结果。"""
with _lock:
@@ -146,20 +171,23 @@ def release(case_dir: Path) -> None:
def close_all() -> None:
"""尽力关闭所有浏览器并释放锁(进程退出时调用)。"""
"""尽力关闭所有浏览器并释放锁(进程退出时调用)。
close() 必须在 actor 线程里跑——Playwright sync 的对象有 greenlet 线程亲和,
从主线程(atexit 就是主线程)直接关会抛 Cannot switch to a different thread,
浏览器就留成孤儿进程了。actor 线程可能已经在退出流程里,所以带超时并兜底放锁。
"""
global _executor
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)
if bot is not None and _executor is not None:
try:
if bot is not None:
bot.close()
_executor.submit(bot.close).result(timeout=20)
except Exception as exc:
log.warning("关闭浏览器失败 %s: %s", case_id, exc)
log.warning("关闭浏览器失败 %s: %s(Chromium 可能需要手动结束)", case_id, exc)
if isinstance(work_dir, Path):
release(work_dir)
atexit.register(close_all)
+284 -28
View File
@@ -3,6 +3,8 @@
提供浏览器生命周期管理、登录、导航、截图等公共操作。
"""
import re
import time
from pathlib import Path
from typing import Any
@@ -149,50 +151,304 @@ class BaseBot:
# 导航
# ---------------------------------------------------------------
def navigate_to_reimburse(self, page_key: str = "reimburse_page") -> None:
"""从统一信息平台进入报销系统"""
log.info("进入报销系统...")
self._wait_for('text="快捷入口"', timeout=5000)
# ---------------------------------------------------------------
# 进入报销系统
#
# 门户 → 财务系统(dddl) → 网络报销(wlbx 链接) → 具体页面。每一跳都是异步加载,
# 单点硬等待(老代码的 timeout=5000)实测会偶发超时:同一份代码第一次过、第二次挂。
# 所以这里全部改成轮询,且失败时截图 + 抛人话(工具层会转成 fail 信封给宿主看)。
# ---------------------------------------------------------------
def reimburse_host(self) -> str:
return str(self.config.get("reimburse_url", "")).split("//")[-1].split("/")[0].strip()
def _on_reimburse(self) -> bool:
host = self.reimburse_host()
return bool(host) and host in (self.page.url or "")
def _wait_any(self, selectors: list[str], timeout_ms: int = 20000, poll_ms: int = 500) -> str | None:
"""轮询多个选择器,返回第一个命中的;超时返回 None(不抛异常)。"""
deadline = time.monotonic() + timeout_ms / 1000.0
while time.monotonic() < deadline:
for selector in selectors:
try:
self.page.click('text="财务系统"', timeout=5000)
if self.page.query_selector(selector) is not None:
return selector
except Exception:
log.warning("未找到财务系统入口")
pass
try:
self.page.wait_for_timeout(poll_ms)
except Exception:
return None
return None
new_tab = None
for _ in range(15):
self.page.wait_for_timeout(1000)
def _page_hint(self, limit: int = 80) -> str:
"""页面上第一句可见文字,用来把「长时间未操作,请重新登录!」这类提示带进错误信息。"""
try:
text = " ".join((self.page.inner_text("body", timeout=3000) or "").split())
except Exception:
return ""
return text[:limit]
def _fail_navigation(self, what: str, shot: str) -> RuntimeError:
hint = self._page_hint()
detail = f"{what};当前 URL: {self.page.url}"
if hint:
detail += f";页面文字: {hint}"
if "重新登录" in hint or "超时" in hint:
detail += "(财务系统会话已过期:重新调 mcp__arb__browser_login 走一遍门户)"
if shot:
detail += f";截图: {shot}"
log.error(detail)
return RuntimeError(detail)
def _find_reimburse_tab(self, timeout_ms: int = 25000) -> Any:
"""找门户点「财务系统」后弹出的 tab(dddl 或直接是报销系统 IP)。"""
host = self.reimburse_host()
deadline = time.monotonic() + timeout_ms / 1000.0
while time.monotonic() < deadline:
for p in self.context.pages:
if "dddl" in p.url or "210.45.32.214" in p.url:
new_tab = p
break
if new_tab:
break
url = ""
try:
url = p.url or ""
except Exception:
continue
if "dddl" in url or (host and host in url):
return p
self.page.wait_for_timeout(700)
return None
if new_tab:
self.page = new_tab
self._wait_for('text="网络报销"', timeout=5000)
else:
log.warning(f"未找到单点登录页面,当前 URL: {self.page.url}")
def _wlbx_href(self, timeout_ms: int = 20000) -> str:
"""读「网络报销」图标的链接,读到为止;读不到返回空串。"""
deadline = time.monotonic() + timeout_ms / 1000.0
while time.monotonic() < deadline:
try:
link = self.page.query_selector('a:has(img[src*="wlbx"])')
if link is not None:
href = link.get_attribute("href") or ""
if href:
return href
except Exception:
pass
self.page.wait_for_timeout(700)
return ""
for p in self.context.pages[:-1]:
def navigate_to_reimburse(self, page_key: str = "reimburse_page") -> None:
"""从信息门户进入报销系统的具体页面;每一步都可能被门户拖慢,所以能跳过就跳过。"""
log.info("进入报销系统...")
target = str(self.config.get("reimburse_url", "")).rstrip("/") + str(self.config.get(page_key, ""))
# 已经在报销系统页面就不要再走一遍门户:分步填报中途重连时,
# 重新导航会把填了一半的单据冲掉。
if self._on_reimburse() and self._wait_any(['text="单据状态:"', "#table", "#EXPENEXPLAIN"], 2000):
log.info("已在报销系统页面,跳过门户跳转: %s", self.page.url)
return
self._wait_any(['text="快捷入口"', 'text="财务系统"'], 15000)
try:
self.page.click('text="财务系统"', timeout=8000)
except Exception:
shot = self._screenshot("no_finance_entry")
if not self._on_reimburse():
raise self._fail_navigation("门户里没找到「财务系统」入口", shot) from None
tab = self._find_reimburse_tab()
if tab is not None:
self.page = tab
try:
self.page.bring_to_front()
except Exception:
pass
self._wait_any(['text="网络报销"', 'a:has(img[src*="wlbx"])'], 15000)
for p in list(self.context.pages):
if p is self.page:
continue
try:
p.close()
except Exception:
pass
else:
log.warning("没等到财务系统 tab,继续用当前页试试: %s", self.page.url)
href = self._wlbx_href()
if href:
try:
link = self.page.query_selector('a:has(img[src*="wlbx"])')
if link:
reimburse_url = link.get_attribute("href")
self.page.goto(reimburse_url, wait_until="domcontentloaded", timeout=15000)
self.page.goto(href, wait_until="domcontentloaded", timeout=20000)
except Exception:
pass
self._wait_for('text="报销录入"', timeout=5000)
common_url = self.config["reimburse_url"] + self.config[page_key]
self.page.goto(common_url, wait_until="domcontentloaded", timeout=15000)
self._wait_for('text="单据状态:"', timeout=5000)
hit = self._wait_any(['text="报销录入"', 'text="单据状态:"', "#table"], 15000)
if hit is None:
shot = self._screenshot("not_in_reimburse")
raise self._fail_navigation("点了网络报销但没进报销系统", shot)
if page_key not in (self.page.url or ""):
try:
self.page.goto(target, wait_until="domcontentloaded", timeout=20000)
except Exception:
pass
done = self._wait_any(['text="单据状态:"', "#table", "#EXPENEXPLAIN", "#insertDetail"], 25000)
if done is None:
shot = self._screenshot("reimburse_page_not_ready")
raise self._fail_navigation("报销页面没加载出来", shot)
log.info("已进入报销系统: %s", self.page.url)
def _on_form_page(self) -> bool:
try:
return self.page.query_selector("#EXPENEXPLAIN") is not None
except Exception:
return False
# ---------------------------------------------------------------
# 从列表页找回已有单据(分步填报中途断了要能接上)
#
# 列表页表格是 AJAX 后加载的,工具栏右上角的关键字框 #seacher 是可见的,
# 而 #expenexplain 那套高级查询在收起来的面板里(元素存在但不可见,别去 fill 它)。
# ---------------------------------------------------------------
def _rows(self) -> list[Any]:
return [tr for tr in self.page.query_selector_all("#table tr") if (tr.inner_text() or "").strip()]
def _table_text(self) -> str:
try:
return self.page.inner_text("#table", timeout=3000) or ""
except Exception:
return ""
def _rows_loading(self) -> bool:
"""表格是否还在转圈。空结果(没有找到匹配的记录)是加载完成,不是加载中——
否则每次搜不到都要白等 20 秒。
"""
text = self._table_text()
if "没有找到匹配" in text or "暂无数据" in text:
return False
return "加载数据" in text
# 行里可点的元素:真实列表页的「查看」是 <button onclick="gzlc(...)">,不是 <a>;
# 行选择是 input[radio]。只认 <a> 会让这里每次都等满超时(联机实测浪费 20 秒)。
ROW_ACTIONABLE = ("button", "a", "input[type=radio]", "input[type=checkbox]", "[onclick]")
def row_is_ready(self, tr: Any) -> bool:
return any(tr.query_selector(sel) is not None for sel in self.ROW_ACTIONABLE)
def wait_rows_loaded(self, timeout_ms: int = 20000) -> list[Any]:
"""等表格出数据:「正在加载」消失且数据行里有可点元素才算好。"""
deadline = time.monotonic() + timeout_ms / 1000.0
rows = self._rows()
while time.monotonic() < deadline:
rows = self._rows()
data = rows[1:] if rows else []
if data and not self._rows_loading() and any(self.row_is_ready(tr) for tr in data):
return rows
self.page.wait_for_timeout(500)
if rows:
log.warning("等表格加载超时(%sms),按当前 %s 行继续", timeout_ms, max(0, len(rows) - 1))
return rows
def search_forms(self, keyword: str = "") -> int:
"""用列表页右上角的关键字框过滤,返回非表头行数。"""
page = self.page
if keyword:
box = page.query_selector("#seacher")
if box is None:
raise RuntimeError("列表页找不到关键字搜索框 #seacher(可能不在列表页)")
box.click()
box.fill(keyword)
clicked = False
for sel in ('a:has-text("搜索")', 'button:has-text("搜索")', ".search-btn"):
try:
page.click(sel, timeout=4000)
clicked = True
break
except Exception:
continue
if not clicked:
try:
box.press("Enter")
except Exception:
pass
rows = self.wait_rows_loaded()
self._screenshot(f"list_{(keyword or 'all')[:12]}")
return max(0, len(rows) - 1)
def list_forms(self, limit: int = 12) -> list[dict[str, str]]:
"""把当前列表页的行读成结构化文本,给宿主判断是哪一条。"""
out: list[dict[str, str]] = []
for tr in self._rows()[1:]:
cells = [(td.inner_text() or "").strip() for td in tr.query_selector_all("td")]
out.append({"cells": " | ".join(cells)[:220]})
if len(out) >= limit:
break
return out
def row_cells(self, tr: Any) -> list[str]:
return [(td.inner_text() or "").strip() for td in tr.query_selector_all("td")]
def row_summary(self, tr: Any) -> dict[str, Any]:
"""从行里提出可引用的事实:单据编号、状态、金额、说明。"""
joined = " ".join(self.row_cells(tr))
digits = [c for c in self.row_cells(tr) if c.isdigit() and len(c) >= 8]
# 金额只认 0.00 这种两位小数:单据编号也是纯数字,用 float() 会认错
money = next((c for c in self.row_cells(tr) if re.fullmatch(r"\d+\.\d{2}", c)), "")
return {
"bill_no": digits[0] if digits else "",
"state": "未提交" if "未提交" in joined else ("已提交" if "已提交" in joined else ""),
"amount": money,
"text": joined[:200],
}
def find_rows(self, keyword: str) -> list[Any]:
return [tr for i, tr in enumerate(self._rows()) if i > 0 and keyword in (tr.inner_text() or "")]
def open_form(self, keyword: str, dry_run: bool = False) -> dict[str, Any]:
"""选中关键字唯一命中的行并点「修改」把单据打开继续填。
列表里的「查看」按钮是 gzlc()(工作流程图),不是打开单据,别点它。
行选择是 input[name=btSelectItem] 的单选框,工具栏 #update 修改 / #delete 删除
都作用于选中的那一行——所以唯一命中是硬要求,命中多张一律不选。
"""
hits = self.find_rows(keyword)
if not hits:
return {"found": 0, "rows": max(0, len(self._rows()) - 1), "keyword": keyword}
summaries = [self.row_summary(tr) for tr in hits]
if len(hits) > 1:
return {"found": len(hits), "keyword": keyword, "candidates": summaries[:6], "ambiguous": True}
info = dict(summaries[0])
info["found"] = 1
if dry_run:
info["clicked"] = False
return info
radio = hits[0].query_selector("input[name=btSelectItem]") or hits[0].query_selector(
"input[type=radio], input[type=checkbox]"
)
if radio is None:
raise RuntimeError("命中的行里没有可选中的选择框(btSelectItem)")
try:
radio.click()
except Exception:
hits[0].click()
update = self.page.query_selector("#update")
if update is None:
shot = self._screenshot("no_update_button")
raise RuntimeError(f"列表页没有 #update 修改按钮,当前 URL: {self.page.url};截图: {shot}")
update.click()
hit = self._wait_any(["#EXPENEXPLAIN", "#insertDetail", "#receiptmoney2"], 20000)
el = self.page.query_selector("#EXPENEXPLAIN")
info.update(
{
"clicked": True,
"opened_marker": hit or "",
"url": self.page.url,
"description": (el.input_value() if el is not None else None),
"form_markers": {
"基本信息": self.page.query_selector("#EXPENEXPLAIN") is not None,
"明细": self.page.query_selector("#insertDetail") is not None,
"支付": self.page.query_selector("#insertPay") is not None,
"附件": self.page.query_selector("#insertAcc") is not None,
},
"screenshot": self._screenshot("opened_form"),
}
)
return info
def create_new_form(self) -> None:
"""点击「新增」创建新单据"""
+7 -1
View File
@@ -4,7 +4,7 @@ last_reviewed: 2026-10-19
# src/mcp — 工具与协议层
MCP 服务器的全部对外面。24 个工具的参数表在 `docs/MCP.md`(那份表由运行时 schema 生成,与代码同源)。
MCP 服务器的全部对外面。25 个工具的参数表在 `docs/MCP.md`(那份表由运行时 schema 生成,与代码同源)。
| 文件 | 职责 |
|------|------|
@@ -13,12 +13,18 @@ MCP 服务器的全部对外面。24 个工具的参数表在 `docs/MCP.md`(
| `tools/extraction.py` | 抽取交接:render_pages / read_text / save_extraction / patch_extraction / extraction_spec |
| `tools/analysis.py` | 匹配与校验:match_payments / info_skeleton / save_info / validate_info / rules_get |
| `tools/documents.py` | 产物:doc_requisition(经 `run_com`) |
| `tools/browser.py` | 分步填报:open / login / new_form / open_form / fill / page_state / steps / close;
线程与磁盘锁在 `src/infra/browser/actor.py`,页面动作在 `base.py` + `travel.py` + `normal.py` |
| `schemas.py` | 七类票据的字段定义、日期与金额规范化;`invoice_type` 非法直接拒收 |
| `envelope.py` | `ok(**data)` / `fail(reason, next_action=..., **detail)` |
## 写工具的规矩
- **返回信封,不抛异常穿透协议**:文件缺失、参数不合法、COM 失败都转成 `ok: false` + `reason` + `next_action`
- **分层别搞错**:`fail(reason, next_action=..., **detail)` 的额外字段全在 `detail` 里,只有 `ok` / `reason` /
`next_action` 在顶层;成功时 `ok(**data)` 才平铺。写断言或读返回时两边不一样
- 等待一律轮询到超时并给出人话错误(附截图路径),别用单个 `wait_for_selector(timeout=5000)` 硬等——
门户与报销系统加载时快时慢,实测同一份代码第一次过、第二次挂
- `next_action` 写给 Agent 看,尽量是具体工具名,让它照着做
- 不返回密码、令牌明文等敏感值;上传令牌只从 `case_create` 的页面地址里给一次
- 未实现的能力**不要出现在 INSTRUCTIONS 里**,也不要在工具描述里暗示存在
+1
View File
@@ -74,6 +74,7 @@ TOOLS: list[Callable[..., dict[str, Any]]] = [
browser.browser_open,
browser.browser_login,
browser.browser_new_form,
browser.browser_open_form,
browser.browser_fill,
browser.browser_page_state,
browser.browser_steps,
+98 -2
View File
@@ -320,7 +320,10 @@ def _apply_step(bot: Any, kind: str, step: str, info: dict[str, Any]) -> dict[st
def browser_page_state(case_id: str, screenshot: bool = True) -> dict[str, Any]:
"""看当前页面:URL、标题、命中的表单标记、已完成步骤、最近截图。"""
"""看当前页面:URL、标题、命中的表单标记、已完成步骤、最近截图。
at_portal 只表示在信息门户;进了报销系统要用 in_reimburse_system 与 on_form_page 判断。
"""
d = _case_dir(case_id)
ctx = _require_open(case_id)
if d is None or ctx is None:
@@ -356,10 +359,15 @@ def browser_page_state(case_id: str, screenshot: bool = True) -> dict[str, Any]:
url_value = str(bot.page.url)
except Exception:
url_value = ""
# 进了报销系统后 URL 就不再带门户标记,所以分开报,别再用一个 logged_in 混起来
reimburse = str(getattr(bot, "config", {}).get("reimburse_url", "") or "")
host = reimburse.split("//")[-1].split("/")[0]
return {
"url": url_value,
"title": title,
"logged_in": bool(bot.portal_reached()),
"at_portal": bool(bot.portal_reached()),
"in_reimburse_system": bool(host) and host in url_value,
"on_form_page": any(hit.values()),
"human_required": bool(bot.human_required()),
"markers": hit,
"screenshot": shot,
@@ -413,6 +421,94 @@ def browser_close(case_id: str) -> dict[str, Any]:
)
def browser_open_form(
case_id: str,
match: str,
kind: str | None = None,
preview_only: bool = False,
) -> dict[str, Any]:
"""从列表页找回已存在的单据接着填(中途断了不必重来)。
match 用报销说明里的关键词,必须在当前列表页唯一命中;命中多张直接失败,
让宿主把关键词收紧——财务系统里点错别人的单据是不能接受的后果。preview_only=true
只搜索定位、不点开单据,用来先确认是哪一张。
"""
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 not match or not match.strip():
return fail("match 不能为空", next_action="传报销说明里的一段关键词,要能唯一命中")
if kind is None:
kind, both = _infer_kind(d)
if both or kind is None:
if ctx.get("kind"):
kind = ctx["kind"]
else:
return fail(
"无法确定单据类型",
next_action="显式传 kind='travel' 或 'normal'",
)
if kind not in STEP_ORDER:
return fail(f"kind 只能是 travel 或 normal,收到 {kind}")
def _resume() -> dict[str, Any]:
bot = ctx["bot"]
if not bot.portal_reached() and "expen" not in (bot.page.url or ""):
raise RuntimeError("尚未登录信息门户")
bot.navigate_to_reimburse(page_key=PAGE_KEY[kind])
rows = bot.search_forms(match)
opened: dict[str, Any] = bot.open_form(match, dry_run=preview_only)
opened["rows_after_search"] = rows
opened["sample"] = bot.list_forms(4)
return opened
try:
result = actor.run_browser(_resume)
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 看页面现在在哪")
actor.touch(d, str(result.get("url", "")))
if not result.get("found"):
return fail(
f"列表页没有匹配「{match}」的单据(搜索后 {result.get('rows_after_search')} 行)",
next_action="收紧或更换 match;单据区间下拉默认只看近三个月,更早的单据要先把区间改掉",
sample=result.get("sample", []),
)
if result.get("ambiguous"):
return fail(
f"关键字「{match}」命中 {result.get('found')} 张单据,不替你选",
next_action="把 match 收紧到能唯一命中(报销说明全文或单据编号)",
candidates=result.get("candidates", []),
)
if preview_only:
return ok(
preview=True,
kind=kind,
bill_no=result.get("bill_no", ""),
state=result.get("state", ""),
amount=result.get("amount", ""),
text=result.get("text", ""),
next_action="确认单据编号无误后去掉 preview_only 再调一次",
)
ctx["kind"] = kind
ctx["resumed"] = True
return ok(
kind=kind,
resumed=True,
bill_no=result.get("bill_no", ""),
state=result.get("state", ""),
amount=result.get("amount", ""),
url=result.get("url", ""),
description=result.get("description"),
form_markers=result.get("form_markers", {}),
screenshot=result.get("screenshot", ""),
steps=STEP_ORDER[kind],
next_action="mcp__arb__browser_page_state 看已填到哪一步,再从该步继续 browser_fill(重填同一字段是覆盖)",
)
def browser_steps(case_id: str) -> dict[str, Any]:
"""列出该会话单据类型的步骤顺序与含义(不要求浏览器已打开)。"""
d = _case_dir(case_id)
+156 -2
View File
@@ -25,9 +25,27 @@ class FakePage:
self._existing = existing or set()
self.calls: list[Any] = []
self.url = "http://sso.test/login"
self.rows: list[Any] = []
self.loading_text = ""
self.update_clicks = 0
def query_selector(self, selector: str) -> object | None:
return object() if selector in self._existing else None
def query_selector(self, selector: str) -> FakeCell | None:
if selector == "#seacher":
return FakeCell("")
if selector == "#update":
return FakeCell("", page=self)
# 已知选择器要返回带 input_value 之类的元素,不能返回裸 object()
return FakeCell("已填内容") if selector in self._existing else None
def query_selector_all(self, selector: str) -> list[Any]:
if selector == "#table tr":
return list(self.rows)
return []
def inner_text(self, selector: str = "", timeout: int | None = None) -> str:
if selector == "#table":
return self.loading_text
return ""
def title(self) -> str:
return "统一认证"
@@ -45,6 +63,53 @@ class FakePage:
return _record
class FakeRow:
"""列表页的一行:文本 + 行内单选框(财务系统用的是 btSelectItem 单选,不是复选)。"""
def __init__(self, text: str, has_radio: bool = True) -> None:
self._text = text
self._has_radio = has_radio
self.radio_clicks = 0
def inner_text(self) -> str:
return self._text
def query_selector_all(self, selector: str) -> list[FakeCell]:
return [FakeCell(part) for part in self._text.split(" | ")]
def query_selector(self, selector: str) -> FakeCell | None:
if selector.startswith("input[") and self._has_radio:
return FakeCell("", radio_of=self)
return None
class FakeCell:
def __init__(self, text: str, radio_of: Any = None, page: Any = None) -> None:
self._text = text
self._radio_of = radio_of
self._page = page
self.clicks = 0
def inner_text(self) -> str:
return self._text
def input_value(self) -> str:
return self._text
def click(self) -> None:
self.clicks += 1
if self._radio_of is not None:
self._radio_of.radio_clicks += 1
if self._page is not None:
self._page.update_clicks += 1
def __getattr__(self, name: str) -> Any:
def _noop(*args: Any, **kwargs: Any) -> None:
return None
return _noop
class FakeBot:
"""只实现工具真正依赖的接口,行为由构造参数决定。"""
@@ -108,6 +173,25 @@ class FakeBot:
self.closed = True
# 列表页行逻辑直接用 BaseBot 的真实实现来测(只依赖 page 对象,不需要 Playwright)
from src.infra.browser.base import BaseBot # noqa: E402
FakeBot.row_is_ready = BaseBot.row_is_ready # type: ignore[method-assign]
FakeBot._wait_any = BaseBot._wait_any # type: ignore[method-assign]
FakeBot.row_cells = BaseBot.row_cells # type: ignore[method-assign]
FakeBot.row_summary = BaseBot.row_summary # type: ignore[method-assign]
FakeBot.find_rows = BaseBot.find_rows # type: ignore[method-assign]
FakeBot._rows = BaseBot._rows # type: ignore[method-assign]
FakeBot.ROW_ACTIONABLE = BaseBot.ROW_ACTIONABLE
FakeBot._table_text = BaseBot._table_text # type: ignore[method-assign]
FakeBot._rows_loading = BaseBot._rows_loading # type: ignore[method-assign]
FakeBot.wait_rows_loaded = BaseBot.wait_rows_loaded # type: ignore[method-assign]
FakeBot.search_forms = BaseBot.search_forms # type: ignore[method-assign]
FakeBot.list_forms = BaseBot.list_forms # type: ignore[method-assign]
FakeBot.open_form = BaseBot.open_form # type: ignore[method-assign]
FakeBot.navigate_to_reimburse = lambda self, page_key="reimburse_page": self.navigated.append(page_key) # type: ignore[assignment]
class FakeStore:
"""让工具以为 case_id 指向 tmp 目录。"""
@@ -271,6 +355,9 @@ def test_fill_walks_steps_and_reports_remaining(env: dict[str, Any]) -> None:
assert state["ok"] is True
assert state["steps_done"] == ["basic", "details", "payment"]
assert state["markers"]["支付页"] is True
assert state["on_form_page"] is True
assert state["at_portal"] is True
assert state["in_reimburse_system"] is False
assert state["recent_screenshots"]
@@ -325,3 +412,70 @@ def test_steps_lists_both_flows_then_one(env: dict[str, Any]) -> None:
picked = browser.browser_steps(env["id"])
assert picked["kind"] == "travel"
assert [s["step"] for s in picked["steps"]] == ["basic", "details", "payment", "subsidy", "attachments"]
def _rows_with(texts: list[str]) -> list[FakeRow]:
header = FakeRow("查看单据 | 状态 | 报销说明", has_radio=False)
return [header] + [FakeRow(x) for x in texts]
DRAFT = "查看 | 未提交 | 202610020850 | 2026-10-20 | 元器件采购(MCP 实机填充) | 0.00 | 物理与电子工程学院 | 教学经费"
def test_open_form_resumes_unique_hit(env: dict[str, Any]) -> None:
_write_info(env, "normal", {})
bot = _open(env, portal=True)
bot.page.rows = _rows_with([DRAFT])
result = browser.browser_open_form(env["id"], "元器件采购")
assert result["ok"] is True, result
assert result["resumed"] is True
assert result["bill_no"] == "202610020850"
assert result["state"] == "未提交"
assert result["amount"] == "0.00"
assert bot.navigated == ["reimburse_page"]
assert bot.page.rows[1].radio_clicks == 1
assert bot.page.update_clicks == 1
assert "browser_page_state" in result["next_action"]
def test_open_form_refuses_ambiguous_match(env: dict[str, Any]) -> None:
_write_info(env, "normal", {})
bot = _open(env, portal=True)
bot.page.rows = _rows_with(
["查看 | 未提交 | 202610020001 | 元器件采购 甲 | 1.00", "查看 | 未提交 | 202610020002 | 元器件采购 乙 | 2.00"]
)
result = browser.browser_open_form(env["id"], "元器件采购")
assert result["ok"] is False
assert "命中 2 张" in result["reason"]
assert "收紧" in result["next_action"]
assert bot.page.update_clicks == 0
assert all(row.radio_clicks == 0 for row in bot.page.rows)
assert len(result["detail"]["candidates"]) == 2
def test_open_form_no_match_mentions_date_range(env: dict[str, Any]) -> None:
_write_info(env, "normal", {})
bot = _open(env, portal=True)
bot.page.rows = _rows_with(["查看 | 已提交 | 202609020003 | 别的单据 | 3.00"])
result = browser.browser_open_form(env["id"], "元器件采购")
assert result["ok"] is False
assert "近三个月" in result["next_action"]
assert bot.page.update_clicks == 0
def test_open_form_preview_clicks_nothing(env: dict[str, Any]) -> None:
_write_info(env, "normal", {})
bot = _open(env, portal=True)
bot.page.rows = _rows_with([DRAFT])
result = browser.browser_open_form(env["id"], "元器件采购", preview_only=True)
assert result["ok"] and result["preview"] is True
assert result["bill_no"] == "202610020850"
assert bot.page.update_clicks == 0
assert all(row.radio_clicks == 0 for row in bot.page.rows)
def test_open_form_requires_keyword(env: dict[str, Any]) -> None:
_open(env, portal=True)
result = browser.browser_open_form(env["id"], " ")
assert result["ok"] is False
assert "match" in result["reason"]