quality_tuner.py 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  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": 300, "payoff_interval_max": 500,
  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. old_w = self.thresholds["weights"][target]
  113. delta = s["suggested_weight"] - old_w
  114. self.thresholds["weights"][target] = s["suggested_weight"]
  115. # 从其他维度扣减 delta,每维度不低于 floor=0.05
  116. floor = 0.05
  117. donors = sorted(
  118. [(dim, w) for dim, w in self.thresholds["weights"].items()
  119. if dim != target and w > floor],
  120. key=lambda x: x[1], reverse=True
  121. )
  122. remaining = delta
  123. for dim, w in donors:
  124. if remaining <= 0:
  125. break
  126. available = w - floor
  127. take = min(available, remaining)
  128. self.thresholds["weights"][dim] -= take
  129. remaining -= take
  130. # 归一化到 1.0,避免浮点累积
  131. total = sum(self.thresholds["weights"].values())
  132. for dim in self.thresholds["weights"]:
  133. self.thresholds["weights"][dim] = round(
  134. self.thresholds["weights"][dim] / total, 10
  135. )
  136. self.adjustments.append({"what": f"weight_{target}", "from": old_w,
  137. "to": self.thresholds["weights"][target]})
  138. applied.append({**s, "applied": True})
  139. return applied
  140. def sync_to_evolution(self) -> None:
  141. """将当前阈值写入 evolution/thresholds.json"""
  142. save_evolution("thresholds.json", self.thresholds)
  143. def sync_to_genre_evolution(self, genre_id: str) -> None:
  144. """将当前阈值写入 evolution/genres/{genre_id}/thresholds.json"""
  145. from utils import save_genre_evolution
  146. save_genre_evolution(genre_id, "thresholds.json", self.thresholds)
  147. # ── CLI ─────────────────────────────────────────────────
  148. def analyze_and_tune(project: Path, auto_apply: bool = False) -> dict:
  149. print(f"🔧 质量阈值调优: {project.name}")
  150. tuner = QualityTuner()
  151. tuner.load_history(project)
  152. analysis = tuner.analyze()
  153. if analysis["status"] == "no_data":
  154. print(" ⚠ 没有质检历史数据,使用当前阈值")
  155. return analysis
  156. features = analysis.get("features", {})
  157. avg = features.get('avg_score', 0)
  158. print(f" 平均得分: {avg:.1f}" if isinstance(avg, (int, float)) else f" 平均得分: N/A")
  159. print(f" 通过率: {analysis.get('pass_rate', 0):.0%}")
  160. print(f" S1/S2: {features.get('total_s1', 0)}/{features.get('total_s2', 0)}")
  161. suggestions = analysis.get("suggestions", [])
  162. if suggestions:
  163. print(f"\n {len(suggestions)} 条优化建议:")
  164. for s in suggestions:
  165. print(f" [{s['type']}] {s.get('reason', '')}")
  166. applied = tuner.apply(suggestions, auto_apply=auto_apply)
  167. applied_count = sum(1 for a in applied if a.get("applied"))
  168. print(f"\n 应用了 {applied_count} 条,跳过 {len(applied) - applied_count} 条")
  169. else:
  170. print(" ✓ 阈值配置合理,无需调整")
  171. applied = []
  172. from utils import find_project_genre, map_genre_to_inkos_id
  173. genre_id = map_genre_to_inkos_id(find_project_genre(project))
  174. tuner.sync_to_evolution()
  175. tuner.sync_to_genre_evolution(genre_id)
  176. print(f" ✓ 阈值已同步到 skill 级和题材级 evolution/genres/{genre_id}/")
  177. return {"analysis": analysis, "applied": applied}
  178. if __name__ == "__main__":
  179. import argparse
  180. parser = argparse.ArgumentParser(description="质量阈值调优器")
  181. parser.add_argument("action", choices=["analyze", "tune", "show"],
  182. help="analyze=分析 | tune=分析并应用 | show=查看当前阈值")
  183. parser.add_argument("--project", "-p", default=".")
  184. parser.add_argument("--auto", action="store_true")
  185. args = parser.parse_args()
  186. if args.action == "show":
  187. thresholds = load_evolution("thresholds.json") or DEFAULT_THRESHOLDS
  188. print(json.dumps(thresholds, ensure_ascii=False, indent=2))
  189. elif args.action in ("analyze", "tune"):
  190. root = find_project_root(Path(args.project).resolve())
  191. if not root:
  192. print("错误: 未找到小说项目根目录")
  193. sys.exit(1)
  194. auto = args.auto or (args.action == "tune")
  195. result = analyze_and_tune(root, auto_apply=auto)
  196. if args.action == "tune" and result.get("applied"):
  197. log_evolution_event(root, "质量阈值调优", {
  198. "应用": len([a for a in result["applied"] if a.get("applied")]),
  199. })