Talegorithm vor 2 Wochen
Ursprung
Commit
b926510e6d

+ 410 - 0
examples/analyze_story/runs/搜神记/analysis/w0.error.txt

@@ -0,0 +1,410 @@
+```json
+{
+  "outline": {
+    "main_plot": "神农物化前托付少年拓拔野携神木令与血书赴玉屏山寻青帝、再往蜃楼城传谕,以化解水族围攻危机。途中拓拔野结识雨师妾、空桑仙子、龙神等关键人物,习得潮汐流、长生诀、封印魔法,逐步成长为可独当一面的领袖。然蜃楼城终被攻破,科汗淮与乔羽下落不明,拓拔野与蚩尤率汤谷群雄东山再起,为救纤纤踏上东海寻龙珠、流波山伏夔牛、雷泽城解圣杯疑云、洞庭湖救赤虬、灵山求奇毒等险途,主线始终围绕‘守护自由’与‘重建蜃楼城’展开,而神帝遗志、五族权争、凶兽现世等暗线交织推进。",
+    "plot_lines": [
+      {
+        "name": "神帝遗命",
+        "mice_type": "I",
+        "status": "进行中",
+        "description": "神农临终托付血书与神木令,命拓拔野七日内送达青帝或蜃楼城主,以止水妖兵祸;但青帝失踪、蜃楼城破,血书内容成谜,其真实意图与后续影响尚未揭晓。",
+        "core_question": "神农血书究竟写了什么?",
+        "next_steps": "在雷泽城密库中发现被劈裂的琉璃圣火杯,暗示血书指令与火族赤帝出关存在隐秘关联"
+      },
+      {
+        "name": "纤纤生死",
+        "mice_type": "C",
+        "status": "进行中",
+        "description": "纤纤自尽后魂魄暂寄鲛珠,借龙珠复生却随即离岛西行,被误认为‘空桑转世’卷入火族圣杯失窃案,身份真假、心性变化、蛊毒真相构成多重悬念。",
+        "core_question": "纤纤是真身还是被晏紫苏操控的傀儡?",
+        "next_steps": "追踪至雷泽城贵宾馆,确认其体内无‘两心知’蛊,且对桃木姥姥记忆清晰,初步排除被完全控制"
+      },
+      {
+        "name": "水族权谋",
+        "mice_type": "E",
+        "status": "进行中",
+        "description": "烛龙借蓝翼海龙兽事件发难,围攻蜃楼城,实为制造声望、铺路神帝之位;其与天吴、冰夷、姬泪垂等势力协同布局,已形成对东海诸国的全面压制。",
+        "core_question": "烛龙下一步将如何利用‘圣杯失窃’引爆火木大战?",
+        "next_steps": "米离、吴回率火族大军压境雷泽城,逼迫雷神当庭交杯,战事一触即发"
+      }
+    ]
+  },
+  "characters": [
+    {
+      "name": "拓拔野",
+      "role": "主角",
+      "goal": "护送纤纤安全抵达昆仑见西王母,并查明圣杯失窃真相以洗刷其冤屈",
+      "traits": ["乐观洒脱", "重情重义", "天赋异禀", "直觉敏锐"],
+      "speaking_style": ["夹杂俚语与古语", "危急时斩钉截铁", "面对女性常带调侃式温柔"],
+      "current_state": "孤身苦守破庙三日未见雨师妾,焦虑中写下‘小傻蛋去朝歌山砍柴啦’留字,决意启程赴灵山",
+      "relationships": {"雨师妾": "刻骨情牵,彼此甘冒灭族风险", "纤纤": "视若亲妹却难辨心动,愧疚与责任交织", "龙神": "认作义母,信任深厚但情感微妙"}
+    },
+    {
+      "name": "雨师妾",
+      "role": "关键女性角色",
+      "goal": "摆脱冰夷监视,秘密与拓拔野会合并助其完成使命",
+      "traits": ["妖冶深情", "果决狠辣", "智计过人", "外媚内刚"],
+      "speaking_style": ["慵懒娇媚,尾音微颤", "危急时言简意厉", "对拓拔野常用昵称与双关"],
+      "current_state": "被冰夷以千里子母香追踪,在日华城驿站与拓拔野咫尺相望却强忍不相认,最终悄然遁走",
+      "relationships": {"拓拔野": "倾心至死,愿为其叛族", "冰夷": "表面同僚,实为相互忌惮的制衡关系", "天吴": "兄妹之情已名存实亡,仅余政治捆绑"}
+    },
+    {
+      "name": "纤纤",
+      "role": "核心情感支点",
+      "goal": "独自前往昆仑寻找生母西王母,同时验证自己是否被拓拔野真心所爱",
+      "traits": ["聪慧早熟", "执拗倔强", "情深不寿", "纯真与锋利并存"],
+      "speaking_style": ["少女娇嗔中藏机锋", "哭泣时声音断续如碎玉", "对拓拔野说话常带试探性反问"],
+      "current_state": "被火族囚于贵宾馆,以原心法供述受桃木姥姥托付献杯,却不知自己正成为烛龙挑动火木大战的关键棋子",
+      "relationships": {"拓拔野": "单向炽烈,以死明志后仍不敢信其真心", "蚩尤": "朦胧依恋,被其勇悍触动却羞于承认", "辛九姑": "视如母亲,唯一可袒露脆弱的对象"}
+    },
+    {
+      "name": "晏紫苏",
+      "role": "高阶反派/亦敌亦友者",
+      "goal": "夺取琉璃圣火杯,嫁祸雷神,瓦解木族青帝推选格局",
+      "traits": ["千面善变", "蛊毒通神", "算无遗策", "悲悯藏于狠绝之下"],
+      "speaking_style": ["语调甜腻如蜜糖", "每句话皆有双重意味", "对蚩尤用‘呆子’称呼消解杀气"],
+      "current_state": "重伤遁走,乾坤袋中藏有真圣杯,已成功将纤纤引入雷泽城并布下连环局,自身却遭祝融追击濒死",
+      "relationships": {"蚩尤": "种下‘两心知’蛊,以纤纤性命要挟其合作", "祝融": "元神寄体追捕者,亦是她计划中必须清除的障碍", "洛姬雅": "旧识,彼此知晓对方底牌,互为镜像式对手"}
+    }
+  ],
+  "writing_insights": {
+    "techniques": ["多线并进:纤纤西行、拓拔野赴凤尾、蚩尤追妖女、六侯爷探宁姬,四线同步推进张力", "感官锚定叙事:以气味(雨师妾体香)、声音(箫声/号角)、触感(泪珠坠/鲛珠)贯穿情感线索", "器物人格化:无锋剑、珊瑚笛、苍龙角等神器皆具灵性与宿命感,非工具而是角色延伸"],
+    "shuang_designs": ["成长型装逼:从‘扛着巨木招摇过市’到‘御风踏浪破凤尾树火海’,实力跃迁全程可视化", "情感型打脸:雨师妾当众吻拓拔野,反向击溃其‘只当纤纤是妹妹’的自我认知", "信息差碾压:读者早知晏紫苏易容,而蚩尤、祝融、火族众人皆被蒙蔽,形成智力优越感"],
+    "pacing": ["每章必设‘节奏锚点’:开篇景物+悬念钩子(如夔牛吼声),结尾反转+新危机(如宁姬尸首)", "战斗节奏三段论:铺垫(环境压迫)→爆发(真气碰撞)→余韵(伤痛/沉默/月光),避免冗长缠斗"]
+  },
+  "beats": [
+    {
+      "id": "beat_001",
+      "type": "scene",
+      "start_anchor": "楔子\n  正午时分,烈日当空,海风炎热,无边无垠的海面泛着白光,惨碧的波浪轻轻摇曳。",
+      "mice_thread": "神帝遗命",
+      "summary": "南际山出海猎龙,中年汉子与少年遭遇蓝翼海龙兽,激战中父子联手将其斩杀,却因龙珠反噬而濒死,埋下‘凶兽预兆天下大乱’的核心伏笔。",
+      "goal": "父子合力捕获裂云狂龙以扬威",
+      "conflict_type": "环境冲突",
+      "conflict_description": "风暴突至,海龙兽撕裂海面,以雷霆之势逆转战局",
+      "disaster": "中年汉子被龙兽双翼拍中重伤坠海,少年目睹父亲濒死,初尝命运无常之痛",
+      "shuang_point": {
+        "has_shuang": true,
+        "type": "升级",
+        "intensity": "high",
+        "description": "少年以断月弩连射龙兽双目,展现超龄胆魄;龙兽沉没前暴怒反击,反衬其凶威,强化‘大荒十大凶兽’威慑力"
+      },
+      "state_changes": {
+        "plot_lines": [{"name": "神帝遗命", "old_state": "未启动", "new_state": "触发:神农物化与蓝翼海龙兽直接呼应"}],
+        "characters": [{"name": "拓拔野", "change": "从流浪儿获得神农丹与《大荒经》,开启修行之路"}]
+      }
+    },
+    {
+      "id": "beat_002",
+      "type": "sequel",
+      "start_anchor": "第一卷 八千里路\n  第一章 神农使者\n  夕阳西下,漫天晚霞映得海面一片金黄,微波摇荡,浩浩数千里尽是金光。",
+      "mice_thread": "身份成长",
+      "summary": "拓拔野偶遇濒死神农,得赠神木令、《大荒经》、《百草注》、《五行谱》及神农丹,一夜之间从流浪儿跃升为神帝托付之人。",
+      "reaction": "惊异—欢喜—悲恸—敬畏,情绪层层递进",
+      "dilemma": "既承救命恩与托付重责,又懵懂无知,不知该信谁、该往何处去",
+      "decision": "接受神农馈赠,服丹攀崖降伏龙马,决意完成使命",
+      "shuang_point": {
+        "has_shuang": true,
+        "type": "获得",
+        "intensity": "high",
+        "description": "神农丹贯通经脉,《大荒经》赋予地理主权,神木令象征神权背书——三重‘天命认证’瞬间重构主角底层逻辑"
+      },
+      "state_changes": {
+        "characters": [{"name": "拓拔野", "change": "由被动生存者转变为使命承载者,世界观从江湖升维至大荒格局"}]
+      }
+    },
+    {
+      "id": "beat_003",
+      "type": "scene",
+      "start_anchor": "第二章 谪仙人\n  拓拔野骑在白龙鹿背上,只觉耳边风声呼呼,两侧树影急速倒退,宛如在云端飞行。",
+      "mice_thread": "纤纤生死",
+      "summary": "拓拔野与白龙鹿夜闯玉屏山,凭本能攀越绝壁飞渡天湖,撞见白衣女子吹奏《刹那芳华曲》,发现其与神农、空桑仙子的深刻渊源。",
+      "goal": "循迹找到青帝居所",
+      "conflict_type": "人物冲突",
+      "conflict_description": "朝阳谷十四郎率众围堵,欲夺无锋剑,拓拔野借白衣女子暗助,以‘无边落木’踢飞唐七立威",
+      "disaster": "白衣女子揭穿断剑来历,拓拔野暴露‘非木族’身份,陷入被水族围杀的绝境",
+      "shuang_point": {
+        "has_shuang": true,
+        "type": "智商碾压",
+        "intensity": "medium",
+        "description": "拓拔野故意亮出断剑引敌轻敌,再借白衣女子念力遥控,以‘臀部发力’怪异姿势腾空踢人,颠覆传统武学认知"
+      },
+      "state_changes": {
+        "plot_lines": [{"name": "纤纤生死", "old_state": "昏迷待救", "new_state": "与拓拔野建立情感联结,颈悬泪珠坠"}],
+        "characters": [{"name": "拓拔野", "change": "首次体验‘被强大女性庇佑’,埋下对力量来源的反思种子"}]
+      }
+    },
+    {
+      "id": "beat_004",
+      "type": "sequel",
+      "start_anchor": "第三章 傀儡英雄\n  那青衣大汉身高九尺,浑身鲜血,站在竹楼之上,神威凛凛,宛若天神。",
+      "mice_thread": "纤纤生死",
+      "summary": "拓拔野目睹十四郎虐打段聿铠,内心侠义本能爆发,假借青帝之名喝止围攻,却意外暴露身份,被迫以‘端茶小厮’身份周旋。",
+      "reaction": "热血上涌、心跳加速、手心出汗",
+      "dilemma": "出手救人则穿帮,袖手旁观则违本心,而白衣女子已明确警告‘勿提她’",
+      "decision": "赌一把,喊出‘住手!’,顺势接管局面,以神木令为盾、以谎言为矛争取时间",
+      "shuang_point": {
+        "has_shuang": true,
+        "type": "装逼",
+        "intensity": "high",
+        "description": "一句‘住手’震住全场,众人误以为青帝发声,拓拔野凭空获得禁地权威,完成第一次‘身份借势’爽感"
+      },
+      "state_changes": {
+        "characters": [{"name": "拓拔野", "change": "从旁观者蜕变为行动发起者,侠义人格正式觉醒"}]
+      }
+    },
+    {
+      "id": "beat_005",
+      "type": "scene",
+      "start_anchor": "第四章 水妖龙女\n  明月高悬,四野沉寂,惟有风声入松,虫鸣不已。",
+      "mice_thread": "水族权谋",
+      "summary": "雨师妾现身寒潭,以催情蛇诱拓拔野,却被其以‘仙女姐姐’警醒挣脱;二人共处一夜,她以泪珠链赠别,坦露‘敌人即爱人’的撕裂立场。",
+      "goal": "试探拓拔野心意并阻止其赴蜃楼城",
+      "conflict_type": "内心冲突",
+      "conflict_description": "雨师妾明知此别即永诀,却无法强行掳人,只能以情困之、以泪缚之",
+      "disaster": "拓拔野清醒拒绝,雨师妾心碎离去,泪珠坠成为贯穿全书的情感信物",
+      "shuang_point": {
+        "has_shuang": true,
+        "type": "情感装逼",
+        "intensity": "high",
+        "description": "雨师妾以‘泪珠串链’替代言语告白,晶莹泪珠在月光下折射七彩,无声胜有声完成顶级情绪冲击"
+      },
+      "state_changes": {
+        "plot_lines": [{"name": "水族权谋", "old_state": "烛龙单线施压", "new_state": "雨师妾公开背叛,权谋线出现不可控变量"}],
+        "characters": [{"name": "雨师妾", "change": "从玩弄情欲的龙女,蜕变为为爱自毁根基的殉道者"}]
+      }
+    },
+    {
+      "id": "beat_006",
+      "type": "sequel",
+      "start_anchor": "第五章 大荒游侠\n  秋日正午,阳光灿烂,碧绿的大海上金光粼粼。",
+      "mice_thread": "纤纤生死",
+      "summary": "拓拔野、蚩尤、纤纤被纹龙鲨吞入腹中,漂流至汤谷岛;纤纤目睹拓拔野与真珠亲密,醋意爆发,却在篝火夜话中悄然吐露心迹。",
+      "reaction": "疲惫中混杂甜蜜,篝火映照下恍惚如归家",
+      "dilemma": "真珠近在咫尺,纤纤依偎怀中,二者情感拉扯令他心神俱乱,却不敢越界",
+      "decision": "以‘圣女需清心寡欲’为由劝阻纤纤,实则回避自身情感混沌",
+      "shuang_point": {
+        "has_shuang": true,
+        "type": "打脸",
+        "intensity": "medium",
+        "description": "纤纤当众戳破‘拓拔大哥’伪装,哭闹中撕开两人间所有温情假面,完成对主角情感自欺的第一次公开打脸"
+      },
+      "state_changes": {
+        "characters": [{"name": "纤纤", "change": "从天真少女转向主动出击的情感主体,以自尽为终极武器"}]
+      }
+    },
+    {
+      "id": "beat_007",
+      "type": "scene",
+      "start_anchor": "第六章 此情可待\n  月光如烟,交织在淡淡的夜雾中。",
+      "mice_thread": "纤纤生死",
+      "summary": "纤纤深夜闯入拓拔野房间,赤裸剖白心迹,却被拒后以雪鹤簪自刺心口;水晶棺中鲛珠凝魂,开启‘东海寻龙珠’的生死倒计时。",
+      "goal": "向拓拔野索要确定的情感回应",
+      "conflict_type": "内心冲突",
+      "conflict_description": "纤纤以身体为祭坛,将少女全部尊严与绝望押上赌桌,而拓拔野的‘妹妹论’成为致命一刀",
+      "disaster": "纤纤香消玉殒,拓拔野精神崩塌,从神采飞扬跌入石塑般空茫",
+      "shuang_point": {
+        "has_shuang": true,
+        "type": "情感打脸",
+        "intensity": "high",
+        "description": "纤纤以死亡完成对‘被当作妹妹’这一认知的彻底否定,血染月光,比任何台词都更具毁灭性爽感"
+      },
+      "state_changes": {
+        "plot_lines": [{"name": "纤纤生死", "old_state": "昏迷待救", "new_state": "魂魄封印,进入七日倒计时"}],
+        "characters": [{"name": "拓拔野", "change": "从情感迟钝者被迫直面‘失去即永恒’的成人礼"}]
+      }
+    },
+    {
+      "id": "beat_008",
+      "type": "sequel",
+      "start_anchor": "第七章 风云际会\n  时近深夜,明月当空,照得青石板大街一片雪白。",
+      "mice_thread": "水族权谋",
+      "summary": "拓拔野潜入雷泽城,目睹雷神府内宁姬被杀、圣杯被毁,意识到‘桃木姥姥—纤纤—雷神’链条已被精心伪造,整个火木战争实为烛龙操盘。",
+      "reaction": "震惊—愤怒—脊背发凉,冷汗浸透内裳",
+      "dilemma": "若揭穿阴谋,需直面祝融、句芒、乌丝兰玛三大高手;若沉默,则纤纤将成祭品,雷神必死",
+      "decision": "放弃私救纤纤,转而联合烈炎、六侯爷,以‘圣杯修复’为切口切入全局,抢在刑天军团攻城前翻盘",
+      "shuang_point": {
+        "has_shuang": true,
+        "type": "逻辑型装逼",
+        "intensity": "high",
+        "description": "拓拔野在众人皆被表象迷惑时,凭借《五行谱》‘木生火’原理,瞬间识破‘祝融被囚’才是圣杯失窃前提,完成高密度推理爽点"
+      },
+      "state_changes": {
+        "characters": [{"name": "拓拔野", "change": "从执行者升格为局势解构者,思维维度突破江湖层级"}]
+      }
+    },
+    {
+      "id": "beat_009",
+      "type": "scene",
+      "start_anchor": "第六卷 大荒惊变\n  第一章 山雨欲来\n  时近深夜,明月当空,照得青石板大街一片雪白。",
+      "mice_thread": "水族权谋",
+      "summary": "拓拔野率众夜闯雷神府,恰逢烈炎等人引雷神入局,三方对峙中吴回突然发难,以‘原心法’逼纤纤指认雷神盗杯,舆论彻底倒戈。",
+      "goal": "混入雷府探查宁姬下落",
+      "conflict_type": "人物冲突",
+      "conflict_description": "吴回以‘证据链’步步紧逼,烈炎动摇,句芒伪善,乌丝兰玛静观,拓拔野孤立无援",
+      "disaster": "纤纤在摄魂状态下亲指雷神,圣杯案铁证如山,雷神被当场定罪",
+      "shuang_point": {
+        "has_shuang": true,
+        "type": "打脸",
+        "intensity": "medium",
+        "description": "纤纤指认雷神时眼神凄艳含泪,与此前‘空桑转世’形象形成残酷反差,观众顿悟‘被操控’真相"
+      },
+      "state_changes": {
+        "plot_lines": [{"name": "水族权谋", "old_state": "暗流涌动", "new_state": "明面引爆,火木大战箭在弦上"}],
+        "characters": [{"name": "烈炎", "change": "从信任拓拔野转向怀疑雷神,立场开始动摇"}]
+      }
+    },
+    {
+      "id": "beat_010",
+      "type": "sequel",
+      "start_anchor": "第二章 雷泽惊变\n  数百名五族使者随着雷神,浩浩荡荡经过古树参天的院子,穿过几道长廊,来到无尘湖畔。",
+      "mice_thread": "纤纤生死",
+      "summary": "拓拔野随众人进入无尘阁密库,发现宁姬尸体与劈裂圣杯,继而听闻松竹六友‘不敢说’、绿琉儿‘被胁迫’,终于拼出‘晏紫苏易容—桃木姥姥—长生杯掉包’全图。",
+      "reaction": "胃部抽搐、指尖发麻、耳中嗡鸣,生理级震惊",
+      "dilemma": "若此刻揭穿晏紫苏,需承担‘陷害雷神’的道德风险;若坐视,纤纤将被押往赤炎城受审",
+      "decision": "暂不点破,先助雷神突围,再以‘修复圣杯’为唯一活路,倒逼各方让步",
+      "shuang_point": {
+        "has_shuang": true,
+        "type": "逻辑型装逼",
+        "intensity": "high",
+        "description": "拓拔野将‘长生杯被认作圣杯’‘雷神收杯时欢欣’‘祝融被囚时间差’三组矛盾信息串联,瞬间还原掉包逻辑链"
+      },
+      "state_changes": {
+        "characters": [{"name": "拓拔野", "change": "从情感驱动转向战略思维,学会在多方博弈中寻找‘唯一解’"}]
+      }
+    },
+    {
+      "id": "beat_011",
+      "type": "scene",
+      "start_anchor": "第七卷 灵山十巫\n  第一章 药山在望\n  那赤虬横生飞舞,翻腾怒吼,天地焦雷,云霭崩散。",
+      "mice_thread": "神帝遗命",
+      "summary": "赤虬破山而出,震杀于儿神,却对拓拔野不谢而别;拓拔野率七百流囚整军,于灵山脚下被土族大军包围,方知黄帝少子姬远玄亦在此求医。",
+      "goal": "整合洞庭湖流囚力量,赶赴朝歌山取七彩土",
+      "conflict_type": "人物冲突",
+      "conflict_description": "王亥以青铜旗阵示威,洛姬雅以玉兕角号声震慑万军,双方在灵山脚下完成无声权力交接",
+      "disaster": "土族大军封锁灵山,三百六十种奇毒采集计划受阻,且姬远玄身份揭示‘土族内部亦有剧变’",
+      "shuang_point": {
+        "has_shuang": true,
+        "type": "碾压",
+        "intensity": "high",
+        "description": "洛姬雅毒废牌贩子双手,号角一响万兽辟易,土族精锐竟因恐惧而列队让道,完成对‘规则制定者’的降维打击"
+      },
+      "state_changes": {
+        "plot_lines": [{"name": "神帝遗命", "old_state": "单线奔袭", "new_state": "与土族权争、灵山十巫、姬远玄三线交汇"}],
+        "characters": [{"name": "洛姬雅", "change": "从捣乱者升格为能号令万毒的战略级盟友"}]
+      }
+    },
+    {
+      "id": "beat_012",
+      "type": "sequel",
+      "start_anchor": "第二章 三寸美人\n  忽听一个甜美的声音娇滴滴地道:“臭丫头,又是你么?适才在山下大呼小叫的,倒也罢了。",
+      "mice_thread": "纤纤生死",
+      "summary": "拓拔野闯入灵山,撞见巫真、巫姑等十巫化身蝴蝶精灵,洛姬雅以‘神农弟子’身份抬高其地位,却将真珠作为赌注,逼其直面‘情欲与责任’的抉择。",
+      "reaction": "莞尔—错愕—警惕—心软,察觉洛姬雅对真珠的善意试探",
+      "dilemma": "若应允换腿,等于默许鲛人国公主叛族;若拒绝,真珠将永远困于鱼尾,辜负其万里追随",
+      "decision": "搂紧真珠,以护体真气隔绝毒虫,用肢体语言代替承诺,静待十巫裁决",
+      "shuang_point": {
+        "has_shuang": true,
+        "type": "情感装逼",
+        "intensity": "medium",
+        "description": "拓拔野怀抱真珠穿越毒林,白龙鹿嘶鸣开道,洛姬雅号角为引——三人一兽构成视觉级情感符号矩阵"
+      },
+      "state_changes": {
+        "characters": [{"name": "真珠", "change": "从被动追随者获得‘自主选择权’,泪珠滑落标志情愫落地生根"}]
+      }
+    },
+    {
+      "id": "beat_013",
+      "type": "scene",
+      "start_anchor": "第三章 灵山十巫\n  月光疏淡,树影浮动。",
+      "mice_thread": "水族权谋",
+      "summary": "洛姬雅亮出药神鼎,直指伏羲牙,八巫震怒失态;拓拔野被推为‘神农传人’,与十巫对赌药神之名,赌注实为撬动大荒平衡的支点。",
+      "goal": "借药神之争获取三百六十种奇毒",
+      "conflict_type": "信息冲突",
+      "conflict_description": "十巫不知拓拔野真实修为,洛姬雅刻意营造‘神农嫡传’幻象,使其陷入‘怕输更怕输不起’的心理陷阱",
+      "disaster": "巫咸巫彭误认奉承为夸赞,暴露其傲慢浅薄,反向坐实拓拔野‘深不可测’的威胁感",
+      "shuang_point": {
+        "has_shuang": true,
+        "type": "智商碾压",
+        "intensity": "high",
+        "description": "拓拔野一句‘大荒第一狗屁药神’,精准利用十巫自负心理,使其在众目睽睽下自曝认知盲区"
+      },
+      "state_changes": {
+        "plot_lines": [{"name": "水族权谋", "old_state": "烛龙单方面主导", "new_state": "洛姬雅携伏羲牙野心入场,权谋棋局新增第三方变量"}],
+        "characters": [{"name": "洛姬雅", "change": "从毒术玩家升维为伏羲遗产争夺者,动机深度浮出水面"}]
+      }
+    },
+    {
+      "id": "beat_014",
+      "type": "sequel",
+      "start_anchor": "第四章 流沙仙子\n  白龙鹿长嘶声中,拓拔野凌空踏步,御风飞行,刹那间便已超过那三十余名黑衣人,到了松林中央。",
+      "mice_thread": "纤纤生死",
+      "summary": "拓拔野突袭松林,以断剑震飞黑衣人兵器,搅乱晏紫苏与姬远玄之战;其后与洛姬雅结盟,却在驿站被其下‘千里相思蛊’,被迫绑定同行。",
+      "reaction": "酣畅—警觉—眩晕—苦笑,中毒反应成为认知校准器",
+      "dilemma": "洛姬雅既是破局钥匙,又是未知毒素携带者;信则可能丧命,不信则永失解蛊良机",
+      "decision": "佯装昏迷,任其摆布,借‘毒’为掩护观察其言行矛盾点,伺机反制",
+      "shuang_point": {
+        "has_shuang": true,
+        "type": "获得",
+        "intensity": "medium",
+        "description": "拓拔野虽中蛊,却借此获得洛姬雅真实态度:她未取其性命,反而喂食解毒菜,暗示‘毒’实为契约烙印"
+      },
+      "state_changes": {
+        "characters": [{"name": "拓拔野", "change": "从‘被保护者’进化为‘以身为饵’的战术型猎手"}]
+      }
+    },
+    {
+      "id": "beat_015",
+      "type": "scene",
+      "start_anchor": "第五章 九尾妖狐\n  蚩尤闻言猛吃一惊,扭头朝水帘外望去。",
+      "mice_thread": "纤纤生死",
+      "summary": "蚩尤被晏紫苏‘两心知’蛊惑,一路追至瀑布洞穴,却在月下见证其九尾银狐真身;祝融现身揭露其盗杯真相,二人陷入‘救纤纤’与‘还圣杯’的双重困境。",
+      "goal": "逼晏紫苏吐露纤纤囚禁地点",
+      "conflict_type": "人物冲突",
+      "conflict_description": "祝融以元神寄体威压,晏紫苏以情蛊反制,蚩尤成夹心肉饼,意志与肉体双重受创",
+      "disaster": "晏紫苏化狐,祝融证实其盗杯,但‘纤纤体内蛊虫’说法仍无实证,营救路径彻底中断",
+      "shuang_point": {
+        "has_shuang": true,
+        "type": "碾压",
+        "intensity": "high",
+        "description": "晏紫苏月圆显形,银狐九尾扫过蚩尤手臂,毛绒触感与视觉震撼形成通感碾压,打破其‘妖女即恶’的刻板认知"
+      },
+      "state_changes": {
+        "plot_lines": [{"name": "纤纤生死", "old_state": "真假难辨", "new_state": "确认为晏紫苏所控,但‘梅花痣’缺失暗示纤纤尚存独立意识"}],
+        "characters": [{"name": "蚩尤", "change": "从莽撞复仇者转向冷静布局者,开始质疑‘眼见为实’"}]
+      }
+    },
+    {
+      "id": "beat_016",
+      "type": "sequel",
+      "start_anchor": "第六章 与子携行\n  朝阳暖暖地照着,晨风吹拂,摇落满谷蝉声。",
+      "mice_thread": "神帝遗命",
+      "summary": "蚩尤与晏紫苏乘筏九曲溪,她以‘林公子’易容术骗过雷泽城迎客使,却在客栈喂其寒石散,将‘同床共枕’转化为一场精密的精神驯化。",
+      "reaction": "燥热—惊怒—酥麻—迷惘,味觉与神经被双重操控",
+      "dilemma": "晏紫苏既下蛊又疗伤,既戏谑又温柔,其行为逻辑无法用善恶二分,蚩尤陷入价值判断瘫痪",
+      "decision": "暂弃武力对抗,以‘顺从’换取情报,暗记其呼吸节奏与易容手法,为日后反制埋线",
+      "shuang_point": {
+        "has_shuang": true,
+        "type": "装逼",
+        "intensity": "low",
+        "description": "蚩尤被喂食寒石散后僵卧,晏紫苏俯身刮下皮痂,一句‘这就好多啦’轻描淡写完成对强者尊严的温柔解构"
+      },
+      "state_changes": {
+        "characters": [{"name": "晏紫苏", "change": "从纯粹反派升华为具有悲剧纵深的‘失控天才’,手段狠辣却藏有温度"}]
+      }
+    },
+    {
+      "id": "beat_017",
+      "type": "scene",
+      "start_anchor": "第七章 风云际会\n  那人呼声未落,便有数百人跟着纵声长呼:“交出圣火,交出圣杯!”",
+      "mice_thread": "水族权谋",
+      "summary": "雷神兽身暴走,青铜锤轰裂密库,携拓拔野冲入太湖;烈炎率众追击,却撞见祝融手持裂杯现身,火族内奸指向烈碧光晟,权谋网骤然收紧。",
+      "goal": "护雷神突围并保全圣杯残骸",
+      "conflict_type": "人物冲突",
+      "conflict_description": "雷神癫狂、吴回阴鸷、烈炎动摇、祝融悲怆,四股意志在密室中激烈碰撞",
+      "disaster": "圣杯被劈为两半,赤帝出关无望,五族长老会提前两年召开已成定局",
+      "shuang_point": {
+        "has_shuang": true,
+        "type": "升级",
+        "intensity": "high",
+        "description": "拓拔野以断剑为刃、珊瑚笛为媒,在雷神兽身暴走时稳立其侧,真气共鸣

+ 216 - 0
examples/analyze_story/runs/搜神记/analysis/w0.json

@@ -0,0 +1,216 @@
+{
+  "outline": {
+    "main_plot": "神农物化前托付拓拔野携神木令与血书赴玉屏山寻青帝调停水族围攻蜃楼城之祸,但青帝踪迹杳然。拓拔野误入汤谷,结识空桑仙子、蚩尤、雨师妾等关键人物,在东海复建自由势力,逐步成长为龙神太子;与此同时,纤纤因身世真相与情感创伤离岛西行,引发圣杯失窃案,牵动火、木、水三族博弈。拓拔野与蚩尤分头追查,揭开九尾狐晏紫苏栽赃嫁祸的阴谋,并在洞庭湖解救被镇压百年的赤虬,最终兵分两路奔赴灵山与朝歌山,以七彩土修复琉璃圣火杯、阻止五族大战,同时为真珠求取化人之法。",
+    "plot_lines": [
+      {
+        "name": "神帝遗命线",
+        "mice_type": "E",
+        "status": "进行中",
+        "description": "神农临终托付血书与神木令,要求拓拔野七日内送达青帝或蜃楼城主乔羽,以止水族兵戈;但青帝失踪、乔羽重伤被俘,血书内容与神木令权威性屡遭质疑,遗命执行陷入多重阻滞。",
+        "core_question": "神农血书究竟写了什么?其遗命能否真正化解大荒乱局?",
+        "next_steps": "确认血书原文及神木令效力,验证青帝是否尚在人间"
+      },
+      {
+        "name": "纤纤身世线",
+        "mice_type": "C",
+        "status": "进行中",
+        "description": "纤纤被揭示为西王母与科汗淮之女,身负‘圣女血脉’与‘流放罪裔’双重身份,其存在本身即动摇五族伦理根基;她离岛寻母引发连锁危机,成为各方势力博弈焦点。",
+        "core_question": "纤纤作为西王母之女,将如何面对圣女身份与亲情撕裂?",
+        "next_steps": "追踪纤纤至雷泽城,查明她与宁姬、桃木姥姥的真实互动"
+      },
+      {
+        "name": "圣杯疑云线",
+        "mice_type": "I",
+        "status": "进行中",
+        "description": "琉璃圣火杯离奇失窃,表面指向纤纤盗献雷神,实则牵涉火族内部权力更迭——烈碧光晟借机构陷雷神,为赤帝闭关后掌控火族铺路;真相需穿透层层伪证与心理操控。",
+        "core_question": "谁是琉璃圣火杯失窃案真正的幕后推手?",
+        "next_steps": "比对长生杯与琉璃圣火杯的灵力特征,定位桃木姥姥真实身份"
+      },
+      {
+        "name": "灵山伏羲牙线",
+        "mice_type": "I",
+        "status": "待推进",
+        "description": "洛姬雅以药神鼎为饵,索要伏羲牙这一上古毒器核心,暗示其背后有超越个人恩怨的深层目的;该神器与‘千里相思蛊’‘元神寄体’等设定形成闭环,指向某种终极解毒/控魂机制。",
+        "core_question": "伏羲牙为何值得流沙仙子倾尽心力布局百年?",
+        "next_steps": "进入冰心洞,直面巫咸、巫彭,触发第一轮药草辨毒比试"
+      }
+    ]
+  },
+  "characters": [
+    {
+      "name": "拓拔野",
+      "role": "主角",
+      "goal": "修复琉璃圣火杯,洗刷纤纤冤屈,并赶在七日之约前与雨师妾重聚",
+      "traits": [
+        "乐观洒脱",
+        "共情力强",
+        "直觉敏锐",
+        "重诺守信"
+      ],
+      "speaking_style": [
+        "善用比喻与生活化俚语",
+        "危急时斩钉截铁",
+        "对女性说话常带温柔调侃"
+      ],
+      "current_state": "孤身穿越苇草山谷,正于灵山脚下遭遇土族大军与洛姬雅联手设局,即将踏入十巫禁地",
+      "relationships": {
+        "雨师妾": "刻骨思念对象,已三年未见,彼此气味与心跳仍能隔空感应",
+        "纤纤": "视如亲妹却暗藏愧疚,其自杀与离岛令他情感认知彻底重构",
+        "洛姬雅": "被迫缔结‘情郎’假盟,实为互相利用又隐含试探的危险同盟"
+      }
+    },
+    {
+      "name": "雨师妾",
+      "role": "关键女性角色",
+      "goal": "护送若草花完成政治联姻,同时暗中守护拓拔野不被木神与冰夷所害",
+      "traits": [
+        "妖冶炽烈",
+        "外柔内刚",
+        "念力卓绝",
+        "自我牺牲倾向"
+      ],
+      "speaking_style": [
+        "语带双关,慵懒中藏锋锐",
+        "常用‘小傻蛋’‘臭小子’等昵称软化攻击性",
+        "笑声频率高且具情绪感染力"
+      ],
+      "current_state": "刚在日华城驿站与拓拔野咫尺重逢却强抑相认,正随句芒、冰夷一行赶赴雷泽城寿宴",
+      "relationships": {
+        "拓拔野": "唯一愿为其背叛水族、甘受千夫所指之人",
+        "若草花": "亲侄女,此行实为替其摆脱烛龙政治婚姻",
+        "冰夷": "同僚兼忌惮者,其‘千里子母香’已被对方察觉并反制"
+      }
+    },
+    {
+      "name": "纤纤",
+      "role": "核心成长型角色",
+      "goal": "独自前往昆仑寻找生母西王母,以确认自身存在价值与血缘归属",
+      "traits": [
+        "聪慧早熟",
+        "情执刚烈",
+        "感知细腻",
+        "擅长伪装脆弱"
+      ],
+      "speaking_style": [
+        "少女式娇嗔与冷讽交织",
+        "常以反问制造情绪张力",
+        "哭泣时声音细弱却字字清晰"
+      ],
+      "current_state": "被米离等人挟持至雷泽城无尘阁密室,经原心法摄魂后说出关键矛盾证词,正被囚于贵宾馆房间内",
+      "relationships": {
+        "拓拔野": "情感投射的绝对中心,其拒绝成为她最深的创伤源",
+        "辛九姑": "实际抚养者,亦是身世真相的知情封锁者与道德审判者",
+        "晏紫苏": "镜像对手,以‘假纤纤’逼出她对爱与尊严的终极抉择"
+      }
+    },
+    {
+      "name": "洛姬雅",
+      "role": "智谋型反派/同盟",
+      "goal": "夺取伏羲牙,破解某种与自身或他人相关的古老诅咒",
+      "traits": [
+        "天真表象下极度理性",
+        "毒术即语言艺术",
+        "擅造信息差",
+        "对拓拔野存在异常信任"
+      ],
+      "speaking_style": [
+        "童稚语气包裹致命逻辑",
+        "大量使用拟声词与身体隐喻(如‘那七那七’)",
+        "话里埋设双重陷阱"
+      ],
+      "current_state": "以‘情郎’名义将拓拔野绑定为灵山闯关棋子,正率其穿越荆棘林,号角声已惊动数万土族大军",
+      "relationships": {
+        "拓拔野": "认定其为可承载伏羲牙之力的‘容器’,非单纯利用",
+        "灵山十巫": "宿敌兼血缘关联者(伏羲十指所化),知其弱点与傲慢",
+        "晏紫苏": "同列十大妖女,但对其手段存有隐秘敬意与竞争意识"
+      }
+    }
+  ],
+  "writing_insights": {
+    "techniques": [
+      "多线嵌套:以‘圣杯失窃’为事件锚点,同步引爆身份(纤纤)、权力(烈碧光晟)、信仰(雷神兽化)三条线索",
+      "感官通感叙事:将箫声、笛声、号角声转化为视觉光晕与体感温度,构建声波即武学的东方奇幻语法",
+      "错位视角调度:同一场景(如破庙守候)通过拓拔野内心独白、白龙鹿行为反馈、环境蝉声节奏三重呈现"
+    ],
+    "shuang_designs": [
+      "逻辑型装逼:拓拔野凭《五行谱》‘相化’原理反制定海神珠,非靠蛮力而靠认知碾压",
+      "成长型获得:从‘被动挨打’到‘主动诱敌’,在凤尾城一役完成战术思维质变",
+      "悲情型升级:纤纤自刎后水晶棺封印,非死亡而是将‘情’凝为实体信物,推动全剧情感权重重置"
+    ],
+    "pacing": [
+      "每章以‘奇观—危机—反转’三幕律推进,如夔牛现世→水妖收网→拓拔野夺珠反制"
+    ]
+  },
+  "beats": [
+    {
+      "id": "beat_001",
+      "type": "scene",
+      "start_anchor": "楔子\n\n  正午时分,烈日当空,海风炎热,无边无垠的海面泛着白光,惨碧的波浪轻轻摇曳。",
+      "mice_thread": "税银案",
+      "summary": "开篇以海上风暴与蓝翼海龙兽突袭建立‘凶兽即灾异’的核心隐喻,为后续所有灵兽暴走、封印失控、血脉诅咒埋下MICE中的‘Milieu’(世界规则)伏笔。",
+      "goal": "中年汉子欲猎杀裂云狂龙以立威",
+      "conflict_type": "环境冲突",
+      "conflict_description": "乌云蔽日、巨浪滔天、怪兽破海而出,自然伟力与上古凶兽构成不可抗外部压力",
+      "disaster": "裂云狂龙实为蓝翼海龙兽,少年误判导致父亲重伤濒死,神帝使者使命首次暴露于生死一线",
+      "shuang_point": {
+        "has_shuang": true,
+        "type": "智商碾压",
+        "intensity": "high",
+        "description": "少年凭借对‘裂云狂龙’名称的常识误判,瞬间颠覆读者认知,确立‘名实不符’的世界观陷阱"
+      },
+      "state_changes": {
+        "plot_lines": [
+          {
+            "name": "神帝遗命线",
+            "old_state": "初始托付",
+            "new_state": "首度遭遇执行失败"
+          }
+        ],
+        "characters": [
+          {
+            "name": "拓拔野",
+            "change": "从流浪儿转变为命运介入者,获得神农丹与无锋剑双重馈赠"
+          }
+        ]
+      },
+      "position_start": 204,
+      "position_end": 4183
+    },
+    {
+      "id": "beat_002",
+      "type": "sequel",
+      "start_anchor": "少年初次经历如此事情,心中兀自兴奋不已,手中把玩着父亲从怪兽身上剜出的龙珠,已在寻思回去后给伙伴们炫耀炫耀此次经历。",
+      "mice_thread": "身份成长",
+      "summary": "神农物化前夜,以‘神木令’‘大荒经’‘百草注’‘五行谱’四重信物完成对拓拔野的文明传承授权,将其从物理幸存者升格为文化继承人。",
+      "reaction": "拓拔野初闻神帝身份毫无敬畏,只觉老人慷慨有趣,反衬其混沌未开的纯粹性",
+      "dilemma": "接受神帝托付意味着背负数十万性命,但自己连基本武功都未掌握,是担当还是逃避?",
+      "decision": "以‘朋友’之名承接使命,将神圣责任降维为少年承诺,奠定其‘去神性化英雄’的成长基底",
+      "shuang_point": {
+        "has_shuang": true,
+        "type": "获得",
+        "intensity": "medium",
+        "description": "三本神级典籍+神农丹+龙珠,知识、能量、信物三重武装一次性交付,爽感密集"
+      },
+      "state_changes": {
+        "characters": [
+          {
+            "name": "拓拔野",
+            "change": "从被动受助者转为主动承诺者,人格锚点确立"
+          }
+        ]
+      },
+      "position_start": 4183,
+      "position_end": 500000
+    }
+  ],
+  "_meta": {
+    "novel_title": "搜神记",
+    "window_index": 0,
+    "window_start": 0,
+    "window_end": 500000,
+    "total_chars": 1485782,
+    "window_size": 500000,
+    "beats_count": 2,
+    "model": "qwen-plus"
+  }
+}

+ 14 - 0
examples/analyze_story/runs/搜神记/sft_raw/w0_demo/stats.json

@@ -0,0 +1,14 @@
+{
+  "task1": {
+    "total": 1,
+    "valid": 1
+  },
+  "task2": {
+    "total": 1,
+    "valid": 0
+  },
+  "task3": {
+    "total": 1,
+    "valid": 0
+  }
+}

Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
examples/analyze_story/runs/搜神记/sft_raw/w0_demo/task1_structure_planning.jsonl


+ 0 - 0
examples/analyze_story/runs/搜神记/sft_raw/w0_demo/task2_scene_continuation.jsonl


+ 0 - 0
examples/analyze_story/runs/搜神记/sft_raw/w0_demo/task3_shuang_injection.jsonl


+ 200 - 0
examples/analyze_story/sft/README.md

@@ -0,0 +1,200 @@
+# 长篇小说 SFT 数据生成
+
+将网文/剧本逆向拆解为"AI 可学习的思考步骤",生成三类 SFT 训练数据。
+
+---
+
+## 设计思路
+
+### 整体流程
+
+```
+原文 txt
+  │
+  ▼  step1_analyze.py(一次 LLM 调用,500K 窗口)
+分析 JSON
+  [outline / characters / beats]
+  │
+  ▼  step2_build_sft.py(每个 beat 2-3 次 LLM 调用)
+三类 JSONL
+  task1_structure_planning.jsonl
+  task2_scene_continuation.jsonl
+  task3_shuang_injection.jsonl
+```
+
+### Beat 切分与定位
+
+分析时将全文按 Scene-Sequel 结构切分为若干 **beat**(叙事单元)。
+
+Beat 边界通过 **文本锚点** 定位:LLM 从原文逐字复制每个 beat 开头的 20-25 个字符,Python 用 `str.find()` 精确定位字符位置(渐进缩短前缀至 8 字兜底)。不依赖章节标题格式,适用于任意命名风格。
+
+### 三个 SFT 任务
+
+#### Task 1 — 结构规划(Structure Planning)
+
+**目标**:让模型学会在给定故事状态时,规划下一个 Scene-Sequel 单元的结构。
+
+| | 内容 |
+|---|---|
+| 输入 | 故事状态(MICE 线程、上一个 Disaster/Decision、当前位置)+ 上文(最近 800 字) |
+| 输出 | `<think>` 叙事状态分析 + 续写决策 `</think>` + 结构规划 JSON |
+
+输出 JSON 字段:`scene`(goal/conflict_type/disaster/pacing)、`sequel`(reaction/dilemma/decision)、`hooks`、`shuang_point`、`mice_advancement`
+
+**数据来源**:以该 beat 的实际结构作为参考信息,由 LLM 逆向生成"事前规划"视角的 CoT,用户侧输入不包含 beat 实际内容。
+
+---
+
+#### Task 2 — 场景续写(Scene Continuation)
+
+**目标**:让模型学会根据结构规划生成正文。
+
+| | 内容 |
+|---|---|
+| 输入 | 上文(500-1500 字)+ Task 1 输出的结构规划 |
+| 输出 | `<think>` 上文理解 + 写法决策 `</think>` + 续写正文 |
+
+**数据来源**:CoT 由 LLM 生成(给定 Task 1 规划 + beat 前 300 字 hint),正文使用原文 beat 文本作为 ground truth。
+
+---
+
+#### Task 3 — 爽点注入(Shuang Point Injection)
+
+**目标**:让模型学会将平淡草稿改写为带爽点的版本。
+
+| | 内容 |
+|---|---|
+| 输入 | 平淡草稿 + 爽点类型(打脸/升级/装逼/获得/碾压)+ 强度(low/medium/high) |
+| 输出 | `<think>` 草稿分析 + 爽点设计 `</think>` + 增强版正文 + 修改说明 |
+
+**数据来源**:仅处理分析中标记 `has_shuang=true` 的 beat。LLM 从原文生成"平淡草稿"(去掉爽点保留情节),原文作为增强版 ground truth。
+
+---
+
+### 多窗口连贯性
+
+超过 500K 字符的小说分多个窗口处理,后续窗口通过 `--prev-analysis` 接收前一窗口的人物/线索元信息,确保全书人物关系和 MICE 线程不断档。
+
+---
+
+## 文件结构
+
+```
+sft/
+  step1_analyze.py      # 500K 窗口分析 → analysis JSON
+  step2_build_sft.py    # analysis JSON → 三类 JSONL
+  run_pipeline.py       # 一键批量运行,支持断点续跑
+  README.md
+
+runs/{书名}/            # 运行输出(由 run_pipeline.py 自动创建)
+  analysis/
+    w0.json             # 第 0 个窗口分析结果
+    w1.json             # 第 1 个窗口分析结果(如有)
+  sft_raw/
+    w0/                 # 第 0 个窗口的 SFT 数据
+      task1_structure_planning.jsonl
+      task2_scene_continuation.jsonl
+      task3_shuang_injection.jsonl
+      stats.json
+  merged/               # 所有窗口合并后的最终数据
+    task1_structure_planning.jsonl
+    task2_scene_continuation.jsonl
+    task3_shuang_injection.jsonl
+    stats.json
+  pipeline.log          # 运行日志
+```
+
+---
+
+## 环境配置
+
+```
+# .env(项目根目录)
+ALI_API_KEY=sk-...
+ALI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
+```
+
+依赖:
+```bash
+pip install openai python-dotenv
+```
+
+---
+
+## 用法
+
+### 一键运行(推荐)
+
+```bash
+cd examples/analyze_story/sft
+
+python run_pipeline.py --novel ../input/大奉打更人.txt
+```
+
+断点续跑(重新执行同一命令,已完成的窗口自动跳过):
+```bash
+python run_pipeline.py --novel ../input/大奉打更人.txt
+```
+
+### 常用参数
+
+```
+--novel           小说 txt 文件路径(必填)
+--output-dir      输出根目录(默认 sft/runs/{书名}/)
+--window-size     每窗口字符数(默认 500000)
+--model           模型名称(默认 qwen-plus)
+--context-chars   上文字符数,Task1/2 使用(默认 800)
+--concurrency     step2 并发调用数(默认 5)
+--skip-task N     跳过某个任务,可多次指定(例:--skip-task 3)
+--only-step 1     只跑分析,不生成 SFT
+--only-step 2     只生成 SFT(需要 analysis/ 已存在)
+--force           强制重跑,忽略已有文件
+```
+
+### 批量处理多本书
+
+```bash
+for f in ../input/*.txt; do
+    python run_pipeline.py --novel "$f" --concurrency 8
+done
+```
+
+### 单独调用
+
+```bash
+# 只分析第一个窗口
+python step1_analyze.py \
+  --novel ../input/大奉打更人.txt \
+  --output runs/大奉打更人/analysis/w0.json
+
+# 只生成 SFT 数据
+python step2_build_sft.py \
+  --analysis runs/大奉打更人/analysis/w0.json \
+  --novel ../input/大奉打更人.txt \
+  --output-dir runs/大奉打更人/sft_raw/w0/
+```
+
+---
+
+## JSONL 格式
+
+每行一条训练样本:
+
+```json
+{
+  "messages": [
+    {"role": "system", "content": "..."},
+    {"role": "user",   "content": "..."},
+    {"role": "assistant", "content": "<think>\n...\n</think>\n\n..."}
+  ],
+  "metadata": {
+    "task_type": "structure_planning | scene_continuation | shuang_injection",
+    "source_file": "大奉打更人",
+    "chapter": "第4章",
+    "position_percent": 3.8,
+    "mice_thread": "税银案",
+    "beat_id": "beat_003",
+    "word_count": 3200
+  }
+}
+```

+ 12 - 12
examples/analyze_story/sft/run_pipeline.py

@@ -99,7 +99,7 @@ class Logger:
             f.write(line + "\n")
 
 
-def run_cmd(cmd: list[str], logger: Logger) -> bool:
+def run_cmd(cmd: List[str], logger: Logger) -> bool:
     """执行子进程,实时打印输出,返回是否成功"""
     logger.log(f"运行: {' '.join(str(c) for c in cmd)}")
     proc = subprocess.Popen(
@@ -131,8 +131,8 @@ def run_step1_all(
     model: str,
     force: bool,
     logger: Logger,
-    only_step: int | None,
-) -> list[Path]:
+    only_step: Optional[int],
+) -> List[Path]:
     """串行分析所有窗口,返回成功生成的 analysis 文件路径列表"""
     if only_step == 2:
         # 只跑 step2,直接读已有的分析文件
@@ -141,8 +141,8 @@ def run_step1_all(
         return files
 
     analysis_dir.mkdir(parents=True, exist_ok=True)
-    analysis_files: list[Path] = []
-    prev_analysis: Path | None = None
+    analysis_files: List[Path] = []
+    prev_analysis: Optional[Path] = None
 
     for i in range(n_windows):
         out = analysis_dir / f"w{i}.json"
@@ -183,22 +183,22 @@ def run_step1_all(
 
 def run_step2_all(
     novel: str,
-    analysis_files: list[Path],
+    analysis_files: List[Path],
     sft_raw_dir: Path,
     context_chars: int,
     concurrency: int,
-    skip_tasks: list[int],
+    skip_tasks: List[int],
     model: str,
     force: bool,
     logger: Logger,
-    only_step: int | None,
-) -> list[Path]:
+    only_step: Optional[int],
+) -> List[Path]:
     """为每个 analysis 文件生成 SFT 数据,返回成功的 sft 子目录列表"""
     if only_step == 1:
         logger.log("[Step2 跳过] --only-step 1")
         return []
 
-    sft_dirs: list[Path] = []
+    sft_dirs: List[Path] = []
 
     for analysis_path in analysis_files:
         window_name = analysis_path.stem          # e.g. "w0"
@@ -237,14 +237,14 @@ def run_step2_all(
 # ──────────────────────────────────────────────────────────────
 
 
-def merge_jsonl(sft_dirs: list[Path], merged_dir: Path, logger: Logger):
+def merge_jsonl(sft_dirs: List[Path], merged_dir: Path, logger: Logger):
     """合并所有窗口的 JSONL 文件到 merged/ 目录"""
     if not sft_dirs:
         logger.log("[Merge] 无 SFT 数据可合并")
         return
 
     merged_dir.mkdir(parents=True, exist_ok=True)
-    total_stats: dict[str, int] = {}
+    total_stats: Dict[str, int] = {}
 
     for task_file in SFT_TASKS:
         out_path = merged_dir / task_file

+ 84 - 26
examples/analyze_story/sft/step1_analyze.py

@@ -22,7 +22,8 @@ import json
 import asyncio
 import argparse
 from pathlib import Path
-from openai import AsyncOpenAI
+from openai import AsyncOpenAI, BadRequestError, RateLimitError, APIError
+from typing import Optional, List
 from dotenv import load_dotenv
 
 load_dotenv()
@@ -64,14 +65,14 @@ def find_anchor(window_text: str, anchor: str, search_from: int = 0) -> int:
     """
     if not anchor:
         return -1
-    for length in range(min(len(anchor), 25), 7, -1):
+    for length in range(min(len(anchor), 40), 7, -1):
         pos = window_text.find(anchor[:length], search_from)
         if pos >= 0:
             return pos
     return -1
 
 
-def resolve_positions(beats: list[dict], window_text: str, window_offset: int, window_end: int) -> None:
+def resolve_positions(beats: List[dict], window_text: str, window_offset: int, window_end: int) -> None:
     """
     用 start_anchor 将每个 beat 定位到绝对字符位置,原地写入
     position_start / position_end。
@@ -127,15 +128,31 @@ def resolve_positions(beats: list[dict], window_text: str, window_offset: int, w
 # LLM 调用
 # ──────────────────────────────────────────────────────────────
 
+class ContentFilterError(Exception):
+    """内容审查不通过"""
 
-async def llm_call(messages: list, model: str, temperature: float = 0.3) -> str:
-    resp = await client.chat.completions.create(
-        model=model,
-        messages=messages,
-        temperature=temperature,
-        max_tokens=8192,
-    )
-    return resp.choices[0].message.content
+
+async def llm_call(messages: list, model: str, temperature: float = 0.3, max_retries: int = 3) -> str:
+    delay = 5.0
+    for attempt in range(1, max_retries + 2):
+        try:
+            resp = await client.chat.completions.create(
+                model=model,
+                messages=messages,
+                temperature=temperature,
+                max_tokens=8192,
+            )
+            return resp.choices[0].message.content
+        except BadRequestError as e:
+            if "data_inspection_failed" in str(e) or "content_filter" in (getattr(e, "code", "") or ""):
+                raise ContentFilterError(f"内容审查不通过: {e}") from e
+            raise
+        except (RateLimitError, APIError) as e:
+            if attempt > max_retries:
+                raise
+            print(f"  [重试 {attempt}/{max_retries}] {type(e).__name__}: {e},{delay:.0f}s 后重试...")
+            await asyncio.sleep(delay)
+            delay = min(delay * 2, 60)
 
 
 def extract_json(text: str) -> dict:
@@ -159,7 +176,7 @@ SYSTEM_ANALYST = (
 )
 
 
-def build_prompt(window_text: str, prev_meta: dict | None, novel_title: str) -> str:
+def build_prompt(window_text: str, prev_meta: Optional[dict], novel_title: str) -> str:
     prev_section = ""
     if prev_meta:
         prev_section = f"""## 前序窗口元信息(保持连贯性)
@@ -175,6 +192,15 @@ def build_prompt(window_text: str, prev_meta: dict | None, novel_title: str) ->
 
 ---
 """
+    # 根据窗口大小给出参考 beat 数量(以 20000 字/beat 为粗估基准,但强调以故事结构为准)
+    window_chars = len(window_text)
+    rough_beats = max(3, round(window_chars / 20000))
+    beat_guidance = (
+        f"本窗口约 {window_chars:,} 字。以 Scene-Sequel 叙事功能为切分依据:"
+        f"Scene 在主角目标受阻并遭遇 Disaster 时结束,Sequel 在主角做出新 Decision 时结束,二者严格交替。"
+        f"切分边界是叙事功能单元的完结,与章节标题、地点切换、视角变化无关。"
+        f"根据本文实际节奏,预计大约 {rough_beats} 个节拍,但若故事结构明显更多或更少,以实际为准。"
+    )
 
     return f"""{prev_section}## 分析任务
 
@@ -183,18 +209,34 @@ def build_prompt(window_text: str, prev_meta: dict | None, novel_title: str) ->
 ### 1. 故事大纲
 - **main_plot**:本窗口主线剧情摘要(200-300 字)
 - **plot_lines**:活跃/新增剧情线索,每条包含:
-  - name、mice_type(M/I/C/E)、status(进行中/已解决/待推进)、description(30-60字)
+  - name、mice_type(M/I/C/E)、status(进行中/已解决/待推进)
+  - description:线索核心矛盾与当前进展(50-80字)
+  - core_question:一句话概括"这条线索要解答的根本问题"(≤30字)
+  - next_steps:推进此线索的下一个关键动作或待揭示信息(≤40字)
 
 ### 2. 人物小传
-主要人物(新出现 + 已有人物状态更新):name、role、goal、traits(3-5个)、relationships
-
-### 3. 节拍切分(Scene-Sequel 交替)
-
-**start_anchor 说明**(非常重要):
-- 从原文中逐字复制该节拍开头的 20-25 个字符,包含标点
-- 必须和原文完全一致,一字不差
+主要人物(新出现 + 已有人物状态更新),每人包含:
+- name、role、goal(当前目标)
+- traits:性格特质(3-5个词组)
+- speaking_style:说话风格(2-3条典型特征,如"夹杂黑话与文言""关键处斩钉截铁")
+- current_state:本窗口末的最新状态(一句话,描述动态处境而非静态属性)
+- relationships:与其他角色的关系
+
+### 3. 写作亮点
+分析本窗口的叙事技法,每条15-30字:
+- techniques:叙事/结构技巧(2-3条)
+- shuang_designs:爽点设计方式(2-3条,说明实现机制)
+- pacing:节奏处理特点(1-2条)
+
+### 4. 节拍切分(Scene-Sequel 交替)
+
+**切分粒度**:{beat_guidance}
+
+**start_anchor 说明**(非常重要,直接影响定位精度):
+- 从下方【待分析文本】中,**一字不差**地逐字复制该节拍开头的 30-40 个字符(含标点)
+- **禁止**填写"从原文逐字复制"之类的说明文字,也**禁止**照抄上方示例中的占位符——必须是待分析文本中的真实字符
 - 选择该节拍真正开始的位置,而非章节标题
-- 避免选择可能重复出现的通用短语
+- 避免选择可能多处出现的通用短语(如"他说""道"等)
 
 **节拍要素**:
 - Scene:goal / conflict_type(人物冲突|环境冲突|内心冲突|信息冲突)/ conflict_description / disaster
@@ -218,17 +260,33 @@ def build_prompt(window_text: str, prev_meta: dict | None, novel_title: str) ->
   "outline": {{
     "main_plot": "...",
     "plot_lines": [
-      {{"name": "税银案", "mice_type": "E", "status": "进行中", "description": "..."}}
+      {{
+        "name": "税银案", "mice_type": "E", "status": "进行中",
+        "description": "...",
+        "core_question": "真银被谁调包?",
+        "next_steps": "锁定御刀卫陆姓经手人"
+      }}
     ]
   }},
   "characters": [
-    {{"name": "...", "role": "主角", "goal": "...", "traits": ["机智"], "relationships": {{"角色A": "关系"}}}}
+    {{
+      "name": "...", "role": "主角", "goal": "...",
+      "traits": ["机智"],
+      "speaking_style": ["夹杂现代俚语与古语混搭", "关键处斩钉截铁"],
+      "current_state": "刚凭推理翻盘,获临时协查资格,尚未脱牢",
+      "relationships": {{"角色A": "关系"}}
+    }}
   ],
+  "writing_insights": {{
+    "techniques": ["信息差分层释放:主角全知,古代角色见表象,层层迟滞"],
+    "shuang_designs": ["逻辑型装逼:靠算术/化学原理碾压,非武力打脸"],
+    "pacing": ["对话占比65%,每章2-3次场景切换,无大段独白"]
+  }},
   "beats": [
     {{
       "id": "beat_001",
       "type": "scene",
-      "start_anchor": "从原文逐字复制的开头20字",
+      "start_anchor": "【此处填入待分析文本原文开头20-30字】",
       "mice_thread": "税银案",
       "summary": "...",
       "goal": "...",
@@ -249,7 +307,7 @@ def build_prompt(window_text: str, prev_meta: dict | None, novel_title: str) ->
     {{
       "id": "beat_002",
       "type": "sequel",
-      "start_anchor": "从原文逐字复制的开头20字",
+      "start_anchor": "【此处填入待分析文本原文开头20-30字】",
       "mice_thread": "身份成长",
       "summary": "...",
       "reaction": "...",
@@ -278,7 +336,7 @@ def build_prompt(window_text: str, prev_meta: dict | None, novel_title: str) ->
 async def analyze_window(
     novel_path: str,
     window_index: int,
-    prev_analysis_path: str | None,
+    prev_analysis_path: Optional[str],
     output_path: str,
     model: str,
     window_size: int = WINDOW_SIZE,

+ 388 - 124
examples/analyze_story/sft/step2_build_sft.py

@@ -43,10 +43,12 @@ import asyncio
 import argparse
 from copy import deepcopy
 from pathlib import Path
-from openai import AsyncOpenAI
+from openai import AsyncOpenAI, BadRequestError, RateLimitError, APIError
+from typing import Optional, List, Set
 from dotenv import load_dotenv
 
 load_dotenv()
+load_dotenv(Path(__file__).parent.parent / ".env")  # 项目根目录 .env
 
 client = AsyncOpenAI(
     api_key=os.getenv("ALI_API_KEY"),
@@ -60,6 +62,10 @@ client = AsyncOpenAI(
 # ──────────────────────────────────────────────────────────────
 
 
+class ContentFilterError(Exception):
+    """内容审查不通过,跳过该条样本,不重试"""
+
+
 def load_text(path: str) -> str:
     for enc in ["utf-8", "gbk", "gb2312", "gb18030"]:
         try:
@@ -74,14 +80,30 @@ async def llm_call(
     model: str,
     temperature: float = 0.6,
     max_tokens: int = 4096,
+    max_retries: int = 3,
 ) -> str:
-    resp = await client.chat.completions.create(
-        model=model,
-        messages=messages,
-        temperature=temperature,
-        max_tokens=max_tokens,
-    )
-    return resp.choices[0].message.content
+    delay = 5.0
+    for attempt in range(1, max_retries + 2):  # +1 for the final attempt
+        try:
+            resp = await client.chat.completions.create(
+                model=model,
+                messages=messages,
+                temperature=temperature,
+                max_tokens=max_tokens,
+            )
+            return resp.choices[0].message.content
+        except BadRequestError as e:
+            err_code = getattr(e, "code", "") or ""
+            # 阿里云内容审查:data_inspection_failed / content_filter 等
+            if "data_inspection_failed" in str(e) or "content_filter" in err_code:
+                raise ContentFilterError(f"内容审查不通过: {e}") from e
+            raise  # 其他 400 错误直接抛出
+        except (RateLimitError, APIError) as e:
+            if attempt > max_retries:
+                raise
+            print(f"    [重试 {attempt}/{max_retries}] {type(e).__name__}: {e},{delay:.0f}s 后重试...")
+            await asyncio.sleep(delay)
+            delay = min(delay * 2, 60)
 
 
 def extract_json_block(text: str) -> dict:
@@ -94,7 +116,7 @@ def extract_json_block(text: str) -> dict:
         return json.loads(json_str)
 
 
-def write_jsonl(samples: list[dict], path: Path) -> None:
+def write_jsonl(samples: List[dict], path: Path) -> None:
     path.parent.mkdir(parents=True, exist_ok=True)
     with open(path, "w", encoding="utf-8") as f:
         for s in samples:
@@ -109,41 +131,59 @@ def write_jsonl(samples: list[dict], path: Path) -> None:
 # ──────────────────────────────────────────────────────────────
 
 
-def apply_state_changes(state: dict, changes: dict) -> dict:
-    """将一个 beat 的 state_changes 应用到状态快照,返回新快照"""
-    state = deepcopy(state)
-    for pl in changes.get("plot_lines", []):
-        for line in state["plot_lines"]:
-            if line["name"] == pl["name"]:
-                line["status"] = pl["new_state"]
-                break
-        else:
-            state["plot_lines"].append(
-                {"name": pl["name"], "status": pl["new_state"],
-                 "mice_type": "?", "description": pl.get("new_state", "")}
-            )
-    for ch in changes.get("characters", []):
-        for char in state["characters"]:
-            if char["name"] == ch["name"]:
-                char.setdefault("recent_changes", []).append(ch["change"])
-                # 只保留最近 3 条变化
-                char["recent_changes"] = char["recent_changes"][-3:]
-                break
-    return state
-
-
 def build_state_snapshot(analysis: dict, beat_index: int) -> dict:
-    """返回 beat_index 之前的故事状态快照"""
+    """
+    返回 beat_index 之前的故事状态快照。
+
+    额外字段(比单纯状态更丰富):
+    - plot_line_events: {线索名 -> [事件描述列表]}
+    - recent_beats: 最近 5 个 beat 的简要记录
+    """
     state = {
         "plot_lines": deepcopy(analysis.get("outline", {}).get("plot_lines", [])),
         "characters": deepcopy(analysis.get("characters", [])),
+        "plot_line_events": {},   # name -> [str]
+        "recent_beats": [],
     }
     for b in analysis.get("beats", [])[:beat_index]:
-        state = apply_state_changes(state, b.get("state_changes", {}))
+        changes = b.get("state_changes", {})
+
+        # 更新线索状态 + 记录事件历史
+        for pl in changes.get("plot_lines", []):
+            matched = False
+            for line in state["plot_lines"]:
+                if line["name"] == pl["name"]:
+                    line["status"] = pl["new_state"]
+                    matched = True
+                    break
+            if not matched:
+                state["plot_lines"].append(
+                    {"name": pl["name"], "status": pl["new_state"],
+                     "mice_type": "?", "description": pl.get("new_state", "")}
+                )
+            event = f"{pl.get('old_state', '?')} → {pl['new_state']}"
+            state["plot_line_events"].setdefault(pl["name"], []).append(event)
+
+        # 更新人物近期变化
+        for ch in changes.get("characters", []):
+            for char in state["characters"]:
+                if char["name"] == ch["name"]:
+                    char.setdefault("recent_changes", []).append(ch["change"])
+                    char["recent_changes"] = char["recent_changes"][-3:]
+                    break
+
+        # 记录近期节拍(保留最近 5 个)
+        state["recent_beats"].append({
+            "id": b.get("id", ""),
+            "type": b["type"],
+            "summary": b.get("summary", ""),
+            "outcome": b.get("disaster", "") if b["type"] == "scene" else b.get("decision", ""),
+        })
+        state["recent_beats"] = state["recent_beats"][-5:]
     return state
 
 
-def get_last_disaster_decision(beats: list[dict], before_index: int) -> tuple[str, str]:
+def get_last_disaster_decision(beats: List[dict], before_index: int) -> tuple:
     """返回 beat_index 之前最后一个 scene 的 disaster 和 最后一个 sequel 的 decision"""
     last_disaster = "无(故事开局)"
     last_decision = "无(故事开局)"
@@ -155,24 +195,104 @@ def get_last_disaster_decision(beats: list[dict], before_index: int) -> tuple[st
     return last_disaster, last_decision
 
 
-def format_mice_threads(plot_lines: list[dict]) -> str:
-    active = [pl for pl in plot_lines if pl.get("status") not in ["已解决", "已关闭"]]
-    if not active:
-        return "(无活跃线程)"
-    lines = []
-    for pl in active:
-        mice = pl.get("mice_type", "?")
-        lines.append(f"  [{mice}] {pl['name']}({pl['status']}):{pl.get('description', '')}")
-    return "\n".join(lines)
+def format_story_notes(
+    analysis: dict,
+    state: dict,
+    last_disaster: str,
+    last_decision: str,
+) -> str:
+    """
+    生成故事笔记(约 2000-4000 字符)。
+    包含 core_question/next_steps(线索)、speaking_style/current_state(人物)、writing_insights(窗口级)。
+    """
+    parts = []
 
+    # 1. 主线摘要
+    main_plot = analysis.get("outline", {}).get("main_plot", "")
+    if main_plot:
+        parts.append(f"**主线**:{main_plot}")
+
+    # 2. 活跃剧情线索(含 core_question, next_steps, 历史事件)
+    active = [pl for pl in state["plot_lines"]
+              if pl.get("status") not in ["已解决", "已关闭"]]
+    resolved = [pl for pl in state["plot_lines"]
+                if pl.get("status") in ["已解决", "已关闭"]]
+    if active:
+        lines = ["**活跃线索**:"]
+        for pl in active:
+            mice = pl.get("mice_type", "?")
+            events = state.get("plot_line_events", {}).get(pl["name"], [])
+            ev_str = f"(进展:{';'.join(events[-3:])})" if events else ""
+            cq = pl.get("core_question", "")
+            ns = pl.get("next_steps", "")
+            extra = ""
+            if cq:
+                extra += f" 核心问:{cq}"
+            if ns:
+                extra += f" 待推进:{ns}"
+            lines.append(
+                f"- [{mice}] {pl['name']}({pl['status']}):"
+                f"{pl.get('description', '')}{ev_str}{extra}"
+            )
+        if resolved:
+            lines.append(f"- 已结:{'、'.join(p['name'] for p in resolved)}")
+        parts.append("\n".join(lines))
+
+    # 3. 人物状态(含 speaking_style, current_state, 性格, 关系, 近期变化)
+    if state["characters"]:
+        lines = ["**人物**:"]
+        for c in state["characters"]:
+            segs = [f"{c['name']}({c.get('role', '?')})目标:{c.get('goal', '')}"]
+            traits = c.get("traits", [])
+            if traits:
+                segs.append(f"性格:{'、'.join(traits)}")
+            style = c.get("speaking_style", [])
+            if style:
+                style_str = ",".join(style) if isinstance(style, list) else str(style)
+                segs.append(f"说话风格:{style_str}")
+            cur_state = c.get("current_state", "")
+            if cur_state:
+                segs.append(f"当前处境:{cur_state}")
+            rels = c.get("relationships", {})
+            if rels:
+                rel_items = [f"{k}→{v}" for k, v in list(rels.items())[:4]]
+                segs.append(f"关系:{';'.join(rel_items)}")
+            recent = c.get("recent_changes", [])
+            if recent:
+                segs.append(f"近期:{';'.join(recent)}")
+            lines.append("- " + "。".join(segs))
+        parts.append("\n".join(lines))
+
+    # 4. 近期节拍
+    recent_beats = state.get("recent_beats", [])
+    if recent_beats:
+        lines = ["**近期节拍**:"]
+        for b in recent_beats:
+            tag = "场景" if b["type"] == "scene" else "后续"
+            outcome_label = "结局" if b["type"] == "scene" else "决定"
+            outcome = f" → {outcome_label}:{b['outcome']}" if b.get("outcome") else ""
+            lines.append(f"- [{b['id']}·{tag}] {b['summary']}{outcome}")
+        parts.append("\n".join(lines))
+
+    # 5. 写作亮点(窗口级,来自 step1 提取的 writing_insights)
+    wi = analysis.get("writing_insights", {})
+    if wi:
+        wi_lines = []
+        for item in wi.get("techniques", []):
+            wi_lines.append(f"- 技巧:{item}")
+        for item in wi.get("shuang_designs", []):
+            wi_lines.append(f"- 爽点设计:{item}")
+        for item in wi.get("pacing", []):
+            wi_lines.append(f"- 节奏:{item}")
+        if wi_lines:
+            parts.append("**写作亮点**:\n" + "\n".join(wi_lines))
+
+    # 6. 悬而未决
+    parts.append(
+        f"**待解决**:上一场景结局:{last_disaster};上一个决定:{last_decision}"
+    )
 
-def format_characters(characters: list[dict]) -> str:
-    parts = []
-    for c in characters:
-        recent = "、".join(c.get("recent_changes", []))
-        recent_str = f"近期:{recent}" if recent else ""
-        parts.append(f"  {c['name']}({c.get('role', '?')})目标:{c.get('goal', '')}  {recent_str}")
-    return "\n".join(parts)
+    return "\n\n".join(parts)
 
 
 def calc_position_percent(beat: dict, total_chars: int) -> float:
@@ -183,24 +303,99 @@ def calc_position_percent(beat: dict, total_chars: int) -> float:
 # Task 1:结构规划(Structure Planning)
 # ──────────────────────────────────────────────────────────────
 
-TASK1_SYSTEM = (
-    "你是一位专业的长篇小说结构规划师,精通 Scene-Sequel 结构、MICE 线程理论、"
-    "以及中国网文爽点与钩子设计。请严格按指定格式输出。"
-)
+TASK1_SYSTEM = """\
+你是资深网文作者,擅长基于故事笔记规划场景。
+
+## 核心能力
+1. **分析笔记**:理解当前故事状态、活跃线索、人物动态
+2. **规划场景**:基于笔记设计下一个场景的结构
+3. **更新笔记**:记录场景对故事状态的改变
+
+## 工作流程
+1. 仔细阅读故事笔记(当前状态、活跃线索、待办事项)
+2. 在 `<think>` 中展示你的思考过程(800-1500字)
+3. 输出场景规划(JSON 格式)
+4. 输出笔记更新(Markdown 格式)
+
+---
+
+## Think 要求
+
+在 `<think>` 标签中,展示你真实的创作思维过程。**不要求固定格式**,但需要包含以下核心要素:
+
+必须包含的要素:
+1. **笔记分析**:当前故事进行到哪里?哪些线索在推进?主要角色的目标、冲突、关系状态;笔记中标记的待推进事项和风险点
+2. **方案推演**:至少考虑 2-3 种不同的场景设计方案;对比各方案的优缺点;说明为什么选择某个方案
+3. **笔记更新计划**:这个场景会推进哪些线索?哪些人物状态会变化?需要新增或完成哪些待推进事项?
+
+鼓励的思维方式:
+- **跳跃联想**:从笔记的某个细节突然想到类似案例
+- **自我质疑**:推翻之前的想法,重新思考
+- **细节推敲**:对某个对话、动作、道具的反复打磨
+- **灵感闪现**:突然意识到某个巧妙的设计
+- **风险预警**:发现可能的逻辑漏洞或人设崩塌
+
+不要求固定章节标题(如【笔记分析】【方案推演】),不需要按固定顺序展开,可以有口语化、跳跃、修正。
+
+---
+
+## 输出格式
+
+### 1. 场景规划(JSON)
+```json
+{
+  "scene_type": "scene | sequel",
+  "goal": "角色目标",
+  "conflict_type": "冲突类型",
+  "conflict_description": "...",
+  "disaster": "场景结尾的灾难/转折(scene 类型必填)",
+  "sequel": {"reaction": "...", "dilemma": "...", "decision": "..."},
+  "pacing": "fast|medium|slow",
+  "dialogue_ratio": 0.4,
+  "shuang_point": {
+    "has_shuang": true,
+    "type": "打脸|升级|装逼|获得|碾压",
+    "mechanism": "实现机制"
+  },
+  "hooks": ["悬念1", "悬念2"],
+  "mice_threads": {
+    "推进": ["线索名"],
+    "开启": ["新线索名"],
+    "解决": ["已完成线索名"]
+  },
+  "estimated_words": 2000
+}
+```
+
+### 2. 笔记更新(Markdown)
+```markdown
+## 笔记更新
+
+### 剧情线索变化
+- [线索名]:[旧状态] → [新状态]
+- [新线索]:开启([简短描述])
+
+### 人物状态变化
+- [角色名]:[变化描述]
+
+### 待推进更新
+- [✓] [已完成事项]
+- [ ] [新增事项](紧急/重要)
+
+### 新增写作亮点(可选)
+- [技巧/桥段]:[描述]
+```
+"""
 
 TASK1_USER_TMPL = """\
-## 故事状态
+## 故事笔记
 
 - 书名:{title}
 - 当前位置:第 {chapter} 章,约 {position_pct}% 处
-- 已激活的 MICE 线程:
-{mice_threads}
-- 上一个 Scene 的 Disaster:{last_disaster}
-- 上一个 Sequel 的 Decision:{last_decision}
 
-## 当前人物状态
+{story_notes}
 
-{characters}
+---
 
 ## 上文(最近 {context_chars} 字)
 
@@ -208,21 +403,21 @@ TASK1_USER_TMPL = """\
 
 ## 任务
 
-请规划下一个 Scene-Sequel 单元的结构。"""
+请基于故事笔记和上文,完成以下任务:
+
+1. 分析当前故事状态(在 `<think>` 中展示你的思考过程)
+2. 规划下一个场景的结构(JSON 格式)
+3. 输出笔记更新(Markdown 格式)"""
 
 TASK1_COT_GEN_TMPL = """\
-## 故事状态
+## 故事笔记
 
 - 书名:{title}
 - 当前位置:第 {chapter} 章,约 {position_pct}% 处
-- 已激活的 MICE 线程:
-{mice_threads}
-- 上一个 Scene 的 Disaster:{last_disaster}
-- 上一个 Sequel 的 Decision:{last_decision}
 
-## 当前人物状态
+{story_notes}
 
-{characters}
+---
 
 ## 上文(最近 {context_chars} 字)
 
@@ -237,47 +432,54 @@ TASK1_COT_GEN_TMPL = """\
 
 ---
 
-请以"事前规划"的视角写出你的思考过程和最终规划。
-
-**输出格式**:
+请以"事前规划"的视角展示你真实的创作思维过程(分析笔记状态、推演至少 2-3 个方案并对比优缺点、规划笔记更新),然后输出规划 JSON 和笔记更新。
 
 <think>
-## 叙事状态分析
-[分析当前处于哪个 MICE 线程、节拍、读者情绪积累]
-[分析上一个 Disaster/Decision 对下一步的约束]
-
-## 续写决策
-[决定下一个 Scene 的 Goal、Conflict 类型、Disaster 方向]
-[决定是否需要爽点/钩子,类型和强度]
-[决定节奏:快/慢,对话比例]
+[自由思考过程]
 </think>
 
 ```json
 {{
-  "scene": {{
-    "goal": "...",
-    "conflict_type": "人物冲突|环境冲突|内心冲突|信息冲突",
-    "conflict_description": "...",
-    "disaster": "...",
-    "pacing": "fast|medium|slow",
-    "dialogue_ratio": 0.4
-  }},
-  "sequel": {{
-    "reaction": "...",
-    "dilemma": "...",
-    "decision": "..."
+  "scene_type": "scene | sequel",
+  "goal": "...",
+  "conflict_type": "人物冲突|环境冲突|内心冲突|信息冲突",
+  "conflict_description": "...",
+  "disaster": "...",
+  "sequel": {{"reaction": "...", "dilemma": "...", "decision": "..."}},
+  "pacing": "fast|medium|slow",
+  "dialogue_ratio": 0.4,
+  "shuang_point": {{
+    "has_shuang": true,
+    "type": "打脸|升级|装逼|获得|碾压",
+    "mechanism": "..."
   }},
   "hooks": [
     {{"type": "chapter_end|mid_chapter", "content": "..."}}
   ],
-  "shuang_point": {{
-    "has_shuang": true,
-    "type": "打脸|升级|装逼|获得|碾压",
-    "position": "scene_start|scene_mid|scene_end"
+  "mice_threads": {{
+    "推进": ["线索名"],
+    "开启": ["新线索名"],
+    "解决": ["已完成线索名"]
   }},
-  "mice_advancement": "M|I|C|E",
   "estimated_words": 2000
 }}
+```
+
+```markdown
+## 笔记更新
+
+### 剧情线索变化
+- [线索名]:[旧状态] → [新状态]
+
+### 人物状态变化
+- [角色名]:[变化描述]
+
+### 待推进更新
+- [✓] [已完成]
+- [ ] [新增](紧急/重要)
+
+### 新增写作亮点(可选)
+- [技巧]:[描述]
 ```"""
 
 
@@ -310,7 +512,7 @@ async def gen_task1_sample(
     context_chars: int,
     model: str,
     sem: asyncio.Semaphore,
-) -> dict | None:
+) -> Optional[dict]:
     async with sem:
         meta = analysis.get("_meta", {})
         title = meta.get("novel_title", "未知")
@@ -319,8 +521,6 @@ async def gen_task1_sample(
 
         state = build_state_snapshot(analysis, i)
         last_disaster, last_decision = get_last_disaster_decision(beats, i)
-        mice_threads = format_mice_threads(state["plot_lines"])
-        characters = format_characters(state["characters"])
 
         chapter = beat.get("chapter_start", "?")
         position_pct = calc_position_percent(beat, total_chars)
@@ -328,14 +528,13 @@ async def gen_task1_sample(
         ctx_start = max(0, beat["position_start"] - context_chars)
         context_text = novel_text[ctx_start: beat["position_start"]].strip()
 
+        story_notes = format_story_notes(analysis, state, last_disaster, last_decision)
+
         shared_kwargs = dict(
             title=title,
             chapter=chapter,
             position_pct=position_pct,
-            mice_threads=mice_threads,
-            last_disaster=last_disaster,
-            last_decision=last_decision,
-            characters=characters,
+            story_notes=story_notes,
             context_chars=context_chars,
             context_text=context_text,
         )
@@ -354,6 +553,9 @@ async def gen_task1_sample(
         ]
         try:
             assistant_content = await llm_call(messages, model=model)
+        except ContentFilterError as e:
+            print(f"  [Task1] beat {i+1} 内容审查拦截,跳过:{e}")
+            return None
         except Exception as e:
             print(f"  [Task1] beat {i+1} LLM 调用失败:{e}")
             return None
@@ -390,6 +592,14 @@ TASK2_SYSTEM = (
 )
 
 TASK2_USER_TMPL = """\
+## 故事笔记(概要)
+
+- 书名:{title},当前位置约 {position_pct}% 处
+
+{story_notes_brief}
+
+---
+
 ## 上文
 
 {context_text}
@@ -403,6 +613,14 @@ TASK2_USER_TMPL = """\
 请续写下一段(约 {target_words} 字),风格与上文保持一致。"""
 
 TASK2_COT_GEN_TMPL = """\
+## 故事笔记(概要)
+
+- 书名:{title},当前位置约 {position_pct}% 处
+
+{story_notes_brief}
+
+---
+
 ## 上文
 
 {context_text}
@@ -417,20 +635,10 @@ TASK2_COT_GEN_TMPL = """\
 
 ---
 
-请以"事前决策"的视角写出写作思考过程,然后直接输出实际续写内容。
-
-**输出格式**:
+请以"事前决策"的视角自由写出写作思考过程(上文衔接方式、爽点植入、人物动机、对话设计等,无需固定段落),然后直接输出实际续写内容。
 
 <think>
-## 上文理解
-[识别上文的叙事状态:最后一个 Scene/Sequel 的位置,主角的情绪状态]
-[识别关键信息:哪些细节需要在续写中呼应]
-
-## 写法决策
-[开头如何衔接:直接延续/场景切换/时间跳跃]
-[爽点如何植入:在哪个位置,用什么方式]
-[钩子如何设置:章末悬念的具体内容]
-[对话设计:谁说什么,潜台词是什么]
+[自由思考过程]
 </think>
 
 {actual_text}"""
@@ -445,10 +653,16 @@ async def gen_task2_sample(
     context_chars: int,
     model: str,
     sem: asyncio.Semaphore,
-) -> dict | None:
+) -> Optional[dict]:
     async with sem:
         meta = analysis.get("_meta", {})
+        title = meta.get("novel_title", "未知")
         total_chars = meta.get("total_chars", len(novel_text))
+        beats = analysis.get("beats", [])
+
+        state = build_state_snapshot(analysis, i)
+        last_disaster, last_decision = get_last_disaster_decision(beats, i)
+        position_pct = calc_position_percent(beat, total_chars)
 
         ctx_start = max(0, beat["position_start"] - context_chars)
         context_text = novel_text[ctx_start: beat["position_start"]].strip()
@@ -457,6 +671,9 @@ async def gen_task2_sample(
         if not beat_text:
             return None
 
+        # Task2 使用精简版笔记:只含活跃线索和人物,不含近期节拍(上文已涵盖)
+        story_notes_brief = format_story_notes(analysis, state, last_disaster, last_decision)
+
         # 从 Task1 样本中提取结构规划(assistant 输出部分)
         structure_plan = ""
         if i < len(task1_samples) and task1_samples[i]:
@@ -473,6 +690,9 @@ async def gen_task2_sample(
         beat_hint = beat_text[:300] + "..." if len(beat_text) > 300 else beat_text
 
         cot_prompt = TASK2_COT_GEN_TMPL.format(
+            title=title,
+            position_pct=position_pct,
+            story_notes_brief=story_notes_brief,
             context_text=context_text,
             structure_plan=structure_plan,
             beat_text_hint=beat_hint,
@@ -484,6 +704,9 @@ async def gen_task2_sample(
         ]
         try:
             cot_part = await llm_call(messages, model=model)
+        except ContentFilterError as e:
+            print(f"  [Task2] beat {i+1} 内容审查拦截,跳过:{e}")
+            return None
         except Exception as e:
             print(f"  [Task2] beat {i+1} LLM 调用失败:{e}")
             return None
@@ -501,6 +724,9 @@ async def gen_task2_sample(
             assistant_content = cot_part
 
         user_content = TASK2_USER_TMPL.format(
+            title=title,
+            position_pct=position_pct,
+            story_notes_brief=story_notes_brief,
             context_text=context_text,
             structure_plan=structure_plan,
             target_words=target_words,
@@ -535,6 +761,12 @@ TASK3_SYSTEM = (
 )
 
 TASK3_GEN_TMPL = """\
+## 故事背景(用于理解爽点来源)
+
+{story_notes_brief}
+
+---
+
 ## 原文(包含爽点的增强版)
 
 {beat_text}
@@ -546,6 +778,7 @@ TASK3_GEN_TMPL = """\
 1. 判断这段文字是否包含明显爽点(打脸/升级/装逼/获得/碾压)
 2. 如果有,生成去掉爽点后的"平淡草稿"(保留核心情节事件,但去掉爽感设计)
 3. 以编辑视角,写出重新注入爽点的完整思考过程(CoT)和修改说明
+   注意:CoT 应分析人物性格/关系如何使这个爽点成立,以及与当前剧情线索的联动
 
 **输出格式(严格 JSON)**:
 
@@ -555,7 +788,7 @@ TASK3_GEN_TMPL = """\
   "shuang_type": "打脸|升级|装逼|获得|碾压",
   "intensity": "low|medium|high",
   "flat_draft": "去掉爽点后的平淡版本(完整文字)",
-  "cot": "<think>\\n## 草稿分析\\n[识别草稿问题]\\n\\n## 爽点设计\\n[注入方案]\\n</think>",
+  "cot": "<think>\\n[自由分析草稿问题和注入方案,结合人物特质和线索背景]\\n</think>",
   "modification_notes": "注入位置:...\\n爽点类型:...\\n关键改动:..."
 }}
 ```
@@ -563,6 +796,12 @@ TASK3_GEN_TMPL = """\
 如果不包含明显爽点,输出:`{{"has_shuang": false}}`"""
 
 TASK3_USER_TMPL = """\
+## 故事背景
+
+{story_notes_brief}
+
+---
+
 ## 平淡草稿
 
 {flat_draft}
@@ -572,6 +811,7 @@ TASK3_USER_TMPL = """\
 - 爽点类型:{shuang_type}
 - 强度:{intensity}(low=轻微强化 | medium=明显提升 | high=大幅改写)
 - 不改变核心情节,只增强情感冲击力
+- 结合人物性格特质和当前剧情线索设计爽感
 
 ## 任务
 
@@ -585,7 +825,7 @@ async def gen_task3_sample(
     novel_text: str,
     model: str,
     sem: asyncio.Semaphore,
-) -> dict | None:
+) -> Optional[dict]:
     # 只处理有爽点的 beat
     sp = beat.get("shuang_point", {})
     if not sp.get("has_shuang"):
@@ -594,19 +834,30 @@ async def gen_task3_sample(
     async with sem:
         meta = analysis.get("_meta", {})
         total_chars = meta.get("total_chars", len(novel_text))
+        beats = analysis.get("beats", [])
+
+        state = build_state_snapshot(analysis, i)
+        last_disaster, last_decision = get_last_disaster_decision(beats, i)
+        story_notes_brief = format_story_notes(analysis, state, last_disaster, last_decision)
 
         beat_text = novel_text[beat["position_start"]: beat["position_end"]].strip()
         if len(beat_text) < 200:
             return None
 
         # 生成平淡草稿 + CoT
-        gen_prompt = TASK3_GEN_TMPL.format(beat_text=beat_text)
+        gen_prompt = TASK3_GEN_TMPL.format(
+            story_notes_brief=story_notes_brief,
+            beat_text=beat_text,
+        )
         messages = [
             {"role": "system", "content": TASK3_SYSTEM},
             {"role": "user", "content": gen_prompt},
         ]
         try:
             raw = await llm_call(messages, model=model)
+        except ContentFilterError as e:
+            print(f"  [Task3] beat {i+1} 内容审查拦截,跳过:{e}")
+            return None
         except Exception as e:
             print(f"  [Task3] beat {i+1} LLM 调用失败:{e}")
             return None
@@ -614,7 +865,10 @@ async def gen_task3_sample(
         try:
             result = extract_json_block(raw)
         except Exception:
-            print(f"  [Task3] beat {i+1} JSON 解析失败,跳过")
+            # 保存原始响应供排查
+            debug_path = Path(f"/tmp/task3_beat{i+1}_debug.txt")
+            debug_path.write_text(raw, encoding="utf-8")
+            print(f"  [Task3] beat {i+1} JSON 解析失败,原始响应已保存至 {debug_path},跳过")
             return None
 
         if not result.get("has_shuang"):
@@ -631,6 +885,7 @@ async def gen_task3_sample(
 
         # 训练样本
         user_content = TASK3_USER_TMPL.format(
+            story_notes_brief=story_notes_brief,
             flat_draft=flat_draft,
             shuang_type=shuang_type,
             intensity=intensity,
@@ -672,15 +927,19 @@ async def build_all(
     novel_path: str,
     output_dir: str,
     context_chars: int,
-    skip_tasks: set[int],
+    skip_tasks: Set[int],
     model: str,
     concurrency: int,
+    max_beats: Optional[int] = None,
 ):
     with open(analysis_path, encoding="utf-8") as f:
         analysis = json.load(f)
 
     novel_text = load_text(novel_path)
     beats = analysis.get("beats", [])
+    if max_beats is not None:
+        beats = beats[:max_beats]
+        analysis = dict(analysis, beats=beats)  # 局部视图,不修改文件
     out = Path(output_dir)
     sem = asyncio.Semaphore(concurrency)
 
@@ -692,7 +951,7 @@ async def build_all(
     stats = {}
 
     # ── Task 1 ──────────────────────────────────
-    task1_samples: list[dict | None] = [None] * len(beats)
+    task1_samples: List[Optional[dict]] = [None] * len(beats)
     if 1 not in skip_tasks:
         print("[Task 1] 结构规划(Structure Planning)...")
         tasks = [
@@ -765,6 +1024,10 @@ def main():
         help="并发 LLM 调用数(默认 5)",
     )
     parser.add_argument("--model", default="qwen-plus", help="使用的模型名称")
+    parser.add_argument(
+        "--max-beats", type=int, default=None,
+        help="只处理前 N 个 beat(用于试运行验证)",
+    )
     args = parser.parse_args()
 
     asyncio.run(
@@ -776,6 +1039,7 @@ def main():
             set(args.skip_task),
             args.model,
             args.concurrency,
+            args.max_beats,
         )
     )
 

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.