forge: rewrite pass — re-author prose in an author's voice

New Forge::rewrite + PassKind::Rewrite. An author re-authors
existing chapter prose entirely in their voice — sentence rhythm,
word choice, paragraph shape all become theirs — while canon
(names, dates, places, events, order, technical facts) is preserved
exactly. Not editing; re-authoring. SystemMode::Replace, max effort.

skald rewrite --chapter <uuid> [--author slug] overwrites body_md
with the rewritten version. The pre-rewrite prose is stashed in the
new chapters.body_md_original column on first rewrite (migration
0008, idempotent) so the original is never lost. body_md_tts is
cleared — it was annotated against the old prose and must be
regenerated by a fresh prepare-narration.

prepare-narration gains --single-voice: skips the character speaker
roster so no [voice:X] dialogue tags are inserted, only beat
markers. Right for one-voice narration.

Migration 0008 also extends generation_runs.kind to allow 'rewrite'.
This commit is contained in:
Kayos 2026-05-14 21:35:20 -07:00
parent 303b6c73f4
commit d2442f0a87
5 changed files with 371 additions and 5 deletions

View file

@ -74,6 +74,10 @@ pub enum PassKind {
/// 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 {
@ -84,6 +88,7 @@ impl PassKind {
Self::Audit => "audit",
Self::Summary => "summary",
Self::NarratePrep => "narrate_prep",
Self::Rewrite => "rewrite",
}
}
}
@ -237,6 +242,45 @@ impl Forge {
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.
/// Max effort: re-authoring is the heaviest prose-craft task.
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::Max),
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
@ -349,6 +393,8 @@ const NARRATE_PREP_DIRECTIVE: &str = "This is a NARRATION-ANNOTATION pass. You r
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.";
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(
@ -395,6 +441,19 @@ pub struct CharacterSpeaker {
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);