"""确定性优先的任务规划;仅在无法分类页面时才请求可选 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")))