v0.3 step 1: migration 0004 + authors module + web form panels
Migration 0004 — authors + author_revisions + stories.author_id + stories.author_revision_id + stories.cross_story_memory + author_corpus. Soul versioning built in from day one per cobb's locked decisions: - authors.id immutable identity (slug + display_name + tagline + model) - author_revisions tracks each soul revision with n monotonic - Partial unique index 'idx_author_revisions_current' enforces exactly one is_current=true per author - stories.author_revision_id pins to the exact soul used at gen time (so 'this was the Orson Black active when chapter 8 was written' is always recoverable) - author_corpus tracks 'authored' + 'read' relationships for the v0.3 cross-story memory toggle skald-core::authors module — CRUD: get_by_slug, get_with_current_revision, get_current_revision, get_revision, create_or_get (idempotent), add_revision (transactional, demotes prior is_current=true), assign_to_story (also touches author_corpus). Web v0.1 forms (the second feedback bucket — 'no way to make new stories', 'no options for sequels'): handlers + form panels + POST routes for /stories/new and /stories/:id/continue. Both create a story stub with status='seed'; actual generation will be fired by 'skald continue' (next commit) walking seed rows. Norse visual revamp + mobile collapse deferred — vetting full gen is the priority per cobb's 'green light for v0.3'. Coming back to the aesthetic after the pipeline works end-to-end against a real Orson Black-authored Chapter 8 of Coast-Down.
This commit is contained in:
parent
7187bf5ace
commit
713ba41977
4 changed files with 529 additions and 1 deletions
263
skald-core/src/authors.rs
Normal file
263
skald-core/src/authors.rs
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
//! Authors module. Owns the read/write paths for the persona layer.
|
||||
//!
|
||||
//! An [`Author`] is the immutable identity (slug, display_name, model).
|
||||
//! A [`Revision`] is the versioned soul content — exactly one revision
|
||||
//! per author is marked `is_current = true` at any time.
|
||||
//!
|
||||
//! When skald-the-binary runs a forge pass, it pulls the current
|
||||
//! revision via [`get_with_current_revision`], stuffs the soul into
|
||||
//! the system prompt with `SystemMode::Replace`, and the model
|
||||
//! becomes the author for the duration of the call.
|
||||
|
||||
use anyhow::{Context, bail};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Author {
|
||||
pub id: Uuid,
|
||||
pub slug: String,
|
||||
pub display_name: String,
|
||||
pub persona_tagline: Option<String>,
|
||||
pub model: String,
|
||||
pub is_synthetic: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Revision {
|
||||
pub id: Uuid,
|
||||
pub author_id: Uuid,
|
||||
pub n: i32,
|
||||
pub soul: String,
|
||||
pub system_template: Option<String>,
|
||||
pub tools: Vec<String>,
|
||||
pub note: Option<String>,
|
||||
pub is_current: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// An author + their currently-active revision. Most pipeline calls
|
||||
/// want both at once.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthorWithRevision {
|
||||
pub author: Author,
|
||||
pub revision: Revision,
|
||||
}
|
||||
|
||||
/// Fetch an author by slug. Returns None if no such author.
|
||||
pub async fn get_by_slug(pool: &PgPool, slug: &str) -> anyhow::Result<Option<Author>> {
|
||||
let row: Option<(Uuid, String, String, Option<String>, String, bool, DateTime<Utc>, DateTime<Utc>)> =
|
||||
sqlx::query_as(
|
||||
"SELECT id, slug, display_name, persona_tagline, model, is_synthetic,
|
||||
created_at, updated_at
|
||||
FROM authors WHERE slug = $1",
|
||||
)
|
||||
.bind(slug)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(
|
||||
|(id, slug, display_name, persona_tagline, model, is_synthetic, created_at, updated_at)| {
|
||||
Author {
|
||||
id,
|
||||
slug,
|
||||
display_name,
|
||||
persona_tagline,
|
||||
model,
|
||||
is_synthetic,
|
||||
created_at,
|
||||
updated_at,
|
||||
}
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
/// Fetch an author + their current revision in one call.
|
||||
pub async fn get_with_current_revision(
|
||||
pool: &PgPool,
|
||||
slug: &str,
|
||||
) -> anyhow::Result<Option<AuthorWithRevision>> {
|
||||
let Some(author) = get_by_slug(pool, slug).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let revision = get_current_revision(pool, author.id)
|
||||
.await?
|
||||
.with_context(|| format!("author {slug} has no current revision"))?;
|
||||
Ok(Some(AuthorWithRevision { author, revision }))
|
||||
}
|
||||
|
||||
/// Fetch the current revision for an author.
|
||||
pub async fn get_current_revision(
|
||||
pool: &PgPool,
|
||||
author_id: Uuid,
|
||||
) -> anyhow::Result<Option<Revision>> {
|
||||
let row: Option<(Uuid, Uuid, i32, String, Option<String>, Vec<String>, Option<String>, bool, DateTime<Utc>)> =
|
||||
sqlx::query_as(
|
||||
"SELECT id, author_id, n, soul, system_template, tools, note, is_current, created_at
|
||||
FROM author_revisions WHERE author_id = $1 AND is_current = true",
|
||||
)
|
||||
.bind(author_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(|(id, author_id, n, soul, system_template, tools, note, is_current, created_at)| {
|
||||
Revision {
|
||||
id,
|
||||
author_id,
|
||||
n,
|
||||
soul,
|
||||
system_template,
|
||||
tools,
|
||||
note,
|
||||
is_current,
|
||||
created_at,
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/// Fetch a specific revision by id (e.g. the one pinned on a story).
|
||||
pub async fn get_revision(pool: &PgPool, revision_id: Uuid) -> anyhow::Result<Option<Revision>> {
|
||||
let row: Option<(Uuid, Uuid, i32, String, Option<String>, Vec<String>, Option<String>, bool, DateTime<Utc>)> =
|
||||
sqlx::query_as(
|
||||
"SELECT id, author_id, n, soul, system_template, tools, note, is_current, created_at
|
||||
FROM author_revisions WHERE id = $1",
|
||||
)
|
||||
.bind(revision_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(|(id, author_id, n, soul, system_template, tools, note, is_current, created_at)| {
|
||||
Revision {
|
||||
id,
|
||||
author_id,
|
||||
n,
|
||||
soul,
|
||||
system_template,
|
||||
tools,
|
||||
note,
|
||||
is_current,
|
||||
created_at,
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/// Create a new author. Idempotent on slug — if an author with this
|
||||
/// slug exists, returns it instead.
|
||||
pub async fn create_or_get(
|
||||
pool: &PgPool,
|
||||
slug: &str,
|
||||
display_name: &str,
|
||||
persona_tagline: Option<&str>,
|
||||
) -> anyhow::Result<Author> {
|
||||
if let Some(existing) = get_by_slug(pool, slug).await? {
|
||||
return Ok(existing);
|
||||
}
|
||||
sqlx::query(
|
||||
"INSERT INTO authors (slug, display_name, persona_tagline)
|
||||
VALUES ($1, $2, $3)",
|
||||
)
|
||||
.bind(slug)
|
||||
.bind(display_name)
|
||||
.bind(persona_tagline)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
get_by_slug(pool, slug)
|
||||
.await?
|
||||
.with_context(|| format!("author {slug} just created but not found"))
|
||||
}
|
||||
|
||||
/// Add a new revision to an author and mark it current. The previous
|
||||
/// current revision (if any) gets flipped to is_current=false.
|
||||
pub async fn add_revision(
|
||||
pool: &PgPool,
|
||||
author_id: Uuid,
|
||||
soul: &str,
|
||||
system_template: Option<&str>,
|
||||
tools: &[String],
|
||||
note: Option<&str>,
|
||||
) -> anyhow::Result<Revision> {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
// Demote the current revision if there is one.
|
||||
sqlx::query("UPDATE author_revisions SET is_current = false WHERE author_id = $1 AND is_current = true")
|
||||
.bind(author_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Next n.
|
||||
let next_n: i32 = sqlx::query_scalar(
|
||||
"SELECT COALESCE(MAX(n), 0) + 1 FROM author_revisions WHERE author_id = $1",
|
||||
)
|
||||
.bind(author_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let revision_id: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO author_revisions
|
||||
(author_id, n, soul, system_template, tools, note, is_current)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, true)
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(author_id)
|
||||
.bind(next_n)
|
||||
.bind(soul)
|
||||
.bind(system_template)
|
||||
.bind(tools)
|
||||
.bind(note)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
get_revision(pool, revision_id)
|
||||
.await?
|
||||
.with_context(|| format!("revision {revision_id} just inserted but not found"))
|
||||
}
|
||||
|
||||
/// Assign an author + revision to an existing story. Pins the
|
||||
/// revision so future re-reads always know which version wrote
|
||||
/// what. Also adds a row to author_corpus marking the author as
|
||||
/// having authored this story.
|
||||
pub async fn assign_to_story(
|
||||
pool: &PgPool,
|
||||
story_id: Uuid,
|
||||
author_id: Uuid,
|
||||
revision_id: Uuid,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
let affected = sqlx::query(
|
||||
"UPDATE stories
|
||||
SET author_id = $1, author_revision_id = $2
|
||||
WHERE id = $3",
|
||||
)
|
||||
.bind(author_id)
|
||||
.bind(revision_id)
|
||||
.bind(story_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if affected.rows_affected() == 0 {
|
||||
bail!("story {story_id} not found");
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO author_corpus (author_id, story_id, role)
|
||||
VALUES ($1, $2, 'authored')
|
||||
ON CONFLICT (author_id, story_id) DO NOTHING",
|
||||
)
|
||||
.bind(author_id)
|
||||
.bind(story_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
// DB-touching tests live in skald/tests/integration.rs against a
|
||||
// real ephemeral postgres. Unit tests would mostly check trivial
|
||||
// mappings.
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
//! assembly for LLM calls. The story-independence rule: nothing in
|
||||
//! this crate knows about any specific story. Every story is rows.
|
||||
|
||||
pub mod authors;
|
||||
pub mod config;
|
||||
pub mod context;
|
||||
pub mod db;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue