From 5681989194c4a3c0cb40e1378e6e7d876a185e5d Mon Sep 17 00:00:00 2001 From: butubb <1422726308@qq.com> Date: Fri, 4 Sep 2026 15:53:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BB=8F=E9=AA=8C=E5=BA=93=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=20AI=20=E5=B7=A1=E6=A3=80=20+=20=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E2=80=94=E2=80=94=E2=91=A0experience=5Faudit?= =?UTF-8?q?=20=E8=A1=A8=EF=BC=9A=E9=80=90=E6=9D=A1=E8=AF=84=E5=AE=A1?= =?UTF-8?q?=EF=BC=88=E5=85=B7=E4=BD=93=E5=8F=AF=E6=89=A7=E8=A1=8C/?= =?UTF-8?q?=E6=97=A0=E8=BF=87=E6=97=B6=E5=9D=90=E6=A0=87/=E9=9D=9E?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E7=BB=AD=E8=AF=AD/=E9=85=8D=E6=96=B9?= =?UTF-8?q?=E4=B8=8E=E5=BA=8F=E5=88=97=E4=B8=80=E8=87=B4=20checklist?= =?UTF-8?q?=EF=BC=89=EF=BC=8Ckeep=20=E7=9B=B4=E6=8E=A5=E5=BD=92=E6=A1=A3?= =?UTF-8?q?=E3=80=81delete=20=E6=A0=87=20pending=20=E5=BE=85=E4=BA=BA?= =?UTF-8?q?=E5=B7=A5=EF=BC=8C=E7=BB=9D=E4=B8=8D=E8=87=AA=E5=8A=A8=E5=88=A0?= =?UTF-8?q?=EF=BC=9B=E4=BA=BA=E5=B7=A5=20kept=20=E5=90=8E=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E9=87=8D=E5=A4=8D=E5=BB=BA=E8=AE=AE=EF=BC=9B=E2=91=A1?= =?UTF-8?q?web=5Fserver=20=E6=B3=A8=E5=86=8C=E6=AF=8F=E6=97=A5=2003:47=20?= =?UTF-8?q?=E5=B7=A1=E6=A3=80=20job=EF=BC=88Asia/Shanghai=EF=BC=89?= =?UTF-8?q?=EF=BC=9B=E2=91=A2API=EF=BC=9A=E7=BB=8F=E9=AA=8C=E5=88=97?= =?UTF-8?q?=E8=A1=A8=EF=BC=88=E5=90=AB=E5=B7=A1=E6=A3=80=E5=BB=BA=E8=AE=AE?= =?UTF-8?q?=EF=BC=89/=E7=A1=AE=E8=AE=A4=E5=88=A0=E9=99=A4/=E4=BF=9D?= =?UTF-8?q?=E7=95=99/=E6=89=8B=E5=8A=A8=E8=A7=A6=E5=8F=91=E5=B7=A1?= =?UTF-8?q?=E6=A3=80=EF=BC=9B=E2=91=A3AI=20=E6=8E=A7=E5=88=B6=E5=8F=B0?= =?UTF-8?q?=E5=8F=B3=E4=B8=8A=E8=A7=92=E3=80=8C=F0=9F=A7=A0=20=E7=BB=8F?= =?UTF-8?q?=E9=AA=8C=E5=BA=93=E3=80=8D=E9=9D=A2=E6=9D=BF=EF=BC=9A=E7=BB=8F?= =?UTF-8?q?=E9=AA=8C=E5=8D=A1=E7=89=87=20+=20=E5=BB=BA=E8=AE=AE=E5=BE=BD?= =?UTF-8?q?=E7=AB=A0=E4=B8=8E=E7=90=86=E7=94=B1=20+=20=E5=88=A0=E9=99=A4?= =?UTF-8?q?=EF=BC=88confirm=EF=BC=89/=E4=BF=9D=E7=95=99=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- static/admin/agent.js | 84 ++++++++++++++ templates/admin/monitor.html | 19 ++++ web/agent_api.py | 213 ++++++++++++++++++++++++++++++++++- web_server.py | 13 +++ 4 files changed, 328 insertions(+), 1 deletion(-) diff --git a/static/admin/agent.js b/static/admin/agent.js index 2a23ef0..9ae532c 100644 --- a/static/admin/agent.js +++ b/static/admin/agent.js @@ -296,3 +296,87 @@ function endRun(){ _agentStream = null; scrollChat(); } + +// ================== 经验库(自进化记忆管理) ================== +function openExpLib(){ + document.getElementById('agent-exp-overlay').style.display = 'flex'; + loadExpLib(); +} +function closeExpLib(){ + document.getElementById('agent-exp-overlay').style.display = 'none'; +} +function loadExpLib(){ + const list = document.getElementById('exp-lib-list'); + apiGet('/api/agent/experience').then(r=>{ + if(!r||!r.ok){ showToast((r&&r.error)||'加载失败','error'); return; } + document.getElementById('exp-lib-last').textContent = + (r.last ? ('最近巡检 ' + r.last + ':' + (r.last_summary||'')) : ''); + if(r.running){ + list.innerHTML = '
🔍 AI 巡检进行中(约 1 分钟),完成后请刷新…
'; + return; + } + const exps = r.experiences||[]; + if(!exps.length){ + list.innerHTML = '
暂无经验——跑一轮带手机操作的任务后会自动沉淀
'; + return; + } + list.innerHTML = ''; + exps.forEach(e=>{ + const a = e.audit||{}; + const card = document.createElement('div'); + card.style.cssText = 'border:1px solid var(--card-line);border-radius:10px;padding:9px 13px;background:#0d1117'; + // 徽章 + let badge = ''; + if(a.action === 'pending' && a.verdict === 'delete'){ + badge = '⚠ 建议删除'; + }else if(a.action === 'kept'){ + badge = '' + + (a.verdict === 'delete' ? '已人工保留' : '✓ 已保留') + ''; + } + let actBtns = ''; + if(a.action === 'pending'){ + actBtns = '' + + ''; + }else{ + actBtns = ''; + } + card.innerHTML = + '
' + + '#' + e.id + '' + + '' + esc(e.created_at||'') + '' + + '引用 ' + (e.hits||0) + ' 次' + badge + + '' + actBtns + '
' + + '
' + esc(e.task_prompt) + '
' + + '
' + esc((e.recipe||'').slice(0,220)) + '
' + + (a.action === 'pending' && a.reason + ? '
AI 建议理由:' + esc(a.reason) + + (a.score ? '(评分 ' + a.score + '/10)' : '') + '
' + : ''); + list.appendChild(card); + }); + }); +} +function triggerExpAudit(){ + apiPost('/api/agent/experience/audit',{}).then(r=>{ + if(r&&r.ok){ + showToast('巡检已启动,约 1 分钟完成','success'); + document.getElementById('exp-lib-list').innerHTML = + '
🔍 AI 巡检进行中…
'; + // 完成后自动刷新 + setTimeout(()=>{ if(document.getElementById('agent-exp-overlay').style.display==='flex') loadExpLib(); }, 90000); + }else showToast((r&&r.error)||'巡检启动失败','error'); + }); +} +function expDelete(id){ + if(!confirm('确认删除该经验?删除后不可恢复(下次相似任务不再自动参考)。')) return; + apiPost('/api/agent/experience/delete',{id:id}).then(r=>{ + if(r&&r.ok){ showToast('已删除','success'); loadExpLib(); } + else showToast((r&&r.error)||'删除失败','error'); + }); +} +function expKeep(id){ + apiPost('/api/agent/experience/keep',{id:id}).then(r=>{ + if(r&&r.ok){ showToast('已保留,后续不再重复建议','success'); loadExpLib(); } + else showToast((r&&r.error)||'操作失败','error'); + }); +} diff --git a/templates/admin/monitor.html b/templates/admin/monitor.html index 368980e..fcc172a 100644 --- a/templates/admin/monitor.html +++ b/templates/admin/monitor.html @@ -469,6 +469,7 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14 + @@ -513,6 +514,24 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14 + + +
日志查看
diff --git a/web/agent_api.py b/web/agent_api.py index 7851b68..d8d7169 100644 --- a/web/agent_api.py +++ b/web/agent_api.py @@ -79,6 +79,38 @@ CREATE TABLE IF NOT EXISTS agent_experience ( hits INTEGER DEFAULT 0, created_at VARCHAR(20) DEFAULT '')""" +# 经验巡检(AI 质检):每日定时把经验交给模型评审,疑似问题标 pending 待人工确认。 +# 删除只允许人工(前端 confirm / 本 API),巡检绝不自动删。 +_AUDIT_TABLE = """ +CREATE TABLE IF NOT EXISTS experience_audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + exp_id INTEGER NOT NULL, + verdict TEXT DEFAULT '', -- keep 保留 / delete 建议删除 + score REAL DEFAULT 0, -- 0-10 可用性评分 + reason TEXT DEFAULT '', -- 模型给出的理由 + hits INTEGER DEFAULT 0, -- 巡检时的引用次数(hits>0 被删要更谨慎) + action TEXT DEFAULT 'pending', -- pending 待人工确认 / kept 已保留 / deleted 已删除 + audited_at VARCHAR(20) DEFAULT '')""" + +# 巡检评审提示词:模型只判「保留/建议删除」+ 评分 + 理由,不直接删。 +_AUDIT_PROMPT = """你是「手机自动化经验库」质检员。经验库存放 AI 成功操作手机后提炼的 +操作套路,下次相似任务会自动注入参考。请判断下面这条经验是否值得继续保留。 + +判断标准(全部满足才保留): +1. 具体可执行:步骤是明确的工具操作(de_* 或清晰的中文步骤),不是「de_tap / de_input」 + 这类空泛占位,也没有编造不存在的工具名 +2. 不依赖易过时信息:不含写死的像素坐标(x,y 数字)这类界面一变就失效的步骤 +3. 是独立任务的操作套路(如「打开X搜索Y并点赞」),不是对话追问/抱怨/单次性内容 +4. 配方与下方实际操作序列大致一致,不是模型自由发挥编造 + +经验内容: +任务描述:{prompt} +操作配方:{recipe} +实际操作序列:{tool_seq} +被引用次数:{hits} + +只输出 JSON:{{"keep": true或false, "score": 0到10的整数, "reason": "一句话中文理由"}}""" + def _ensure_exp_table(): try: @@ -241,7 +273,186 @@ def _save_experience(prompt, recipe, tool_seq): return False -# ================== 配置 ================== +# ================== 经验巡检(AI 质检,删除需人工确认) ================== +_audit_state = {"running": False, "last": "", "last_summary": ""} + + +def _ensure_audit_table(): + try: + db.session.execute(db.text(_AUDIT_TABLE)) + db.session.commit() + except Exception: + pass + + +def _review_one_exp(cfg, prompt, recipe, tool_seq, hits): + """调模型评审单条经验,返回 (verdict, score, reason);失败保守返回保留。""" + try: + import httpx + body = { + "model": cfg.get("model") or "deepseek-v4-flash-vision-exp", + "messages": [{"role": "user", + "content": _AUDIT_PROMPT.format( + prompt=(prompt or "")[:400], + recipe=(recipe or "")[:600], + tool_seq=(tool_seq or "")[:600], + hits=hits or 0)}], + "max_tokens": 200, + } + headers = {"Authorization": f"Bearer {cfg.get('api_key', '')}", + "Content-Type": "application/json"} + r = httpx.post(f"{(cfg.get('api_base') or 'https://api.deepseek.com').rstrip('/')}/chat/completions", + json=body, headers=headers, timeout=20) + if r.status_code != 200: + raise RuntimeError(f"HTTP {r.status_code}") + text = (((r.json() or {}).get("choices") or [{}])[0] + .get("message") or {}).get("content") or "" + m = re.search(r"\{.*\}", text, re.S) + if not m: + raise RuntimeError("响应无 JSON") + j = json.loads(m.group(0)) + keep = bool(j.get("keep", True)) + score = max(0, min(10, int(j.get("score", 5) or 5))) + reason = str(j.get("reason", ""))[:200] + return ("keep" if keep else "delete"), score, reason + except Exception as e: + _log.warning(f"经验评审失败(按保留处理): {e}") + return "keep", 0, f"评审失败自动保留: {str(e)[:80]}" + + +def run_experience_audit(): + """巡检一轮:逐条评审经验 → 写 experience_audit。 + + 规则: + - 最新已 kept / deleted 的经验不再重复评审(人工拍板过的尊重) + - 评审 keep → 直接 action=kept;delete → action=pending(等人工确认) + - 绝不自动删除;无 api_key 配置时跳过并记日志 + 由 web_server 每日定时调度或 API 手动触发(后台线程)。 + """ + if _audit_state["running"]: + _log.info("经验巡检已在运行,跳过本次触发") + return + _audit_state["running"] = True + try: + if _flask_app is None: + return + with _flask_app.app_context(): + _ensure_exp_table() + _ensure_audit_table() + cfg = _read_cfg() + if not cfg.get("api_key"): + _log.info("经验巡检跳过:未配置 API Key") + return + rows = db.session.execute(db.text( + "SELECT id, task_prompt, recipe, tool_seq, hits FROM agent_experience " + "ORDER BY id")).fetchall() + if not rows: + _log.info("经验巡检:经验库为空") + return + from datetime import datetime + now = datetime.now().strftime("%Y-%m-%d %H:%M") + reviewed = suggested = 0 + for exp_id, prompt, recipe, tool_seq, hits in rows: + # 最新一次人工/评审结论:kept / deleted 的不再打扰 + last = db.session.execute(db.text( + "SELECT action FROM experience_audit WHERE exp_id=:e " + "ORDER BY id DESC LIMIT 1"), {"e": exp_id}).scalar() + if last in ("kept", "deleted"): + continue + verdict, score, reason = _review_one_exp( + cfg, prompt, recipe, tool_seq, hits) + action = "kept" if verdict == "keep" else "pending" + db.session.execute(db.text( + "INSERT INTO experience_audit" + "(exp_id, verdict, score, reason, hits, action, audited_at) " + "VALUES(:e, :v, :s, :r, :h, :a, :t)"), + {"e": exp_id, "v": verdict, "s": score, "r": reason, + "h": hits or 0, "a": action, "t": now}) + reviewed += 1 + suggested += 1 if action == "pending" else 0 + db.session.commit() + summary = f"评审 {reviewed} 条,建议删除 {suggested} 条(待人工确认)" + _audit_state.update(last=now, last_summary=summary) + _log.info("经验巡检完成: %s", summary) + except Exception as e: + _log.warning(f"经验巡检异常: {e}") + finally: + _audit_state["running"] = False + + +# ================== 经验库管理 API ================== +@bp.route("/api/agent/experience") +@admin_required +def agent_experience_list(): + """经验库列表(含最近一次巡检结论),供 AI 控制台「🧠 经验库」面板管理。""" + _ensure_exp_table() + _ensure_audit_table() + rows = db.session.execute(db.text( + "SELECT e.id, e.task_prompt, e.recipe, e.tool_seq, e.hits, e.created_at, " + "a.verdict, a.score, a.reason, a.action, a.audited_at " + "FROM agent_experience e LEFT JOIN experience_audit a ON a.id = " + "(SELECT id FROM experience_audit WHERE exp_id = e.id ORDER BY id DESC LIMIT 1) " + "ORDER BY e.id DESC")).fetchall() + exps = [] + for r in rows: + (eid, task_prompt, recipe, tool_seq, hits, created_at, + verdict, score, reason, action, audited_at) = r + audit = None + if action: + audit = {"verdict": verdict, "score": round(score or 0, 1), + "reason": reason, "action": action, "at": audited_at} + exps.append({"id": eid, "task_prompt": task_prompt or "", + "recipe": recipe or "", "tool_seq": tool_seq or "", + "hits": hits or 0, "created_at": created_at or "", + "audit": audit}) + return jsonify({"ok": True, "running": _audit_state["running"], + "last": _audit_state["last"], + "last_summary": _audit_state["last_summary"], + "experiences": exps}) + + +@bp.route("/api/agent/experience/delete", methods=["POST"]) +@admin_required +def agent_experience_delete(): + """人工确认删除经验(真删;前端有二次确认,巡检绝不自动调它)。""" + data = request.json or {} + exp_id = int(data.get("id") or 0) + if exp_id <= 0: + return jsonify({"ok": False, "error": "缺少 id"}), 400 + db.session.execute(db.text( + "DELETE FROM agent_experience WHERE id=:i"), {"i": exp_id}) + db.session.execute(db.text( + "UPDATE experience_audit SET action='deleted' " + "WHERE exp_id=:i AND action='pending'"), {"i": exp_id}) + db.session.commit() + _log.info("人工确认删除经验 #%d", exp_id) + return jsonify({"ok": True, "msg": f"经验 #{exp_id} 已删除"}) + + +@bp.route("/api/agent/experience/audit", methods=["POST"]) +@admin_required +def agent_experience_audit_now(): + """手动触发一轮经验巡检(后台线程;进行中返回 409)。""" + if _audit_state["running"]: + return jsonify({"ok": False, "error": "巡检正在进行中"}), 409 + threading.Thread(target=run_experience_audit, daemon=True).start() + return jsonify({"ok": True, "msg": "巡检已启动,完成后刷新列表查看建议"}) + + +@bp.route("/api/agent/experience/keep", methods=["POST"]) +@admin_required +def agent_experience_keep(): + """人工保留:撤销建议删除(该条后续巡检不再重复建议)。""" + data = request.json or {} + exp_id = int(data.get("id") or 0) + if exp_id <= 0: + return jsonify({"ok": False, "error": "缺少 id"}), 400 + db.session.execute(db.text( + "UPDATE experience_audit SET action='kept' " + "WHERE exp_id=:i AND action='pending'"), {"i": exp_id}) + db.session.commit() + return jsonify({"ok": True, "msg": f"经验 #{exp_id} 已保留"}) + @bp.route("/api/agent/config", methods=["GET"]) @admin_required def agent_config_get(): diff --git a/web_server.py b/web_server.py index ff88caa..f4c726c 100644 --- a/web_server.py +++ b/web_server.py @@ -57,6 +57,19 @@ from web.auth import _csrf_protect, _csrf_token context.init(mgr, apk_mgr, device_pool) register_blueprints(app) +# 经验库每日 AI 巡检(凌晨 03:47):评审标记疑似问题经验,删除只走人工确认。 +# 巡检线程在 run_experience_audit 内自建 app context,不依赖这里。 +try: + from apscheduler.schedulers.background import BackgroundScheduler + from apscheduler.triggers.cron import CronTrigger + from web.agent_api import run_experience_audit + _audit_sched = BackgroundScheduler(timezone="Asia/Shanghai") + _audit_sched.add_job(run_experience_audit, CronTrigger(hour=3, minute=47)) + _audit_sched.start() + _log.info("经验库每日巡检已注册(03:47 Asia/Shanghai)") +except Exception as e: + _log.warning(f"经验库巡检调度注册失败(不影响主服务): {e}") + @login_manager.user_loader def load_user(user_id):