Adds `skald dedup --story <id>`: reads a story's most recent prose-audit findings and walks every chapter, handing the author the chapter prose + the findings with instructions to rephrase ONLY the flagged repetitions (each recurrence made distinct) and fix flagged continuity errors — everything else stays verbatim. A surgical dedup, not a rewrite. Overwrites body_md, clears body_md_tts so the chapter is re-prepped before narration. High effort (prose-craft). Migration 0011 adds the 'dedup' run kind. Completes the QC loop: audit (find) -> dedup (fix) -> re-audit.
691 lines
38 KiB
Rust
691 lines
38 KiB
Rust
//! 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,
|
||
/// Whole-story prose-quality audit — reads a finished story end
|
||
/// to end and flags repetition, template tics, self-restatement
|
||
/// and continuity drift. The QC gate before narration.
|
||
ProseAudit,
|
||
/// Surgical dedup — takes a chapter plus the story's audit
|
||
/// findings and rephrases only the flagged repetitions, leaving
|
||
/// everything else verbatim. The fix half of the audit loop.
|
||
Dedup,
|
||
}
|
||
|
||
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",
|
||
Self::ProseAudit => "prose_audit",
|
||
Self::Dedup => "dedup",
|
||
}
|
||
}
|
||
}
|
||
|
||
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 run 10–20 min wall clock;
|
||
// the max-effort whole-book prose audit can run past an hour.
|
||
// This is the client-side ceiling — set high enough to cover
|
||
// the slowest pass; each pass still sends its own server-side
|
||
// timeout_secs. Default 120s would strand any real pass.
|
||
.timeout(Duration::from_secs(7200))
|
||
.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,
|
||
})
|
||
}
|
||
|
||
/// Whole-story prose-quality audit. Receives every chapter of a
|
||
/// finished story concatenated in order and returns findings JSON
|
||
/// — repetition, template tics, self-restatement, continuity
|
||
/// drift. This is the QC gate before a story goes to narration,
|
||
/// where repetition a silent reader skims becomes glaring.
|
||
///
|
||
/// HIGH effort: finding repeated passages and motif reuse is
|
||
/// comparison work, not deep reasoning — high-effort Opus does it
|
||
/// well in minutes. A max-effort pass over a whole-book input
|
||
/// (quarter-million chars) runs an impractical hour-plus.
|
||
pub async fn prose_audit(&self, full_story: &str) -> anyhow::Result<PassOutput> {
|
||
let prompt = format!(
|
||
"Audit the complete story below for repetition, template tics, \
|
||
self-restatement and continuity errors. Read every chapter. \
|
||
Return JSON only, matching the schema in the system prompt.\n\n\
|
||
{full_story}"
|
||
);
|
||
let body = RunRequest {
|
||
prompt,
|
||
model: Some(self.model.clone()),
|
||
system: Some(SYSTEM_PROSE_AUDIT.to_string()),
|
||
effort: Some(Effort::High),
|
||
// High effort over a whole book lands in minutes; 3600s is
|
||
// a generous ceiling that won't bite.
|
||
timeout_secs: Some(3600),
|
||
..Default::default()
|
||
};
|
||
let r = self.client.run(body).await?;
|
||
let duration_ms = r.duration_ms;
|
||
Ok(PassOutput { kind: PassKind::ProseAudit, result: r, duration_ms })
|
||
}
|
||
|
||
/// Surgical dedup of one chapter — the fix half of the audit
|
||
/// loop. Receives the chapter's prose plus the whole story's
|
||
/// audit findings, and rephrases ONLY the flagged repetitions
|
||
/// that occur in this chapter; everything the findings do not
|
||
/// flag stays verbatim. Author REQUIRED — the fresh phrasing
|
||
/// lands in the author's voice (SystemMode::Replace). High
|
||
/// effort: it is prose-craft, same posture as rewrite.
|
||
pub async fn dedup(
|
||
&self,
|
||
prose: &str,
|
||
findings: &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}}", DEDUP_DIRECTIVE)
|
||
.replace("{{soul}}", &author.revision.soul);
|
||
let user_prompt = dedup_user_prompt(prose, findings);
|
||
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::Dedup, 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.\n\nGuard against repetition. If a recurring image, motif or descriptive beat appeared in an earlier chapter, render it in fresh words here — never reuse the same sentence shape or verb sequence for it. Do not stack consecutive sentences on one template (\"He thought… He thought…\", \"She felt… She felt…\"). Vary how you land beats. This will be read aloud by a single narrator, so phrasing a silent reader skims is glaring to a listener.";
|
||
|
||
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.\n\nHunt repetition hard — this prose will be narrated aloud, where repetition is far more glaring than on the page. If a recurring image or motif appears more than once, re-phrase every occurrence so no two share a sentence shape or verb sequence. Do not stack more than two consecutive sentences on the same opening stem (\"He thought… He thought…\"). Echo a line of dialogue back verbatim at most once in the whole chapter. If a sentence merely restates something already said, cut it. 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_PROSE_AUDIT: &str = "You are a ruthless prose-quality auditor for long-form fiction destined for single-voice audiobook narration. You receive a complete story — every chapter, in order — and you read all of it. You return STRUCTURED JSON ONLY: no commentary, no preamble. You hunt four things:\n\n1. REPETITION across the whole book: a recurring image, motif or descriptive beat rendered in near-identical wording more than once; a sentence (or near-identical sentence) reused in different chapters; a line of dialogue echoed verbatim.\n2. TEMPLATE TICS: the same sentence template stacked or over-reused — e.g. clusters of consecutive sentences all opening 'He thought:' / 'She felt:' — anywhere it becomes a noticeable pattern.\n3. SELF-RESTATEMENT: a sentence or paragraph that says again, slightly reworded, something the text already said.\n4. CONTINUITY ERRORS: a fact, name, age, date or detail that contradicts an earlier one.\n\nThis prose will be read ALOUD by one narrator, so repetition a silent reader skims is glaring to a listener — be exhaustive, do not let near-duplicates pass. Return EXACTLY this shape: { \"findings\": [ { \"severity\": \"info\"|\"warn\"|\"crit\", \"area\": \"repetition\"|\"continuity\"|\"other\", \"body\": \"...\" } ] }. In each finding's body, quote the offending text exactly and name the chapter number(s). Use 'crit' for anything a listener will plainly hear as a mistake, 'warn' for noticeable repetition, 'info' for minor. If the story is clean, return { \"findings\": [] }.";
|
||
|
||
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 DEDUP_DIRECTIVE: &str = "This is a DEDUP pass. The user prompt contains ONE chapter of a story you wrote, plus a list of audit findings — repeated phrases, motifs, similes, sentence templates and continuity errors found across the whole book. Your job: return this chapter with every flagged repetition that occurs IN IT rephrased fresh, and everything else byte-identical.\n\nHARD RULES:\n- For any motif, simile, phrase, image or structural tic the findings flag as recurring: if it appears in THIS chapter, render this chapter's occurrence in fresh, distinctive wording. Never reuse the flagged original phrasing. The other chapters' occurrences are being revised separately — do NOT try to coordinate with them; just make yours distinct from the flagged original.\n- Fix any continuity error the findings flag that touches this chapter (a wrong age, number, name, date) — use the correct value the findings identify.\n- Change NOTHING the findings do not flag. Every sentence not implicated by a finding stays EXACTLY as written, word for word. This is not a rewrite, not a polish, not an edit for taste — it is a surgical dedup. When in doubt, leave it.\n- Canon is absolute: names, dates, events, the order they happen, every fact — unchanged. The chapter stays the same length and shape.\n- Return ONLY the chapter prose. No heading unless the source had one. No preamble, no commentary, no list of what you changed.\n\n";
|
||
|
||
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 dedup_user_prompt(prose: &str, findings: &str) -> String {
|
||
let mut out = String::with_capacity(prose.len() + findings.len() + 512);
|
||
out.push_str("# Audit findings for this story\n\n");
|
||
out.push_str(
|
||
"These repetitions and errors were found across the whole book. \
|
||
Fix only the ones that occur in the chapter below.\n\n",
|
||
);
|
||
out.push_str(findings);
|
||
out.push_str("\n\n# Chapter to dedup\n\n");
|
||
out.push_str(prose);
|
||
out.push_str(
|
||
"\n\n# Task\n\nReturn the chapter above with every flagged repetition \
|
||
that appears in it rephrased fresh, and any flagged continuity error \
|
||
touching it corrected. Leave every unflagged sentence verbatim. \
|
||
Return only the chapter prose.\n",
|
||
);
|
||
out
|
||
}
|
||
|
||
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>,
|
||
}
|