diff --git a/CLAUDE.md b/CLAUDE.md index 2fbd1ad..2ec4680 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 (26 migrations) +## Database (27 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/20260326000027_add_max_links_per_source.sql b/backend/migrations/20260326000027_add_max_links_per_source.sql new file mode 100644 index 0000000..d54bf1d --- /dev/null +++ b/backend/migrations/20260326000027_add_max_links_per_source.sql @@ -0,0 +1 @@ +ALTER TABLE settings ADD COLUMN max_links_per_source INTEGER NOT NULL DEFAULT 8; diff --git a/backend/src/db/settings.rs b/backend/src/db/settings.rs index 2d84da9..5b44e3b 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, + max_links_per_source: i32, use_brave_search: bool, article_history_days: i32, batch_size: i32, @@ -47,6 +48,7 @@ impl TryFrom for UserSettings { categories, max_items_per_category: row.max_items_per_category, max_articles_per_source: row.max_articles_per_source, + max_links_per_source: row.max_links_per_source, use_brave_search: row.use_brave_search, article_history_days: row.article_history_days, batch_size: row.batch_size, @@ -78,10 +80,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_websearch, rate_limit_max_requests, rate_limit_time_window_seconds, max_articles_per_source, use_brave_search, article_history_days, batch_size, summary_length, source_extraction_window) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + INSERT INTO settings (user_id, theme, max_age_days, categories, max_items_per_category, search_agent_behavior, ai_provider, ai_model, ai_model_websearch, rate_limit_max_requests, rate_limit_time_window_seconds, max_articles_per_source, max_links_per_source, use_brave_search, article_history_days, batch_size, summary_length, source_extraction_window) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) 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_websearch, rate_limit_max_requests, rate_limit_time_window_seconds, max_articles_per_source, use_brave_search, article_history_days, batch_size, summary_length, source_extraction_window, updated_at + RETURNING user_id, theme, max_age_days, categories, max_items_per_category, search_agent_behavior, ai_provider, ai_model, ai_model_websearch, rate_limit_max_requests, rate_limit_time_window_seconds, max_articles_per_source, max_links_per_source, use_brave_search, article_history_days, batch_size, summary_length, source_extraction_window, updated_at "#, ) .bind(user_id) @@ -96,6 +98,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.max_links_per_source) .bind(defaults.use_brave_search) .bind(defaults.article_history_days) .bind(defaults.batch_size) @@ -119,8 +122,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_websearch, rate_limit_max_requests, rate_limit_time_window_seconds, max_articles_per_source, use_brave_search, article_history_days, batch_size, summary_length, source_extraction_window) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + INSERT INTO settings (user_id, theme, max_age_days, categories, max_items_per_category, search_agent_behavior, ai_provider, ai_model, ai_model_websearch, rate_limit_max_requests, rate_limit_time_window_seconds, max_articles_per_source, max_links_per_source, use_brave_search, article_history_days, batch_size, summary_length, source_extraction_window) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) ON CONFLICT (user_id) DO UPDATE SET theme = EXCLUDED.theme, max_age_days = EXCLUDED.max_age_days, @@ -133,13 +136,14 @@ 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, + max_links_per_source = EXCLUDED.max_links_per_source, use_brave_search = EXCLUDED.use_brave_search, article_history_days = EXCLUDED.article_history_days, batch_size = EXCLUDED.batch_size, summary_length = EXCLUDED.summary_length, source_extraction_window = EXCLUDED.source_extraction_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_websearch, rate_limit_max_requests, rate_limit_time_window_seconds, max_articles_per_source, use_brave_search, article_history_days, batch_size, summary_length, source_extraction_window, updated_at + RETURNING user_id, theme, max_age_days, categories, max_items_per_category, search_agent_behavior, ai_provider, ai_model, ai_model_websearch, rate_limit_max_requests, rate_limit_time_window_seconds, max_articles_per_source, max_links_per_source, use_brave_search, article_history_days, batch_size, summary_length, source_extraction_window, updated_at "#, ) .bind(user_id) @@ -154,6 +158,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.max_links_per_source) .bind(req.use_brave_search) .bind(req.article_history_days) .bind(req.batch_size) diff --git a/backend/src/models/settings.rs b/backend/src/models/settings.rs index 7886d6e..e29ce14 100644 --- a/backend/src/models/settings.rs +++ b/backend/src/models/settings.rs @@ -14,6 +14,7 @@ pub struct UserSettings { pub categories: Vec, pub max_items_per_category: i32, pub max_articles_per_source: i32, + pub max_links_per_source: i32, pub use_brave_search: bool, pub article_history_days: i32, @@ -38,6 +39,7 @@ pub struct UpdateSettingsRequest { pub categories: Vec, pub max_items_per_category: i32, pub max_articles_per_source: i32, + pub max_links_per_source: i32, pub use_brave_search: bool, pub article_history_days: i32, @@ -90,6 +92,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 !(1..=30).contains(&self.max_links_per_source) { + return Err("max_links_per_source must be between 1 and 30".into()); + } if !(0..=365).contains(&self.article_history_days) { return Err("article_history_days must be between 0 and 365".into()); } @@ -144,6 +149,7 @@ impl Default for UserSettings { ], max_items_per_category: 4, max_articles_per_source: 3, + max_links_per_source: 8, use_brave_search: false, article_history_days: 90, @@ -173,6 +179,7 @@ mod tests { categories: vec!["Category 1".into(), "Category 2".into()], max_items_per_category: 4, max_articles_per_source: 3, + max_links_per_source: 8, use_brave_search: false, article_history_days: 90, diff --git a/backend/src/services/prompts.rs b/backend/src/services/prompts.rs index 5e8a690..a3e3ea8 100644 --- a/backend/src/services/prompts.rs +++ b/backend/src/services/prompts.rs @@ -186,6 +186,7 @@ mod tests { ], max_items_per_category: 4, max_articles_per_source: 3, + max_links_per_source: 8, use_brave_search: false, article_history_days: 90, batch_size: 5, diff --git a/backend/src/services/synthesis.rs b/backend/src/services/synthesis.rs index e339964..b09aa65 100644 --- a/backend/src/services/synthesis.rs +++ b/backend/src/services/synthesis.rs @@ -291,7 +291,7 @@ pub async fn run_generation_inner( let last_source = db::article_history::get_last_source_url(&state.pool, user_id).await.unwrap_or(None); let rotated_sources = rotate_sources(sources.clone(), last_source.as_deref()); - let max_links = 15usize; + let max_links = settings.max_links_per_source.max(1) as usize; let window_size = settings.source_extraction_window.max(1) as usize; // Process sources in waves of `window_size` diff --git a/backend/tests/api_settings_test.rs b/backend/tests/api_settings_test.rs index b89b235..33f2d92 100644 --- a/backend/tests/api_settings_test.rs +++ b/backend/tests/api_settings_test.rs @@ -47,6 +47,7 @@ async fn put_settings_without_auth_returns_401() { "categories": ["Cat"], "max_items_per_category": 4, "max_articles_per_source": 3, + "max_links_per_source": 8, "use_brave_search": false, "article_history_days": 90, @@ -134,6 +135,7 @@ async fn put_settings_with_valid_data_returns_200() { "categories": ["Vulnerabilites", "Patch Tuesday", "Threat Intel"], "max_items_per_category": 6, "max_articles_per_source": 3, + "max_links_per_source": 8, "use_brave_search": false, "article_history_days": 90, @@ -192,6 +194,7 @@ async fn put_then_get_returns_updated_data() { "categories": ["Macro", "Finance"], "max_items_per_category": 10, "max_articles_per_source": 3, + "max_links_per_source": 8, "use_brave_search": false, "article_history_days": 90, @@ -244,6 +247,7 @@ async fn put_settings_empty_theme_returns_422() { "categories": ["Cat"], "max_items_per_category": 4, "max_articles_per_source": 3, + "max_links_per_source": 8, "use_brave_search": false, "article_history_days": 90, @@ -288,6 +292,7 @@ async fn put_settings_too_many_categories_returns_422() { "categories": categories, "max_items_per_category": 4, "max_articles_per_source": 3, + "max_links_per_source": 8, "use_brave_search": false, "article_history_days": 90, @@ -331,6 +336,7 @@ async fn put_settings_empty_categories_returns_422() { "categories": [], "max_items_per_category": 4, "max_articles_per_source": 3, + "max_links_per_source": 8, "use_brave_search": false, "article_history_days": 90, @@ -375,6 +381,7 @@ async fn put_settings_max_age_days_out_of_range_returns_422() { "categories": ["Cat"], "max_items_per_category": 4, "max_articles_per_source": 3, + "max_links_per_source": 8, "use_brave_search": false, "article_history_days": 90, @@ -404,6 +411,7 @@ async fn put_settings_max_age_days_out_of_range_returns_422() { "categories": ["Cat"], "max_items_per_category": 4, "max_articles_per_source": 3, + "max_links_per_source": 8, "use_brave_search": false, "article_history_days": 90, @@ -445,6 +453,7 @@ async fn put_settings_max_items_out_of_range_returns_422() { "categories": ["Cat"], "max_items_per_category": 51, "max_articles_per_source": 3, + "max_links_per_source": 8, "use_brave_search": false, "article_history_days": 90, @@ -494,6 +503,7 @@ async fn settings_are_per_user_isolated() { "categories": ["A-Category"], "max_items_per_category": 2, "max_articles_per_source": 3, + "max_links_per_source": 8, "use_brave_search": false, "article_history_days": 90, @@ -519,6 +529,7 @@ async fn settings_are_per_user_isolated() { "categories": ["B-Category-1", "B-Category-2"], "max_items_per_category": 8, "max_articles_per_source": 3, + "max_links_per_source": 8, "use_brave_search": false, "article_history_days": 90, @@ -575,6 +586,7 @@ async fn put_settings_boundary_values_succeed() { "categories": ["C"], "max_items_per_category": 1, "max_articles_per_source": 3, + "max_links_per_source": 8, "use_brave_search": false, "article_history_days": 90, @@ -601,6 +613,7 @@ async fn put_settings_boundary_values_succeed() { "categories": categories_max, "max_items_per_category": 50, "max_articles_per_source": 3, + "max_links_per_source": 8, "use_brave_search": false, "article_history_days": 90, diff --git a/backend/tests/api_syntheses_test.rs b/backend/tests/api_syntheses_test.rs index 0fa58fb..b53ea77 100644 --- a/backend/tests/api_syntheses_test.rs +++ b/backend/tests/api_syntheses_test.rs @@ -631,6 +631,7 @@ async fn generate_pipeline_resolves_model_from_admin_config() { "categories": ["Test Category"], "max_items_per_category": 4, "max_articles_per_source": 3, + "max_links_per_source": 8, "use_brave_search": false, "article_history_days": 90, diff --git a/backend/tests/pipeline_test.rs b/backend/tests/pipeline_test.rs index 0548dc7..a295c6a 100644 --- a/backend/tests/pipeline_test.rs +++ b/backend/tests/pipeline_test.rs @@ -56,6 +56,7 @@ async fn setup_user_with_settings( "categories": categories_json, "max_items_per_category": max_items, "max_articles_per_source": 10, + "max_links_per_source": 8, "use_brave_search": false, "article_history_days": 90, "batch_size": 5, diff --git a/e2e/tests/generation-live.spec.ts b/e2e/tests/generation-live.spec.ts index fd79fa7..9b1dc3b 100644 --- a/e2e/tests/generation-live.spec.ts +++ b/e2e/tests/generation-live.spec.ts @@ -137,6 +137,7 @@ test.describe('Live generation with OpenAI', () => { categories: ['AI News'], max_items_per_category: 4, max_articles_per_source: 3, + max_links_per_source: 8, search_agent_behavior: '', ai_provider: 'openai', ai_model: 'gpt-4o-mini', diff --git a/frontend/src/i18n/fr.ts b/frontend/src/i18n/fr.ts index 5ce1a28..9cc27e8 100644 --- a/frontend/src/i18n/fr.ts +++ b/frontend/src/i18n/fr.ts @@ -123,6 +123,8 @@ const fr = { 'settings.maxAgeDays': 'Anciennete maximum (jours)', 'settings.maxItems': 'Actualites max par categorie', 'settings.maxArticlesPerSource': 'Articles max par source', + 'settings.maxLinksPerSource': 'Liens max par source', + 'settings.maxLinksPerSourceHelp': 'Nombre maximum de liens extraits de chaque source. Les premiers liens sont generalement les plus recents.', 'settings.searchBehavior': "Comportement de l'agent de recherche", 'settings.searchBehaviorHelp': "Personnalisez les instructions donnees a l'IA concernant sa methode de recherche.", diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 8eb57de..468846f 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -431,6 +431,29 @@ const Settings: Component = () => { +
+ +

{t('settings.maxLinksPerSourceHelp')}

+
+ + setSettings((prev) => ({ + ...prev, + max_links_per_source: parseInt(e.currentTarget.value) || 8, + })) + } + /> +
+
+ {/* Summary length slider */} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 2895654..60d0459 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -44,6 +44,7 @@ export interface UserSettings { max_age_days: number; max_items_per_category: number; max_articles_per_source: number; + max_links_per_source: number; use_brave_search: boolean; article_history_days: number; batch_size: number; @@ -63,6 +64,7 @@ export const DEFAULT_SETTINGS: UserSettings = { max_age_days: 7, max_items_per_category: 4, max_articles_per_source: 3, + max_links_per_source: 8, use_brave_search: false, article_history_days: 90, batch_size: 5,