diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b488a0c5..0e3eadfa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -133,6 +133,17 @@ jobs: --locked \ -- --test-threads=1 + # The SQLite → Postgres migration suite is entirely `#![cfg(feature = + # "postgres")]`, so this job is the only place it has any tests at all. + - name: Run migration tests against Postgres + run: | + cargo test \ + -p atomic-core \ + --features postgres \ + --test migrate_tests \ + --locked \ + -- --test-threads=1 + # End-to-end HTTP suites exercise the full atomic-server stack # (BearerAuth middleware, X-Atomic-Database routing, route handlers, # background pipeline) against Postgres. The suite is parameterized @@ -149,6 +160,8 @@ jobs: --test e2e_websocket \ --test e2e_concurrent \ --test e2e_mcp \ + --test e2e_wiki \ + --test e2e_tags_settings \ --locked \ -- --test-threads=1 @@ -224,5 +237,10 @@ jobs: - name: Install dependencies run: npm ci + # Vitest strips types without checking them, so a type error reaches + # main unless something type-checks the app separately. + - name: Typecheck + run: npx tsc --noEmit + - name: Run Vitest run: npm test diff --git a/crates/atomic-core/src/db.rs b/crates/atomic-core/src/db.rs index 92e1e225..8f7a8cd9 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,9 +1174,50 @@ impl Database { )?; } - conn.execute_batch(&format!("PRAGMA user_version = {};", Self::LATEST_VERSION))?; + 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. + // + // Two ALTERs and the version bump run in one transaction. Left to + // autocommit they land separately, and the probe below only asks about + // the *first* column: a crash between the two ALTERs would re-enter + // with `has_col` true, skip the second column, and stamp 25 over a + // schema that has no `wiki_update_prompt` — every wiki read then fails + // for good, with no version left to repair it. + 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); + + let tx = conn.unchecked_transaction()?; + if !has_col { + tx.execute_batch( + "ALTER TABLE tags ADD COLUMN wiki_generation_prompt TEXT; + ALTER TABLE tags ADD COLUMN wiki_update_prompt TEXT;", + )?; + } + tx.execute_batch("PRAGMA user_version = 25;")?; + tx.commit()?; + } + + // Each block above stamps its own literal N, while `LATEST_VERSION` is + // a separate declaration; a new migration that bumps one without the + // other is a skew nothing else would catch. Debug builds — every test + // run — assert the two agree. + debug_assert_eq!( + conn.query_row::("PRAGMA user_version", [], |row| row.get(0))?, + Self::LATEST_VERSION, + "migrations must leave the database at LATEST_VERSION" + ); + // --- Triggers (recreated every startup to stay current) --- conn.execute_batch( "DROP TRIGGER IF EXISTS atom_tags_insert_count; diff --git a/crates/atomic-core/src/lib.rs b/crates/atomic-core/src/lib.rs index 977ce282..1f8dade0 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,32 @@ 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 TagWikiPrompts { + generation_prompt: tag_generation_prompt, + update_prompt: tag_update_prompt, + } = self + .storage + .get_tag_wiki_prompts_impl(tag_id) + .await? + .unwrap_or_default(); + // 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). + // + // The update chain takes the tag's *generation* prompt as its middle + // term: the article was written to that prompt, so an update falling + // straight through to the global one would accrete stock encyclopedia + // prose onto a deliberately-shaped article — a "collect the unchecked + // tasks" wiki would stop being a checklist after its first update. + // `section_ops_prompt` prepends whatever it is handed, so the JSON + // section-ops contract holds for either text. + let custom_update_prompt = tag_update_prompt + .or_else(|| tag_generation_prompt.clone()) + .or_else(|| settings_map.get("wiki_update_prompt").cloned()); + let custom_generation_prompt = + tag_generation_prompt.or_else(|| settings_map.get("wiki_generation_prompt").cloned()); let related = self .storage .get_related_tags_impl(tag_id, MAX_CROSS_LINK_TAGS) @@ -1952,8 +1996,8 @@ 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(), + custom_generation_prompt, + custom_update_prompt, }; 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..83469ee9 100644 --- a/crates/atomic-core/src/storage/sqlite/tags.rs +++ b/crates/atomic-core/src/storage/sqlite/tags.rs @@ -285,6 +285,46 @@ impl SqliteStorage { Ok(()) } + pub(crate) fn get_tag_wiki_prompts_impl( + &self, + id: &str, + ) -> StorageResult> { + let conn = self.db.read_conn()?; + 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 +762,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/src/wiki/agentic.rs b/crates/atomic-core/src/wiki/agentic.rs index 272b58d0..68c96a4f 100644 --- a/crates/atomic-core/src/wiki/agentic.rs +++ b/crates/atomic-core/src/wiki/agentic.rs @@ -452,8 +452,13 @@ fn trim_to_budget( // ==================== Research Prompts ==================== -fn research_system_prompt(tag_name: &str) -> String { - format!( +/// `article_prompt` is the custom prompt the article will be written to, when +/// there is one. It is appended rather than substituted: the curation +/// guidelines below ("skip redundant, vague, off-topic chunks") are tuned for +/// an encyclopedia article and would otherwise discard exactly the material a +/// diary or checklist wiki needs, while the tool contract must stay intact. +fn research_system_prompt(tag_name: &str, article_prompt: Option<&str>) -> String { + let mut prompt = format!( r#"You are a research agent curating source material for a wiki article about "{tag_name}". Your job is to search the knowledge base, review results, and select the best chunks to use as sources for the article. You have three tools: @@ -469,7 +474,13 @@ Guidelines: - Aim for comprehensive coverage of the topic's key aspects - Call done() when you have sufficient material for a well-sourced article - You do NOT write the article — you only curate the sources"# - ) + ); + if let Some(article_prompt) = article_prompt { + prompt.push_str(&format!( + "\n\nThe article will be written to these instructions — select material accordingly:\n{article_prompt}" + )); + } + prompt } fn research_user_prompt_generate(tag_name: &str) -> String { @@ -521,7 +532,7 @@ pub(crate) async fn generate( // Run research let mut rc = ResearchContext::new( - research_system_prompt(&ctx.tag_name), + research_system_prompt(&ctx.tag_name, ctx.generation_prompt_override()), research_user_prompt_generate(&ctx.tag_name), ); @@ -606,7 +617,7 @@ pub(crate) async fn research_for_update( .map_err(|e| e.to_string())?; let mut rc = ResearchContext::new( - research_system_prompt(&ctx.tag_name), + research_system_prompt(&ctx.tag_name, ctx.generation_prompt_override()), research_user_prompt_update(&ctx.tag_name, &existing.article.content), ); @@ -675,4 +686,24 @@ pub(crate) async fn research_for_update( Ok(Some((chunks, atom_count))) } +#[cfg(test)] +mod tests { + use super::research_system_prompt; + #[test] + fn research_prompt_appends_the_article_instructions() { + let stock = research_system_prompt("Diary", None); + assert!(stock.contains("You do NOT write the article")); + + let steered = research_system_prompt("Diary", Some("Keep it chronological.")); + assert!( + steered.starts_with(&stock), + "curation guidance must be appended to the tool contract, never replace it; \ + got {steered}" + ); + assert!( + steered.ends_with("Keep it chronological."), + "the article's own instructions must reach the researcher; got {steered}" + ); + } +} diff --git a/crates/atomic-core/src/wiki/mod.rs b/crates/atomic-core/src/wiki/mod.rs index afe88814..91e3f529 100644 --- a/crates/atomic-core/src/wiki/mod.rs +++ b/crates/atomic-core/src/wiki/mod.rs @@ -61,11 +61,19 @@ pub struct WikiStrategyContext { } impl WikiStrategyContext { - /// Returns the generation system prompt, using custom if set, otherwise the default. - pub fn generation_prompt(&self) -> &str { + /// The custom generation prompt, when one carries any content. Callers + /// that must tell "the user asked for this" apart from the built-in + /// prompt — the agentic research loop, which steers curation by it — read + /// this rather than [`Self::generation_prompt`]. + pub fn generation_prompt_override(&self) -> Option<&str> { self.custom_generation_prompt .as_deref() - .filter(|s| !s.is_empty()) + .filter(|s| !s.trim().is_empty()) + } + + /// Returns the generation system prompt, using custom if set, otherwise the default. + pub fn generation_prompt(&self) -> &str { + self.generation_prompt_override() .unwrap_or(WIKI_GENERATION_SYSTEM_PROMPT) } @@ -76,7 +84,7 @@ impl WikiStrategyContext { match self .custom_update_prompt .as_deref() - .filter(|s| !s.is_empty()) + .filter(|s| !s.trim().is_empty()) { Some(custom) => format!("{}\n\n{}", custom, WIKI_UPDATE_SECTION_OPS_PROMPT), None => WIKI_UPDATE_SECTION_OPS_PROMPT.to_string(), @@ -1702,6 +1710,65 @@ mod tests { .expect("section_ops_schema must be portable across providers"); } + // ==================== Prompt resolution ==================== + + /// The two accessors every strategy and the update path read through. + /// The temp file rides along so the database outlives the context. + fn prompt_ctx( + custom_generation_prompt: Option<&str>, + custom_update_prompt: Option<&str>, + ) -> (WikiStrategyContext, NamedTempFile) { + let (db, temp) = create_test_db(); + let ctx = WikiStrategyContext { + storage: StorageBackend::Sqlite(crate::storage::SqliteStorage::new( + std::sync::Arc::new(db), + )), + provider_config: ProviderConfig::from_settings(&Default::default()), + wiki_model: "test-model".to_string(), + tag_id: "tag1".to_string(), + tag_name: "TestTopic".to_string(), + linkable_article_names: Vec::new(), + custom_generation_prompt: custom_generation_prompt.map(str::to_string), + custom_update_prompt: custom_update_prompt.map(str::to_string), + }; + (ctx, temp) + } + + #[test] + fn generation_prompt_falls_back_unless_a_custom_prompt_has_content() { + for blank in [None, Some(""), Some(" \n\t ")] { + let (ctx, _temp) = prompt_ctx(blank, None); + assert_eq!( + ctx.generation_prompt(), + WIKI_GENERATION_SYSTEM_PROMPT, + "blank custom prompt {blank:?} must not replace the built-in one" + ); + } + + let (ctx, _temp) = prompt_ctx(Some("Write it as a diary."), None); + assert_eq!(ctx.generation_prompt(), "Write it as a diary."); + } + + #[test] + fn section_ops_prompt_prepends_only_a_custom_prompt_with_content() { + for blank in [None, Some(""), Some(" \n\t ")] { + let (ctx, _temp) = prompt_ctx(None, blank); + assert_eq!( + ctx.section_ops_prompt(), + WIKI_UPDATE_SECTION_OPS_PROMPT, + "blank custom prompt {blank:?} must not prepend to the section-ops contract" + ); + } + + let (ctx, _temp) = prompt_ctx(None, Some("Newest entries on top.")); + let prompt = ctx.section_ops_prompt(); + assert!(prompt.starts_with("Newest entries on top.")); + assert!( + prompt.ends_with(WIKI_UPDATE_SECTION_OPS_PROMPT), + "the structural contract must survive the prepend" + ); + } + fn insert_tag(conn: &Connection, id: &str, name: &str) { let now = chrono::Utc::now().to_rfc3339(); conn.execute( 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..cd979474 100644 --- a/crates/atomic-core/tests/storage_tests.rs +++ b/crates/atomic-core/tests/storage_tests.rs @@ -472,6 +472,97 @@ 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 +1412,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 +1614,72 @@ 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 + ); + + /// `tags.id` is a global primary key on Postgres: several logical + /// databases share the table, and the `AND db_id = $n` predicate in the + /// wiki-prompt queries is the entire fence between them. This is the only + /// test that puts two db_ids in front of it. + #[tokio::test] + async fn pg_tag_wiki_prompts_fenced_by_db_id() { + let Some(ref owner) = postgres_storage().await else { + eprintln!( + "Skipping pg_tag_wiki_prompts_fenced_by_db_id (ATOMIC_TEST_DATABASE_URL not set)" + ); + return; + }; + // Connected after `postgres_storage`, whose truncate would otherwise + // wipe the tag this test just seeded. + let url = std::env::var("ATOMIC_TEST_DATABASE_URL").unwrap(); + let neighbor = atomic_core::storage::PostgresStorage::connect(&url, "test-neighbor") + .await + .unwrap(); + + let tag = owner.create_tag("FencedDiary", None).await.unwrap(); + owner + .set_tag_wiki_prompts( + &tag.id, + &TagWikiPrompts { + generation_prompt: Some("Only this database may see it.".to_string()), + update_prompt: None, + }, + ) + .await + .unwrap(); + + assert!( + neighbor + .get_tag_wiki_prompts(&tag.id) + .await + .unwrap() + .is_none(), + "another database's tag must read as absent, not as its prompts" + ); + assert!( + matches!( + neighbor + .set_tag_wiki_prompts(&tag.id, &TagWikiPrompts::default()) + .await, + Err(AtomicCoreError::NotFound(_)) + ), + "another database's tag must not be writable" + ); + + let owned = owner + .get_tag_wiki_prompts(&tag.id) + .await + .unwrap() + .expect("the owning database still sees its tag"); + assert_eq!( + owned.generation_prompt.as_deref(), + Some("Only this database may see it."), + "the rejected cross-database write must not have landed" + ); + } + 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..a2011878 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,65 @@ 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) => tag_not_found(), + Err(e) => crate::error::error_response(e), + } +} + +/// The one body both wiki-prompt routes answer an unknown tag with. GET learns +/// of the missing tag as `Ok(None)` and PUT as a storage `NotFound`, whose +/// rendering ("Not found: tag ") the modal would otherwise show verbatim +/// for the identical condition. +fn tag_not_found() -> HttpResponse { + HttpResponse::NotFound().json(serde_json::json!({"error": "Tag not found"})) +} + +#[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(atomic_core::AtomicCoreError::NotFound(_)) => tag_not_found(), + 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..45f1323e 100644 --- a/crates/atomic-server/tests/e2e_tags_settings.rs +++ b/crates/atomic-server/tests/e2e_tags_settings.rs @@ -367,6 +367,214 @@ 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; + + // The modal renders these bodies verbatim, so the two verbs must answer + // the identical condition identically — PUT learns of the missing tag as + // a storage `NotFound`, whose own rendering names the id. + let (status, body) = get_wiki_prompts(&app, ctx.auth_header(), "no-such-tag").await; + assert_eq!(status, 404, "GET on an unknown tag must 404"); + assert_eq!(body, json!({ "error": "Tag not found" })); + + let (status, body) = 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"); + assert_eq!(body, json!({ "error": "Tag not found" })); +} + +#[actix_web::test] +async fn tag_wiki_prompts_require_auth_sqlite() { + run_tag_wiki_prompts_require_auth(Backend::Sqlite).await; +} + +#[actix_web::test] +async fn tag_wiki_prompts_require_auth_postgres() { + if std::env::var("ATOMIC_TEST_DATABASE_URL").is_err() { + eprintln!( + "tag_wiki_prompts_require_auth_postgres: skipping (ATOMIC_TEST_DATABASE_URL not set)" + ); + return; + } + run_tag_wiki_prompts_require_auth(Backend::Postgres).await; +} + +async fn run_tag_wiki_prompts_require_auth(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(), "GuardedPrompts", None).await; + + for req in [ + actix_test::TestRequest::get() + .uri(&format!("/api/tags/{id}/wiki-prompts")) + .to_request(), + actix_test::TestRequest::put() + .uri(&format!("/api/tags/{id}/wiki-prompts")) + .set_json(json!({ "generation_prompt": "unauthorized" })) + .to_request(), + ] { + let err = match actix_test::try_call_service(&app, req).await { + Ok(resp) => panic!( + "wiki-prompts must reject missing tokens, got {}", + resp.status() + ), + Err(err) => err, + }; + assert_eq!(err.as_response_error().error_response().status(), 401); + } + + // The rejected PUT must not have written anything. + let (status, body) = get_wiki_prompts(&app, ctx.auth_header(), &id).await; + assert_eq!(status, 200); + assert!(body["generation_prompt"].is_null()); +} + // ==================== 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..ca38ba0e 100644 --- a/crates/atomic-server/tests/e2e_wiki.rs +++ b/crates/atomic-server/tests/e2e_wiki.rs @@ -684,3 +684,300 @@ 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."; +const GLOBAL_UPDATE_PROMPT: &str = "GLOBAL-UPDATE-PROMPT: fold changes in the house way."; +const TAG_UPDATE_PROMPT: &str = "TAG-UPDATE-PROMPT: put the newest tasks on top."; + +/// The `system` message of every chat request the mock has answered, in +/// arrival order. +fn system_prompts(ctx: &TestCtx) -> Vec { + ctx.mock + .chat_request_bodies() + .iter() + .filter_map(system_prompt_of) + .collect() +} + +/// The system prompts of the incremental-update calls, in arrival order — +/// the requests asking for the `wiki_update_section_ops` schema, as opposed +/// to the full-rewrite calls generation makes. +fn section_ops_system_prompts(ctx: &TestCtx) -> Vec { + ctx.mock + .chat_request_bodies() + .iter() + .filter(|body| { + body.pointer("/response_format/json_schema/name") + .and_then(Value::as_str) + == Some("wiki_update_section_ops") + }) + .filter_map(system_prompt_of) + .collect() +} + +fn system_prompt_of(body: &Value) -> Option { + body["messages"] + .as_array()? + .iter() + .find(|m| m["role"] == "system")?["content"] + .as_str() + .map(str::to_string) +} + +/// PUT the tag's wiki prompt overrides, asserting the save lands. +async fn set_wiki_prompts(app: &S, auth: (&'static str, String), tag_id: &str, body: 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; + assert_eq!(resp.status(), 200, "saving the tag's prompts must succeed"); +} + +/// POST /api/wiki/{tag_id}/update, asserting the incremental path ran. +async fn update_wiki(app: &S, auth: (&'static str, String), tag_id: &str, tag_name: &str) +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::post() + .uri(&format!("/api/wiki/{tag_id}/update")) + .insert_header(auth) + .set_json(json!({ "tag_name": tag_name })) + .to_request(); + let resp = actix_test::call_service(app, req).await; + let status = resp.status(); + if !status.is_success() { + let body = actix_test::read_body(resp).await; + panic!( + "wiki update must succeed, got {} body: {}", + status, + String::from_utf8_lossy(&body) + ); + } +} + +#[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; + + set_wiki_prompts( + &app, + ctx.auth_header(), + &tag_id, + json!({ "generation_prompt": TAG_WIKI_PROMPT }), + ) + .await; + + generate_wiki(&app, ctx.auth_header(), &tag_id, "TodoWiki").await; + + let prompts = system_prompts(&ctx); + assert!( + prompts.iter().any(|p| p == TAG_WIKI_PROMPT), + "generation must run on the tag's prompt; system prompts seen: {prompts:?}" + ); + assert!( + !prompts.iter().any(|p| p.contains(GLOBAL_WIKI_PROMPT)), + "the tag override replaces the global prompt outright; system prompts seen: {prompts:?}" + ); + + // Second half of the chain: a tag with no override still reaches the + // global prompt. Without this, dropping `.or_else(global)` from the + // resolver would leave the assertions above green. + let plain_tag_id = create_tag(&app, ctx.auth_header(), "PlainWiki").await; + seed_atom( + &app, + ctx.auth_header(), + "the domain registrar bills annually", + &[plain_tag_id.as_str()], + ) + .await; + generate_wiki(&app, ctx.auth_header(), &plain_tag_id, "PlainWiki").await; + + let prompts = system_prompts(&ctx); + assert!( + prompts.iter().any(|p| p == GLOBAL_WIKI_PROMPT), + "a tag with no override must fall through to the global prompt; \ + system prompts seen: {prompts:?}" + ); +} + +// ============ 11. The tag's generation prompt also steers its updates ============ + +#[actix_web::test] +async fn tag_prompts_steer_wiki_updates_sqlite() { + run_tag_prompts_steer_wiki_updates(Backend::Sqlite).await; +} + +#[actix_web::test] +async fn tag_prompts_steer_wiki_updates_postgres() { + if std::env::var("ATOMIC_TEST_DATABASE_URL").is_err() { + eprintln!( + "tag_prompts_steer_wiki_updates_postgres: skipping (ATOMIC_TEST_DATABASE_URL not set)" + ); + return; + } + run_tag_prompts_steer_wiki_updates(Backend::Postgres).await; +} + +/// Precedence for the update prepend is `tag.update_prompt → +/// tag.generation_prompt → global wiki_update_prompt`. The middle term is +/// what keeps a deliberately-shaped article in shape: a tag told to "list +/// only the unchecked tasks" must not accrete stock prose the first time it +/// is incrementally updated. +async fn run_tag_prompts_steer_wiki_updates(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_update_prompt", GLOBAL_UPDATE_PROMPT) + .await + .expect("seed global update prompt"); + + let tag_id = create_tag(&app, ctx.auth_header(), "TaskWiki").await; + seed_atom( + &app, + ctx.auth_header(), + "- [ ] renew the domain", + &[tag_id.as_str()], + ) + .await; + set_wiki_prompts( + &app, + ctx.auth_header(), + &tag_id, + json!({ "generation_prompt": TAG_WIKI_PROMPT }), + ) + .await; + generate_wiki(&app, ctx.auth_header(), &tag_id, "TaskWiki").await; + + // (a) Only a generation prompt is set: it steers the update too, ahead of + // the global update prompt, and prepended so the structural section-ops + // contract still follows it. + seed_atom( + &app, + ctx.auth_header(), + "- [ ] file the quarterly report", + &[tag_id.as_str()], + ) + .await; + update_wiki(&app, ctx.auth_header(), &tag_id, "TaskWiki").await; + + let prompts = section_ops_system_prompts(&ctx); + assert_eq!( + prompts.len(), + 1, + "the update path must issue one section-ops call; saw {prompts:?}" + ); + assert!( + prompts[0].starts_with(TAG_WIKI_PROMPT), + "the tag's generation prompt must lead the update prompt; saw {:?}", + prompts[0] + ); + assert!( + prompts[0].len() > TAG_WIKI_PROMPT.len(), + "the section-ops instructions must survive the prepend; saw {:?}", + prompts[0] + ); + assert!( + !prompts[0].contains(GLOBAL_UPDATE_PROMPT), + "the tag's own intent outranks the global update prompt; saw {:?}", + prompts[0] + ); + + // (b) A per-tag update prompt outranks both. + set_wiki_prompts( + &app, + ctx.auth_header(), + &tag_id, + json!({ + "generation_prompt": TAG_WIKI_PROMPT, + "update_prompt": TAG_UPDATE_PROMPT, + }), + ) + .await; + seed_atom( + &app, + ctx.auth_header(), + "- [ ] book the venue deposit", + &[tag_id.as_str()], + ) + .await; + update_wiki(&app, ctx.auth_header(), &tag_id, "TaskWiki").await; + + let prompts = section_ops_system_prompts(&ctx); + assert_eq!( + prompts.len(), + 2, + "the second update must issue its own section-ops call; saw {prompts:?}" + ); + let latest = &prompts[1]; + assert!( + latest.starts_with(TAG_UPDATE_PROMPT), + "the tag's update prompt must lead; saw {latest:?}" + ); + assert!( + !latest.contains(TAG_WIKI_PROMPT), + "the update prompt replaces the generation prompt, not adds to it; saw {latest:?}" + ); + assert!( + !latest.contains(GLOBAL_UPDATE_PROMPT), + "the tag's update prompt outranks the global one; saw {latest:?}" + ); +} 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)]'; + +/// How far one tag's prompts got in loading. The tag id is part of the state +/// rather than a sibling flag on purpose: the modal stays mounted across +/// open/close, so the previous tag's load outcome is still in hand for the +/// render that happens before the load effect runs. Every read below matches +/// `tagId` against the tag in the title, which is what makes it impossible to +/// show — or save — one tag's prompts under another tag's name. +type Load = + | { tagId: string; status: 'loading' } + | { tagId: string; status: 'ready' } + | { tagId: string; status: 'failed'; message: string }; + +/// The transport throws the raw error body, which is `''` for an error +/// response with no body — that renders as nothing, leaving the user in front +/// of a modal that refuses to save and won't say why. Give every message a floor. +function errorMessage(e: unknown): string { + return String(e) || 'Request failed'; +} + +/// 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. +/// Until settings have loaded we don't know which of the two it is, so the +/// copy names both rather than claiming a fallback that may not be the real one. +function fallbackLabel(globalPrompt: string | undefined, settingsLoaded: boolean): string { + if (!settingsLoaded) return "the global prompt from Settings → Prompts, or Atomic's built-in one"; + 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 fetchSettings = useSettingsStore(s => s.fetchSettings); + + const [load, setLoad] = useState(null); + const [generationPrompt, setGenerationPrompt] = useState(''); + const [updatePrompt, setUpdatePrompt] = useState(''); + const [showUpdatePrompt, setShowUpdatePrompt] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + + const tagId = tag?.id ?? null; + /// Only the open tag's load reaches the UI; any other one is left over. + const currentLoad = load && load.tagId === tagId ? load : null; + /// The single gate. True only when the fields hold *this* tag's stored + /// prompts, so both the form and Save are off until then — a save is a full + /// replace, and replacing with fields we never filled would wipe prompts the + /// user never saw. + const isLoaded = currentLoad?.status === 'ready'; + + // Prompts aren't carried on the tag tree, so they are read fresh every time + // the modal opens. Everything the previous tag left behind is cleared before + // the fetch, and only the success branch claims the fields for this tag. + useEffect(() => { + // Closing drops the load outright — the component stays mounted, and + // nothing one tag left behind may reach the next open. + if (!isOpen || !tagId) { + setLoad(null); + return; + } + let cancelled = false; + setLoad({ tagId, status: 'loading' }); + setGenerationPrompt(''); + setUpdatePrompt(''); + setShowUpdatePrompt(false); + setSaveError(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())); + setLoad({ tagId, status: 'ready' }); + }) + .catch((e) => { + if (!cancelled) setLoad({ tagId, status: 'failed', message: errorMessage(e) }); + }); + return () => { + cancelled = true; + }; + }, [isOpen, tagId, fetchTagWikiPrompts]); + + // The fallback copy names the user's global prompts, which live in the + // settings store — empty until someone fetches them, and Settings may never + // have been opened this session. + useEffect(() => { + if (isOpen) void fetchSettings(); + }, [isOpen, fetchSettings]); + + // The store starts empty and only a successful fetch fills it, so a + // non-empty map means the global prompts are known. + const settingsLoaded = Object.keys(settings).length > 0; + + const handleSave = async () => { + if (!tagId || !isLoaded || isSaving) return; + setIsSaving(true); + setSaveError(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. + setSaveError(errorMessage(e)); + } finally { + setIsSaving(false); + } + }; + + return ( + + {isLoaded ? ( +
+ {/* 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, settingsLoaded)}. +

+