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:
parent
75a609d507
commit
aece970b50
2 changed files with 70 additions and 22 deletions
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue