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 建议理由:' + 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):