"""编排层:意图路由、资料收集、角色准入、多步串行执行、暂停/续跑、前置阻断、进度文档。 状态流转: collecting -> running -> paused(等用户回答 / 前置阻断) -> running -> submitted | completed \\-> failed \\-> blocked(被前置事项拦住,处理好可继续) 知识结构(knowledge/*.yaml): systems 系统列表(入口、允许域名、登录方式) businesses 业务列表,每个业务有 role / required_inputs / workflow(多步,按顺序执行)/ learned learned 全局已知信息(门户结构等),与业务 learned 一起注入 prompt 身份模型:一个人在一个系统只有一个身份(密码本 system_accounts 按 人 × 系统 存,带 role); 业务的 role 是准入条件;跨系统任务把各系统凭据按域名分组放进 sensitive_data,一个会话依次登录。 """ import asyncio import re from datetime import date from typing import Any from backend import hub_sync from backend.browser import runner from backend.config import PROFILES_DIR, settings from backend.db import database as db from backend.learning import draft_learned from backend.nlu import classify_and_extract from backend.orchestrator.progress_doc import append_progress, init_progress_doc, write_summary from backend.security import decrypt_secret COLLECTING = "collecting" RUNNING = "running" PAUSED = "paused" SUBMITTED = "submitted" COMPLETED = "completed" FAILED = "failed" BLOCKED = "blocked" # 被前置事项拦住,用户处理好后可点「继续」重试 REJECTED = "rejected" # 提交后被驳回 / 退回 TERMINAL = {SUBMITTED, COMPLETED, FAILED, BLOCKED, REJECTED} _APPROVED_WORDS = ("通过", "同意", "已审批", "审批完成", "办结", "已完成", "批准", "已生效", "归档") _REJECTED_WORDS = ("驳回", "退回", "拒绝", "不通过", "未通过", "否决", "作废", "撤销") _PENDING_WORDS = ("待审批", "未审批", "审批中", "待审核", "审核中", "处理中", "待处理", "已提交") def _classify_approval(text: str) -> str: """把页面上的审批状态原文归为 approved / rejected / pending / unknown。驳回词优先,避免「审批不通过」被当成通过。""" t = (text or "").strip() if not t: return "unknown" if any(w in t for w in _REJECTED_WORDS): return "rejected" if any(w in t for w in _PENDING_WORDS): return "pending" if any(w in t for w in _APPROVED_WORDS): return "approved" return "unknown" INTERNAL_INPUT_KEYS = {"goal", "business_id", "user_notes", "pending_blocker", "step_index"} # 多字词出现在回答任何位置都算同意;单字词只在开头才算,否则「课程是高等数学」里的「是」会被当成同意 _AFFIRMATIVE = ("可以", "同意", "确认", "已处理", "处理好", "处理完", "继续", "没问题", "麻烦你", "ok", "yes") _AFFIRMATIVE_PREFIX = ("是", "好", "行", "办", "去", "嗯", "对") _NEGATIVE = ("否", "不", "别", "取消", "算了", "先不", "不用", "no") def _is_negative(answer: str) -> bool: a = answer.strip().lower() return any(a.startswith(w) or w in a[:6] for w in _NEGATIVE) and not any(a.startswith(w) for w in ("不错", "不用担心")) def _is_affirmative(answer: str) -> bool: a = answer.strip().lower() if _is_negative(a): return False return any(w in a for w in _AFFIRMATIVE) or any(a.startswith(w) for w in _AFFIRMATIVE_PREFIX) # ---------- 知识访问 ---------- class _SafeDict(dict): def __missing__(self, key: str) -> str: return "{" + key + "}" def _render(template: str, values: dict) -> str: try: return str(template).format_map(_SafeDict(values)) except Exception: # noqa: BLE001 return str(template) def _businesses(knowledge: dict) -> list[dict]: return knowledge.get("businesses") or [] def _business_by_id(knowledge: dict, business_id: str | None) -> dict | None: return next((b for b in _businesses(knowledge) if b.get("id") == business_id), None) def _fallback_business(knowledge: dict) -> dict: businesses = _businesses(knowledge) return next((b for b in businesses if b.get("id") == "generic_explore"), businesses[0]) def _required_inputs(business: dict) -> list[dict]: return business.get("required_inputs") or [] def _missing_inputs(task: dict, business: dict) -> list[dict]: inputs = task.get("inputs") or {} return [f for f in _required_inputs(business) if f.get("required") and not inputs.get(f["name"])] def _missing_labels(missing: list[dict]) -> str: return "、".join(m["label"] for m in missing) def _enabled_steps(business: dict) -> list[dict]: return [s for s in business.get("workflow") or [] if s.get("enabled", True)] def _active_step(business: dict) -> dict | None: steps = _enabled_steps(business) return steps[0] if steps else None def _current_step(business: dict, inputs: dict) -> dict | None: steps = _enabled_steps(business) if not steps: return None idx = min(int((inputs or {}).get("step_index", 0) or 0), len(steps) - 1) return steps[idx] def _system_by_id(knowledge: dict, system_id: str | None) -> dict: systems = knowledge.get("systems") or [] return next((s for s in systems if s.get("id") == system_id), systems[0]) def _system_for(business: dict, knowledge: dict, step: dict | None = None) -> dict: wanted = (step or {}).get("system") or business.get("system") return _system_by_id(knowledge, wanted) def _systems_for_business(business: dict, knowledge: dict) -> list[dict]: seen: dict[str, dict] = {} for step in _enabled_steps(business) or [{}]: system = _system_for(business, knowledge, step) seen.setdefault(system.get("id", ""), system) if not seen: system = _system_for(business, knowledge) seen[system.get("id", "")] = system return list(seen.values()) def _profile_path(student_id: str, system_id: str, role: str | None) -> str: """一个人在一个系统一份登录态。测试环境同一系统会切不同角色的快捷登录,所以再按角色分开。""" path = PROFILES_DIR / student_id / f"{system_id}_{role or 'default'}" path.mkdir(parents=True, exist_ok=True) return str(path) _PLACEHOLDER_PREFIXES = ("未获取", "未找到", "未能", "无法", "失败", "没有", "不存在", "暂无", "n/a", "none", "null") def _meaningful(value: Any) -> bool: """产物里“未获取 / 无法读取”这类占位文字不算拿到了信息。""" text = str(value or "").strip().lower() return bool(text) and not text.startswith(_PLACEHOLDER_PREFIXES) def _criteria_met(step: dict, artifacts: dict) -> bool: keys = step.get("success_artifacts_any") or [] return any(_meaningful(artifacts.get(k)) for k in keys) def apply_verdict_overrides( verdict: str, agent_done: bool, step: dict, artifacts: dict, summary: str ) -> tuple[str, str, tuple[str, str] | None]: """Agent 自评失败,但实际结果不该算失败的两种情况。返回 (verdict, summary, 进度文档条目)。 Agent 的 is_successful 不稳定:同一个「用户点了取消」动作,它有时给 True、有时给 False, 于是同样的操作一次记成已完成、一次记成办理未完成。这里按客观产物纠正。 """ if verdict != "failed" or not agent_done: return verdict, summary, None if _meaningful(artifacts.get("cancelled")): # 用户在确认卡点了取消:按用户意愿结束,不是办砸了 return "success", summary, ("用户取消", "用户选择不提交,按已完成处理") if _criteria_met(step, artifacts): return ( "success", "(Agent 自评未完全达成,但产物已满足本步完成判据)\n" + summary, ("判定覆盖", "产物满足 success_artifacts_any,按完成处理"), ) return verdict, summary, None def _final_status(steps: list[dict], artifacts: dict) -> str: """全部步骤成功后的终态:有提交类步骤且真的提交了才是 submitted;用户取消优先。""" if artifacts.get("cancelled"): return COMPLETED for step in steps: if step.get("on_success_status") == SUBMITTED: keys = step.get("submitted_artifacts_any") or ["submitted_record", "result_message"] if any(artifacts.get(k) for k in keys): return SUBMITTED return COMPLETED def _tracking_cfg(business: dict) -> dict | None: cfg = business.get("tracking") if not cfg or not cfg.get("step"): return None return { "interval_minutes": int(cfg.get("interval_minutes", 60)), "max_checks": int(cfg.get("max_checks", 24)), "step": {**cfg["step"], "step_id": cfg["step"].get("step_id", "track_status")}, } def _next_check_str(minutes: int) -> str: from datetime import datetime, timedelta return (datetime.now() + timedelta(minutes=max(1, minutes))).strftime("%Y-%m-%d %H:%M:%S") def _prerequisite(step: dict, prereq_id: str) -> dict | None: for p in step.get("prerequisites") or []: if str(p.get("id")) == prereq_id: return p return None def _blocker_question(prereq: dict | None, message: str) -> str: if not prereq: return f"系统提示:{message}\n这不在我知道的前置事项里。你处理好之后回复「已处理」我再重试,或回复「取消」结束。" title = prereq.get("title") or prereq.get("id") if prereq.get("resolution") == "self" and prereq.get("business_id"): return ( f"系统提示:{message}\n需要先完成「{title}」。要我现在顺带帮你办吗?" "回复「是」我就去办,办完自动回来继续;回复「否」则先结束。" ) advice = prereq.get("advice") or f"先完成「{title}」" return f"系统提示:{message}\n这一步需要{advice},我在系统里替你办不了。处理好之后回复「已处理」我再继续,或回复「取消」结束。" # ---------- 提示词 ---------- def _placeholders(system_id: str) -> tuple[str, str]: return f"x_username_{system_id}", f"x_password_{system_id}" def _login_instructions(system: dict, account: dict | None, role: str | None) -> str: login = system.get("login") or {} role_note = ( f"如果打开后页面已处于登录状态但身份不是「{role}」,先点击右上角「退出」回到登录页再登录。" if role else "" ) if account: u, p = _placeholders(system.get("id", "")) return ( f"系统已为你准备好「{system.get('name', '')}」的账号。在它的登录页:用户名框填 {u},密码框填 {p},然后点击登录。" "不要向用户询问账号密码,也不要把账号密码写进任何总结。" + role_note ) mode = login.get("fallback_mode", "manual") if mode == "quick_login" and role: return ( f"这是测试环境。登录页有「测试环境快捷登录」区域,点击「{role}」或「以{role}身份快捷登录」按钮即可登录," "无需输入账号密码。" + role_note ) return "如遇登录页、验证码、扫码:调用 ask_user 请用户手动完成,用户回答后再继续。" def _inputs_block(business: dict, inputs: dict) -> str: lines = [f"- 用户原话:{inputs.get('goal', '')}"] for f in _required_inputs(business): v = inputs.get(f["name"]) if v: lines.append(f"- {f['label']}:{v}") for k, v in inputs.items(): if k not in INTERNAL_INPUT_KEYS and v and not any(f["name"] == k for f in _required_inputs(business)): lines.append(f"- {k}:{v}") return "\n".join(lines) def _artifacts_block(artifacts: dict) -> str: items = {k: v for k, v in (artifacts or {}).items() if k != "summary" and v} if not items: return "" lines = "\n".join(f"- {k}:{str(v)[:600]}" for k, v in items.items()) return f"\n\n## 之前步骤已取得的信息(可直接使用)\n{lines}" def _merged_learned(knowledge: dict, business: dict) -> dict: """YAML 出厂 learned 打底,库里审核过的(全局 + 本业务)叠加覆盖。""" merged = {**(knowledge.get("learned") or {}), **(business.get("learned") or {})} try: merged.update(db.verified_learned(knowledge.get("id", ""), business.get("id"))) except Exception: # noqa: BLE001 库不可用时退回 YAML pass return merged def _learned_block(knowledge: dict, business: dict, trials: list[dict] | None = None) -> str: """已确认经验当事实给,待验证线索单列一段并明确要求回执。 分级的原因:这些知识的真假只有真跑一遍才知道,读是读不出来的。压着等人审等于永远不生效, 所以草稿也注入,但降一级呈现,并让 Agent 在结束时逐条回答对不对——这是最准的裁判。 """ merged = _merged_learned(knowledge, business) parts: list[str] = [] if merged: lines = "\n".join(f"- {k}:{v}" for k, v in merged.items()) parts.append(f"\n\n## 已知信息(已确认,可直接利用,但仍以页面实际情况为准)\n{lines}") if trials: lines = "\n".join(f"- [{t['id']}] {t['key']}:{t['value']}" for t in trials) parts.append( "\n\n## 待验证线索(尚未确认,只当参考)\n" f"{lines}\n" "这些是系统自动沉淀、还没验证过的说法,可能过时或根本就是错的。用法:\n" "1. 与页面实际情况冲突时,**一律以页面为准**,不要为了迁就线索去做多余操作;\n" "2. 任务结束调用 done 时,在 text 末尾附一段「线索回执」,对每条用过或看到的线索给出判断,格式为每行\n" " `线索[编号] 相符|不符|没用上 一句话说明`,例如 `线索[42] 不符 提交按钮实际叫「保存并提交」`;\n" "3. 没去到相关页面的线索写「没用上」,不要猜。" ) return "".join(parts) _TRIAL_RECEIPT_RE = re.compile(r"线索\[?(\d+)\]?\s*[::]?\s*(相符|不符|没用上|未用上|没有用上)\s*[,,。::]?\s*(.*)") _TRIAL_OUTCOME_MAP = {"相符": "confirmed", "不符": "contradicted", "没用上": "unused", "未用上": "unused", "没有用上": "unused"} def parse_trial_receipt(text: str, allowed_ids: set[int]) -> list[dict]: """从 Agent 的 done 文本里解析线索回执。只认注入过的编号,避免它凭空编。""" outcomes: dict[int, dict] = {} for line in (text or "").splitlines(): match = _TRIAL_RECEIPT_RE.search(line.strip()) if not match: continue entry_id = int(match.group(1)) if entry_id not in allowed_ids: continue outcomes[entry_id] = { "id": entry_id, "outcome": _TRIAL_OUTCOME_MAP[match.group(2)], "note": match.group(3).strip()[:300], } return list(outcomes.values()) def _prerequisites_block(step: dict) -> str: prereqs = step.get("prerequisites") or [] if not prereqs: return "" lines = [] for p in prereqs: lines.append(f"- id={p.get('id')}:当页面提示包含「{p.get('match', p.get('title', ''))}」时 → 调用 report_blocker(\"{p.get('id')}\", 提示原文)") return "\n\n## 可能遇到的前置阻断\n遇到下列系统提示时不要绕过、不要反复重试、不要用 ask_user,直接调用 report_blocker 上报:\n" + "\n".join(lines) def _build_agent_task( step: dict, knowledge: dict, business: dict, task: dict, system: dict, account: dict | None, step_no: int = 1, total_steps: int = 1, trials: list[dict] | None = None, ) -> str: inputs = task.get("inputs") or {} values = {**{k: str(v)[:400] for k, v in (task.get("artifacts") or {}).items()}, **inputs, "today": date.today().isoformat()} notes = inputs.get("user_notes") or [] notes_block = ("\n\n## 用户补充说明\n" + "\n".join(f"- {n}" for n in notes)) if notes else "" rules = "\n".join(f"- {r}" for r in knowledge.get("rules", [])) instructions = "\n".join(f"- {_render(i, values)}" for i in step.get("instructions", [])) entry_url = _render(step.get("entry_url") or system["url"], values) role = business.get("role") if step.get("human_confirm_before"): submit_rule = ( "正式提交前必须调用 ask_user,把即将提交的每个字段和值逐项列出请用户确认;" "用户明确同意后才点击提交类按钮,提交成功后才保存 submitted_record 和 result_message。" "用户说不提交或取消:不要点提交,用 save_artifact 保存 cancelled(用户原话)," "不要保存 submitted_record / result_message,然后 done 说明已填未提交。" ) else: submit_rule = "本步骤是只读探索:不要填写表单、不要点击提交/保存/删除类按钮、不要修改任何数据。" step_header = f"(第 {step_no}/{total_steps} 步)" if total_steps > 1 else "" return f"""你是学校办事助手,正在独立浏览器窗口中以「{role or "用户"}」身份操作「{system["name"]}」。所有输出用中文。 ## 业务 {business.get("name", "")}{step_header} ## 本步目标 {_render(step.get("goal", ""), values)} ## 用户提供的资料 {_inputs_block(business, inputs)}{notes_block}{_artifacts_block(task.get("artifacts") or {})} ## 登录({system.get("name", "")}) {_login_instructions(system, account, role)} ## 业务规则(必须遵守) {rules}{_learned_block(knowledge, business, trials)} ## 本步具体要求 {instructions or "- 按目标自主探索"}{_prerequisites_block(step)} ## 通用操作要求 1. 先打开:{entry_url} 2. 只在允许的域名内操作,不要访问其他站点;只通过页面上的菜单和链接导航,不要凭猜测拼 URL 3. 不确定、缺资料、需要用户决定时调用 ask_user,问题要具体;调用后任务会暂停等待用户 4. {submit_rule} 5. 关键信息用 save_artifact 保存,key 用英文(如 entry_url、form_fields、menus、submitted_record、result_message);拿不到的信息不要用「未获取」之类的占位文字保存,不保存即可,在 done 里说明原因 6. 完成后用 done 结束,在 text 里用中文总结:做了什么、发现了什么、下一步建议 """ # ---------- 编排 ---------- class Orchestrator: def __init__(self) -> None: self._running: set[str] = set() # -- 对外入口 -- async def start_task(self, knowledge_id: str, message: str, student_id: str = "default") -> dict: knowledge = db.get_knowledge(knowledge_id) if not knowledge: raise ValueError(f"未知业务包: {knowledge_id}") businesses = _businesses(knowledge) if not businesses: raise ValueError("业务配置缺少 businesses") nlu = await classify_and_extract(message, businesses) business = _business_by_id(knowledge, nlu["business_id"]) or _fallback_business(knowledge) inputs: dict[str, Any] = {"goal": message, "business_id": business["id"], "step_index": 0} for f in _required_inputs(business): value = nlu["inputs"].get(f["name"]) if value: inputs[f["name"]] = value elif f.get("default") is not None: inputs[f["name"]] = f["default"] task = db.create_task(knowledge_id, student_id, inputs) first_system = _system_for(business, knowledge, _active_step(business)) task = db.update_task( task["id"], browser_profile_path=_profile_path(student_id, first_system.get("id", "sys"), business.get("role")), ) init_progress_doc(task["id"], f"{knowledge['name']} / {business['name']}", message) extracted = ",".join( f"{f['label']}={inputs[f['name']]}" for f in _required_inputs(business) if inputs.get(f["name"]) ) or "无" source = nlu["source"] + (f",{nlu['error']}" if nlu.get("error") else "") append_progress( task["id"], "识别业务", COLLECTING, f"{business['name']}({source},置信度 {nlu['confidence']:.2f});抽取字段:{extracted}", ) denied = self._role_admission(task, knowledge, business) if denied: db.update_task(task["id"], status=FAILED, pause_reason=denied) append_progress(task["id"], "角色准入", FAILED, denied, level="warning") return {**db.get_task(task["id"]), "business_name": business.get("name"), "reply": denied, "status": FAILED} return self._schedule_or_return(task, knowledge, business, nlu.get("reply")) async def submit_inputs(self, task_id: str, inputs: dict | None = None, message: str | None = None) -> dict: task = db.get_task(task_id) if not task: raise ValueError("任务不存在") knowledge = db.get_knowledge(task["knowledge_id"]) business = _business_by_id(knowledge, task["inputs"].get("business_id")) or _fallback_business(knowledge) merged = dict(task["inputs"]) reply_hint = None if inputs: merged.update({k: v for k, v in inputs.items() if v}) append_progress(task_id, "用户补充资料", COLLECTING, str(inputs)) if message: nlu = await classify_and_extract(message, [business], existing=merged, fixed_business_id=business["id"]) for f in _required_inputs(business): value = nlu["inputs"].get(f["name"]) if value: merged[f["name"]] = value merged["user_notes"] = list(merged.get("user_notes") or []) + [message] reply_hint = nlu.get("reply") extracted = ",".join(f"{k}={v}" for k, v in nlu["inputs"].items()) or "无" append_progress(task_id, "用户补充资料", COLLECTING, f"{message}(抽取:{extracted})") task = db.update_task(task_id, inputs=merged) return self._schedule_or_return(task, knowledge, business, reply_hint) async def resume_task(self, task_id: str, answer: str = "请继续执行") -> dict: """用户回答 / 点击继续。浏览器会话仍在时续跑同一个 Agent,否则带上说明重新执行当前步骤。""" task = db.get_task(task_id) if not task: raise ValueError("任务不存在") if task_id in self._running: return {**task, "reply": "任务正在执行中,请稍候", "status": RUNNING} if task["status"] not in (PAUSED, FAILED, BLOCKED): raise ValueError(f"当前状态为 {task['status']},无需继续") pending = (task["inputs"] or {}).get("pending_blocker") if pending and runner.has_live_session(task_id): return await self._resume_from_blocker(task, pending, answer) if runner.has_live_session(task_id): append_progress(task_id, "用户回答", RUNNING, answer) question = task.get("pause_reason") or "" continuation = f"用户对你的提问「{question}」的回答:{answer}\n请据此继续完成当前步骤的目标;如果已经完成,用 done 总结。" self._launch(task_id, continuation=continuation) return {**db.get_task(task_id), "reply": "已收到,继续执行中", "status": RUNNING} inputs = {k: v for k, v in task["inputs"].items() if k != "pending_blocker"} inputs["user_notes"] = list(inputs.get("user_notes") or []) + [answer] db.update_task(task_id, inputs=inputs) append_progress(task_id, "重新执行", RUNNING, f"浏览器会话已结束,带上补充说明从当前步骤重新开始:{answer}") self._launch(task_id) return {**db.get_task(task_id), "reply": "浏览器会话已结束,正在带上你的说明重新执行", "status": RUNNING} # -- 前置阻断 -- async def _resume_from_blocker(self, task: dict, pending: dict, answer: str) -> dict: task_id = task["id"] inputs = {k: v for k, v in task["inputs"].items() if k != "pending_blocker"} title = pending.get("title") or pending.get("id") if _is_negative(answer): db.update_task(task_id, status=BLOCKED, inputs=inputs, pause_reason=f"被「{title}」拦住:{pending.get('message', '')}") append_progress(task_id, "前置阻断", BLOCKED, f"用户选择先不处理「{title}」,任务暂停在此。处理好后可点「继续执行」重试", level="warning") await runner.close_live_session(task_id) return { **db.get_task(task_id), "reply": f"好的,先到这里。等「{title}」处理好之后,点「继续执行」我再重新办理。", "status": BLOCKED, } knowledge = db.get_knowledge(task["knowledge_id"]) business = _business_by_id(knowledge, inputs.get("business_id")) or _fallback_business(knowledge) step = _current_step(business, inputs) or {} original_goal = _render(step.get("goal", ""), inputs) if pending.get("resolution") == "self" and pending.get("business_id") and _is_affirmative(answer): prereq_business = _business_by_id(knowledge, pending["business_id"]) prereq_step = _active_step(prereq_business) if prereq_business else None if prereq_business and prereq_step: sub_instructions = "\n".join(f"- {_render(i, inputs)}" for i in prereq_step.get("instructions", [])) learned = _learned_block(knowledge, prereq_business) continuation = ( f"用户同意先办前置事项「{title}」。现在切换到子目标:\n" f"{_render(prereq_step.get('goal', ''), inputs)}\n" f"子目标要求:\n{sub_instructions or '- 按目标自主完成'}{learned}\n\n" f"子目标完成后(或确认无法完成时),回到原目标继续:{original_goal}\n" f"注意:前置事项「{title}」这时已经处理过,不要再为它调用 report_blocker;" "只有页面再次弹出同样的拦截提示时才重新上报。" ) db.update_task(task_id, inputs=inputs) append_progress(task_id, "切换前置事项", RUNNING, f"用户同意,先办「{title}」,办完回到原目标") self._launch(task_id, continuation=continuation) return {**db.get_task(task_id), "reply": f"好,先去办「{title}」,办完自动回来继续。", "status": RUNNING} db.update_task(task_id, inputs=inputs) append_progress(task_id, "用户回答", RUNNING, answer) continuation = ( f"关于前置阻断「{title}」(系统提示:{pending.get('message', '')}),用户回答:{answer}\n" f"请重新尝试原目标:{original_goal}\n如果仍被同样的提示拦截,再次调用 report_blocker 上报。" ) self._launch(task_id, continuation=continuation) return {**db.get_task(task_id), "reply": "已收到,重新尝试中", "status": RUNNING} # -- 内部 -- @staticmethod def _pick_trials(knowledge: dict, business: dict) -> list[dict]: """挑一批待验证线索注入。数量有上限:注入太多会撑长提示词,也会让互相矛盾的说法干扰判断。""" if not settings.trial_enabled: return [] try: return db.trial_learned(knowledge.get("id", ""), business.get("id"), settings.trial_max_inject) except Exception: # noqa: BLE001 库不可用时照常执行,只是没有线索 return [] @staticmethod def _record_trial_receipt(task_id: str, trials: list[dict], summary: str) -> None: """从 Agent 的总结里解析线索回执,升级被证实的、降级被证伪的。""" if not trials: return try: outcomes = parse_trial_receipt(summary, {int(t["id"]) for t in trials}) db.record_trials(task_id, [int(t["id"]) for t in trials]) if not outcomes: append_progress(task_id, "线索回执", RUNNING, f"注入 {len(trials)} 条待验证线索,Agent 未给出回执") return stats = db.apply_trial_outcomes( task_id, outcomes, settings.trial_promote_after, settings.trial_demote_after ) detail = ( f"注入 {len(trials)} 条,回执 {len(outcomes)} 条:" f"相符 {stats['confirmed']}、不符 {stats['contradicted']}、没用上 {stats['unused']};" f"升为已确认 {stats['promoted']}、降为驳回 {stats['demoted']}" ) append_progress(task_id, "线索回执", RUNNING, detail) except Exception as e: # noqa: BLE001 验证机制不能影响办事 append_progress(task_id, "线索回执", RUNNING, f"处理失败:{e}", level="warning") @staticmethod def _role_admission(task: dict, knowledge: dict, business: dict) -> str | None: """业务声明了 role,而当前用户在该系统的密码本账号是别的角色 → 直接告知办不了。没有账号时不拦(走 fallback 登录)。""" role = business.get("role") if not role: return None system = _system_for(business, knowledge, _active_step(business)) account = db.get_account(task["student_id"], system.get("id", "")) if account and account.get("role") and account["role"] != role: return ( f"「{business.get('name')}」需要以「{role}」身份办理,而你在「{system.get('name')}」的账号是「{account['role']}」," "这件事我替你办不了。" ) return None def _schedule_or_return(self, task: dict, knowledge: dict, business: dict, reply_hint: str | None) -> dict: missing = _missing_inputs(task, business) if missing: labels = _missing_labels(missing) db.update_task(task["id"], status=COLLECTING) append_progress(task["id"], "收集资料", COLLECTING, f"缺少:{labels}") return { **db.get_task(task["id"]), "business_name": business.get("name"), "reply": reply_hint or f"好的,办理「{business['name']}」还需要你告诉我:{labels}", "needs_input": [m["name"] for m in missing], } if task["id"] in self._running: return {**task, "reply": "任务正在执行中", "status": RUNNING} self._launch(task["id"]) summary = ",".join( f"{f['label']} {task['inputs'][f['name']]}" for f in _required_inputs(business) if task["inputs"].get(f["name"]) ) steps = _enabled_steps(business) step_note = f",共 {len(steps)} 步" if len(steps) > 1 else "" return { **db.get_task(task["id"]), "business_name": business.get("name"), "reply": f"已识别为「{business['name']}」" + (f"({summary})" if summary else "") + f"{step_note},开始执行,浏览器窗口即将打开。", "status": RUNNING, } def _launch(self, task_id: str, continuation: str | None = None) -> None: self._running.add(task_id) asyncio.create_task(self._run_task_background(task_id, continuation)) async def _run_task_background(self, task_id: str, continuation: str | None) -> None: try: await self.run_task(task_id, continuation) except Exception as e: # noqa: BLE001 db.update_task(task_id, status=FAILED, pause_reason=str(e)) append_progress(task_id, "执行失败", FAILED, str(e), level="error") finally: self._running.discard(task_id) async def run_task(self, task_id: str, continuation: str | None = None) -> dict: """从 inputs.step_index 指向的步骤开始,按顺序执行业务的全部启用步骤(同一个浏览器会话)。""" task = db.get_task(task_id) if not task: raise ValueError("任务不存在") knowledge = db.get_knowledge(task["knowledge_id"]) if not knowledge: raise ValueError("业务配置不存在") business = _business_by_id(knowledge, task["inputs"].get("business_id")) or _fallback_business(knowledge) steps = _enabled_steps(business) if not steps: raise ValueError(f"业务「{business.get('name')}」未配置可执行的 workflow 步骤") systems = _systems_for_business(business, knowledge) allowed: list[str] = [] for s in systems: for d in s.get("allowed_domains", []): if d not in allowed: allowed.append(d) accounts = {s.get("id", ""): db.get_account(task["student_id"], s.get("id", "")) for s in systems} sensitive = self._sensitive_data(systems, accounts) idx = min(int((task["inputs"] or {}).get("step_index", 0) or 0), len(steps) - 1) artifacts: dict = dict(task.get("artifacts") or {}) first_iteration = True while idx < len(steps): step = steps[idx] system = _system_for(business, knowledge, step) inputs = {**task["inputs"], "step_index": idx} task = db.update_task(task_id, status=RUNNING, current_step=step["step_id"], pause_reason=None, inputs=inputs) # 待验证线索:草稿也注入,但降一级呈现并要回执,用运行结果当裁判(见 _learned_block) trials = self._pick_trials(knowledge, business) prompt = _build_agent_task( step, knowledge, business, task, system, accounts.get(system.get("id", "")), idx + 1, len(steps), trials ) if first_iteration and continuation is not None: cont: str | None = continuation # 用户回答 / 前置事项切换,续跑当前步骤 elif first_iteration: cont = None # 新会话(首次执行,或会话丢失后重跑) append_progress(task_id, step["step_id"], RUNNING, _render(step.get("goal", ""), inputs)) else: prev = steps[idx - 1] cont = f"上一步「{prev.get('step_id')}」已完成。现在开始下一步。\n\n{prompt}" append_progress(task_id, step["step_id"], RUNNING, _render(step.get("goal", ""), inputs)) if cont is not None and not runner.has_live_session(task_id): cont = None # 会话已不在,只能新开浏览器从当前步骤重跑 first_iteration = False result = await runner.run_browser_step( task_id=task_id, agent_task=prompt, profile_path=task["browser_profile_path"] or _profile_path(task["student_id"], system.get("id", "sys"), business.get("role")), allowed_domains=allowed, sensitive_data=sensitive, continuation=cont, max_steps=int(step.get("max_steps", 40)), max_actions_per_step=int(step.get("max_actions_per_step", 3)), keep_session=idx + 1 < len(steps), ) artifacts = {**artifacts, **result.get("artifacts", {})} verdict = result["verdict"] if verdict == "paused": return self._handle_pause(task_id, task, step, artifacts, result) summary = result.get("summary") or "执行结束" agent_done = bool((result.get("history") or {}).get("is_done")) verdict, summary, override = apply_verdict_overrides(verdict, agent_done, step, artifacts, summary) if override: append_progress(task_id, override[0], RUNNING, override[1]) # 用 Agent 刚在真实页面上的观察来升降级待验证线索 self._record_trial_receipt(task_id, trials, summary) db.add_run_log(task_id, step["step_id"], verdict, result.get("history")) if verdict != "success": await runner.close_live_session(task_id) db.update_task(task_id, status=FAILED, artifacts=artifacts, pause_reason=summary) append_progress(task_id, step["step_id"], FAILED, summary, level="error") return db.get_task(task_id) artifacts[f"summary_{step['step_id']}"] = summary[:2000] append_progress(task_id, step["step_id"], COMPLETED, summary, level="success") idx += 1 task = db.update_task(task_id, artifacts=artifacts, inputs={**task["inputs"], "step_index": idx}) if idx < len(steps) and not runner.has_live_session(task_id): append_progress(task_id, "会话结束", RUNNING, "浏览器会话已关闭,下一步将新开浏览器", level="warning") # 全部步骤完成 last = steps[-1] final_summary = artifacts.get(f"summary_{last['step_id']}") or "执行结束" artifacts["summary"] = final_summary final = _final_status(steps, artifacts) await self._sediment(task_id, knowledge, business, last, artifacts, {"history": None}, final_summary) await runner.close_live_session(task_id) next_check = None tracking = _tracking_cfg(business) if final == SUBMITTED and tracking: next_check = _next_check_str(tracking["interval_minutes"]) artifacts["tracking"] = {"checks": 0, "history": [], "max_checks": tracking["max_checks"]} append_progress(task_id, "提交后跟踪", SUBMITTED, f"已排定回查:{next_check},每 {tracking['interval_minutes']} 分钟一次,最多 {tracking['max_checks']} 次") db.update_task(task_id, status=final, artifacts=artifacts, pause_reason=None, next_check_at=next_check) write_summary(task_id, final_summary) return db.get_task(task_id) # -- 提交后跟踪 -- async def run_tracking(self, task_id: str, force: bool = False) -> dict: """拉起一个短的只读任务回查审批状态。approved → completed,rejected → rejected,否则排下一次。""" task = db.get_task(task_id) if not task: raise ValueError("任务不存在") if task["status"] != SUBMITTED: raise ValueError(f"当前状态为 {task['status']},不需要回查") if task_id in self._running: return {**task, "reply": "任务正在执行中,请稍候"} knowledge = db.get_knowledge(task["knowledge_id"]) business = _business_by_id(knowledge, task["inputs"].get("business_id")) or _fallback_business(knowledge) tracking = _tracking_cfg(business) if not tracking: raise ValueError(f"业务「{business.get('name')}」没有配置 tracking") if not force and runner.live_session_ids(): db.update_task(task_id, next_check_at=_next_check_str(5)) return {**task, "reply": "有其他任务在使用浏览器,5 分钟后再回查"} self._running.add(task_id) try: step = tracking["step"] system = _system_for(business, knowledge, step) account = db.get_account(task["student_id"], system.get("id", "")) sensitive = self._sensitive_data([system], {system.get("id", ""): account}) artifacts = dict(task.get("artifacts") or {}) state = artifacts.get("tracking") or {"checks": 0, "history": [], "max_checks": tracking["max_checks"]} n = int(state.get("checks", 0)) + 1 append_progress(task_id, f"跟踪回查 #{n}", SUBMITTED, _render(step.get("goal", ""), {**artifacts, **task["inputs"]})) prompt = _build_agent_task(step, knowledge, business, task, system, account) result = await runner.run_browser_step( task_id=task_id, agent_task=prompt, profile_path=task["browser_profile_path"] or _profile_path(task["student_id"], system.get("id", "sys"), business.get("role")), allowed_domains=system.get("allowed_domains", []), sensitive_data=sensitive, continuation=None, max_steps=int(step.get("max_steps", 20)), max_actions_per_step=int(step.get("max_actions_per_step", 3)), ) await runner.close_live_session(task_id) found = result.get("artifacts", {}) status_text = str(found.get("approval_status") or "").strip() note = str(found.get("approval_note") or "").strip() verdict = _classify_approval(status_text) if result["verdict"] == "success" else "unknown" from datetime import datetime entry = {"at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "status": status_text or "(未读到)", "verdict": verdict, "note": note[:300]} state["checks"] = n state["history"] = (state.get("history") or [])[-19:] + [entry] artifacts["tracking"] = state if status_text: artifacts["approval_status"] = status_text if note: artifacts["approval_note"] = note db.add_run_log(task_id, step["step_id"], result["verdict"], result.get("history")) if verdict == "approved": summary = f"审批已通过:{status_text}" + (f"。{note}" if note else "") db.update_task(task_id, status=COMPLETED, artifacts=artifacts, next_check_at=None, pause_reason=None) append_progress(task_id, f"跟踪回查 #{n}", COMPLETED, summary, level="success") write_summary(task_id, summary) return {**db.get_task(task_id), "reply": summary} if verdict == "rejected": summary = f"审批被驳回:{status_text}" + (f"。{note}" if note else "") db.update_task(task_id, status=REJECTED, artifacts=artifacts, next_check_at=None, pause_reason=summary) append_progress(task_id, f"跟踪回查 #{n}", REJECTED, summary, level="warning") write_summary(task_id, summary) return {**db.get_task(task_id), "reply": summary} if n >= int(state.get("max_checks", tracking["max_checks"])): db.update_task(task_id, artifacts=artifacts, next_check_at=None) append_progress(task_id, f"跟踪回查 #{n}", SUBMITTED, f"状态仍为「{status_text or '未知'}」,已达最多回查次数,停止自动回查", level="warning") return {**db.get_task(task_id), "reply": f"审批状态仍为「{status_text or '未知'}」,已停止自动回查,可手动点「立即回查」"} next_check = _next_check_str(tracking["interval_minutes"]) db.update_task(task_id, artifacts=artifacts, next_check_at=next_check) append_progress(task_id, f"跟踪回查 #{n}", SUBMITTED, f"状态「{status_text or '未读到'}」({verdict}),下次回查 {next_check}") return {**db.get_task(task_id), "reply": f"审批状态:{status_text or '未读到'},下次回查 {next_check}"} finally: self._running.discard(task_id) async def tracking_loop(self, poll_seconds: int) -> None: """后台调度:到点的已提交任务逐个回查。桌面端单用户,一次只跑一个。""" while True: try: for task in db.due_tracking_tasks(limit=3): if task["id"] in self._running: continue try: await self.run_tracking(task["id"]) except Exception as e: # noqa: BLE001 append_progress(task["id"], "跟踪回查", SUBMITTED, f"回查失败:{e},30 分钟后重试", level="warning") db.update_task(task["id"], next_check_at=_next_check_str(30)) except Exception: # noqa: BLE001 库暂时不可用等,下一轮再来 pass await asyncio.sleep(max(10, int(poll_seconds))) def _handle_pause(self, task_id: str, task: dict, step: dict, artifacts: dict, result: dict) -> dict: blocker = result.get("blocker") if blocker: prereq = _prerequisite(step, blocker["id"]) question = _blocker_question(prereq, blocker["message"]) pending = { "id": blocker["id"], "message": blocker["message"], "resolution": (prereq or {}).get("resolution", "manual"), "business_id": (prereq or {}).get("business_id"), "title": (prereq or {}).get("title", blocker["id"]), "advice": (prereq or {}).get("advice", ""), } db.update_task( task_id, status=PAUSED, artifacts=artifacts, pause_reason=question, inputs={**task["inputs"], "pending_blocker": pending}, ) append_progress(task_id, "前置阻断", PAUSED, f"{pending['title']}({pending['resolution']}):{blocker['message']}", level="pause") return db.get_task(task_id) question = result["question"] db.update_task(task_id, status=PAUSED, artifacts=artifacts, pause_reason=question) append_progress(task_id, "等待用户", PAUSED, question, level="pause") return db.get_task(task_id) @staticmethod async def _sediment( task_id: str, knowledge: dict, business: dict, step: dict, artifacts: dict, result: dict, summary: str ) -> None: """任务成功后自动生成 learned 草稿,失败不影响任务本身。""" try: existing = _merged_learned(knowledge, business) drafts = {r["key"]: r["value"] for r in db.list_learned(knowledge.get("id", ""), business.get("id", ""), "draft")} entries, note = await draft_learned( business, step, artifacts, result.get("history"), summary, existing, drafts ) stats = db.add_learned_drafts(knowledge.get("id", ""), business.get("id", ""), entries, task_id) keys = "、".join(f"{e['key']}[{e.get('category', 'note')}]" for e in entries) or "无" append_progress( task_id, "沉淀草稿", RUNNING, f"新增 {stats['written']},合并 {stats['merged']},自动生效 {stats['promoted']},已知跳过 {stats['skipped']}({note}):{keys}", ) except Exception as e: # noqa: BLE001 append_progress(task_id, "沉淀草稿", RUNNING, f"生成失败:{e}", level="warning") # 配了中心服务端就顺手报上去,让团队在审核台看到。后台跑,失败不影响任务。 if hub_sync.enabled(): asyncio.create_task(hub_sync.report_drafts(knowledge.get("id", ""), business.get("id", ""))) @staticmethod def _sensitive_data(systems: list[dict], accounts: dict[str, dict | None]) -> dict | None: """把任务涉及的所有系统的凭据按域名分组:占位符带系统后缀,只在对应域名的页面替换。LLM 看不到明文。""" data: dict[str, dict[str, str]] = {} for system in systems: sid = system.get("id", "") account = accounts.get(sid) if not account: continue u, p = _placeholders(sid) secrets = {u: account["username"], p: decrypt_secret(account["password_enc"])} domains = system.get("allowed_domains") or [] if not domains: data.update(secrets) continue for d in domains: data.setdefault(d, {}).update(secrets) return data or None def business_name_for(task: dict) -> str | None: knowledge = db.get_knowledge(task["knowledge_id"]) if not knowledge: return None business = _business_by_id(knowledge, (task.get("inputs") or {}).get("business_id")) return business.get("name") if business else None