Skip to content

Decouple voice_persona from prompt hardcoding#79

Merged
DankerMu merged 3 commits into
mainfrom
voice-persona-dehardcoding
Apr 17, 2026
Merged

Decouple voice_persona from prompt hardcoding#79
DankerMu merged 3 commits into
mainfrom
voice-persona-dehardcoding

Conversation

@DankerMu
Copy link
Copy Markdown
Owner

@DankerMu DankerMu commented Apr 17, 2026

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_constraints only toggles anti_intuitive_detail and max_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:

Field Purpose
narrator_role Narrator attitude (one sentence)
protagonist_voice_tone Protagonist inner-monologue baseline
dialogue_tag_preferences Preferred "XX道" variants
rhetoric_preferences_voice Preferred 比喻 引导词
rhythm_accelerators Preferred 节奏加速词
voice_lock Fallback control (false = legacy default; true = feel-from-samples)

Two mirror prompts (prompts/api-writer-system.md + agents/chapter-writer.md) now reference these fields and style-samples.md § {叙述者态度,主角内心声音,语域微注入} instead of listing inline examples.

scripts/api-writer.py resolves voice_persona at runtime: empty fields fall back to DEFAULT_VOICE_PERSONA (snarky-storyteller equivalent) when voice_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:start Step B (4 presets + custom).

Full proposal: openspec/changes/voice-persona-dehardcoding/proposal.md

Surface area

  • Schematemplates/style-profile-template.json (+voice_persona object)
  • Templatestemplates/style-samples-template.md (+2 sections), templates/voice-personas/*.json (4 presets + README)
  • Promptsprompts/api-writer-system.md, agents/chapter-writer.md (mirror rewrites of Role / 语域微注入 / 正向风格引导)
  • Runtimescripts/api-writer.py (resolve_voice_persona(), build_voice_persona_section(), DEFAULT_VOICE_PERSONA)
  • Skillsskills/start/references/quick-start-workflow.md (Step B preset picker), skills/novel-writing/references/quality-rubric.md (tonal_variance example)
  • Agentsagents/world-builder.md Mode 7 (extract voice_persona from samples), agents/style-refiner.md (untangle protection rule)
  • MetaCLAUDE.md, .claude-plugin/plugin.json (3.1.0 → 3.2.0)

Test plan

  • Static scan: grep -rn '好家伙\|得,又来\|行吧,能用\|韭菜移植\|龟龟\|哔了狗' prompts/ agents/ skills/ → 0 matches
  • Schema validity: python3 -m py_compile scripts/api-writer.py and json.load on all 5 JSON files → all OK
  • Legacy equivalence: project with no voice_persona field → resolve_voice_persona() injects full snarky DNA → behaviour identical to v3.1.x
  • voice_lock=true: empty fields produce a "feel from samples" instruction with no fallback → no snarky DNA leak
  • epic-chronicler preset: produces 史诗叙事者 + 宿命感 + 朗声道 + 霎时/骤然 → no snarky DNA leak
  • End-to-end: run /novel:start on a fresh project, pick epic-chronicler, write a chapter, confirm output voice matches preset (manual, post-merge)
  • End-to-end legacy: run /novel:continue on an existing project (no voice_persona field), diff vs pre-refactor baseline, confirm < 5% diff (manual, post-merge)

Migration

Project state Behaviour
No voice_persona field Auto fallback to snarky DNA at runtime — zero behaviour change
voice_persona populated (preset or custom) Prompts use the configured voice
voice_lock: true with empty fields Prompts instruct model to feel voice from style-samples.md

Out of scope

  • Cross-volume voice evolution (protagonist growth → tonal shift)
  • Per-POV voice_persona for multi-POV projects (v1 is project-level only)

Agent Review

  • Reviewer agents used: Correctness Reviewer (Codex), Security & Performance Reviewer (Codex), Independent Reviewer (Claude Code general-purpose)
  • Reviewed head SHA: 7890348cdcdd1f06bf68f1867a7181ac347795f2
  • Review evidence: Correctness | Security & Performance | Independent
  • Key findings addressed: C-1 CW manifest voice_persona inlining (major), C-2 malformed-JSON WARNING (major), C-3 DEFAULT_VOICE_PERSONA = snarky preset (minor), S-1 preset allowlist + reprompt (major), S-3 eliminated duplicate voice_persona injection (major), plus 2 independent-reviewer observability nits cleaned up. S-2 (prompt injection via voice_persona) and S-4 (BOM + repeated reads) wontfix with spec-referenced justification in comments.
  • Fix commits: e053493 (5 cross-review fixes) + 7890348 (2 independent-review cleanups)

CI Status

  • Manifest & JSON Validation: pass
  • Markdown Lint / Link Check: fail — pre-existing on 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.

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).
@DankerMu
Copy link
Copy Markdown
Owner Author

Reviewer agent: Correctness Reviewer (Codex)
Reviewed head SHA: 7890348
Reviewed commits: f91028d (initial) → e053493 (fixes) → 7890348 (cleanup)
Summary: Initial review on f91028d found 3 findings; all addressed in e053493 + 7890348.
Findings:

  • major Legacy projects lose v3.1.x voice on API-writer-fails → ChapterWriter-agent fallback path; api-writer.py fallback only ran in the API branch, CW manifest carried only style_profile path → FIXED in e053493: scripts/assemble-manifests.py:_resolve_voice_persona_for_manifest now inlines resolved voice_persona into the CW manifest via dynamic import of api_writer.resolve_voice_persona (same fallback semantics).
  • major _load_style_profile swallowed JSONDecodeError and silently returned None → resolve_voice_persona then injected snarky default over the user's actual custom voice → FIXED in e053493: scripts/api-writer.py:_load_style_profile now prints stderr WARNING with file path + parse error; file-not-found still silent (normal legacy path).
  • minor DEFAULT_VOICE_PERSONA had 5 dialogue tags vs snarky-storyteller preset's 7 (嘀咕道 / 嘟囔道 missing) → legacy fallback diverged from preset selection → FIXED in e053493: DEFAULT_VOICE_PERSONA loaded from templates/voice-personas/snarky-storyteller.json at module import; hardcoded dict kept as defense-in-depth.

@DankerMu
Copy link
Copy Markdown
Owner Author

Reviewer agent: Security & Performance Reviewer (Codex)
Reviewed head SHA: 7890348
Reviewed commits: f91028d (initial) → e053493 (fixes) → 7890348 (cleanup)
Summary: Initial review on f91028d found 4 findings; 2 major fixed, 2 intentionally deferred with spec-referenced justification.
Findings:

  • major Preset loading used raw {preset} path interpolation without allowlist; free-form Other AskUserQuestion input could turn into path traversal → FIXED in e053493: skills/start/references/quick-start-workflow.md now pins the 5-value allowlist {snarky-storyteller, austere-narrator, empathetic-observer, epic-chronicler, custom}, mandates reprompt on unknown input (no silent fallback), and adds explicit "validate before path construction" safety note.
  • major voice_persona fields injected as trusted prompt instructions with no sanitization — "Ignore previous instructions" style attacks possible → WONTFIX: voice_persona is project-owner configuration in the same trust tier as world/rules.json, characters/active/*.json, templates/ai-blacklist.json, and every other plugin config file. No multi-tenant or untrusted-input surface. A user who can write style-profile.json can already alter any other config with equal or greater blast radius. Asymmetric hardening on one field is misleading. Spec: openspec/changes/voice-persona-dehardcoding/proposal.md Non-Goals treats voice_persona as L1-tier trusted config.
  • major Unbounded voice_persona payload + duplicate injection (raw style-profile.json dumped as 风格指纹 AND rendered separately as voice_persona section) → token waste + potential context overflow → FIXED in e053493: scripts/api-writer.py:_read_style_profile_section_without_voice filters out voice_persona and _voice_persona_comment keys before rendering 风格指纹; voice_persona appears exactly once in the prompt.
  • minor style-profile.json read 2-3× per request + strict UTF-8 (no BOM tolerance) → WONTFIX for this PR: micro-performance + rare-failure-mode. BOM in JSON requires a BOM-emitting editor (uncommon on modern toolchains); 3 reads of a ~5KB file add negligible IO. Open follow-up issue for style-profile IO consolidation + BOM-tolerant decoder if real reports surface.

@DankerMu
Copy link
Copy Markdown
Owner Author

Reviewer agent: Independent Reviewer (Claude Code general-purpose)
Reviewed head SHA: 7890348
Reviewed commits: e053493 (fixes); cleanup in 7890348 resolves both minor observations.
Summary: Fix commit correctly addresses C-1/C-2/C-3/S-1/S-3; two minor observability nits, no regressions, backward-compatible.
Findings:

  • minor assemble-manifests.py::_resolve_voice_persona_for_manifest swallowed all Exception from dynamic import and returned {} with no log — if api-writer.py breaks, operators see voice drift with no signal → FIXED in 7890348: now emits stderr WARNING mirroring C-2 pattern with full diagnostic (module name, error, fallback behaviour).
  • minor _read_style_profile_section_without_voice dead-code fallback to read_section() on parse error — style-profile.json is pure JSON without markdown headings, so read_section never produced useful output → FIXED in 7890348: removed the dead branch, documented that corrupt profile yields None (the single WARNING from _load_style_profile is sufficient diagnostic).

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 build_voice_persona_section renders + voice_lock flag, no orphan or missing fields. Backward compatibility: legacy path (no voice_persona field) traces through 4 layers → returns DEFAULT_VOICE_PERSONA = snarky preset, byte-equivalent to v3.1.x.

@DankerMu DankerMu merged commit e97e410 into main Apr 17, 2026
1 of 3 checks passed
@DankerMu DankerMu deleted the voice-persona-dehardcoding branch April 17, 2026 02:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant