199 lines
7.7 KiB
Python
199 lines
7.7 KiB
Python
"""原子工具注册表。编排器与 LLM 都只能从这里选择动作。"""
|
||
from __future__ import annotations
|
||
|
||
import xml.etree.ElementTree as ET
|
||
import re
|
||
import time
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
from typing import Callable
|
||
|
||
from . import device
|
||
from .paths import WORK, ensure_workdirs
|
||
from .profiles import normalized_point
|
||
|
||
|
||
class ToolError(RuntimeError):
|
||
pass
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ToolSpec:
|
||
name: str
|
||
handler: Callable[[dict, dict], dict]
|
||
requires_submit_permission: bool = False
|
||
|
||
|
||
def _serial(context: dict) -> str:
|
||
serial = str(context.get("device_serial") or "")
|
||
return device.selected_device(serial).serial
|
||
|
||
|
||
def device_status(_: dict, context: dict) -> dict:
|
||
serial = _serial(context)
|
||
return {"device": device.fingerprint(serial)}
|
||
|
||
|
||
def observe_screen(_: dict, context: dict) -> dict:
|
||
serial = _serial(context)
|
||
ensure_workdirs()
|
||
task_id = re.sub(r"[^A-Za-z0-9_.-]+", "_", str(context.get("id") or serial))
|
||
target = WORK / "screens" / task_id / f"{time.time_ns()}.png"
|
||
target.parent.mkdir(parents=True, exist_ok=True)
|
||
target.write_bytes(device.run(serial, ["exec-out", "screencap", "-p"], binary=True).stdout)
|
||
return {"screenshot": str(target), "screen": device.screen_size(serial)}
|
||
|
||
|
||
def observe_xml(_: dict, context: dict) -> dict:
|
||
serial = _serial(context)
|
||
remote = "/sdcard/autotrain-next-ui.xml"
|
||
device.run(serial, ["shell", "uiautomator", "dump", remote])
|
||
xml = device.run(serial, ["exec-out", "cat", remote]).stdout
|
||
if isinstance(xml, bytes):
|
||
xml = xml.decode("utf-8", "ignore")
|
||
try:
|
||
root = ET.fromstring(xml)
|
||
except ET.ParseError:
|
||
return {"xml_available": False, "texts": []}
|
||
texts: list[str] = []
|
||
for node in root.iter("node"):
|
||
for key in ("text", "content-desc"):
|
||
value = (node.attrib.get(key) or "").strip()
|
||
if value and value not in texts:
|
||
texts.append(value)
|
||
return {"xml_available": True, "texts": texts[:80]}
|
||
|
||
|
||
def tap_normalized(arguments: dict, context: dict) -> dict:
|
||
point = arguments.get("point")
|
||
if not isinstance(point, list) or len(point) != 2:
|
||
raise ToolError("ui.tap_normalized 需要 point: [x, y]")
|
||
serial = _serial(context)
|
||
x, y = normalized_point((float(point[0]), float(point[1])), device.screen_size(serial))
|
||
device.run(serial, ["shell", "input", "tap", str(x), str(y)])
|
||
return {"tap": {"normalized": point, "pixel": [x, y]}}
|
||
|
||
|
||
def swipe_normalized(arguments: dict, context: dict) -> dict:
|
||
start, end = arguments.get("from"), arguments.get("to")
|
||
if not all(isinstance(value, list) and len(value) == 2 for value in (start, end)):
|
||
raise ToolError("ui.swipe_normalized 需要 from 和 to")
|
||
serial = _serial(context)
|
||
screen = device.screen_size(serial)
|
||
x1, y1 = normalized_point((float(start[0]), float(start[1])), screen)
|
||
x2, y2 = normalized_point((float(end[0]), float(end[1])), screen)
|
||
duration = max(50, min(int(arguments.get("duration_ms", 350)), 3000))
|
||
device.run(serial, ["shell", "input", "swipe", str(x1), str(y1), str(x2), str(y2), str(duration)])
|
||
return {"swipe": {"from": [x1, y1], "to": [x2, y2], "duration_ms": duration}}
|
||
|
||
|
||
def input_text(arguments: dict, context: dict) -> dict:
|
||
value = str(arguments.get("text") or "").upper()
|
||
if not re.fullmatch(r"[0-9]{7}[0-9X]", value):
|
||
raise ToolError("ui.input_id_suffix 只接受身份证后八位的数字或 X,避免 ADB shell 转义风险")
|
||
device.run(_serial(context), ["shell", "input", "text", value])
|
||
return {"length": len(value)}
|
||
|
||
|
||
def back(_: dict, context: dict) -> dict:
|
||
device.run(_serial(context), ["shell", "input", "keyevent", "4"])
|
||
return {"keyevent": "BACK"}
|
||
|
||
|
||
def invoice_inspect(arguments: dict, context: dict) -> dict:
|
||
from .invoice_tools import inspect_page
|
||
return inspect_page(arguments, context)
|
||
|
||
|
||
def invoice_open_next(arguments: dict, context: dict) -> dict:
|
||
from .invoice_tools import open_next
|
||
return open_next(arguments, context)
|
||
|
||
|
||
def invoice_verify(arguments: dict, context: dict) -> dict:
|
||
from .invoice_tools import verify
|
||
return verify(arguments, context)
|
||
|
||
|
||
def invoice_submit_info(arguments: dict, context: dict) -> dict:
|
||
from .invoice_tools import submit_info
|
||
return submit_info(arguments, context)
|
||
|
||
|
||
def invoice_confirm(arguments: dict, context: dict) -> dict:
|
||
from .invoice_tools import confirm
|
||
return confirm(arguments, context)
|
||
|
||
|
||
def invoice_download(arguments: dict, context: dict) -> dict:
|
||
from .invoice_tools import download_and_continue
|
||
return download_and_continue(arguments, context)
|
||
|
||
|
||
def invoice_email(arguments: dict, context: dict) -> dict:
|
||
from .invoice_tools import send_email_and_continue
|
||
return send_email_and_continue(arguments, context)
|
||
|
||
|
||
def invoice_no_ticket(arguments: dict, context: dict) -> dict:
|
||
from .invoice_tools import mark_no_ticket
|
||
return mark_no_ticket(arguments, context)
|
||
|
||
|
||
def invoice_wait(arguments: dict, context: dict) -> dict:
|
||
from .invoice_tools import wait_loading
|
||
return wait_loading(arguments, context)
|
||
|
||
|
||
def nav_push_qr(arguments: dict, context: dict) -> dict:
|
||
from .navigation import push_qr
|
||
return push_qr(arguments, context)
|
||
|
||
|
||
def nav_open_scan(arguments: dict, context: dict) -> dict:
|
||
from .navigation import open_scan
|
||
return open_scan(arguments, context)
|
||
|
||
|
||
def nav_open_orders(arguments: dict, context: dict) -> dict:
|
||
from .navigation import open_orders
|
||
return open_orders(arguments, context)
|
||
|
||
|
||
def nav_open_album(arguments: dict, context: dict) -> dict:
|
||
from .navigation import open_album
|
||
return open_album(arguments, context)
|
||
|
||
|
||
def nav_select_qr(arguments: dict, context: dict) -> dict:
|
||
from .navigation import select_qr
|
||
return select_qr(arguments, context)
|
||
|
||
|
||
def nav_wait_list(arguments: dict, context: dict) -> dict:
|
||
from .navigation import wait_list
|
||
return wait_list(arguments, context)
|
||
|
||
|
||
REGISTRY = {
|
||
spec.name: spec for spec in (
|
||
ToolSpec("device.status", device_status), ToolSpec("observe.screen", observe_screen),
|
||
ToolSpec("observe.xml", observe_xml), ToolSpec("ui.tap_normalized", tap_normalized),
|
||
ToolSpec("ui.swipe_normalized", swipe_normalized), ToolSpec("ui.input_digits", input_text), ToolSpec("ui.back", back),
|
||
ToolSpec("invoice.inspect", invoice_inspect), ToolSpec("invoice.open_next", invoice_open_next, requires_submit_permission=True),
|
||
ToolSpec("invoice.verify", invoice_verify, requires_submit_permission=True), ToolSpec("invoice.submit_info", invoice_submit_info, requires_submit_permission=True),
|
||
ToolSpec("invoice.confirm", invoice_confirm, requires_submit_permission=True), ToolSpec("invoice.download", invoice_download, requires_submit_permission=True), ToolSpec("invoice.email", invoice_email, requires_submit_permission=True),
|
||
ToolSpec("invoice.no_ticket", invoice_no_ticket, requires_submit_permission=True), ToolSpec("invoice.wait", invoice_wait),
|
||
ToolSpec("nav.push_qr", nav_push_qr), ToolSpec("nav.open_scan", nav_open_scan, requires_submit_permission=True), ToolSpec("nav.open_orders", nav_open_orders, requires_submit_permission=True), ToolSpec("nav.open_album", nav_open_album, requires_submit_permission=True), ToolSpec("nav.select_qr", nav_select_qr, requires_submit_permission=True), ToolSpec("nav.wait_list", nav_wait_list),
|
||
)
|
||
}
|
||
|
||
|
||
def execute(name: str, arguments: dict, context: dict) -> dict:
|
||
spec = REGISTRY.get(name)
|
||
if not spec:
|
||
raise ToolError(f"不允许的工具:{name}")
|
||
if spec.requires_submit_permission and not context.get("allow_submit"):
|
||
raise ToolError(f"{name} 可能触发开票,任务未启用 allow_submit")
|
||
return spec.handler(arguments, context)
|