summarize: first real forge call — generate per-chapter summaries

skald summarize --story <uuid> walks every chapter without an
existing summary, calls Forge::summarize() (clawdforge → opus →
~250 words of plot/character/setting/threads), and inserts the
result into chapter_summaries.

Side effects:
- generation_runs row per chapter (kind='summary', status flow
  running → succeeded|failed). Errors update the row + bail; happy
  path closes it with ended_at + tokens.
- ON CONFLICT (chapter_id) means re-running with --force replaces
  the previous summary cleanly.

CLI:
  skald summarize --story <uuid>           # only-missing
  skald summarize --story <uuid> --force   # re-summarize all

Reads from env (loaded by skald.env in the container):
  CLAWDFORGE_URL    — base URL of clawdforge HTTP service
  CLAWDFORGE_TOKEN  — app-level bearer (per-app, not the admin token)
  SKALD_MODEL       — defaults to 'opus'

This is the first subcommand that actually exercises the forge.
Unlocks ContinuationContext::assemble's coverage metric (was stuck
at 24%% on Coast-Down because the 5 placeholder summaries don't
actually carry the prose). After running summarize against
Coast-Down: coverage should jump to ~100%% and the context blob
for any sequel becomes fully canon-faithful without dragging the
full ~21k words of earlier-chapter prose along.

Forge prompt template for summarize ships REAL (not stubbed) — it's
the simplest pass and has a well-defined shape. The gen/cleanup/
audit prompts remain stubs pending the deeper prose-craft session.
This commit is contained in:
Kayos 2026-05-13 10:42:51 -07:00
parent b32938ef43
commit 39e991240a
3 changed files with 247 additions and 0 deletions

View file

@ -66,6 +66,8 @@ pub enum PassKind {
Cleanup,
/// Canon audit across parent + sequel. Outputs findings JSON.
Audit,
/// Chapter summary for cheap context loading on long series.
Summary,
}
impl PassKind {
@ -74,6 +76,7 @@ impl PassKind {
Self::Gen => "gen",
Self::Cleanup => "cleanup",
Self::Audit => "audit",
Self::Summary => "summary",
}
}
}
@ -143,8 +146,46 @@ impl Forge {
let duration_ms = r.duration_ms;
Ok(PassOutput { kind: PassKind::Audit, 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
/// in full prose; carries summaries of books 1-10 + full prose of
/// books 11-12).
///
/// Unlike gen/cleanup/audit, summarize has a real prompt template
/// shipped here — summarization is a simple, well-defined task
/// and doesn't need the prose-craft TODO treatment.
pub async fn summarize(&self, chapter_body_md: &str, chapter_label: &str) -> anyhow::Result<PassOutput> {
let prompt = format!(
"Summarize the following chapter in ~250 words for use as future \
sequel context. Capture: (1) plot beats in order, (2) character \
developments and emotional shifts, (3) setting changes, (4) any \
explicit or implied unresolved threads, (5) the chapter's \
closing position for each named character.\n\nReturn prose only \
no headings, no bullet lists, no commentary about the task. \
Write as if you're handing this to another author who needs to \
write the next chapter without re-reading this one.\n\n\
## {chapter_label}\n\n{chapter_body_md}"
);
let body = RunRequest {
prompt,
model: Some(self.model.clone()),
system: Some(SYSTEM_SUMMARIZE.to_string()),
timeout_secs: Some(300),
..Default::default()
};
let r = self.client.run(body).await?;
let duration_ms = r.duration_ms;
Ok(PassOutput { kind: PassKind::Summary, result: r, duration_ms })
}
}
const SYSTEM_SUMMARIZE: &str = "You are a continuity assistant for a long-form \
fiction author. You write chapter summaries that future authors of sequels \
will read to understand what happened. Be specific. Names, dates, locations. \
Don't editorialize just compress the events.";
fn build_request(model: &str, kind: PassKind, primary: &str, context: &str, system: &str) -> RunRequest {
let prompt = format!(
"# Pass: {kind}\n\n## Context\n\n{context}\n\n## Input\n\n{primary}",