"""智能采纳:按策略自动处理一批草稿,只对证据足够硬的条目动手,其余留给人。 为什么不是「模型说采纳就采纳」:模型偶尔会把本次任务的具体数据当成可复用知识, 一条错的系统规则会让 Agent 以后一直绕开正确操作。所以要求多路信号一致,且分级: 自动采纳 需要同时满足:类别属于控件技巧 / 入口路径、规则层建议采纳、 模型层(配了才算)也建议采纳、证据够硬(跨校观察或观察次数达标) 自动驳回 只处理明显的垃圾(内容过短、整条基本只剩日期 / 班级 / 人名), 且标记为 auto —— 再被观察到会退回队列,不像人工驳回那样是终局 其余 一律留给人,包括所有系统规则类 每次都能先干跑(dry_run)看会动哪些,确认了再真跑。单次有条数上限, 避免一次模型抽风批量放行。 """ from __future__ import annotations import asyncio from dataclasses import dataclass from server import review from server.config import settings from server.db import database as db from shared import knowledge_match # 只有这两类可能被自动采纳;系统规则和其他类永远要人看 AUTO_ADOPT_CATEGORIES = knowledge_match.AUTO_VERIFY_CATEGORIES @dataclass class Policy: """自动采纳的门槛,越严越安全。""" min_schools: int = 2 # 至少几所学校各自观察到,才算跨校成立 min_observations: int = 3 # 单校时至少观察多少次 min_trials: int = 2 # Agent 在真实页面上验证相符几次,算实测通过 require_llm: bool = True # 配了模型 Key 时,是否要求模型也同意 auto_reject: bool = True # 是否自动清掉明显的垃圾 max_actions: int = 50 # 单次最多处理多少条 @classmethod def from_settings(cls) -> "Policy": return cls( min_schools=settings.auto_min_schools, min_observations=settings.auto_min_observations, min_trials=settings.auto_min_trials, require_llm=settings.auto_require_llm, auto_reject=settings.auto_reject, max_actions=settings.auto_max_actions, ) def trial_proven(entry: dict, policy: Policy) -> bool: """这条是否已被 Agent 在真实页面上验证过:相符够次数且从没被证伪。 这是最硬的证据。「几所学校推导出同一条」只说明它被反复想到, 「Agent 去页面上对过」才说明它是真的。 """ return int(entry.get("confirmed") or 0) >= policy.min_trials and int(entry.get("contradicted") or 0) == 0 def _evidence_ok(entry: dict, policy: Policy) -> tuple[bool, str]: schools = int(entry.get("school_count") or 1) observations = int(entry.get("observations") or 1) confirmed = int(entry.get("confirmed") or 0) if trial_proven(entry, policy): return True, f"Agent 在真实页面上验证相符 {confirmed} 次" if schools >= policy.min_schools: return True, f"{schools} 所学校各自观察到" if observations >= policy.min_observations: return True, f"单校观察 {observations} 次" extra = f",实测相符 {confirmed} 次" if confirmed else "" return False, f"证据不足({schools} 校 / 观察 {observations} 次{extra})" def decide(entry: dict, rule: tuple[str, str], llm: tuple[str, str] | None, policy: Policy) -> dict: """对一条草稿给出处置。返回 {action, scope, reason},action 为 verify / reject / hold。 两条路都能走到自动采纳,但都要先过类别硬门槛(只有控件技巧 / 入口路径): A 证据够硬:跨校观察或观察次数达标。模型配了且要求一致时,模型不能反对。 B 模型背书:证据薄,但模型读过内容、比对过已采纳条目后明确说可复用。 这条路发布范围强制为「仅来源学校」——没有跨校证据就不往别的学校推。 自动驳回同样要两路一致,且标记为 auto(暂缓,不是终局)。 """ rule_verdict, rule_reason = rule llm_verdict, llm_reason = llm if llm else (None, "") llm_active = bool(llm and settings.llm_api_key) category = entry.get("category") or "note" label = knowledge_match.CATEGORY_LABELS.get(category, category) def note(*parts: str) -> str: return ";".join(p for p in parts if p) # ---- 自动驳回:明显的垃圾,两路不能有人说好 ---- junk = rule_verdict == "reject" and llm_verdict in (None, "reject") llm_says_junk = llm_verdict == "reject" and rule_verdict != "adopt" if junk or llm_says_junk: if not policy.auto_reject: return {"action": "hold", "scope": None, "reason": note("疑似无效,但自动驳回已关闭", rule_reason)} return {"action": "reject", "scope": None, "reason": note(rule_reason if junk else "模型判定为一次性数据或重复", llm_reason[:80] if llm_reason else "")} # ---- 类别门槛:系统规则和其他类只有一条路能过,就是 Agent 在真实页面上验过 ---- # 当初禁止自动采纳规则,理由是「读不出真假」。实测不是读:Agent 去页面上对过了, # 这比任何人读一遍都强。所以实测通过就放行,靠猜(规则层启发式、模型意见)的一律不放行。 if category not in AUTO_ADOPT_CATEGORIES: if trial_proven(entry, policy): confirmed = int(entry.get("confirmed") or 0) return {"action": "verify", "scope": "origin", "reason": note(f"{label}类,但已被 Agent 在真实页面上验证相符 {confirmed} 次且从未被证伪")} return {"action": "hold", "scope": None, "reason": f"{label}类影响后续所有执行,必须人工确认或等 Agent 在页面上验证通过。"} if rule_verdict == "reject": return {"action": "hold", "scope": None, "reason": note("规则层与模型层意见相反", rule_reason)} evidence_ok, why = _evidence_ok(entry, policy) llm_adopts = llm_verdict == "adopt" llm_blocks = llm_active and policy.require_llm and not llm_adopts proven = trial_proven(entry, policy) # A 路:证据够硬。实测通过时不再要求规则层也点头——规则层只是启发式, # 「Agent 去页面上对过两次」压过「启发式拿不准」。但规则层明确说 reject 的上面已经拦掉了。 if evidence_ok and (rule_verdict == "adopt" or proven): if llm_blocks: return {"action": "hold", "scope": None, "reason": note("证据够但模型不认可", llm_reason[:80] or "无理由")} schools = int(entry.get("school_count") or 1) scope = "all" if schools >= policy.min_schools else "origin" return {"action": "verify", "scope": scope, "reason": note(why, "规则层与模型层一致" if llm_adopts else "规则层认可")} # 模型认可但同时想改写:原文不够好,自动采纳原文没意义,留给人确认改写 if llm_adopts and review.REWRITE_MARKER in llm_reason: return {"action": "hold", "scope": None, "reason": note("模型认可但建议改写,改写后再采纳", llm_reason[:120])} # B 路:证据薄,但模型明确背书。只发回来源学校 if llm_adopts: return {"action": "verify", "scope": "origin", "reason": note(f"{label}类,模型判定为可复用知识", llm_reason[:80], f"证据尚薄({why}),先只发回来源学校")} if llm_active: return {"action": "hold", "scope": None, "reason": note(why, "模型也未明确认可")} return {"action": "hold", "scope": None, "reason": note(why, "未配模型 Key,无法进一步判断")} async def _evaluate(entry: dict, policy: Policy, use_llm: bool) -> dict: rule = review.rule_suggestion(entry) llm = None if use_llm and settings.llm_api_key: siblings = [ e for e in db.list_entries(status="verified", knowledge_id=entry["knowledge_id"], business_id=entry["business_id"], limit=20) if e["id"] != entry["id"] ] llm = await review.llm_suggestion(entry, siblings) plan = decide(entry, rule, llm, policy) return { "id": entry["id"], "key": entry["key"], "value": entry["value"][:200], "category": entry["category"], "school_count": entry["school_count"], "observations": entry["observations"], **plan, } async def run(dry_run: bool = True, policy: Policy | None = None, limit: int | None = None, ids: list[int] | None = None, use_llm: bool = True) -> dict: """跑一遍智能采纳。dry_run=True 只给出计划,不改任何状态。""" policy = policy or Policy.from_settings() cap = limit or policy.max_actions entries = ([db.get_entry(i) for i in ids] if ids else db.list_entries(status="draft", limit=cap * 4)) entries = [e for e in entries if e][:cap * 4] plans = await asyncio.gather(*(_evaluate(e, policy, use_llm) for e in entries)) verify = [p for p in plans if p["action"] == "verify"][:cap] reject = [p for p in plans if p["action"] == "reject"][:cap] hold = [p for p in plans if p["action"] == "hold"] if not dry_run: for plan in verify: db.review_entry(plan["id"], "verify", scope=plan["scope"], by="auto") db.save_suggestion(plan["id"], "adopt", f"自动采纳:{plan['reason']}") for plan in reject: db.review_entry(plan["id"], "reject", by="auto") db.save_suggestion(plan["id"], "reject", f"自动驳回(暂缓,再被观察到会退回队列):{plan['reason']}") for plan in hold: db.save_suggestion(plan["id"], "review", f"留给人工:{plan['reason']}") return { "dry_run": dry_run, "examined": len(plans), "verified": len(verify), "rejected": len(reject), "held": len(hold), "policy": { "min_schools": policy.min_schools, "min_observations": policy.min_observations, "require_llm": policy.require_llm and bool(settings.llm_api_key), "auto_reject": policy.auto_reject, "max_actions": policy.max_actions, }, "plans": {"verify": verify, "reject": reject, "hold": hold[:50]}, }