prompt_evolver.py 44 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762
  1. #!/usr/bin/env python3
  2. """
  3. prompt_evolver.py — 提示词模板进化器
  4. 进化结果写入 evolution/prompts/,不修改 SKILL.md。
  5. 核心机制:
  6. - 种群管理 + 双选择策略(Top + Diverse)
  7. - 遗传算子:交叉、突变、简化
  8. - 岛屿模型:5个独立种群,定期迁移
  9. - 进化结果同步到 evolution/prompts/current_best.json
  10. """
  11. import sys, os, re, json, random, hashlib
  12. from pathlib import Path
  13. from typing import Any
  14. sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
  15. from utils import (
  16. find_project_root, get_evolution_dir,
  17. load_evolution, save_evolution, load_project_db, save_project_db,
  18. log_evolution_event,
  19. )
  20. # ── 种子模板 ───────────────────────────────────────────
  21. SEED_TEMPLATES = {
  22. "writing": [
  23. "核心规则(写作时内化不列出):Show, don't tell——用细节堆出真实。价值观盐溶于汤——靠行为证明不靠口号。任何行动立于三条腿:过往经历+当前利益+性格底色。配角有自己账本和利益诉求。每章结尾必有小悬念或情绪缺口。全员智商在线。日常场景七成成后续伏笔。关系改变事件驱动。每一行字要么推动剧情要么塑造人物。",
  24. "看点密集度(硬尺):每300字至少1个爽点(小看点/梗/反套路/暧昧台词/情绪拉扯)。每500字至少1个钩子(引发'接下来怎样'的小悬念)。每1000-1500字至少1个完整悬念(问题—蓄力—未解)。连续300字以上只是环境/回忆/议论/心理独白而无推进=水文须删改。叙事段≥40字(手机屏2行),目标40-120字。短段(<40字)只允许:开场反转金句/章末钩子/≤3个爆点短段,合计≤5个。禁止3+短段连排。",
  25. "去AI味铁律:叙述者永不替读者下结论。禁止分析报告式语言入正文——'核心动机''信息边界''信息落差'等术语只用于PRE_WRITE_CHECK内部推理。转折/惊讶标记词(仿佛/忽然/竟/猛地/不禁/宛如)全篇≤每3000字1次。同一体感/意象禁止连续渲染超两轮。硬性禁令:全文禁止'不是…而是…'句式;全文禁止破折号'——'。反例→正例:✗'他感到非常愤怒'→✓'他捏碎了茶杯,滚烫茶水流过指缝';✗'然而事情没那么简单'→✓'哪有那么便宜的事'。",
  26. "六步走人物心理分析:1.当前处境(面临什么局面?手上什么牌?)2.核心动机(最想要什么?最怕什么?)3.信息边界(知道什么?不知道什么?误判什么?)4.性格过滤(同样局面这角色怎么反应?)5.行为选择(基于以上四点做什么选择?)6.情绪外化(伴随什么情绪?什么身体语言?)。禁止跳过步骤直接写行为。人设防崩三问:他为什么这么做?符合之前人设吗?只看过前文的读者会觉得突兀吗?",
  27. "读者心理学框架:期待管理——读者期待释放时适当延迟增强快感,即将失去耐心时立即给反馈。信息落差——让读者比角色多知道一点(紧张)或少知道一点(好奇)。情绪节拍——压制→释放→更大压制→更大释放,释放超读者预期。递进升级——层层加码,每次比上一次更过分。锚定效应——先给读者参照再展示主角表现。沉没成本——读者已投入的阅读时间是留存关键。",
  28. "黄金三章纪律:第1章主角出场800字内触发主线冲突,前300字最后一句须戏剧性反转,场景≤2个冲突人物≤2个。第2章金手指/能力/系统必须'做出来'——一次具体使用事件+看得见的后果——不是'说出来'。第3章中段让主角下一个可量化短期目标浮上水面。贯穿三章:段落3-5行(手机阅读),动词压过形容词,每章结尾必有小钩子。",
  29. "章节80/20断章:永远不要在一章里把本章故事讲完——主剧情写到80%,剩下20%留给下一章。章末必须断在action-climax一刻:主角刚放大招尚未见效/刚拔刀尚未落下——不给结果。章节结构优先于字数。不凑字数也不为卡字数提前把高潮讲完。",
  30. "代入感六支柱:基础信息标签化(100字内谁/在哪/发生什么)。可视化熟悉感(消毒水味/地铁座椅凉/外卖塑料袋塑胶感)。共鸣两层(认知共鸣+情绪共鸣)。欲望两条腿(基础欲望+主动欲望)。五感钩子(每场景除视觉外1-2种感官)。人设核心标签+反差细节才活——冷面杀手偷偷喂流浪猫。",
  31. "情感节点设计:关系发展必须事件驱动节点递进——设计3-5个关键事件(共同御敌/秘密分享/利益冲突/信任考验/牺牲妥协)。每个事件推进关系一个层级,禁止跨越式发展。情绪用场景传达:环境烘托+微动作替代直白抒情。强情绪升级法——坏事叠坏事:被骂→赶不上公交→手机掉了→直播课结束→包子噎住,每层比上一层过分。",
  32. "配角设计方法论:配角必须有反击有自己算盘。主角强大在于压服聪明人不是碾压傻子。动机绑定主线。核心标签+反差细节=活人。通过事件立人设禁止外貌形容词堆砌。语言区分度——用词习惯/句子长短/口头禅/方言痕迹。拒绝集体反应——不写'众人齐声惊呼',挑1-2个角色写具体反应。",
  33. "输出格式:=== PRE_WRITE_CHECK ===(Markdown表格:当前任务/读者在等什么/该兑现的暂不掀的/日常过渡承担任务/章尾必须发生的改变/不要做/上下文范围/当前锚点/待回收伏笔/本章冲突/章节类型/风险扫描)=== CHAPTER_TITLE ===(章节标题不含'第X章',避开近期重复标题词根)=== CHAPTER_CONTENT ===(正文目标字数区间)。只输出以上三个区块。",
  34. ],
  35. "revision": [
  36. "修稿分流规则:PATCHES处理局部文字问题(措辞/对话/AI痕迹/小连续性错误)——每个PATCH引用原文段落给出替换文本,未涉及内容保持原样。REVISED_CONTENT处理全章级问题(字数压缩/结构重组/节奏重排/重大剧情偏离)——Critical包含字数或结构性问题时必须用REVISED_CONTENT。修稿原则:修根因不做表面润色;伏笔状态须与伏笔池同步;不改变剧情走向和核心冲突;保留原文语言风格节奏呼吸;情绪用动作外化;坏事叠坏事每层比上一层过分。",
  37. "润色边界(硬约束):只改文字层——句式/段落/排版/用词/五感/对话自然度。禁止增删情节/改变人设/调整主线——这些归Reviewer。消灭六条文笔雷点:描写无效删到一笔带过;文笔华丽过度让文字服从情绪;文笔欠佳重写成通顺有画面句子;排版不规范统一手机阅读格式;AI味痕迹替换口语化表达或具体动作;群像脸谱化不写众人震惊。文字层硬规约:段落3-5行/段;句式多样化禁3句以上同结构;动词>形容词;每场景1-2种感官;对话符合身份情绪信息掌握;删除叙述者结论。修改后总长变化≤±15%。",
  38. "字数治理:压缩时优先合并重复信息/删除无变化场景/精简过渡段落——如需删75%以上原文→拒绝标准化标记为需人工拆分。扩写时优先增加五感描写/角色内心反应/场景氛围——如扩展后信息密度显著下降→拒绝标准化标记为需合并。只修正一次不递归。保留关键标记/人物名/地点名/已有事实。不凭空新增子情节。",
  39. "小目标周期修稿指引:如果本章应该是'后效'阶段但仍在加压→把最密集冲突段落改写为展示改变的段落(谁失去了什么/谁的态度变了/新常态是什么)。如果本章应该是'爆发'阶段但没有明确兑现→放大最接近回报的场景让承诺释放超预期。日常段落不服务主线→改写为'饵':加入指向未来的细节/暗示/角色反应。",
  40. ],
  41. "quality_check": [
  42. "自修正循环(不可跳过门控):Loop最多5轮——执行五遍编辑评估→综合评分≥85通过→60-84针对性修订→40-59重写问题段落→<40废弃从细纲重新写。每轮记录本轮评分/发现问题(S1-S4分级)/修复内容/下一轮重点。反模式:评分≥85还在修=无限打磨者;只跑P1=跳过者;每轮同问题=失忆者需向上追溯。",
  43. "五遍编辑评估(按顺序不可跳过):P1结构(35%)——场景目标→阻碍→变化?章节推动力?爽点释放时机?章末钩子拉力?节奏快慢交替?P2角色(25%)——弧线推进?行为一致性?关系变化?配角不只是工具人?换个名字还能认出谁在说话?P3原创性(15%)——反陈词滥调?反AI模式?至少一处读者没预料到?P4对话(15%)——声音辨识度?三维标准同时携带?潜台词?P5文本(10%)——句式变化?措辞精准?五感至少2-3种?综合≥85通过/60-84修订/40-59重写/<40废弃。P1不过不进P2。",
  44. "37维结构审查(InkOS Continuity Auditor标准):OOC/时间线/设定冲突/战力崩坏/数值/伏笔/节奏/文风/信息越界/词汇疲劳/利益链断裂/年代考据/配角降智/配角工具人化/爽点虚化/台词失真/流水账/知识库污染/视角一致性/段落等长/套话密度/公式化转折/列表式结构/支线停滞/弧线平坦/节奏单调/敏感词/正传事件冲突/未来信息泄露/世界规则跨书一致性/番外伏笔隔离/读者期待管理/章节备忘偏离/角色还原度/世界规则遵守/关系动态/正典事件一致性。评分:95-100可发布;85-94小瑕疵流畅;75-84明显问题主干完整;65-74多处影响阅读;<65结构性崩溃。",
  45. "写后确定性校验(零LLM成本规则):'不是…而是…'→error改用直述句;破折号'——'→error用逗号或句号;惊讶标记词全篇≤每3000字1次→warning;高疲劳词单章最多1次→warning;元叙事('到这里算是''接下来就是')→warning;分析报告术语入正文→error;章号引用('第X章')→error改自然表达;作者说教词('显然''毋庸置疑')→warning;集体震惊反应→warning改写1-2个具体角色;连续'了'字句≥6句→warning;长段落≥2段超300字→warning;段落过碎(60%以上<40字)→warning;连续短段≥3段→warning。",
  46. "基础设定审核(5维度评分):1.核心冲突——是否有清晰且有足够张力的核心冲突支撑40章?2.开篇节奏——前5章能否形成翻页驱动力?3.世界一致性——世界观是否内洽且具体?4.角色区分度——主要角色声音和动机是否各不相同?5.节奏可行性——卷纲是否有足够变化不连续10章同节拍?80+通过可开写/60-79需修改/<60方向性错误需重新设计。",
  47. ],
  48. "outline": [
  49. "大纲共创三层决策树:第一层全书结构——分几卷?每卷核心任务?每卷不可逆变化?全书最高潮在哪卷为什么不可替代?第二层卷纲细化——高潮事件?需要哪些铺垫(≥3个伏笔/线索)?几个高潮点分别在哪些章节?主角情感弧线从什么状态到什么状态?卷末钩子须全书级别——不是'下章会怎样'而是'整个故事走向要变了'。第三层细纲关键节点——前10章每章核心事件/章末钩子类型和期待感等级/爽点类型和铺垫起点/推进哪个伏笔种下什么新伏笔。关键章逐章追问,普通章确认核心推进和钩子级别。",
  50. "架构师基础设定生成:产出散文密度基础设定——不是表格/schema/条目式bullet list。story_frame(4段散文):主题与基调→核心冲突与前台/后台双层故事→世界观底色(3-5条不可违反铁律+感官锚点)→终局方向+全书Objective。volume_map(5段散文+节奏原则尾段):各卷主题与情绪曲线→卷间钩子与回收承诺→各卷OKR(Objective+3 Key Results)→卷尾不可逆改变→节奏原则6条(高潮间距/喘息频率/钩子密度/信息释放节奏/爽点节奏/情感节点递进)。",
  51. "章节备忘(chapter_memo)七段格式:## 当前任务→本章必须完成的具体动作,写作始终对齐。## 读者此刻在等什么→控制情绪缺口的制造/延迟/兑现程度。## 该兑现的/暂不掀的→本章必须兑现伏笔+必须压住不掀底牌。## 日常/过渡承担什么任务→非冲突段落功能映射(段落位置→承担功能)。## 关键抉择过三连问→关键选择检查(为何做?符合利益?符合人设?)。## 章尾必须发生的改变→1-3条(信息/关系/物理/权力)。## 本章hook账→硬对应:advance/resolve下每个hook_id须在正文里有具体可定位兑现段(具体物件/事件/信息+可观察动作或交谈),每兑现段≥60字。defer不用落open在章末安排自然引出新悬念。## 不要做→硬约束红线。",
  52. "规划器14条原则:3-5章一个小目标周期(build-up→escalation→climax→aftermath完整循环)。主动塑造读者期待——信息落差+情绪缺口。万物皆饵——日常段埋伏笔/推关系/建反差。人设防崩——三连问每次写行为前必问。1主线+1支线——不超载,支线2-3章内与核心目标关联。爽点密集化——每300字至少1看点/500字1钩子/1000-1500字1完整悬念。高潮前足够铺垫——铺垫越长释放越爽。高潮后展示影响——紧接着1-2章写出改变。人物立体化——核心标签+反差细节。五感具体化——每场景至少1-2种感官。钩子承接——每章结尾必种新钩子或推进已有。钩子账本必结账——兑现移走/推进追加/新种写入/stale标记。圆心法同场多视角——同一事件不同角色视角呈现不同信息。揭1埋2——每揭示一个答案至少埋下两个新问题。",
  53. ],
  54. "hook_management": [
  55. "伏笔全生命周期:plant(种植)——新伏笔分配唯一ID/标记类型(SUSPENSE/PROMISE/MYSTERY/CONFLICT/CHEKHOV)和优先级/标明计划兑现章。advance(推进)——虽未兑现但给出新线索新事实新风险升级或范围收缩,更新最近推进章号。payoff(兑现)——明确揭示或解决,状态改为已回收。defer(推迟)——主动搁置转入后台或被剧情压后。stale(过期)——超10章无推进决定推进/废弃/合并。每章前读钩子账本→列出高优活跃钩子→确认推进/兑现机会。每章后更新:兑现移走→推进追加→新种写入→检查stale。",
  56. "钩子准入控制:不重复——指向同一事件合并不新增;同一钩子家族(多个钩子都指向主角身份秘密)合并为核心钩子+多条线索。不超量——每卷最多3-5个high优先级,活跃钩子总数≤12。有计划——无兑现计划的钩子不种,先想好怎么收再种。新钩子候选:正文中出现会延续到后续且有具体回收方向的未解问题才新增。不要为旧钩子换说法/重述/抽象总结再开新hook_id。提及vs推进:旧钩子只是被提到无新增信息→mention不更新最近推进;出现新事实/证据/关系变化/风险升级→必须更新最近推进=当前章号。",
  57. "钩子健康分析:stale(>10章无推进)→立即标记决定推进/废弃/合并。burst(单章>5个新钩子)→警告削减低优先级。no-advance(high钩子>5章无推进)→下章安排推进。payoff逾期(计划兑现章已过未标记)→确认断头钩子。核心钩子(core_hook=true)stale超10章→warning升级critical——全书只有3-7条核心钩子,任其漂移是叙事腐烂先行指标。卷末核心钩子仍open/stale无显式转入下卷规划→critical。",
  58. "状态追踪铁律(Settler):只提取正文中明确描写的事件和状态变化——不推断/不预测/不补充正文没写内容。正文只写到角色走到门口→状态卡不能写已进入房间。正文暗示可能性未确认→不当已发生事实记录。不删除或修改已有hooks中与本章无关内容——只更新本章正文涉及hooks。伏笔例外:正文中未解疑问/悬念/伏笔线索须在hooks中记录——这是提取叙事承诺不是推断。结算输出:RUNTIME_STATE_DELTA(JSON增量不重写完整truth files)——含currentStatePatch+hookOps(upsert/mention/resolve/defer)+newHookCandidates+chapterSummary。",
  59. "钩子推进判定(严格执行):只有状态真的变了才更新最近推进。不要把'再次提到''换个说法重述''抽象复盘'当推进。判定标准:旧钩子本章出现新事实/新证据/新关系变化/风险升级/范围收缩→推进更新lastAdvancedChapter=当前章号。旧钩子本章只是被提到无新增信息→mention不更新最近推进。旧钩子本章被明确揭示/解决/不再成立→回收状态改已回收。旧钩子被主动搁置/转入后台/被剧情压后→延后标注原因。payoffTiming用语义节奏不用硬写章号:immediate/near-term/mid-arc/slow-burn/endgame。",
  60. "长跨度疲劳检测:章节类型单调——连续5+章同一类型→警告建议轮换铺垫-兑现-反转-后效。情绪单调——连续高张力无释放→警告插入释放节拍(温情/幽默/亲密/反思安静)再升级。标题塌缩——近3章标题共享相同命名壳→警告换新意象/动作/后果/角色向量。开头/结尾句式重复——最近章首句或末句相似度过高→警告从动作/后果/意外开局不用相同镜头调度。",
  61. ],
  62. }
  63. class PromptVariant:
  64. """提示词变体 — 支持多维评分"""
  65. def __init__(self, template: str, source: str = "manual",
  66. scores: dict[str, float] | None = None):
  67. self.id = hashlib.md5(template.encode()).hexdigest()[:12]
  68. self.template = template
  69. self.source = source
  70. self.scores: dict[str, float] = scores or {}
  71. self.trials_per_dim: dict[str, int] = {}
  72. self.trials = 0
  73. self.lineage: list[str] = []
  74. def composite_score(self, weights: dict[str, float] | None = None) -> float:
  75. """按权重计算加权综合分"""
  76. if not self.scores:
  77. return 0.0
  78. # 兼容旧格式:单字段 "composite"
  79. if "composite" in self.scores and len(self.scores) == 1:
  80. return self.scores["composite"]
  81. if not weights:
  82. weights = {"P1": 0.35, "P2": 0.25, "P3": 0.15, "P4": 0.15, "P5": 0.10}
  83. total = 0.0
  84. weight_sum = 0.0
  85. for dim, w in weights.items():
  86. if dim in self.scores:
  87. total += self.scores[dim] * w
  88. weight_sum += w
  89. return total / weight_sum if weight_sum > 0 else 0.0
  90. def to_dict(self) -> dict:
  91. return {
  92. "id": self.id, "template": self.template, "source": self.source,
  93. "scores": self.scores, "trials": self.trials,
  94. "trials_per_dim": self.trials_per_dim, "lineage": self.lineage,
  95. }
  96. @classmethod
  97. def from_dict(cls, d: dict) -> "PromptVariant":
  98. p = cls(d["template"], d.get("source", "manual"))
  99. p.id = d.get("id", p.id)
  100. p.scores = d.get("scores", {})
  101. p.trials = d.get("trials", 0)
  102. p.trials_per_dim = d.get("trials_per_dim", {})
  103. p.lineage = d.get("lineage", [])
  104. # 向后兼容: 旧格式 'score' 单字段 → composite
  105. if not p.scores and "score" in d:
  106. p.scores = {"composite": d["score"]}
  107. return p
  108. class PromptPopulation:
  109. """提示词种群 — 支持多维评分、停滞检测、灭绝重播"""
  110. def __init__(self, category: str, max_size: int = 35):
  111. self.category = category
  112. self.max_size = max_size
  113. self.variants: list[PromptVariant] = []
  114. self.generation = 0
  115. self._best_score_history: list[float] = []
  116. def seed(self, templates: list[str]) -> None:
  117. for t in templates:
  118. self.variants.append(PromptVariant(t, source="seed"))
  119. def select_top(self, n: int = 3, weights: dict[str, float] | None = None) -> list[PromptVariant]:
  120. scored = [v for v in self.variants if v.trials > 0 or v.scores]
  121. scored.sort(key=lambda v: v.composite_score(weights), reverse=True)
  122. return scored[:n]
  123. def select_diverse(self, n: int = 2) -> list[PromptVariant]:
  124. if len(self.variants) <= n:
  125. return self.variants[:]
  126. by_len = sorted(self.variants, key=lambda v: len(v.template))
  127. indices = [int(i * len(by_len) / (n + 1)) for i in range(1, n + 1)]
  128. return [by_len[i] for i in indices if i < len(by_len)]
  129. def add(self, variant: PromptVariant) -> bool:
  130. for v in self.variants:
  131. if v.id == variant.id:
  132. # 合并评分(滑动平均)
  133. for dim, score in variant.scores.items():
  134. old = v.scores.get(dim, 0)
  135. old_trials = v.trials_per_dim.get(dim, 0)
  136. if old_trials > 0:
  137. v.scores[dim] = (old * old_trials + score) / (old_trials + 1)
  138. else:
  139. v.scores[dim] = score
  140. v.trials_per_dim[dim] = old_trials + 1
  141. v.trials += variant.trials
  142. return False
  143. if len(self.variants) < self.max_size:
  144. self.variants.append(variant)
  145. return True
  146. # 淘汰最弱个体
  147. scored = [(v.composite_score() if (v.trials > 0 or v.scores) else float("-inf"), v)
  148. for v in self.variants]
  149. scored.sort(key=lambda x: x[0])
  150. worst = scored[0][1]
  151. if variant.composite_score() > worst.composite_score() or worst.trials == 0:
  152. self.variants.remove(worst)
  153. self.variants.append(variant)
  154. return True
  155. return False
  156. # ── 停滞检测 & 灭绝 ────────────────────────────
  157. def track_best_history(self) -> None:
  158. scored = [v for v in self.variants if v.trials > 0 or v.scores]
  159. best = max((v.composite_score() for v in scored), default=0.0)
  160. self._best_score_history.append(best)
  161. if len(self._best_score_history) > 5:
  162. self._best_score_history = self._best_score_history[-5:]
  163. def is_stagnant(self, generations: int = 2,
  164. improvement_threshold: float = 0.03) -> bool:
  165. if len(self._best_score_history) < generations + 1:
  166. return False
  167. recent = self._best_score_history[-generations:]
  168. if recent[0] == 0:
  169. return False
  170. for s in recent[1:]:
  171. if s >= recent[0] * (1 + improvement_threshold):
  172. return False
  173. return True
  174. def needs_extinction(self, generations: int = 3,
  175. improvement_threshold: float = 0.05) -> bool:
  176. return (self.is_stagnant(generations, improvement_threshold)
  177. and len(self.variants) >= self.max_size * 0.7)
  178. def apply_extinction(self, genre_seeds: list[str] | None = None,
  179. style_injects: list[str] | None = None) -> dict:
  180. """删除底30%,重新播种"""
  181. events = {"removed": 0, "reseeded_genre": 0,
  182. "reseeded_style": 0, "reseeded_crossover": 0}
  183. scored = sorted(
  184. [v for v in self.variants if v.trials > 0 or v.scores],
  185. key=lambda v: v.composite_score()
  186. )
  187. unscored = [v for v in self.variants if v.trials == 0]
  188. remove_count = max(1, int(len(scored) * 0.3))
  189. removed = scored[:remove_count]
  190. survivors = scored[remove_count:] + unscored
  191. events["removed"] = len(removed)
  192. self.variants = survivors
  193. to_add = []
  194. if genre_seeds:
  195. for s in genre_seeds[:max(1, remove_count // 2)]:
  196. to_add.append(PromptVariant(s, source="reseed:genre"))
  197. if style_injects:
  198. for s in style_injects[:max(1, remove_count * 3 // 10)]:
  199. to_add.append(PromptVariant(s, source="reseed:style"))
  200. if len(survivors) >= 2:
  201. for _ in range(max(1, remove_count // 5)):
  202. a, b = random.sample(survivors, 2)
  203. child = crossover(a, b)
  204. child.source = "reseed:crossover"
  205. to_add.append(child)
  206. for v in to_add:
  207. if self.add(v):
  208. key = "reseeded_" + v.source.split(":")[-1]
  209. events[key] = events.get(key, 0) + 1
  210. return events
  211. # ── 遗传算子 ───────────────────────────────────────────
  212. def crossover(a: PromptVariant, b: PromptVariant) -> PromptVariant:
  213. a_sents = re.split(r"[。;;]\s*", a.template)
  214. b_sents = re.split(r"[。;;]\s*", b.template)
  215. if len(a_sents) < 2 or len(b_sents) < 2:
  216. a_kw = re.findall(r"[一-鿿]{2,4}", a.template)
  217. b_kw = re.findall(r"[一-鿿]{2,4}", b.template)
  218. combined = list(set(a_kw + b_kw))
  219. random.shuffle(combined)
  220. template = "。".join(combined[:max(3, len(combined) // 2)])
  221. else:
  222. split_a = random.randint(1, len(a_sents) - 1)
  223. split_b = random.randint(1, len(b_sents) - 1)
  224. template = ";".join(s for s in (a_sents[:split_a] + b_sents[split_b:]) if s.strip())
  225. child = PromptVariant(template, source="crossover")
  226. child.lineage = [a.id, b.id]
  227. return child
  228. def mutate(v: PromptVariant) -> PromptVariant:
  229. template = v.template
  230. synonyms = {
  231. "必须": ["务必", "一定", "确保"],
  232. "检查": ["核查", "验证", "确认"],
  233. "推进": ["推动", "进展"],
  234. "冲突": ["矛盾", "张力"],
  235. "钩子": ["悬念", "抓手"],
  236. "爽点": ["高潮点", "释放点"],
  237. }
  238. for word, alts in synonyms.items():
  239. if word in template and random.random() < 0.3:
  240. template = template.replace(word, random.choice(alts), 1)
  241. sents = re.split(r"[。;;]\s*", template)
  242. if len(sents) >= 3 and random.random() < 0.3:
  243. i, j = random.sample(range(len(sents)), 2)
  244. sents[i], sents[j] = sents[j], sents[i]
  245. template = ";".join(sents)
  246. if random.random() < 0.2:
  247. prefixes = ["关键:", "注意:", "核心:"]
  248. template = random.choice(prefixes) + template
  249. mutant = PromptVariant(template, source="mutate")
  250. mutant.lineage = [v.id]
  251. return mutant
  252. def simplify(v: PromptVariant) -> PromptVariant:
  253. sents = re.split(r"[。;;]\s*", v.template)
  254. if len(sents) <= 2:
  255. return v
  256. simplified = PromptVariant(";".join([sents[0], sents[-1]]), source="simplify")
  257. simplified.lineage = [v.id]
  258. return simplified
  259. def template_similarity(a: str, b: str) -> float:
  260. a_words = set(re.findall(r"[一-鿿]{2,}", a))
  261. b_words = set(re.findall(r"[一-鿿]{2,}", b))
  262. if not a_words or not b_words:
  263. return 0.0
  264. return len(a_words & b_words) / len(a_words | b_words)
  265. def inject_from_style_report(report_path: Path) -> PromptVariant | None:
  266. """从对标/风格报告.md 提取风格约束并生成注入变体。
  267. 解析风格报告的结构化章节(句式/对话/描写/节奏/语调),
  268. 编码为写作规则提示词,source 标记为 inject:{书名}。
  269. """
  270. try:
  271. text = report_path.read_text(encoding="utf-8")
  272. except Exception:
  273. return None
  274. fm_match = re.match(r"^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$", text)
  275. if not fm_match:
  276. return None
  277. body = fm_match.group(2)
  278. rules = []
  279. source_name = report_path.parent.name
  280. # 句式 → 句长和节奏约束
  281. sent_match = re.search(r"## 句式\s*\n(.*?)(?=\n## |\Z)", body, re.DOTALL)
  282. if sent_match:
  283. sent_text = sent_match.group(1)
  284. avg_match = re.search(r"平均句长[::]\s*(\d+)[–\-—]?\d*\s*字", sent_text)
  285. if avg_match:
  286. rules.append(f"目标句长:{avg_match.group(1)}字/句")
  287. ratio_match = re.search(r"短句.*?(?:约占\s*)?(\d+)\s*%", sent_text)
  288. if ratio_match:
  289. rules.append(f"短句比例参考:{ratio_match.group(1)}%")
  290. # 对话 → 对话占比和风格
  291. dial_match = re.search(r"## 对话\s*\n(.*?)(?=\n## |\Z)", body, re.DOTALL)
  292. if dial_match:
  293. dial_text = dial_match.group(1)
  294. pct_match = re.search(r"对话占比[::]\s*约?\s*(\d+)\s*%", dial_text)
  295. if pct_match:
  296. rules.append(f"对话占比参考:{pct_match.group(1)}%")
  297. # 描写 → 五感偏好
  298. desc_match = re.search(r"## 描写\s*\n(.*?)(?=\n## |\Z)", body, re.DOTALL)
  299. if desc_match:
  300. desc_text = desc_match.group(1)
  301. for sense in ["视觉", "触觉", "听觉", "嗅觉", "味觉"]:
  302. if sense in desc_text:
  303. rules.append(f"五感偏好:{sense}主导描写")
  304. break
  305. # 节奏 → 章首章末规则
  306. pacing_match = re.search(r"## 节奏\s*\n(.*?)(?=\n## |\Z)", body, re.DOTALL)
  307. if pacing_match:
  308. pacing_text = pacing_match.group(1)
  309. if "章首" in pacing_text or "开头" in pacing_text:
  310. rules.append("章首规则:3段内进入情境")
  311. if "章末" in pacing_text and "钩子" in pacing_text:
  312. rules.append("章末规则:留钩子")
  313. # 语调 → 叙事距离和视角
  314. tone_match = re.search(r"## 语调\s*\n(.*?)(?=\n## |\Z)", body, re.DOTALL)
  315. if tone_match:
  316. tone_text = tone_match.group(1)
  317. for pov in ["第三人称有限", "第三人称", "第一人称", "上帝视角", "全知"]:
  318. if pov in tone_text:
  319. rules.append(f"视角约束:{pov}")
  320. break
  321. if not rules:
  322. return None
  323. prompt = f"【{source_name}风格注入】" + ";".join(rules)
  324. v = PromptVariant(prompt, source=f"inject:{source_name}")
  325. v.lineage = [f"style:{source_name}"]
  326. return v
  327. # ── 岛屿模型 ───────────────────────────────────────────
  328. class IslandModel:
  329. """岛屿模型 — 5 个独立种群 + 迁移 + 早停"""
  330. def __init__(self):
  331. self.islands: dict[str, PromptPopulation] = {}
  332. self.stagnation_counters: dict[str, int] = {}
  333. def initialize(self, seeds: dict[str, list[str]]) -> None:
  334. for cat, templates in seeds.items():
  335. pop = PromptPopulation(cat)
  336. pop.seed(templates)
  337. self.islands[cat] = pop
  338. def evolve_all(self, generations: int = 1, mutation_rate: float = 0.3,
  339. weights: dict[str, float] | None = None,
  340. genre_seeds: dict[str, list[str]] | None = None,
  341. style_injects: dict[str, list[str]] | None = None,
  342. project_path: Path | None = None) -> dict[str, list[PromptVariant]]:
  343. offspring: dict[str, list[PromptVariant]] = {}
  344. for cat, pop in self.islands.items():
  345. pop.track_best_history()
  346. # 早停:连续2代提升<3% → 跳过变异
  347. if pop.is_stagnant(generations=2, improvement_threshold=0.03):
  348. self.stagnation_counters[cat] = self.stagnation_counters.get(cat, 0) + 1
  349. print(f" [{cat}] 停滞 {self.stagnation_counters[cat]} 代,跳过变异")
  350. offspring[cat] = []
  351. continue
  352. else:
  353. self.stagnation_counters[cat] = 0
  354. # 灭绝:连续3代提升<5% 且种群≥70%满
  355. if pop.needs_extinction(generations=3, improvement_threshold=0.05):
  356. print(f" [{cat}] 触发灭绝-重播")
  357. gs = (genre_seeds or {}).get(cat, [])
  358. si = (style_injects or {}).get(cat, [])
  359. events = pop.apply_extinction(gs, si)
  360. if project_path:
  361. log_evolution_event(project_path, f"灭绝事件_{cat}", events)
  362. offspring[cat] = []
  363. continue
  364. # 正常进化
  365. children = []
  366. parents = pop.select_top(3, weights)
  367. diverse = pop.select_diverse(2)
  368. for p in parents[:2]:
  369. children.append(PromptVariant(
  370. p.template, source="elite", scores=p.scores.copy()
  371. ))
  372. for i in range(min(3, len(parents) - 1)):
  373. children.append(crossover(parents[i], parents[i + 1]))
  374. for p in parents + diverse:
  375. if random.random() < mutation_rate:
  376. children.append(mutate(p))
  377. for p in diverse:
  378. if random.random() < mutation_rate * 0.5:
  379. children.append(simplify(p))
  380. for child in children:
  381. pop.add(child)
  382. offspring[cat] = children
  383. pop.generation += generations
  384. return offspring
  385. def all_islands_stagnant(self) -> bool:
  386. if not self.islands:
  387. return False
  388. return all(
  389. pop.is_stagnant(generations=2, improvement_threshold=0.03)
  390. for pop in self.islands.values()
  391. )
  392. def migrate(self, top_n: int = 2) -> int:
  393. migrated = 0
  394. islands = list(self.islands.keys())
  395. if len(islands) < 2:
  396. return 0
  397. for i, source_cat in enumerate(islands):
  398. target_cat = islands[(i + 1) % len(islands)]
  399. elites = self.islands[source_cat].select_top(top_n)
  400. for elite in elites:
  401. copy = PromptVariant(
  402. f"[迁自{source_cat}]{elite.template}",
  403. source=f"migrate:{source_cat}",
  404. scores={k: v * 0.9 for k, v in elite.scores.items()},
  405. )
  406. if self.islands[target_cat].add(copy):
  407. migrated += 1
  408. return migrated
  409. def sync_to_evolution(self) -> dict:
  410. """同步最优提示词到 skill 级 evolution/prompts/current_best.json"""
  411. best = {}
  412. for cat, pop in self.islands.items():
  413. top = pop.select_top(3)
  414. if len(top) < 3:
  415. # 用未评分变体补充到 3 个
  416. unscored = [v for v in pop.variants if v.trials == 0]
  417. for v in unscored:
  418. if v.template not in [t.template for t in top]:
  419. top.append(v)
  420. if len(top) >= 3:
  421. break
  422. best[cat] = [v.template for v in top[:3]] if top else [
  423. v.template for v in pop.variants[:3]
  424. ]
  425. save_evolution("prompts/current_best.json", best)
  426. return best
  427. def sync_to_genre(self, genre_id: str) -> dict:
  428. """同步最优提示词到 evolution/genres/{genre_id}/prompts/current_best.json"""
  429. from utils import save_genre_evolution
  430. best = {}
  431. for cat, pop in self.islands.items():
  432. top = pop.select_top(3)
  433. if len(top) < 3:
  434. unscored = [v for v in pop.variants if v.trials == 0]
  435. for v in unscored:
  436. if v.template not in [t.template for t in top]:
  437. top.append(v)
  438. if len(top) >= 3:
  439. break
  440. best[cat] = [v.template for v in top[:3]] if top else [
  441. v.template for v in pop.variants[:3]
  442. ]
  443. save_genre_evolution(genre_id, "prompts/current_best.json", best)
  444. return best
  445. # ── 持久化 ─────────────────────────────────────────────
  446. def save_model(model: IslandModel, genre_id: str | None = None) -> None:
  447. """保存岛屿模型到 population.json(题材级或技能级)"""
  448. data = {}
  449. for cat, pop in model.islands.items():
  450. data[cat] = {
  451. "generation": pop.generation,
  452. "variants": [v.to_dict() for v in pop.variants],
  453. }
  454. if genre_id:
  455. from utils import save_genre_evolution
  456. save_genre_evolution(genre_id, "prompts/population.json", data)
  457. else:
  458. save_evolution("prompts/population.json", data)
  459. def load_model(genre_id: str | None = None) -> IslandModel:
  460. """加载岛屿模型(题材级优先,回退到技能级)"""
  461. model = IslandModel()
  462. if genre_id:
  463. from utils import load_genre_evolution
  464. data = load_genre_evolution(genre_id, "prompts/population.json")
  465. if data:
  466. for cat, pop_data in data.items():
  467. pop = PromptPopulation(cat, max_size=35)
  468. pop.generation = pop_data.get("generation", 0)
  469. pop.variants = [PromptVariant.from_dict(d)
  470. for d in pop_data.get("variants", [])]
  471. model.islands[cat] = pop
  472. return model
  473. # 回退:技能级
  474. data = load_evolution("prompts/population.json")
  475. if data:
  476. for cat, pop_data in data.items():
  477. pop = PromptPopulation(cat, max_size=35)
  478. pop.generation = pop_data.get("generation", 0)
  479. pop.variants = [PromptVariant.from_dict(d)
  480. for d in pop_data.get("variants", [])]
  481. model.islands[cat] = pop
  482. else:
  483. model.initialize(SEED_TEMPLATES)
  484. return model
  485. # ── 题材级进化入口 ─────────────────────────────────────
  486. def evolve_genre(genre_id: str, project_path: Path | None = None,
  487. generations: int = 3, mutation_rate: float = 0.3) -> dict:
  488. """题材级共享提示词进化。
  489. 同题材所有项目共享进化数据。project_path 可选——传入时
  490. 会扫描该项目的风格报告注入到题材池中,实现跨书风格交叉。
  491. """
  492. print(f"遗传 提示词进化 (题材: {genre_id})")
  493. from utils import ensure_genre_evolution, find_style_reports
  494. ensure_genre_evolution(genre_id)
  495. model = load_model(genre_id)
  496. # 收集风格报告注入(来自具体项目,但写入题材池)
  497. style_injects: dict[str, list[str]] = {cat: [] for cat in model.islands}
  498. if project_path:
  499. style_reports = find_style_reports(project_path)
  500. for report_path in style_reports:
  501. injected = inject_from_style_report(report_path)
  502. if injected:
  503. style_injects.setdefault("writing", []).append(injected.template)
  504. if model.islands.get("writing", PromptPopulation("writing")).add(injected):
  505. print(f" 风格注入: {report_path.parent.name} → writing 岛(题材池)")
  506. # 获取题材种子(供灭绝重播用)
  507. from seed_extractor import SeedExtractor
  508. extractor = SeedExtractor()
  509. genre_seeds = extractor.extract_seeds(genre_id=genre_id)
  510. # 加载权重
  511. from utils import load_genre_evolution
  512. thresholds = load_genre_evolution(genre_id, "thresholds.json")
  513. if not thresholds:
  514. thresholds = load_evolution("thresholds.json") or {}
  515. weights = thresholds.get("weights",
  516. {"P1": 0.35, "P2": 0.25, "P3": 0.15, "P4": 0.15, "P5": 0.10})
  517. total_offspring = 0
  518. # 记录演化前状态用于事件日志
  519. pre_variant_ids = {
  520. cat: {v.id for v in pop.variants}
  521. for cat, pop in model.islands.items()
  522. }
  523. for gen in range(generations):
  524. if model.all_islands_stagnant():
  525. print(" 所有岛屿停滞,进化收敛")
  526. break
  527. offspring = model.evolve_all(
  528. generations=1, mutation_rate=mutation_rate,
  529. weights=weights,
  530. genre_seeds=genre_seeds,
  531. style_injects=style_injects,
  532. project_path=project_path,
  533. )
  534. gen_count = sum(len(v) for v in offspring.values())
  535. total_offspring += gen_count
  536. if (gen + 1) % 2 == 0:
  537. model.migrate(top_n=2)
  538. # 写入事件日志(Event Sourcing)
  539. post_variant_ids = {
  540. cat: {v.id for v in pop.variants}
  541. for cat, pop in model.islands.items()
  542. }
  543. events = []
  544. for cat, pop in model.islands.items():
  545. pre_ids = pre_variant_ids.get(cat, set())
  546. post_ids = post_variant_ids.get(cat, set())
  547. new_ids = post_ids - pre_ids
  548. removed_ids = pre_ids - post_ids
  549. if new_ids or removed_ids:
  550. new_variants = [
  551. v.to_dict() for v in pop.variants if v.id in new_ids
  552. ]
  553. events.append({
  554. "type": "mutate",
  555. "island": cat,
  556. "generation": pop.generation,
  557. "new_variants": new_variants,
  558. "removed_variants": list(removed_ids),
  559. })
  560. if events:
  561. from event_store import write_events
  562. write_events(genre_id, events)
  563. save_model(model, genre_id)
  564. best = model.sync_to_genre(genre_id)
  565. print(f" 产生 {total_offspring} 个新变体")
  566. print(f" 题材进化结果已写入 evolution/genres/{genre_id}/")
  567. for cat, prompts in best.items():
  568. print(f" [{cat}]: {len(prompts)} 最优提示词")
  569. return {"status": "ok", "offspring": total_offspring, "genre": genre_id}
  570. # ── CLI ─────────────────────────────────────────────────
  571. def evolve_skill(generations: int = 3, mutation_rate: float = 0.3) -> dict:
  572. """进化 skill 自身的提示词(不依赖项目)"""
  573. print(f"遗传 提示词进化 (skill-level)")
  574. model = load_model()
  575. thresholds = load_evolution("thresholds.json") or {}
  576. weights = thresholds.get("weights", {"P1": 0.35, "P2": 0.25, "P3": 0.15, "P4": 0.15, "P5": 0.10})
  577. print(f" 岛屿数: {len(model.islands)}")
  578. for cat, pop in model.islands.items():
  579. scored = [v for v in pop.variants if v.trials > 0 or v.scores]
  580. print(f" [{cat}] variants={len(pop.variants)} scored={len(scored)} gen={pop.generation}")
  581. total_offspring = 0
  582. for gen in range(generations):
  583. if model.all_islands_stagnant():
  584. print(" 所有岛屿停滞,进化收敛")
  585. break
  586. offspring = model.evolve_all(generations=1, mutation_rate=mutation_rate)
  587. gen_count = sum(len(v) for v in offspring.values())
  588. total_offspring += gen_count
  589. if (gen + 1) % 2 == 0:
  590. model.migrate(top_n=2)
  591. save_model(model)
  592. best = model.sync_to_evolution()
  593. print(f" ✓ 产生 {total_offspring} 个新变体")
  594. print(f" ✓ 进化结果已写入 evolution/prompts/")
  595. for cat, prompts in best.items():
  596. print(f" [{cat}]: {len(prompts)} 最优提示词")
  597. return {"status": "ok", "offspring": total_offspring}
  598. if __name__ == "__main__":
  599. import argparse
  600. parser = argparse.ArgumentParser(description="提示词进化器")
  601. parser.add_argument("action", choices=["evolve", "show"],
  602. help="evolve=进化 | show=展示当前最优提示词")
  603. parser.add_argument("--project", "-p", type=str, default=None,
  604. help="项目路径(自动检测题材)")
  605. parser.add_argument("--genre", "-g", type=str, default=None,
  606. help="题材 ID(直接指定,跳过自动检测)")
  607. parser.add_argument("--generations", type=int, default=3)
  608. parser.add_argument("--mutation-rate", "-m", type=float, default=0.3)
  609. args = parser.parse_args()
  610. if args.action == "evolve":
  611. if args.genre:
  612. evolve_genre(args.genre,
  613. generations=args.generations,
  614. mutation_rate=args.mutation_rate)
  615. elif args.project:
  616. from utils import resolve_genre_from_project
  617. genre_id = resolve_genre_from_project(Path(args.project))
  618. evolve_genre(genre_id, project_path=Path(args.project),
  619. generations=args.generations,
  620. mutation_rate=args.mutation_rate)
  621. else:
  622. evolve_skill(generations=args.generations,
  623. mutation_rate=args.mutation_rate)
  624. elif args.action == "show":
  625. if args.genre:
  626. from utils import load_genre_evolution
  627. best = load_genre_evolution(args.genre, "prompts/current_best.json")
  628. elif args.project:
  629. from utils import resolve_genre_from_project, load_genre_evolution
  630. genre_id = resolve_genre_from_project(Path(args.project))
  631. best = load_genre_evolution(genre_id, "prompts/current_best.json")
  632. else:
  633. best = load_evolution("prompts/current_best.json")
  634. if not best:
  635. model = IslandModel()
  636. model.initialize(SEED_TEMPLATES)
  637. model.sync_to_evolution()
  638. best = load_evolution("prompts/current_best.json")
  639. for cat, prompts in best.items():
  640. print(f"\n## {cat}")
  641. for i, p in enumerate(prompts):
  642. print(f" {i+1}. {p[:120]}...")