| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231 |
- #!/usr/bin/env python3
- """
- quality_tuner.py — 质量阈值自动调优器
- 调优结果写入 evolution/thresholds.json,不修改 SKILL.md。
- """
- import sys, os, re, json, math
- from pathlib import Path
- from typing import Any
- sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
- from utils import (
- find_project_root, get_evolution_dir,
- load_evolution, save_evolution, parse_quality_log,
- extract_quality_features, log_evolution_event,
- )
- DEFAULT_THRESHOLDS = {
- "pass": 80, "revise_low": 60, "rewrite_low": 40,
- "max_revision_rounds": 5,
- "weights": {"P1": 0.35, "P2": 0.25, "P3": 0.15, "P4": 0.15, "P5": 0.10},
- "stale_hook_limit": 10, "burst_hook_limit": 5,
- "no_advance_limit": 5,
- "payoff_interval_min": 300, "payoff_interval_max": 500,
- "weak_hook_consecutive_max": 3,
- }
- class QualityTuner:
- def __init__(self, thresholds: dict | None = None):
- self.thresholds = thresholds or load_evolution("thresholds.json") or DEFAULT_THRESHOLDS.copy()
- self.history: list[dict] = []
- self.adjustments: list[dict] = []
- def load_history(self, project: Path) -> None:
- self.history = parse_quality_log(project)
- def analyze(self) -> dict:
- if not self.history:
- return {"status": "no_data"}
- features = extract_quality_features(self.history)
- suggestions = []
- recent = [r for r in self.history[-10:] if r.get("score")]
- recent_scores = [r["score"] for r in recent]
- pass_rate = sum(1 for s in recent_scores if s >= self.thresholds["pass"]) / max(len(recent_scores), 1)
- if pass_rate > 0.9 and len(recent_scores) >= 5:
- suggestions.append({
- "type": "pass_threshold_raise",
- "current": self.thresholds["pass"],
- "suggested": min(88, self.thresholds["pass"] + 3),
- "reason": f"近期通过率 {pass_rate:.0%},阈值可能偏低",
- "confidence": 0.7,
- })
- elif pass_rate < 0.3 and len(recent_scores) >= 5:
- suggestions.append({
- "type": "pass_threshold_lower",
- "current": self.thresholds["pass"],
- "suggested": max(70, self.thresholds["pass"] - 3),
- "reason": f"近期通过率 {pass_rate:.0%},阈值可能偏高",
- "confidence": 0.6,
- })
- pass_weights = ["P1", "P2", "P3", "P4", "P5"]
- pass_scores = {}
- for p in pass_weights:
- scores = [r.get(f"score_{p.lower()}", 0) for r in self.history if f"score_{p.lower()}" in r]
- if scores:
- pass_scores[p] = sum(scores) / len(scores)
- if pass_scores:
- weakest = min(pass_scores, key=pass_scores.get)
- strongest = max(pass_scores, key=pass_scores.get)
- if pass_scores[strongest] - pass_scores[weakest] > 15:
- current_weight = self.thresholds["weights"][weakest]
- new_weight = min(0.40, current_weight + 0.05)
- suggestions.append({
- "type": "weight_adjust",
- "target_pass": weakest,
- "current_weight": current_weight,
- "suggested_weight": new_weight,
- "reason": f"{weakest} 最弱 ({pass_scores[weakest]:.0f}),增权",
- "confidence": 0.75,
- "redistribute_from": strongest,
- })
- rounds = [r.get("revision_rounds", 1) for r in self.history if r.get("revision_rounds")]
- avg_rounds = sum(rounds) / len(rounds) if rounds else 1.0
- if avg_rounds > 3:
- suggestions.append({
- "type": "root_cause_warning",
- "reason": f"平均修订 {avg_rounds:.1f} 轮,建议向上追溯大纲/设定问题",
- "confidence": 0.8,
- })
- s1_recent = sum(r.get("s1_count", 0) for r in recent)
- if s1_recent > 0:
- suggestions.append({
- "type": "quality_alert", "level": "S1", "count": s1_recent,
- "reason": f"近10章有 {s1_recent} 个S1致命问题",
- "confidence": 0.9,
- })
- return {
- "status": "ok", "features": features, "pass_rate": pass_rate,
- "recent_scores": recent_scores, "pass_scores": pass_scores,
- "suggestions": suggestions, "avg_revision_rounds": avg_rounds,
- }
- def apply(self, suggestions: list[dict], auto_apply: bool = False) -> list[dict]:
- applied = []
- for s in suggestions:
- if s.get("type") == "quality_alert":
- applied.append({**s, "applied": False})
- continue
- if not auto_apply and s.get("confidence", 0) < 0.7:
- applied.append({**s, "applied": False})
- continue
- if s["type"] in ("pass_threshold_raise", "pass_threshold_lower"):
- old = self.thresholds["pass"]
- self.thresholds["pass"] = s["suggested"]
- self.adjustments.append({"what": "pass", "from": old, "to": s["suggested"]})
- applied.append({**s, "applied": True})
- elif s["type"] == "weight_adjust":
- target = s["target_pass"]
- old_w = self.thresholds["weights"][target]
- delta = s["suggested_weight"] - old_w
- self.thresholds["weights"][target] = s["suggested_weight"]
- # 从其他维度扣减 delta,每维度不低于 floor=0.05
- floor = 0.05
- donors = sorted(
- [(dim, w) for dim, w in self.thresholds["weights"].items()
- if dim != target and w > floor],
- key=lambda x: x[1], reverse=True
- )
- remaining = delta
- for dim, w in donors:
- if remaining <= 0:
- break
- available = w - floor
- take = min(available, remaining)
- self.thresholds["weights"][dim] -= take
- remaining -= take
- # 归一化到 1.0,避免浮点累积
- total = sum(self.thresholds["weights"].values())
- for dim in self.thresholds["weights"]:
- self.thresholds["weights"][dim] = round(
- self.thresholds["weights"][dim] / total, 10
- )
- self.adjustments.append({"what": f"weight_{target}", "from": old_w,
- "to": self.thresholds["weights"][target]})
- applied.append({**s, "applied": True})
- return applied
- def sync_to_evolution(self) -> None:
- """将当前阈值写入 evolution/thresholds.json"""
- save_evolution("thresholds.json", self.thresholds)
- def sync_to_genre_evolution(self, genre_id: str) -> None:
- """将当前阈值写入 evolution/genres/{genre_id}/thresholds.json"""
- from utils import save_genre_evolution
- save_genre_evolution(genre_id, "thresholds.json", self.thresholds)
- # ── CLI ─────────────────────────────────────────────────
- def analyze_and_tune(project: Path, auto_apply: bool = False) -> dict:
- print(f"🔧 质量阈值调优: {project.name}")
- tuner = QualityTuner()
- tuner.load_history(project)
- analysis = tuner.analyze()
- if analysis["status"] == "no_data":
- print(" ⚠ 没有质检历史数据,使用当前阈值")
- return analysis
- features = analysis.get("features", {})
- avg = features.get('avg_score', 0)
- print(f" 平均得分: {avg:.1f}" if isinstance(avg, (int, float)) else f" 平均得分: N/A")
- print(f" 通过率: {analysis.get('pass_rate', 0):.0%}")
- print(f" S1/S2: {features.get('total_s1', 0)}/{features.get('total_s2', 0)}")
- suggestions = analysis.get("suggestions", [])
- if suggestions:
- print(f"\n {len(suggestions)} 条优化建议:")
- for s in suggestions:
- print(f" [{s['type']}] {s.get('reason', '')}")
- applied = tuner.apply(suggestions, auto_apply=auto_apply)
- applied_count = sum(1 for a in applied if a.get("applied"))
- print(f"\n 应用了 {applied_count} 条,跳过 {len(applied) - applied_count} 条")
- else:
- print(" ✓ 阈值配置合理,无需调整")
- applied = []
- from utils import find_project_genre, map_genre_to_inkos_id
- genre_id = map_genre_to_inkos_id(find_project_genre(project))
- tuner.sync_to_evolution()
- tuner.sync_to_genre_evolution(genre_id)
- print(f" ✓ 阈值已同步到 skill 级和题材级 evolution/genres/{genre_id}/")
- return {"analysis": analysis, "applied": applied}
- if __name__ == "__main__":
- import argparse
- parser = argparse.ArgumentParser(description="质量阈值调优器")
- parser.add_argument("action", choices=["analyze", "tune", "show"],
- help="analyze=分析 | tune=分析并应用 | show=查看当前阈值")
- parser.add_argument("--project", "-p", default=".")
- parser.add_argument("--auto", action="store_true")
- args = parser.parse_args()
- if args.action == "show":
- thresholds = load_evolution("thresholds.json") or DEFAULT_THRESHOLDS
- print(json.dumps(thresholds, ensure_ascii=False, indent=2))
- elif args.action in ("analyze", "tune"):
- root = find_project_root(Path(args.project).resolve())
- if not root:
- print("错误: 未找到小说项目根目录")
- sys.exit(1)
- auto = args.auto or (args.action == "tune")
- result = analyze_and_tune(root, auto_apply=auto)
- if args.action == "tune" and result.get("applied"):
- log_evolution_event(root, "质量阈值调优", {
- "应用": len([a for a in result["applied"] if a.get("applied")]),
- })
|