diff --git a/CLAUDE.md b/CLAUDE.md index e3011f6..07eea90 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,7 +117,7 @@ cd frontend && npx tsc --noEmit - `GET /api/v1/admin/users` — user list - `PUT /api/v1/admin/users/:id/role` — role management -## Database (12 migrations) +## Database (13 migrations) Tables: `users`, `sessions`, `magic_link_tokens`, `user_settings`, `sources`, `syntheses`, `admin_providers`, `admin_rate_limits`, `user_api_keys`, `audit_log` ## Environment Variables diff --git a/backend/migrations/20260323000013_add_source_diversity_window.sql b/backend/migrations/20260323000013_add_source_diversity_window.sql new file mode 100644 index 0000000..18de241 --- /dev/null +++ b/backend/migrations/20260323000013_add_source_diversity_window.sql @@ -0,0 +1 @@ +ALTER TABLE settings ADD COLUMN source_diversity_window INTEGER NOT NULL DEFAULT 3; diff --git a/backend/src/db/settings.rs b/backend/src/db/settings.rs index 72137f4..496fa47 100644 --- a/backend/src/db/settings.rs +++ b/backend/src/db/settings.rs @@ -18,6 +18,7 @@ struct SettingsRow { categories: serde_json::Value, max_items_per_category: i32, max_articles_per_source: i32, + source_diversity_window: i32, search_agent_behavior: String, ai_provider: String, ai_model: String, @@ -42,6 +43,7 @@ impl TryFrom for UserSettings { categories, max_items_per_category: row.max_items_per_category, max_articles_per_source: row.max_articles_per_source, + source_diversity_window: row.source_diversity_window, search_agent_behavior: row.search_agent_behavior, ai_provider: row.ai_provider, ai_model: row.ai_model, @@ -68,10 +70,10 @@ pub async fn get_or_create_default( let row = sqlx::query_as::<_, SettingsRow>( r#" - INSERT INTO settings (user_id, theme, max_age_days, categories, max_items_per_category, search_agent_behavior, ai_provider, ai_model, ai_model_writing, rate_limit_max_requests, rate_limit_time_window_seconds, max_articles_per_source) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + INSERT INTO settings (user_id, theme, max_age_days, categories, max_items_per_category, search_agent_behavior, ai_provider, ai_model, ai_model_writing, rate_limit_max_requests, rate_limit_time_window_seconds, max_articles_per_source, source_diversity_window) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) ON CONFLICT (user_id) DO UPDATE SET user_id = settings.user_id - RETURNING user_id, theme, max_age_days, categories, max_items_per_category, search_agent_behavior, ai_provider, ai_model, ai_model_writing, rate_limit_max_requests, rate_limit_time_window_seconds, max_articles_per_source, updated_at + RETURNING user_id, theme, max_age_days, categories, max_items_per_category, search_agent_behavior, ai_provider, ai_model, ai_model_writing, rate_limit_max_requests, rate_limit_time_window_seconds, max_articles_per_source, source_diversity_window, updated_at "#, ) .bind(user_id) @@ -86,6 +88,7 @@ pub async fn get_or_create_default( .bind(defaults.rate_limit_max_requests) .bind(defaults.rate_limit_time_window_seconds) .bind(defaults.max_articles_per_source) + .bind(defaults.source_diversity_window) .fetch_one(pool) .await?; @@ -104,8 +107,8 @@ pub async fn upsert( let row = sqlx::query_as::<_, SettingsRow>( r#" - INSERT INTO settings (user_id, theme, max_age_days, categories, max_items_per_category, search_agent_behavior, ai_provider, ai_model, ai_model_writing, rate_limit_max_requests, rate_limit_time_window_seconds, max_articles_per_source) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + INSERT INTO settings (user_id, theme, max_age_days, categories, max_items_per_category, search_agent_behavior, ai_provider, ai_model, ai_model_writing, rate_limit_max_requests, rate_limit_time_window_seconds, max_articles_per_source, source_diversity_window) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) ON CONFLICT (user_id) DO UPDATE SET theme = EXCLUDED.theme, max_age_days = EXCLUDED.max_age_days, @@ -118,8 +121,9 @@ pub async fn upsert( rate_limit_max_requests = EXCLUDED.rate_limit_max_requests, rate_limit_time_window_seconds = EXCLUDED.rate_limit_time_window_seconds, max_articles_per_source = EXCLUDED.max_articles_per_source, + source_diversity_window = EXCLUDED.source_diversity_window, updated_at = now() - RETURNING user_id, theme, max_age_days, categories, max_items_per_category, search_agent_behavior, ai_provider, ai_model, ai_model_writing, rate_limit_max_requests, rate_limit_time_window_seconds, max_articles_per_source, updated_at + RETURNING user_id, theme, max_age_days, categories, max_items_per_category, search_agent_behavior, ai_provider, ai_model, ai_model_writing, rate_limit_max_requests, rate_limit_time_window_seconds, max_articles_per_source, source_diversity_window, updated_at "#, ) .bind(user_id) @@ -134,6 +138,7 @@ pub async fn upsert( .bind(req.rate_limit_max_requests) .bind(req.rate_limit_time_window_seconds) .bind(req.max_articles_per_source) + .bind(req.source_diversity_window) .fetch_one(pool) .await?; diff --git a/backend/src/models/settings.rs b/backend/src/models/settings.rs index 386d73d..e331d9a 100644 --- a/backend/src/models/settings.rs +++ b/backend/src/models/settings.rs @@ -13,6 +13,7 @@ pub struct UserSettings { pub categories: Vec, pub max_items_per_category: i32, pub max_articles_per_source: i32, + pub source_diversity_window: i32, pub search_agent_behavior: String, pub ai_provider: String, pub ai_model: String, @@ -30,6 +31,7 @@ pub struct SettingsResponse { pub categories: Vec, pub max_items_per_category: i32, pub max_articles_per_source: i32, + pub source_diversity_window: i32, pub search_agent_behavior: String, pub ai_provider: String, pub ai_model: String, @@ -46,6 +48,7 @@ impl From for SettingsResponse { categories: s.categories, max_items_per_category: s.max_items_per_category, max_articles_per_source: s.max_articles_per_source, + source_diversity_window: s.source_diversity_window, search_agent_behavior: s.search_agent_behavior, ai_provider: s.ai_provider, ai_model: s.ai_model, @@ -64,6 +67,7 @@ pub struct UpdateSettingsRequest { pub categories: Vec, pub max_items_per_category: i32, pub max_articles_per_source: i32, + pub source_diversity_window: i32, pub search_agent_behavior: String, pub ai_provider: String, pub ai_model: String, @@ -110,6 +114,9 @@ impl UpdateSettingsRequest { if !(1..=10).contains(&self.max_articles_per_source) { return Err("max_articles_per_source must be between 1 and 10".into()); } + if !(0..=10).contains(&self.source_diversity_window) { + return Err("source_diversity_window must be between 0 and 10".into()); + } if self.search_agent_behavior.len() > 2000 { return Err("search_agent_behavior must be at most 2000 characters".into()); } @@ -152,6 +159,7 @@ impl Default for UserSettings { ], max_items_per_category: 4, max_articles_per_source: 3, + source_diversity_window: 3, search_agent_behavior: String::new(), ai_provider: String::new(), ai_model: String::new(), @@ -175,6 +183,7 @@ mod tests { categories: vec!["Category 1".into(), "Category 2".into()], max_items_per_category: 4, max_articles_per_source: 3, + source_diversity_window: 3, search_agent_behavior: String::new(), ai_provider: String::new(), ai_model: String::new(), @@ -379,4 +388,32 @@ mod tests { let err = req.validate().unwrap_err(); assert!(err.contains("ai_model_writing")); } + + #[test] + fn test_source_diversity_window_zero_is_valid() { + let mut req = valid_request(); + req.source_diversity_window = 0; + assert!(req.validate().is_ok()); + } + + #[test] + fn test_source_diversity_window_ten_is_valid() { + let mut req = valid_request(); + req.source_diversity_window = 10; + assert!(req.validate().is_ok()); + } + + #[test] + fn test_source_diversity_window_below_range() { + let mut req = valid_request(); + req.source_diversity_window = -1; + assert!(req.validate().is_err()); + } + + #[test] + fn test_source_diversity_window_above_range() { + let mut req = valid_request(); + req.source_diversity_window = 11; + assert!(req.validate().is_err()); + } } diff --git a/backend/src/services/prompts.rs b/backend/src/services/prompts.rs index 4df9731..e72270a 100644 --- a/backend/src/services/prompts.rs +++ b/backend/src/services/prompts.rs @@ -145,6 +145,7 @@ mod tests { ], max_items_per_category: 4, max_articles_per_source: 3, + source_diversity_window: 3, search_agent_behavior: String::new(), ai_provider: String::new(), ai_model: String::new(),