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

@ -0,0 +1,15 @@
-- The rewrite pass: an author re-authors existing chapter prose in
-- their own voice (canon preserved, prose reworked). body_md gets
-- overwritten with the rewritten version; body_md_original keeps
-- the pre-rewrite prose so the original is never lost. Populated
-- only on the FIRST rewrite of a chapter (if NULL) — subsequent
-- rewrites leave the original alone.
ALTER TABLE chapters
ADD COLUMN IF NOT EXISTS body_md_original text;
-- Allow 'rewrite' as a generation_runs.kind.
ALTER TABLE generation_runs
DROP CONSTRAINT generation_runs_kind_check;
ALTER TABLE generation_runs
ADD CONSTRAINT generation_runs_kind_check
CHECK (kind = ANY (ARRAY['gen', 'cleanup', 'audit', 'summary', 'embed', 'narrate_prep', 'rewrite']));

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);

View file

@ -9,6 +9,7 @@ mod continue_story;
mod import;
mod narrate;
mod narrate_prep;
mod rewrite;
mod serve;
mod show_context;
mod summarize;
@ -155,6 +156,23 @@ enum Cmd {
/// errors out to avoid clobbering a hand-tuned version.
#[arg(long)]
overwrite: bool,
/// Single-voice mode: skip the character speaker roster so
/// no [voice:X] dialogue tags are inserted. Use when the
/// whole chapter narrates in one voice.
#[arg(long)]
single_voice: bool,
},
/// Re-author one chapter's prose in an author's voice. Canon
/// preserved, prose reworked. Overwrites body_md (stashing the
/// original in body_md_original) and clears body_md_tts.
Rewrite {
/// Chapter UUID to re-author.
#[arg(long)]
chapter: Uuid,
/// Author slug to rewrite as. Falls back to the story's
/// bound author if omitted.
#[arg(long)]
author: Option<String>,
},
}
@ -230,8 +248,19 @@ async fn run() -> anyhow::Result<()> {
chapter,
author,
overwrite,
single_voice,
} => {
narrate_prep::run(&cli.database_url, chapter, author.as_deref(), overwrite).await
narrate_prep::run(
&cli.database_url,
chapter,
author.as_deref(),
overwrite,
single_voice,
)
.await
}
Cmd::Rewrite { chapter, author } => {
rewrite::run(&cli.database_url, chapter, author.as_deref()).await
}
}
}

View file

@ -24,6 +24,7 @@ pub async fn run(
chapter_id: Uuid,
author_slug: Option<&str>,
overwrite: bool,
single_voice: bool,
) -> anyhow::Result<()> {
let cfg = load_forge_config()?;
tracing::info!(base_url = %cfg.base_url, model = %cfg.model, "forge configured");
@ -60,10 +61,20 @@ pub async fn run(
.fetch_one(&pool)
.await?;
let characters = load_speakers(&pool, chapter.story_id).await?;
if !characters.is_empty() {
tracing::info!(speaker_count = characters.len(), "speaker roster loaded");
}
// Single-voice mode skips the speaker roster entirely — the
// narrate_prep pass then inserts only [breath]/[pause]/[scene]
// beats, no [voice:X] dialogue tags. Right when the whole
// chapter narrates in one voice.
let characters = if single_voice {
tracing::info!("single-voice mode — skipping speaker roster");
Vec::new()
} else {
let c = load_speakers(&pool, chapter.story_id).await?;
if !c.is_empty() {
tracing::info!(speaker_count = c.len(), "speaker roster loaded");
}
c
};
let started = Instant::now();
let out_res = forge

252
skald/src/rewrite.rs Normal file
View file

@ -0,0 +1,252 @@
//! `skald rewrite` — re-author one chapter's prose in an author's
//! voice. Canon preserved, prose reworked. Overwrites chapters.body_md
//! with the rewritten version; the pre-rewrite prose is stashed in
//! chapters.body_md_original on the first rewrite (if NULL) so the
//! original is never lost.
//!
//! Author resolution: --author flag wins, else the chapter's
//! story.author_id. A rewrite with no author errors — there's no
//! target voice.
use std::time::Instant;
use anyhow::{Context, bail};
use chrono::Utc;
use skald_core::authors::{self, AuthorWithRevision};
use skald_core::config::ForgeConfig;
use skald_core::db;
use skald_core::forge::{Forge, PassKind, PassOutput};
use sqlx::PgPool;
use uuid::Uuid;
pub async fn run(
database_url: &str,
chapter_id: Uuid,
author_slug: Option<&str>,
) -> anyhow::Result<()> {
let cfg = load_forge_config()?;
tracing::info!(base_url = %cfg.base_url, model = %cfg.model, "forge configured");
let pool = db::connect_and_migrate(database_url).await?;
let forge = Forge::new(&cfg)?;
let chapter = load_chapter(&pool, chapter_id).await?;
let author = resolve_author(&pool, &chapter, author_slug)
.await?
.ok_or_else(|| {
anyhow::anyhow!(
"rewrite needs an author — pass --author <slug> or bind one to the story"
)
})?;
tracing::info!(
slug = %author.author.slug,
revision_n = author.revision.n,
chapter_n = chapter.n,
word_count_in = word_count(&chapter.body_md),
"re-authoring chapter",
);
let run_id: Uuid = sqlx::query_scalar(
"INSERT INTO generation_runs (story_id, kind, status) VALUES ($1, $2, 'running') RETURNING id",
)
.bind(chapter.story_id)
.bind(PassKind::Rewrite.as_str())
.fetch_one(&pool)
.await?;
let started = Instant::now();
let out_res = forge.rewrite(&chapter.body_md, &author).await;
let elapsed = started.elapsed();
let out: PassOutput = match out_res {
Ok(o) => o,
Err(e) => {
sqlx::query(
"UPDATE generation_runs SET status='failed', error=$1, ended_at=$2 WHERE id=$3",
)
.bind(format!("{e:#}"))
.bind(Utc::now())
.bind(run_id)
.execute(&pool)
.await?;
return Err(e);
}
};
let rewritten = pass_text(&out)?;
let (_n, title, body) = parse_chapter(&rewritten);
// Stash the original on first rewrite, then overwrite body_md.
// body_md_tts is cleared — it was annotated against the OLD
// prose and must be regenerated by a fresh prepare-narration.
sqlx::query(
"UPDATE chapters
SET body_md_original = COALESCE(body_md_original, body_md),
body_md = $1,
title = COALESCE($2, title),
body_md_tts = NULL,
word_count = $3,
generated_at = now()
WHERE id = $4",
)
.bind(&body)
.bind(title.as_deref())
.bind(word_count(&body))
.bind(chapter_id)
.execute(&pool)
.await?;
// Replace passages with the rewritten paragraphs.
sqlx::query("DELETE FROM passages WHERE chapter_id = $1")
.bind(chapter_id)
.execute(&pool)
.await?;
for (i, para) in body.split("\n\n").enumerate() {
let p = para.trim();
if p.is_empty() || p == "---" {
continue;
}
sqlx::query("INSERT INTO passages (chapter_id, paragraph_n, body) VALUES ($1, $2, $3)")
.bind(chapter_id)
.bind(i as i32 + 1)
.bind(p)
.execute(&pool)
.await?;
}
sqlx::query(
"UPDATE stories SET word_count_actual = (SELECT COALESCE(SUM(word_count), 0) FROM chapters WHERE story_id = $1) WHERE id = $1",
)
.bind(chapter.story_id)
.execute(&pool)
.await?;
sqlx::query("UPDATE generation_runs SET status='succeeded', ended_at=$1 WHERE id=$2")
.bind(Utc::now())
.bind(run_id)
.execute(&pool)
.await?;
println!(
"rewrote chapter {} of story {} as {} ({} → {} words) in {:.1}s",
chapter.n,
chapter.story_id,
author.author.slug,
word_count(&chapter.body_md),
word_count(&body),
elapsed.as_secs_f32(),
);
Ok(())
}
#[derive(Debug, Clone)]
struct ChapterRow {
story_id: Uuid,
n: i32,
body_md: String,
story_author_id: Option<Uuid>,
}
async fn load_chapter(pool: &PgPool, id: Uuid) -> anyhow::Result<ChapterRow> {
let row: Option<(Uuid, i32, String, Option<Uuid>)> = sqlx::query_as(
"SELECT c.story_id, c.n, c.body_md, s.author_id
FROM chapters c JOIN stories s ON s.id = c.story_id
WHERE c.id = $1",
)
.bind(id)
.fetch_optional(pool)
.await?;
let (story_id, n, body_md, story_author_id) =
row.with_context(|| format!("chapter {id} not found"))?;
Ok(ChapterRow {
story_id,
n,
body_md,
story_author_id,
})
}
async fn resolve_author(
pool: &PgPool,
chapter: &ChapterRow,
flag_slug: Option<&str>,
) -> anyhow::Result<Option<AuthorWithRevision>> {
if let Some(slug) = flag_slug {
return authors::get_with_current_revision(pool, slug)
.await?
.map(Some)
.with_context(|| format!("author '{slug}' not found"));
}
if let Some(aid) = chapter.story_author_id {
let row: Option<(String,)> = sqlx::query_as("SELECT slug FROM authors WHERE id = $1")
.bind(aid)
.fetch_optional(pool)
.await?;
if let Some((slug,)) = row {
return Ok(authors::get_with_current_revision(pool, &slug).await?);
}
}
Ok(None)
}
fn pass_text(out: &PassOutput) -> anyhow::Result<String> {
let text = out
.result
.as_text()
.map(|s| s.to_string())
.or_else(|| out.result.result.as_str().map(|s| s.to_string()))
.unwrap_or_else(|| out.result.result.to_string());
if text.trim().is_empty() {
bail!("rewrite pass returned empty");
}
Ok(text)
}
/// Parse (n, title, body) out of the rewritten chapter. Tolerant of
/// a missing heading — if the first line isn't a heading we keep the
/// whole text as body and return n=0 (caller keeps the existing n).
fn parse_chapter(text: &str) -> (i32, Option<String>, String) {
let trimmed = text.trim_start();
let first = trimmed.lines().next().unwrap_or("").trim();
if let Some(heading) = first.strip_prefix('#') {
let heading = heading.trim_start_matches('#').trim();
let n = heading
.to_lowercase()
.find("chapter")
.and_then(|idx| {
heading[idx + 7..]
.trim_start()
.split([' ', '—', '-', ':', ','])
.next()
.and_then(|w| w.parse::<i32>().ok())
})
.unwrap_or(0);
let title = heading
.split_once("")
.or_else(|| heading.split_once(" - "))
.map(|(_, t)| t.trim().to_string())
.filter(|t| !t.is_empty());
let body = trimmed
.lines()
.skip(1)
.collect::<Vec<_>>()
.join("\n")
.trim_start()
.to_string();
let body = if body.is_empty() { text.trim().to_string() } else { body };
return (n, title, body);
}
(0, None, text.trim().to_string())
}
fn word_count(s: &str) -> i32 {
s.split_whitespace().count() as i32
}
fn load_forge_config() -> anyhow::Result<ForgeConfig> {
let base_url = std::env::var("CLAWDFORGE_URL")
.context("CLAWDFORGE_URL not set")?;
let app_token = std::env::var("CLAWDFORGE_TOKEN")
.context("CLAWDFORGE_TOKEN not set")?;
let model = std::env::var("SKALD_MODEL").unwrap_or_else(|_| "opus".into());
Ok(ForgeConfig { base_url, app_token, model })
}