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 建议理由:' + 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 =
+ '
日志查看
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):