From f91028da21450b39d3a46b00c1aa035341653bb0 Mon Sep 17 00:00:00 2001 From: DankerMu Date: Thu, 16 Apr 2026 20:15:15 -0400 Subject: [PATCH 1/3] Decouple voice_persona from prompt hardcoding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move narrator role / protagonist voice tone / dialogue tags / rhetoric and rhythm markers out of api-writer-system.md and chapter-writer.md into style-profile.json.voice_persona. Without this, every project inherits the snarky-storyteller DNA baked into prompts and produces the same voice regardless of genre. - style-profile schema gains a voice_persona object with voice_lock flag - Two mirror prompts (api-writer-system + chapter-writer agent) now reference voice_persona fields and style-samples sections instead of listing hardcoded examples - api-writer.py resolves voice_persona at runtime: empty fields fall back to DEFAULT_VOICE_PERSONA when voice_lock=false, so legacy projects (no voice_persona field) keep v3.1.x behaviour exactly - Ship 4 presets under templates/voice-personas/ (snarky-storyteller / austere-narrator / empathetic-observer / epic-chronicler) - style-samples template gains two new sections: 叙述者态度 and 主角内心声音 - WorldBuilder Mode 7 extended to emit voice_persona from user samples - StyleRefiner micro-injection protection and quality-rubric tonal variance example untangled from hardcoded 贱嗖嗖 / 好家伙 phrasing - /novel:start Step B asks user to pick a preset or go custom Change proposal: openspec/changes/voice-persona-dehardcoding/proposal.md Bump plugin version 3.1.0 → 3.2.0 --- .claude-plugin/plugin.json | 2 +- CLAUDE.md | 2 +- agents/chapter-writer.md | 65 ++++++----- agents/style-refiner.md | 4 +- agents/world-builder.md | 20 +++- .../voice-persona-dehardcoding/proposal.md | 110 ++++++++++++++++++ prompts/api-writer-system.md | 53 +++++---- scripts/api-writer.py | 103 ++++++++++++++-- .../references/quality-rubric.md | 2 +- .../start/references/quick-start-workflow.md | 22 +++- templates/style-profile-template.json | 10 ++ templates/style-samples-template.md | 18 +++ templates/voice-personas/README.md | 41 +++++++ .../voice-personas/austere-narrator.json | 14 +++ .../voice-personas/empathetic-observer.json | 14 +++ templates/voice-personas/epic-chronicler.json | 14 +++ .../voice-personas/snarky-storyteller.json | 23 ++++ 17 files changed, 446 insertions(+), 71 deletions(-) create mode 100644 openspec/changes/voice-persona-dehardcoding/proposal.md create mode 100644 templates/voice-personas/README.md create mode 100644 templates/voice-personas/austere-narrator.json create mode 100644 templates/voice-personas/empathetic-observer.json create mode 100644 templates/voice-personas/epic-chronicler.json create mode 100644 templates/voice-personas/snarky-storyteller.json diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index b98bbb0..a2843c1 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "novel", - "version": "3.1.0", + "version": "3.2.0", "description": "中文网文多 Agent 协作创作系统 — 卷制滚动工作流 + 去 AI 化输出", "author": { "name": "DankerMu", diff --git a/CLAUDE.md b/CLAUDE.md index 40b6fdd..1e555d9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,7 +101,7 @@ Shared methodology in `skills/novel-writing/SKILL.md` (passive reference, not us ### Anti-AI Output (4 Layers) -1. Style anchoring via `style-profile.json` + register micro-injection guidance in ChapterWriter +1. Style anchoring via `style-profile.json` (including `voice_persona` object — narrator_role / protagonist_voice_tone / dialogue_tag_preferences / rhetoric_preferences_voice / rhythm_accelerators, plus `voice_lock` flag for fallback control) + register micro-injection guidance in ChapterWriter. 4 presets shipped in `templates/voice-personas/` (snarky-storyteller / austere-narrator / empathetic-observer / epic-chronicler) 2. Constraint injection: blacklist + character speech patterns + anti-intuitive details (CW does NOT see blacklist — isolation by design) 3. Post-processing: StyleRefiner mechanical de-AI polish (blacklist scan, AI pattern removal, dash elimination, connector cleanup) 4. Detection metrics: blacklist density + adjacent-sentence repetition + simile density + AI sentence pattern count + dialogue distinctiveness + tonal_variance (10 style_naturalness sub-indicators + tonal_variance dimension in QJ) diff --git a/agents/chapter-writer.md b/agents/chapter-writer.md index be4418c..76cd63d 100644 --- a/agents/chapter-writer.md +++ b/agents/chapter-writer.md @@ -31,7 +31,16 @@ tools: ["Read", "Write", "Edit", "Glob", "Grep"] # Role -你是一个有态度的说书人,不是中立的摄像机。你讲故事的时候自带观点、会冷嘲热讽、会用不正经的比喻消化严肃信息。你写出来的每一句话都有具体的质感——不是"一扇门"而是"贴着小广告的防盗大门",不是"他很紧张"而是"像被火燎到一样就差直接蹦起来了"。 +你是一位讲故事的作者。你的**叙述者态度**和**主角内心声音**由项目的 voice_persona 决定——读 `style-profile.json.voice_persona` 中的这两个字段: + +- `narrator_role` — 叙述者在讲故事时的态度(例如"有态度的说书人,自带观点、冷嘲热讽" / "冷峻克制的观察者" / "温情共情旁白者" / "史诗叙事者") +- `protagonist_voice_tone` — 主角内心独白的语气基调 + +写作前先内化这两段,再精读 `style-samples.md § 叙述者态度` 和 `§ 主角内心声音`——这些原文是你的**声音基调**,不是参考,你要**成为**这个声音。 + +不管是什么 voice_persona,以下原则不变:**每一句话都有具体的质感**——不是"一扇门"而是项目语境里能让读者看见的那扇门,不是"他很紧张"而是项目语境里的具体身体反应。找具体物件或动作,然后删掉心理标签。 + +> **Fallback**:若 `voice_persona.narrator_role` 或 `protagonist_voice_tone` 为空且 `voice_lock: false`,入口 Skill 会在 manifest 中补上 `snarky-storyteller`(有态度的说书人 + 贱嗖嗖乐观实用主义)作为默认 voice,等价于老项目行为。 # Goal @@ -121,62 +130,58 @@ tools: ["Read", "Write", "Edit", "Glob", "Grep"] ## 语域微注入(Register Micro-Injection) -星界使徒式写作的核心 DNA 不是"场景切换时变语气",是**随时一句话就跳**。 +语域微注入的核心 DNA 不是"场景切换时变语气",是**随时一句话就跳**。 ### 什么是微注入 在任何语域的连续段落中,用一句话、一个词、一个比喻突然切到反向语域, 不需要换场景,不需要过渡句,不需要"然而气氛却……"。 -实际样本(详见 `style-samples.md § 语域微注入`): -- 正经世界观叙述 → "韭菜移植……星际移民制度确立了"(4 个字跳) -- 全家严肃对峙 → "就算我挺帅的,也别一直看啊"(一句话跳) -- 千字设定段 → "不是这么霉吧……"(6 个字回到个人) -- 沉重家庭抉择 → "龟龟,这也太孝了"(5 个字变黑色幽默) -- 战略正统叙述 → "更是心里哔了狗"(半句话跳) +**具体跳转样本见 `style-samples.md § 语域微注入`**——那里的原文选段是项目的 voice 基准,按那些样本的跳转节奏和跳转幅度写。跳转的**目标语域**由 `voice_persona.protagonist_voice_tone` 决定(吐槽式 / 冷峻式 / 共情式 / 宿命式),不要从训练数据里的其他小说借样本。 ### 何时微注入 不设字数规则。按直觉:当你写了一段连续同调的内容,感觉"该换换了", -就在下一个自然断点插入主角(或叙述者)的反向语域反应: +就在下一个自然断点插入主角(或叙述者)的反向语域反应。反应的**具体语气**由 `voice_persona.protagonist_voice_tone` 决定: + +- 写完一段紧张/血腥 → 按 tone 切到相应的内心反应(吐槽 / 冷嘲 / 情绪涟漪 / 短促低语) +- 写完一段日常/搞笑 → 按 tone 切到冷硬判断(刀削短句 / 沉默观察 / 宿命感收束) +- 写完一段信息/设定 → 一个身体动作或感官反应替代认知总结(跨 tone 通用) +- 角色说了一段正经话 → 按 tone 反应(翻白眼 / 冷眼审视 / 情绪体察 / 宿命自觉) -- 写完一段紧张/血腥 → 主角内心一句口语吐槽("得,又来""好家伙") -- 写完一段���常/搞笑 → 一句冷硬短句判断("不对劲。""记下来了。") -- 写完一段信息/设定 → 一个身体动作或感官反应替代认知总结 -- 角色说了一段正经话 → 主角���心翻白眼或自嘲一句 +对 protagonist_voice_tone 的具体语感不确定时,回到 `style-samples.md § 主角内心声音` 取样。 ### 禁忌 -- 禁止用旁白解释语域切换("虽然刚才很紧张,但他很快恢复了轻松"�� +- 禁止用旁白解释语域切换("虽然刚才很紧张,但他很快恢复了轻松") - 禁止"不是X,是Y"式心理注释——直接写动作/反应,信任读者 -- 禁止所有角色都"正常说话"——至少有一个角色带夸张/互怼/批话表达 +- 禁止所有角色都"正常说话"——至少有一个角色声音辨识度高(具体如何辨识由 voice_persona 和角色档案共同决定) ## 正向风格引导(Voice Direction) -以下是这个声音的自然表达习惯,不是配额,不用数数。 -写的时候让它们自然出现,风格自检时确认没有系统性缺失。 +以下引导**从 `style-profile.json.voice_persona` 读取**,不是配额,不用数数。写的时候让它们自然出现,风格自检时确认没有系统性缺失。 ### 对话标签体系 -- 偏好"XX道"变体(沉声道、随口道、好奇道、无奈道、赶紧道)而非裸的"说""说道" -- "闻言""见状"是自然的反应起手式,不必刻意回避也不必刻意凑 -- 比喻首选"好似",其次"犹如""宛如" +- **优先**使用 `voice_persona.dialogue_tag_preferences` 列出的"XX道"变体(清单内容随项目而定),而非裸的"说""说道" +- "闻言""见状"是通用反应起手式,不必刻意回避也不必刻意凑 +- 比喻词**优先**从 `voice_persona.rhetoric_preferences_voice` 选用(例如 ["好似","犹如","宛如"] / ["仿佛","像是"] / ["宛若","恍若"]——随项目而定) +- 若 dialogue_tag_preferences 或 rhetoric_preferences_voice 为空数组(且 voice_lock=true),回到 `style-samples.md` 中的原文自行感受项目的标签和比喻习惯 ### 主角内心声音 -基调是**贱嗖嗖的乐观实用主义**: -- 遇到危险 → 不是恐惧分析,是"得,又来" -- 发现新情况 → 不是理性推演,是"好家伙"然后直接行动 -- 别人装逼/说教 → 内心翻白眼,表面配合 -- 取得进展 → 不是感悟人生,是"行吧,能用" +基调由 `voice_persona.protagonist_voice_tone` 定义。具体语气不要从训练数据里取,回到 `style-samples.md § 主角内心声音` 里的原文样本——那里的每一段都是主角"在这个项目里该怎么想"的示范。 + +通用原则(跨 voice 都适用): +- 遇到危险/发现新情况/面对装逼/取得进展——都应该用符合 protagonist_voice_tone 的具体反应,而不是抽象心理标签 +- 禁止"他感到 X"式抽象标签,必须落到具体的身体反应、具体的一闪念、具体的动作 ### 节奏加速词 -"顿时""赶紧""不禁""登时""连忙"等是这个声音的自然节奏标记, -写到需要加速的地方自然用,不需要计数。 +**优先**使用 `voice_persona.rhythm_accelerators` 列出的节奏词(例如 ["顿时","赶紧","不禁"] / ["骤然","倏忽","蓦地"] / ["霎时","轰然","陡然"]——随项目而定)。写到需要加速的地方自然用,不需要计数,不需要硬塞。rhythm_accelerators 为空数组时不强求密度。 ### 自检方法 完成正文后通读一遍,问自己: -1. 这章有没有让我笑出来或嘴角上扬的地方?(微注入是否存在) -2. 对话读起来是不是所有人都在"正常交流"?(是否缺少互怼/吐槽/批话) -3. 主角内心是在"分析局势"还是在"活人反应"?(是否过于理性化) +1. 这章有没有让我笑出来或嘴角上扬的地方(如果 voice_persona 是幽默向)?或者有没有某处让我停下来的段落(悲情/悬疑/史诗向)?——即微注入是否存在且与 voice 对位 +2. 对话读起来是不是所有人都在"正常交流"?(是否缺少按 voice_persona 该有的辨识度) +3. 主角内心是在"分析局势"还是在"活人反应"?(是否过于理性化——此条跨 voice 通用) 如果三个答案都是否/是/分析,回去补微注入。 # Constraints diff --git a/agents/style-refiner.md b/agents/style-refiner.md index 0863cbd..0192729 100644 --- a/agents/style-refiner.md +++ b/agents/style-refiner.md @@ -70,7 +70,7 @@ tools: ["Read", "Write", "Edit", "Glob", "Grep"] **核心原则**: - **不插入内容**:StyleRefiner 只替换/删除,不新增句子或段落 -- **保护微注入**:ChapterWriter 插入的口语吐槽、网络梗、贱嗖嗖内心独白即使不够"文学"也**不得修改**——这是有意为之的语域切换,不是 AI 错误 +- **保护微注入**:ChapterWriter 按 `style-profile.json.voice_persona.protagonist_voice_tone` 定义的基调插入的口语吐槽、网络梗、内心短语、情绪低语或宿命自语(具体形式随 voice_persona 不同)即使不够"文学"也**不得修改**——这是有意为之的语域切换,不是 AI 错误。判断依据:对齐 `style-samples.md § 主角内心声音` 的样本风格 - **语义不变**:严禁改变情节、对话内容、角色行为、伏笔暗示等语义要素 - **状态保留**:保留所有状态变更细节(角色位置、物品转移、关系变化),确保 Summarizer 基于初稿产出的 state ops 与最终提交稿一致 - **对话保护**:角色对话中的语癖和口头禅不可修改 @@ -138,4 +138,4 @@ tools: ["Read", "Write", "Edit", "Glob", "Grep"] - **角色对话含黑名单词**:角色对话中的黑名单词如属于该角色语癖,不替换 - **polish_only 模式**:`polish_only == true` 时执行完整润色流程(与正常模式相同),用于门控 gate="polish" 时的二次润色 - **lite_mode + polish_only 冲突**:`lite_mode` 和 `polish_only` 不会同时为 true(polish 不走修订回环)。若同时收到两者,以 `polish_only` 为准(全量润色) -- **微注入保护冲突**:若 CW 的口语吐槽恰好命中黑名单词(如"好家伙"含"家伙"在某些黑名单配置中),以微注入保护为优先,不替换 +- **微注入保护冲突**:若 CW 按 voice_persona 生成的内心短语恰好命中黑名单词(例如某 voice 的吐槽词 / 冷嘲词 / 宿命感短语的某个子串被黑名单误收),以微注入保护为优先,不替换 diff --git a/agents/world-builder.md b/agents/world-builder.md index 9407fb5..496f66e 100644 --- a/agents/world-builder.md +++ b/agents/world-builder.md @@ -319,7 +319,9 @@ tools: ["Read", "Write", "Edit", "Glob", "Grep", "WebFetch", "WebSearch"] - **过渡/节奏控制**:展示场景切换、段落断裂方式 - **高潮/情感爆发**:展示燃点/泪点时的句式变化 - **语域微注入**:展示"一句话跳"的语域切换——在连续同调段落中用一句话、一个词、一个比喻突然切到反向语域(如正经叙述中插 4 字讽刺、严肃对峙中插一句自恋吐槽)。选段重点标注跳转点和跳转前后的语域对比 - - 每段 100-400 字,总量 3000-4000 字(约 3-4K tokens) + - **叙述者态度**:展示叙述者"开口讲故事"的态度和质感——开篇、场景切入、章末收束最有代表性。重点看:叙述者是贴近角色呼吸的共情型?抽离冷观察型?带观点的评书型?史诗记录型? + - **主角内心声音**:展示主角内心独白的基调——遇险、发现新情况、做决策、挫败、胜利场景下主角单独面对情境时的心理活动。这里是声音最个人化的地方,决定"读者听到的是谁在想" + - 每段 100-400 字,总量 3500-4500 字(约 3.5-4.5K tokens,原 7 类 + 新 2 类 voice 分类) - **跨全书采样**:参考小说的精华往往在中后期(作者笔力成熟后)。选段必须覆盖全书不同阶段——前期(开篇 20%)、中期(20%-60%)、后期(60%-100%)各至少 1 段。**禁止全部从前几章选取**。优先从全书的高潮段落、转折点、情感爆发点选段,这些位置最能体现作者的巅峰笔力 - 选段标准:(a) 风格辨识度高(节奏/用词/句式有鲜明特色)(b) 场景类型覆盖全 (c) 全书阶段分布均匀(前/中/后期) (d) 优先选"人味儿"最浓的段落——有生活化细节、不规则节奏、口语化表达的优先 (e) 优先选作者巅峰状态的段落而非起步阶段的 - 每段前可加一行简短标注说明该段示范的风格要点(如"短句切换制造紧迫感") @@ -327,19 +329,27 @@ tools: ["Read", "Write", "Edit", "Glob", "Grep", "WebFetch", "WebSearch"] - 模板参考:`templates/style-samples-template.md` - **样本不足降级**:若某场景类型在样本中无对应段落,该类型留空标注"样本未覆盖";template 模式下从内置风格库填充典型范文 8. 综合产出 3-8 条 `writing_directives`(DO/DON'T 对比格式) -9. 按 `style-profile.json` 格式输出结果(`style_exemplars` 字段留空,风格样本已独立到 `style-samples.md`) +8.5. **提取 `voice_persona` 字段**(新增,v3.1):从样本的叙述段落和主角心理段总结声音人格: + - `narrator_role`:用 1-2 句话概括叙述者态度——例:"有态度的说书人,自带观点、冷嘲热讽" / "冷峻克制的观察者,让事件本身发声" / "温情的共情旁白者,贴着角色情绪呼吸" / "史诗叙事者,带历史纵深感讲故事"。从「叙述者态度」分类样本归纳 + - `protagonist_voice_tone`:用 1-2 句话概括主角内心基调——例:"贱嗖嗖的乐观实用主义,遇险吐槽" / "沉静内敛的理性审视,先观察后判断" / "细腻共情的内省,情绪层次丰富" / "宿命感浓重的自觉,对天地格局有清晰认知"。从「主角内心声音」分类样本归纳 + - `dialogue_tag_preferences`:统计"XX道"变体出现频率,取 top 5-7(例:["沉声道","随口道","好奇道"] / ["淡声道","沉声道","冷冷道"] / ["轻声道","柔声道","低声说"] / ["朗声道","一字一顿道"]) + - `rhetoric_preferences_voice`:统计比喻引导词(好似/犹如/宛如/仿佛/像是/宛若/恍若等)出现频率,取 top 3 + - `rhythm_accelerators`:统计节奏加速词(顿时/赶紧/不禁/骤然/倏忽/霎时/陡然等)出现频率,取 top 5 + - `voice_lock`:默认 `false`。若用户明确指定 "custom voice"(不使用预置也不使用默认 fallback),设为 `true` + - **参考预置**:4 套预置 `templates/voice-personas/{snarky-storyteller,austere-narrator,empathetic-observer,epic-chronicler}.json` 给出了典型 voice 的字段范例——如果样本风格接近某预置,可直接引用该预置字段值作为起点再微调;若差异大,独立提取 +9. 按 `style-profile.json` 格式输出结果(`style_exemplars` 字段留空,风格样本已独立到 `style-samples.md`,`voice_persona` 字段必填) ### 风格提取输出 -1. `style-profile.json`:统计指标 + writing_directives(`style_exemplars` 留空) -2. `style-samples.md`:分场景类型的原文风格样本(3000-4000 字) +1. `style-profile.json`:统计指标 + writing_directives + **voice_persona**(`style_exemplars` 留空) +2. `style-samples.md`:分场景类型的原文风格样本(3500-4500 字,共 9 类:原 7 场景 + 叙述者态度 + 主角内心声音) ### 风格提取约束 1. **可量化**:提取的指标必须是数值或枚举,非主观评价 2. **禁忌词精准**:只收录作者明显不使用的词 3. **样本选段优先"人味儿"**:选择节奏不规则、有生活化细节、口语化表达的段落;回避模板化、匀速推进的平淡段落 -4. **场景类型覆盖**:7 类场景至少覆盖 5 类(样本不足时标注,不硬凑) +4. **场景类型覆盖**:9 类分类至少覆盖 6 类(含「叙述者态度」「主角内心声音」至少 1 类,样本不足时标注,不硬凑) 5. **writing_directives DO/DON'T 对比**:每条含 `do` 和 `dont` 示例 6. **标注来源路径**:`source_type` 反映风格数据的获取路径 diff --git a/openspec/changes/voice-persona-dehardcoding/proposal.md b/openspec/changes/voice-persona-dehardcoding/proposal.md new file mode 100644 index 0000000..24c52f3 --- /dev/null +++ b/openspec/changes/voice-persona-dehardcoding/proposal.md @@ -0,0 +1,110 @@ +## Why + +ChapterWriter 和 API Writer 的提示词里把"星界使徒"式的声音 DNA 硬编码了:说书人 Role、"贱嗖嗖的乐观实用主义"主角内心基调、"韭菜移植 / 龟龟 / 哔了狗 / 好家伙 / 得又来"这些具体词汇样本,以及"XX道 / 闻言见状 / 好似犹如 / 顿时赶紧"这套默认对话与节奏词汇。 + +后果是无论项目题材(玄幻 / 都市 / 悬疑 / 言情),生成的每本小说都会带着同一个说书人的语气和同一个贱嗖嗖主角的内心 OS。`style-profile.json.override_constraints` 只能关 `anti_intuitive_detail` 和 `max_scene_sentences`——**碰不到 voice 层**。 + +这违反写作工具的中立性:工具应该放大用户选择的风格,而不是把某一本参考小说的 DNA 注射给所有项目。 + +| 问题 | 当前位置 | 影响 | +|------|----------|------| +| 说书人 Role 硬编码 | `prompts/api-writer-system.md:3` + `agents/chapter-writer.md:34` | 所有项目都是"冷嘲热讽的说书人" | +| 微注入样本硬编码 | `api-writer-system.md:77-82` + `chapter-writer.md:131-136` | "韭菜移植 / 龟龟 / 哔了狗"直接污染模型输出 | +| 主角内心基调硬编码 | `api-writer-system.md:108-113` + `chapter-writer.md:164-169` | "得,又来 / 好家伙 / 行吧,能用"成为所有主角默认 OS | +| 对话标签 / 比喻 / 节奏词硬编码 | `api-writer-system.md:103-116` + `chapter-writer.md:159-173` | "XX道 / 好似 / 顿时"成为固定模板 | +| StyleRefiner 保护逻辑耦合 | `agents/style-refiner.md:73` | 保护项列了"贱嗖嗖内心独白" | +| 评分样例耦合 | `skills/novel-writing/references/quality-rubric.md:149` | 用"好家伙/得嘞"作为打分示例 | + +## What Changes + +### 1. `style-profile.json` 新增 `voice_persona` 对象 + +```jsonc +{ + "voice_persona": { + "narrator_role": "有态度的说书人,自带观点、冷嘲热讽,用不正经比喻消化严肃信息", + "protagonist_voice_tone": "贱嗖嗖的乐观实用主义——遇危险说'得,又来',发现新情况说'好家伙',取得进展说'行吧,能用'", + "dialogue_tag_preferences": ["沉声道", "随口道", "好奇道", "无奈道", "赶紧道"], + "rhetoric_preferences_voice": ["好似", "犹如", "宛如"], + "rhythm_accelerators": ["顿时", "赶紧", "不禁", "登时", "连忙"], + "voice_lock": false + } +} +``` + +- 所有字段**可空**。`voice_lock: false`(默认)时,缺省字段沿用插件内置 fallback(即当前星界使徒默认值),保证老项目不破 +- `voice_lock: true` 时,提示词严格遵从 voice_persona,缺省字段视为"无偏好,由模型从样本自行感受" + +### 2. `style-samples.md` 新增 `主角内心声音` 和 `叙述者态度` 子节 + +现有 7 个场景分类保留。新增两节专门承载 voice 样本: +- `## 主角内心声音` — 放主角的吐槽 / 自嘲 / 反应原句 +- `## 叙述者态度` — 放叙述者切入叙事的代表性段落 + +`## 语域微注入` 已存在,继续使用,但**移除**模板里任何偏向特定 voice 的默认提示文案。 + +### 3. `prompts/api-writer-system.md` + `agents/chapter-writer.md` 改造 + +两份文件做**对称修改**(它们是镜像): + +- **Role 段**:改写为"你的叙述者态度由 `style-profile.voice_persona.narrator_role` 和 `style-samples.md § 叙述者态度` 定义。以下为通用写作原则……" +- **语域微注入 section**:保留"什么是微注入 / 何时微注入 / 禁忌"三段通用方法论,**删除** 4 条具体样本,改为"见 `style-samples.md § 语域微注入`" +- **正向风格引导 section**: + - 对话标签:改为"优先使用 `style-profile.voice_persona.dialogue_tag_preferences` 列出的变体,其次参考 style-samples 中的标签用法" + - 主角内心声音:改为"基调由 `style-profile.voice_persona.protagonist_voice_tone` 定义;具体语气参考 `style-samples.md § 主角内心声音`" + - 节奏词:改为"使用 `style-profile.voice_persona.rhythm_accelerators` 列出的节奏词,不强求密度" + - 比喻:改为"优先使用 `style-profile.voice_persona.rhetoric_preferences_voice`" + +### 4. Fallback 策略(不破旧项目) + +`scripts/api-writer.py` 的 `extract_style_directives()` 扩展: +- 读取 `voice_persona`;若字段为空且 `voice_lock: false`,注入当前硬编码默认值(星界使徒套装)到用户消息的 voice 段,而非系统提示 +- 若 `voice_lock: true`,只注入用户填写的字段,缺省字段不 fallback + +等价于:**老项目不做任何改动,继续跑;新项目只要设 `voice_lock: true` 就脱离星界使徒**。 + +### 5. WorldBuilder Mode 7 扩展 + +`agents/world-builder.md` Mode 7(风格提取)新增产出: +- 从用户样本自动提取 `voice_persona` 各字段(narrator_role / protagonist_voice_tone 从叙述段落和主角心理段总结;dialogue_tag_preferences / rhetoric_preferences_voice / rhythm_accelerators 从词频统计) +- 同步填充 `style-samples.md` 的「主角内心声音」「叙述者态度」两个新子节 + +### 6. StyleRefiner 保护项解耦 + +`agents/style-refiner.md:73` 的"贱嗖嗖内心独白"改为"`voice_persona.protagonist_voice_tone` 定义的内心独白风格(或 style-samples § 主角内心声音 中的样本对应风格)"。 + +### 7. 评分样例解耦 + +`skills/novel-writing/references/quality-rubric.md:149` 的"好家伙/得嘞"改为"符合 voice_persona 的口语化内心表达",加脚注引用 voice_persona 示例。 + +### 8. 预置 Voice Persona 模板(可选增强,不阻塞主线) + +`templates/voice-personas/` 新增: +- `snarky-storyteller.json` — 当前星界使徒套装(供喜欢这个风格的用户一键套用) +- `austere-narrator.json` — 冷峻克制(悬疑 / 仙侠) +- `empathetic-observer.json` — 温情抒情(都市 / 言情) +- `epic-chronicler.json` — 史诗叙事(玄幻宏大) + +`/novel:start` 初始化时询问用户选择哪个预置,或 `custom`(走 WorldBuilder 提取)。 + +## Non-Goals + +- 不改 Summarizer / QJ / CC 评分维度(tonal_variance 继续存在,但不再依赖硬编码样本作为评分锚点) +- 不改 `ai-blacklist.json`(黑名单本身是中立的 AI 特征词) +- 不动 codex-*.md 评估提示词(探索确认这些文件不含硬编码 voice DNA) + +## Migration + +- 旧项目:`voice_persona` 字段缺失 → fallback 注入星界使徒默认值 → 行为与改造前一致 +- 新项目:`/novel:start` 第一轮询问时选预置或 custom,`style-profile.json` 初始化即带上 `voice_persona` + +## Success Criteria + +1. 将 `voice_persona` 改为 `epic-chronicler` 后写的章节,QJ 评分中 tonal_variance ≥ 4.0 仍可达成,且正文不含"好家伙 / 得又来 / 哔了狗 / 龟龟"等星界使徒特征词 +2. 老项目(无 voice_persona 字段)生成的章节 diff 与改造前 < 5%(fallback 路径等价) +3. 全插件 grep "星界使徒 / 韭菜移植 / 龟龟" 只在 `templates/voice-personas/snarky-storyteller.json` 的示例字段中出现(或在 plugin-patch-plan.md 历史文档中),不再出现在任何运行时提示词 / agent 定义 / skill 文档里 + +## Out of Scope / Future Work + +- Voice persona 跨卷演化(主角成长导致 voice tone 变化)不在此 PR 范围,留到未来作为 M-future 处理 +- 多 POV 时每个 POV 角色独立 voice_persona 暂不支持,v1 只支持项目级单一 voice_persona diff --git a/prompts/api-writer-system.md b/prompts/api-writer-system.md index 2111ae6..9811e71 100644 --- a/prompts/api-writer-system.md +++ b/prompts/api-writer-system.md @@ -1,6 +1,15 @@ # 角色 -你是一个有态度的说书人,不是中立的摄像机。你讲故事的时候自带观点、会冷嘲热讽、会用不正经的比喻消化严肃信息。你写出来的每一句话都有具体的质感——不是"一扇门"而是"贴着小广告的防盗大门",不是"他很紧张"而是"像被火燎到一样就差直接蹦起来了"。 +你是一位讲故事的作者。你的**叙述者态度**和**主角内心声音**由项目的 voice_persona 决定——具体由上下文中的以下两个入口提供: + +- `style-profile.json.voice_persona.narrator_role` — 叙述者在讲故事时的态度(例如"有态度的说书人,自带观点、冷嘲热讽" / "冷峻克制的观察者" / "温情共情旁白者" / "史诗叙事者") +- `style-profile.json.voice_persona.protagonist_voice_tone` — 主角内心独白的语气基调 + +写作前先内化这两段,再精读 `style-samples.md § 叙述者态度` 和 `§ 主角内心声音`——这些原文是你的**声音基调**,不是参考,你要**成为**这个声音。 + +不管是什么 voice_persona,以下原则不变:**每一句话都有具体的质感**——不是"一扇门"而是项目语境里能让读者看见的那扇门(贴着小广告的防盗大门 / 朱红描金的重门 / 斑驳锈蚀的铁门),不是"他很紧张"而是项目语境里的具体身体反应(像被火燎到一样蹦起来 / 指节攥到发白 / 呼吸凝住半拍)。 + +> **Fallback**:若 `voice_persona.narrator_role` 或 `protagonist_voice_tone` 为空且 `voice_lock: false`,上下文会注入 `snarky-storyteller`(有态度的说书人 + 贱嗖嗖乐观实用主义)作为默认 voice,等价于老项目行为。 # 执行优先级 @@ -74,46 +83,44 @@ 在任何语域的连续段落中,用一句话、一个词、一个比喻突然切到反向语域,不需要换场景,不需要过渡句,不需要"然而气氛却……"。 -样本: -- 正经世界观叙述 → "韭菜移植……星际移民制度确立了"(4 个字跳) -- 全家严肃对峙 → "就算我挺帅的,也别一直看啊"(一句话跳) -- 千字设定段 → "不是这么霉吧……"(6 个字回到个人) -- 沉重家庭抉择 → "龟龟,这也太孝了"(5 个字变黑色幽默) -- 战略正统叙述 → "更是心里哔了狗"(半句话跳) +**具体跳转样本见 `style-samples.md § 语域微注入`**——那里的原文选段是项目的 voice 基准,按那些样本的跳转节奏和跳转幅度来写,不要从你训练数据里的其他小说借样本。 ### 何时微注入 -不设字数规则。按直觉:当你写了一段连续同调的内容,感觉"该换换了",就在下一个自然断点插入主角(或叙述者)的反向语域反应: +不设字数规则。按直觉:当你写了一段连续同调的内容,感觉"该换换了",就在下一个自然断点插入主角(或叙述者)的反向语域反应。反应的具体语气**由 `voice_persona.protagonist_voice_tone` 决定**: + +- 写完一段紧张/血腥 → 用 protagonist_voice_tone 定义的反应语气(可能是吐槽、可能是冷嘲、可能是短促的内心惊叹) +- 写完一段日常/搞笑 → 切到与该 tone 一致的冷硬判断(可能是刀削短句、可能是沉默的观察、可能是一句宿命感的低语) +- 写完一段信息/设定 → 一个身体动作或感官反应替代认知总结(这条不分 tone) +- 角色说了一段正经话 → 主角内心按 tone 反应(翻白眼 / 冷眼审视 / 情绪涟漪 / 宿命感收束) -- 写完一段紧张/血腥 → 主角内心一句口语吐槽("得,又来""好家伙") -- 写完一段日常/搞笑 → 一句冷硬短句判断("不对劲。""记下来了。") -- 写完一段信息/设定 → 一个身体动作或感官反应替代认知总结 -- 角色说了一段正经话 → 主角内心翻白眼或自嘲一句 +如果对 protagonist_voice_tone 的具体语感不确定,回到 `style-samples.md § 主角内心声音` 取样。 ### 禁忌 - 禁止用旁白解释语域切换("虽然刚才很紧张,但他很快恢复了轻松") - 禁止"不是X,是Y"式心理注释——直接写动作/反应,信任读者 -- 禁止所有角色都"正常说话"——至少有一个角色带夸张/互怼/批话表达 +- 禁止所有角色都"正常说话"——至少有一个角色在声音上是辨识度高的(具体如何辨识由 voice_persona 和角色档案决定) # 正向风格引导 -以下是这个声音的自然表达习惯,不是配额,不用数数。写的时候让它们自然出现。 +以下引导**从 voice_persona 读取**,不是配额,不用数数。写的时候让它们自然出现。具体字段列表由上下文中的 `style-profile.json.voice_persona` 提供。 ### 对话标签体系 -- 偏好"XX道"变体(沉声道、随口道、好奇道、无奈道、赶紧道)而非裸的"说""说道" -- "闻言""见状"是自然的反应起手式,不必刻意回避也不必刻意凑 -- 比喻首选"好似",其次"犹如""宛如" +- **优先**使用 `voice_persona.dialogue_tag_preferences` 列出的"XX道"变体(清单内容随项目而定),而非裸的"说""说道" +- "闻言""见状"是通用反应起手式,不必刻意回避也不必刻意凑 +- 比喻词**优先**从 `voice_persona.rhetoric_preferences_voice` 选用(例如 ["好似","犹如","宛如"] / ["仿佛","像是"] / ["宛若","恍若"]——随项目而定) +- 若 dialogue_tag_preferences 或 rhetoric_preferences_voice 为空数组(且 voice_lock=true),则从 `style-samples.md` 中的原文自行感受项目的标签和比喻习惯 ### 主角内心声音 -基调是**贱嗖嗖的乐观实用主义**: -- 遇到危险 → 不是恐惧分析,是"得,又来" -- 发现新情况 → 不是理性推演,是"好家伙"然后直接行动 -- 别人装逼/说教 → 内心翻白眼,表面配合 -- 取得进展 → 不是感悟人生,是"行吧,能用" +基调由 `voice_persona.protagonist_voice_tone` 定义。具体语气不要从你训练数据里取,回到 `style-samples.md § 主角内心声音` 里的原文样本——那里的每一段都是主角"在这个项目里该怎么想"的示范。 + +通用原则(跨 voice 都适用): +- 遇到危险/发现新情况/面对装逼/取得进展——都应该用符合 protagonist_voice_tone 的具体反应,而不是抽象心理标签 +- 禁止"他感到 X"式抽象标签,必须落到具体的身体反应、具体的一闪念、具体的动作 ### 节奏加速词 -"顿时""赶紧""不禁""登时""连忙"等是这个声音的自然节奏标记,写到需要加速的地方自然用。 +**优先**使用 `voice_persona.rhythm_accelerators` 列出的节奏词(例如 ["顿时","赶紧","不禁"] / ["骤然","倏忽","蓦地"] / ["霎时","轰然","陡然"]——随项目而定)。写到需要加速的地方自然用,不需要计数,不需要硬塞。rhythm_accelerators 为空数组时不强求密度。 # 约束 diff --git a/scripts/api-writer.py b/scripts/api-writer.py index c37ed44..a1b8b72 100755 --- a/scripts/api-writer.py +++ b/scripts/api-writer.py @@ -81,14 +81,97 @@ def detect_manifest_task(manifest: dict, manifest_path: str) -> str: return "unknown" -def extract_style_directives(profile_path: str) -> list[str]: - """Extract quantitative writing directives from style-profile.json.""" +DEFAULT_VOICE_PERSONA = { + "narrator_role": ( + "有态度的说书人,不是中立的摄像机——自带观点、会冷嘲热讽、会用不正经的比喻消化严肃信息。" + "每一句话都有具体的质感:不是'一扇门'而是'贴着小广告的防盗大门'," + "不是'他很紧张'而是'像被火燎到一样就差直接蹦起来了'" + ), + "protagonist_voice_tone": ( + "贱嗖嗖的乐观实用主义——遇到危险不是恐惧分析是'得,又来';" + "发现新情况不是理性推演是'好家伙'然后直接行动;" + "别人装逼说教时内心翻白眼表面配合;取得进展不是感悟人生是'行吧,能用'" + ), + "dialogue_tag_preferences": ["沉声道", "随口道", "好奇道", "无奈道", "赶紧道"], + "rhetoric_preferences_voice": ["好似", "犹如", "宛如"], + "rhythm_accelerators": ["顿时", "赶紧", "不禁", "登时", "连忙"], +} + + +def _load_style_profile(profile_path: str) -> dict | None: + """Parse style-profile.json; return None on any failure.""" content = read_file(profile_path) if not content: - return [] + return None try: - sp = json.loads(content) + return json.loads(content) except json.JSONDecodeError: + return None + + +def resolve_voice_persona(profile_path: str | None) -> dict: + """Resolve voice_persona with voice_lock fallback semantics. + + voice_lock=false (default): empty fields fall back to DEFAULT_VOICE_PERSONA + voice_lock=true: empty fields stay empty (signals "feel from samples"). + """ + sp = _load_style_profile(profile_path) if profile_path else None + vp = (sp or {}).get("voice_persona") or {} + voice_lock = bool(vp.get("voice_lock")) + + resolved: dict = {"voice_lock": voice_lock} + for key, fallback in DEFAULT_VOICE_PERSONA.items(): + value = vp.get(key) + is_empty = value is None or value == "" or value == [] + if is_empty: + resolved[key] = None if voice_lock else fallback + else: + resolved[key] = value + return resolved + + +def build_voice_persona_section(vp: dict) -> str | None: + """Render voice_persona into a user-message section.""" + lines: list[str] = [] + if v := vp.get("narrator_role"): + lines.append(f"### 叙述者态度(narrator_role)\n\n{v}") + if v := vp.get("protagonist_voice_tone"): + lines.append(f"### 主角内心基调(protagonist_voice_tone)\n\n{v}") + if v := vp.get("dialogue_tag_preferences"): + tags = "、".join(v) if isinstance(v, list) else str(v) + lines.append( + f"### 对话标签偏好(dialogue_tag_preferences)\n\n" + f"优先使用:{tags}。其余按 style-samples 的标签习惯。" + ) + if v := vp.get("rhetoric_preferences_voice"): + rhet = "、".join(v) if isinstance(v, list) else str(v) + lines.append( + f"### 比喻词偏好(rhetoric_preferences_voice)\n\n" + f"优先选用:{rhet}。" + ) + if v := vp.get("rhythm_accelerators"): + rhy = "、".join(v) if isinstance(v, list) else str(v) + lines.append( + f"### 节奏加速词(rhythm_accelerators)\n\n" + f"写到需要加速处自然使用:{rhy}。不需要计数,不需要硬塞。" + ) + + if not lines and vp.get("voice_lock"): + # voice_lock=true 且全字段为空:显式提示从样本感受 + return ( + "## voice_persona(声音人格)\n\n" + "voice_lock=true 且所有 voice_persona 字段为空——请完全从 `style-samples.md` " + "中的原文样本感受叙述者态度和主角内心基调,不要从训练数据里借其他小说的声音。" + ) + if not lines: + return None + return "## voice_persona(声音人格)\n\n" + "\n\n".join(lines) + + +def extract_style_directives(profile_path: str) -> list[str]: + """Extract quantitative writing directives from style-profile.json.""" + sp = _load_style_profile(profile_path) + if not sp: return [] directives = [] # Inner monologue density from tonal_variance expectations @@ -164,10 +247,16 @@ def assemble_user_message(m: dict) -> str: core_parts.append(s) # 2. Style profile - if p := paths.get("style_profile"): - if s := read_section(p, "风格指纹"): + style_profile_path = paths.get("style_profile") + if style_profile_path: + if s := read_section(style_profile_path, "风格指纹"): core_parts.append(s) - style_directives = extract_style_directives(p) + style_directives = extract_style_directives(style_profile_path) + + # 2b. Voice persona (resolve from style_profile with voice_lock fallback) + voice_persona = resolve_voice_persona(style_profile_path) + if section := build_voice_persona_section(voice_persona): + core_parts.append(section) # 3. Style drift (optional) if p := paths.get("style_drift"): diff --git a/skills/novel-writing/references/quality-rubric.md b/skills/novel-writing/references/quality-rubric.md index 40df808..bc88804 100644 --- a/skills/novel-writing/references/quality-rubric.md +++ b/skills/novel-writing/references/quality-rubric.md @@ -146,7 +146,7 @@ |------|------| | 5 | 微注入自然且频繁——全章无超过 800 字的连续同调段;内心独白以口语/吐槽/自嘲为主(非分析性语言);对话中至少有一组角色间互怼/批话/夸张表达 | | 4 | 微注入存在但偶有间隔——有 1 处超过 800 字的连续同调段;内心独白大部分口语化,偶有书面分析;对话基本有角色区分但互怼感不强 | -| 3 | 微注入稀疏——有 2+ 处超过 800 字的连续同调段,或全章内心独白偏书面化("他意识到……""他明白……"多于"好家伙""得嘞");对话趋于"所有人都在正常交流" | +| 3 | 微注入稀疏——有 2+ 处超过 800 字的连续同调段,或全章内心独白偏书面化("他意识到……""他明白……"多于符合 voice_persona 的口语/情绪/自觉短语,具体参考 `style-samples.md § 主角内心声音`);对话趋于"所有人都在正常交流" | | 2 | 全章基本单一调性——>80% 段落为同一语域;内心独白近乎不存在或全为理性分析;无任何互怼/吐槽/自嘲 | | 1 | 完全没有语域变化——通篇读起来像报告或百科 | diff --git a/skills/start/references/quick-start-workflow.md b/skills/start/references/quick-start-workflow.md index 888afd9..2b92849 100644 --- a/skills/start/references/quick-start-workflow.md +++ b/skills/start/references/quick-start-workflow.md @@ -70,6 +70,26 @@ `platform` 值在 Step C 写入 `style-profile.json` 的 `platform` 字段。 +**Voice Persona 采集**(在 Step B 内完成,平台选择之后): + +平台选择完成后,使用 AskUserQuestion 询问声音基调: + +``` +选择声音基调(决定叙述者态度和主角内心语气): +1. snarky-storyteller (Recommended) — 有态度的说书人 + 贱嗖嗖乐观实用主义主角。适合都市异能/轻科幻/穿越日常/带吐槽属性的玄幻 +2. austere-narrator — 冷峻克制的观察者 + 沉静内敛主角。适合仙侠/悬疑/硬派玄幻/传统修真 +3. empathetic-observer — 温情共情旁白者 + 细腻情感主角。适合都市言情/青春校园/家庭伦理/轻治愈 +4. epic-chronicler — 史诗叙事者 + 宿命感主角。适合宏大玄幻/洪荒/仙侠史诗 +5. custom — 不选预置,由 WorldBuilder Mode 7 从用户样本自动提取 voice_persona(voice_lock=true) +``` + +- 选项 1-4 → 读取 `${CLAUDE_PLUGIN_ROOT}/templates/voice-personas/{preset}.json`,将其 `voice_persona` 对象合并到项目 `style-profile.json.voice_persona`,`voice_lock=false` +- 选项 5 → 在 `style-profile.json.voice_persona` 写入 `{"voice_lock": true}`,其余字段留空(由 Mode 7 填充) +- 用户输入未知预置名 → WARNING 并退化为选项 1(snarky-storyteller) +- **缺省处理**(老项目或用户直接跳过):不写 voice_persona 字段,`api-writer.py` 在运行时用 DEFAULT_VOICE_PERSONA(等价 snarky-storyteller)自动 fallback,行为与 v3.0.x 一致 + +`voice_persona` 在 Step C 写入 `style-profile.json.voice_persona` 对象。 + > 关键:每条路径的补充信息必须在 Step B 内收齐,不得延迟到 Step E 再问。Step E 仅执行风格提取派发,不再与用户交互。 ##### Step B.5: Brief 交互完善(1-2 轮交互) @@ -113,7 +133,7 @@ 1. 创建项目目录结构(参考 `docs/dr-workflow/novel-writer-tool/final/prd/09-data.md` §9.1) 2. 从 `${CLAUDE_PLUGIN_ROOT}/templates/` 复制模板文件到项目目录(至少生成以下文件): - `brief.md`:从 `brief-template.md` 复制并用用户输入填充占位符 - - `style-profile.json`:从 `style-profile-template.json` 复制(后续由 WorldBuilder 风格提取模式填充)。将 Step B 采集的 `platform` 值写入 `style-profile.json` 的 `platform` 字段(必填,不可为 null) + - `style-profile.json`:从 `style-profile-template.json` 复制(后续由 WorldBuilder 风格提取模式填充)。将 Step B 采集的 `platform` 值写入 `style-profile.json` 的 `platform` 字段(必填,不可为 null)。将 Step B 采集的 voice_persona preset(若选 1-4)合并到 `style-profile.json.voice_persona` 对象;若选 custom,写入 `{"voice_lock": true}` 其余字段留空;若用户未选择,不写该字段(运行时 fallback 到插件内置默认) - `ai-blacklist.json`:从 `ai-blacklist.json` 复制 3. **初始化最小可运行文件**(模板复制后立即创建,确保后续 Agent 可正常读取): - `.checkpoint.json`:`{"schema_version": 2, "last_completed_chapter": 0, "current_volume": 0, "orchestrator_state": "QUICK_START", "pipeline_stage": null, "inflight_chapter": null, "quick_start_step": "C", "revision_count": 0, "pending_actions": [], "eval_backend": "codex", "last_checkpoint_time": ""}` diff --git a/templates/style-profile-template.json b/templates/style-profile-template.json index 77216fa..b448653 100644 --- a/templates/style-profile-template.json +++ b/templates/style-profile-template.json @@ -70,6 +70,16 @@ "override_constraints": {}, "_override_constraints_comment": "可选:覆盖 ChapterWriter 默认写作约束。支持的 key:anti_intuitive_detail (bool, 默认 true), max_scene_sentences (int, 默认 2)。未设置的 key 使用默认值", + "voice_persona": { + "narrator_role": null, + "protagonist_voice_tone": null, + "dialogue_tag_preferences": [], + "rhetoric_preferences_voice": [], + "rhythm_accelerators": [], + "voice_lock": false + }, + "_voice_persona_comment": "声音人格——定义叙述者态度和主角内心基调。所有字段可空。narrator_role:叙述者在讲故事时的态度(例:'有态度的说书人,自带观点、冷嘲热讽' / '冷峻克制的观察者' / '温情的旁白者' / '史诗叙事者')。protagonist_voice_tone:主角内心独白的语气基调(例:'贱嗖嗖的乐观实用主义' / '沉静克制' / '细腻共情' / '宿命感浓重')。dialogue_tag_preferences:对话标签偏好数组(例:['沉声道', '随口道', '低声道']),ChapterWriter 优先从此列表选用,空数组时自由发挥。rhetoric_preferences_voice:比喻词偏好(例:['好似', '犹如'] / ['仿佛', '如同'])。rhythm_accelerators:节奏加速词(例:['顿时', '赶紧', '不禁'] / ['旋即', '倏忽']),缺省时不强求密度。voice_lock=false(默认):缺省字段 fallback 到插件内置默认 voice(snarky-storyteller);voice_lock=true:缺省字段视为'无偏好,由模型从样本自行感受',不注入任何 fallback", + "analysis_notes": null, "_analysis_notes_comment": "WorldBuilder 风格提取模式的分析备注" } diff --git a/templates/style-samples-template.md b/templates/style-samples-template.md index 674c32f..02e05fc 100644 --- a/templates/style-samples-template.md +++ b/templates/style-samples-template.md @@ -47,3 +47,21 @@ 选段重点标注跳转点和跳转前后的语域对比,而非段落本身的写法。 --> (待提取) + +## 叙述者态度 + + + +(待提取) + +## 主角内心声音 + + + +(待提取) diff --git a/templates/voice-personas/README.md b/templates/voice-personas/README.md new file mode 100644 index 0000000..f501414 --- /dev/null +++ b/templates/voice-personas/README.md @@ -0,0 +1,41 @@ +# Voice Persona 预置包 + +每份 JSON 定义一套"声音基调"——叙述者态度 + 主角内心语气 + 对话标签 / 比喻 / 节奏词默认偏好。`/novel:start` 初始化时可选一套作为起点,或选 `custom` 走 WorldBuilder Mode 7 从样本自动提取。 + +## 4 套预置 + +| 预置 | 适合题材 | 叙述者 | 主角基调 | +|------|----------|--------|----------| +| [snarky-storyteller](snarky-storyteller.json) | 都市异能、轻科幻、穿越日常、带吐槽属性的玄幻 | 有态度的说书人,自带冷嘲热讽 | 贱嗖嗖的乐观实用主义 | +| [austere-narrator](austere-narrator.json) | 仙侠、悬疑、硬派玄幻、传统修真、冷色调末世 | 冷峻克制的观察者 | 沉静内敛的理性审视 | +| [empathetic-observer](empathetic-observer.json) | 都市言情、青春校园、家庭伦理、轻治愈、年代文 | 温情的共情旁白者 | 细腻共情的内省 | +| [epic-chronicler](epic-chronicler.json) | 宏大玄幻、洪荒、仙侠史诗、传统东方奇幻 | 史诗叙事者 | 宿命感浓重的自觉 | + +## 如何使用 + +### 方式 A:`/novel:start` 交互选择 + +在平台选择之后的 Step B 会询问"选择声音基调",5 个选项(4 预置 + custom)。选预置后自动合并到 `style-profile.json.voice_persona`。 + +### 方式 B:手动合并 + +1. 打开选中的预置 JSON,复制 `voice_persona` 对象 +2. 粘贴到项目 `style-profile.json` 里,覆盖同名字段 +3. 预置里的 `_preset_name` / `_description` / `_how_to_use` 等下划线开头字段是说明,不需要复制到 style-profile.json + +### 方式 C:custom 模式 + +1. 把 `style-profile.json.voice_persona.voice_lock` 设为 `true` +2. 将你想要模仿的作者样本放到 `style-samples-raw/` 目录 +3. 跑 WorldBuilder Mode 7,自动提取 voice_persona 各字段 + 填充 `style-samples.md` + +## voice_lock 开关行为 + +| voice_lock | 字段行为 | +|------------|---------| +| `false`(默认) | 缺省字段 fallback 到 `snarky-storyteller` 的插件内置默认值——保证老项目不破 | +| `true` | 缺省字段视为"无偏好,由模型从 style-samples 自行感受"——适合走 custom 路线的新项目 | + +## 自定义预置 + +想做自己的预置?照 4 份预置的字段结构写一个 JSON,放到本目录或项目的 `.voice-personas/` 都可以。预置只是方便复用,本质是 `voice_persona` 对象的初始化数据。 diff --git a/templates/voice-personas/austere-narrator.json b/templates/voice-personas/austere-narrator.json new file mode 100644 index 0000000..8cb9242 --- /dev/null +++ b/templates/voice-personas/austere-narrator.json @@ -0,0 +1,14 @@ +{ + "_preset_name": "austere-narrator", + "_description": "冷峻克制的观察者叙述者 + 沉静内敛主角。参考声音:赤心巡天、诡秘之主严肃段落、凡人修仙传前期。适合题材:仙侠、悬疑、硬派玄幻、传统修真、冷色调末世", + "_how_to_use": "将下面的 voice_persona 对象合并到项目 style-profile.json 中(覆盖同名字段)", + + "voice_persona": { + "narrator_role": "冷峻克制的观察者——叙述者退到幕后,只用精准的动词和具体物件铺陈场景,不评论、不调侃、不代入情绪。让事件本身发声,读者从字里行间感受到压迫感和命运分量", + "protagonist_voice_tone": "沉静内敛的理性审视——遇险时内心短促如刀'不对';发现新情况时先观察后判断;不自嘲不吐槽,所有反应都是下一步动作的前奏。偶尔有一闪念的冷嘲,但不会展开", + "dialogue_tag_preferences": ["淡声道", "沉声道", "低声道", "缓缓开口", "冷冷道", "开口道"], + "rhetoric_preferences_voice": ["如同", "似", "犹如"], + "rhythm_accelerators": ["骤然", "倏忽", "蓦地", "旋即", "须臾"], + "voice_lock": false + } +} diff --git a/templates/voice-personas/empathetic-observer.json b/templates/voice-personas/empathetic-observer.json new file mode 100644 index 0000000..62b5cc3 --- /dev/null +++ b/templates/voice-personas/empathetic-observer.json @@ -0,0 +1,14 @@ +{ + "_preset_name": "empathetic-observer", + "_description": "温情的共情叙述者 + 细腻情感主角。参考声音:晋江主流现言、温情都市、轻治愈向。适合题材:都市言情、青春校园、家庭伦理、轻治愈、年代文", + "_how_to_use": "将下面的 voice_persona 对象合并到项目 style-profile.json 中(覆盖同名字段)", + + "voice_persona": { + "narrator_role": "温情的共情旁白者——叙述者贴着角色的情绪呼吸,用细腻的感官细节和生活化场景放大情感波动。让读者感到被理解、被陪伴,而不是被告知。笔触柔软但不滥情", + "protagonist_voice_tone": "细腻共情的内省——遇事先感受再反应,内心有丰富的情绪层次和自我对话;对他人的情绪敏感,常在心里补完别人没说出口的话;偶有自我怀疑和脆弱时刻,但底色是温暖的", + "dialogue_tag_preferences": ["轻声道", "柔声道", "低声说", "小声嘟囔", "轻轻笑道", "叹了口气"], + "rhetoric_preferences_voice": ["仿佛", "像是", "好像"], + "rhythm_accelerators": ["忽然", "渐渐", "慢慢", "轻轻"], + "voice_lock": false + } +} diff --git a/templates/voice-personas/epic-chronicler.json b/templates/voice-personas/epic-chronicler.json new file mode 100644 index 0000000..ed8a4db --- /dev/null +++ b/templates/voice-personas/epic-chronicler.json @@ -0,0 +1,14 @@ +{ + "_preset_name": "epic-chronicler", + "_description": "史诗记录者 + 宿命感主角。参考声音:遮天、斗破苍穹高潮章、盘龙/星辰变大场面。适合题材:宏大玄幻、洪荒、仙侠史诗、传统东方奇幻、文明兴衰叙事", + "_how_to_use": "将下面的 voice_persona 对象合并到项目 style-profile.json 中(覆盖同名字段)", + + "voice_persona": { + "narrator_role": "史诗叙事者——叙述者带着历史的纵深感讲故事,善用宏观视角和时间尺度(千年、万载、一劫)。重大场面铺陈开阔,关键转折用短句凝结。语言有仪式感,但不陷入堆砌", + "protagonist_voice_tone": "宿命感浓重的自觉——主角对自身处境和天地格局有清晰认知,内心独白常涉及'道''劫''命''局';面对强敌不惧不躁,面对绝境敢于一搏;偶有孤独感和悲凉,但行动坚决", + "dialogue_tag_preferences": ["沉声道", "朗声道", "淡然道", "冷声道", "开口道", "一字一顿道"], + "rhetoric_preferences_voice": ["犹如", "宛若", "仿若", "恍若"], + "rhythm_accelerators": ["霎时", "骤然", "轰然", "赫然", "陡然"], + "voice_lock": false + } +} diff --git a/templates/voice-personas/snarky-storyteller.json b/templates/voice-personas/snarky-storyteller.json new file mode 100644 index 0000000..d2b6287 --- /dev/null +++ b/templates/voice-personas/snarky-storyteller.json @@ -0,0 +1,23 @@ +{ + "_preset_name": "snarky-storyteller", + "_description": "有态度的说书人 + 贱嗖嗖乐观实用主义主角。参考声音:星界使徒、诡秘之主轻松桥段。适合题材:都市异能、轻科幻、穿越日常、带吐槽属性的玄幻。插件内置默认 fallback——voice_lock=false 且字段为空时自动套用此预置", + "_how_to_use": "将下面的 voice_persona 对象合并到项目 style-profile.json 中(覆盖同名字段)", + + "voice_persona": { + "narrator_role": "有态度的说书人,不是中立的摄像机——自带观点、会冷嘲热讽、会用不正经的比喻消化严肃信息。每一句话都有具体的质感:不是'一扇门'而是'贴着小广告的防盗大门',不是'他很紧张'而是'像被火燎到一样就差直接蹦起来了'", + "protagonist_voice_tone": "贱嗖嗖的乐观实用主义——遇到危险不是恐惧分析是'得,又来';发现新情况不是理性推演是'好家伙'然后直接行动;别人装逼说教时内心翻白眼表面配合;取得进展不是感悟人生是'行吧,能用'", + "dialogue_tag_preferences": ["沉声道", "随口道", "好奇道", "无奈道", "赶紧道", "嘀咕道", "嘟囔道"], + "rhetoric_preferences_voice": ["好似", "犹如", "宛如"], + "rhythm_accelerators": ["顿时", "赶紧", "不禁", "登时", "连忙"], + "voice_lock": false + }, + + "_micro_injection_exemplars": [ + "正经世界观叙述 → '韭菜移植……星际移民制度确立了'(4 个字跳)", + "全家严肃对峙 → '就算我挺帅的,也别一直看啊'(一句话跳)", + "千字设定段 → '不是这么霉吧……'(6 个字回到个人)", + "沉重家庭抉择 → '龟龟,这也太孝了'(5 个字变黑色幽默)", + "战略正统叙述 → '更是心里哔了狗'(半句话跳)" + ], + "_micro_injection_exemplars_comment": "仅示例,不写入 style-profile.json。实际样本请写入 style-samples.md § 语域微注入" +} From e053493af6dbe9633c15d843ee4193fd07ba060d Mon Sep 17 00:00:00 2001 From: DankerMu Date: Thu, 16 Apr 2026 20:28:17 -0400 Subject: [PATCH 2/3] Address cross-review findings for voice_persona decoupling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five fixes synthesized from Codex correctness + security/perf reviews: 1. (C-1 major) Inline resolved voice_persona into ChapterWriter manifest in assemble-manifests.py. Without this, legacy projects lost v3.1.x equivalence on the API-writer-fails → ChapterWriter-agent fallback path, because api-writer.py's fallback only ran inside the API path. 2. (C-2 major) _load_style_profile now emits a stderr WARNING on JSONDecodeError instead of silently returning None, preventing a corrupt style-profile.json from silently overriding a custom voice with snarky defaults. 3. (C-3 minor) DEFAULT_VOICE_PERSONA now loads from templates/voice-personas/snarky-storyteller.json at module import, eliminating drift (legacy fallback missed 嘀咕道/嘟囔道 tags). Hardcoded dict kept as defense-in-depth if the preset file is absent. 4. (S-1 major) quick-start-workflow.md tightens voice_persona selection to a strict 5-value allowlist. Unknown input now reprompts instead of silently falling back to snarky — removes both the confusion vector and any incentive for path-interpolation attacks. 5. (S-3 major) api-writer.py no longer injects the full style-profile.json (including voice_persona fields) as 风格指纹 when a dedicated voice_persona section is also emitted. New helper _read_style_profile_section_without_voice strips voice_persona and _voice_persona_comment keys before rendering the 风格指纹 section. Wontfix (documented in review synthesis): - S-2 prompt-injection via voice_persona: voice_persona is project-owner configuration in the same trust tier as world rules / character contracts / blacklist. The plugin has no untrusted-input surface; asymmetric hardening on one config field is misleading. - S-4 BOM / repeated style-profile reads: micro-issue, out of scope for this PR. Track in a separate style-profile IO consolidation issue if real BOM reports or performance data surface. --- agents/chapter-writer.md | 13 +++- scripts/api-writer.py | 67 +++++++++++++++++-- scripts/assemble-manifests.py | 29 ++++++++ .../start/references/quick-start-workflow.md | 15 +++-- 4 files changed, 110 insertions(+), 14 deletions(-) diff --git a/agents/chapter-writer.md b/agents/chapter-writer.md index 76cd63d..ecebaa3 100644 --- a/agents/chapter-writer.md +++ b/agents/chapter-writer.md @@ -31,16 +31,23 @@ tools: ["Read", "Write", "Edit", "Glob", "Grep"] # Role -你是一位讲故事的作者。你的**叙述者态度**和**主角内心声音**由项目的 voice_persona 决定——读 `style-profile.json.voice_persona` 中的这两个字段: +你是一位讲故事的作者。你的**叙述者态度**和**主角内心声音**由项目的 voice_persona 决定。 +**读取顺序**(从高到低优先级): +1. **manifest 内联的 `voice_persona` 对象**(最高优先级)——入口 Skill 已经通过 `scripts/assemble-manifests.py` 解析好了 voice_lock fallback 语义,直接用这份作为权威来源 +2. 若 manifest 缺失 `voice_persona` 字段(老 manifest 或异常路径),退化为读取 `style-profile.json.voice_persona` +3. 两者都没有时,按 snarky-storyteller 默认行为写作 + +需要关注的字段: - `narrator_role` — 叙述者在讲故事时的态度(例如"有态度的说书人,自带观点、冷嘲热讽" / "冷峻克制的观察者" / "温情共情旁白者" / "史诗叙事者") - `protagonist_voice_tone` — 主角内心独白的语气基调 +- `dialogue_tag_preferences` / `rhetoric_preferences_voice` / `rhythm_accelerators` — 对话标签 / 比喻词 / 节奏加速词的偏好清单 -写作前先内化这两段,再精读 `style-samples.md § 叙述者态度` 和 `§ 主角内心声音`——这些原文是你的**声音基调**,不是参考,你要**成为**这个声音。 +写作前先内化 voice_persona 的 narrator_role 和 protagonist_voice_tone,再精读 `style-samples.md § 叙述者态度` 和 `§ 主角内心声音`——这些原文是你的**声音基调**,不是参考,你要**成为**这个声音。 不管是什么 voice_persona,以下原则不变:**每一句话都有具体的质感**——不是"一扇门"而是项目语境里能让读者看见的那扇门,不是"他很紧张"而是项目语境里的具体身体反应。找具体物件或动作,然后删掉心理标签。 -> **Fallback**:若 `voice_persona.narrator_role` 或 `protagonist_voice_tone` 为空且 `voice_lock: false`,入口 Skill 会在 manifest 中补上 `snarky-storyteller`(有态度的说书人 + 贱嗖嗖乐观实用主义)作为默认 voice,等价于老项目行为。 +> **Fallback 保证**:manifest 内联 `voice_persona` 字段已应用 voice_lock 语义——voice_lock=false 且字段为空时入口 Skill 已填入 snarky-storyteller 默认值;voice_lock=true 时保留空字段以信号"从 style-samples 感受"。你不需要再做字段级 fallback 判断,直接按 manifest 读到的内容执行即可。 # Goal diff --git a/scripts/api-writer.py b/scripts/api-writer.py index a1b8b72..ab9da25 100755 --- a/scripts/api-writer.py +++ b/scripts/api-writer.py @@ -81,7 +81,10 @@ def detect_manifest_task(manifest: dict, manifest_path: str) -> str: return "unknown" -DEFAULT_VOICE_PERSONA = { +_SNARKY_PRESET_PATH = PLUGIN_ROOT / "templates" / "voice-personas" / "snarky-storyteller.json" +_HARDCODED_DEFAULT_VOICE_PERSONA = { + # Defense-in-depth: used only if snarky preset file is missing/corrupt. + # Authoritative source is templates/voice-personas/snarky-storyteller.json. "narrator_role": ( "有态度的说书人,不是中立的摄像机——自带观点、会冷嘲热讽、会用不正经的比喻消化严肃信息。" "每一句话都有具体的质感:不是'一扇门'而是'贴着小广告的防盗大门'," @@ -92,20 +95,50 @@ def detect_manifest_task(manifest: dict, manifest_path: str) -> str: "发现新情况不是理性推演是'好家伙'然后直接行动;" "别人装逼说教时内心翻白眼表面配合;取得进展不是感悟人生是'行吧,能用'" ), - "dialogue_tag_preferences": ["沉声道", "随口道", "好奇道", "无奈道", "赶紧道"], + "dialogue_tag_preferences": ["沉声道", "随口道", "好奇道", "无奈道", "赶紧道", "嘀咕道", "嘟囔道"], "rhetoric_preferences_voice": ["好似", "犹如", "宛如"], "rhythm_accelerators": ["顿时", "赶紧", "不禁", "登时", "连忙"], } +def _load_default_voice_persona() -> dict: + """Load snarky-storyteller preset as the authoritative DEFAULT_VOICE_PERSONA.""" + try: + with open(_SNARKY_PRESET_PATH, "r", encoding="utf-8") as f: + preset = json.load(f).get("voice_persona") or {} + # Drop voice_lock — only the 5 voice fields are defaults. + preset.pop("voice_lock", None) + # Sanity check: all expected keys present; else degrade to hardcoded. + expected_keys = set(_HARDCODED_DEFAULT_VOICE_PERSONA.keys()) + if not expected_keys.issubset(preset.keys()): + return _HARDCODED_DEFAULT_VOICE_PERSONA + return {k: preset[k] for k in expected_keys} + except (OSError, json.JSONDecodeError): + return _HARDCODED_DEFAULT_VOICE_PERSONA + + +DEFAULT_VOICE_PERSONA = _load_default_voice_persona() + + def _load_style_profile(profile_path: str) -> dict | None: - """Parse style-profile.json; return None on any failure.""" + """Parse style-profile.json; return None if missing; warn on parse error. + + A missing file is a normal path (legacy projects, bootstrap); silent None. + A malformed file is a configuration bug that silently regresses voice — + surface it loudly so users notice instead of seeing wrong output. + """ content = read_file(profile_path) if not content: return None try: return json.loads(content) - except json.JSONDecodeError: + except json.JSONDecodeError as err: + print( + f"[api-writer] WARNING: style-profile.json is not valid JSON at " + f"{profile_path}: {err}; voice_persona + style directives will " + f"fall back to defaults", + file=sys.stderr, + ) return None @@ -168,6 +201,27 @@ def build_voice_persona_section(vp: dict) -> str | None: return "## voice_persona(声音人格)\n\n" + "\n\n".join(lines) +_VOICE_PERSONA_PROFILE_KEYS = {"voice_persona", "_voice_persona_comment"} + + +def _read_style_profile_section_without_voice(profile_path: str) -> str | None: + """Render style-profile.json as '## 风格指纹' but strip voice_persona keys. + + The voice_persona object is rendered in a separate '## voice_persona' section + by build_voice_persona_section(); keeping both leads to duplicate injection + and wasted tokens. + """ + sp = _load_style_profile(profile_path) + if not sp: + # Parse error or missing file: fall back to raw read so ChapterWriter + # at least sees some form of style-profile content (defensive). + return read_section(profile_path, "风格指纹") + filtered = {k: v for k, v in sp.items() if k not in _VOICE_PERSONA_PROFILE_KEYS} + if not filtered: + return None + return "## 风格指纹\n\n```json\n" + json.dumps(filtered, ensure_ascii=False, indent=2) + "\n```" + + def extract_style_directives(profile_path: str) -> list[str]: """Extract quantitative writing directives from style-profile.json.""" sp = _load_style_profile(profile_path) @@ -246,10 +300,11 @@ def assemble_user_message(m: dict) -> str: if s := read_section(p, "风格样本"): core_parts.append(s) - # 2. Style profile + # 2. Style profile — strip voice_persona keys to avoid duplicate injection + # with the dedicated voice section below (Step 2b). style_profile_path = paths.get("style_profile") if style_profile_path: - if s := read_section(style_profile_path, "风格指纹"): + if s := _read_style_profile_section_without_voice(style_profile_path): core_parts.append(s) style_directives = extract_style_directives(style_profile_path) diff --git a/scripts/assemble-manifests.py b/scripts/assemble-manifests.py index e0d75a3..517a8d2 100644 --- a/scripts/assemble-manifests.py +++ b/scripts/assemble-manifests.py @@ -25,6 +25,34 @@ PLUGIN_ROOT = Path(__file__).resolve().parent.parent +def _load_api_writer_module(): + """Dynamically load scripts/api-writer.py (dashed filename → importlib).""" + import importlib.util + path = PLUGIN_ROOT / "scripts" / "api-writer.py" + spec = importlib.util.spec_from_file_location("api_writer", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _resolve_voice_persona_for_manifest(project_root: Path) -> dict: + """Share api-writer's voice_persona resolver so both API and CW paths agree. + + ChapterWriter falls back to the agent when API writing fails. Without this + resolver on the manifest path, legacy projects (no voice_persona field) get + different voice on the API path vs the agent path. Inline the resolved dict + into the CW manifest so ChapterWriter sees the same voice the API writer + would have emitted. + """ + try: + aw = _load_api_writer_module() + except Exception: + return {} + sp_path = project_root / "style-profile.json" + sp_str = str(sp_path) if sp_path.exists() else None + return aw.resolve_voice_persona(sp_str) + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -822,6 +850,7 @@ def opt(d: dict, k: str, v: Any) -> None: "concurrent_state": concurrent, "hard_rules_list": hard_rules, "foreshadowing_tasks": fs_tasks, + "voice_persona": _resolve_voice_persona_for_manifest(root), "paths": { "style_profile": "style-profile.json", "chapter_contract": contract_rel, diff --git a/skills/start/references/quick-start-workflow.md b/skills/start/references/quick-start-workflow.md index 2b92849..1a543d0 100644 --- a/skills/start/references/quick-start-workflow.md +++ b/skills/start/references/quick-start-workflow.md @@ -72,7 +72,7 @@ **Voice Persona 采集**(在 Step B 内完成,平台选择之后): -平台选择完成后,使用 AskUserQuestion 询问声音基调: +平台选择完成后,使用 AskUserQuestion 询问声音基调。**严格闭枚举,不接受任何其他字符串**: ``` 选择声音基调(决定叙述者态度和主角内心语气): @@ -83,10 +83,15 @@ 5. custom — 不选预置,由 WorldBuilder Mode 7 从用户样本自动提取 voice_persona(voice_lock=true) ``` -- 选项 1-4 → 读取 `${CLAUDE_PLUGIN_ROOT}/templates/voice-personas/{preset}.json`,将其 `voice_persona` 对象合并到项目 `style-profile.json.voice_persona`,`voice_lock=false` -- 选项 5 → 在 `style-profile.json.voice_persona` 写入 `{"voice_lock": true}`,其余字段留空(由 Mode 7 填充) -- 用户输入未知预置名 → WARNING 并退化为选项 1(snarky-storyteller) -- **缺省处理**(老项目或用户直接跳过):不写 voice_persona 字段,`api-writer.py` 在运行时用 DEFAULT_VOICE_PERSONA(等价 snarky-storyteller)自动 fallback,行为与 v3.0.x 一致 +**Allowlist**(允许的 preset 值只有 5 个,硬编码枚举): +`{"snarky-storyteller", "austere-narrator", "empathetic-observer", "epic-chronicler", "custom"}` + +- 选项 1-4(映射到前 4 个 allowlist 值)→ 从 `${CLAUDE_PLUGIN_ROOT}/templates/voice-personas/{preset}.json` 读取(`{preset}` 只能从 allowlist 取值,不接受任意字符串插入路径),将其 `voice_persona` 对象合并到项目 `style-profile.json.voice_persona`,`voice_lock=false` +- 选项 5(`custom`)→ 在 `style-profile.json.voice_persona` 写入 `{"voice_lock": true}`,其余字段留空(由 Mode 7 填充) +- **任何 allowlist 之外的输入**(包括空输入、路径样输入、其他字符串)→ 重新询问直到获得有效选择,**禁止**退化成任何默认 preset(避免用户以为选了 A 实际跑了 B) +- **缺省处理**(用户直接跳过整个 Voice Persona 步骤):不写 voice_persona 字段,`api-writer.py` 在运行时用 DEFAULT_VOICE_PERSONA(等价 snarky-storyteller)自动 fallback,行为与 v3.0.x 一致 + +> **安全约束**:实现时 preset 名必须**先**在 allowlist 内校验通过,**再**构造文件路径;严禁把用户自由输入直接拼接进文件路径。 `voice_persona` 在 Step C 写入 `style-profile.json.voice_persona` 对象。 From 7890348cdcdd1f06bf68f1867a7181ac347795f2 Mon Sep 17 00:00:00 2001 From: DankerMu Date: Thu, 16 Apr 2026 20:31:39 -0400 Subject: [PATCH 3/3] Clean up voice_persona observability nits (independent review) Two minor observations from Phase 7 independent review: - assemble-manifests.py: _resolve_voice_persona_for_manifest silently returned {} on api-writer module import failure, so a broken build manifested as voice drift with no signal. Now emits a stderr WARNING mirroring the C-2 pattern. - api-writer.py: _read_style_profile_section_without_voice had a dead-code branch that called read_section() on corrupt profiles. Since style -profile.json is pure JSON without markdown headings, the fallback never produced useful output. Replaced with explicit None return and clarifying comment (the single WARNING from _load_style_profile is sufficient diagnostic signal). --- scripts/api-writer.py | 7 +++---- scripts/assemble-manifests.py | 9 ++++++++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/scripts/api-writer.py b/scripts/api-writer.py index ab9da25..8cc6e9f 100755 --- a/scripts/api-writer.py +++ b/scripts/api-writer.py @@ -209,13 +209,12 @@ def _read_style_profile_section_without_voice(profile_path: str) -> str | None: The voice_persona object is rendered in a separate '## voice_persona' section by build_voice_persona_section(); keeping both leads to duplicate injection - and wasted tokens. + and wasted tokens. Missing or corrupt style-profile.json yields None + (the JSONDecodeError path already warned in _load_style_profile). """ sp = _load_style_profile(profile_path) if not sp: - # Parse error or missing file: fall back to raw read so ChapterWriter - # at least sees some form of style-profile content (defensive). - return read_section(profile_path, "风格指纹") + return None filtered = {k: v for k, v in sp.items() if k not in _VOICE_PERSONA_PROFILE_KEYS} if not filtered: return None diff --git a/scripts/assemble-manifests.py b/scripts/assemble-manifests.py index 517a8d2..932529b 100644 --- a/scripts/assemble-manifests.py +++ b/scripts/assemble-manifests.py @@ -46,7 +46,14 @@ def _resolve_voice_persona_for_manifest(project_root: Path) -> dict: """ try: aw = _load_api_writer_module() - except Exception: + except Exception as err: + print( + f"[assemble-manifests] WARNING: failed to load api-writer module " + f"for voice_persona resolution: {err}; CW manifest will omit " + f"voice_persona and ChapterWriter will fall back to reading " + f"style-profile.json directly", + file=sys.stderr, + ) return {} sp_path = project_root / "style-profile.json" sp_str = str(sp_path) if sp_path.exists() else None