ceefe3c4eb
联机跑通 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 提交纪律)。
534 lines
21 KiB
Python
534 lines
21 KiB
Python
"""分步浏览器填报工具
|
|
|
|
取代旧的「一把梭」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、标题、命中的表单标记、已完成步骤、最近截图。
|
|
|
|
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:
|
|
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 = ""
|
|
# 进了报销系统后 URL 就不再带门户标记,所以分开报,别再用一个 logged_in 混起来
|
|
reimburse = str(getattr(bot, "config", {}).get("reimburse_url", "") or "")
|
|
host = reimburse.split("//")[-1].split("/")[0]
|
|
return {
|
|
"url": url_value,
|
|
"title": title,
|
|
"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,
|
|
}
|
|
|
|
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_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)
|
|
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", [])),
|
|
)
|