"""自主学习闭环第一步:任务成功后自动生成 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