assistant/backend/learning.py

119 lines
5.4 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.

"""自主学习闭环第一步:任务成功后自动生成 learned 草稿。
来源两路:
1. 产物直接映射:entry_url -> 入口地址,form_fields -> 表单字段,menus -> 菜单结构
2. 一次模型调用:从 Agent 的总结和访问过的页面里提炼"下次同类任务能直接复用的操作知识"
草稿写入 learned_entries(status=draft),用户在桌面端采纳后才会注入 prompt。
"""
import json
from typing import Any
from backend.browser.llm_factory import get_llm_config
from backend.nlu import _call_llm
ARTIFACT_KEYS = {
"entry_url": "入口地址",
"form_fields": "表单字段",
"menus": "菜单结构",
}
MAX_ENTRIES = 8
def _is_submit_step(step: dict) -> bool:
return bool(step.get("human_confirm_before") or step.get("on_success_status") == "submitted")
def _from_artifacts(artifacts: dict, step: dict) -> list[dict]:
entries = []
for art_key, learned_key in ARTIFACT_KEYS.items():
value = artifacts.get(art_key)
if not value:
continue
# 填报类步骤的 form_fields 是本次填的具体值(班级、人名…),不是表单结构,不能当知识
if art_key == "form_fields" and _is_submit_step(step):
continue
entries.append({"key": learned_key, "value": str(value).strip()[:2000], "category": "path"})
return entries
def _build_prompt(
business: dict, step: dict, summary: str, history: dict, existing: dict, existing_drafts: dict | None = None
) -> str:
urls = history.get("urls") or []
existing_lines = "\n".join(f"- {k}:{str(v)[:120]}" for k, v in existing.items()) or "(无)"
draft_lines = "\n".join(f"- {k}:{str(v)[:120]}" for k, v in (existing_drafts or {}).items()) or "(无)"
return "\n".join(
[
"你是学校办事助手的知识沉淀模块。下面是一次成功执行的记录,请提炼出「下次执行同类任务时能直接复用的操作知识」。",
"",
f"业务:{business.get('name', '')}",
f"本步目标:{step.get('goal', '')}",
f"访问过的页面(按顺序):{json.dumps(urls[:20], ensure_ascii=False)}",
f"执行步数:{history.get('steps')},用时 {history.get('duration_s')} 秒",
"",
"Agent 的执行总结:",
summary[:3000],
"",
"已经生效的知识(不要重复):",
existing_lines,
"",
"待审核的草稿(如果这次又观察到同一条知识,请沿用完全相同的 key,value 可以补充得更完整;这是它被多次验证的依据):",
draft_lines,
"",
"要求:",
"1. 只记录操作知识:入口路径、控件操作技巧(如某个下拉要点文字容器才能选中、日期要键入后回车)、系统规则和前置条件、容易踩的坑",
"2. 不要记录本次的具体业务数据:班级、日期、人名、课程名、填了什么内容都不要",
"2b. 不要记录本次用户临时指令的处理方式:取消、测试、干跑、评测、不要提交这类都不是知识",
"2c. 不要复述任务提示里已经写明的流程(如提交前要确认),只记录执行中新发现的东西",
"3. 每条 key 用 4 到 12 个字的名词短语,value 用一到三句话,具体可执行",
"4. 每条给 category:control_tip(控件操作技巧)/ path(入口路径、页面地址、菜单结构)/ rule(系统规则、前置条件、达标要求、审批流程)/ note(其他)",
"5. 最多 5 条,没有新知识就返回空数组",
'输出 JSON:{"entries": [{"key": "...", "value": "...", "category": "control_tip"}]}',
]
)
async def draft_learned(
business: dict,
step: dict,
artifacts: dict,
history: dict | None,
summary: str,
existing: dict | None = None,
existing_drafts: dict | None = None,
) -> tuple[list[dict], str]:
"""返回 (草稿列表, 备注)。每条 {key, value, category}。模型不可用时只返回产物映射的条目。"""
existing = existing or {}
entries: list[dict] = _from_artifacts(artifacts or {}, step)
note = "产物映射"
cfg = get_llm_config()
if cfg.get("api_key"):
try:
result: Any = await _call_llm(
_build_prompt(business, step, summary or "", history or {}, existing, existing_drafts), cfg
)
llm_entries = (result or {}).get("entries") or []
for e in llm_entries:
if not isinstance(e, dict):
continue
key = str(e.get("key") or "").strip()
value = str(e.get("value") or "").strip()
category = str(e.get("category") or "note").strip()
if key and value:
entries.append({"key": key[:128], "value": value[:2000], "category": category})
note = f"产物映射 + 模型提炼 {len(llm_entries)} 条"
except Exception as e: # noqa: BLE001
note = f"产物映射(模型提炼失败:{type(e).__name__})"
seen: set[str] = set()
unique: list[dict] = []
for e in entries:
if e["key"] in seen:
continue
if existing.get(e["key"]) == e["value"]:
continue
seen.add(e["key"])
unique.append(e)
return unique[:MAX_ENTRIES], note