"""Skill 的增量维护:按开放式规则 id 合并、prune 与 SKILL.md 生成。 防膨胀原则:提炼模型看到当前规则,语义相同时复用稳定 id;本模块按 id 合并来源, 而不是再用预置 detector 指纹把新发现筛掉。 长期未被新证据确认、或被评估证据推翻的规则归档到 skill/archive/,不再进入 SKILL.md。所有合并/激活/归档决定都发生在这里,不交给生成候选的模型。 """ from __future__ import annotations import json from pathlib import Path from typing import Any, Dict, List, Set, Tuple ROOT = Path(__file__).resolve().parent SKILL_DIR = ROOT / "skill" def rule_signature(rule: Dict[str, Any]) -> str: """模型在已有规则语义相同时复用 id;该稳定 id 就是合并键。""" return str(rule.get("id", "")).strip().lower() def merge_rules( existing: List[Dict[str, Any]], candidates: List[Dict[str, Any]] ) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]: """把候选规则合并进现有规则集,返回 (规则集, 合并报告)。""" rules = [dict(rule) for rule in existing] report: Dict[str, Any] = {"added": [], "merged": [], "conflicts": []} for cand in candidates: sig = rule_signature(cand) match = next( (rule for rule in rules if rule_signature(rule) == sig), None ) if match is None: rules.append(cand) report["added"].append(cand["id"]) continue # 去重合并:并集来源与作用域,保留首次通过 schema 检查的定义与范例。 match["source_ids"] = sorted(set(match.get("source_ids", [])) | set(cand.get("source_ids", []))) match["scope"] = sorted(set(match.get("scope", [])) | set(cand.get("scope", []))) report["merged"].append(cand["id"]) return rules, report def prune_rules( rules: List[Dict[str, Any]], *, current_batch: int, idle_batches: int = 2, contradicted_ids: Set[str] | None = None, ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: """把长期未被新证据确认、或被证据推翻的规则归档,返回 (存活, 归档)。""" contradicted = contradicted_ids or set() active, archived = [], [] for rule in rules: last = rule.get("last_confirmed_batch", current_batch) reason = None if rule["id"] in contradicted: reason = "被评估证据推翻(误伤率过高或与金标集冲突)" elif current_batch - last > idle_batches: reason = f"连续超过 {idle_batches} 批反馈未再被触发" if reason: archived.append({**rule, "status": "archived", "archive_reason": reason}) else: active.append(rule) return active, archived def _describe_detector(detector: Dict[str, Any]) -> str: if detector.get("type") != "llm": return "无效检测器(规则不会激活)" return "LLM judge 语义判定(上线前须通过独立人工金标集校准)" def render_skill_md(rules: List[Dict[str, Any]]) -> str: """按 house 风格渲染 SKILL.md:何时加载 + 每条规则的定义/坏例/好例/作用域。""" lines = [ "---", "name: ai-style", "description: 中文文案去「AI 味」检查清单,由用户纠正反馈持续提炼而来", "---", "", "# 去 AI 味写作 Skill", "", "## 何时加载", "", "当任务是用中文撰写或改写面向读者的文案(产品发布稿、公众号文章、邮件、README 等),", "或用户反馈文字「AI 味太重」「不像人写的」时,加载本 Skill。", "", "## 使用方式", "", "起草或改写时逐条对照下面的规则自查。每条规则都给出定义、可检查的检测方法、", "坏例与好例;规则只在声明的作用域内生效,作用域之外的文体不要套用。", "", f"## 规则清单(共 {len(rules)} 条)", "", ] for i, rule in enumerate(rules, 1): lines += [ f"### 规则 {i}:{rule['name']}(`{rule['id']}`)", "", f"- **定义**:{rule['definition']}", f"- **检测方法**:{_describe_detector(rule['detector'])}", f"- **坏例**:{rule.get('bad_example', '')}", f"- **好例**:{rule.get('good_example', '')}", f"- **改写建议**:{rule.get('rewrite_hint', '按定义改写。')}", f"- **作用域**:{'、'.join(rule.get('scope', [])) or '通用'}", f"- **来源反馈**:{', '.join(rule.get('source_ids', [])) or '无'}", "", ] return "\n".join(lines) def write_skill(rules: List[Dict[str, Any]], skill_dir: Path | None = None) -> Path: out_dir = skill_dir or SKILL_DIR out_dir.mkdir(parents=True, exist_ok=True) path = out_dir / "SKILL.md" path.write_text(render_skill_md(rules), encoding="utf-8") # 同步保存机器可读的规则集,供 evaluate/judge 直接加载。 (out_dir / "rules.json").write_text( json.dumps(rules, ensure_ascii=False, indent=2), encoding="utf-8" ) return path def write_archive(archived: List[Dict[str, Any]], skill_dir: Path | None = None) -> Path | None: if not archived: return None out_dir = (skill_dir or SKILL_DIR) / "archive" out_dir.mkdir(parents=True, exist_ok=True) lines = ["# 已归档规则", ""] for rule in archived: lines += [ f"## {rule['name']}(`{rule['id']}`)", f"- 归档原因:{rule.get('archive_reason', '未说明')}", f"- 原定义:{rule['definition']}", f"- 来源反馈:{', '.join(rule.get('source_ids', [])) or '无'}", "", ] path = out_dir / "ARCHIVED.md" path.write_text("\n".join(lines), encoding="utf-8") return path