quality_tuner.py 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. #!/usr/bin/env python3
  2. """
  3. quality_tuner.py — 质量阈值自动调优器
  4. 调优结果写入 evolution/thresholds.json,不修改 SKILL.md。
  5. """
  6. import sys, os, re, json, math
  7. from pathlib import Path
  8. from typing import Any
  9. sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
  10. from utils import (
  11. find_project_root, get_evolution_dir,
  12. load_evolution, save_evolution, parse_quality_log,
  13. extract_quality_features, log_evolution_event,
  14. )
  15. DEFAULT_THRESHOLDS = {
  16. "pass": 80, "revise_low": 60, "rewrite_low": 40,
  17. "max_revision_rounds": 5,
  18. "weights": {"P1": 0.35, "P2": 0.25, "P3": 0.15, "P4": 0.15, "P5": 0.10},
  19. "stale_hook_limit": 10, "burst_hook_limit": 5,
  20. "no_advance_limit": 5,
  21. "payoff_interval_min": 2000, "payoff_interval_max": 5000,
  22. "weak_hook_consecutive_max": 3,
  23. }
  24. class QualityTuner:
  25. def __init__(self, thresholds: dict | None = None):
  26. self.thresholds = thresholds or load_evolution("thresholds.json") or DEFAULT_THRESHOLDS.copy()
  27. self.history: list[dict] = []
  28. self.adjustments: list[dict] = []
  29. def load_history(self, project: Path) -> None:
  30. self.history = parse_quality_log(project)
  31. def analyze(self) -> dict:
  32. if not self.history:
  33. return {"status": "no_data"}
  34. features = extract_quality_features(self.history)
  35. suggestions = []
  36. recent = [r for r in self.history[-10:] if r.get("score")]
  37. recent_scores = [r["score"] for r in recent]
  38. pass_rate = sum(1 for s in recent_scores if s >= self.thresholds["pass"]) / max(len(recent_scores), 1)
  39. if pass_rate > 0.9 and len(recent_scores) >= 5:
  40. suggestions.append({
  41. "type": "pass_threshold_raise",
  42. "current": self.thresholds["pass"],
  43. "suggested": min(88, self.thresholds["pass"] + 3),
  44. "reason": f"近期通过率 {pass_rate:.0%},阈值可能偏低",
  45. "confidence": 0.7,
  46. })
  47. elif pass_rate < 0.3 and len(recent_scores) >= 5:
  48. suggestions.append({
  49. "type": "pass_threshold_lower",
  50. "current": self.thresholds["pass"],
  51. "suggested": max(70, self.thresholds["pass"] - 3),
  52. "reason": f"近期通过率 {pass_rate:.0%},阈值可能偏高",
  53. "confidence": 0.6,
  54. })
  55. pass_weights = ["P1", "P2", "P3", "P4", "P5"]
  56. pass_scores = {}
  57. for p in pass_weights:
  58. scores = [r.get(f"score_{p.lower()}", 0) for r in self.history if f"score_{p.lower()}" in r]
  59. if scores:
  60. pass_scores[p] = sum(scores) / len(scores)
  61. if pass_scores:
  62. weakest = min(pass_scores, key=pass_scores.get)
  63. strongest = max(pass_scores, key=pass_scores.get)
  64. if pass_scores[strongest] - pass_scores[weakest] > 15:
  65. current_weight = self.thresholds["weights"][weakest]
  66. new_weight = min(0.40, current_weight + 0.05)
  67. suggestions.append({
  68. "type": "weight_adjust",
  69. "target_pass": weakest,
  70. "current_weight": current_weight,
  71. "suggested_weight": new_weight,
  72. "reason": f"{weakest} 最弱 ({pass_scores[weakest]:.0f}),增权",
  73. "confidence": 0.75,
  74. "redistribute_from": strongest,
  75. })
  76. rounds = [r.get("revision_rounds", 1) for r in self.history if r.get("revision_rounds")]
  77. avg_rounds = sum(rounds) / len(rounds) if rounds else 1.0
  78. if avg_rounds > 3:
  79. suggestions.append({
  80. "type": "root_cause_warning",
  81. "reason": f"平均修订 {avg_rounds:.1f} 轮,建议向上追溯大纲/设定问题",
  82. "confidence": 0.8,
  83. })
  84. s1_recent = sum(r.get("s1_count", 0) for r in recent)
  85. if s1_recent > 0:
  86. suggestions.append({
  87. "type": "quality_alert", "level": "S1", "count": s1_recent,
  88. "reason": f"近10章有 {s1_recent} 个S1致命问题",
  89. "confidence": 0.9,
  90. })
  91. return {
  92. "status": "ok", "features": features, "pass_rate": pass_rate,
  93. "recent_scores": recent_scores, "pass_scores": pass_scores,
  94. "suggestions": suggestions, "avg_revision_rounds": avg_rounds,
  95. }
  96. def apply(self, suggestions: list[dict], auto_apply: bool = False) -> list[dict]:
  97. applied = []
  98. for s in suggestions:
  99. if s.get("type") == "quality_alert":
  100. applied.append({**s, "applied": False})
  101. continue
  102. if not auto_apply and s.get("confidence", 0) < 0.7:
  103. applied.append({**s, "applied": False})
  104. continue
  105. if s["type"] in ("pass_threshold_raise", "pass_threshold_lower"):
  106. old = self.thresholds["pass"]
  107. self.thresholds["pass"] = s["suggested"]
  108. self.adjustments.append({"what": "pass", "from": old, "to": s["suggested"]})
  109. applied.append({**s, "applied": True})
  110. elif s["type"] == "weight_adjust":
  111. target = s["target_pass"]
  112. redistribute = s.get("redistribute_from")
  113. old_w = self.thresholds["weights"][target]
  114. self.thresholds["weights"][target] = s["suggested_weight"]
  115. if redistribute:
  116. self.thresholds["weights"][redistribute] -= (s["suggested_weight"] - old_w)
  117. self.adjustments.append({"what": f"weight_{target}", "from": old_w, "to": s["suggested_weight"]})
  118. applied.append({**s, "applied": True})
  119. return applied
  120. def sync_to_evolution(self) -> None:
  121. """将当前阈值写入 evolution/thresholds.json"""
  122. save_evolution("thresholds.json", self.thresholds)
  123. def sync_to_genre_evolution(self, genre_id: str) -> None:
  124. """将当前阈值写入 evolution/genres/{genre_id}/thresholds.json"""
  125. from utils import save_genre_evolution
  126. save_genre_evolution(genre_id, "thresholds.json", self.thresholds)
  127. # ── CLI ─────────────────────────────────────────────────
  128. def analyze_and_tune(project: Path, auto_apply: bool = False) -> dict:
  129. print(f"🔧 质量阈值调优: {project.name}")
  130. tuner = QualityTuner()
  131. tuner.load_history(project)
  132. analysis = tuner.analyze()
  133. if analysis["status"] == "no_data":
  134. print(" ⚠ 没有质检历史数据,使用当前阈值")
  135. return analysis
  136. features = analysis.get("features", {})
  137. avg = features.get('avg_score', 0)
  138. print(f" 平均得分: {avg:.1f}" if isinstance(avg, (int, float)) else f" 平均得分: N/A")
  139. print(f" 通过率: {analysis.get('pass_rate', 0):.0%}")
  140. print(f" S1/S2: {features.get('total_s1', 0)}/{features.get('total_s2', 0)}")
  141. suggestions = analysis.get("suggestions", [])
  142. if suggestions:
  143. print(f"\n {len(suggestions)} 条优化建议:")
  144. for s in suggestions:
  145. print(f" [{s['type']}] {s.get('reason', '')}")
  146. applied = tuner.apply(suggestions, auto_apply=auto_apply)
  147. applied_count = sum(1 for a in applied if a.get("applied"))
  148. print(f"\n 应用了 {applied_count} 条,跳过 {len(applied) - applied_count} 条")
  149. else:
  150. print(" ✓ 阈值配置合理,无需调整")
  151. applied = []
  152. from utils import find_project_genre, map_genre_to_inkos_id
  153. genre_id = map_genre_to_inkos_id(find_project_genre(project))
  154. tuner.sync_to_evolution()
  155. tuner.sync_to_genre_evolution(genre_id)
  156. print(f" ✓ 阈值已同步到 skill 级和题材级 evolution/genres/{genre_id}/")
  157. return {"analysis": analysis, "applied": applied}
  158. if __name__ == "__main__":
  159. import argparse
  160. parser = argparse.ArgumentParser(description="质量阈值调优器")
  161. parser.add_argument("action", choices=["analyze", "tune", "show"],
  162. help="analyze=分析 | tune=分析并应用 | show=查看当前阈值")
  163. parser.add_argument("--project", "-p", default=".")
  164. parser.add_argument("--auto", action="store_true")
  165. args = parser.parse_args()
  166. if args.action == "show":
  167. thresholds = load_evolution("thresholds.json") or DEFAULT_THRESHOLDS
  168. print(json.dumps(thresholds, ensure_ascii=False, indent=2))
  169. elif args.action in ("analyze", "tune"):
  170. root = find_project_root(Path(args.project).resolve())
  171. if not root:
  172. print("错误: 未找到小说项目根目录")
  173. sys.exit(1)
  174. auto = args.auto or (args.action == "tune")
  175. result = analyze_and_tune(root, auto_apply=auto)
  176. if args.action == "tune" and result.get("applied"):
  177. log_evolution_event(root, "质量阈值调优", {
  178. "应用": len([a for a in result["applied"] if a.get("applied")]),
  179. })