pattern_mine.py 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  1. #!/usr/bin/env python3
  2. """
  3. pattern_mine.py — 写作模式挖掘器
  4. MAP-Elites 质量-多样性搜索,结果写入 evolution/patterns/。
  5. """
  6. import sys, os, re, json, random, hashlib
  7. from pathlib import Path
  8. from collections import defaultdict
  9. from typing import Any
  10. sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
  11. from utils import (
  12. find_project_root, get_evolution_dir,
  13. load_evolution, save_evolution, parse_chapters,
  14. log_evolution_event,
  15. )
  16. class WritingPattern:
  17. def __init__(self, name: str, category: str, template: str,
  18. complexity: float = 0.5, diversity: float = 0.5, quality_score: float = 0.0):
  19. self.name = name
  20. self.category = category
  21. self.template = template
  22. self.complexity = complexity
  23. self.diversity = diversity
  24. self.quality_score = quality_score
  25. self.usage_count = 0
  26. self.success_count = 0
  27. self.fingerprint = hashlib.md5(f"{category}:{template[:100]}".encode()).hexdigest()[:8]
  28. def to_dict(self) -> dict:
  29. return {"name": self.name, "category": self.category, "template": self.template,
  30. "complexity": self.complexity, "diversity": self.diversity,
  31. "quality_score": self.quality_score, "usage_count": self.usage_count,
  32. "success_count": self.success_count, "fingerprint": self.fingerprint}
  33. @classmethod
  34. def from_dict(cls, d: dict) -> "WritingPattern":
  35. p = cls(d["name"], d["category"], d["template"],
  36. d.get("complexity", 0.5), d.get("diversity", 0.5), d.get("quality_score", 0.0))
  37. p.usage_count = d.get("usage_count", 0)
  38. p.success_count = d.get("success_count", 0)
  39. p.fingerprint = d.get("fingerprint", p.fingerprint)
  40. return p
  41. class PatternExtractor:
  42. HOOK_PATTERNS = [
  43. (r"就在这时[,,](.+?)(?:[。!?]|\n)", "危机降临"),
  44. (r"他[忽然突然].*?发现(.+?)(?:[。!?]|\n)", "信息揭示"),
  45. (r"(?:但是|然而|可是)(.+?)(?:[。!?]|\n)", "反转"),
  46. (r'[\"\'""「].+?[\"\'""」]\s*$', "对话悬念"),
  47. (r"(?:明天|明日|天亮|第二).*?(?:就会|将要|会)(.+?)", "时间压力"),
  48. (r"(?:他|她).*?(?:决定|选择|下决心)(.+?)", "决定时刻"),
  49. (r"(?:远处|门外|身后).*?(?:传来|响起)(.+?)", "威胁到来"),
  50. ]
  51. PAYOFF_PATTERNS = [
  52. (r"(?:众人|所有人|在场).*?(?:震惊|愕然|呆住)", "打脸爽"),
  53. (r"(?:突破|晋升|觉醒|进化|升级).*?(?:成功|完成)", "升级爽"),
  54. (r"(?:获得|得到|继承|传承|吸收).+?(?:力量|能力|宝物)", "获得爽"),
  55. (r"(?:守护|保护|挡住|拦下).+?(?:身后|背后)", "守护爽"),
  56. ]
  57. @classmethod
  58. def extract_all(cls, text: str, chapter_num: int, score: float = 0) -> list[WritingPattern]:
  59. patterns = []
  60. text_clean = text.replace("\n", " ")
  61. last_500 = text_clean[-500:]
  62. for pattern_re, name in cls.HOOK_PATTERNS:
  63. matches = re.findall(pattern_re, last_500)
  64. if matches:
  65. p = WritingPattern(
  66. name=f"钩子_{name}", category="hook", template=matches[0][:200],
  67. complexity=min(1.0, len(matches[0]) / 80.0),
  68. diversity=random.uniform(0.3, 0.9), quality_score=score,
  69. )
  70. p.usage_count = 1
  71. p.success_count = 1 if score >= 75 else 0
  72. patterns.append(p)
  73. for pattern_re, name in cls.PAYOFF_PATTERNS:
  74. matches = re.findall(pattern_re, text_clean)
  75. if matches:
  76. p = WritingPattern(
  77. name=f"爽点_{name}", category="payoff", template=matches[0][:200],
  78. complexity=min(1.0, len(matches) / 5.0),
  79. diversity=0.3 + (hash(name) % 70) / 100.0, quality_score=score,
  80. )
  81. p.usage_count = len(matches)
  82. p.success_count = len(matches) if score >= 75 else 0
  83. patterns.append(p)
  84. return patterns
  85. class MAPElitesGrid:
  86. def __init__(self, bins: int = 5):
  87. self.bins = bins
  88. self.grid: dict[tuple[int, int], list[WritingPattern]] = defaultdict(list)
  89. def _bin_coord(self, complexity: float, diversity: float) -> tuple[int, int]:
  90. c = min(self.bins - 1, int(complexity * self.bins))
  91. d = min(self.bins - 1, int(diversity * self.bins))
  92. return (c, d)
  93. def insert(self, pattern: WritingPattern) -> bool:
  94. coord = self._bin_coord(pattern.complexity, pattern.diversity)
  95. for existing in self.grid[coord]:
  96. if existing.fingerprint == pattern.fingerprint:
  97. existing.usage_count += pattern.usage_count
  98. existing.success_count += pattern.success_count
  99. if pattern.quality_score > existing.quality_score:
  100. existing.quality_score = pattern.quality_score
  101. existing.template = pattern.template
  102. return False
  103. self.grid[coord].append(pattern)
  104. return True
  105. def get_elite(self, n: int = 3) -> list[WritingPattern]:
  106. elites = []
  107. for cell in self.grid.values():
  108. if cell:
  109. cell.sort(key=lambda p: p.quality_score, reverse=True)
  110. elites.append(cell[0])
  111. elites.sort(key=lambda p: p.quality_score, reverse=True)
  112. return elites[:n]
  113. def cell_count(self) -> int:
  114. return sum(1 for cell in self.grid.values() if cell)
  115. # ── 主流程 ─────────────────────────────────────────────
  116. def mine_project(project: Path, min_score: int = 70, top_k: int = 20) -> dict:
  117. print(f"⛏ 挖掘项目: {project.name}")
  118. chapters = parse_chapters(project)
  119. if not chapters:
  120. print(" ⚠ 未找到章节文件")
  121. return {"status": "no_chapters"}
  122. grid = MAPElitesGrid(bins=5)
  123. total_patterns = 0
  124. high_quality = 0
  125. # 加载项目数据库获取质量评分
  126. from utils import load_project_db
  127. db = load_project_db(project)
  128. quality_scores = db.get("meta", {}).get("quality_scores", {})
  129. for ch_num, ch_path in sorted(chapters.items()):
  130. try:
  131. text = ch_path.read_text(encoding="utf-8")
  132. except Exception:
  133. continue
  134. score = quality_scores.get(str(ch_num), 75)
  135. patterns = PatternExtractor.extract_all(text, ch_num, score)
  136. for p in patterns:
  137. total_patterns += 1
  138. if grid.insert(p) and p.quality_score >= min_score:
  139. high_quality += 1
  140. elites = grid.get_elite(top_k)
  141. # 合并到 evolution/patterns/pattern_library.json
  142. existing = load_evolution("patterns/pattern_library.json") or {"patterns": [], "grid_cells": 0}
  143. all_patterns = {p.get("fingerprint", ""): p for p in existing.get("patterns", [])}
  144. for p in elites:
  145. fp = p.fingerprint
  146. if fp not in all_patterns or p.quality_score > all_patterns[fp].get("quality_score", 0):
  147. all_patterns[fp] = p.to_dict()
  148. pattern_list = sorted(all_patterns.values(), key=lambda x: x.get("quality_score", 0), reverse=True)
  149. # 保留前 50 个
  150. pattern_list = pattern_list[:50]
  151. output = {
  152. "patterns": pattern_list,
  153. "grid_cells": grid.cell_count(),
  154. "last_mined_from": project.name,
  155. "total_chapters": len(chapters),
  156. }
  157. save_evolution("patterns/pattern_library.json", output)
  158. print(f" ✓ 分析了 {len(chapters)} 章")
  159. print(f" ✓ 发现 {total_patterns} 个模式(高质量 {high_quality})")
  160. print(f" ✓ MAP-Elites 网格: {grid.cell_count()}/25 格")
  161. print(f" ✓ 模式库保有 {len(pattern_list)} 个模式")
  162. print(f" ✓ 已写入 evolution/patterns/pattern_library.json")
  163. return {"status": "ok", "chapters": len(chapters),
  164. "patterns_found": total_patterns, "library_size": len(pattern_list)}
  165. def show_patterns(category: str | None = None, top_n: int = 10) -> None:
  166. data = load_evolution("patterns/pattern_library.json")
  167. patterns = data.get("patterns", [])
  168. if category:
  169. patterns = [p for p in patterns if p.get("category") == category]
  170. patterns.sort(key=lambda p: p.get("quality_score", 0), reverse=True)
  171. print(f"\n═══ 写作模式库 (evolution/patterns/) ═══")
  172. print(f"总计: {len(patterns)} 个模式\n")
  173. for i, p in enumerate(patterns[:top_n]):
  174. print(f"{'★' if p.get('quality_score', 0) >= 80 else '●'} "
  175. f"[{p.get('category', '?')}] {p.get('name', '?')}")
  176. print(f" 质量: {p.get('quality_score', 0):.0f} | "
  177. f"complexity={p.get('complexity', 0):.2f} diversity={p.get('diversity', 0):.2f}")
  178. preview = p.get("template", "")[:80].replace("\n", " ")
  179. print(f" {preview}...")
  180. print()
  181. if __name__ == "__main__":
  182. import argparse
  183. parser = argparse.ArgumentParser(description="写作模式挖掘器")
  184. parser.add_argument("action", choices=["mine", "show"],
  185. help="mine=挖掘 | show=展示")
  186. parser.add_argument("--project", "-p", default=".")
  187. parser.add_argument("--min-score", type=int, default=70)
  188. parser.add_argument("--top", type=int, default=10)
  189. parser.add_argument("--category", "-c",
  190. choices=["hook", "payoff", "dialogue", "pacing", "description", "emotion"])
  191. args = parser.parse_args()
  192. if args.action == "mine":
  193. root = find_project_root(Path(args.project).resolve())
  194. if not root:
  195. print("错误: 未找到小说项目根目录")
  196. sys.exit(1)
  197. result = mine_project(root, min_score=args.min_score, top_k=args.top)
  198. sys.exit(0 if result["status"] == "ok" else 1)
  199. elif args.action == "show":
  200. show_patterns(category=args.category, top_n=args.top)