From e6c238911a1e15344928a0dba9b16f24b685521a Mon Sep 17 00:00:00 2001 From: Kenny Bergquist Date: Mon, 3 Aug 2026 21:55:57 -0400 Subject: [PATCH 1/4] wiki: per-tag prompt overrides for generation and update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tag can now carry its own wiki generation/update prompts, resolved tag → global setting → built-in default at the single strategy-context funnel, so both strategies and the incremental-update path inherit the override with no changes. Prompts stay off the Tag payload (the tree ships hundreds of rows; prompts run to kilobytes) and are fetched on demand by the new GET/PUT /api/tags/{id}/wiki-prompts routes and the tag context menu's Wiki Prompt modal. SQLite V25 + Postgres 026 add the columns; migrate push copies them. Co-Authored-By: Claude Fable 5 --- crates/atomic-core/src/db.rs | 25 ++- crates/atomic-core/src/lib.rs | 36 +++- crates/atomic-core/src/migrate/tables.rs | 7 +- crates/atomic-core/src/models.rs | 32 ++++ crates/atomic-core/src/storage/mod.rs | 5 + .../migrations/026_tag_wiki_prompts.sql | 11 ++ .../atomic-core/src/storage/postgres/mod.rs | 1 + .../atomic-core/src/storage/postgres/tags.rs | 36 ++++ crates/atomic-core/src/storage/sqlite/tags.rs | 52 ++++++ crates/atomic-core/src/storage/traits.rs | 7 + crates/atomic-core/tests/migrate_tests.rs | 20 ++- crates/atomic-core/tests/storage_tests.rs | 89 ++++++++++ crates/atomic-server/src/lib.rs | 3 + crates/atomic-server/src/routes/atoms.rs | 52 +++++- crates/atomic-server/src/routes/mod.rs | 8 + .../atomic-server/tests/e2e_tags_settings.rs | 159 +++++++++++++++++ crates/atomic-server/tests/e2e_wiki.rs | 80 +++++++++ src/components/tags/TagTree.tsx | 27 ++- src/components/tags/TagWikiPromptModal.tsx | 163 ++++++++++++++++++ src/components/tags/index.ts | 1 + src/lib/transport/command-map.ts | 18 ++ src/stores/tags.test.ts | 64 +++++++ src/stores/tags.ts | 36 ++++ 23 files changed, 923 insertions(+), 9 deletions(-) create mode 100644 crates/atomic-core/src/storage/postgres/migrations/026_tag_wiki_prompts.sql create mode 100644 src/components/tags/TagWikiPromptModal.tsx create mode 100644 src/stores/tags.test.ts diff --git a/crates/atomic-core/src/db.rs b/crates/atomic-core/src/db.rs index 92e1e225..946c2519 100644 --- a/crates/atomic-core/src/db.rs +++ b/crates/atomic-core/src/db.rs @@ -281,7 +281,7 @@ impl Database { /// 1. Add a new `if version < N` block at the end (before the virtual-table section) /// 2. End the block with `PRAGMA user_version = N;` /// 3. Bump LATEST_VERSION - const LATEST_VERSION: i32 = 24; + const LATEST_VERSION: i32 = 25; pub fn run_migrations(conn: &Connection) -> Result<(), AtomicCoreError> { Self::run_migrations_internal(conn, false) @@ -1174,6 +1174,29 @@ impl Database { )?; } + conn.execute_batch("PRAGMA user_version = 24;")?; + } + + // V25: a tag can override the wiki prompts used for its own article. + // NULL means "no override" — the resolver in + // `AtomicCore::build_wiki_strategy_context` then falls through to the + // global setting and finally the built-in prompt. + if version < 25 { + let has_col: bool = conn + .query_row( + "SELECT 1 FROM pragma_table_info('tags') WHERE name='wiki_generation_prompt'", + [], + |_| Ok(true), + ) + .unwrap_or(false); + + if !has_col { + conn.execute_batch( + "ALTER TABLE tags ADD COLUMN wiki_generation_prompt TEXT; + ALTER TABLE tags ADD COLUMN wiki_update_prompt TEXT;", + )?; + } + conn.execute_batch(&format!("PRAGMA user_version = {};", Self::LATEST_VERSION))?; } diff --git a/crates/atomic-core/src/lib.rs b/crates/atomic-core/src/lib.rs index 977ce282..06b7796f 100644 --- a/crates/atomic-core/src/lib.rs +++ b/crates/atomic-core/src/lib.rs @@ -1735,6 +1735,24 @@ impl AtomicCore { .await } + /// Read a tag's wiki prompt overrides. `None` when the tag doesn't exist. + pub async fn get_tag_wiki_prompts( + &self, + id: &str, + ) -> Result, AtomicCoreError> { + self.storage.get_tag_wiki_prompts_impl(id).await + } + + /// Replace a tag's wiki prompt overrides. Takes effect the next time the + /// tag's article is generated or updated — nothing is regenerated here. + pub async fn set_tag_wiki_prompts( + &self, + id: &str, + prompts: &TagWikiPrompts, + ) -> Result<(), AtomicCoreError> { + self.storage.set_tag_wiki_prompts_impl(id, prompts).await + } + /// Configure auto-tag targets in one shot — used by the onboarding wizard /// and the settings tab. /// @@ -1933,6 +1951,13 @@ impl AtomicCore { .map(|s| s.as_str()) .unwrap_or("centroid"), ); + // A tag deleted out from under an in-flight generation resolves as + // "no overrides"; the missing tag surfaces on the caller's own path. + let tag_prompts = self + .storage + .get_tag_wiki_prompts_impl(tag_id) + .await? + .unwrap_or_default(); let related = self .storage .get_related_tags_impl(tag_id, MAX_CROSS_LINK_TAGS) @@ -1952,8 +1977,15 @@ impl AtomicCore { tag_id: tag_id.to_string(), tag_name: tag_name.to_string(), linkable_article_names, - custom_generation_prompt: settings_map.get("wiki_generation_prompt").cloned(), - custom_update_prompt: settings_map.get("wiki_update_prompt").cloned(), + // Precedence, per field independently: this tag's override, then + // the global custom prompt, then the built-in default (applied by + // `WikiStrategyContext` when both are absent). + custom_generation_prompt: tag_prompts + .generation_prompt + .or_else(|| settings_map.get("wiki_generation_prompt").cloned()), + custom_update_prompt: tag_prompts + .update_prompt + .or_else(|| settings_map.get("wiki_update_prompt").cloned()), }; Ok((strategy, ctx)) } diff --git a/crates/atomic-core/src/migrate/tables.rs b/crates/atomic-core/src/migrate/tables.rs index 195f04e5..e14b8533 100644 --- a/crates/atomic-core/src/migrate/tables.rs +++ b/crates/atomic-core/src/migrate/tables.rs @@ -109,7 +109,8 @@ pub(super) const TABLE_SPECS: &[TableSpec] = &[ table: "tags", source_table: "tags", select_exprs: "id, name, NULL, created_at, 0, COALESCE(is_autotag_target, 0), \ - COALESCE(autotag_description, '')", + COALESCE(autotag_description, ''), wiki_generation_prompt, \ + wiki_update_prompt", guard: None, pg_cols: &[ "id", @@ -119,6 +120,8 @@ pub(super) const TABLE_SPECS: &[TableSpec] = &[ "atom_count", "is_autotag_target", "autotag_description", + "wiki_generation_prompt", + "wiki_update_prompt", ], types: &[ Col::Text, @@ -128,6 +131,8 @@ pub(super) const TABLE_SPECS: &[TableSpec] = &[ Col::Int, Col::Bool, Col::Text, + Col::Text, + Col::Text, ], binds_skip_json: false, }, diff --git a/crates/atomic-core/src/models.rs b/crates/atomic-core/src/models.rs index 62e9183a..c9dd38b6 100644 --- a/crates/atomic-core/src/models.rs +++ b/crates/atomic-core/src/models.rs @@ -174,6 +174,38 @@ pub struct Tag { pub autotag_description: String, } +/// Per-tag overrides for the wiki prompts, `None` meaning "no override". +/// +/// Deliberately not a field on [`Tag`]: the tag tree ships hundreds of rows to +/// every client on load and a prompt can run to several kilobytes, so these are +/// fetched on demand for the one tag being edited. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +pub struct TagWikiPrompts { + #[serde(default)] + pub generation_prompt: Option, + #[serde(default)] + pub update_prompt: Option, +} + +impl TagWikiPrompts { + /// Blank text clears an override. Persisting `Some("")` instead would pin + /// the tag to an empty prompt and cut off the global/default fallback. + pub fn normalized(&self) -> Self { + fn clear_if_blank(prompt: &Option) -> Option { + prompt + .as_deref() + .map(str::trim) + .filter(|p| !p.is_empty()) + .map(str::to_string) + } + Self { + generation_prompt: clear_if_blank(&self.generation_prompt), + update_prompt: clear_if_blank(&self.update_prompt), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] pub struct AtomWithTags { diff --git a/crates/atomic-core/src/storage/mod.rs b/crates/atomic-core/src/storage/mod.rs index b923b629..e1060016 100644 --- a/crates/atomic-core/src/storage/mod.rs +++ b/crates/atomic-core/src/storage/mod.rs @@ -287,6 +287,7 @@ impl_reborrow_struct!( CreateAtomRequest, UpdateAtomRequest, ListAtomsParams, + TagWikiPrompts, WikiArticle, WikiProposal, crate::models::KindFilter, @@ -420,6 +421,10 @@ dispatch! { => sqlite: set_tag_autotag_target_impl, pg_trait: TagStore, pg_method: set_tag_autotag_target; fn set_tag_autotag_description_impl(&self, id: &str, description: &str) -> Result<(), AtomicCoreError> => sqlite: set_tag_autotag_description_impl, pg_trait: TagStore, pg_method: set_tag_autotag_description; + fn get_tag_wiki_prompts_impl(&self, id: &str) -> Result, AtomicCoreError> + => sqlite: get_tag_wiki_prompts_impl, pg_trait: TagStore, pg_method: get_tag_wiki_prompts; + fn set_tag_wiki_prompts_impl(&self, id: &str, prompts: &TagWikiPrompts) -> Result<(), AtomicCoreError> + => sqlite: set_tag_wiki_prompts_impl, pg_trait: TagStore, pg_method: set_tag_wiki_prompts; fn configure_autotag_targets_impl(&self, keep_default_names: &[String], add_custom_names: &[String]) -> Result, AtomicCoreError> => sqlite: configure_autotag_targets_impl, pg_trait: TagStore, pg_method: configure_autotag_targets; fn get_related_tags_impl(&self, tag_id: &str, limit: usize) -> Result, AtomicCoreError> diff --git a/crates/atomic-core/src/storage/postgres/migrations/026_tag_wiki_prompts.sql b/crates/atomic-core/src/storage/postgres/migrations/026_tag_wiki_prompts.sql new file mode 100644 index 00000000..ee36332f --- /dev/null +++ b/crates/atomic-core/src/storage/postgres/migrations/026_tag_wiki_prompts.sql @@ -0,0 +1,11 @@ +-- Migration 026: a tag can override the wiki prompts used for its own article. +-- +-- NULL means "no override" — the resolver in +-- `AtomicCore::build_wiki_strategy_context` then falls through to the global +-- setting and finally the built-in prompt, so existing tags keep behaving +-- exactly as they do today. + +ALTER TABLE tags ADD COLUMN IF NOT EXISTS wiki_generation_prompt TEXT; +ALTER TABLE tags ADD COLUMN IF NOT EXISTS wiki_update_prompt TEXT; + +INSERT INTO schema_version (version) VALUES (26); diff --git a/crates/atomic-core/src/storage/postgres/mod.rs b/crates/atomic-core/src/storage/postgres/mod.rs index 11320654..0d7a9363 100644 --- a/crates/atomic-core/src/storage/postgres/mod.rs +++ b/crates/atomic-core/src/storage/postgres/mod.rs @@ -173,6 +173,7 @@ const MIGRATIONS: &[(i32, &str)] = &[ 25, include_str!("migrations/025_conversation_tags_mode.sql"), ), + (26, include_str!("migrations/026_tag_wiki_prompts.sql")), ]; /// Postgres-backed storage implementation using sqlx + pgvector. diff --git a/crates/atomic-core/src/storage/postgres/tags.rs b/crates/atomic-core/src/storage/postgres/tags.rs index f3d55241..171d0b4f 100644 --- a/crates/atomic-core/src/storage/postgres/tags.rs +++ b/crates/atomic-core/src/storage/postgres/tags.rs @@ -428,6 +428,42 @@ impl TagStore for PostgresStorage { Ok(()) } + async fn get_tag_wiki_prompts(&self, id: &str) -> StorageResult> { + let row: Option<(Option, Option)> = sqlx::query_as( + "SELECT wiki_generation_prompt, wiki_update_prompt + FROM tags WHERE id = $1 AND db_id = $2", + ) + .bind(id) + .bind(&self.db_id) + .fetch_optional(&self.pool) + .await + .map_err(|e| AtomicCoreError::DatabaseOperation(e.to_string()))?; + Ok(row.map(|r| TagWikiPrompts { + generation_prompt: r.0, + update_prompt: r.1, + })) + } + + async fn set_tag_wiki_prompts(&self, id: &str, prompts: &TagWikiPrompts) -> StorageResult<()> { + let prompts = prompts.normalized(); + let result = sqlx::query( + "UPDATE tags + SET wiki_generation_prompt = $1, wiki_update_prompt = $2 + WHERE id = $3 AND db_id = $4", + ) + .bind(&prompts.generation_prompt) + .bind(&prompts.update_prompt) + .bind(id) + .bind(&self.db_id) + .execute(&self.pool) + .await + .map_err(|e| AtomicCoreError::DatabaseOperation(e.to_string()))?; + if result.rows_affected() == 0 { + return Err(AtomicCoreError::NotFound(format!("tag {}", id))); + } + Ok(()) + } + async fn configure_autotag_targets( &self, keep_default_names: &[String], diff --git a/crates/atomic-core/src/storage/sqlite/tags.rs b/crates/atomic-core/src/storage/sqlite/tags.rs index 74efae44..b1ecd869 100644 --- a/crates/atomic-core/src/storage/sqlite/tags.rs +++ b/crates/atomic-core/src/storage/sqlite/tags.rs @@ -285,6 +285,50 @@ impl SqliteStorage { Ok(()) } + pub(crate) fn get_tag_wiki_prompts_impl( + &self, + id: &str, + ) -> StorageResult> { + let conn = self + .db + .conn + .lock() + .map_err(|e| AtomicCoreError::Lock(e.to_string()))?; + conn.query_row( + "SELECT wiki_generation_prompt, wiki_update_prompt FROM tags WHERE id = ?1", + [id], + |row| { + Ok(TagWikiPrompts { + generation_prompt: row.get(0)?, + update_prompt: row.get(1)?, + }) + }, + ) + .optional() + .map_err(AtomicCoreError::from) + } + + pub(crate) fn set_tag_wiki_prompts_impl( + &self, + id: &str, + prompts: &TagWikiPrompts, + ) -> StorageResult<()> { + let prompts = prompts.normalized(); + let conn = self + .db + .conn + .lock() + .map_err(|e| AtomicCoreError::Lock(e.to_string()))?; + let affected = conn.execute( + "UPDATE tags SET wiki_generation_prompt = ?1, wiki_update_prompt = ?2 WHERE id = ?3", + rusqlite::params![prompts.generation_prompt, prompts.update_prompt, id], + )?; + if affected == 0 { + return Err(AtomicCoreError::NotFound(format!("tag {}", id))); + } + Ok(()) + } + /// Apply a full auto-tag-target configuration in a single transaction. /// /// Steps run atomically: any error rolls back the savepoint, leaving the @@ -722,6 +766,14 @@ impl TagStore for SqliteStorage { self.set_tag_autotag_description_impl(id, description) } + async fn get_tag_wiki_prompts(&self, id: &str) -> StorageResult> { + self.get_tag_wiki_prompts_impl(id) + } + + async fn set_tag_wiki_prompts(&self, id: &str, prompts: &TagWikiPrompts) -> StorageResult<()> { + self.set_tag_wiki_prompts_impl(id, prompts) + } + async fn configure_autotag_targets( &self, keep_default_names: &[String], diff --git a/crates/atomic-core/src/storage/traits.rs b/crates/atomic-core/src/storage/traits.rs index e3ab8ffc..2753c5c5 100644 --- a/crates/atomic-core/src/storage/traits.rs +++ b/crates/atomic-core/src/storage/traits.rs @@ -263,6 +263,13 @@ pub trait TagStore: Send + Sync { /// Set optional guidance used when this tag is an auto-tag target. async fn set_tag_autotag_description(&self, id: &str, description: &str) -> StorageResult<()>; + /// Read a tag's wiki prompt overrides. `None` when no tag with that id exists. + async fn get_tag_wiki_prompts(&self, id: &str) -> StorageResult>; + + /// Replace a tag's wiki prompt overrides, normalizing blank text to "no + /// override". Errors with `NotFound` when no tag with that id exists. + async fn set_tag_wiki_prompts(&self, id: &str, prompts: &TagWikiPrompts) -> StorageResult<()>; + /// Apply a full auto-tag-target configuration in a single transaction. /// See `AtomicCore::configure_autotag_targets` for semantics. async fn configure_autotag_targets( diff --git a/crates/atomic-core/tests/migrate_tests.rs b/crates/atomic-core/tests/migrate_tests.rs index 71085716..7b0cc4da 100644 --- a/crates/atomic-core/tests/migrate_tests.rs +++ b/crates/atomic-core/tests/migrate_tests.rs @@ -78,9 +78,10 @@ fn seed_source_db(dir: &TempDir) -> PathBuf { -- external tools (where the pragma defaults off) may hold. PRAGMA foreign_keys = OFF; - INSERT INTO tags (id, name, parent_id, created_at, atom_count, is_autotag_target, autotag_description) - VALUES ('tag-root', 'Topics', NULL, '2026-01-01T00:00:00Z', 1, 1, 'general topics'), - ('tag-child', 'Rust', 'tag-root', '2026-01-02T00:00:00Z', 2, 0, ''); + INSERT INTO tags (id, name, parent_id, created_at, atom_count, is_autotag_target, + autotag_description, wiki_generation_prompt, wiki_update_prompt) + VALUES ('tag-root', 'Topics', NULL, '2026-01-01T00:00:00Z', 1, 1, 'general topics', NULL, NULL), + ('tag-child', 'Rust', 'tag-root', '2026-01-02T00:00:00Z', 2, 0, '', 'Lead with the ownership rules.', NULL); INSERT INTO atoms (id, content, title, snippet, source_url, source, published_at, created_at, updated_at, embedding_status, tagging_status, @@ -342,6 +343,19 @@ async fn migrate_full_fidelity_roundtrip() { .await .unwrap(); assert!(root_autotag, "INTEGER 1 lands as BOOLEAN true"); + let (generation, update): (Option, Option) = sqlx::query_as( + "SELECT wiki_generation_prompt, wiki_update_prompt FROM tags WHERE id = 'tag-child' AND db_id = $1", + ) + .bind(&db_id) + .fetch_one(pool) + .await + .unwrap(); + assert_eq!( + generation.as_deref(), + Some("Lead with the ownership rules."), + "per-tag wiki prompts travel with the tag" + ); + assert!(update.is_none()); // Column-mapped drift: wiki target_tag_name → link_text, tool_output → tool_result. let (link_text, target_tag): (String, Option) = sqlx::query_as( diff --git a/crates/atomic-core/tests/storage_tests.rs b/crates/atomic-core/tests/storage_tests.rs index 6fdc3573..3e038961 100644 --- a/crates/atomic-core/tests/storage_tests.rs +++ b/crates/atomic-core/tests/storage_tests.rs @@ -472,6 +472,85 @@ async fn test_get_tag(storage: &dyn TagStore) { assert!(missing.is_none()); } +async fn test_tag_wiki_prompts_round_trip(storage: &dyn TagStore) { + let tag = storage.create_tag("Diary", None).await.unwrap(); + + // A fresh tag has no overrides. + let fresh = storage.get_tag_wiki_prompts(&tag.id).await.unwrap(); + let fresh = fresh.expect("existing tag resolves to a prompts row"); + assert!(fresh.generation_prompt.is_none()); + assert!(fresh.update_prompt.is_none()); + + // Both fields set. + storage + .set_tag_wiki_prompts( + &tag.id, + &TagWikiPrompts { + generation_prompt: Some("Collect the unchecked tasks.".to_string()), + update_prompt: Some("Keep the newest entries on top.".to_string()), + }, + ) + .await + .unwrap(); + let saved = storage.get_tag_wiki_prompts(&tag.id).await.unwrap().unwrap(); + assert_eq!( + saved.generation_prompt.as_deref(), + Some("Collect the unchecked tasks.") + ); + assert_eq!( + saved.update_prompt.as_deref(), + Some("Keep the newest entries on top.") + ); + + // A null field clears that field alone — the write replaces the pair. + storage + .set_tag_wiki_prompts( + &tag.id, + &TagWikiPrompts { + generation_prompt: Some("Collect the unchecked tasks.".to_string()), + update_prompt: None, + }, + ) + .await + .unwrap(); + let saved = storage.get_tag_wiki_prompts(&tag.id).await.unwrap().unwrap(); + assert_eq!( + saved.generation_prompt.as_deref(), + Some("Collect the unchecked tasks.") + ); + assert!(saved.update_prompt.is_none()); + + // Whitespace-only text clears rather than pinning an empty prompt. + storage + .set_tag_wiki_prompts( + &tag.id, + &TagWikiPrompts { + generation_prompt: Some(" \n ".to_string()), + update_prompt: None, + }, + ) + .await + .unwrap(); + let cleared = storage.get_tag_wiki_prompts(&tag.id).await.unwrap().unwrap(); + assert!( + cleared.generation_prompt.is_none(), + "blank text must clear the override, not store an empty prompt" + ); + + // Unknown tag: read yields None, write reports NotFound. + assert!(storage + .get_tag_wiki_prompts("nonexistent-tag-id") + .await + .unwrap() + .is_none()); + assert!(matches!( + storage + .set_tag_wiki_prompts("nonexistent-tag-id", &TagWikiPrompts::default()) + .await, + Err(AtomicCoreError::NotFound(_)) + )); +} + // ==================== TaskRunStore Tests ==================== /// Build a `TaskRun` row with caller-controlled state/timing fields — @@ -1321,6 +1400,12 @@ async fn sqlite_get_tag() { test_get_tag(&s).await; } +#[tokio::test] +async fn sqlite_tag_wiki_prompts_round_trip() { + let (s, _dir) = sqlite_storage().await; + test_tag_wiki_prompts_round_trip(&s).await; +} + #[tokio::test] async fn sqlite_list_runnable_task_runs() { let (s, _dir) = sqlite_storage().await; @@ -1517,6 +1602,10 @@ mod postgres_tests { pg_test!(pg_update_tag, test_update_tag); pg_test!(pg_delete_tag, test_delete_tag); pg_test!(pg_get_tag, test_get_tag); + pg_test!( + pg_tag_wiki_prompts_round_trip, + test_tag_wiki_prompts_round_trip + ); pg_test!(pg_list_runnable_task_runs, test_list_runnable_task_runs); pg_test!( pg_gc_task_runs_never_deletes_non_terminal, diff --git a/crates/atomic-server/src/lib.rs b/crates/atomic-server/src/lib.rs index 32daba2b..a49c1031 100644 --- a/crates/atomic-server/src/lib.rs +++ b/crates/atomic-server/src/lib.rs @@ -51,6 +51,8 @@ use utoipa::OpenApi; routes::atoms::delete_tag, routes::atoms::set_tag_autotag_target, routes::atoms::set_tag_autotag_description, + routes::atoms::get_tag_wiki_prompts, + routes::atoms::set_tag_wiki_prompts, routes::atoms::configure_autotag_targets, // Search routes::search::search, @@ -181,6 +183,7 @@ use utoipa::OpenApi; atomic_core::AtomLink, atomic_core::AtomLinkSuggestion, atomic_core::Tag, + atomic_core::TagWikiPrompts, atomic_core::AtomWithTags, atomic_core::AtomSummary, atomic_core::PaginatedAtoms, diff --git a/crates/atomic-server/src/routes/atoms.rs b/crates/atomic-server/src/routes/atoms.rs index 3e5a4fd4..51df468f 100644 --- a/crates/atomic-server/src/routes/atoms.rs +++ b/crates/atomic-server/src/routes/atoms.rs @@ -8,7 +8,7 @@ use crate::state::ServerEvent; use actix_web::{web, HttpResponse}; use atomic_core::{ AtomLink, AtomWithTags, BulkCreateResult, PaginatedAtoms, PaginatedTagChildren, SourceInfo, - Tag, TagWithCount, + Tag, TagWikiPrompts, TagWithCount, }; use serde::{Deserialize, Serialize}; use utoipa::{IntoParams, ToSchema}; @@ -741,6 +741,56 @@ pub async fn set_tag_autotag_description( } } +#[utoipa::path( + get, + path = "/api/tags/{id}/wiki-prompts", + params( + ("id" = String, Path, description = "Tag ID"), + ), + responses( + (status = 200, description = "The tag's wiki prompt overrides", body = TagWikiPrompts), + (status = 404, description = "Tag not found", body = ApiErrorResponse), + ), + tag = "tags", +)] +pub async fn get_tag_wiki_prompts(db: Db, path: web::Path) -> HttpResponse { + let id = path.into_inner(); + match db.0.get_tag_wiki_prompts(&id).await { + Ok(Some(prompts)) => HttpResponse::Ok().json(prompts), + Ok(None) => HttpResponse::NotFound().json(serde_json::json!({"error": "Tag not found"})), + Err(e) => crate::error::error_response(e), + } +} + +#[utoipa::path( + put, + path = "/api/tags/{id}/wiki-prompts", + params( + ("id" = String, Path, description = "Tag ID"), + ), + request_body = TagWikiPrompts, + responses( + (status = 200, description = "The saved overrides, normalized", body = TagWikiPrompts), + (status = 404, description = "Tag not found", body = ApiErrorResponse), + ), + tag = "tags", +)] +pub async fn set_tag_wiki_prompts( + db: Db, + path: web::Path, + body: web::Json, +) -> HttpResponse { + let id = path.into_inner(); + // Normalizing here as well as in storage is what lets the response echo + // exactly what was persisted without a follow-up read; `normalized` is + // idempotent, so the second pass is a no-op. + let prompts = body.into_inner().normalized(); + match db.0.set_tag_wiki_prompts(&id, &prompts).await { + Ok(()) => HttpResponse::Ok().json(prompts), + Err(e) => crate::error::error_response(e), + } +} + #[derive(Deserialize, Serialize, ToSchema)] pub struct ConfigureAutotagTargetsRequest { /// Names of seeded default categories to keep flagged. diff --git a/crates/atomic-server/src/routes/mod.rs b/crates/atomic-server/src/routes/mod.rs index f31c662d..20867bc0 100644 --- a/crates/atomic-server/src/routes/mod.rs +++ b/crates/atomic-server/src/routes/mod.rs @@ -77,6 +77,14 @@ pub fn configure_routes(cfg: &mut web::ServiceConfig) { "/tags/{id}/autotag-description", web::put().to(atoms::set_tag_autotag_description), ); + cfg.route( + "/tags/{id}/wiki-prompts", + web::get().to(atoms::get_tag_wiki_prompts), + ); + cfg.route( + "/tags/{id}/wiki-prompts", + web::put().to(atoms::set_tag_wiki_prompts), + ); cfg.route("/tags/{id}", web::put().to(atoms::update_tag)); cfg.route("/tags/{id}", web::delete().to(atoms::delete_tag)); diff --git a/crates/atomic-server/tests/e2e_tags_settings.rs b/crates/atomic-server/tests/e2e_tags_settings.rs index 364be766..966bab8e 100644 --- a/crates/atomic-server/tests/e2e_tags_settings.rs +++ b/crates/atomic-server/tests/e2e_tags_settings.rs @@ -367,6 +367,165 @@ async fn run_tag_compaction_merges_pair(backend: Backend) { ); } +// ==================== T8. Per-tag wiki prompts ==================== + +/// GET/PUT `/api/tags/{id}/wiki-prompts`. The PUT body is the same shape it +/// returns, and blank text clears an override rather than storing it — a +/// stored empty prompt would shadow the global one instead of falling back. +async fn put_wiki_prompts( + app: &S, + auth: (&'static str, String), + tag_id: &str, + body: Value, +) -> (u16, Value) +where + S: actix_web::dev::Service< + actix_http::Request, + Response = actix_web::dev::ServiceResponse, + Error = actix_web::Error, + >, + B: actix_web::body::MessageBody, +{ + let req = actix_test::TestRequest::put() + .uri(&format!("/api/tags/{tag_id}/wiki-prompts")) + .insert_header(auth) + .set_json(body) + .to_request(); + let resp = actix_test::call_service(app, req).await; + let status = resp.status().as_u16(); + (status, actix_test::read_body_json(resp).await) +} + +async fn get_wiki_prompts( + app: &S, + auth: (&'static str, String), + tag_id: &str, +) -> (u16, Value) +where + S: actix_web::dev::Service< + actix_http::Request, + Response = actix_web::dev::ServiceResponse, + Error = actix_web::Error, + >, + B: actix_web::body::MessageBody, +{ + let req = actix_test::TestRequest::get() + .uri(&format!("/api/tags/{tag_id}/wiki-prompts")) + .insert_header(auth) + .to_request(); + let resp = actix_test::call_service(app, req).await; + let status = resp.status().as_u16(); + (status, actix_test::read_body_json(resp).await) +} + +#[actix_web::test] +async fn tag_wiki_prompts_round_trip_sqlite() { + run_tag_wiki_prompts_round_trip(Backend::Sqlite).await; +} + +#[actix_web::test] +async fn tag_wiki_prompts_round_trip_postgres() { + if std::env::var("ATOMIC_TEST_DATABASE_URL").is_err() { + eprintln!( + "tag_wiki_prompts_round_trip_postgres: skipping (ATOMIC_TEST_DATABASE_URL not set)" + ); + return; + } + run_tag_wiki_prompts_round_trip(Backend::Postgres).await; +} + +async fn run_tag_wiki_prompts_round_trip(backend: Backend) { + let Some(ctx) = TestCtx::new(backend).await else { + return; + }; + let app = actix_test::init_service(test_app(&ctx)).await; + let id = create_tag(&app, ctx.auth_header(), "TodoSummary", None).await; + + let (status, body) = get_wiki_prompts(&app, ctx.auth_header(), &id).await; + assert_eq!(status, 200); + assert!(body["generation_prompt"].is_null()); + assert!(body["update_prompt"].is_null()); + + let (status, body) = put_wiki_prompts( + &app, + ctx.auth_header(), + &id, + json!({ + "generation_prompt": " List every unchecked task. ", + "update_prompt": "Fold new tasks into the existing list.", + }), + ) + .await; + assert_eq!(status, 200); + assert_eq!( + body["generation_prompt"], "List every unchecked task.", + "PUT must answer with the normalized (trimmed) prompt it stored" + ); + assert_eq!( + body["update_prompt"], + "Fold new tasks into the existing list." + ); + + let (status, body) = get_wiki_prompts(&app, ctx.auth_header(), &id).await; + assert_eq!(status, 200); + assert_eq!(body["generation_prompt"], "List every unchecked task."); + assert_eq!( + body["update_prompt"], + "Fold new tasks into the existing list." + ); + + // Blank text clears one field; an omitted field clears the other. + let (status, body) = put_wiki_prompts( + &app, + ctx.auth_header(), + &id, + json!({ "generation_prompt": " " }), + ) + .await; + assert_eq!(status, 200); + assert!(body["generation_prompt"].is_null()); + assert!(body["update_prompt"].is_null()); + + let (_, body) = get_wiki_prompts(&app, ctx.auth_header(), &id).await; + assert!(body["generation_prompt"].is_null()); + assert!(body["update_prompt"].is_null()); +} + +#[actix_web::test] +async fn tag_wiki_prompts_unknown_tag_404_sqlite() { + run_tag_wiki_prompts_unknown_tag_404(Backend::Sqlite).await; +} + +#[actix_web::test] +async fn tag_wiki_prompts_unknown_tag_404_postgres() { + if std::env::var("ATOMIC_TEST_DATABASE_URL").is_err() { + eprintln!( + "tag_wiki_prompts_unknown_tag_404_postgres: skipping (ATOMIC_TEST_DATABASE_URL not set)" + ); + return; + } + run_tag_wiki_prompts_unknown_tag_404(Backend::Postgres).await; +} + +async fn run_tag_wiki_prompts_unknown_tag_404(backend: Backend) { + let Some(ctx) = TestCtx::new(backend).await else { + return; + }; + let app = actix_test::init_service(test_app(&ctx)).await; + + let (status, _) = get_wiki_prompts(&app, ctx.auth_header(), "no-such-tag").await; + assert_eq!(status, 404, "GET on an unknown tag must 404"); + + let (status, _) = put_wiki_prompts( + &app, + ctx.auth_header(), + "no-such-tag", + json!({ "generation_prompt": "orphan" }), + ) + .await; + assert_eq!(status, 404, "PUT on an unknown tag must 404"); +} + // ==================== S1. Setting round-trip ==================== #[actix_web::test] diff --git a/crates/atomic-server/tests/e2e_wiki.rs b/crates/atomic-server/tests/e2e_wiki.rs index 6ccff461..bf2610ee 100644 --- a/crates/atomic-server/tests/e2e_wiki.rs +++ b/crates/atomic-server/tests/e2e_wiki.rs @@ -684,3 +684,83 @@ async fn run_distinct_tags_generate_concurrently(backend: Backend) { assert_eq!(history[0].subject_id.as_deref(), Some(tag_id.as_str())); } } + +// ==================== 10. Per-tag prompt beats the global setting ==================== + +/// A tag-level generation prompt fully replaces the global custom prompt, +/// which in turn replaces the built-in default. The assertion reads the +/// system message the mock provider actually received, so it fails if the +/// resolver in `build_wiki_strategy_context` stops preferring the tag. +const GLOBAL_WIKI_PROMPT: &str = "GLOBAL-WIKI-PROMPT: write it the house way."; +const TAG_WIKI_PROMPT: &str = "TAG-WIKI-PROMPT: list only the unchecked tasks."; + +#[actix_web::test] +async fn tag_prompt_overrides_global_wiki_prompt_sqlite() { + run_tag_prompt_overrides_global_wiki_prompt(Backend::Sqlite).await; +} + +#[actix_web::test] +async fn tag_prompt_overrides_global_wiki_prompt_postgres() { + if std::env::var("ATOMIC_TEST_DATABASE_URL").is_err() { + eprintln!( + "tag_prompt_overrides_global_wiki_prompt_postgres: skipping (ATOMIC_TEST_DATABASE_URL not set)" + ); + return; + } + run_tag_prompt_overrides_global_wiki_prompt(Backend::Postgres).await; +} + +async fn run_tag_prompt_overrides_global_wiki_prompt(backend: Backend) { + let Some(ctx) = TestCtx::new(backend).await else { + return; + }; + let app = actix_test::init_service(test_app(&ctx)).await; + + active_core(&ctx) + .await + .set_setting("wiki_generation_prompt", GLOBAL_WIKI_PROMPT) + .await + .expect("seed global wiki prompt"); + + let tag_id = create_tag(&app, ctx.auth_header(), "TodoWiki").await; + seed_atom( + &app, + ctx.auth_header(), + "- [ ] renew the domain\n- [x] pay the invoice", + &[tag_id.as_str()], + ) + .await; + + let req = actix_test::TestRequest::put() + .uri(&format!("/api/tags/{tag_id}/wiki-prompts")) + .insert_header(ctx.auth_header()) + .set_json(json!({ "generation_prompt": TAG_WIKI_PROMPT })) + .to_request(); + let resp = actix_test::call_service(&app, req).await; + assert_eq!(resp.status(), 200, "saving the tag's prompt must succeed"); + + generate_wiki(&app, ctx.auth_header(), &tag_id, "TodoWiki").await; + + let system_prompts: Vec = ctx + .mock + .chat_request_bodies() + .iter() + .filter_map(|body| { + body["messages"] + .as_array()? + .iter() + .find(|m| m["role"] == "system")?["content"] + .as_str() + .map(str::to_string) + }) + .collect(); + + assert!( + system_prompts.iter().any(|p| p == TAG_WIKI_PROMPT), + "generation must run on the tag's prompt; system prompts seen: {system_prompts:?}" + ); + assert!( + !system_prompts.iter().any(|p| p.contains(GLOBAL_WIKI_PROMPT)), + "the tag override replaces the global prompt outright; system prompts seen: {system_prompts:?}" + ); +} diff --git a/src/components/tags/TagTree.tsx b/src/components/tags/TagTree.tsx index 2d2e8fcc..f0682dc4 100644 --- a/src/components/tags/TagTree.tsx +++ b/src/components/tags/TagTree.tsx @@ -1,7 +1,8 @@ import { useState, useRef, useMemo, useEffect, MouseEvent } from 'react'; import { useVirtualizer } from '@tanstack/react-virtual'; -import { Pencil, Plus, Trash2, Inbox, Search } from 'lucide-react'; +import { Pencil, Plus, Trash2, Inbox, Search, BookOpen } from 'lucide-react'; import { TagNode } from './TagNode'; +import { TagWikiPromptModal } from './TagWikiPromptModal'; import { ContextMenu } from '../ui/ContextMenu'; import { Modal } from '../ui/Modal'; import { Input } from '../ui/Input'; @@ -135,6 +136,11 @@ export function TagTree({ onOpenTagSettings }: TagTreeProps = {}) { name: string; }>({ isOpen: false, parentId: null, name: '' }); + const [wikiPromptModal, setWikiPromptModal] = useState<{ + isOpen: boolean; + tag: TagWithCount | null; + }>({ isOpen: false, tag: null }); + const handleSelectTag = async (tagId: string | null) => { setSelectedTag(tagId); if (tagId) { @@ -203,6 +209,18 @@ export function TagTree({ onOpenTagSettings }: TagTreeProps = {}) { ), }, + { + label: 'Wiki Prompt…', + onClick: () => { + setWikiPromptModal({ + isOpen: true, + tag: contextMenu.tag, + }); + }, + icon: ( + + ), + }, { label: 'Delete', onClick: () => { @@ -403,6 +421,13 @@ export function TagTree({ onOpenTagSettings }: TagTreeProps = {}) { )} + {/* Wiki Prompt Modal */} + setWikiPromptModal({ isOpen: false, tag: null })} + /> + {/* New Tag Modal */} | null; + onClose: () => void; +} + +const TEXTAREA_CLASS = ` + px-3 py-2 rounded-md text-sm font-mono leading-relaxed resize-y + bg-[var(--color-bg-main)] border border-[var(--color-border)] + text-[var(--color-text-primary)] placeholder:text-[var(--color-text-secondary)]/40 + focus:outline-none focus:ring-1 focus:ring-[var(--color-accent)] +`; + +const LABEL_CLASS = 'text-xs font-medium uppercase tracking-[0.1em] text-[var(--color-text-tertiary)]'; + +/// What an empty field falls back to. Settings → Prompts holds the global +/// prompts; when one of those is empty too, Atomic's built-in prompt runs. +function fallbackLabel(globalPrompt: string | undefined): string { + return globalPrompt?.trim() + ? 'the global prompt from Settings → Prompts' + : "Atomic's built-in prompt"; +} + +export function TagWikiPromptModal({ isOpen, tag, onClose }: TagWikiPromptModalProps) { + const fetchTagWikiPrompts = useTagsStore(s => s.fetchTagWikiPrompts); + const saveTagWikiPrompts = useTagsStore(s => s.saveTagWikiPrompts); + const settings = useSettingsStore(s => s.settings); + + const [generationPrompt, setGenerationPrompt] = useState(''); + const [updatePrompt, setUpdatePrompt] = useState(''); + const [showUpdatePrompt, setShowUpdatePrompt] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + + const tagId = tag?.id ?? null; + + // Prompts aren't carried on the tag tree, so they are read fresh every + // time the modal opens. + useEffect(() => { + if (!isOpen || !tagId) return; + let cancelled = false; + setIsLoading(true); + setError(null); + fetchTagWikiPrompts(tagId) + .then((prompts) => { + if (cancelled) return; + setGenerationPrompt(prompts.generation_prompt ?? ''); + setUpdatePrompt(prompts.update_prompt ?? ''); + // Unfold the secondary field when this tag already overrides it, + // so an existing value is never hidden behind a disclosure. + setShowUpdatePrompt(Boolean(prompts.update_prompt?.trim())); + }) + .catch((e) => { + if (!cancelled) setError(String(e)); + }) + .finally(() => { + if (!cancelled) setIsLoading(false); + }); + return () => { + cancelled = true; + }; + }, [isOpen, tagId, fetchTagWikiPrompts]); + + const handleSave = async () => { + if (!tagId || isLoading || isSaving) return; + setIsSaving(true); + setError(null); + try { + // Blank fields go over as-is; the server reads them as "clear this + // override". + await saveTagWikiPrompts(tagId, { + generation_prompt: generationPrompt, + update_prompt: updatePrompt, + }); + onClose(); + } catch (e) { + // Keep the modal open so the user doesn't lose what they typed. + setError(String(e)); + } finally { + setIsSaving(false); + } + }; + + return ( + + {isLoading ? ( +
+ + Loading prompts… +
+ ) : ( +
+ {/* Generation prompt — the reason anyone opens this modal. */} +
+ +

+ Replaces the prompt used to write this tag's article. Leave empty to use{' '} + {fallbackLabel(settings.wiki_generation_prompt)}. +

+