skald/skald-core/src/forge.rs
Kayos c8c44a5d23 narrate: single-voice prep drops voice tags; GC superseded renders
Two fixes:

- narrate_prep in single-voice mode (empty character roster) was
  still handed the multi-voice directive, so the model invented
  [voice:<slug>] tags from character names in the prose. The
  narrate path neutralised them by falling back to the narrator,
  but it was log spam and a leak of intent. Single-voice now gets
  directive + house-system variants that forbid voice tags
  outright, and the user-prompt task line matches.

- Every narrate run wrote a fresh ~80MB WAV and never reclaimed
  the previous one, so re-renders piled up stale files. A
  successful render now deletes the WAVs of prior renders of the
  same chapter and nulls their output_path. Render history rows
  are kept; only the dead file pointer is cleared. Best-effort —
  cleanup failure never fails the render.
2026-05-15 07:02:10 -07:00

587 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! clawdforge wiring. Three passes per chapter; the actual prompt
//! templates are TODO (v0.2 prompt-engineering sprint) — this module
//! ships the plumbing so prompts can be filled in without
//! refactoring.
//!
//! The three passes:
//!
//! 1. **gen** — produces a new chapter draft from an assembled
//! context blob (parent prose + bible + characters + similarity-
//! matched passages, all from the database). Opus, high effort.
//!
//! 2. **cleanup** — polishes the draft for prose quality, voice
//! consistency, dialogue rhythm, pacing dead spots. Same Opus,
//! fresh eyes; sees gen pass output + same context.
//!
//! 3. **audit** — third Opus reads parent prose + sequel prose +
//! bible, returns structured findings: dropped threads, character
//! voice drift, retconned facts, timeline contradictions. Output
//! parses into rows for the `audit_findings` table.
//!
//! Every pass is logged as a `generation_runs` row before / after
//! for cost tracking, replay, and forensics.
//!
//! ## Naming context
//!
//! The Rust binding for clawdforge is the upstream `clawdforge` crate
//! (vendored at `vendor/clawdforge`). This module is the skald-side
//! glue: turn a story-id + a pass-kind into the right RunRequest +
//! parse the response into the right shape.
use std::time::Duration;
use clawdforge::{Client, ClientBuilder, Effort, RunRequest, RunResult, SystemMode};
use serde::{Deserialize, Serialize};
use crate::authors::AuthorWithRevision;
use crate::config::ForgeConfig;
/// Thin wrapper around the clawdforge `Client`. Configured once,
/// cheap to clone — each pass just calls `.run()` with a different
/// prompt.
#[derive(Clone)]
pub struct Forge {
client: Client,
/// The model alias we pass to clawdforge. Skald is opinionated:
/// always opus. Story-writing passes (gen/cleanup/narrate_prep/
/// rewrite) run at HIGH effort; only the audit pass runs at MAX —
/// audit genuinely needs the frontier reasoning, prose-craft does
/// not, and the $200/mo `claude -p` cap makes max-everywhere
/// unaffordable. `clawdforge` resolves the alias to the CLI flag.
model: String,
}
/// Per-pass output. `result` is the raw response from clawdforge.
/// Callers parse it into the shape they need.
#[derive(Debug, Clone)]
pub struct PassOutput {
pub kind: PassKind,
pub result: RunResult,
pub duration_ms: u64,
}
/// What a given pass over the model is for.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PassKind {
/// First-pass long-form draft from prompt + context.
Gen,
/// Polish + humanize the gen pass output.
Cleanup,
/// Canon audit across parent + sequel. Outputs findings JSON.
Audit,
/// Chapter summary for cheap context loading on long series.
Summary,
/// Annotate prose with narration control tags ([pause:Xs],
/// [breath], [scene]) for the TTS render path. Does NOT change
/// prose; output should be byte-identical except for the
/// tag insertions.
NarratePrep,
/// Re-author existing chapter prose in an author's voice. Canon
/// (names, dates, events, places, facts) is preserved exactly;
/// the prose itself is rewritten. Not editing — re-authoring.
Rewrite,
}
impl PassKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Gen => "gen",
Self::Cleanup => "cleanup",
Self::Audit => "audit",
Self::Summary => "summary",
Self::NarratePrep => "narrate_prep",
Self::Rewrite => "rewrite",
}
}
}
impl Forge {
pub fn new(cfg: &ForgeConfig) -> anyhow::Result<Self> {
let client = ClientBuilder::default()
.base_url(&cfg.base_url)
.token(&cfg.app_token)
// Generation passes at --effort max can run 1020 min wall
// clock. clawdforge's server-side cap is 1800s — match it.
// Default 120s would strand any prose-craft pass.
.timeout(Duration::from_secs(1800))
.user_agent(concat!("skald/", env!("CARGO_PKG_VERSION")))
.build()?;
Ok(Self {
client,
model: cfg.model.clone(),
})
}
/// First-pass draft. `prompt` is the user-supplied story prompt;
/// `context` is the full assembled blob (bible + characters +
/// parent prose summaries + passages).
///
/// Prompt template is TODO (v0.2). Stub builds the simplest
/// possible request shape so the wiring compiles.
/// Generation pass — produce the next chapter prose. If
/// `author` is supplied, the soul REPLACES claude's base system
/// prompt (SystemMode::Replace); the model becomes the author.
/// If None, we fall back to default append-mode with the
/// "House" neutral scaffold.
pub async fn generate(
&self,
context: &str,
direction: Option<&str>,
target_words: Option<i32>,
author: Option<&AuthorWithRevision>,
chapter_n: Option<i32>,
) -> anyhow::Result<PassOutput> {
let user_prompt = gen_user_prompt(context, direction, target_words, chapter_n);
let (system, mode) = compose_system(author, PassKind::Gen);
let body = RunRequest {
prompt: user_prompt,
model: Some(self.model.clone()),
system: Some(system),
system_mode: Some(mode),
effort: Some(Effort::High),
timeout_secs: Some(1800),
..Default::default()
};
let r = self.client.run(body).await?;
let duration_ms = r.duration_ms;
Ok(PassOutput { kind: PassKind::Gen, result: r, duration_ms })
}
/// Cleanup / humanize pass over the gen draft. Author voice
/// stays — same scaffold + soul, same Replace mode.
pub async fn cleanup(
&self,
draft: &str,
context: &str,
author: Option<&AuthorWithRevision>,
chapter_n: Option<i32>,
) -> anyhow::Result<PassOutput> {
let user_prompt = cleanup_user_prompt(draft, context, chapter_n);
let (system, mode) = compose_system(author, PassKind::Cleanup);
let body = RunRequest {
prompt: user_prompt,
model: Some(self.model.clone()),
system: Some(system),
system_mode: Some(mode),
effort: Some(Effort::High),
timeout_secs: Some(1800),
..Default::default()
};
let r = self.client.run(body).await?;
let duration_ms = r.duration_ms;
Ok(PassOutput { kind: PassKind::Cleanup, result: r, duration_ms })
}
/// Canon audit — neutral always. Author intentionally NOT
/// threaded through; the audit checks the author's work with
/// fresh eyes. Stays on Append (claude's default base prompt is
/// useful substrate for structured-JSON tool-use).
pub async fn audit(
&self,
parent_prose: &str,
sequel_prose: &str,
bible: &str,
) -> anyhow::Result<PassOutput> {
let body = build_audit_request(&self.model, parent_prose, sequel_prose, bible);
let r = self.client.run(body).await?;
let duration_ms = r.duration_ms;
Ok(PassOutput {
kind: PassKind::Audit,
result: r,
duration_ms,
})
}
/// Annotate prose with narration control tags. The model
/// receives the full chapter prose and returns the SAME prose
/// with `[pause:Xs]`, `[breath]`, `[scene]` markers inserted
/// at natural beats. The author voice DOES thread through —
/// Orson Black places beats differently than another author
/// would. Replace-mode if author is set; Append otherwise.
///
/// `characters` is the story's character roster. When non-empty,
/// the system prompt instructs the model to wrap dialogue in
/// `[voice:<slug>]"..."[/voice]` for multi-voice rendering; the
/// slug is mapped to a Kokoro voice id by skald's narrate path.
/// An EMPTY roster selects single-voice mode — the prompt then
/// forbids `[voice:...]` tags entirely (one narrator, no cast).
///
/// Hard rule the system prompt enforces: do not change a word
/// of prose. Tags are additive only.
pub async fn narrate_prep(
&self,
prose: &str,
author: Option<&AuthorWithRevision>,
characters: &[CharacterSpeaker],
) -> anyhow::Result<PassOutput> {
// An empty character roster means single-voice narration —
// the whole chapter reads in one voice. In that mode the
// prompt must NOT invite `[voice:...]` tags, or the model
// invents speaker slugs from names in the prose that the
// narrate path then has to detect and neutralize.
let single_voice = characters.is_empty();
let user_prompt = narrate_prep_user_prompt(prose, characters);
let (system, mode) = match author {
Some(a) => {
let scaffold = a
.revision
.system_template
.as_deref()
.unwrap_or(DEFAULT_AUTHOR_SCAFFOLD);
let directive = if single_voice {
NARRATE_PREP_DIRECTIVE_SINGLE
} else {
NARRATE_PREP_DIRECTIVE
};
let composed = scaffold
.replace("{{display_name}}", &a.author.display_name)
.replace("{{pass_directive}}", directive)
.replace("{{soul}}", &a.revision.soul);
(composed, SystemMode::Replace)
}
None => {
let house = if single_voice {
HOUSE_NARRATE_PREP_SYSTEM_SINGLE
} else {
HOUSE_NARRATE_PREP_SYSTEM
};
(house.to_string(), SystemMode::Append)
}
};
let body = RunRequest {
prompt: user_prompt,
model: Some(self.model.clone()),
system: Some(system),
system_mode: Some(mode),
// Tag placement IS a craft choice; high effort is
// plenty for beat sense. Same posture as gen/cleanup.
effort: Some(Effort::High),
timeout_secs: Some(1800),
..Default::default()
};
let r = self.client.run(body).await?;
let duration_ms = r.duration_ms;
Ok(PassOutput { kind: PassKind::NarratePrep, result: r, duration_ms })
}
/// Re-author existing chapter prose in the author's voice. The
/// model receives prose written by another hand and rewrites it
/// entirely in its own style — sentence rhythm, word choice,
/// paragraph shape all become the author's. Canon is preserved
/// exactly: names, dates, events, places, technical facts, and
/// the sequence of what happens do not change.
///
/// Author REQUIRED — a rewrite without an author has no target
/// voice. SystemMode::Replace; the model BECOMES the author.
/// High effort: re-authoring is heavy prose-craft, but it's
/// still craft, not reasoning — max is reserved for audit.
pub async fn rewrite(
&self,
prose: &str,
author: &AuthorWithRevision,
) -> anyhow::Result<PassOutput> {
let scaffold = author
.revision
.system_template
.as_deref()
.unwrap_or(DEFAULT_AUTHOR_SCAFFOLD);
let system = scaffold
.replace("{{display_name}}", &author.author.display_name)
.replace("{{pass_directive}}", REWRITE_DIRECTIVE)
.replace("{{soul}}", &author.revision.soul);
let user_prompt = rewrite_user_prompt(prose);
let body = RunRequest {
prompt: user_prompt,
model: Some(self.model.clone()),
system: Some(system),
system_mode: Some(SystemMode::Replace),
effort: Some(Effort::High),
timeout_secs: Some(1800),
..Default::default()
};
let r = self.client.run(body).await?;
let duration_ms = r.duration_ms;
Ok(PassOutput { kind: PassKind::Rewrite, result: r, duration_ms })
}
/// Summarize one chapter to ~250 words. The summary feeds into
/// the continuation context for older chapters so the token
/// budget stays sane on long series (book 12 doesn't carry book 1
/// in full prose; carries summaries of books 1-10 + full prose of
/// books 11-12).
///
/// Unlike gen/cleanup/audit, summarize has a real prompt template
/// shipped here — summarization is a simple, well-defined task
/// and doesn't need the prose-craft TODO treatment.
pub async fn summarize(&self, chapter_body_md: &str, chapter_label: &str) -> anyhow::Result<PassOutput> {
let prompt = format!(
"Summarize the following chapter in ~250 words for use as future \
sequel context. Capture: (1) plot beats in order, (2) character \
developments and emotional shifts, (3) setting changes, (4) any \
explicit or implied unresolved threads, (5) the chapter's \
closing position for each named character.\n\nReturn prose only \
— no headings, no bullet lists, no commentary about the task. \
Write as if you're handing this to another author who needs to \
write the next chapter without re-reading this one.\n\n\
## {chapter_label}\n\n{chapter_body_md}"
);
let body = RunRequest {
prompt,
model: Some(self.model.clone()),
system: Some(SYSTEM_SUMMARIZE.to_string()),
timeout_secs: Some(300),
..Default::default()
};
let r = self.client.run(body).await?;
let duration_ms = r.duration_ms;
Ok(PassOutput { kind: PassKind::Summary, result: r, duration_ms })
}
}
const SYSTEM_SUMMARIZE: &str = "You are a continuity assistant for a long-form \
fiction author. You write chapter summaries that future authors of sequels \
will read to understand what happened. Be specific. Names, dates, locations. \
Don't editorialize — just compress the events.";
// ─── System-prompt composition ──────────────────────────────────
//
// Three passes, three system-prompt shapes:
//
// - Gen / Cleanup with an author: scaffold + soul, sent as
// SystemMode::Replace. The model BECOMES the author.
// - Gen / Cleanup without an author: a neutral house scaffold,
// sent as Append (claude's default base prompt stays).
// - Audit: always neutral, always Append. The audit checks the
// author's work with fresh eyes.
fn compose_system(author: Option<&AuthorWithRevision>, kind: PassKind) -> (String, SystemMode) {
match author {
Some(a) => {
let scaffold = a
.revision
.system_template
.as_deref()
.unwrap_or(DEFAULT_AUTHOR_SCAFFOLD);
let pass_directive = match kind {
PassKind::Gen => GEN_DIRECTIVE,
PassKind::Cleanup => CLEANUP_DIRECTIVE,
_ => "",
};
let composed = scaffold
.replace("{{display_name}}", &a.author.display_name)
.replace("{{pass_directive}}", pass_directive)
.replace("{{soul}}", &a.revision.soul);
(composed, SystemMode::Replace)
}
None => {
let house = match kind {
PassKind::Gen => HOUSE_GEN_SYSTEM,
PassKind::Cleanup => HOUSE_CLEANUP_SYSTEM,
_ => HOUSE_GEN_SYSTEM,
};
(house.to_string(), SystemMode::Append)
}
}
}
const DEFAULT_AUTHOR_SCAFFOLD: &str = r#"You are {{display_name}}, an author. Your voice and identity are described in the soul block below. Honor them in every sentence — not as performance, but as substrate. You are not playing a role; you ARE the author.
You will receive a user-facing prompt containing the story's canon (characters, setting, established facts), prior chapters or summaries of them, and an optional user direction.
Hard rules:
- Canon is non-negotiable. Names, dates, established events do not bend to your voice. Your voice bends to them.
- Voice is yours. Express it.
- Return ONLY the requested output. No preamble, no "here is the chapter," no commentary about the task, no closing remarks. Start with the requested heading line.
- Markdown formatting (## headings, blank-line paragraphs, em-dashes) is fine.
- Never break the fourth wall to comment on yourself or the task.
{{pass_directive}}
---
{{soul}}
"#;
const GEN_DIRECTIVE: &str = "This is a GENERATION pass. Write the next chapter from scratch. Honor canon. Begin with a chapter heading on the first line.";
const CLEANUP_DIRECTIVE: &str = "This is a CLEANUP pass. The user prompt contains a draft you wrote. Polish for prose quality — tighten dialogue, fix pacing dead spots, hold your voice steady. Do NOT add new plot, do NOT retcon canon. Return ONLY the polished chapter.";
const HOUSE_GEN_SYSTEM: &str = "You are a long-form fiction author writing the next chapter of a series. Honor the canon (characters, setting, established facts) exactly. Return only the chapter prose, starting with a heading line. No preamble.";
const HOUSE_CLEANUP_SYSTEM: &str = "You are a copy editor polishing a draft chapter. Tighten dialogue, fix pacing, keep voice consistent. Do not add new plot. Return only the polished chapter.";
const SYSTEM_AUDIT: &str = "You are a canon auditor for long-form fiction. You compare a parent story and a new chapter against the bible. You flag continuity drift, character voice shift, retconned facts, dropped threads, timeline contradictions. You return STRUCTURED JSON ONLY — no commentary, no preamble. The exact shape: { \"findings\": [ { \"severity\": \"info\"|\"warn\"|\"crit\", \"area\": \"character\"|\"continuity\"|\"tone\"|\"fact\"|\"timeline\"|\"other\", \"body\": \"...\" } ] }. If no findings, return { \"findings\": [] }.";
const NARRATE_PREP_DIRECTIVE: &str = "This is a NARRATION-ANNOTATION pass. You receive your own prose and prepare it for an audiobook reading. Three kinds of inserts are allowed:\n\n1. BEAT MARKERS (additive, not prose): `[breath]` (~400ms), `[pause:1.2s]` (explicit silence in seconds, e.g. 0.5s, 1.2s, 2s), `[scene]` (~1500ms scene break). Place where the prose's rhythm asks for them — after a hard one-line beat, before a turn in dialogue, on a paragraph that lands with weight.\n\n2. SPEAKER VOICE TAGS (multi-voice dialogue): wrap dialogue lines in `[voice:<slug>]\"...\"[/voice]` based on who is speaking. The roster of available speaker slugs is given in the user prompt. The dialogue itself stays verbatim — only the wrapper is added. If a line of dialogue is not clearly attributable to a roster speaker, leave it unwrapped (the narrator voice will read it). Quoted thoughts (italicized interior monologue) stay unwrapped — only spoken aloud dialogue gets a voice tag.\n\n3. NARRATOR STUMBLES (humanizing prose-level inserts): a real narrator occasionally stumbles on a hard word, catches themselves, repeats. You may add these *sparingly* where the prose's pacing makes them feel right. Patterns: em-dash repetition (`Prip— Pripyat`), self-correction (`she — no, the wife — had been told`), hesitation (`the dose, the dose was`). USE SPARINGLY. Maybe 1-3 per chapter. Pick proper nouns, technical terms, or moments where the narrator might genuinely catch herself. Avoid stumbling on emotional climaxes — those should land clean.\n\nApart from stumbles, do NOT change a word of the original prose. Return the prose with beat markers, voice tags, and stumbles inline. No preamble. No commentary about your choices.";
/// Single-voice variant of [`NARRATE_PREP_DIRECTIVE`]. Used when the
/// chapter narrates in one voice (no speaker roster). The multi-voice
/// directive's section 2 is dropped entirely AND a hard prohibition
/// is added — without it the model invents `[voice:<slug>]` tags from
/// character names in the prose, which the narrate path then has to
/// detect and neutralize.
const NARRATE_PREP_DIRECTIVE_SINGLE: &str = "This is a NARRATION-ANNOTATION pass. You receive your own prose and prepare it for a SINGLE-narrator audiobook reading — the whole chapter, dialogue included, is read aloud in ONE voice. Two kinds of inserts are allowed:\n\n1. BEAT MARKERS (additive, not prose): `[breath]` (~400ms), `[pause:1.2s]` (explicit silence in seconds, e.g. 0.5s, 1.2s, 2s), `[scene]` (~1500ms scene break). Place where the prose's rhythm asks for them — after a hard one-line beat, before a turn in dialogue, on a paragraph that lands with weight.\n\n2. NARRATOR STUMBLES (humanizing prose-level inserts): a real narrator occasionally stumbles on a hard word, catches themselves, repeats. You may add these *sparingly* where the prose's pacing makes them feel right. Patterns: em-dash repetition (`Prip— Pripyat`), self-correction (`she — no, the wife — had been told`), hesitation (`the dose, the dose was`). USE SPARINGLY. Maybe 1-3 per chapter. Pick proper nouns, technical terms, or moments where the narrator might genuinely catch herself. Avoid stumbling on emotional climaxes — those should land clean.\n\nDo NOT add `[voice:...]` speaker tags of any kind — there is one narrator, not a cast. Apart from stumbles, do NOT change a word of the original prose. Return the prose with beat markers and stumbles inline. No preamble. No commentary about your choices.";
const HOUSE_NARRATE_PREP_SYSTEM: &str = "You are a senior audiobook director annotating prose for narration. You insert (a) beat markers — `[breath]`, `[pause:Xs]`, `[scene]` — where a skilled narrator would breathe or pause, (b) speaker voice tags `[voice:<slug>]\"...\"[/voice]` wrapping dialogue based on who is speaking (roster supplied in user prompt; leave unattributed dialogue unwrapped), and (c) occasional humanizing narrator stumbles using em-dash repetition or self-correction (sparingly — maybe 1-3 per chapter, on proper nouns or hard words). Apart from those stumbles you do NOT change a word of the prose. Return the prose verbatim plus beat markers, voice tags, and (rare) stumbles inline. No preamble, no commentary.";
/// Single-voice variant of [`HOUSE_NARRATE_PREP_SYSTEM`] — no speaker
/// voice tags, one narrator throughout.
const HOUSE_NARRATE_PREP_SYSTEM_SINGLE: &str = "You are a senior audiobook director annotating prose for a SINGLE-narrator reading. You insert (a) beat markers — `[breath]`, `[pause:Xs]`, `[scene]` — where a skilled narrator would breathe or pause, and (b) occasional humanizing narrator stumbles using em-dash repetition or self-correction (sparingly — maybe 1-3 per chapter, on proper nouns or hard words). Do NOT add `[voice:...]` speaker tags — the whole chapter is one voice. Apart from those stumbles you do NOT change a word of the prose. Return the prose verbatim plus beat markers and (rare) stumbles inline. No preamble, no commentary.";
const REWRITE_DIRECTIVE: &str = "This is a REWRITE pass. The user prompt contains a chapter of prose written by another hand. Re-author it entirely in YOUR voice — every sentence reworked in your style: your sentence rhythm, your word choice, your paragraph shape, your way of landing a beat. This is not editing or polishing. It is re-authoring. The reader should not be able to tell another writer ever touched it.\n\nHARD CONSTRAINTS — canon is non-negotiable:\n- Every character name, every date, every place name stays exactly as written.\n- Every event, and the ORDER events happen in, stays exactly as written.\n- Every technical or historical fact stays exactly as written.\n- Do not add new scenes, characters, or events. Do not cut any scene or beat. Same story, same shape — your telling.\n\nReturn ONLY the rewritten chapter prose. Begin with the chapter heading line (`## Chapter N — title`) exactly as in the source. No preamble, no commentary about the rewrite.";
// ─── User-prompt builders ───────────────────────────────────────
fn gen_user_prompt(
context: &str,
direction: Option<&str>,
target_words: Option<i32>,
chapter_n: Option<i32>,
) -> String {
let mut out = String::with_capacity(context.len() + 512);
out.push_str("# Story canon and prior chapters\n\n");
out.push_str(context);
out.push_str("\n\n# User direction\n\n");
out.push_str(direction.unwrap_or("(none — choose what the next chapter should explore based on the canon and recent prose)"));
out.push_str("\n\n# Task\n\n");
if let Some(n) = chapter_n {
out.push_str(&format!("Write Chapter {n}. "));
} else {
out.push_str("Write the next chapter. ");
}
if let Some(w) = target_words {
out.push_str(&format!("Target length: roughly {w} words. "));
}
if let Some(n) = chapter_n {
out.push_str(&format!(
"Begin with the heading line `## Chapter {n} — <title or date>`. "
));
} else {
out.push_str("Begin with a chapter heading line (`## Chapter N — title or date`). ");
}
out.push_str("Return only the chapter prose. No preamble.\n");
out
}
/// One row of the story's character roster, passed to narrate_prep
/// so the LLM knows what speaker slugs to use in `[voice:slug]`
/// tags. Built from skald's characters table.
#[derive(Debug, Clone)]
pub struct CharacterSpeaker {
pub slug: String,
pub name: String,
/// Short note (1 sentence) giving the LLM enough to disambiguate
/// who's speaking when prose says "she said". Pulled from
/// characters.key_facts but trimmed.
pub hint: Option<String>,
}
fn rewrite_user_prompt(prose: &str) -> String {
let mut out = String::with_capacity(prose.len() + 256);
out.push_str("# Chapter to re-author\n\n");
out.push_str(prose);
out.push_str(
"\n\n# Task\n\nRe-author the chapter above entirely in your voice. \
Preserve all canon — names, dates, places, events, the order they \
happen, every technical fact. Change only the prose. Return only \
the rewritten chapter, starting with its `## Chapter N` heading.\n",
);
out
}
fn narrate_prep_user_prompt(prose: &str, characters: &[CharacterSpeaker]) -> String {
let mut out = String::with_capacity(prose.len() + 512);
if !characters.is_empty() {
out.push_str("# Speaker roster\n\n");
out.push_str(
"Use these slugs in `[voice:<slug>]\"...\"[/voice]` wrappers on dialogue. \
Leave dialogue without a clear roster speaker unwrapped (the narrator \
voice will read it).\n\n",
);
for c in characters {
out.push_str("- `");
out.push_str(&c.slug);
out.push_str("` — ");
out.push_str(&c.name);
if let Some(h) = &c.hint {
if !h.trim().is_empty() {
out.push_str(" (");
out.push_str(h.trim());
out.push(')');
}
}
out.push('\n');
}
out.push('\n');
}
out.push_str("# Prose to annotate\n\n");
out.push_str(prose);
if characters.is_empty() {
out.push_str(
"\n\n# Task\n\nReturn the prose above with `[breath]`, `[pause:Xs]`, \
`[scene]` beat markers inserted appropriately. Do NOT add any \
`[voice:...]` tags — this is a single-voice reading. Do not \
change any word. Do not skip any sentence. Return only the \
annotated prose.\n",
);
} else {
out.push_str(
"\n\n# Task\n\nReturn the prose above with `[breath]`, `[pause:Xs]`, \
`[scene]` markers and `[voice:<slug>]\"...\"[/voice]` dialogue wrappers \
inserted appropriately. Do not change any word. Do not skip any \
sentence. Return only the annotated prose.\n",
);
}
out
}
fn cleanup_user_prompt(draft: &str, context: &str, chapter_n: Option<i32>) -> String {
let mut out = String::with_capacity(context.len() + draft.len() + 512);
out.push_str("# Story canon (for reference — do not retcon)\n\n");
out.push_str(context);
out.push_str("\n\n# Draft to polish\n\n");
out.push_str(draft);
out.push_str("\n\n# Task\n\nPolish the draft above for prose quality. Tighten dialogue, fix pacing dead spots, hold the voice steady. Do NOT add new plot. Do NOT retcon canon. Return only the polished chapter.");
if let Some(n) = chapter_n {
out.push_str(&format!(
" Preserve the chapter heading: `## Chapter {n} — <title or date>`."
));
}
out.push('\n');
out
}
fn build_audit_request(model: &str, parent: &str, sequel: &str, bible: &str) -> RunRequest {
let prompt = format!(
"## Bible\n\n{bible}\n\n## Parent story prose\n\n{parent}\n\n## Sequel story prose\n\n{sequel}\n\nReturn JSON only matching the schema described in the system prompt.",
);
RunRequest {
prompt,
model: Some(model.to_string()),
system: Some(SYSTEM_AUDIT.to_string()),
// Audit is the one pass that keeps MAX effort — catching
// canon drift, timeline gaps and retcons is reasoning work
// worth the frontier spend; prose-craft passes run at high.
effort: Some(Effort::Max),
timeout_secs: Some(600),
..Default::default()
}
}
/// Audit finding shape returned by the audit pass. Parses out of the
/// `result` field on the audit pass's [`RunResult`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditFinding {
pub severity: String,
pub area: String,
pub body: String,
}
/// Wrapper shape for the audit response.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditResponse {
pub findings: Vec<AuditFinding>,
}