seed_extractor.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459
  1. #!/usr/bin/env python3
  2. """
  3. seed_extractor.py — InkOS 题材感知种子提取器
  4. 从 InkOS 题材配置文件(YAML frontmatter + Markdown body)提取
  5. 题材特定的初始提示词种子,映射到 novel-write 的 5 岛结构。
  6. 回退链:指定题材 → other.md → 硬编码通用种子(SEED_TEMPLATES)
  7. """
  8. import sys, os, re, json, yaml
  9. from pathlib import Path
  10. from typing import Any
  11. sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
  12. from utils import (
  13. save_genre_evolution, find_project_genre, map_genre_to_inkos_id,
  14. resolve_genre_from_project, log_evolution_event,
  15. )
  16. # ── 通用回退种子(当 InkOS 不可用时) ─────────────────
  17. FALLBACK_SEEDS: dict[str, list[str]] = {
  18. "writing": [
  19. "核心规则(写作时内化不列出):Show, don't tell——用细节堆出真实。价值观盐溶于汤——靠行为证明不靠口号。任何行动立于三条腿:过往经历+当前利益+性格底色。配角有自己账本和利益诉求。每章结尾必有小悬念或情绪缺口。全员智商在线。日常场景七成成后续伏笔。关系改变事件驱动。每一行字要么推动剧情要么塑造人物。",
  20. "看点密集度(硬尺):每300字至少1个爽点。每500字至少1个钩子。每1000-1500字至少1个完整悬念。连续300字以上只是环境/回忆/议论/心理独白而无推进=水文须删改。叙事段≥40字,目标40-120字。短段(<40字)只允许:开场反转金句/章末钩子/≤3个爆点短段,合计≤5个。",
  21. "去AI味铁律:叙述者永不替读者下结论。禁止分析报告式语言入正文。转折/惊讶标记词(仿佛/忽然/竟/猛地/不禁/宛如)全篇≤每3000字1次。硬性禁令:全文禁止'不是…而是…'句式;全文禁止破折号'——'。",
  22. ],
  23. "revision": [
  24. "修稿分流规则:PATCHES处理局部文字问题——每个PATCH引用原文段落给出替换文本。REVISED_CONTENT处理全章级问题——Critical包含字数或结构性问题时必须用REVISED_CONTENT。修稿原则:修根因不做表面润色;伏笔状态须与伏笔池同步;不改变剧情走向和核心冲突。",
  25. "润色边界(硬约束):只改文字层——句式/段落/排版/用词/五感/对话自然度。禁止增删情节/改变人设/调整主线。文字层硬规约:段落3-5行/段;句式多样化;动词>形容词;每场景1-2种感官。修改后总长变化≤±15%。",
  26. ],
  27. "quality_check": [
  28. "自修正循环(不可跳过门控):Loop最多5轮——综合评分≥85通过→60-84针对性修订→40-59重写问题段落→<40废弃从细纲重新写。每轮记录本轮评分/发现问题/修复内容/下一轮重点。",
  29. "五遍编辑评估(按顺序不可跳过):P1结构(35%)→P2角色(25%)→P3原创性(15%)→P4对话(15%)→P5文本(10%)。综合≥85通过/60-84修订/40-59重写/<40废弃。P1不过不进P2。",
  30. ],
  31. "outline": [
  32. "大纲共创三层决策树:第一层全书结构——分几卷?每卷核心任务?不可逆变化?全书最高潮在哪卷?第二层卷纲细化——高潮事件?需要哪些铺垫(≥3个伏笔)?主角情感弧线?卷末钩子须全书级别。第三层细纲关键节点——前10章每章核心事件/章末钩子类型/爽点类型/伏笔推进计划。",
  33. "章节备忘七段格式:当前任务→读者此刻在等什么→该兑现的/暂不掀的→日常/过渡承担任务→关键抉择过三连问→章尾必须发生的改变→本章hook账→不要做。",
  34. ],
  35. "hook_management": [
  36. "伏笔全生命周期:plant(种植)→advance(推进)→payoff(兑现)→defer(推迟)→stale(过期)。每章前读钩子账本→列出高优活跃钩子→确认推进/兑现机会。每章后更新:兑现移走→推进追加→新种写入→检查stale。",
  37. "钩子准入控制:不重复——指向同一事件合并不新增。不超量——每卷最多3-5个high优先级,活跃钩子总数≤12。有计划——无兑现计划的钩子不种。新钩子候选:正文中出现会延续到后续且有具体回收方向的未解问题才新增。",
  38. ],
  39. }
  40. class SeedExtractor:
  41. """从 InkOS 题材配置提取 5 岛种子提示词"""
  42. def __init__(self, inkos_genres_dir: str | None = None):
  43. if inkos_genres_dir:
  44. self.genres_dir = Path(inkos_genres_dir)
  45. else:
  46. # 内置题材配置,无外部依赖
  47. self.genres_dir = Path(__file__).resolve().parent.parent / "genres"
  48. self._profile_cache: dict[str, dict | None] = {}
  49. # ── 题材配置加载 ─────────────────────────────────
  50. def _load_profile(self, genre_id: str) -> dict | None:
  51. """加载单个题材配置文件,解析 YAML frontmatter + body sections"""
  52. if genre_id in self._profile_cache:
  53. return self._profile_cache[genre_id]
  54. fpath = self.genres_dir / f"{genre_id}.md"
  55. if not fpath.exists():
  56. self._profile_cache[genre_id] = None
  57. return None
  58. text = fpath.read_text(encoding="utf-8")
  59. fm_match = re.match(r"^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$", text)
  60. if not fm_match:
  61. self._profile_cache[genre_id] = None
  62. return None
  63. try:
  64. frontmatter = yaml.safe_load(fm_match.group(1))
  65. except yaml.YAMLError:
  66. frontmatter = {}
  67. body = fm_match.group(2)
  68. sections = {}
  69. for sec_match in re.finditer(r"##\s+(.+?)\s*\n([\s\S]*?)(?=\n## |\Z)", body):
  70. title = sec_match.group(1).strip()
  71. content = sec_match.group(2).strip()
  72. sections[title] = content
  73. profile = {
  74. "frontmatter": frontmatter or {},
  75. "sections": sections,
  76. "language": frontmatter.get("language", "zh") if frontmatter else "zh",
  77. }
  78. self._profile_cache[genre_id] = profile
  79. return profile
  80. def _load_with_fallback(self, genre_id: str) -> dict:
  81. """加载题材配置,失败时回退到 other → None"""
  82. profile = self._load_profile(genre_id)
  83. if profile:
  84. return profile
  85. if genre_id != "other":
  86. return self._load_profile("other") or {"frontmatter": {}, "sections": {}, "language": "zh"}
  87. return {"frontmatter": {}, "sections": {}, "language": "zh"}
  88. # ── 字段→岛屿 映射 ───────────────────────────────
  89. def _build_writing_seeds(self, fm: dict, sections: dict, lang: str) -> list[str]:
  90. """从题材配置构建 writing 岛种子"""
  91. seeds = []
  92. # 疲劳词 → 词汇疲劳规避规则
  93. fatigue_words = fm.get("fatigueWords", [])
  94. if fatigue_words:
  95. word_list = "、".join(fatigue_words)
  96. seeds.append(
  97. f"本文疲劳词监控(题材特定):以下词汇单章最多使用1次——{word_list}。"
  98. f"超限即标记为S2警告。这些是{lang == 'en' and 'genre fatigue words' or '题材疲劳词'},"
  99. f"过度使用会让读者产生'又是这套'的审美疲劳。"
  100. )
  101. # 节奏规则
  102. pacing = fm.get("pacingRule", "")
  103. if pacing:
  104. seeds.append(f"节奏控制规则(题材特定):{pacing}。偏离此节奏需在章节备忘中说明原因。")
  105. # 爽点类型
  106. satisfaction = fm.get("satisfactionTypes", [])
  107. if satisfaction:
  108. types_str = "、".join(satisfaction)
  109. seeds.append(
  110. f"本文期待感类型优先排序(题材特定):{types_str}。"
  111. f"每章至少兑现其中一种类型。爽点设计优先从该清单选取,清单外类型需在细纲中标注'非标爽点'。"
  112. )
  113. # 叙事指导 section
  114. narrative_guidance = (
  115. sections.get("叙事指导")
  116. or sections.get("Narrative Guidance")
  117. or sections.get("Pacing Guidance")
  118. or ""
  119. )
  120. if narrative_guidance:
  121. # 提取关键句子(以句号分隔,取有实质内容的句子)
  122. key_lines = [
  123. s.strip() for s in re.split(r"[。.\n]", narrative_guidance)
  124. if len(s.strip()) > 10 and not s.strip().startswith("-")
  125. ][:5]
  126. if key_lines:
  127. guidance_text = "。".join(key_lines)
  128. seeds.append(f"题材叙事指导(注入写作规则):{guidance_text}。")
  129. return seeds
  130. def _build_revision_seeds(self, fm: dict, sections: dict, lang: str) -> list[str]:
  131. """从题材配置构建 revision 岛种子"""
  132. seeds = []
  133. # 语言铁律 / Language Rules
  134. language_rules = sections.get("语言铁律") or sections.get("Language Rules") or ""
  135. if language_rules:
  136. # 提取具体规则行
  137. rule_lines = [
  138. line.strip().lstrip("- ").lstrip("* ")
  139. for line in language_rules.split("\n")
  140. if line.strip() and (line.strip().startswith("-") or line.strip().startswith("*")
  141. or "→" in line or "✗" in line or "✓" in line)
  142. ]
  143. if rule_lines:
  144. rules_text = ";".join(rule_lines[:8])
  145. seeds.insert(0, f"题材润色特化规则:{rules_text}。这些规则优先于通用润色规则执行。")
  146. # 年代考据规则
  147. if fm.get("eraResearch"):
  148. seeds.append(
  149. "年代考据规则(题材特定):涉及法律/政策/商业规则/物价/机构名/行业术语必须符合设定年代。"
  150. "金融操作和公司运营需有基本可信度。地名和机构名不可凭空编造。"
  151. )
  152. return seeds
  153. def _build_quality_check_seeds(self, fm: dict, sections: dict, lang: str) -> list[str]:
  154. """从题材配置构建 quality_check 岛种子"""
  155. seeds = []
  156. # 题材禁忌 → S1/S2 检查项
  157. prohibitions = sections.get("题材禁忌") or sections.get("Genre Prohibitions") or ""
  158. if prohibitions:
  159. items = []
  160. for line in prohibitions.split("\n"):
  161. stripped = line.strip().lstrip("- ").lstrip("* ")
  162. if len(stripped) > 10:
  163. items.append(stripped)
  164. if items:
  165. check_items = []
  166. for i, item in enumerate(items[:10]):
  167. severity = "S1" if any(
  168. kw in item for kw in ["禁止", "不可", "杜绝", "never", "must not", "prohibited"]
  169. ) else "S2"
  170. check_items.append(f" {i+1}. [{severity}] {item}")
  171. seeds.append(
  172. f"题材特定质量审查清单:\n" + "\n".join(check_items) +
  173. f"\n每章完成后逐项检查,S1项不过直接退回修改。"
  174. )
  175. # 审计维度
  176. audit_dims = fm.get("auditDimensions", [])
  177. if audit_dims:
  178. dims_str = ",".join(str(d) for d in audit_dims)
  179. seeds.append(
  180. f"37维审查启用维度子集(题材特定):[{dims_str}]。"
  181. f"未启用的维度本作不适用,跳过检查以降低误报。"
  182. )
  183. # 数值系统检查
  184. if fm.get("numericalSystem"):
  185. seeds.append(
  186. "数值一致性检查(题材特定):统计面板/战力数值/资源计数必须与上下文一致。"
  187. "同质资源重复获取须写明衰减。增量逐笔列出,不可跳跃式增长。"
  188. )
  189. # 战力体系检查
  190. if fm.get("powerScaling"):
  191. seeds.append(
  192. "战力体系检查(题材特定):境界/品阶/等级突破必须有积累过程。"
  193. "不可出现无铺垫跳跃式突破。跨大境界需特殊条件(天劫/机缘/丹药)。"
  194. "能力上限/附加代价/触发条件/成长路径——四维约束缺一不可。"
  195. )
  196. return seeds
  197. def _build_outline_seeds(self, fm: dict, sections: dict, lang: str) -> list[str]:
  198. """从题材配置构建 outline 岛种子"""
  199. seeds = []
  200. # 章节类型
  201. chapter_types = fm.get("chapterTypes", [])
  202. if chapter_types:
  203. types_str = "、".join(chapter_types)
  204. seeds.append(
  205. f"章节类型轮换策略(题材特定):可用类型——{types_str}。"
  206. f"连续3章以上同类型触发疲劳警告。每卷类型分布应覆盖所有列出类型。"
  207. f"各类型比例为:战斗/推进类40%,布局/过渡类35%,回收/高潮类25%。"
  208. )
  209. # 世界观规则 section
  210. world_rules = (
  211. sections.get("修炼规则")
  212. or sections.get("World Rules")
  213. or sections.get("年代与现实约束")
  214. or sections.get("System Design Rules")
  215. or ""
  216. )
  217. if world_rules:
  218. key_rules = []
  219. for line in world_rules.split("\n"):
  220. stripped = line.strip().lstrip("- ").lstrip("* ")
  221. if len(stripped) > 15:
  222. key_rules.append(stripped)
  223. if key_rules:
  224. rules_text = ";".join(key_rules[:6])
  225. seeds.append(f"世界观一致性规则(题材特定,大纲阶段检查):{rules_text}。")
  226. return seeds
  227. def _build_hook_management_seeds(self, fm: dict, sections: dict, lang: str) -> list[str]:
  228. """从题材配置构建 hook_management 岛种子"""
  229. seeds = []
  230. # 题材特定的悬念期待类型
  231. satisfaction = fm.get("satisfactionTypes", [])
  232. if satisfaction:
  233. types_str = "、".join(satisfaction[:4])
  234. seeds.append(
  235. f"题材悬念期待管理:本类型读者的核心期待——{types_str}。"
  236. f"伏笔设计优先围绕这几类期待展开。每卷至少种下2个指向不同类型期待的伏笔。"
  237. )
  238. # 节奏规则 → 伏笔兑现节奏
  239. pacing = fm.get("pacingRule", "")
  240. if pacing:
  241. seeds.append(
  242. f"伏笔兑现节奏约束:{pacing}。伏笔的advance和payoff需与此节奏同步——"
  243. f"快节奏段兑现密度可提高,慢节奏段以种植和推进为主。"
  244. )
  245. # 从世界观/修炼规则提取伏笔管理特殊规则
  246. world_rules = (
  247. sections.get("修炼规则")
  248. or sections.get("World Rules")
  249. or sections.get("年代与现实约束")
  250. or ""
  251. )
  252. if world_rules:
  253. key_lines = [
  254. s.strip().lstrip("- ").lstrip("* ")
  255. for s in world_rules.split("\n")
  256. if len(s.strip()) > 15 and any(
  257. kw in s for kw in ["规则", "约束", "限制", "禁止", "条件", "代价", "rule", "limit", "cannot", "must"]
  258. )
  259. ][:3]
  260. if key_lines:
  261. rules_text = ";".join(key_lines)
  262. seeds.append(
  263. f"题材伏笔管理特殊规则:{rules_text}。"
  264. f"违反这些规则的伏笔在plant阶段即应被拒绝或标记为'需特殊机制'。"
  265. )
  266. return seeds
  267. # ── 主入口 ──────────────────────────────────────
  268. def extract_seeds(self, genre_id: str | None = None,
  269. project_path: Path | None = None) -> dict[str, list[str]]:
  270. """
  271. 提取题材特定的 5 岛种子提示词。
  272. 返回: {island_name: [prompt_strings, ...]}
  273. """
  274. # 确定题材 ID
  275. if not genre_id and project_path:
  276. genre_name = find_project_genre(project_path)
  277. genre_id = map_genre_to_inkos_id(genre_name)
  278. elif not genre_id:
  279. genre_id = "other"
  280. profile = self._load_with_fallback(genre_id)
  281. fm = profile.get("frontmatter", {})
  282. sections = profile.get("sections", {})
  283. lang = profile.get("language", "zh")
  284. # 如果 profile 是空的(InkOS 不可用),用回退种子
  285. if not fm and not sections:
  286. print(f" ⚠ InkOS 题材配置不可用,使用通用回退种子")
  287. return FALLBACK_SEEDS
  288. seeds = {
  289. "writing": self._build_writing_seeds(fm, sections, lang),
  290. "revision": self._build_revision_seeds(fm, sections, lang),
  291. "quality_check": self._build_quality_check_seeds(fm, sections, lang),
  292. "outline": self._build_outline_seeds(fm, sections, lang),
  293. "hook_management": self._build_hook_management_seeds(fm, sections, lang),
  294. }
  295. # 对种子过少的岛屿,从 general pool 补充(避免重复)
  296. general = self._load_profile("other")
  297. for island, current in seeds.items():
  298. if len(current) < 2:
  299. # 先从 general profile 补
  300. if general and general.get("frontmatter"):
  301. gf = general["frontmatter"]
  302. gs = general["sections"]
  303. gl = general.get("language", "zh")
  304. fallback_builder = getattr(self, f"_build_{island}_seeds", None)
  305. if fallback_builder:
  306. fb_seeds = fallback_builder(gf, gs, gl)
  307. for s in fb_seeds:
  308. if s not in current:
  309. current.append(s)
  310. if len(current) >= 2:
  311. break
  312. # 还不够 → 从硬编码回退种子补
  313. if len(current) < 2 and island in FALLBACK_SEEDS:
  314. for s in FALLBACK_SEEDS[island]:
  315. if s not in current:
  316. current.append(s)
  317. if len(current) >= 2:
  318. break
  319. return seeds
  320. def initialize_genre(self, genre_id: str) -> dict[str, list[str]]:
  321. """
  322. 初始化题材级共享进化目录 evolution/genres/{genre_id}/。
  323. 创建 population.json(含题材种子)和相关文件。
  324. 返回提取的种子字典。
  325. """
  326. seeds = self.extract_seeds(genre_id=genre_id)
  327. import hashlib
  328. population = {}
  329. for island_name, templates in seeds.items():
  330. variants = []
  331. for template in templates:
  332. variant_id = hashlib.md5(template.encode()).hexdigest()[:12]
  333. variants.append({
  334. "id": variant_id,
  335. "template": template,
  336. "source": f"seed:{genre_id}",
  337. "scores": {},
  338. "trials": 0,
  339. "trials_per_dim": {},
  340. "lineage": [],
  341. })
  342. population[island_name] = {
  343. "generation": 0,
  344. "variants": variants,
  345. }
  346. save_genre_evolution(genre_id, "prompts/population.json", population)
  347. save_genre_evolution(genre_id, "prompts/current_best.json",
  348. {k: [v["template"] for v in pop["variants"][:3]]
  349. for k, pop in population.items()})
  350. default_thresholds = load_evolution("thresholds.json")
  351. if default_thresholds:
  352. save_genre_evolution(genre_id, "thresholds.json", default_thresholds)
  353. else:
  354. save_genre_evolution(genre_id, "thresholds.json", {
  355. "pass": 85, "revise_low": 60, "rewrite_low": 40,
  356. "max_revision_rounds": 3,
  357. "weights": {"P1": 0.35, "P2": 0.25, "P3": 0.15, "P4": 0.15, "P5": 0.10},
  358. })
  359. save_genre_evolution(genre_id, "patterns/pattern_library.json", {
  360. "patterns": [], "meta": {"total_mined": 0}
  361. })
  362. save_genre_evolution(genre_id, "session_state.json", {
  363. "recent_chapters": [], "total_feedback_count": 0
  364. })
  365. print(f" ✓ 题材进化目录已初始化: evolution/genres/{genre_id}/")
  366. for island, tmpls in seeds.items():
  367. print(f" [{island}]: {len(tmpls)} 条种子")
  368. return seeds
  369. # ── CLI ─────────────────────────────────────────────────
  370. if __name__ == "__main__":
  371. import argparse
  372. parser = argparse.ArgumentParser(description="InkOS 题材感知种子提取器")
  373. parser.add_argument("--project", "-p", type=str, default=None,
  374. help="项目路径(自动检测题材)")
  375. parser.add_argument("--genre", "-g", type=str, default=None,
  376. help="强制指定题材 ID")
  377. parser.add_argument("--show", action="store_true",
  378. help="仅显示提取的种子,不初始化")
  379. args = parser.parse_args()
  380. extractor = SeedExtractor()
  381. if args.project:
  382. project = Path(args.project)
  383. genre_id = args.genre or resolve_genre_from_project(project)
  384. else:
  385. genre_id = args.genre or "other"
  386. if args.show:
  387. seeds = extractor.extract_seeds(genre_id=genre_id)
  388. for island, tmpls in seeds.items():
  389. print(f"\n## {island} ({len(tmpls)} 条)")
  390. for i, t in enumerate(tmpls):
  391. print(f" {i+1}. {t[:150]}...")
  392. else:
  393. extractor.initialize_genre(genre_id)