feat: add source_diversity_window setting (migration + model + DB + validation tests)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
master
oabrivard 3 months ago
parent b558619d10
commit a31915d3ce

@ -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

@ -0,0 +1 @@
ALTER TABLE settings ADD COLUMN source_diversity_window INTEGER NOT NULL DEFAULT 3;

@ -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<SettingsRow> 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?;

@ -13,6 +13,7 @@ pub struct UserSettings {
pub categories: Vec<String>,
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<String>,
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<UserSettings> 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<String>,
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());
}
}

@ -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(),

Loading…
Cancel
Save