autotrain/nextgen/autotrain_next/planner.py
2026-07-24 10:37:32 +08:00

128 lines
6.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""确定性优先的任务规划;仅在无法分类页面时才请求可选 LLM。"""
from __future__ import annotations
import json
import os
import base64
import urllib.request
from dataclasses import dataclass
from .tools import REGISTRY
class PlanError(RuntimeError):
pass
@dataclass(frozen=True)
class Step:
tool: str
arguments: dict
reason: str
def classify(texts: list[str]) -> str:
content = "\n".join(texts)
if "证件号码后8位" in content or "请输入乘车人" in content:
return "verify"
if "发票信息确认" in content:
return "invoice_confirm"
if "发票开具成功" in content:
return "success"
if "发票信息" in content and ("提交" in content or "确认" in content):
return "invoice_info"
if "没有可开具电子发票" in content:
return "no_ticket"
if "开具" in content and "*" in content:
return "list"
return "unknown"
def deterministic_plan(task: dict) -> list[Step]:
history = task.get("history", [])
navigation = task.get("navigation", {})
phase = navigation.get("phase")
if phase in {"push_qr", "open_scan", "open_orders", "open_album", "select_qr", "wait_list"}:
if phase not in {"push_qr", "wait_list"} and not task.get("allow_submit"):
return []
return [Step(f"nav.{phase}", {}, "进入扫码开票单列表")]
if task.get("invoice", {}).get("finished"):
return []
if not history:
return [Step("device.status", {}, "确认唯一目标设备"), Step("observe.screen", {}, "保存当前屏幕"), Step("observe.xml", {}, "读取可访问 UI 文本")]
if history[-1].get("tool") not in {"observe.xml", "invoice.inspect"}:
return [Step("observe.screen", {}, "动作后保存当前屏幕"), Step("observe.xml", {}, "动作后重新读取 UI 文本")]
last = next((entry for entry in reversed(history) if entry.get("tool") == "observe.xml"), None)
page = classify(last.get("result", {}).get("texts", [])) if last else "other"
if page in {"other", "unknown"} and history[-1].get("tool") == "invoice.inspect":
page = str(history[-1].get("result", {}).get("page", "other"))
if page in {"other", "unknown"}:
return []
if page in {"other", "unknown"}:
return [Step("invoice.inspect", {}, "XML 不足时由视觉识别页面")]
if page == "loading":
return [Step("invoice.wait", {"seconds": 2}, "等待开票网络请求完成")]
if not task.get("allow_submit"):
return []
routes = {
"list": ("invoice.open_next", "匹配名单并打开下一位乘客"),
"verify": ("invoice.verify", "填写证件后八位并核验"),
"invoice_info": ("invoice.submit_info", "提交发票信息"),
"invoice_confirm": ("invoice.confirm", "确认开具"),
"no_ticket": ("invoice.no_ticket", "记录无票并返回列表"),
}
if page == "success":
action = task.get("invoice", {}).get("success_action", "download")
return [Step("invoice.email", {}, "发送发票邮件并继续开票")] if action == "email" else [Step("invoice.download", {}, "下载发票、归档并继续开票")]
if page in routes:
tool, reason = routes[page]
return [Step(tool, {}, reason)]
return []
def validate_llm_steps(raw: object, *, allow_submit: bool) -> list[Step]:
if not isinstance(raw, list):
raise PlanError("LLM 返回必须是 steps 数组")
if len(raw) > 3:
raise PlanError("LLM 单次最多重规划 3 个原子工具")
steps: list[Step] = []
for item in raw:
if not isinstance(item, dict):
raise PlanError("LLM step 必须是对象")
tool = item.get("tool")
if tool not in REGISTRY:
raise PlanError(f"LLM 请求了未注册工具:{tool}")
if tool in {"ui.tap_normalized", "invoice.open_next", "invoice.verify", "invoice.submit_info", "invoice.confirm", "invoice.download", "invoice.email", "invoice.no_ticket", "nav.open_scan", "nav.open_orders", "nav.open_album", "nav.select_qr"} and not allow_submit:
raise PlanError("默认禁止 LLM 直接点击业务按钮;请先在任务中显式启用 allow_submit")
arguments = item.get("arguments", {})
if not isinstance(arguments, dict):
raise PlanError("工具参数必须是对象")
steps.append(Step(tool, arguments, str(item.get("reason") or "LLM 重规划")))
return steps
def llm_plan(task: dict, settings: dict) -> list[Step]:
planner = settings.get("planner", {})
if not planner.get("enabled"):
return []
base_url, model = str(planner.get("base_url", "")).rstrip("/"), str(planner.get("model", ""))
api_key = os.environ.get(str(planner.get("api_key_env", "AUTOTRAIN_NEXT_LLM_API_KEY")), "")
if not (base_url and model and api_key):
raise PlanError("LLM 已启用但 base_url、model 或 API key 环境变量缺失")
observations = [entry.get("result", {}) for entry in task.get("history", []) if entry.get("tool") == "observe.screen"]
safe_task = {"type": task.get("type"), "allow_submit": bool(task.get("allow_submit")), "page": task.get("invoice", {}).get("page"), "recent_tools": [entry.get("tool") for entry in task.get("history", [])[-6:]]}
content: list[dict] = [{"type": "text", "text": json.dumps({"task": safe_task, "tools": list(REGISTRY)}, ensure_ascii=False)}]
if observations and observations[-1].get("screenshot"):
try:
image = open(observations[-1]["screenshot"], "rb").read()
content.append({"type": "image_url", "image_url": {"url": "data:image/png;base64," + base64.b64encode(image).decode()}})
except OSError:
pass
payload = {"model": model, "temperature": 0, "messages": [{"role": "system", "content": "你是手机自动化规划器。只返回 JSON{\\\"steps\\\":[{\\\"tool\\\":...,\\\"arguments\\\":{},\\\"reason\\\":...}]}。不可编造工具。"}, {"role": "user", "content": content}]}
request = urllib.request.Request(base_url + "/chat/completions", data=json.dumps(payload).encode(), headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"})
with urllib.request.urlopen(request, timeout=45) as response:
body = json.loads(response.read())
content = body["choices"][0]["message"]["content"]
parsed = json.loads(content)
return validate_llm_steps(parsed.get("steps"), allow_submit=bool(task.get("allow_submit")))