assistant/backend/orchestrator/orchestrator.py

957 lines
48 KiB
Python
Raw 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.

"""编排层:意图路由、资料收集、角色准入、多步串行执行、暂停/续跑、前置阻断、进度文档。
状态流转:
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