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

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

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

验证:
- 联机跑通 open(3.1s 起有头 Chromium)→ 锁写入(pid/heartbeat)→ page_state(about:blank,
  001-state.png 4721 字节)→ 重复 open 复用(already_open=true,不多开窗)→ close(锁释放、注册表清空)
- 登录与填报的联机验证需要用户在场(SSO 可能扫码),本轮未做
- 门禁:ruff / format --check / mypy src / deptry / pytest 129 passed / mcp_smoke 24 工具
  instructions 2109 字节。仍用 --no-verify 提交并手跑等价检查(原因见 AGENTS 提交纪律)。
This commit is contained in:
wandering
2026-10-20 00:46:23 +08:00
parent 9c6f5be56b
commit 1725244faf
15 changed files with 1201 additions and 61 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ MCP 服务器的全部实现:工具层在上,确定性内核在下,infra
```
src/
├── mcp/ 工具与协议层(17 个工具,stdio)
├── mcp/ 工具与协议层(24 个工具,stdio)
│ ├── server.py INSTRUCTIONS + TOOLS 注册 + build_server()
│ ├── tools/ materials 材料 / extraction 抽取交接 / analysis 匹配校验 / documents 产物
│ ├── schemas.py 抽取字段定义单一真源 + 日期金额规范化
+25 -8
View File
@@ -13,15 +13,32 @@ Playwright 操作财务报销系统:登录信息门户、进入报销系统、
| `normal.py` | 普通填报:基本信息 → 总明细 → 支付方式 → 附件 |
| `__init__.py` | `run_bot(config, travel_info, normal_info)`;`run_bot_web(config, work_dir)` 从会话缓存加载后执行 |
## 现状与计划
## 两种用法
现有入口是**一次性跑完**的编排函数,适合手工验证,不适合 Agent 分步介入:中途出错只能整体重来,
也没法在填到一半时先看一眼。P3 计划拆成 `browser_open / login / new_form / fill(step) / page_state / close`
分步工具,过程截图进 `cases/<id>/screenshots/NNN-<step>.png`,并用 `.browser.lock`(PID + 心跳)防止多代进程
抢同一个浏览器;P4 再加提交前预览与一次性 token 护栏(提交不可逆)。
- **MCP 分步工具**(推荐):`browser_open → browser_login → browser_new_form → browser_fill(step) →
`browser_page_state → browser_close`,实现在 `src/mcp/tools/browser.py`
- `run_bot()` / `run_bot_web()`:一把梭编排,只留给手工验证与回归;新代码别用——中途失败只能整体重来
步骤函数(`fill_travel_info` / `add_travel_items` / `fill_normal_payment` 等)是两种用法共用的底座,
**出错必须回抛**(`log.error` 之后要 `raise`)。吞异常会让上层以为填好了——本项目已经因为静默失败
丢了 7 周的出库单产物(方案文档 §11.4),不要重犯。
## 线程与进程约束
- Playwright sync 的 browser/page 只能在创建它的线程里用;MCP 是多线程调用,所以一律经
`actor.run_browser()` 提交到专属单线程执行(与 COM actor 分开,套间要求不同)
- 浏览器归属写在 `cases/<id>/.browser.lock`(PID + 心跳;180 秒未更新可被别的进程接管)。
宿主可能同时跑多代 MCP 子进程(重连窗口、subagent/teammate 各一条 transport),
各开一个 Chromium 会让 Agent 看到的截图不是它正在操作的那个页面
- `actor.py` 的 atexit 会尽力关浏览器并释放锁;被强杀时锁文件会留下,靠心跳过期接管
## 计划中的 P4
`submit_preview`(快照 + 摘要 + 一次性 token)与 `submit_execute`(token 一次性、摘要变更即失效)。
在那之前,填报完成必须停下交人工核对提交。
## 注意
- 会打开 Chromium,自动化期间不要手动点页面
- 调试截图落在 `images/`(已 gitignore,不进仓库)
- 步骤函数最好返回「填了什么 / 什么失败」的结构,P3 可直接复用
- 会打开 Chromium(默认有头),自动化期间不要手动点页面
- 分步填报的截图写 `cases/<id>/screenshots/NNN-<步骤名>.png`;只有走 `run_bot()` 才写
`images/debug_*.png`(已 gitignore)
+165
View File
@@ -0,0 +1,165 @@
"""浏览器会话的常驻持有者与独占锁
两件事必须同时成立,填报才可信:
1. **线程亲和**:Playwright 同步 API 底层是 greenlet,browser/page 对象必须在创建它的那个线程里使用。
MCP 服务器是多线程的(每个工具调用可能在不同线程),所以所有浏览器操作统一提交到这个单线程 actor。
与 COM 的 actor 分开:COM 套间要求不同,混用会踩坑。
2. **进程独占**:宿主可能同时存在多代 MCP 子进程(重连窗口、subagent/teammate 各一条 transport,
实测 4 个 PID 并存)。每个进程各开一个 Chromium,Agent 看到的截图就不是它正在操作的那个页面。
"""
from __future__ import annotations
import atexit
import json
import os
import threading
import time
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import Any
from ... import get_logger
log = get_logger("browser.actor")
LOCK_FILENAME = ".browser.lock"
# 心跳超过这个秒数没更新就视为持有者已死,可被接管
STALE_AFTER_SECONDS = 180
_executor: ThreadPoolExecutor | None = None
_lock = threading.Lock()
# case_id -> {"bot": BaseBot, "steps": [str, ...], "kind": str|None}
_sessions: dict[str, dict[str, Any]] = {}
def _pool() -> ThreadPoolExecutor:
global _executor
if _executor is None:
_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="playwright")
return _executor
def run_browser[T](fn: Callable[..., T], *args: Any, **kwargs: Any) -> T:
"""在专用 Playwright 线程上串行执行 fn 并等待结果。"""
with _lock:
return _pool().submit(fn, *args, **kwargs).result()
# ------------------------------------------------------------------
# 会话注册表(只在 actor 线程里读写,不加锁也不竞争)
# ------------------------------------------------------------------
def session(case_id: str) -> dict[str, Any] | None:
"""取会话的浏览器上下文;未打开返回 None。"""
return _sessions.get(case_id)
def put_session(case_id: str, bot: Any, kind: str | None = None) -> dict[str, Any]:
ctx: dict[str, Any] = {"bot": bot, "steps": [], "kind": kind, "opened_at": time.time()}
_sessions[case_id] = ctx
return ctx
def drop_session(case_id: str) -> dict[str, Any] | None:
return _sessions.pop(case_id, None)
def list_sessions() -> list[str]:
return sorted(_sessions)
def mark_step(case_id: str, step: str) -> None:
ctx = _sessions.get(case_id)
if ctx is not None and step not in ctx["steps"]:
ctx["steps"].append(step)
# ------------------------------------------------------------------
# 磁盘锁
# ------------------------------------------------------------------
def lock_path(case_dir: Path) -> Path:
return case_dir / LOCK_FILENAME
def read_lock(case_dir: Path) -> dict[str, Any] | None:
"""读锁文件;损坏或不存在返回 None(损坏不阻断,按无锁处理)。"""
path = lock_path(case_dir)
if not path.exists():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception as exc:
log.warning("浏览器锁文件损坏,按无锁处理: %s (%s)", path, exc)
return None
return data if isinstance(data, dict) else None
def _write_lock(case_dir: Path, url: str = "") -> dict[str, Any]:
payload = {
"pid": os.getpid(),
"started_at": round(time.time(), 3),
"heartbeat": round(time.time(), 3),
"url": url,
}
lock_path(case_dir).write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
return payload
def acquire(case_dir: Path, url: str = "") -> tuple[bool, dict[str, Any]]:
"""尝试取得该会话的浏览器所有权。返回 (是否取得, 锁信息或占用者信息)。"""
held = read_lock(case_dir)
if held is not None:
other_pid = int(held.get("pid", -1))
age = time.time() - float(held.get("heartbeat", 0))
if other_pid != os.getpid() and age < STALE_AFTER_SECONDS:
return False, {**held, "age_seconds": round(age, 1), "stale": False}
if other_pid != os.getpid():
log.warning("接管过期浏览器锁(原持有 pid=%s,%.0fs 无心跳)", other_pid, age)
return True, _write_lock(case_dir, url)
def touch(case_dir: Path, url: str = "") -> None:
"""刷心跳:每次浏览器工具调用都调一下。"""
if read_lock(case_dir) is None:
return
_write_lock(case_dir, url)
def release(case_dir: Path) -> None:
held = read_lock(case_dir)
if held is not None and int(held.get("pid", -1)) == os.getpid():
try:
lock_path(case_dir).unlink()
except OSError:
pass
# ------------------------------------------------------------------
# 收尾
# ------------------------------------------------------------------
def close_all() -> None:
"""尽力关闭所有浏览器并释放锁(进程退出时调用)。"""
for case_id in list(_sessions):
ctx = _sessions.pop(case_id, None)
if not ctx:
continue
bot = ctx.get("bot")
work_dir = getattr(bot, "work_dir", None)
try:
if bot is not None:
bot.close()
except Exception as exc:
log.warning("关闭浏览器失败 %s: %s", case_id, exc)
if isinstance(work_dir, Path):
release(work_dir)
atexit.register(close_all)
+63 -29
View File
@@ -38,6 +38,9 @@ class BaseBot:
self.config = config
self.headless = headless
self.work_dir: Path | None = None
# 设了就把截图按 001-步骤名.png 编号写进这里;不设保持旧行为(src/images/debug_*.png)
self.screenshot_dir: Path | None = None
self._shot_seq = 0
self.browser: Any = None
self.context: Any = None
self.page: Any = None
@@ -74,12 +77,44 @@ class BaseBot:
# ---------------------------------------------------------------
def login_portal(self) -> None:
"""登录信息门户"""
log.info("登录信息门户...")
"""登录信息门户;一把梭版本,等价于 submit_login 后等门户出现。"""
self.submit_login()
if not self.wait_for_portal(30):
self._screenshot("portal_timeout")
raise TimeoutError("登录超时,未跳转到信息门户")
PORTAL_MARKERS = ("tyrz.fynu.edu.cn/zs-uip", "tyrz.fynu.edu.cn/oshall", "portal")
def portal_reached(self) -> bool:
"""是否已落到信息门户;可反复调用判断登录是否完成。"""
try:
url = self.page.url or ""
except Exception:
return False
return any(marker in url for marker in self.PORTAL_MARKERS)
def human_required(self) -> bool:
"""页面是否卡在需要人工的地方(扫码、验证码、仍在 SSO 登录页)。"""
try:
if self.portal_reached():
return False
for selector in (
'text="微信扫码登录"',
'img[src*="qrcode"]',
'img[src*="captcha"]',
'input[placeholder*="验证码"]',
):
if self.page.query_selector(selector):
return True
return "login" in (self.page.url or "").lower()
except Exception:
return False
def submit_login(self) -> None:
"""填工号密码、勾同意、点登录;不等跳转。"""
log.info("登录信息门户...")
self.page.goto(self.config["sso_login_url"], wait_until="domcontentloaded")
self._wait_for('text="微信扫码登录"', timeout=5000)
try:
self.page.fill(
'input[placeholder*="工号"], input[placeholder*="学号"]',
@@ -88,41 +123,27 @@ class BaseBot:
self.page.fill('input[placeholder*="密码"]', self.config["password"])
except Exception:
log.warning("未找到登录输入框,可能已登录")
try:
checkbox = self.page.query_selector('input[type="checkbox"]')
if checkbox and not checkbox.is_checked():
checkbox.click()
except Exception:
pass
for selector in ['button:has-text("登录")', 'input[value="登录"]', 'text="登录"']:
for selector in ('button:has-text("登录")', 'input[value="登录"]', 'text="登录"'):
try:
self.page.click(selector, timeout=3000)
break
except Exception:
continue
self._wait_for_portal()
def _wait_for_portal(self) -> None:
"""等待跳转到统一信息平台"""
for _ in range(30):
self.page.wait_for_timeout(1000)
url = self.page.url
if any(
kw in url
for kw in (
"tyrz.fynu.edu.cn/zs-uip",
"tyrz.fynu.edu.cn/oshall",
"portal",
)
):
def wait_for_portal(self, timeout: int = 30) -> bool:
"""等待门户跳转;返回是否成功,不抛异常——分步填报靠它判 need_human。"""
for _ in range(timeout):
if self.portal_reached():
self._screenshot("portal_loaded")
return
log.error("等待门户跳转超时")
self._screenshot("portal_timeout")
raise TimeoutError("登录超时,未跳转到信息门户")
return True
self.page.wait_for_timeout(1000)
return self.portal_reached()
# ---------------------------------------------------------------
# 导航
@@ -197,7 +218,20 @@ class BaseBot:
def _wait_for(self, selector: str, timeout: int | None = None) -> None:
self.page.wait_for_selector(selector, timeout=timeout)
def _screenshot(self, name: str) -> None:
img_dir = Path(__file__).parent.parent.parent / "images"
img_dir.mkdir(exist_ok=True)
self.page.screenshot(path=str(img_dir / f"debug_{name}.png"))
def _screenshot(self, name: str) -> str:
"""截图并返回落盘路径;截图失败只告警,别盖掉真正的填报错误。"""
self._shot_seq += 1
if self.screenshot_dir is not None:
img_dir = Path(self.screenshot_dir)
img_dir.mkdir(parents=True, exist_ok=True)
target = img_dir / f"{self._shot_seq:03d}-{name}.png"
else:
img_dir = Path(__file__).parent.parent.parent / "images"
img_dir.mkdir(exist_ok=True)
target = img_dir / f"debug_{name}.png"
try:
self.page.screenshot(path=str(target))
except Exception as exc:
log.warning("截图失败 %s: %s", target.name, exc)
return ""
return str(target)
+1 -1
View File
@@ -4,7 +4,7 @@ last_reviewed: 2026-10-19
# src/mcp — 工具与协议层
MCP 服务器的全部对外面。17 个工具的参数表在 `docs/MCP.md`(那份表由运行时 schema 生成,与代码同源)。
MCP 服务器的全部对外面。24 个工具的参数表在 `docs/MCP.md`(那份表由运行时 schema 生成,与代码同源)。
| 文件 | 职责 |
|------|------|
+19 -4
View File
@@ -13,11 +13,11 @@ from typing import Any
from mcp.server import MCPServer
from .tools import analysis, documents, extraction, materials
from .tools import analysis, browser, documents, extraction, materials
SERVER_NAME = "auto-reimbursement"
INSTRUCTIONS = """财务报销自动化工具集(发票抽取交接 → 匹配 → 校验 → 文档 → 浏览器填报 → 提交)。
INSTRUCTIONS = """财务报销自动化工具集(发票抽取交接 → 匹配 → 校验 → 文档 → 浏览器填报;提交由人工完成)。
当前已实现(可直接调用):
1. mcp__arb__case_create 建会话;mcp__arb__case_add_files 拷入发票/支付截图/申请单
@@ -38,9 +38,17 @@ INSTRUCTIONS = """财务报销自动化工具集(发票抽取交接 → 匹配
人工核对后要改单张票据的某个字段,用 mcp__arb__case_patch_extraction(只传要改的键,会回显 from/to)。
7. 每步之后用 mcp__arb__case_status 复查态势;阶段由产物推导,没有内部状态机。
mcp__arb__case_reset(scope) 可清派生产物重跑;mcp__arb__case_list 看全部会话。
8. 浏览器分步填报:mcp__arb__browser_open(默认有头 Chromium;同一会话重复调用会复用现有浏览器)→
mcp__arb__browser_login(页面停在扫码或验证码时返回 need_human,请用户在窗口里完成后重调本工具)→
mcp__arb__browser_new_form(kind 省略时按会话里的 info 推断;travel_info 与 normal_info 同时存在必须显式传)→
按它返回的 steps 顺序逐个 mcp__arb__browser_fill(step=...)(travel: basic/details/payment/subsidy/attachments,
normal 没有 subsidy)→ mcp__arb__browser_page_state 复查 → mcp__arb__browser_close(不提交也不存草稿)。
浏览器归属写在 cases/<id>/.browser.lock(PID + 心跳):返回 owned_by_other_process 时不要另开窗口,
让持有者 close 或等心跳超时 180 秒后自动接管。截图按 001-步骤名.png 落在 cases/<id>/screenshots/。
尚未实现(**现在不要调用,也不要在回答里承诺**):Word 出库单与 Excel 申购单、浏览器填报、
提交预览与执行。需要这些步骤时告诉用户该环节还在改造中,可暂用旧的 Web 流程,不要自己拼 HTML 或猜接口。
尚未实现(**现在不要调用,也不要在回答里承诺**):提交预览与提交执行(submit_preview / submit_execute)。
填报完成后必须停下:把 browser_page_state 的截图与已填内容报给用户,由用户在财务系统里核对并亲自点提交。
Word 出库单功能已下线(模板自 2026-08 起不在仓库),不要再提;Excel 申购单见第 6 条。
硬约束:不得回显密码;不得臆造财务数据,拿不准的字段标出来问用户;产物只写 cases/<case_id>/。
"""
@@ -63,6 +71,13 @@ TOOLS: list[Callable[..., dict[str, Any]]] = [
analysis.rules_get,
analysis.info_skeleton,
documents.doc_requisition,
browser.browser_open,
browser.browser_login,
browser.browser_new_form,
browser.browser_fill,
browser.browser_page_state,
browser.browser_steps,
browser.browser_close,
]
+437
View File
@@ -0,0 +1,437 @@
"""分步浏览器填报工具
取代旧的「一把梭」run_bot:Agent 可以开浏览器、登录、建单、按步填、看页面、关,
每步都有截图与明确状态,中途失败不必从头再来。提交不在这里——提交不可逆,属于 P4 的
submit_preview / submit_execute(带一次性 token)。
所有 Playwright 调用都在 src/infra/browser/actor.py 的单线程上执行;浏览器归属用
cases/<id>/.browser.lock 记录(PID + 心跳),防止多代 MCP 子进程各开一个 Chromium。
"""
from __future__ import annotations
import time
from pathlib import Path
from typing import Any
from ... import get_logger
from ...cases import CACHE_DIR_NAME, CaseStore
from ...config import load_config
from ...infra.browser import actor
from ..envelope import fail, ok
log = get_logger("mcp.browser")
_store = CaseStore()
# 每一步对应 infra/browser 里的一个步骤函数;顺序不能跳(页面状态是连续的)
STEP_ORDER: dict[str, list[str]] = {
"travel": ["basic", "details", "payment", "subsidy", "attachments"],
"normal": ["basic", "details", "payment", "attachments"],
}
STEP_LABEL: dict[str, str] = {
"basic": "基本信息",
"details": "报销明细",
"payment": "支付方式",
"subsidy": "补助清单",
"attachments": "附件上传",
}
PAGE_KEY = {"travel": "travel_page", "normal": "reimburse_page"}
INFO_FILE = {"travel": "travel_info.json", "normal": "normal_info.json"}
def _case_dir(case_id: str) -> Path | None:
return _store.resolve(case_id)
def _require_open(case_id: str) -> dict[str, Any] | None:
return actor.session(case_id)
def _load_info(d: Path, kind: str) -> dict[str, Any] | None:
import json
path = d / CACHE_DIR_NAME / INFO_FILE[kind]
if not path.exists():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception as exc:
log.warning("读取 %s 失败: %s", path.name, exc)
return None
return data if isinstance(data, dict) else None
def _infer_kind(d: Path) -> tuple[str | None, bool]:
"""按会话里已有的 info 推断单据类型;返回 (类型, 是否两种都有)。"""
has_travel = (d / CACHE_DIR_NAME / INFO_FILE["travel"]).exists()
has_normal = (d / CACHE_DIR_NAME / INFO_FILE["normal"]).exists()
if has_travel and has_normal:
return None, True
if has_travel:
return "travel", False
if has_normal:
return "normal", False
return None, False
def browser_open(case_id: str, headless: bool = False) -> dict[str, Any]:
"""打开(或复用)该会话的浏览器。
默认有头:提交不可逆,要看得见在点什么。同一会话重复调用直接复用现有浏览器。
被别的进程占用时返回 owned_by_other_process,别绕过去另开一个浏览器。
"""
d = _case_dir(case_id)
if d is None:
return fail(f"会话不存在: {case_id}")
got, info = actor.acquire(d)
if not got:
return fail(
"浏览器被其他进程占用",
next_action=f"让持有者调 mcp__arb__browser_close;心跳超过 {actor.STALE_AFTER_SECONDS} 秒未更新即可自动接管",
owned_by={"pid": info.get("pid"), "age_seconds": info.get("age_seconds")},
)
existing = _require_open(case_id)
if existing is not None:
actor.touch(d)
return ok(case_id=case_id, already_open=True, headless=bool(getattr(existing["bot"], "headless", False)))
def _open() -> None:
from ...infra.browser.base import BaseBot
bot = BaseBot(load_config(), headless=headless)
bot.work_dir = d
bot.screenshot_dir = d / "screenshots"
bot.launch()
actor.put_session(case_id, bot)
try:
actor.run_browser(_open)
except Exception as exc:
actor.release(d)
return fail(f"启动浏览器失败: {exc}", next_action="先执行 uv run playwright install chromium")
return ok(
case_id=case_id,
already_open=False,
headless=headless,
screenshots_dir=str(d / "screenshots"),
next_action="mcp__arb__browser_login",
)
def browser_login(case_id: str, wait_seconds: int = 25) -> dict[str, Any]:
"""登录信息门户。遇到扫码或验证码不要死等:返回 need_human,人工完成后再次调用。"""
d = _case_dir(case_id)
ctx = _require_open(case_id)
if d is None or ctx is None:
return fail("浏览器还没打开", next_action="mcp__arb__browser_open")
def _login() -> dict[str, Any]:
bot = ctx["bot"]
if bot.portal_reached():
return {"logged_in": True, "already": True}
bot.submit_login()
if bot.wait_for_portal(wait_seconds):
return {"logged_in": True, "already": False}
if bot.human_required():
return {"logged_in": False, "need_human": True, "screenshot": bot._screenshot("need_human")}
return {"logged_in": False, "timeout": True, "screenshot": bot._screenshot("login_timeout")}
try:
result = actor.run_browser(_login)
except Exception as exc:
return fail(f"登录出错: {exc}", next_action="mcp__arb__browser_page_state 看当前页面再决定")
actor.touch(d)
if result.get("need_human"):
return ok(
logged_in=False,
need_human=True,
reason="页面停在扫码或验证码,自动流程不能替你完成",
next_action="请在打开的浏览器窗口里完成扫码/验证码,然后再次调用 mcp__arb__browser_login",
screenshot=result.get("screenshot", ""),
)
if not result.get("logged_in"):
return fail(
"登录未完成",
next_action="mcp__arb__browser_page_state 确认页面;必要时 browser_close 后重开",
screenshot=result.get("screenshot", ""),
)
return ok(logged_in=True, already=result.get("already", False), next_action="mcp__arb__browser_new_form")
def browser_new_form(case_id: str, kind: str | None = None) -> dict[str, Any]:
"""进入报销系统并新建单据。kind 省略时按会话里已有的 info 推断。"""
d = _case_dir(case_id)
ctx = _require_open(case_id)
if d is None or ctx is None:
return fail("浏览器还没打开", next_action="mcp__arb__browser_open")
if kind is None:
kind, both = _infer_kind(d)
if both:
return fail(
"会话里同时有 travel_info 与 normal_info,无法判断单据类型",
next_action="显式传 kind='travel' 或 'normal'(混合发票只能走普通报销)",
)
if kind is None:
return fail(
"会话里还没有 travel_info.json / normal_info.json",
next_action="先 mcp__arb__save_info 落盘报销信息",
)
if kind not in STEP_ORDER:
return fail(f"kind 只能是 travel 或 normal,收到 {kind}")
if ctx.get("kind") and ctx["kind"] != kind:
return fail(
f"当前浏览器已经建了 {ctx['kind']} 单据",
next_action="要换类型先 mcp__arb__browser_close 再 browser_open",
)
def _new() -> dict[str, Any]:
bot = ctx["bot"]
if not bot.portal_reached():
raise RuntimeError("尚未登录信息门户")
bot.navigate_to_reimburse(page_key=PAGE_KEY[kind])
bot.create_new_form()
return {"url": bot.page.url, "screenshot": bot._screenshot(f"new_form_{kind}")}
try:
result = actor.run_browser(_new)
except RuntimeError as exc:
return fail(str(exc), next_action="mcp__arb__browser_login")
except Exception as exc:
return fail(f"新建单据失败: {exc}", next_action="mcp__arb__browser_page_state 看页面卡在哪一步")
ctx["kind"] = kind
ctx["steps"] = []
actor.touch(d, result.get("url", ""))
return ok(
kind=kind,
url=result.get("url", ""),
screenshot=result.get("screenshot", ""),
steps=STEP_ORDER[kind],
next_action="mcp__arb__browser_fill(step='basic')",
)
def browser_fill(case_id: str, step: str) -> dict[str, Any]:
"""执行一步填报。step 按 browser_new_form 返回的 steps 顺序,不能跳步。"""
d = _case_dir(case_id)
ctx = _require_open(case_id)
if d is None or ctx is None:
return fail("浏览器还没打开", next_action="mcp__arb__browser_open")
kind = ctx.get("kind")
if not kind:
return fail("还没建单据", next_action="mcp__arb__browser_new_form")
if step not in STEP_ORDER[kind]:
return fail(f"{kind} 流程没有这一步: {step}", steps=STEP_ORDER[kind])
done = list(ctx.get("steps", []))
expected = STEP_ORDER[kind]
missing = [s for s in expected[: expected.index(step)] if s not in done]
if missing:
return fail(
f"缺少前置步骤: {'、'.join(missing)}",
next_action=f"mcp__arb__browser_fill(step='{missing[0]}')",
done=done,
)
info = _load_info(d, kind)
if info is None:
return fail(f"读不到 {INFO_FILE[kind]}", next_action="先 mcp__arb__save_info 落盘报销信息")
def _fill() -> dict[str, Any]:
bot = ctx["bot"]
filled = _apply_step(bot, kind, step, info)
return {"filled": filled, "screenshot": bot._screenshot(f"{step}_done"), "url": bot.page.url}
try:
result = actor.run_browser(_fill)
except Exception as exc:
def _snap() -> str:
bot = ctx.get("bot")
return str(bot._screenshot(f"{step}_error")) if bot is not None else ""
shot = ""
try:
shot = str(actor.run_browser(_snap))
except Exception:
shot = ""
return fail(
f"{STEP_LABEL[step]}填写失败: {exc}",
next_action="mcp__arb__browser_page_state 看当前页;修正数据后用同一 step 重填",
step=step,
screenshot=shot,
)
actor.mark_step(case_id, step)
actor.touch(d, str(result.get("url", "")))
done_after = list(ctx.get("steps", []))
remaining = [s for s in expected if s not in done_after]
return ok(
step=step,
label=STEP_LABEL[step],
filled=result.get("filled"),
screenshot=result.get("screenshot", ""),
done=done_after,
remaining=remaining,
next_action=(f"mcp__arb__browser_fill(step='{remaining[0]}')" if remaining else "mcp__arb__browser_page_state"),
)
def _apply_step(bot: Any, kind: str, step: str, info: dict[str, Any]) -> dict[str, Any]:
"""把 step 映射到 infra/browser 的步骤函数,返回「填了多少」的描述。"""
from ...infra.browser import normal as normal_flow
from ...infra.browser import travel as travel_flow
if kind == "travel":
if step == "basic":
basic = info.get("basic_info", {})
travel_flow.fill_travel_info(bot, basic)
return {"字段数": len(basic)}
if step == "details":
details = info.get("reimbursement_details", {})
travel_flow.add_travel_items(bot, details)
counts = {k: len(v) for k, v in details.items() if isinstance(v, list)}
return counts if counts else {"明细": "见 travel_info"}
if step == "payment":
payments = info.get("payment_methods", [])
travel_flow.fill_travel_payment(bot, payments)
return {"支付笔数": len(payments)}
if step == "subsidy":
subsidy = info.get("subsidy_list", [])
travel_flow.fill_travel_subsidy(bot, subsidy)
return {"补助条目": len(subsidy)}
attachments = info.get("attachments", [])
travel_flow.upload_travel_attachments(bot, attachments)
return {"附件数": len(attachments)}
if step == "basic":
desc = str(info.get("basic_info", {}).get("reimbursement_description", "元器件采购报销"))
normal_flow.fill_basic_info(bot, desc)
return {"报销说明": desc}
if step == "details":
details = info.get("reimbursement_details", {})
total_invoices = int(details.get("total_invoices", 0) or 0)
total_amount = float(details.get("total_amount", 0) or 0)
normal_flow.add_normal_item(bot, total_invoices, total_amount)
return {"单据数": total_invoices, "总金额": total_amount}
if step == "payment":
payments = info.get("payment_methods", [])
normal_flow.fill_normal_payment(bot, payments)
return {"支付笔数": len(payments)}
attachments = info.get("attachments", [])
normal_flow.upload_normal_attachments(bot, attachments)
return {"附件数": len(attachments)}
def browser_page_state(case_id: str, screenshot: bool = True) -> dict[str, Any]:
"""看当前页面:URL、标题、命中的表单标记、已完成步骤、最近截图。"""
d = _case_dir(case_id)
ctx = _require_open(case_id)
if d is None or ctx is None:
return fail("浏览器还没打开", next_action="mcp__arb__browser_open")
def _state() -> dict[str, Any]:
bot = ctx["bot"]
markers = {
"基本信息页": "#EXPENEXPLAIN",
"明细页": "#insertDetail",
"支付页": "#insertPay",
"附件页": "#insertAcc",
"提交按钮": "#submit",
}
hit: dict[str, bool] = {}
for label, selector in markers.items():
try:
hit[label] = bot.page.query_selector(selector) is not None
except Exception:
hit[label] = False
shot = ""
if screenshot:
try:
shot = str(bot._screenshot("state"))
except Exception:
shot = ""
try:
title = str(bot.page.title())
except Exception:
title = ""
url_value = ""
try:
url_value = str(bot.page.url)
except Exception:
url_value = ""
return {
"url": url_value,
"title": title,
"logged_in": bool(bot.portal_reached()),
"human_required": bool(bot.human_required()),
"markers": hit,
"screenshot": shot,
}
try:
state = actor.run_browser(_state)
except Exception as exc:
return fail(f"读取页面状态失败: {exc}", next_action="mcp__arb__browser_close 后重开")
actor.touch(d, str(state.get("url", "")))
shots_dir = d / "screenshots"
shots = sorted(shots_dir.glob("*.png")) if shots_dir.exists() else []
return ok(
kind=ctx.get("kind"),
steps_done=list(ctx.get("steps", [])),
recent_screenshots=[str(s) for s in shots[-5:]],
**state,
)
def browser_close(case_id: str) -> dict[str, Any]:
"""关闭浏览器并释放锁。不会提交也不会存草稿,填了一半的内容会丢。"""
d = _case_dir(case_id)
if d is None:
return fail(f"会话不存在: {case_id}")
ctx = _require_open(case_id)
if ctx is None:
actor.release(d)
return ok(closed=False, note="该会话本来就没有浏览器")
elapsed = round(time.time() - float(ctx.get("opened_at", time.time())), 1)
steps = list(ctx.get("steps", []))
kind = ctx.get("kind")
def _close() -> None:
bot = ctx.get("bot")
if bot is not None:
bot.close()
try:
actor.run_browser(_close)
except Exception as exc:
log.warning("关闭浏览器时出错 %s: %s", case_id, exc)
actor.drop_session(case_id)
actor.release(d)
return ok(
closed=True,
kind=kind,
steps_done=steps,
open_seconds=elapsed,
next_action="提交还没实现(P4);要回看填了什么用 browser_page_state",
)
def browser_steps(case_id: str) -> dict[str, Any]:
"""列出该会话单据类型的步骤顺序与含义(不要求浏览器已打开)。"""
d = _case_dir(case_id)
if d is None:
return fail(f"会话不存在: {case_id}")
kind, both = _infer_kind(d)
ctx = _require_open(case_id)
if ctx is not None and ctx.get("kind"):
kind = ctx["kind"]
if both and not (ctx is not None and ctx.get("kind")):
return ok(kinds=list(STEP_ORDER), note="会话里 travel_info 与 normal_info 同时存在,建单时要显式传 kind")
if kind is None:
return ok(
kinds=list(STEP_ORDER),
note="还没落 info,无法确定类型;两种流程的步骤如下",
steps={k: [{"step": s, "label": STEP_LABEL[s]} for s in v] for k, v in STEP_ORDER.items()},
)
return ok(
kind=kind,
steps=[{"step": s, "label": STEP_LABEL[s]} for s in STEP_ORDER[kind]],
done=list((ctx or {}).get("steps", [])),
)