feat(mcp): P3 联机验证通过,新增 browser_open_form 断点续填,修 4 个联机才暴露的真 bug
联机跑通 open→login→new_form→fill(basic)→page_state→close:财务系统真的落库(单据编号 202610020850,
basic 页点「下一步」即保存)。据此补上续填能力并把测试草稿清理干净(删除前后截图 + 复查 0 命中)。
- 新增第 25 个工具 browser_open_form:列表页 #seacher 定位 → 唯一命中 → 行内单选 → #update 修改,
读回 #EXPENEXPLAIN 证明数据还在;preview_only 只定位不点开并回显 bill_no/state/amount;
命中多张直接拒绝并回 candidates,不替用户猜单据
- 修 4 个真 bug(都是单元测试看不见的):
1) navigate_to_reimburse 单点硬等 5 秒偶发超时 → 全程轮询 + 失败带页面文字与截图;
并查明直达报销 URL 不建立会话(会被踢「长时间未操作,请重新登录!」),门户点击链是必经
2) actor.close_all 用 atexit 注册无效:concurrent.futures.thread 的钩子走 threading._register_atexit,
比 atexit 早跑,executor 已 shutdown → close 提交不进去。改为注册到同一列表,实测 close
在 playwright_0 线程执行,退出不再漏浏览器
3) wait_rows_loaded 只认 <a>,真实「查看」是 <button>,每次白等满 20 秒(也拖慢了测试套件)
4) 空结果「没有找到匹配的记录」被当成加载中,搜不到时再白等 20 秒
- browser_page_state 语义修正:logged_in 判门户 URL,进报销系统后必然 false 会误导 → 拆成
at_portal / in_reimburse_system / on_form_page
- case_status 补齐 info_both / screenshots / browser(锁持有者与心跳年龄)/ warnings(混合 info、
陈旧抽取、他人持锁);.browser.lock 文件名由 cases.py 单一定义,actor 反向引用
- 列表页结构实测写进 AGENTS:btSelectItem 单选、#insert/#update/#delete、行内「查看」是 gzlc 流程图、
可见搜索框是 #seacher 而 #expenexplain 在收起面板(fill 会卡 30 秒)
- 测试:FakeBot 绑 BaseBot 真实行逻辑,新增 5 例续填用例(唯一命中/歧义拒绝/未命中/preview/空关键词),
134 passed 0.36s;文档 docs/MCP.md 工具参考按 wire schema 重生成(25 工具),SKILL 增「断了不要重来,
用 browser_open_form 续填」与破坏性操作纪律;方案文档 §13.5 记录联机结果、4 个 bug 与未完成项
验证:mcp_smoke 25 工具 / ruff / format / mypy / deptry / pytest 134 passed 全绿;
联机时间线见方案文档 §13.5。仍用 --no-verify 提交并手跑等价检查(原因见 AGENTS 提交纪律)。
This commit is contained in:
@@ -320,7 +320,10 @@ def _apply_step(bot: Any, kind: str, step: str, info: dict[str, Any]) -> dict[st
|
||||
|
||||
|
||||
def browser_page_state(case_id: str, screenshot: bool = True) -> dict[str, Any]:
|
||||
"""看当前页面:URL、标题、命中的表单标记、已完成步骤、最近截图。"""
|
||||
"""看当前页面:URL、标题、命中的表单标记、已完成步骤、最近截图。
|
||||
|
||||
at_portal 只表示在信息门户;进了报销系统要用 in_reimburse_system 与 on_form_page 判断。
|
||||
"""
|
||||
d = _case_dir(case_id)
|
||||
ctx = _require_open(case_id)
|
||||
if d is None or ctx is None:
|
||||
@@ -356,10 +359,15 @@ def browser_page_state(case_id: str, screenshot: bool = True) -> dict[str, Any]:
|
||||
url_value = str(bot.page.url)
|
||||
except Exception:
|
||||
url_value = ""
|
||||
# 进了报销系统后 URL 就不再带门户标记,所以分开报,别再用一个 logged_in 混起来
|
||||
reimburse = str(getattr(bot, "config", {}).get("reimburse_url", "") or "")
|
||||
host = reimburse.split("//")[-1].split("/")[0]
|
||||
return {
|
||||
"url": url_value,
|
||||
"title": title,
|
||||
"logged_in": bool(bot.portal_reached()),
|
||||
"at_portal": bool(bot.portal_reached()),
|
||||
"in_reimburse_system": bool(host) and host in url_value,
|
||||
"on_form_page": any(hit.values()),
|
||||
"human_required": bool(bot.human_required()),
|
||||
"markers": hit,
|
||||
"screenshot": shot,
|
||||
@@ -413,6 +421,94 @@ def browser_close(case_id: str) -> dict[str, Any]:
|
||||
)
|
||||
|
||||
|
||||
def browser_open_form(
|
||||
case_id: str,
|
||||
match: str,
|
||||
kind: str | None = None,
|
||||
preview_only: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""从列表页找回已存在的单据接着填(中途断了不必重来)。
|
||||
|
||||
match 用报销说明里的关键词,必须在当前列表页唯一命中;命中多张直接失败,
|
||||
让宿主把关键词收紧——财务系统里点错别人的单据是不能接受的后果。preview_only=true
|
||||
只搜索定位、不点开单据,用来先确认是哪一张。
|
||||
"""
|
||||
d = _case_dir(case_id)
|
||||
ctx = _require_open(case_id)
|
||||
if d is None or ctx is None:
|
||||
return fail("浏览器还没打开", next_action="mcp__arb__browser_open")
|
||||
if not match or not match.strip():
|
||||
return fail("match 不能为空", next_action="传报销说明里的一段关键词,要能唯一命中")
|
||||
if kind is None:
|
||||
kind, both = _infer_kind(d)
|
||||
if both or kind is None:
|
||||
if ctx.get("kind"):
|
||||
kind = ctx["kind"]
|
||||
else:
|
||||
return fail(
|
||||
"无法确定单据类型",
|
||||
next_action="显式传 kind='travel' 或 'normal'",
|
||||
)
|
||||
if kind not in STEP_ORDER:
|
||||
return fail(f"kind 只能是 travel 或 normal,收到 {kind}")
|
||||
|
||||
def _resume() -> dict[str, Any]:
|
||||
bot = ctx["bot"]
|
||||
if not bot.portal_reached() and "expen" not in (bot.page.url or ""):
|
||||
raise RuntimeError("尚未登录信息门户")
|
||||
bot.navigate_to_reimburse(page_key=PAGE_KEY[kind])
|
||||
rows = bot.search_forms(match)
|
||||
opened: dict[str, Any] = bot.open_form(match, dry_run=preview_only)
|
||||
opened["rows_after_search"] = rows
|
||||
opened["sample"] = bot.list_forms(4)
|
||||
return opened
|
||||
|
||||
try:
|
||||
result = actor.run_browser(_resume)
|
||||
except RuntimeError as exc:
|
||||
return fail(str(exc), next_action="mcp__arb__browser_login")
|
||||
except Exception as exc:
|
||||
return fail(f"找回单据失败: {exc}", next_action="mcp__arb__browser_page_state 看页面现在在哪")
|
||||
actor.touch(d, str(result.get("url", "")))
|
||||
if not result.get("found"):
|
||||
return fail(
|
||||
f"列表页没有匹配「{match}」的单据(搜索后 {result.get('rows_after_search')} 行)",
|
||||
next_action="收紧或更换 match;单据区间下拉默认只看近三个月,更早的单据要先把区间改掉",
|
||||
sample=result.get("sample", []),
|
||||
)
|
||||
if result.get("ambiguous"):
|
||||
return fail(
|
||||
f"关键字「{match}」命中 {result.get('found')} 张单据,不替你选",
|
||||
next_action="把 match 收紧到能唯一命中(报销说明全文或单据编号)",
|
||||
candidates=result.get("candidates", []),
|
||||
)
|
||||
if preview_only:
|
||||
return ok(
|
||||
preview=True,
|
||||
kind=kind,
|
||||
bill_no=result.get("bill_no", ""),
|
||||
state=result.get("state", ""),
|
||||
amount=result.get("amount", ""),
|
||||
text=result.get("text", ""),
|
||||
next_action="确认单据编号无误后去掉 preview_only 再调一次",
|
||||
)
|
||||
ctx["kind"] = kind
|
||||
ctx["resumed"] = True
|
||||
return ok(
|
||||
kind=kind,
|
||||
resumed=True,
|
||||
bill_no=result.get("bill_no", ""),
|
||||
state=result.get("state", ""),
|
||||
amount=result.get("amount", ""),
|
||||
url=result.get("url", ""),
|
||||
description=result.get("description"),
|
||||
form_markers=result.get("form_markers", {}),
|
||||
screenshot=result.get("screenshot", ""),
|
||||
steps=STEP_ORDER[kind],
|
||||
next_action="mcp__arb__browser_page_state 看已填到哪一步,再从该步继续 browser_fill(重填同一字段是覆盖)",
|
||||
)
|
||||
|
||||
|
||||
def browser_steps(case_id: str) -> dict[str, Any]:
|
||||
"""列出该会话单据类型的步骤顺序与含义(不要求浏览器已打开)。"""
|
||||
d = _case_dir(case_id)
|
||||
|
||||
Reference in New Issue
Block a user