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