feat: 经验库每日 AI 巡检 + 管理面板——①experience_audit 表:逐条评审(具体可执行/无过时坐标/非对话续语/配方与序列一致 checklist),keep 直接归档、delete 标 pending 待人工,绝不自动删;人工 kept 后不再重复建议;②web_server 注册每日 03:47 巡检 job(Asia/Shanghai);③API:经验列表(含巡检建议)/确认删除/保留/手动触发巡检;④AI 控制台右上角「🧠 经验库」面板:经验卡片 + 建议徽章与理由 + 删除(confirm)/保留按钮
This commit is contained in:
+212
-1
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user