Decouple voice_persona from prompt hardcoding#79
Conversation
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
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.
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).
|
Reviewer agent: Correctness Reviewer (Codex)
|
|
Reviewer agent: Security & Performance Reviewer (Codex)
|
|
Reviewer agent: Independent Reviewer (Claude Code general-purpose)
Per-fix verification on e053493: all 5 fixes (C-1/C-2/C-3/S-1/S-3) traced and confirmed correct. Consistency across all 3 consumers (api-writer.py / assemble-manifests.py / chapter-writer.md) — no divergence. All 4 preset JSONs expose exactly the 5 fields |
Why
ChapterWriter and API Writer prompts currently bake the snarky-storyteller voice DNA into the system prompt — narrator role, "贱嗖嗖乐观实用主义" protagonist tone, "韭菜移植 / 龟龟 / 哔了狗" micro-injection samples, "XX道 / 好似 / 顿时" default vocabulary. Result: every project, regardless of genre (仙侠 / 都市 / 悬疑 / 言情), ships chapters with the same storyteller voice and same protagonist OS.
style-profile.json.override_constraintsonly togglesanti_intuitive_detailandmax_scene_sentences— it does not reach the voice layer.This violates tool neutrality: the plugin should amplify the user's chosen voice, not inject one specific reference novel's DNA into all projects.
What
Move voice DNA from prompts into
style-profile.json.voice_persona:narrator_roleprotagonist_voice_tonedialogue_tag_preferencesrhetoric_preferences_voicerhythm_acceleratorsvoice_lockTwo mirror prompts (
prompts/api-writer-system.md+agents/chapter-writer.md) now reference these fields andstyle-samples.md § {叙述者态度,主角内心声音,语域微注入}instead of listing inline examples.scripts/api-writer.pyresolves voice_persona at runtime: empty fields fall back toDEFAULT_VOICE_PERSONA(snarky-storyteller equivalent) whenvoice_lock=false, so legacy projects with no voice_persona field keep v3.1.x behaviour exactly. New projects can pick a genre-appropriate voice via/novel:startStep B (4 presets + custom).Full proposal:
openspec/changes/voice-persona-dehardcoding/proposal.mdSurface area
templates/style-profile-template.json(+voice_persona object)templates/style-samples-template.md(+2 sections),templates/voice-personas/*.json(4 presets + README)prompts/api-writer-system.md,agents/chapter-writer.md(mirror rewrites of Role / 语域微注入 / 正向风格引导)scripts/api-writer.py(resolve_voice_persona(),build_voice_persona_section(),DEFAULT_VOICE_PERSONA)skills/start/references/quick-start-workflow.md(Step B preset picker),skills/novel-writing/references/quality-rubric.md(tonal_variance example)agents/world-builder.mdMode 7 (extract voice_persona from samples),agents/style-refiner.md(untangle protection rule)CLAUDE.md,.claude-plugin/plugin.json(3.1.0 → 3.2.0)Test plan
grep -rn '好家伙\|得,又来\|行吧,能用\|韭菜移植\|龟龟\|哔了狗' prompts/ agents/ skills/→ 0 matchespython3 -m py_compile scripts/api-writer.pyandjson.loadon all 5 JSON files → all OKvoice_personafield →resolve_voice_persona()injects full snarky DNA → behaviour identical to v3.1.x/novel:starton a fresh project, pickepic-chronicler, write a chapter, confirm output voice matches preset (manual, post-merge)/novel:continueon an existing project (no voice_persona field), diff vs pre-refactor baseline, confirm < 5% diff (manual, post-merge)Migration
voice_personafieldvoice_personapopulated (preset or custom)voice_lock: truewith empty fieldsstyle-samples.mdOut of scope
voice_personafor multi-POV projects (v1 is project-level only)Agent Review
7890348cdcdd1f06bf68f1867a7181ac347795f2CI Status
main, not introduced by this PR. 119 lint errors predate this branch; Link Check is a lychee config bug ("No links were found"). Tracked for a separate infra PR.