forge: skald owns chapter numbers, not the LLM

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<i32>. 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.
This commit is contained in:
Kayos 2026-05-13 18:29:22 -07:00
parent 75a609d507
commit aece970b50
2 changed files with 70 additions and 22 deletions

View file

@ -116,8 +116,9 @@ impl Forge {
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);
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<i32>,
) -> anyhow::Result<PassOutput> {
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<i32>) -> String {
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\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} — <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
}
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
}

View file

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