utils.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373
  1. #!/usr/bin/env python3
  2. """
  3. novel-write 自优化系统 — 共享工具模块
  4. OpenEvolve 启发的进化优化框架
  5. 进化输出到 skill 目录下的 evolution/ 文件夹,不修改 SKILL.md。
  6. """
  7. import json
  8. import re
  9. import os
  10. import sys
  11. import hashlib
  12. import time
  13. from pathlib import Path
  14. from datetime import datetime
  15. from collections import defaultdict
  16. from typing import Any
  17. # ── 路径解析 ───────────────────────────────────────────
  18. def get_skill_dir() -> Path:
  19. """获取 skill 安装目录(symlink 目标)"""
  20. # 优先使用环境变量,否则推断
  21. env = os.environ.get("NOVEL_SKILL_DIR", "")
  22. if env:
  23. return Path(env)
  24. # 从脚本位置推断: scripts/utils.py → 上级 = skill 目录
  25. return Path(__file__).resolve().parent.parent
  26. def get_evolution_dir() -> Path:
  27. """获取进化输出目录"""
  28. return get_skill_dir() / "evolution"
  29. def find_project_root(start: Path | None = None) -> Path | None:
  30. """向上查找小说项目根目录(包含 项目铁律.md 的目录)"""
  31. d = Path(start or os.getcwd()).resolve()
  32. for _ in range(10):
  33. if (d / "项目铁律.md").exists():
  34. return d
  35. if d.parent == d:
  36. return None
  37. d = d.parent
  38. return None
  39. def find_all_projects(base: str = os.path.expanduser("~/Documents")) -> list[Path]:
  40. """查找 base 下所有小说项目"""
  41. projects = []
  42. base = Path(base)
  43. if not base.exists():
  44. return projects
  45. for d in base.iterdir():
  46. if d.is_dir() and (d / "项目铁律.md").exists():
  47. projects.append(d)
  48. return projects
  49. # ── 章节解析 ───────────────────────────────────────────
  50. def parse_chapters(project: Path) -> dict[int, Path]:
  51. """解析项目中的章节文件,返回 {章号: 路径}"""
  52. chapters = {}
  53. body_dir = project / "正文"
  54. if not body_dir.exists():
  55. return chapters
  56. for f in sorted(body_dir.glob("*第*章*.md")):
  57. m = re.search(r"第(\d+)章", f.name)
  58. if m:
  59. chapters[int(m.group(1))] = f
  60. return chapters
  61. def count_words(text: str) -> int:
  62. """中文字数统计"""
  63. chinese = len(re.findall(r"[一-鿿]", text))
  64. return chinese
  65. def extract_chapter_meta(content: str) -> dict:
  66. """从章节 markdown 提取元数据"""
  67. meta = {}
  68. patterns = {
  69. "核心事件": r"## 核心事件[::]\s*(.+?)(?:\n|$)",
  70. "章末钩子类型": r"章末钩子.*?类型[::]\s*(.+?)(?:\n|$)",
  71. "期待感等级": r"期待感等级[::]\s*(.+?)(?:\n|$)",
  72. "爽点类型": r"爽点.*?类型[::]\s*(.+?)(?:\n|$)",
  73. "爽点模式": r"爽点.*?模式[::]\s*(.+?)(?:\n|$)",
  74. }
  75. for key, pat in patterns.items():
  76. m = re.search(pat, content)
  77. if m:
  78. meta[key] = m.group(1).strip()
  79. return meta
  80. # ── 质量数据解析 ────────────────────────────────────────
  81. def parse_quality_log(project: Path) -> list[dict]:
  82. """解析优化日志,返回历史质检记录"""
  83. log_file = project / "追踪" / "优化日志.md"
  84. if not log_file.exists():
  85. return []
  86. records = []
  87. text = log_file.read_text(encoding="utf-8")
  88. blocks = re.split(r"\n## \d{4}-\d{2}-\d{2}", text)
  89. for block in blocks[1:]:
  90. record = {}
  91. m = re.search(r"(?:第\d+章)?综合评分\*{0,2}[::]\s*(\d+)", block)
  92. if m:
  93. record["score"] = int(m.group(1))
  94. for p in ["P1", "P2", "P3", "P4", "P5"]:
  95. m = re.search(rf"\*{{0,2}}{p}\*{{0,2}}[::]\s*(\d+)", block)
  96. if m:
  97. record[f"score_{p.lower()}"] = int(m.group(1))
  98. m = re.search(r"第(\d+)章", block)
  99. if m:
  100. record["chapter"] = int(m.group(1))
  101. record["s1_count"] = len(re.findall(r"S1[::]", block))
  102. record["s2_count"] = len(re.findall(r"S2[::]", block))
  103. records.append(record)
  104. return records
  105. def parse_hook_ledger(project: Path) -> dict:
  106. """解析钩子账本,返回钩子健康数据"""
  107. ledger = project / "追踪" / "钩子账本.md"
  108. if not ledger.exists():
  109. return {}
  110. text = ledger.read_text(encoding="utf-8")
  111. return {
  112. "total_active": len(re.findall(r"状态[::]\s*(?:活跃|active)", text)),
  113. "stale": len(re.findall(r"stale|过期", text)),
  114. "high_priority": len(re.findall(r"优先级[::]\s*high|高", text)),
  115. "paid_off": len(re.findall(r"已兑现|payoff", text)),
  116. }
  117. # ── 特征提取 ───────────────────────────────────────────
  118. def extract_style_features(text: str) -> dict:
  119. """提取文本的风格特征向量"""
  120. sentences = re.split(r"[。!?.!?\n]+", text)
  121. sentences = [s.strip() for s in sentences if s.strip()]
  122. if not sentences:
  123. return {}
  124. lengths = [len(s) for s in sentences]
  125. dialogue_lines = len(re.findall(r"[「""'].*?[」""']", text))
  126. return {
  127. "avg_sentence_len": sum(lengths) / len(lengths),
  128. "sentence_len_variance": sum((l - sum(lengths) / len(lengths)) ** 2 for l in lengths) / len(lengths),
  129. "short_sentence_ratio": sum(1 for l in lengths if l <= 5) / len(lengths),
  130. "long_sentence_ratio": sum(1 for l in lengths if l >= 30) / len(lengths),
  131. "dialogue_ratio": dialogue_lines / max(len(sentences), 1),
  132. "paragraph_count": len(re.split(r"\n\n+", text)),
  133. "total_chinese_chars": count_words(text),
  134. }
  135. def extract_quality_features(records: list[dict]) -> dict:
  136. """从质检记录提取质量特征"""
  137. if not records:
  138. return {}
  139. scores = [r.get("score", 0) for r in records if r.get("score")]
  140. p1_scores = [r.get("score_p1", 0) for r in records if r.get("score_p1")]
  141. p2_scores = [r.get("score_p2", 0) for r in records if r.get("score_p2")]
  142. return {
  143. "avg_score": sum(scores) / len(scores) if scores else 0,
  144. "min_score": min(scores) if scores else 0,
  145. "max_score": max(scores) if scores else 0,
  146. "score_trend": "up" if len(scores) >= 3 and scores[-3:] == sorted(scores[-3:]) else "flat",
  147. "weakest_pass": "P1" if (sum(p1_scores) / max(len(p1_scores), 1)) < 70 else
  148. "P2" if (sum(p2_scores) / max(len(p2_scores), 1)) < 70 else "none",
  149. "total_s1": sum(r.get("s1_count", 0) for r in records),
  150. "total_s2": sum(r.get("s2_count", 0) for r in records),
  151. }
  152. # ── 进化数据持久化 ─────────────────────────────────────
  153. def load_evolution(name: str) -> dict:
  154. """从 evolution/ 目录加载进化数据"""
  155. fpath = get_evolution_dir() / name
  156. if not fpath.exists():
  157. return {}
  158. with open(fpath, "r", encoding="utf-8") as f:
  159. return json.load(f)
  160. def save_evolution(name: str, data: dict) -> None:
  161. """保存进化数据到 evolution/ 目录"""
  162. evo_dir = get_evolution_dir()
  163. evo_dir.mkdir(parents=True, exist_ok=True)
  164. fpath = evo_dir / name
  165. with open(fpath, "w", encoding="utf-8") as f:
  166. json.dump(data, f, ensure_ascii=False, indent=2)
  167. def load_project_db(project: Path) -> dict:
  168. """加载项目进化数据库"""
  169. db_file = project / "追踪" / ".evolution_db.json"
  170. if not db_file.exists():
  171. return {"prompts": [], "patterns": [], "config_mutations": [], "meta": {}}
  172. with open(db_file, "r", encoding="utf-8") as f:
  173. return json.load(f)
  174. def save_project_db(project: Path, db: dict) -> None:
  175. """保存项目进化数据库"""
  176. db_file = project / "追踪" / ".evolution_db.json"
  177. db["meta"]["last_updated"] = datetime.now().isoformat()
  178. db["meta"]["version"] = db["meta"].get("version", 0) + 1
  179. with open(db_file, "w", encoding="utf-8") as f:
  180. json.dump(db, f, ensure_ascii=False, indent=2)
  181. def log_evolution_event(project: Path, event_type: str, data: dict) -> None:
  182. """记录进化事件到 evolution/evolution_log.md 和项目日志"""
  183. timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
  184. entry = f"\n## {timestamp} | {event_type}\n\n"
  185. for k, v in data.items():
  186. entry += f"- **{k}**: {v}\n"
  187. entry += "\n---\n"
  188. # 写到 evolution/ 目录
  189. evo_log = get_evolution_dir() / "evolution_log.md"
  190. with open(evo_log, "a", encoding="utf-8") as f:
  191. f.write(entry)
  192. # 也写项目日志
  193. proj_log = project / "追踪" / "优化日志.md"
  194. proj_log.parent.mkdir(parents=True, exist_ok=True)
  195. with open(proj_log, "a", encoding="utf-8") as f:
  196. f.write(entry)
  197. # ── 统计工具 ───────────────────────────────────────────
  198. def compute_improvement(before: list[float], after: list[float]) -> float:
  199. if not before or not after:
  200. return 0.0
  201. avg_before = sum(before) / len(before)
  202. avg_after = sum(after) / len(after)
  203. if avg_before == 0:
  204. return 100.0 if avg_after > 0 else 0.0
  205. return ((avg_after - avg_before) / avg_before) * 100
  206. # ── 题材级共享进化路径 ─────────────────────────────────
  207. def get_genre_evolution_dir(genre_id: str) -> Path:
  208. """获取题材级共享进化目录,自动创建"""
  209. evo_dir = get_evolution_dir() / "genres" / genre_id
  210. evo_dir.mkdir(parents=True, exist_ok=True)
  211. return evo_dir
  212. def load_genre_evolution(genre_id: str, name: str) -> dict:
  213. """从 evolution/genres/{genre_id}/ 加载进化数据"""
  214. fpath = get_genre_evolution_dir(genre_id) / name
  215. if not fpath.exists():
  216. return {}
  217. with open(fpath, "r", encoding="utf-8") as f:
  218. return json.load(f)
  219. def save_genre_evolution(genre_id: str, name: str, data: dict) -> None:
  220. """保存进化数据到 evolution/genres/{genre_id}/"""
  221. evo_dir = get_genre_evolution_dir(genre_id)
  222. fpath = evo_dir / name
  223. fpath.parent.mkdir(parents=True, exist_ok=True)
  224. with open(fpath, "w", encoding="utf-8") as f:
  225. json.dump(data, f, ensure_ascii=False, indent=2)
  226. def find_project_genre(project_path: Path) -> str:
  227. """从 项目铁律.md frontmatter 提取 genre 字段"""
  228. iron_law = project_path / "项目铁律.md"
  229. if not iron_law.exists():
  230. return "other"
  231. text = iron_law.read_text(encoding="utf-8")
  232. m = re.search(r'genre[:\s]+["\']?([^"\'\n]+)', text)
  233. if m:
  234. return m.group(1).strip()
  235. m = re.search(r'#\s*(.+?)(?:\n|$)', text)
  236. return m.group(1).strip() if m else "other"
  237. def find_style_reports(project_path: Path) -> list[Path]:
  238. """查找对标/ 目录下所有风格报告"""
  239. reports_dir = project_path / "对标"
  240. if not reports_dir.exists():
  241. return []
  242. return sorted(reports_dir.glob("*/风格报告.md"))
  243. def resolve_genre_from_project(project_path: Path) -> str:
  244. """从项目路径解析题材 ID,确保题材进化目录已初始化"""
  245. genre_name = find_project_genre(project_path)
  246. genre_id = map_genre_to_inkos_id(genre_name)
  247. return genre_id
  248. def ensure_genre_evolution(genre_id: str) -> bool:
  249. """确保题材进化目录存在并已初始化;返回 True 表示已存在"""
  250. evo_dir = get_genre_evolution_dir(genre_id)
  251. if evo_dir.exists() and (evo_dir / "prompts" / "population.json").exists():
  252. return True
  253. from seed_extractor import SeedExtractor
  254. extractor = SeedExtractor()
  255. extractor.initialize_genre(genre_id)
  256. log_evolution_event(Path("."), "题材进化初始化", {
  257. "题材ID": genre_id,
  258. "类型": "genre-level evolution init",
  259. })
  260. return False
  261. # ── 中文题材→InkOS ID 映射 ─────────────────────────────
  262. GENRE_NAME_TO_ID = {
  263. # 仙侠 — 修炼/悟道/天劫/因果
  264. "仙侠": "xianxia", "修仙": "xianxia", "修真": "xianxia",
  265. # 玄幻 — 异界/战力/血脉
  266. "玄幻": "xuanhuan",
  267. # 都市 — 商战/社交/年代考据
  268. "都市": "urban", "现代": "urban", "都市异能": "urban", "职场": "urban",
  269. # 恐怖 — 灵异/惊悚/恐惧层级
  270. "恐怖": "horror", "惊悚": "horror", "灵异": "horror", "悬疑": "horror",
  271. # 科幻 — 科技/未来/星际
  272. "科幻": "sci-fi", "星际": "sci-fi", "未来": "sci-fi",
  273. # 末世/废土 — System Apocalypse 规则并入 other(中文通用回退)
  274. "末世": "system-apocalypse", "废土": "system-apocalypse", "末日": "system-apocalypse",
  275. # 无限流/塔爬 — 层级递进
  276. "无限流": "tower-climber", "无限": "tower-climber", "轮回": "tower-climber",
  277. # LitRPG/游戏 — 数值系统/面板/属性
  278. "游戏": "litrpg", "网游": "litrpg", "系统": "litrpg",
  279. # 奇幻/西幻 — 魔法/种族/世界构建
  280. "奇幻": "progression", "西幻": "progression", "魔法": "progression",
  281. # 言情/浪漫奇幻 — 关系驱动
  282. "言情": "romantasy", "古言": "romantasy", "现言": "romantasy", "恋爱": "romantasy",
  283. # 异世界/轻小说 — 穿越/转生
  284. "轻小说": "isekai", "异世界": "isekai", "穿越": "isekai", "转生": "isekai",
  285. # 种田/日常/温馨 — 慢节奏/生活流
  286. "种田": "cozy", "日常": "cozy", "温馨": "cozy", "治愈": "cozy",
  287. # 地下城核心 — 地城建造/管理
  288. "地下城": "dungeon-core", "地城": "dungeon-core",
  289. # 东方修真(English Cultivation)
  290. "东方玄幻": "cultivation", "修真English": "cultivation",
  291. }
  292. def map_genre_to_inkos_id(genre_name: str) -> str:
  293. """将中文题材名映射到 InkOS 题材 ID"""
  294. if not genre_name:
  295. return "other"
  296. # 精确匹配
  297. if genre_name.lower() in GENRE_NAME_TO_ID:
  298. return GENRE_NAME_TO_ID[genre_name.lower()]
  299. # 子串匹配(处理"种田+诡异"这种复合题材)
  300. for cn_name, inkos_id in GENRE_NAME_TO_ID.items():
  301. if cn_name in genre_name:
  302. return inkos_id
  303. return "other"
  304. if __name__ == "__main__":
  305. print(f"Skill 目录: {get_skill_dir()}")
  306. print(f"进化目录: {get_evolution_dir()}")
  307. print("utils.py 模块加载正常")