From 1725244fafa45dfb6eb510ec679e958a8976a882 Mon Sep 17 00:00:00 2001 From: wandering Date: Tue, 20 Oct 2026 00:46:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20=E6=B5=8F=E8=A7=88=E5=99=A8?= =?UTF-8?q?=E5=88=86=E6=AD=A5=E5=A1=AB=E6=8A=A5=20P3=E2=80=94=E2=80=947=20?= =?UTF-8?q?=E4=B8=AA=20browser=20=E5=B7=A5=E5=85=B7=20+=20=E5=8D=95?= =?UTF-8?q?=E7=BA=BF=E7=A8=8B=20actor=20+=20=E7=A3=81=E7=9B=98=E9=94=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 填报从「一把梭」变成分步:Agent 可以开浏览器、登录、建单、按步填、看页面、关,每步有截图, 中途失败不必从头重来。提交不在本步——提交不可逆,留给 P4 的 submit_preview/submit_execute。 - src/infra/browser/actor.py(新):Playwright 单线程 actor(sync API 的 greenlet 线程亲和要求所有 调用回到创建线程)+ 会话注册表 + cases//.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 提交纪律)。 --- .../docs/plans/MCP化改造方案-2026-10-19.md | 50 +- .agents/skills/reimbursement/SKILL.md | 14 +- AGENTS.md | 11 +- README.md | 12 +- docs/MCP.md | 71 ++- docs/README.md | 2 +- docs/报销操作指南.md | 21 +- src/README.md | 2 +- src/infra/browser/README.md | 33 +- src/infra/browser/actor.py | 165 +++++++ src/infra/browser/base.py | 92 ++-- src/mcp/README.md | 2 +- src/mcp/server.py | 23 +- src/mcp/tools/browser.py | 437 ++++++++++++++++++ tests/test_mcp_browser.py | 327 +++++++++++++ 15 files changed, 1201 insertions(+), 61 deletions(-) create mode 100644 src/infra/browser/actor.py create mode 100644 src/mcp/tools/browser.py create mode 100644 tests/test_mcp_browser.py diff --git a/.agents/docs/plans/MCP化改造方案-2026-10-19.md b/.agents/docs/plans/MCP化改造方案-2026-10-19.md index f4d5577..27c8c3a 100644 --- a/.agents/docs/plans/MCP化改造方案-2026-10-19.md +++ b/.agents/docs/plans/MCP化改造方案-2026-10-19.md @@ -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//artifacts/`,不再写模板旁边。新增 5 个纯单测覆盖解析优先级(不碰 COM)。 +## 13. P3 浏览器分步填报(2026-10-20 凌晨) + +### 13.1 落地内容 + +- 新增 `src/infra/browser/actor.py`:单线程 Playwright actor + 会话注册表 + `cases//.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 行 + diff --git a/.agents/skills/reimbursement/SKILL.md b/.agents/skills/reimbursement/SKILL.md index 16ccf7f..35de665 100644 --- a/.agents/skills/reimbursement/SKILL.md +++ b/.agents/skills/reimbursement/SKILL.md @@ -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//`(该目录已 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` 里)。 ## 参考 diff --git a/AGENTS.md b/AGENTS.md index 618058d..f194cfa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,7 +63,7 @@ last_reviewed: 2026-10-19 `schemas.py` 抽取字段定义(单一真源),`envelope.py` 统一 `{ok, ...}` / `{ok:false, reason, next_action}` 信封 - `src/cases.py` — 会话目录 `cases//` 的唯一实现:材料、收件箱、产物与**阶段推导**(阶段由产物推导,不存在状态机文件) - `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//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//.browser.lock`(PID+心跳)声明。返回 `owned_by_other_process` 时**不要**另开窗口, + 那会让 Agent 看到的截图和实际操作的页面不是同一个 * **多进程共存**:宿主会保留旧代 stdio 子进程,subagent/teammate 也会各起一条 transport—— observed 4 个 PID 并存。 因此任何"独占资源"(浏览器、Office)都要有磁盘锁,别假设只有一个服务器实例 * **敏感信息**:`scripts/data/config.json` 含登录凭据、`cases/` 含身份证号与卡号,两者都已在 `.gitignore`, diff --git a/README.md b/README.md index 911d471..314082e 100644 --- a/README.md +++ b/README.md @@ -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) | 改造决策、实测数据、踩坑记录 | diff --git a/docs/MCP.md b/docs/MCP.md index 7abce7f..a618c46 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -288,8 +288,7 @@ cases// ## 文档产物 -Excel COM,需本机装有 Microsoft Excel;仅普通发票需要。模板查找顺序:环境变量 -`REQUISITION_TEMPLATE` → `templates/低值易耗品申购*.xls` → 项目根(旧位置);产物写 `cases//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//.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 | 是 | — | — | diff --git a/docs/README.md b/docs/README.md index 84eb5d3..c95fdf7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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`。 diff --git a/docs/报销操作指南.md b/docs/报销操作指南.md index 66bd2cb..37ddc4f 100644 --- a/docs/报销操作指南.md +++ b/docs/报销操作指南.md @@ -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//.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//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/ --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) — 开发约束与踩坑记录 diff --git a/src/README.md b/src/README.md index c49850b..9b9f687 100644 --- a/src/README.md +++ b/src/README.md @@ -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 抽取字段定义单一真源 + 日期金额规范化 diff --git a/src/infra/browser/README.md b/src/infra/browser/README.md index 4d35911..e221de9 100644 --- a/src/infra/browser/README.md +++ b/src/infra/browser/README.md @@ -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//screenshots/NNN-.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//.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//screenshots/NNN-<步骤名>.png`;只有走 `run_bot()` 才写 + `images/debug_*.png`(已 gitignore) diff --git a/src/infra/browser/actor.py b/src/infra/browser/actor.py new file mode 100644 index 0000000..0544956 --- /dev/null +++ b/src/infra/browser/actor.py @@ -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) diff --git a/src/infra/browser/base.py b/src/infra/browser/base.py index 927e3d5..705c3bb 100644 --- a/src/infra/browser/base.py +++ b/src/infra/browser/base.py @@ -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) diff --git a/src/mcp/README.md b/src/mcp/README.md index e20c77f..7e48627 100644 --- a/src/mcp/README.md +++ b/src/mcp/README.md @@ -4,7 +4,7 @@ last_reviewed: 2026-10-19 # src/mcp — 工具与协议层 -MCP 服务器的全部对外面。17 个工具的参数表在 `docs/MCP.md`(那份表由运行时 schema 生成,与代码同源)。 +MCP 服务器的全部对外面。24 个工具的参数表在 `docs/MCP.md`(那份表由运行时 schema 生成,与代码同源)。 | 文件 | 职责 | |------|------| diff --git a/src/mcp/server.py b/src/mcp/server.py index 5a51ab9..253598a 100644 --- a/src/mcp/server.py +++ b/src/mcp/server.py @@ -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//.browser.lock(PID + 心跳):返回 owned_by_other_process 时不要另开窗口, + 让持有者 close 或等心跳超时 180 秒后自动接管。截图按 001-步骤名.png 落在 cases//screenshots/。 -尚未实现(**现在不要调用,也不要在回答里承诺**):Word 出库单与 Excel 申购单、浏览器填报、 -提交预览与执行。需要这些步骤时告诉用户该环节还在改造中,可暂用旧的 Web 流程,不要自己拼 HTML 或猜接口。 +尚未实现(**现在不要调用,也不要在回答里承诺**):提交预览与提交执行(submit_preview / submit_execute)。 +填报完成后必须停下:把 browser_page_state 的截图与已填内容报给用户,由用户在财务系统里核对并亲自点提交。 +Word 出库单功能已下线(模板自 2026-08 起不在仓库),不要再提;Excel 申购单见第 6 条。 硬约束:不得回显密码;不得臆造财务数据,拿不准的字段标出来问用户;产物只写 cases//。 """ @@ -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, ] diff --git a/src/mcp/tools/browser.py b/src/mcp/tools/browser.py new file mode 100644 index 0000000..df4ba78 --- /dev/null +++ b/src/mcp/tools/browser.py @@ -0,0 +1,437 @@ +"""分步浏览器填报工具 + +取代旧的「一把梭」run_bot:Agent 可以开浏览器、登录、建单、按步填、看页面、关, +每步都有截图与明确状态,中途失败不必从头再来。提交不在这里——提交不可逆,属于 P4 的 +submit_preview / submit_execute(带一次性 token)。 + +所有 Playwright 调用都在 src/infra/browser/actor.py 的单线程上执行;浏览器归属用 +cases//.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", [])), + ) diff --git a/tests/test_mcp_browser.py b/tests/test_mcp_browser.py new file mode 100644 index 0000000..f4dfd75 --- /dev/null +++ b/tests/test_mcp_browser.py @@ -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"]