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:
@@ -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", [])),
|
||||
)
|
||||
Reference in New Issue
Block a user