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:
@@ -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 行
|
||||
|
||||
|
||||
@@ -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` 里)。
|
||||
|
||||
## 参考
|
||||
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 抽取字段定义单一真源 + 日期金额规范化
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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", [])),
|
||||
)
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user