| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459 |
- #!/usr/bin/env python3
- """
- seed_extractor.py — InkOS 题材感知种子提取器
- 从 InkOS 题材配置文件(YAML frontmatter + Markdown body)提取
- 题材特定的初始提示词种子,映射到 novel-write 的 5 岛结构。
- 回退链:指定题材 → other.md → 硬编码通用种子(SEED_TEMPLATES)
- """
- import sys, os, re, json, yaml
- from pathlib import Path
- from typing import Any
- sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
- from utils import (
- save_genre_evolution, find_project_genre, map_genre_to_inkos_id,
- resolve_genre_from_project, log_evolution_event,
- )
- # ── 通用回退种子(当 InkOS 不可用时) ─────────────────
- FALLBACK_SEEDS: dict[str, list[str]] = {
- "writing": [
- "核心规则(写作时内化不列出):Show, don't tell——用细节堆出真实。价值观盐溶于汤——靠行为证明不靠口号。任何行动立于三条腿:过往经历+当前利益+性格底色。配角有自己账本和利益诉求。每章结尾必有小悬念或情绪缺口。全员智商在线。日常场景七成成后续伏笔。关系改变事件驱动。每一行字要么推动剧情要么塑造人物。",
- "看点密集度(硬尺):每300字至少1个爽点。每500字至少1个钩子。每1000-1500字至少1个完整悬念。连续300字以上只是环境/回忆/议论/心理独白而无推进=水文须删改。叙事段≥40字,目标40-120字。短段(<40字)只允许:开场反转金句/章末钩子/≤3个爆点短段,合计≤5个。",
- "去AI味铁律:叙述者永不替读者下结论。禁止分析报告式语言入正文。转折/惊讶标记词(仿佛/忽然/竟/猛地/不禁/宛如)全篇≤每3000字1次。硬性禁令:全文禁止'不是…而是…'句式;全文禁止破折号'——'。",
- ],
- "revision": [
- "修稿分流规则:PATCHES处理局部文字问题——每个PATCH引用原文段落给出替换文本。REVISED_CONTENT处理全章级问题——Critical包含字数或结构性问题时必须用REVISED_CONTENT。修稿原则:修根因不做表面润色;伏笔状态须与伏笔池同步;不改变剧情走向和核心冲突。",
- "润色边界(硬约束):只改文字层——句式/段落/排版/用词/五感/对话自然度。禁止增删情节/改变人设/调整主线。文字层硬规约:段落3-5行/段;句式多样化;动词>形容词;每场景1-2种感官。修改后总长变化≤±15%。",
- ],
- "quality_check": [
- "自修正循环(不可跳过门控):Loop最多5轮——综合评分≥85通过→60-84针对性修订→40-59重写问题段落→<40废弃从细纲重新写。每轮记录本轮评分/发现问题/修复内容/下一轮重点。",
- "五遍编辑评估(按顺序不可跳过):P1结构(35%)→P2角色(25%)→P3原创性(15%)→P4对话(15%)→P5文本(10%)。综合≥85通过/60-84修订/40-59重写/<40废弃。P1不过不进P2。",
- ],
- "outline": [
- "大纲共创三层决策树:第一层全书结构——分几卷?每卷核心任务?不可逆变化?全书最高潮在哪卷?第二层卷纲细化——高潮事件?需要哪些铺垫(≥3个伏笔)?主角情感弧线?卷末钩子须全书级别。第三层细纲关键节点——前10章每章核心事件/章末钩子类型/爽点类型/伏笔推进计划。",
- "章节备忘七段格式:当前任务→读者此刻在等什么→该兑现的/暂不掀的→日常/过渡承担任务→关键抉择过三连问→章尾必须发生的改变→本章hook账→不要做。",
- ],
- "hook_management": [
- "伏笔全生命周期:plant(种植)→advance(推进)→payoff(兑现)→defer(推迟)→stale(过期)。每章前读钩子账本→列出高优活跃钩子→确认推进/兑现机会。每章后更新:兑现移走→推进追加→新种写入→检查stale。",
- "钩子准入控制:不重复——指向同一事件合并不新增。不超量——每卷最多3-5个high优先级,活跃钩子总数≤12。有计划——无兑现计划的钩子不种。新钩子候选:正文中出现会延续到后续且有具体回收方向的未解问题才新增。",
- ],
- }
- class SeedExtractor:
- """从 InkOS 题材配置提取 5 岛种子提示词"""
- def __init__(self, inkos_genres_dir: str | None = None):
- if inkos_genres_dir:
- self.genres_dir = Path(inkos_genres_dir)
- else:
- # 内置题材配置,无外部依赖
- self.genres_dir = Path(__file__).resolve().parent.parent / "genres"
- self._profile_cache: dict[str, dict | None] = {}
- # ── 题材配置加载 ─────────────────────────────────
- def _load_profile(self, genre_id: str) -> dict | None:
- """加载单个题材配置文件,解析 YAML frontmatter + body sections"""
- if genre_id in self._profile_cache:
- return self._profile_cache[genre_id]
- fpath = self.genres_dir / f"{genre_id}.md"
- if not fpath.exists():
- self._profile_cache[genre_id] = None
- return None
- text = fpath.read_text(encoding="utf-8")
- fm_match = re.match(r"^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$", text)
- if not fm_match:
- self._profile_cache[genre_id] = None
- return None
- try:
- frontmatter = yaml.safe_load(fm_match.group(1))
- except yaml.YAMLError:
- frontmatter = {}
- body = fm_match.group(2)
- sections = {}
- for sec_match in re.finditer(r"##\s+(.+?)\s*\n([\s\S]*?)(?=\n## |\Z)", body):
- title = sec_match.group(1).strip()
- content = sec_match.group(2).strip()
- sections[title] = content
- profile = {
- "frontmatter": frontmatter or {},
- "sections": sections,
- "language": frontmatter.get("language", "zh") if frontmatter else "zh",
- }
- self._profile_cache[genre_id] = profile
- return profile
- def _load_with_fallback(self, genre_id: str) -> dict:
- """加载题材配置,失败时回退到 other → None"""
- profile = self._load_profile(genre_id)
- if profile:
- return profile
- if genre_id != "other":
- return self._load_profile("other") or {"frontmatter": {}, "sections": {}, "language": "zh"}
- return {"frontmatter": {}, "sections": {}, "language": "zh"}
- # ── 字段→岛屿 映射 ───────────────────────────────
- def _build_writing_seeds(self, fm: dict, sections: dict, lang: str) -> list[str]:
- """从题材配置构建 writing 岛种子"""
- seeds = []
- # 疲劳词 → 词汇疲劳规避规则
- fatigue_words = fm.get("fatigueWords", [])
- if fatigue_words:
- word_list = "、".join(fatigue_words)
- seeds.append(
- f"本文疲劳词监控(题材特定):以下词汇单章最多使用1次——{word_list}。"
- f"超限即标记为S2警告。这些是{lang == 'en' and 'genre fatigue words' or '题材疲劳词'},"
- f"过度使用会让读者产生'又是这套'的审美疲劳。"
- )
- # 节奏规则
- pacing = fm.get("pacingRule", "")
- if pacing:
- seeds.append(f"节奏控制规则(题材特定):{pacing}。偏离此节奏需在章节备忘中说明原因。")
- # 爽点类型
- satisfaction = fm.get("satisfactionTypes", [])
- if satisfaction:
- types_str = "、".join(satisfaction)
- seeds.append(
- f"本文期待感类型优先排序(题材特定):{types_str}。"
- f"每章至少兑现其中一种类型。爽点设计优先从该清单选取,清单外类型需在细纲中标注'非标爽点'。"
- )
- # 叙事指导 section
- narrative_guidance = (
- sections.get("叙事指导")
- or sections.get("Narrative Guidance")
- or sections.get("Pacing Guidance")
- or ""
- )
- if narrative_guidance:
- # 提取关键句子(以句号分隔,取有实质内容的句子)
- key_lines = [
- s.strip() for s in re.split(r"[。.\n]", narrative_guidance)
- if len(s.strip()) > 10 and not s.strip().startswith("-")
- ][:5]
- if key_lines:
- guidance_text = "。".join(key_lines)
- seeds.append(f"题材叙事指导(注入写作规则):{guidance_text}。")
- return seeds
- def _build_revision_seeds(self, fm: dict, sections: dict, lang: str) -> list[str]:
- """从题材配置构建 revision 岛种子"""
- seeds = []
- # 语言铁律 / Language Rules
- language_rules = sections.get("语言铁律") or sections.get("Language Rules") or ""
- if language_rules:
- # 提取具体规则行
- rule_lines = [
- line.strip().lstrip("- ").lstrip("* ")
- for line in language_rules.split("\n")
- if line.strip() and (line.strip().startswith("-") or line.strip().startswith("*")
- or "→" in line or "✗" in line or "✓" in line)
- ]
- if rule_lines:
- rules_text = ";".join(rule_lines[:8])
- seeds.insert(0, f"题材润色特化规则:{rules_text}。这些规则优先于通用润色规则执行。")
- # 年代考据规则
- if fm.get("eraResearch"):
- seeds.append(
- "年代考据规则(题材特定):涉及法律/政策/商业规则/物价/机构名/行业术语必须符合设定年代。"
- "金融操作和公司运营需有基本可信度。地名和机构名不可凭空编造。"
- )
- return seeds
- def _build_quality_check_seeds(self, fm: dict, sections: dict, lang: str) -> list[str]:
- """从题材配置构建 quality_check 岛种子"""
- seeds = []
- # 题材禁忌 → S1/S2 检查项
- prohibitions = sections.get("题材禁忌") or sections.get("Genre Prohibitions") or ""
- if prohibitions:
- items = []
- for line in prohibitions.split("\n"):
- stripped = line.strip().lstrip("- ").lstrip("* ")
- if len(stripped) > 10:
- items.append(stripped)
- if items:
- check_items = []
- for i, item in enumerate(items[:10]):
- severity = "S1" if any(
- kw in item for kw in ["禁止", "不可", "杜绝", "never", "must not", "prohibited"]
- ) else "S2"
- check_items.append(f" {i+1}. [{severity}] {item}")
- seeds.append(
- f"题材特定质量审查清单:\n" + "\n".join(check_items) +
- f"\n每章完成后逐项检查,S1项不过直接退回修改。"
- )
- # 审计维度
- audit_dims = fm.get("auditDimensions", [])
- if audit_dims:
- dims_str = ",".join(str(d) for d in audit_dims)
- seeds.append(
- f"37维审查启用维度子集(题材特定):[{dims_str}]。"
- f"未启用的维度本作不适用,跳过检查以降低误报。"
- )
- # 数值系统检查
- if fm.get("numericalSystem"):
- seeds.append(
- "数值一致性检查(题材特定):统计面板/战力数值/资源计数必须与上下文一致。"
- "同质资源重复获取须写明衰减。增量逐笔列出,不可跳跃式增长。"
- )
- # 战力体系检查
- if fm.get("powerScaling"):
- seeds.append(
- "战力体系检查(题材特定):境界/品阶/等级突破必须有积累过程。"
- "不可出现无铺垫跳跃式突破。跨大境界需特殊条件(天劫/机缘/丹药)。"
- "能力上限/附加代价/触发条件/成长路径——四维约束缺一不可。"
- )
- return seeds
- def _build_outline_seeds(self, fm: dict, sections: dict, lang: str) -> list[str]:
- """从题材配置构建 outline 岛种子"""
- seeds = []
- # 章节类型
- chapter_types = fm.get("chapterTypes", [])
- if chapter_types:
- types_str = "、".join(chapter_types)
- seeds.append(
- f"章节类型轮换策略(题材特定):可用类型——{types_str}。"
- f"连续3章以上同类型触发疲劳警告。每卷类型分布应覆盖所有列出类型。"
- f"各类型比例为:战斗/推进类40%,布局/过渡类35%,回收/高潮类25%。"
- )
- # 世界观规则 section
- world_rules = (
- sections.get("修炼规则")
- or sections.get("World Rules")
- or sections.get("年代与现实约束")
- or sections.get("System Design Rules")
- or ""
- )
- if world_rules:
- key_rules = []
- for line in world_rules.split("\n"):
- stripped = line.strip().lstrip("- ").lstrip("* ")
- if len(stripped) > 15:
- key_rules.append(stripped)
- if key_rules:
- rules_text = ";".join(key_rules[:6])
- seeds.append(f"世界观一致性规则(题材特定,大纲阶段检查):{rules_text}。")
- return seeds
- def _build_hook_management_seeds(self, fm: dict, sections: dict, lang: str) -> list[str]:
- """从题材配置构建 hook_management 岛种子"""
- seeds = []
- # 题材特定的悬念期待类型
- satisfaction = fm.get("satisfactionTypes", [])
- if satisfaction:
- types_str = "、".join(satisfaction[:4])
- seeds.append(
- f"题材悬念期待管理:本类型读者的核心期待——{types_str}。"
- f"伏笔设计优先围绕这几类期待展开。每卷至少种下2个指向不同类型期待的伏笔。"
- )
- # 节奏规则 → 伏笔兑现节奏
- pacing = fm.get("pacingRule", "")
- if pacing:
- seeds.append(
- f"伏笔兑现节奏约束:{pacing}。伏笔的advance和payoff需与此节奏同步——"
- f"快节奏段兑现密度可提高,慢节奏段以种植和推进为主。"
- )
- # 从世界观/修炼规则提取伏笔管理特殊规则
- world_rules = (
- sections.get("修炼规则")
- or sections.get("World Rules")
- or sections.get("年代与现实约束")
- or ""
- )
- if world_rules:
- key_lines = [
- s.strip().lstrip("- ").lstrip("* ")
- for s in world_rules.split("\n")
- if len(s.strip()) > 15 and any(
- kw in s for kw in ["规则", "约束", "限制", "禁止", "条件", "代价", "rule", "limit", "cannot", "must"]
- )
- ][:3]
- if key_lines:
- rules_text = ";".join(key_lines)
- seeds.append(
- f"题材伏笔管理特殊规则:{rules_text}。"
- f"违反这些规则的伏笔在plant阶段即应被拒绝或标记为'需特殊机制'。"
- )
- return seeds
- # ── 主入口 ──────────────────────────────────────
- def extract_seeds(self, genre_id: str | None = None,
- project_path: Path | None = None) -> dict[str, list[str]]:
- """
- 提取题材特定的 5 岛种子提示词。
- 返回: {island_name: [prompt_strings, ...]}
- """
- # 确定题材 ID
- if not genre_id and project_path:
- genre_name = find_project_genre(project_path)
- genre_id = map_genre_to_inkos_id(genre_name)
- elif not genre_id:
- genre_id = "other"
- profile = self._load_with_fallback(genre_id)
- fm = profile.get("frontmatter", {})
- sections = profile.get("sections", {})
- lang = profile.get("language", "zh")
- # 如果 profile 是空的(InkOS 不可用),用回退种子
- if not fm and not sections:
- print(f" ⚠ InkOS 题材配置不可用,使用通用回退种子")
- return FALLBACK_SEEDS
- seeds = {
- "writing": self._build_writing_seeds(fm, sections, lang),
- "revision": self._build_revision_seeds(fm, sections, lang),
- "quality_check": self._build_quality_check_seeds(fm, sections, lang),
- "outline": self._build_outline_seeds(fm, sections, lang),
- "hook_management": self._build_hook_management_seeds(fm, sections, lang),
- }
- # 对种子过少的岛屿,从 general pool 补充(避免重复)
- general = self._load_profile("other")
- for island, current in seeds.items():
- if len(current) < 2:
- # 先从 general profile 补
- if general and general.get("frontmatter"):
- gf = general["frontmatter"]
- gs = general["sections"]
- gl = general.get("language", "zh")
- fallback_builder = getattr(self, f"_build_{island}_seeds", None)
- if fallback_builder:
- fb_seeds = fallback_builder(gf, gs, gl)
- for s in fb_seeds:
- if s not in current:
- current.append(s)
- if len(current) >= 2:
- break
- # 还不够 → 从硬编码回退种子补
- if len(current) < 2 and island in FALLBACK_SEEDS:
- for s in FALLBACK_SEEDS[island]:
- if s not in current:
- current.append(s)
- if len(current) >= 2:
- break
- return seeds
- def initialize_genre(self, genre_id: str) -> dict[str, list[str]]:
- """
- 初始化题材级共享进化目录 evolution/genres/{genre_id}/。
- 创建 population.json(含题材种子)和相关文件。
- 返回提取的种子字典。
- """
- seeds = self.extract_seeds(genre_id=genre_id)
- import hashlib
- population = {}
- for island_name, templates in seeds.items():
- variants = []
- for template in templates:
- variant_id = hashlib.md5(template.encode()).hexdigest()[:12]
- variants.append({
- "id": variant_id,
- "template": template,
- "source": f"seed:{genre_id}",
- "scores": {},
- "trials": 0,
- "trials_per_dim": {},
- "lineage": [],
- })
- population[island_name] = {
- "generation": 0,
- "variants": variants,
- }
- save_genre_evolution(genre_id, "prompts/population.json", population)
- save_genre_evolution(genre_id, "prompts/current_best.json",
- {k: [v["template"] for v in pop["variants"][:3]]
- for k, pop in population.items()})
- default_thresholds = load_evolution("thresholds.json")
- if default_thresholds:
- save_genre_evolution(genre_id, "thresholds.json", default_thresholds)
- else:
- save_genre_evolution(genre_id, "thresholds.json", {
- "pass": 85, "revise_low": 60, "rewrite_low": 40,
- "max_revision_rounds": 3,
- "weights": {"P1": 0.35, "P2": 0.25, "P3": 0.15, "P4": 0.15, "P5": 0.10},
- })
- save_genre_evolution(genre_id, "patterns/pattern_library.json", {
- "patterns": [], "meta": {"total_mined": 0}
- })
- save_genre_evolution(genre_id, "session_state.json", {
- "recent_chapters": [], "total_feedback_count": 0
- })
- print(f" ✓ 题材进化目录已初始化: evolution/genres/{genre_id}/")
- for island, tmpls in seeds.items():
- print(f" [{island}]: {len(tmpls)} 条种子")
- return seeds
- # ── CLI ─────────────────────────────────────────────────
- if __name__ == "__main__":
- import argparse
- parser = argparse.ArgumentParser(description="InkOS 题材感知种子提取器")
- parser.add_argument("--project", "-p", type=str, default=None,
- help="项目路径(自动检测题材)")
- parser.add_argument("--genre", "-g", type=str, default=None,
- help="强制指定题材 ID")
- parser.add_argument("--show", action="store_true",
- help="仅显示提取的种子,不初始化")
- args = parser.parse_args()
- extractor = SeedExtractor()
- if args.project:
- project = Path(args.project)
- genre_id = args.genre or resolve_genre_from_project(project)
- else:
- genre_id = args.genre or "other"
- if args.show:
- seeds = extractor.extract_seeds(genre_id=genre_id)
- for island, tmpls in seeds.items():
- print(f"\n## {island} ({len(tmpls)} 条)")
- for i, t in enumerate(tmpls):
- print(f" {i+1}. {t[:150]}...")
- else:
- extractor.initialize_genre(genre_id)
|