diff --git a/crates/atomic-core/src/agent.rs b/crates/atomic-core/src/agent.rs index 7d2508b3..551d8b8d 100644 --- a/crates/atomic-core/src/agent.rs +++ b/crates/atomic-core/src/agent.rs @@ -1530,7 +1530,16 @@ where }; // Build message history for API - let mut api_messages = vec![Message::system(get_system_prompt(&scope_description))]; + let custom_chat_prefix = settings_map + .get("chat_prompt") + .filter(|s| !s.is_empty()) + .map(|s| s.as_str()); + let base_system = get_system_prompt(&scope_description); + let system_prompt = match custom_chat_prefix { + Some(prefix) => format!("{prefix}\n\n{base_system}"), + None => base_system, + }; + let mut api_messages = vec![Message::system(system_prompt)]; api_messages.extend(messages); // Truncate to fit context window for providers with limited context @@ -1734,7 +1743,15 @@ where }; // Build message history for API, with canvas context appended to system prompt - let mut system_prompt = get_system_prompt(&scope_description); + let custom_chat_prefix = settings_map + .get("chat_prompt") + .filter(|s| !s.is_empty()) + .map(|s| s.as_str()); + let base_system = get_system_prompt(&scope_description); + let mut system_prompt = match custom_chat_prefix { + Some(prefix) => format!("{prefix}\n\n{base_system}"), + None => base_system, + }; if page_context.is_some() { system_prompt.push_str(get_page_context_system_prompt()); } diff --git a/crates/atomic-core/src/briefing/agentic.rs b/crates/atomic-core/src/briefing/agentic.rs index 424c6122..e2b5fc69 100644 --- a/crates/atomic-core/src/briefing/agentic.rs +++ b/crates/atomic-core/src/briefing/agentic.rs @@ -320,7 +320,7 @@ struct AgentState { done_called: bool, } -async fn resolve_model(core: &AtomicCore) -> Result<(ProviderConfig, String), String> { +async fn resolve_model(core: &AtomicCore) -> Result<(ProviderConfig, String, Option), String> { let settings = core .get_settings() .await @@ -336,7 +336,11 @@ async fn resolve_model(core: &AtomicCore) -> Result<(ProviderConfig, String), St .cloned() .unwrap_or_else(|| "anthropic/claude-sonnet-4.6".to_string()), }; - Ok((config, model)) + let custom_prompt = settings + .get("briefing_prompt") + .filter(|s| !s.is_empty()) + .cloned(); + Ok((config, model, custom_prompt)) } async fn run_research( @@ -501,14 +505,15 @@ pub(crate) async fn generate( new_atoms: &[AtomWithTags], total_new: i32, ) -> Result<(String, Vec<(i32, String, String)>), String> { - let (provider_config, model) = resolve_model(core).await?; + let (provider_config, model, custom_system_prompt) = resolve_model(core).await?; tracing::info!(model = %model, atoms = new_atoms.len(), "[briefing/agentic] Running agent"); let user_prompt = build_user_prompt(since, new_atoms, total_new); + let system = custom_system_prompt.as_deref().unwrap_or(SYSTEM_PROMPT); let mut state = AgentState { messages: vec![ - Message::system(SYSTEM_PROMPT.to_string()), + Message::system(system.to_string()), Message::user(user_prompt), ], done_called: false, diff --git a/crates/atomic-core/src/chat.rs b/crates/atomic-core/src/chat.rs index a62b382f..257c3708 100644 --- a/crates/atomic-core/src/chat.rs +++ b/crates/atomic-core/src/chat.rs @@ -32,7 +32,7 @@ pub fn get_conversation_tags( conversation_id: &str, ) -> Result, AtomicCoreError> { let mut stmt = conn.prepare( - "SELECT t.id, t.name, t.parent_id, t.created_at, t.is_autotag_target + "SELECT t.id, t.name, t.parent_id, t.created_at, t.is_autotag_target, t.autotag_description FROM tags t JOIN conversation_tags ct ON ct.tag_id = t.id WHERE ct.conversation_id = ?1 @@ -47,6 +47,7 @@ pub fn get_conversation_tags( parent_id: row.get(2)?, created_at: row.get(3)?, is_autotag_target: row.get::<_, i32>(4)? != 0, + autotag_description: row.get(5)?, }) })? .collect::, _>>()?; @@ -292,7 +293,7 @@ fn batch_fetch_conversation_tags( } let placeholders = conv_ids.iter().map(|_| "?").collect::>().join(","); let query = format!( - "SELECT ct.conversation_id, t.id, t.name, t.parent_id, t.created_at, t.is_autotag_target + "SELECT ct.conversation_id, t.id, t.name, t.parent_id, t.created_at, t.is_autotag_target, t.autotag_description FROM conversation_tags ct JOIN tags t ON ct.tag_id = t.id WHERE ct.conversation_id IN ({}) @@ -310,6 +311,7 @@ fn batch_fetch_conversation_tags( parent_id: row.get(3)?, created_at: row.get(4)?, is_autotag_target: row.get::<_, i32>(5)? != 0, + autotag_description: row.get(6)?, }, )) })?; diff --git a/crates/atomic-core/src/db.rs b/crates/atomic-core/src/db.rs index c34ce2e3..a1d37f03 100644 --- a/crates/atomic-core/src/db.rs +++ b/crates/atomic-core/src/db.rs @@ -211,7 +211,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 = 15; + const LATEST_VERSION: i32 = 16; pub fn run_migrations(conn: &Connection) -> Result<(), AtomicCoreError> { Self::run_migrations_internal(conn, false) @@ -797,6 +797,25 @@ impl Database { conn.execute_batch("PRAGMA user_version = 15;")?; } + // --- V15 → V16: Per-target auto-tag guidance --- + if version < 16 { + let has_col: bool = conn + .query_row( + "SELECT 1 FROM pragma_table_info('tags') WHERE name='autotag_description'", + [], + |_| Ok(true), + ) + .unwrap_or(false); + + if !has_col { + conn.execute_batch( + "ALTER TABLE tags ADD COLUMN autotag_description TEXT NOT NULL DEFAULT '';", + )?; + } + + conn.execute_batch("PRAGMA user_version = 16;")?; + } + // --- 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/embedding.rs b/crates/atomic-core/src/embedding.rs index b45d79af..71ddc019 100644 --- a/crates/atomic-core/src/embedding.rs +++ b/crates/atomic-core/src/embedding.rs @@ -792,6 +792,10 @@ async fn process_tagging_only_inner( return Ok(TaggingOutcome::Skipped); } + let custom_tagging_prompt = settings_map + .get("tagging_prompt") + .filter(|s| !s.is_empty()) + .map(|s| s.as_str()); let tags = run_tagging_strategy( tagging_strategy, &provider_config, @@ -799,6 +803,7 @@ async fn process_tagging_only_inner( &tag_tree_json, &tagging_model, supported_params, + custom_tagging_prompt, ) .await?; @@ -852,7 +857,6 @@ async fn process_tagging_only_inner( new_tags_created: all_new_tag_ids, }) } - async fn run_tagging_strategy( strategy: TaggingStrategy, provider_config: &ProviderConfig, @@ -860,6 +864,7 @@ async fn run_tagging_strategy( tag_tree_json: &str, model: &str, supported_params: Option>, + custom_system_prompt: Option<&str>, ) -> Result, String> { match strategy { TaggingStrategy::TruncatedFullContent => { @@ -869,6 +874,7 @@ async fn run_tagging_strategy( tag_tree_json, model, supported_params, + custom_system_prompt, ) .await } @@ -882,6 +888,7 @@ async fn run_tagging_strategy( tag_tree_json, model, supported_params, + custom_system_prompt, ) .await } diff --git a/crates/atomic-core/src/extraction.rs b/crates/atomic-core/src/extraction.rs index a316e9d1..d7819c7a 100644 --- a/crates/atomic-core/src/extraction.rs +++ b/crates/atomic-core/src/extraction.rs @@ -144,6 +144,41 @@ Guidelines: - Every tag must have a valid parent_name from the top-level categories listed below - If none of the categories below feel like a natural fit for the content, return an empty tag list rather than forcing a poor match"#; +const SYSTEM_PROMPT_WITH_GUIDANCE: &str = r#"You are a knowledge management assistant that categorizes text with tags. + +PURPOSE OF TAGS: +Tags help users navigate and filter their content. Users can browse by tag and generate wiki articles that synthesize all content under a tag. Only add a tag if you believe strongly that the user would want this content categorized and filterable by that tag. + +IMPORTANT: +- Each tag MUST have a parent_name set to one of the existing top-level categories shown below +- DO NOT create new top-level categories - only use the ones the user has provided below +- Tag names are case-insensitive and globally unique + +The user has chosen which top-level categories the auto-tagger may extend. They are listed below with optional guidance and a sample of existing sub-tags under each, as a point of reference for the kinds of tags in this system. + +HIERARCHY STRUCTURE: +- Level 1: Categories (shown below) - use ONLY these existing categories as parent_name +- Level 2: Specific tags you create under those categories +- Maximum 2 levels - no deeper nesting + +RESPONSE FORMAT: +Return a JSON object with a "tags" array. Each tag is an object with "name" and "parent_name", where parent_name is one of the categories shown below: +{"tags": [{"name": "", "parent_name": ""}]} + +Guidelines: +- Create new Level 2 tags under the user's existing categories when needed +- Prefer broad tags rather than overly specific ones (e.g., "John Smith" instead of "Early Life of John Smith") +- Every tag must have a valid parent_name from the top-level categories listed below +- If none of the categories below feel like a natural fit for the content, return an empty tag list rather than forcing a poor match"#; + +fn default_system_prompt_for_tag_tree(tag_tree_json: &str) -> &'static str { + if tag_tree_json.contains("\nDescription: ") { + SYSTEM_PROMPT_WITH_GUIDANCE + } else { + SYSTEM_PROMPT + } +} + /// JSON schema for tag extraction calls. Shared by `extract_tags_from_content` /// and `extract_tags_from_chunk`. Kept portable: all properties required, /// `additionalProperties: false`, no unions. See `providers::structured::lint_schema` @@ -261,8 +296,8 @@ pub async fn extract_tags_from_content( tag_tree_json: &str, model: &str, supported_params: Option>, + custom_system_prompt: Option<&str>, ) -> Result, String> { - // Truncate based on provider's context length let max_chars = max_tagging_chars(provider_config, tag_tree_json, model); let text = if content.len() > max_chars { // Find the nearest char boundary at or before max_chars @@ -280,7 +315,10 @@ pub async fn extract_tags_from_content( tag_tree_json, text ); - let messages = vec![Message::system(SYSTEM_PROMPT), Message::user(user_content)]; + let system = custom_system_prompt + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| default_system_prompt_for_tag_tree(tag_tree_json)); + let messages = vec![Message::system(system), Message::user(user_content)]; let call = StructuredCall::::new( provider_config, @@ -314,7 +352,10 @@ pub async fn extract_tags_from_chunk( tag_tree_json, chunk_content ); - let messages = vec![Message::system(SYSTEM_PROMPT), Message::user(user_content)]; + let messages = vec![ + Message::system(default_system_prompt_for_tag_tree(tag_tree_json)), + Message::user(user_content), + ]; let call = StructuredCall::::new( provider_config, @@ -347,11 +388,16 @@ pub fn get_tag_tree_for_llm(conn: &Connection) -> Result { // Tags without is_autotag_target = 1 are intentionally excluded so the // auto-tagger only extends categories the user has opted into. let mut top_level_stmt = conn - .prepare("SELECT id, name FROM tags WHERE parent_id IS NULL AND is_autotag_target = 1 ORDER BY name") + .prepare( + "SELECT id, name, autotag_description + FROM tags + WHERE parent_id IS NULL AND is_autotag_target = 1 + ORDER BY name", + ) .map_err(|e| format!("Failed to prepare top-level tag query: {}", e))?; - let top_level_tags: Vec<(String, String)> = top_level_stmt - .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) + let top_level_tags: Vec<(String, String, String)> = top_level_stmt + .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))) .map_err(|e| format!("Failed to query top-level tags: {}", e))? .collect::, _>>() .map_err(|e| format!("Failed to collect top-level tags: {}", e))?; @@ -363,10 +409,16 @@ pub fn get_tag_tree_for_llm(conn: &Connection) -> Result { // Step 2: For each top-level tag, get top 10 most-used child tags by atom count let mut result = String::new(); - for (i, (parent_id, parent_name)) in top_level_tags.iter().enumerate() { + for (i, (parent_id, parent_name, description)) in top_level_tags.iter().enumerate() { // Add the top-level category result.push_str(parent_name); result.push('\n'); + let description = description.trim(); + if !description.is_empty() { + result.push_str("Description: "); + result.push_str(description); + result.push('\n'); + } // Query top 10 children by atom count let mut children_stmt = conn @@ -748,6 +800,40 @@ mod tests { // Should have tree format assert!(result.contains("Topics"), "Should contain parent tag"); assert!(result.contains("AI"), "Should contain child tag"); + assert!( + !result.contains("Description:"), + "Should not include description lines when no guidance is configured" + ); + } + + #[test] + fn test_get_tag_tree_for_llm_includes_autotag_description_when_present() { + let (db, _temp) = create_test_db(); + let conn = db.conn.lock().unwrap(); + + let tag_id = uuid::Uuid::new_v4().to_string(); + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "INSERT INTO tags (id, name, parent_id, created_at, is_autotag_target, autotag_description) + VALUES (?1, ?2, NULL, ?3, 1, ?4)", + rusqlite::params![ + &tag_id, + "Topics", + &now, + "Use for subject-matter themes, not people or organizations." + ], + ) + .unwrap(); + + let result = get_tag_tree_for_llm(&conn).unwrap(); + + assert!(result.contains("Topics")); + assert!(result + .contains("Description: Use for subject-matter themes, not people or organizations.")); + assert_eq!( + default_system_prompt_for_tag_tree(&result), + SYSTEM_PROMPT_WITH_GUIDANCE + ); } #[test] diff --git a/crates/atomic-core/src/lib.rs b/crates/atomic-core/src/lib.rs index b9b7cb18..703282c3 100644 --- a/crates/atomic-core/src/lib.rs +++ b/crates/atomic-core/src/lib.rs @@ -1220,6 +1220,19 @@ impl AtomicCore { self.storage.set_tag_autotag_target_impl(id, value).await } + /// Set optional guidance for a top-level auto-tag target. When present, + /// the guidance is injected next to the category name in the auto-tagging + /// prompt so the model knows how the user intends that category to be used. + pub async fn set_tag_autotag_description( + &self, + id: &str, + description: &str, + ) -> Result<(), AtomicCoreError> { + self.storage + .set_tag_autotag_description_impl(id, description) + .await + } + /// Configure auto-tag targets in one shot — used by the onboarding wizard /// and the settings tab. /// @@ -4125,7 +4138,7 @@ pub(crate) fn get_tags_for_atom( atom_id: &str, ) -> Result, AtomicCoreError> { let mut stmt = conn.prepare( - "SELECT t.id, t.name, t.parent_id, t.created_at, t.is_autotag_target + "SELECT t.id, t.name, t.parent_id, t.created_at, t.is_autotag_target, t.autotag_description FROM tags t INNER JOIN atom_tags at ON t.id = at.tag_id WHERE at.atom_id = ?1", @@ -4139,6 +4152,7 @@ pub(crate) fn get_tags_for_atom( parent_id: row.get(2)?, created_at: row.get(3)?, is_autotag_target: row.get::<_, i32>(4)? != 0, + autotag_description: row.get(5)?, }) })? .collect::, _>>()?; @@ -4152,7 +4166,7 @@ pub(crate) fn get_all_atom_tags_map( conn: &Connection, ) -> Result>, AtomicCoreError> { let mut stmt = conn.prepare( - "SELECT at.atom_id, t.id, t.name, t.parent_id, t.created_at, t.is_autotag_target + "SELECT at.atom_id, t.id, t.name, t.parent_id, t.created_at, t.is_autotag_target, t.autotag_description FROM atom_tags at INNER JOIN tags t ON at.tag_id = t.id", )?; @@ -4168,6 +4182,7 @@ pub(crate) fn get_all_atom_tags_map( parent_id: row.get(3)?, created_at: row.get(4)?, is_autotag_target: row.get::<_, i32>(5)? != 0, + autotag_description: row.get(6)?, }, )) })?; @@ -4191,7 +4206,7 @@ pub(crate) fn get_atom_tags_map_for_ids( let placeholders = atom_ids.iter().map(|_| "?").collect::>().join(","); let query = format!( - "SELECT at.atom_id, t.id, t.name, t.parent_id, t.created_at, t.is_autotag_target + "SELECT at.atom_id, t.id, t.name, t.parent_id, t.created_at, t.is_autotag_target, t.autotag_description FROM atom_tags at INNER JOIN tags t ON at.tag_id = t.id WHERE at.atom_id IN ({})", @@ -4211,6 +4226,7 @@ pub(crate) fn get_atom_tags_map_for_ids( parent_id: row.get(3)?, created_at: row.get(4)?, is_autotag_target: row.get::<_, i32>(5)? != 0, + autotag_description: row.get(6)?, }, )) })?; @@ -4343,6 +4359,7 @@ mod tests { parent_id, created_at, is_autotag_target: is_autotag_target != 0, + autotag_description: String::new(), } } diff --git a/crates/atomic-core/src/models.rs b/crates/atomic-core/src/models.rs index 99709a96..5be0473b 100644 --- a/crates/atomic-core/src/models.rs +++ b/crates/atomic-core/src/models.rs @@ -32,6 +32,8 @@ pub struct Tag { pub parent_id: Option, pub created_at: String, pub is_autotag_target: bool, + #[serde(default)] + pub autotag_description: String, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/atomic-core/src/search.rs b/crates/atomic-core/src/search.rs index c1d1c4e4..54622ebd 100644 --- a/crates/atomic-core/src/search.rs +++ b/crates/atomic-core/src/search.rs @@ -309,7 +309,7 @@ fn batch_fetch_tags( } let placeholders = atom_ids.iter().map(|_| "?").collect::>().join(","); let query = format!( - "SELECT at.atom_id, t.id, t.name, t.parent_id, t.created_at, t.is_autotag_target + "SELECT at.atom_id, t.id, t.name, t.parent_id, t.created_at, t.is_autotag_target, t.autotag_description FROM atom_tags at INNER JOIN tags t ON at.tag_id = t.id WHERE at.atom_id IN ({})", @@ -327,6 +327,7 @@ fn batch_fetch_tags( parent_id: row.get(3)?, created_at: row.get(4)?, is_autotag_target: row.get::<_, i32>(5)? != 0, + autotag_description: row.get(6)?, }, )) }) diff --git a/crates/atomic-core/src/settings.rs b/crates/atomic-core/src/settings.rs index 6d00b00d..b9ba5709 100644 --- a/crates/atomic-core/src/settings.rs +++ b/crates/atomic-core/src/settings.rs @@ -73,6 +73,9 @@ pub const DEFAULT_SETTINGS: &[(&str, &str)] = &[ ("openai_compat_timeout_secs", "300"), // 5 minutes default for OpenAI-compatible servers ("wiki_generation_prompt", ""), ("wiki_update_prompt", ""), + ("briefing_prompt", ""), + ("chat_prompt", ""), + ("tagging_prompt", ""), // Scheduled tasks — see crate::scheduler::state for key format ("task.daily_briefing.enabled", "true"), ("task.daily_briefing.interval_hours", "24"), diff --git a/crates/atomic-core/src/storage/mod.rs b/crates/atomic-core/src/storage/mod.rs index 13fcb2db..9c33367f 100644 --- a/crates/atomic-core/src/storage/mod.rs +++ b/crates/atomic-core/src/storage/mod.rs @@ -387,6 +387,8 @@ dispatch! { => sqlite: delete_tag_impl, pg_trait: TagStore, pg_method: delete_tag; fn set_tag_autotag_target_impl(&self, id: &str, value: bool) -> Result<(), AtomicCoreError> => 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 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/atoms.rs b/crates/atomic-core/src/storage/postgres/atoms.rs index 72eb75f9..1e80105f 100644 --- a/crates/atomic-core/src/storage/postgres/atoms.rs +++ b/crates/atomic-core/src/storage/postgres/atoms.rs @@ -22,8 +22,8 @@ fn escape_like_pattern(input: &str) -> String { impl PostgresStorage { /// Fetch tags for a single atom. async fn tags_for_atom(&self, atom_id: &str) -> StorageResult> { - let rows: Vec<(String, String, Option, String, bool)> = sqlx::query_as( - "SELECT t.id, t.name, t.parent_id, t.created_at, t.is_autotag_target + let rows: Vec<(String, String, Option, String, bool, String)> = sqlx::query_as( + "SELECT t.id, t.name, t.parent_id, t.created_at, t.is_autotag_target, t.autotag_description FROM tags t JOIN atom_tags at ON t.id = at.tag_id WHERE at.atom_id = $1 AND at.db_id = $2 @@ -37,12 +37,13 @@ impl PostgresStorage { Ok(rows .into_iter() - .map(|(id, name, parent_id, created_at, is_autotag_target)| Tag { + .map(|(id, name, parent_id, created_at, is_autotag_target, autotag_description)| Tag { id, name, parent_id, created_at, is_autotag_target, + autotag_description, }) .collect()) } @@ -63,7 +64,7 @@ impl PostgresStorage { .map(|i| format!("${}", i)) .collect(); let sql = format!( - "SELECT at.atom_id, t.id, t.name, t.parent_id, t.created_at, t.is_autotag_target + "SELECT at.atom_id, t.id, t.name, t.parent_id, t.created_at, t.is_autotag_target, t.autotag_description FROM atom_tags at JOIN tags t ON t.id = at.tag_id WHERE at.db_id = $1 AND at.atom_id IN ({}) @@ -72,7 +73,7 @@ impl PostgresStorage { ); let mut query = - sqlx::query_as::<_, (String, String, String, Option, String, bool)>(&sql); + sqlx::query_as::<_, (String, String, String, Option, String, bool, String)>(&sql); query = query.bind(&self.db_id); for id in atom_ids { query = query.bind(id); @@ -84,13 +85,14 @@ impl PostgresStorage { .map_err(|e| AtomicCoreError::DatabaseOperation(e.to_string()))?; let mut map: HashMap> = HashMap::new(); - for (atom_id, tag_id, name, parent_id, created_at, is_autotag_target) in rows { + for (atom_id, tag_id, name, parent_id, created_at, is_autotag_target, autotag_description) in rows { map.entry(atom_id).or_default().push(Tag { id: tag_id, name, parent_id, created_at, is_autotag_target, + autotag_description, }); } diff --git a/crates/atomic-core/src/storage/postgres/briefings.rs b/crates/atomic-core/src/storage/postgres/briefings.rs index 6e9dbac1..730ee9ff 100644 --- a/crates/atomic-core/src/storage/postgres/briefings.rs +++ b/crates/atomic-core/src/storage/postgres/briefings.rs @@ -91,26 +91,30 @@ impl BriefingStore for PostgresStorage { } let atom_ids: Vec = atoms.iter().map(|a| a.id.clone()).collect(); - let tag_rows = sqlx::query_as::<_, (String, String, String, Option, String, bool)>( - "SELECT at.atom_id, t.id, t.name, t.parent_id, t.created_at, t.is_autotag_target + let tag_rows = + sqlx::query_as::<_, (String, String, String, Option, String, bool, String)>( + "SELECT at.atom_id, t.id, t.name, t.parent_id, t.created_at, t.is_autotag_target, t.autotag_description FROM atom_tags at INNER JOIN tags t ON t.id = at.tag_id WHERE at.atom_id = ANY($1) AND at.db_id = $2", - ) - .bind(&atom_ids) - .bind(&self.db_id) - .fetch_all(&self.pool) - .await - .map_err(|e| AtomicCoreError::DatabaseOperation(e.to_string()))?; + ) + .bind(&atom_ids) + .bind(&self.db_id) + .fetch_all(&self.pool) + .await + .map_err(|e| AtomicCoreError::DatabaseOperation(e.to_string()))?; let mut tag_map: HashMap> = HashMap::new(); - for (atom_id, id, name, parent_id, created_at, is_autotag_target) in tag_rows { + for (atom_id, id, name, parent_id, created_at, is_autotag_target, autotag_description) in + tag_rows + { tag_map.entry(atom_id).or_default().push(Tag { id, name, parent_id, created_at, is_autotag_target, + autotag_description, }); } diff --git a/crates/atomic-core/src/storage/postgres/chat.rs b/crates/atomic-core/src/storage/postgres/chat.rs index d339b9ed..a365e4fc 100644 --- a/crates/atomic-core/src/storage/postgres/chat.rs +++ b/crates/atomic-core/src/storage/postgres/chat.rs @@ -12,8 +12,8 @@ async fn fetch_conversation_tags( conversation_id: &str, db_id: &str, ) -> StorageResult> { - let rows = sqlx::query_as::<_, (String, String, Option, String, bool)>( - "SELECT t.id, t.name, t.parent_id, t.created_at, t.is_autotag_target + let rows = sqlx::query_as::<_, (String, String, Option, String, bool, String)>( + "SELECT t.id, t.name, t.parent_id, t.created_at, t.is_autotag_target, t.autotag_description FROM tags t JOIN conversation_tags ct ON ct.tag_id = t.id WHERE ct.conversation_id = $1 AND ct.db_id = $2 AND t.db_id = $2 @@ -27,12 +27,13 @@ async fn fetch_conversation_tags( Ok(rows .into_iter() - .map(|(id, name, parent_id, created_at, is_autotag_target)| Tag { + .map(|(id, name, parent_id, created_at, is_autotag_target, autotag_description)| Tag { id, name, parent_id, created_at, is_autotag_target, + autotag_description, }) .collect()) } @@ -120,8 +121,8 @@ async fn batch_fetch_conversation_tags( return Ok(HashMap::new()); } - let rows = sqlx::query_as::<_, (String, String, String, Option, String, bool)>( - "SELECT ct.conversation_id, t.id, t.name, t.parent_id, t.created_at, t.is_autotag_target + let rows = sqlx::query_as::<_, (String, String, String, Option, String, bool, String)>( + "SELECT ct.conversation_id, t.id, t.name, t.parent_id, t.created_at, t.is_autotag_target, t.autotag_description FROM conversation_tags ct JOIN tags t ON ct.tag_id = t.id WHERE ct.conversation_id = ANY($1) AND ct.db_id = $2 AND t.db_id = $2 @@ -134,13 +135,14 @@ async fn batch_fetch_conversation_tags( .map_err(|e| AtomicCoreError::DatabaseOperation(e.to_string()))?; let mut map: HashMap> = HashMap::new(); - for (conv_id, id, name, parent_id, created_at, is_autotag_target) in rows { + for (conv_id, id, name, parent_id, created_at, is_autotag_target, autotag_description) in rows { map.entry(conv_id).or_default().push(Tag { id, name, parent_id, created_at, is_autotag_target, + autotag_description, }); } diff --git a/crates/atomic-core/src/storage/postgres/chunks.rs b/crates/atomic-core/src/storage/postgres/chunks.rs index fa93f221..2bcd971a 100644 --- a/crates/atomic-core/src/storage/postgres/chunks.rs +++ b/crates/atomic-core/src/storage/postgres/chunks.rs @@ -587,8 +587,8 @@ impl ChunkStore for PostgresStorage { .collect(); // Batch fetch tags - let tag_rows: Vec<(String, String, String, Option, String, bool)> = sqlx::query_as( - "SELECT at.atom_id, t.id, t.name, t.parent_id, t.created_at, t.is_autotag_target + let tag_rows: Vec<(String, String, String, Option, String, bool, String)> = sqlx::query_as( + "SELECT at.atom_id, t.id, t.name, t.parent_id, t.created_at, t.is_autotag_target, t.autotag_description FROM atom_tags at INNER JOIN tags t ON at.tag_id = t.id WHERE at.atom_id = ANY($1) AND at.db_id = $2", @@ -605,13 +605,14 @@ impl ChunkStore for PostgresStorage { })?; let mut tag_map: HashMap> = HashMap::new(); - for (atom_id_val, tag_id, name, parent_id, created_at, is_autotag_target) in tag_rows { + for (atom_id_val, tag_id, name, parent_id, created_at, is_autotag_target, autotag_description) in tag_rows { tag_map.entry(atom_id_val).or_default().push(Tag { id: tag_id, name, parent_id, created_at, is_autotag_target, + autotag_description, }); } diff --git a/crates/atomic-core/src/storage/postgres/migrations/012_autotag_description.sql b/crates/atomic-core/src/storage/postgres/migrations/012_autotag_description.sql new file mode 100644 index 00000000..021b10b8 --- /dev/null +++ b/crates/atomic-core/src/storage/postgres/migrations/012_autotag_description.sql @@ -0,0 +1,4 @@ +-- Optional guidance injected next to top-level auto-tag targets in the tagging prompt. +ALTER TABLE tags ADD COLUMN IF NOT EXISTS autotag_description TEXT NOT NULL DEFAULT ''; + +INSERT INTO schema_version (version) VALUES (12); diff --git a/crates/atomic-core/src/storage/postgres/mod.rs b/crates/atomic-core/src/storage/postgres/mod.rs index ec71fef1..352cd347 100644 --- a/crates/atomic-core/src/storage/postgres/mod.rs +++ b/crates/atomic-core/src/storage/postgres/mod.rs @@ -107,6 +107,7 @@ impl PostgresStorage { (9, include_str!("migrations/009_atom_links.sql")), (10, include_str!("migrations/010_pipeline_jobs.sql")), (11, include_str!("migrations/011_edges_status.sql")), + (12, include_str!("migrations/012_autotag_description.sql")), ]; // Advisory lock key — arbitrary fixed i64 to serialize migrations diff --git a/crates/atomic-core/src/storage/postgres/search.rs b/crates/atomic-core/src/storage/postgres/search.rs index d910fb34..e98f95b0 100644 --- a/crates/atomic-core/src/storage/postgres/search.rs +++ b/crates/atomic-core/src/storage/postgres/search.rs @@ -628,8 +628,8 @@ async fn pg_batch_fetch_tags( return Ok(HashMap::new()); } - let rows: Vec<(String, String, String, Option, String, bool)> = sqlx::query_as( - "SELECT at.atom_id, t.id, t.name, t.parent_id, t.created_at, t.is_autotag_target + let rows: Vec<(String, String, String, Option, String, bool, String)> = sqlx::query_as( + "SELECT at.atom_id, t.id, t.name, t.parent_id, t.created_at, t.is_autotag_target, t.autotag_description FROM atom_tags at INNER JOIN tags t ON at.tag_id = t.id WHERE at.atom_id = ANY($1) AND at.db_id = $2", @@ -641,13 +641,16 @@ async fn pg_batch_fetch_tags( .map_err(|e| AtomicCoreError::Search(format!("Failed to batch fetch tags: {}", e)))?; let mut map: HashMap> = HashMap::new(); - for (atom_id, tag_id, name, parent_id, created_at, is_autotag_target) in rows { + for (atom_id, tag_id, name, parent_id, created_at, is_autotag_target, autotag_description) in + rows + { map.entry(atom_id).or_default().push(Tag { id: tag_id, name, parent_id, created_at, is_autotag_target, + autotag_description, }); } Ok(map) @@ -913,8 +916,8 @@ async fn pg_batch_fetch_conversation_tags( return Ok(HashMap::new()); } - let rows: Vec<(String, String, String, Option, String, bool)> = sqlx::query_as( - "SELECT ct.conversation_id, t.id, t.name, t.parent_id, t.created_at, t.is_autotag_target + let rows: Vec<(String, String, String, Option, String, bool, String)> = sqlx::query_as( + "SELECT ct.conversation_id, t.id, t.name, t.parent_id, t.created_at, t.is_autotag_target, t.autotag_description FROM conversation_tags ct JOIN tags t ON ct.tag_id = t.id WHERE ct.conversation_id = ANY($1) AND ct.db_id = $2 AND t.db_id = $2 @@ -929,13 +932,16 @@ async fn pg_batch_fetch_conversation_tags( })?; let mut map: HashMap> = HashMap::new(); - for (conversation_id, id, name, parent_id, created_at, is_autotag_target) in rows { + for (conversation_id, id, name, parent_id, created_at, is_autotag_target, autotag_description) in + rows + { map.entry(conversation_id).or_default().push(Tag { id, name, parent_id, created_at, is_autotag_target, + autotag_description, }); } Ok(map) diff --git a/crates/atomic-core/src/storage/postgres/tags.rs b/crates/atomic-core/src/storage/postgres/tags.rs index 0a9fc7d3..03432e2e 100644 --- a/crates/atomic-core/src/storage/postgres/tags.rs +++ b/crates/atomic-core/src/storage/postgres/tags.rs @@ -11,8 +11,8 @@ use uuid::Uuid; impl PostgresStorage { /// Load all tags and their direct (denormalized) atom counts. async fn load_tags_and_counts(&self) -> StorageResult<(Vec, HashMap)> { - let rows: Vec<(String, String, Option, String, i32, bool)> = sqlx::query_as( - "SELECT id, name, parent_id, created_at, atom_count, is_autotag_target FROM tags WHERE db_id = $1 ORDER BY name", + let rows: Vec<(String, String, Option, String, i32, bool, String)> = sqlx::query_as( + "SELECT id, name, parent_id, created_at, atom_count, is_autotag_target, autotag_description FROM tags WHERE db_id = $1 ORDER BY name", ) .bind(&self.db_id) .fetch_all(&self.pool) @@ -23,7 +23,15 @@ impl PostgresStorage { let all_tags: Vec = rows .into_iter() .map( - |(id, name, parent_id, created_at, count, is_autotag_target)| { + |( + id, + name, + parent_id, + created_at, + count, + is_autotag_target, + autotag_description, + )| { direct_counts.insert(id.clone(), count); Tag { id, @@ -31,6 +39,7 @@ impl PostgresStorage { parent_id, created_at, is_autotag_target, + autotag_description, } }, ) @@ -250,10 +259,11 @@ impl TagStore for PostgresStorage { }); } - let rows: Vec<(String, String, Option, String, i32, i64, bool)> = sqlx::query_as( + let rows: Vec<(String, String, Option, String, i32, i64, bool, String)> = sqlx::query_as( "SELECT t.id, t.name, t.parent_id, t.created_at, t.atom_count, (SELECT COUNT(*) FROM tags c WHERE c.parent_id = t.id AND c.db_id = $2) AS children_total, - t.is_autotag_target + t.is_autotag_target, + t.autotag_description FROM tags t WHERE t.parent_id = $1 AND t.db_id = $2 ORDER BY t.atom_count DESC @@ -278,6 +288,7 @@ impl TagStore for PostgresStorage { atom_count, children_total, is_autotag_target, + autotag_description, )| TagWithCount { tag: Tag { id, @@ -285,6 +296,7 @@ impl TagStore for PostgresStorage { parent_id, created_at, is_autotag_target, + autotag_description, }, atom_count, children_total: children_total as i32, @@ -322,6 +334,7 @@ impl TagStore for PostgresStorage { parent_id: parent_id.map(String::from), created_at: now, is_autotag_target: false, + autotag_description: String::new(), }) } @@ -340,8 +353,8 @@ impl TagStore for PostgresStorage { .await .map_err(|e| AtomicCoreError::DatabaseOperation(e.to_string()))?; - let row: (String, String, Option, String, bool) = sqlx::query_as( - "SELECT id, name, parent_id, created_at, is_autotag_target FROM tags WHERE id = $1 AND db_id = $2", + let row: (String, String, Option, String, bool, String) = sqlx::query_as( + "SELECT id, name, parent_id, created_at, is_autotag_target, autotag_description FROM tags WHERE id = $1 AND db_id = $2", ) .bind(id) .bind(&self.db_id) @@ -355,6 +368,7 @@ impl TagStore for PostgresStorage { parent_id: row.2, created_at: row.3, is_autotag_target: row.4, + autotag_description: row.5, }) } @@ -373,6 +387,24 @@ impl TagStore for PostgresStorage { Ok(()) } + async fn set_tag_autotag_description(&self, id: &str, description: &str) -> StorageResult<()> { + let result = sqlx::query( + "UPDATE tags + SET autotag_description = $1 + WHERE id = $2 AND db_id = $3 AND parent_id IS NULL", + ) + .bind(description.trim()) + .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!("top-level tag {}", id))); + } + Ok(()) + } + async fn configure_autotag_targets( &self, keep_default_names: &[String], @@ -506,8 +538,8 @@ impl TagStore for PostgresStorage { .execute(&mut *tx) .await .map_err(|e| AtomicCoreError::DatabaseOperation(e.to_string()))?; - let row: (String, String, Option, String, bool) = sqlx::query_as( - "SELECT id, name, parent_id, created_at, is_autotag_target FROM tags WHERE id = $1 AND db_id = $2", + let row: (String, String, Option, String, bool, String) = sqlx::query_as( + "SELECT id, name, parent_id, created_at, is_autotag_target, autotag_description FROM tags WHERE id = $1 AND db_id = $2", ) .bind(&id) .bind(&self.db_id) @@ -520,6 +552,7 @@ impl TagStore for PostgresStorage { parent_id: row.2, created_at: row.3, is_autotag_target: row.4, + autotag_description: row.5, }); } @@ -943,9 +976,12 @@ impl TagStore for PostgresStorage { } async fn get_tag_tree_for_llm(&self) -> StorageResult { - // Step 1: Get top-level category tags - let top_level_tags: Vec<(String, String)> = sqlx::query_as( - "SELECT id, name FROM tags WHERE parent_id IS NULL AND db_id = $1 ORDER BY name", + // Step 1: Get top-level category tags flagged as auto-tag targets. + let top_level_tags: Vec<(String, String, String)> = sqlx::query_as( + "SELECT id, name, autotag_description + FROM tags + WHERE parent_id IS NULL AND is_autotag_target = TRUE AND db_id = $1 + ORDER BY name", ) .bind(&self.db_id) .fetch_all(&self.pool) @@ -959,9 +995,15 @@ impl TagStore for PostgresStorage { // Step 2: For each top-level tag, get top 10 most-used child tags by atom count let mut result = String::new(); - for (parent_id, parent_name) in &top_level_tags { + for (parent_id, parent_name, description) in &top_level_tags { result.push_str(parent_name); result.push('\n'); + let description = description.trim(); + if !description.is_empty() { + result.push_str("Description: "); + result.push_str(description); + result.push('\n'); + } // Query top 10 children by atom count let children: Vec<(String,)> = sqlx::query_as( diff --git a/crates/atomic-core/src/storage/sqlite/briefings.rs b/crates/atomic-core/src/storage/sqlite/briefings.rs index 9cc27566..ff8d60ca 100644 --- a/crates/atomic-core/src/storage/sqlite/briefings.rs +++ b/crates/atomic-core/src/storage/sqlite/briefings.rs @@ -64,7 +64,7 @@ impl SqliteStorage { let mut tag_map: std::collections::HashMap> = std::collections::HashMap::new(); let tag_sql = format!( - "SELECT at.atom_id, t.id, t.name, t.parent_id, t.created_at, t.is_autotag_target + "SELECT at.atom_id, t.id, t.name, t.parent_id, t.created_at, t.is_autotag_target, t.autotag_description FROM atom_tags at INNER JOIN tags t ON t.id = at.tag_id WHERE at.atom_id IN ({})", @@ -80,6 +80,7 @@ impl SqliteStorage { parent_id: row.get(3)?, created_at: row.get(4)?, is_autotag_target: row.get::<_, i64>(5).unwrap_or(0) != 0, + autotag_description: row.get(6)?, }, )) })?; diff --git a/crates/atomic-core/src/storage/sqlite/search.rs b/crates/atomic-core/src/storage/sqlite/search.rs index 8688f592..88c65d75 100644 --- a/crates/atomic-core/src/storage/sqlite/search.rs +++ b/crates/atomic-core/src/storage/sqlite/search.rs @@ -1026,7 +1026,7 @@ fn batch_fetch_tags( } let placeholders = atom_ids.iter().map(|_| "?").collect::>().join(","); let query = format!( - "SELECT at.atom_id, t.id, t.name, t.parent_id, t.created_at, t.is_autotag_target + "SELECT at.atom_id, t.id, t.name, t.parent_id, t.created_at, t.is_autotag_target, t.autotag_description FROM atom_tags at INNER JOIN tags t ON at.tag_id = t.id WHERE at.atom_id IN ({})", @@ -1046,6 +1046,7 @@ fn batch_fetch_tags( parent_id: row.get(3)?, created_at: row.get(4)?, is_autotag_target: row.get::<_, i32>(5)? != 0, + autotag_description: row.get(6)?, }, )) }) @@ -1112,7 +1113,7 @@ fn batch_fetch_conversation_tags( .collect::>() .join(","); let query = format!( - "SELECT ct.conversation_id, t.id, t.name, t.parent_id, t.created_at, t.is_autotag_target + "SELECT ct.conversation_id, t.id, t.name, t.parent_id, t.created_at, t.is_autotag_target, t.autotag_description FROM conversation_tags ct INNER JOIN tags t ON t.id = ct.tag_id WHERE ct.conversation_id IN ({})", @@ -1131,6 +1132,7 @@ fn batch_fetch_conversation_tags( parent_id: row.get(3)?, created_at: row.get(4)?, is_autotag_target: row.get::<_, i32>(5)? != 0, + autotag_description: row.get(6)?, }, )) }) diff --git a/crates/atomic-core/src/storage/sqlite/tags.rs b/crates/atomic-core/src/storage/sqlite/tags.rs index ead7cd01..a9b28f6c 100644 --- a/crates/atomic-core/src/storage/sqlite/tags.rs +++ b/crates/atomic-core/src/storage/sqlite/tags.rs @@ -47,8 +47,11 @@ fn delete_wiki_fts_rows_for_tags( fn load_tags_and_counts( conn: &Connection, ) -> Result<(Vec, HashMap), AtomicCoreError> { - let mut stmt = conn - .prepare("SELECT id, name, parent_id, created_at, atom_count, is_autotag_target FROM tags ORDER BY name")?; + let mut stmt = conn.prepare( + "SELECT id, name, parent_id, created_at, atom_count, is_autotag_target, autotag_description + FROM tags + ORDER BY name", + )?; let mut direct_counts: HashMap = HashMap::new(); let all_tags: Vec = stmt @@ -62,6 +65,7 @@ fn load_tags_and_counts( parent_id: row.get(2)?, created_at: row.get(3)?, is_autotag_target: row.get::<_, i32>(5)? != 0, + autotag_description: row.get(6)?, }) })? .collect::, _>>()?; @@ -116,7 +120,8 @@ impl SqliteStorage { let mut stmt = conn.prepare( "SELECT t.id, t.name, t.parent_id, t.created_at, t.atom_count, (SELECT COUNT(*) FROM tags c WHERE c.parent_id = t.id) AS children_total, - t.is_autotag_target + t.is_autotag_target, + t.autotag_description FROM tags t WHERE t.parent_id = ?1 ORDER BY t.atom_count DESC @@ -132,6 +137,7 @@ impl SqliteStorage { parent_id: row.get(2)?, created_at: row.get(3)?, is_autotag_target: row.get::<_, i32>(6)? != 0, + autotag_description: row.get(7)?, }, atom_count: row.get(4)?, children_total: row.get(5)?, @@ -172,6 +178,7 @@ impl SqliteStorage { parent_id: parent_id.map(String::from), created_at: now, is_autotag_target: false, + autotag_description: String::new(), }) } @@ -202,7 +209,7 @@ impl SqliteStorage { )?; let tag = conn.query_row( - "SELECT id, name, parent_id, created_at, is_autotag_target FROM tags WHERE id = ?1", + "SELECT id, name, parent_id, created_at, is_autotag_target, autotag_description FROM tags WHERE id = ?1", [id], |row| { Ok(Tag { @@ -211,6 +218,7 @@ impl SqliteStorage { parent_id: row.get(2)?, created_at: row.get(3)?, is_autotag_target: row.get::<_, i32>(4)? != 0, + autotag_description: row.get(5)?, }) }, )?; @@ -235,6 +243,26 @@ impl SqliteStorage { Ok(()) } + pub(crate) fn set_tag_autotag_description_impl( + &self, + id: &str, + description: &str, + ) -> StorageResult<()> { + let conn = self + .db + .conn + .lock() + .map_err(|e| AtomicCoreError::Lock(e.to_string()))?; + let affected = conn.execute( + "UPDATE tags SET autotag_description = ?1 WHERE id = ?2 AND parent_id IS NULL", + rusqlite::params![description.trim(), id], + )?; + if affected == 0 { + return Err(AtomicCoreError::NotFound(format!("top-level 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 @@ -372,7 +400,7 @@ impl SqliteStorage { rusqlite::params![&id], )?; let tag = tx.query_row( - "SELECT id, name, parent_id, created_at, is_autotag_target FROM tags WHERE id = ?1", + "SELECT id, name, parent_id, created_at, is_autotag_target, autotag_description FROM tags WHERE id = ?1", [&id], |row| { Ok(Tag { @@ -381,6 +409,7 @@ impl SqliteStorage { parent_id: row.get(2)?, created_at: row.get(3)?, is_autotag_target: row.get::<_, i32>(4)? != 0, + autotag_description: row.get(5)?, }) }, )?; @@ -573,6 +602,10 @@ impl TagStore for SqliteStorage { self.set_tag_autotag_target_impl(id, value) } + async fn set_tag_autotag_description(&self, id: &str, description: &str) -> StorageResult<()> { + self.set_tag_autotag_description_impl(id, description) + } + 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 6356fb5a..5bc749c7 100644 --- a/crates/atomic-core/src/storage/traits.rs +++ b/crates/atomic-core/src/storage/traits.rs @@ -198,6 +198,9 @@ pub trait TagStore: Send + Sync { /// Mark or unmark a tag as a candidate for AI auto-tagging to extend with sub-tags. async fn set_tag_autotag_target(&self, id: &str, value: bool) -> StorageResult<()>; + /// Set optional guidance used when this tag is an auto-tag target. + async fn set_tag_autotag_description(&self, id: &str, description: &str) -> 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-server/src/lib.rs b/crates/atomic-server/src/lib.rs index d7877266..67ebefc9 100644 --- a/crates/atomic-server/src/lib.rs +++ b/crates/atomic-server/src/lib.rs @@ -47,6 +47,7 @@ pub use utoipa_scalar::{Scalar, Servable}; routes::atoms::update_tag, routes::atoms::delete_tag, routes::atoms::set_tag_autotag_target, + routes::atoms::set_tag_autotag_description, routes::atoms::configure_autotag_targets, // Search routes::search::search, diff --git a/crates/atomic-server/src/routes/atoms.rs b/crates/atomic-server/src/routes/atoms.rs index 8ee28382..23533024 100644 --- a/crates/atomic-server/src/routes/atoms.rs +++ b/crates/atomic-server/src/routes/atoms.rs @@ -568,6 +568,12 @@ pub struct SetAutotagTargetRequest { pub value: bool, } +#[derive(Deserialize, Serialize, ToSchema)] +pub struct SetAutotagDescriptionRequest { + /// Optional guidance injected next to this top-level auto-tag target. + pub description: String, +} + #[utoipa::path( put, path = "/api/tags/{id}/autotag-target", @@ -594,6 +600,32 @@ pub async fn set_tag_autotag_target( } } +#[utoipa::path( + put, + path = "/api/tags/{id}/autotag-description", + params( + ("id" = String, Path, description = "Tag ID"), + ), + request_body = SetAutotagDescriptionRequest, + responses( + (status = 204, description = "Description updated"), + (status = 404, description = "Top-level tag not found", body = ApiErrorResponse), + ), + tag = "tags", +)] +pub async fn set_tag_autotag_description( + db: Db, + path: web::Path, + body: web::Json, +) -> HttpResponse { + let id = path.into_inner(); + let description = body.into_inner().description; + match db.0.set_tag_autotag_description(&id, &description).await { + Ok(()) => HttpResponse::NoContent().finish(), + 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 0f6f6f78..709523b8 100644 --- a/crates/atomic-server/src/routes/mod.rs +++ b/crates/atomic-server/src/routes/mod.rs @@ -71,6 +71,10 @@ pub fn configure_routes(cfg: &mut web::ServiceConfig) { "/tags/{id}/autotag-target", web::put().to(atoms::set_tag_autotag_target), ); + cfg.route( + "/tags/{id}/autotag-description", + web::put().to(atoms::set_tag_autotag_description), + ); cfg.route("/tags/{id}", web::put().to(atoms::update_tag)); cfg.route("/tags/{id}", web::delete().to(atoms::delete_tag)); diff --git a/package-lock.json b/package-lock.json index c0216e34..65760a2a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13424,17 +13424,6 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "license": "MIT", - "optional": true, - "peer": true, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/src/components/settings/SettingsModal.tsx b/src/components/settings/SettingsModal.tsx index 0f3038e0..800475df 100644 --- a/src/components/settings/SettingsModal.tsx +++ b/src/components/settings/SettingsModal.tsx @@ -76,11 +76,12 @@ import { formatRelativeDate } from '../../lib/date'; import { useDatabasesStore, type DatabaseInfo, type DatabaseStats } from '../../stores/databases'; import { OverrideControls } from './OverrideControls'; -export type SettingsTab = 'general' | 'ai' | 'tag-categories' | 'connection' | 'integrations' | 'databases'; +export type SettingsTab = 'general' | 'ai' | 'tag-categories' | 'connection' | 'integrations' | 'databases' | 'prompts'; const SETTINGS_TABS: { id: SettingsTab; label: string }[] = [ { id: 'general', label: 'General' }, { id: 'ai', label: 'AI Models' }, + { id: 'prompts', label: 'Prompts' }, { id: 'tag-categories', label: 'Tags' }, { id: 'connection', label: 'Connection' }, { id: 'integrations', label: 'Integrations' }, @@ -91,10 +92,13 @@ function TagCategoriesTab() { const tags = useTagsStore(s => s.tags); const fetchTags = useTagsStore(s => s.fetchTags); const setTagAutotagTarget = useTagsStore(s => s.setTagAutotagTarget); + const setTagAutotagDescription = useTagsStore(s => s.setTagAutotagDescription); const createTag = useTagsStore(s => s.createTag); const [newName, setNewName] = useState(''); const [creating, setCreating] = useState(false); const [errorMsg, setErrorMsg] = useState(null); + const [expandedTagId, setExpandedTagId] = useState(null); + const [descriptionDrafts, setDescriptionDrafts] = useState>({}); useEffect(() => { fetchTags(); @@ -138,6 +142,26 @@ function TagCategoriesTab() { } }; + const handleDescriptionChange = (id: string, value: string) => { + setDescriptionDrafts(current => ({ ...current, [id]: value })); + }; + + const handleDescriptionSave = async (tag: TagWithCount) => { + const draft = descriptionDrafts[tag.id] ?? tag.autotag_description ?? ''; + if (draft === (tag.autotag_description ?? '')) return; + setErrorMsg(null); + try { + await setTagAutotagDescription(tag.id, draft); + setDescriptionDrafts(current => { + const next = { ...current }; + delete next[tag.id]; + return next; + }); + } catch (e) { + setErrorMsg(String(e)); + } + }; + return ( <>
@@ -159,25 +183,62 @@ function TagCategoriesTab() {

None yet.

) : (
- {targets.map(tag => ( + {targets.map(tag => { + const isExpanded = expandedTagId === tag.id; + const description = tag.autotag_description ?? ''; + const draft = descriptionDrafts[tag.id] ?? description; + + return (
-
- {tag.name} - - {(tag as TagWithCount).atom_count} atoms - +
+ +
- + {isExpanded && ( +
+ +