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