From aece970b50e8f7005c627b6b9220efcb64076039 Mon Sep 17 00:00:00 2001 From: Kayos Date: Wed, 13 May 2026 18:29:22 -0700 Subject: [PATCH] forge: skald owns chapter numbers, not the LLM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caught on the 2026-05-13 Coast-Down 10-chapter Orson run: the LLM labeled two chapters 'Chapter 1' instead of 9 and 10, and ON CONFLICT (story_id, n) DO UPDATE silently overwrote them. 8 visible chapters from 10 successful gen+cleanup passes; 27k words of work, ~6k buried. The audit caught the symptom but the data damage was already done. Fix: - continue_story::run computes next_n from MAX(chapters.n) before the batch loop; each iteration's authoritative n is next_n, incremented after success. - forge::generate + cleanup take chapter_n: Option. The gen prompt is now 'Write Chapter N. Begin with: ## Chapter N — ...' instead of the vague 'Write the next chapter.' - We still parse_chapter() the LLM output but only to extract the title; if the LLM-returned n disagrees with ours, we log a warn and use the authoritative N at INSERT time. The (story_id, n) unique constraint stays — it's now a defensive catch for skald bugs, not the LLM's free-spirited numbering. --- skald-core/src/forge.rs | 43 +++++++++++++++++++++++++------- skald/src/continue_story.rs | 49 +++++++++++++++++++++++++++---------- 2 files changed, 70 insertions(+), 22 deletions(-) diff --git a/skald-core/src/forge.rs b/skald-core/src/forge.rs index 1416349..4b61a4e 100644 --- a/skald-core/src/forge.rs +++ b/skald-core/src/forge.rs @@ -116,8 +116,9 @@ impl Forge { direction: Option<&str>, target_words: Option, author: Option<&AuthorWithRevision>, + chapter_n: Option, ) -> anyhow::Result { - let user_prompt = gen_user_prompt(context, direction, target_words); + 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, @@ -140,8 +141,9 @@ impl Forge { draft: &str, context: &str, author: Option<&AuthorWithRevision>, + chapter_n: Option, ) -> anyhow::Result { - let user_prompt = cleanup_user_prompt(draft, context); + let user_prompt = cleanup_user_prompt(draft, context, chapter_n); let (system, mode) = compose_system(author, PassKind::Cleanup); let body = RunRequest { prompt: user_prompt, @@ -287,27 +289,50 @@ const SYSTEM_AUDIT: &str = "You are a canon auditor for long-form fiction. You c // ─── User-prompt builders ─────────────────────────────────────── -fn gen_user_prompt(context: &str, direction: Option<&str>, target_words: Option) -> String { +fn gen_user_prompt( + context: &str, + direction: Option<&str>, + target_words: Option, + chapter_n: Option, +) -> 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\nWrite the next chapter. "); - if let Some(w) = target_words { - out.push_str(&format!("Target length: roughly {} words. ", w)); + 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. "); } - out.push_str("Begin with a chapter heading line (`## Chapter N — title or date`). Return only the chapter prose. No preamble.\n"); + 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} — `. " + )); + } 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 } -fn cleanup_user_prompt(draft: &str, context: &str) -> String { +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.\n"); + 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 } diff --git a/skald/src/continue_story.rs b/skald/src/continue_story.rs index a581f06..017cc06 100644 --- a/skald/src/continue_story.rs +++ b/skald/src/continue_story.rs @@ -100,6 +100,19 @@ pub async fn run( } }; + // Authoritative starting chapter number. The LLM is unreliable + // at picking the right N — caught on the 2026-05-13 Coast-Down + // 10-chapter run, where two chapters were labeled "Chapter 1" + // instead of 9 and 10, and ON CONFLICT silently overwrote them. + // Skald owns the chapter number; the LLM gets a hint and we + // ignore what it returns. + let starting_max_n: Option<i32> = + sqlx::query_scalar("SELECT MAX(n) FROM chapters WHERE story_id = $1") + .bind(story_id) + .fetch_one(&pool) + .await?; + let mut next_n = starting_max_n.unwrap_or(0) + 1; + // Chapters this batch has written so far; appended to context // for each subsequent iteration so the LLM sees what it just // wrote. Each entry: (n, title, body). @@ -124,20 +137,29 @@ pub async fn run( // follow naturally from the just-written prose. let dir = if batch_i == 0 { direction } else { None }; + let chapter_n = next_n; + // ─── gen pass ────────────────────────────────────────── set_status(&pool, story_id, "generating").await?; let gen_out = run_pass(&pool, story_id, PassKind::Gen, async { - forge.generate(&context_md, dir, target, author.as_ref()).await + forge.generate(&context_md, dir, target, author.as_ref(), Some(chapter_n)).await }) .await?; let gen_text = pass_text(&gen_out, "gen")?; - let (chapter_n, chapter_title, chapter_body) = parse_chapter(&gen_text)?; - let chapter_id = insert_chapter(&pool, story_id, chapter_n, chapter_title.as_deref(), &chapter_body).await?; + let (llm_n, parsed_title, chapter_body) = parse_chapter(&gen_text)?; + if llm_n != chapter_n { + tracing::warn!( + expected = chapter_n, + llm_returned = llm_n, + "gen pass returned wrong chapter number — using authoritative N", + ); + } + let chapter_id = insert_chapter(&pool, story_id, chapter_n, parsed_title.as_deref(), &chapter_body).await?; tracing::info!( batch_i = batch_i + 1, of = chapter_count, chapter_n, - title = %chapter_title.as_deref().unwrap_or(""), + title = %parsed_title.as_deref().unwrap_or(""), body_chars = chapter_body.len(), "gen pass stored", ); @@ -145,27 +167,28 @@ pub async fn run( // ─── cleanup pass ────────────────────────────────────── set_status(&pool, story_id, "cleaning").await?; let cleanup_out = run_pass(&pool, story_id, PassKind::Cleanup, async { - forge.cleanup(&chapter_body, &context_md, author.as_ref()).await + forge.cleanup(&chapter_body, &context_md, author.as_ref(), Some(chapter_n)).await }) .await?; let cleanup_text = pass_text(&cleanup_out, "cleanup")?; - let (cn2, ct2, cb2) = parse_chapter(&cleanup_text)?; - if cn2 != chapter_n { + let (cleaned_llm_n, cleaned_title, cleaned_body) = parse_chapter(&cleanup_text)?; + if cleaned_llm_n != chapter_n { tracing::warn!( - original = chapter_n, - cleaned = cn2, - "cleanup pass changed chapter number — keeping the cleaned version", + expected = chapter_n, + llm_returned = cleaned_llm_n, + "cleanup pass returned wrong chapter number — using authoritative N", ); } - replace_chapter(&pool, chapter_id, ct2.as_deref(), &cb2).await?; + replace_chapter(&pool, chapter_id, cleaned_title.as_deref(), &cleaned_body).await?; tracing::info!( batch_i = batch_i + 1, of = chapter_count, - body_chars = cb2.len(), + body_chars = cleaned_body.len(), "cleanup pass stored", ); - written_this_batch.push((cn2, ct2, cb2)); + written_this_batch.push((chapter_n, cleaned_title, cleaned_body)); + next_n += 1; } // ─── audit pass (once at end) ────────────────────────────────