Skip to content

Add user-editable Author's Voice (voicing): genre-seeded textarea, pipeline-wide injection, dedicated final voicing pass #167

Description

@CyberSecDef

Summary

Add a user-editable Author's Voice ("voicing") feature that gives the user explicit control over the prose voice of a generated novel. The feature has three parts:

  1. A voicing textarea on the Step 1 / start page that starts empty and auto-fills with a bespoke per-genre voicing template when the user selects a genre. The user can freely edit it.
  2. Inject that voicing everywhere in the chapter pipeline — the draft prompt and every prose-rewriting refinement pass — as a light-touch "preserve this voice" constraint (same mechanism vocab_rules already uses).
  3. Add one dedicated voicing pass near the end of the pipeline (a strong-touch "apply this voice" rewrite) placed before the terminal vocabulary scan so the existing scanner can clean up after it.

When the textarea is populated, the user's voicing replaces the auto-selected voice_seed entirely — the two must not compete.

Motivation / current behavior

Today prose voice is handled by novelforge/voice.py:

  • select_voice_seed(genre, premise) auto-picks one of 8 fixed tonal palettes (weighted by genre + premise keywords + randomness) at novelforge/routes/outline.py:209, stored in session["voice_seed"].
  • At generation, format_voice_prompt() renders it to voice_prompt (novelforge/routes/generation/chapters.py:280-282) and passes it only to the draft prompt (build_chapter_draft_prompt, called at chapters.py:498-509; param defined at novelforge/agents/chapter/prompts.py:108-116).

Two gaps:

  1. No user control. The voice is auto-picked from 8 options; the user can't specify it. (special_instructions is a free-text catch-all but isn't a dedicated, structured voice mechanism.)
  2. Voice erodes across the pipeline. voice_prompt reaches the draft only. The chapter then goes through ~18 sequential full-rewrite passes in _run_all_chapter_agents (novelforge/agents/chapter/pipeline.py:565) — prose refinement, editing, momentum, structure, synthesizer, polish, anti-LLM, metaphor reduction, quality control, copy edit, etc. Those passes receive vocab_rules but not the voice, so each one sands the prose back toward the model's generic default. The distinct voice established at draft is progressively lost.

This feature closes both gaps: explicit user control, and a voice that survives the full chain.

Confirmed design decisions

  1. Don't clobber user edits. Genre-change auto-fill only overwrites the textarea when it is empty or still equal to a prior auto-fill value (i.e., untouched). Never destroy user-typed content.
  2. Author 21 bespoke per-genre voicings (one per ALLOWED_GENRES entry) — not a reuse of the 8 existing seeds. Drafts provided below.
  3. Final voicing pass placed before the terminal vocabulary scan (pipeline.py:791), so the existing pure-Python scanner + targeted fix-up runs after it.
  4. Light touch when injected everywhere ("preserve and don't fight this voice"); strong touch only in the dedicated final pass ("apply this voice"). One amplifier, not twenty.
  5. Populated textarea replaces the auto seed. When voicing is non-empty, skip / ignore select_voice_seed() so there is exactly one voice source. When empty, fall back to today's auto-seed behavior unchanged.
  6. Do both the inject-everywhere step and the dedicated final pass.

Implementation plan

A. New input field + validation

  • templates/index.html (genre <select> at line 166-169): add a voicing <textarea name="voicing"> to the Step 1 form. Wire a JS change handler on #genre that fills #voicing from a genre→voicing map, respecting decision [WIP] Develop Flask web application for generating fiction novels #1 (only fill when empty/untouched — track the last auto-filled value, e.g. via a data-autofilled attribute).
  • Client data source: embed the genre→voicing map as a JSON <script> block in the template (CSP already allows inline scripts). Render it from the new server-side GENRE_VOICINGS data so there is a single source of truth. (Alternative: a small GET endpoint; embedding avoids a round-trip and is preferred.)
  • novelforge/validation.pyvalidate_outline_input(): add a voicing field, str, optional, with a max-length cap (suggest 5000 to match special_events / special_instructions). Strip and store in values["voicing"].

B. Persist through session + snapshot

  • novelforge/routes/outline.py (around 209-211): read validated voicing; store session["voicing"]. When voicing is non-empty, do NOT call select_voice_seed() / set session["voice_seed"] (decision Harden production defaults: rate limiting backend, config profiles, and deployment readiness #5). When empty, keep current auto-seed behavior.
  • novelforge/session/persistence.py: add "voicing": (str, "") to the schema (near special_instructions at line 115 / voice_seed at 131); include it in the save dict (near 251/267) and the restore path (near 403/419).
  • novelforge/routes/generation/chapters.py snapshot (near 140-150, where voice_seed is added at the snap dict): add "voicing": session.get("voicing", "").

C. Resolve the effective voice prompt (single source)

  • chapters.py:280-282: compute the effective voice text:
    • if snap["voicing"] is non-empty → use it directly as the voice block (wrap with a small header for consistency, mirroring format_voice_prompt);
    • else → format_voice_prompt(voice_seed) as today.
  • Pass this effective voice_prompt to the draft (already wired at chapters.py:508) and down into _run_all_chapter_agents (new param — see D).

D. Inject everywhere (light touch)

  • novelforge/agents/chapter/pipeline.py _run_all_chapter_agents (line 565): add a voicing_prompt: str = "" parameter (or carry it on ChapterContext — see novelforge/agents/chapter/context.py:10-26, which is the established pattern for threading chapter-level strings; adding a voicing: str = "" field there is the cleaner option and avoids touching both call sites' positional args).
  • In the local _safe / _build_with_vocab_rules helper (pipeline.py:613-624): append a light-touch voicing block to the system message alongside vocab_rules. Wording: "VOICE TO PRESERVE: … Maintain this voice; do not flatten or normalize it. Do not over-apply it." Keep it short so it doesn't overpower each pass's primary instruction.
  • Update both call sites: chapters.py:530 and novelforge/routes/generation/revision.py:193 (revision re-runs the pipeline and must load voicing from the snapshot/session too).

E. Dedicated final voicing pass (strong touch)

  • New prompt builder in novelforge/agents/chapter/prompts.py, e.g. build_voicing_pass_prompt(chapter_text, chapter_num, title, voicing_prompt), with a strong-touch instruction: rewrite so the chapter unmistakably reads in the specified voice, while preserving plot, continuity, dialogue meaning, and character actions. Add the corresponding template entry to prompts.yml.
  • Placement in _run_all_chapter_agents: insert the pass after copy edit and before the terminal vocabulary scan at pipeline.py:791 (decision Add full 12-step specialized agent pipeline per chapter #3). Run it via _safe(...) so vocab rules + content-retry apply; the existing scan + conditional build_vocabulary_fix_prompt fix-up then cleans up anything the voicing reintroduced. Skip the pass when voicing_prompt is empty.
  • Respect the per-chapter deadline (_check_deadline()) and step_callback like every other pass. Cost: +1 LLM call/chapter (~5% of the ~19-call budget).

F. Bespoke per-genre voicings (data)

  • Add a GENRE_VOICINGS: dict[str, str] (or structured triplet dict rendered to text) — suggested home: novelforge/voice.py (co-located with the existing seed system) or a new novelforge/genre_voicings.py. Must cover every entry in ALLOWED_GENRES (validation parity check, mirroring the existing _missing_voice_genres guard in voice.py:243-248).
  • Drafts below (each follows the existing seed structure: prose style / emotional register / sensory preference). Tune wording during implementation.
21 bespoke genre voicings (draft)

Adventure — Prose: propulsive and forward-leaning; favor strong active verbs and momentum; keep description tied to motion and stakes. Emotional register: characters meet fear with resolve; bravado masks doubt; camaraderie shows through action under pressure. Sensory: distance, terrain, weather, and the body's exertion — heat, thirst, the burn of effort.

Contemporary Fiction — Prose: grounded, observational, unshowy; natural rhythms and present-day texture; let small moments carry weight. Emotional register: restrained, recognizable interiority; people half-say what they mean; emotion surfaces in ordinary friction. Sensory: the everyday — phones, traffic, food, the specific textures of modern life.

Crime — Prose: lean and procedural with grit; concrete nouns, little ornament; let cause and consequence drive the line. Emotional register: moral ambiguity; characters rationalize; loyalty and betrayal sit close together. Sensory: streets, interiors, the worn detail of evidence and place; money, locks, blood.

Dystopian — Prose: controlled and slightly eroded; institutional diction bleeding into private thought; clarity with an undertow of dread. Emotional register: suppressed feeling; hope is dangerous and rationed; defiance flickers and hides. Sensory: surveillance, scarcity, the texture of decay against enforced order.

Fantasy — Prose: immersive and a touch mythic; let worldbuilding breathe through specific, lived-in detail; cadence with grandeur but earned, not purple. Emotional register: high stakes felt personally; wonder and loss intertwined; loyalty and destiny weigh on choices. Sensory: light, landscape, the materials of a world — stone, steel, magic's texture.

Gothic Fiction — Prose: brooding and atmospheric; long sinuous sentences; the setting breathes and the past intrudes. Emotional register: characters haunted by guilt and memory; dread accumulates by suggestion; beauty and horror share a sentence. Sensory: cold, damp, failing light, the weight of old stone and silence.

Historical Fiction — Prose: textured and measured; period-accurate diction without pastiche; detail anchors the reader in time. Emotional register: feeling shaped by the era's constraints; restraint and propriety against private longing. Sensory: craft, dress, food, and labor of the period; the specific dirt and grandeur of the age.

Horror — Prose: escalating dread; controlled pacing that tightens; plain sentences that turn wrong. Emotional register: fear as physical and psychological; denial gives way to terror; the safe becomes threatening. Sensory: sound, shadow, the body's responses — cold sweat, held breath, the wrongness just out of sight.

Literary Fiction — Prose: precise and layered; the exact word over the approximate; subtext over exposition; each paragraph rewards rereading. Emotional register: interiority betrayed by habit and gesture; insight arrives obliquely. Sensory: taste and smell as memory; place with personality; the charged ordinary.

Magical Realism — Prose: matter-of-fact about the impossible; lyrical but unastonished; the marvelous reported as ordinary. Emotional register: wonder folded into daily grief and joy; the surreal carries emotional truth. Sensory: domestic and natural detail rendered vivid; the uncanny grounded in the tangible.

Mystery — Prose: clean and clue-laden; controlled disclosure; every detail load-bearing without telegraphing. Emotional register: curiosity and unease; characters withhold; trust is provisional. Sensory: rooms, objects, and timing — the precise arrangement of who, where, and when.

Noir — Prose: terse and fatalistic; hard-boiled rhythm; dry observation cut with sudden lyricism. Emotional register: cynicism over buried tenderness; everyone is compromised; doom hums under every choice. Sensory: rain, neon, smoke, shadow; the cheap and the gaudy in sharp relief.

Paranormal — Prose: charged and intimate; the uncanny close and personal; sensory immediacy with a current of the otherworldly. Emotional register: desire and dread entangled; characters drawn toward what should frighten them. Sensory: temperature shifts, skin, the electric edge between the seen and unseen.

Romance — Prose: emotionally rich interiority; attentive to gesture, glance, and the charged pause; warmth without saccharine. Emotional register: yearning, vulnerability, the risk of being known; conflict between fear and want. Sensory: touch, proximity, scent, and the heightened detail of the beloved's presence.

Satire Humor — Prose: dry and ironic; deadpan delivery; humor in the gap between what's said and meant; controlled exaggeration. Emotional register: characters deflect with wit; the funniest beats are the saddest; absurdity observed, not announced. Sensory: the mundane and specific rendered with deliberate anti-climax.

Science Fiction — Prose: precise and conceptually clear; cool, exact diction; ideas dramatized through concrete consequence. Emotional register: wonder tempered by rigor; the human inside the systemic; awe and unease at change. Sensory: technology's texture, scale, and the body against unfamiliar environments.

Speculative Fiction — Prose: idea-driven and unsettling-familiar; clear lines that tilt the ordinary; the premise felt through lived detail. Emotional register: disquiet beneath normalcy; characters adapting to a changed rule of the world. Sensory: the recognizable made strange; small concrete signs of the altered reality.

Thriller — Prose: kinetic and tight; short propulsive sentences in action; a ticking-clock pulse. Emotional register: pressure, adrenaline, calculation under threat; trust is a liability. Sensory: proprioception and speed — the body's alarm, the geometry of escape and pursuit.

Urban Fantasy — Prose: gritty-modern with a wry edge; magic braided into city texture; quick, contemporary rhythm. Emotional register: jaded competence over real vulnerability; humor as armor; loyalty hard-won. Sensory: the city at night, the friction of the mundane and the magical sharing a street.

Western — Prose: spare and elemental; plain strong sentences; landscape as force and character. Emotional register: stoic restraint; codes of honor and survival; feeling shown through what's left unsaid. Sensory: dust, sun, distance, leather and iron; the vast indifferent land.

Young Adult — Prose: immediate and voice-forward; close, propulsive narration; contemporary cadence without condescension. Emotional register: emotionally raw and honest; first-time intensity of feeling; identity and belonging at stake. Sensory: vivid, specific, present-tense-feeling detail; the heightened texture of new experience.

Edge cases & guardrails

  • Genre change after editing: never overwrite a user-modified textarea (decision [WIP] Develop Flask web application for generating fiction novels #1). Use a data-autofilled marker; only replace when current value is empty or equals the last template inserted.
  • Empty voicing: entire feature is a no-op — auto-seed path is unchanged; the dedicated final pass is skipped.
  • Length cap + sanitization: enforce the 5000-char cap server-side; the voicing flows into LLM prompts only (no HTML rendering), but keep it consistent with existing free-text fields.
  • Revision path: /revise_chapter (revision.py:193) must load voicing and run identically, including the final pass.
  • Over-styling: keep injected wording light; only the final pass is strong. Watch for caricature in QA.
  • ALLOWED_GENRES parity: add a startup validation that every genre has a voicing (mirror voice.py:243-248).

Testing

  • validation.py: voicing accepted, stripped, length-capped (extend test_app.py / validation tests).
  • Auto-seed override: when voicing set, select_voice_seed is not used / voice_seed not the voice source; when empty, current behavior preserved.
  • Session save/load/restore round-trips voicing (test_session.py).
  • Snapshot includes voicing (test_progress_snapshot.py).
  • Pipeline: voicing injected into pass system prompts; final voicing pass present when set and skipped when empty; placed before the vocab scan (test_app.py prompt-builder tests + a pipeline test with mock_llm).
  • Genre voicings: parity test that every ALLOWED_GENRES entry has a non-empty voicing.
  • Revision path runs with voicing.

Out of scope

  • Post-manuscript / whole-book re-voicing (voice is a per-chapter, fresh-context concern; the 10 post-manuscript audits are diagnostic/structural, not prose re-stylers).
  • Multiple simultaneous voices / per-character author-voice.
  • Migrating away from the 8-seed auto system (it remains the fallback when voicing is empty).

Acceptance criteria

  • Empty voicing textarea on Step 1; selecting a genre fills it from a bespoke per-genre template; user edits are never clobbered.
  • 21 bespoke genre voicings exist, one per ALLOWED_GENRES, with a parity check.
  • When voicing is set it is the only voice source (auto-seed bypassed); when empty, behavior is unchanged.
  • Effective voicing is injected (light touch) into the draft and every prose-rewriting pass in _run_all_chapter_agents.
  • A dedicated strong-touch voicing pass runs before the terminal vocabulary scan, and is skipped when voicing is empty.
  • voicing persists through session save/load/restore, the generation snapshot, and the /revise_chapter path.
  • Tests above pass; mypy novelforge/ clean.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions