diff --git a/backend/src/db/settings.rs b/backend/src/db/settings.rs index 5b44e3b..aea0ec5 100644 --- a/backend/src/db/settings.rs +++ b/backend/src/db/settings.rs @@ -13,16 +13,11 @@ use crate::models::settings::{UpdateSettingsRequest, UserSettings}; #[derive(Debug, sqlx::FromRow)] struct SettingsRow { user_id: Uuid, - theme: String, - max_age_days: i32, - 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, - summary_length: i32, source_extraction_window: i32, search_agent_behavior: String, ai_provider: String, @@ -37,22 +32,13 @@ impl TryFrom for UserSettings { type Error = AppError; fn try_from(row: SettingsRow) -> Result { - let categories: Vec = serde_json::from_value(row.categories).map_err(|e| { - AppError::Internal(anyhow::anyhow!("Failed to parse categories JSON: {}", e)) - })?; - Ok(Self { user_id: row.user_id, - theme: row.theme, - max_age_days: row.max_age_days, - 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, - summary_length: row.summary_length, source_extraction_window: row.source_extraction_window, search_agent_behavior: row.search_agent_behavior, ai_provider: row.ai_provider, @@ -74,23 +60,16 @@ pub async fn get_or_create_default( user_id: Uuid, ) -> Result { let defaults = UserSettings::default(); - let categories_json = serde_json::to_value(&defaults.categories).map_err(|e| { - AppError::Internal(anyhow::anyhow!("Failed to serialize default categories: {}", e)) - })?; 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, 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) + INSERT INTO settings (user_id, 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, source_extraction_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_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 + RETURNING user_id, 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, source_extraction_window, updated_at "#, ) .bind(user_id) - .bind(&defaults.theme) - .bind(defaults.max_age_days) - .bind(&categories_json) - .bind(defaults.max_items_per_category) .bind(&defaults.search_agent_behavior) .bind(&defaults.ai_provider) .bind(&defaults.ai_model) @@ -102,7 +81,6 @@ pub async fn get_or_create_default( .bind(defaults.use_brave_search) .bind(defaults.article_history_days) .bind(defaults.batch_size) - .bind(defaults.summary_length) .bind(defaults.source_extraction_window) .fetch_one(pool) .await?; @@ -116,19 +94,11 @@ pub async fn upsert( user_id: Uuid, req: &UpdateSettingsRequest, ) -> Result { - let categories_json = serde_json::to_value(&req.categories).map_err(|e| { - AppError::Internal(anyhow::anyhow!("Failed to serialize categories: {}", e)) - })?; - 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, 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) + INSERT INTO settings (user_id, 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, source_extraction_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, - categories = EXCLUDED.categories, - max_items_per_category = EXCLUDED.max_items_per_category, search_agent_behavior = EXCLUDED.search_agent_behavior, ai_provider = EXCLUDED.ai_provider, ai_model = EXCLUDED.ai_model, @@ -140,17 +110,12 @@ pub async fn upsert( 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, max_links_per_source, use_brave_search, article_history_days, batch_size, summary_length, source_extraction_window, updated_at + RETURNING user_id, 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, source_extraction_window, updated_at "#, ) .bind(user_id) - .bind(&req.theme) - .bind(req.max_age_days) - .bind(&categories_json) - .bind(req.max_items_per_category) .bind(&req.search_agent_behavior) .bind(&req.ai_provider) .bind(&req.ai_model) @@ -162,7 +127,6 @@ pub async fn upsert( .bind(req.use_brave_search) .bind(req.article_history_days) .bind(req.batch_size) - .bind(req.summary_length) .bind(req.source_extraction_window) .fetch_one(pool) .await?; diff --git a/backend/src/db/sources.rs b/backend/src/db/sources.rs index c901c8c..ae234a9 100644 --- a/backend/src/db/sources.rs +++ b/backend/src/db/sources.rs @@ -10,18 +10,35 @@ use crate::errors::AppError; use crate::models::source::Source; /// List all sources for a given user, ordered by creation date (newest first). -pub async fn list_for_user(pool: &PgPool, user_id: Uuid) -> Result, AppError> { - let sources = sqlx::query_as::<_, Source>( - r#" - SELECT id, user_id, title, url, created_at - FROM sources - WHERE user_id = $1 - ORDER BY created_at DESC - "#, - ) - .bind(user_id) - .fetch_all(pool) - .await?; +/// +/// When `theme_id` is `Some`, filters sources to those belonging to the given theme. +pub async fn list_for_user(pool: &PgPool, user_id: Uuid, theme_id: Option) -> Result, AppError> { + let sources = if let Some(tid) = theme_id { + sqlx::query_as::<_, Source>( + r#" + SELECT id, user_id, title, url, theme_id, created_at + FROM sources + WHERE user_id = $1 AND theme_id = $2 + ORDER BY created_at DESC + "#, + ) + .bind(user_id) + .bind(tid) + .fetch_all(pool) + .await? + } else { + sqlx::query_as::<_, Source>( + r#" + SELECT id, user_id, title, url, theme_id, created_at + FROM sources + WHERE user_id = $1 + ORDER BY created_at DESC + "#, + ) + .bind(user_id) + .fetch_all(pool) + .await? + }; Ok(sources) } @@ -35,17 +52,19 @@ pub async fn create( user_id: Uuid, title: &str, url: &str, + theme_id: Option, ) -> Result { let source = sqlx::query_as::<_, Source>( r#" - INSERT INTO sources (user_id, title, url) - VALUES ($1, $2, $3) - RETURNING id, user_id, title, url, created_at + INSERT INTO sources (user_id, title, url, theme_id) + VALUES ($1, $2, $3, $4) + RETURNING id, user_id, title, url, theme_id, created_at "#, ) .bind(user_id) .bind(title) .bind(url) + .bind(theme_id) .fetch_one(pool) .await?; @@ -79,21 +98,23 @@ pub async fn bulk_create( pool: &PgPool, user_id: Uuid, sources: &[(String, String)], + theme_id: Option, ) -> Result, AppError> { let mut created = Vec::new(); for (title, url) in sources { let result = sqlx::query_as::<_, Source>( r#" - INSERT INTO sources (user_id, title, url) - VALUES ($1, $2, $3) + INSERT INTO sources (user_id, title, url, theme_id) + VALUES ($1, $2, $3, $4) ON CONFLICT (user_id, url) DO NOTHING - RETURNING id, user_id, title, url, created_at + RETURNING id, user_id, title, url, theme_id, created_at "#, ) .bind(user_id) .bind(title.as_str()) .bind(url.as_str()) + .bind(theme_id) .fetch_optional(pool) .await?; diff --git a/backend/src/db/syntheses.rs b/backend/src/db/syntheses.rs index b5b4c26..a5cc178 100644 --- a/backend/src/db/syntheses.rs +++ b/backend/src/db/syntheses.rs @@ -9,21 +9,38 @@ use uuid::Uuid; use crate::errors::AppError; use crate::models::synthesis::Synthesis; +/// Row type for list queries that includes the theme name from a JOIN. +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct SynthesisWithThemeName { + pub id: Uuid, + pub user_id: Uuid, + pub week: String, + pub sections: serde_json::Value, + pub status: String, + pub created_at: chrono::DateTime, + pub job_id: Option, + pub theme_id: Option, + pub theme_name: Option, +} + /// List syntheses for a user, ordered by creation date (newest first). /// /// Supports pagination via `limit` and `offset`. +/// Includes `theme_name` via LEFT JOIN with the themes table. pub async fn list_for_user( pool: &PgPool, user_id: Uuid, limit: i64, offset: i64, -) -> Result, AppError> { - let rows = sqlx::query_as::<_, Synthesis>( +) -> Result, AppError> { + let rows = sqlx::query_as::<_, SynthesisWithThemeName>( r#" - SELECT id, user_id, week, sections, status, created_at, job_id - FROM syntheses - WHERE user_id = $1 - ORDER BY created_at DESC + SELECT s.id, s.user_id, s.week, s.sections, s.status, s.created_at, s.job_id, s.theme_id, + t.name AS theme_name + FROM syntheses s + LEFT JOIN themes t ON s.theme_id = t.id + WHERE s.user_id = $1 + ORDER BY s.created_at DESC LIMIT $2 OFFSET $3 "#, ) @@ -42,7 +59,7 @@ pub async fn list_for_user( pub async fn get_by_id(pool: &PgPool, id: Uuid) -> Result, AppError> { let row = sqlx::query_as::<_, Synthesis>( r#" - SELECT id, user_id, week, sections, status, created_at, job_id + SELECT id, user_id, week, sections, status, created_at, job_id, theme_id FROM syntheses WHERE id = $1 "#, @@ -64,7 +81,7 @@ pub async fn get_by_id_for_user( ) -> Result, AppError> { let row = sqlx::query_as::<_, Synthesis>( r#" - SELECT id, user_id, week, sections, status, created_at, job_id + SELECT id, user_id, week, sections, status, created_at, job_id, theme_id FROM syntheses WHERE id = $1 AND user_id = $2 "#, @@ -87,18 +104,20 @@ pub async fn create( week: &str, sections_json: &serde_json::Value, job_id: Uuid, + theme_id: Option, ) -> Result { let row = sqlx::query_as::<_, Synthesis>( r#" - INSERT INTO syntheses (user_id, week, sections, status, job_id) - VALUES ($1, $2, $3, 'completed', $4) - RETURNING id, user_id, week, sections, status, created_at, job_id + INSERT INTO syntheses (user_id, week, sections, status, job_id, theme_id) + VALUES ($1, $2, $3, 'completed', $4, $5) + RETURNING id, user_id, week, sections, status, created_at, job_id, theme_id "#, ) .bind(user_id) .bind(week) .bind(sections_json) .bind(job_id) + .bind(theme_id) .fetch_one(pool) .await?; diff --git a/backend/src/handlers/generation.rs b/backend/src/handlers/generation.rs index cc28744..efd31d5 100644 --- a/backend/src/handlers/generation.rs +++ b/backend/src/handlers/generation.rs @@ -17,6 +17,8 @@ use tokio_stream::wrappers::WatchStream; use tokio_stream::StreamExt; use uuid::Uuid; +use serde::Deserialize; + use crate::app_state::AppState; use crate::errors::AppError; use crate::middleware::auth::AuthUser; @@ -29,6 +31,12 @@ pub struct GenerateResponse { pub message: String, } +/// Request body for `POST /api/v1/syntheses/generate`. +#[derive(Debug, Deserialize)] +pub struct GenerateRequest { + pub theme_id: Uuid, +} + /// `POST /api/v1/syntheses/generate` /// /// Triggers an asynchronous synthesis generation. Returns immediately @@ -39,6 +47,7 @@ pub struct GenerateResponse { pub async fn trigger_generate( auth_user: AuthUser, State(state): State, + Json(body): Json, ) -> Result { // Check if user already has an active job if let Some(existing_job_id) = state.job_store.has_active_job(auth_user.id) { @@ -71,12 +80,13 @@ pub async fn trigger_generate( // Spawn the generation pipeline as a background task let state_clone = state.clone(); let user_id = auth_user.id; + let theme_id = body.theme_id; let tx_for_panic = Arc::clone(&tx); let state_for_panic = state.clone(); let join_handle = tokio::spawn(async move { let timeout_duration = std::time::Duration::from_secs(900); - match tokio::time::timeout(timeout_duration, synthesis::run_generation(job_id, state_clone.clone(), user_id, tx.clone(), None)).await { + match tokio::time::timeout(timeout_duration, synthesis::run_generation(job_id, state_clone.clone(), user_id, theme_id, tx.clone(), None)).await { Ok(()) => {} Err(_) => { tracing::error!(job_id = %job_id, user_id = %user_id, "Generation timed out after 15 minutes"); diff --git a/backend/src/handlers/sources.rs b/backend/src/handlers/sources.rs index 1d0f147..f0d85ea 100644 --- a/backend/src/handlers/sources.rs +++ b/backend/src/handlers/sources.rs @@ -7,10 +7,11 @@ //! - `POST /api/v1/sources/import-csv` — import from CSV file upload //! - `GET /api/v1/sources/export-csv` — download sources as CSV -use axum::extract::{Multipart, Path, State}; +use axum::extract::{Multipart, Path, Query, State}; use axum::http::StatusCode; use axum::response::IntoResponse; use axum::Json; +use serde::Deserialize; use uuid::Uuid; use crate::app_state::AppState; @@ -25,15 +26,23 @@ use crate::services::csv as csv_service; /// Maximum number of sources a user can have. const MAX_SOURCES_PER_USER: i64 = 100; +/// Query parameters for `GET /api/v1/sources`. +#[derive(Debug, Deserialize)] +pub struct SourceListQuery { + pub theme_id: Option, +} + /// `GET /api/v1/sources` /// /// Returns all sources belonging to the authenticated user, /// ordered by creation date (newest first). +/// Optionally filters by `theme_id` query parameter. pub async fn list( auth_user: AuthUser, State(state): State, + Query(params): Query, ) -> Result { - let sources = db::sources::list_for_user(&state.pool, auth_user.id).await?; + let sources = db::sources::list_for_user(&state.pool, auth_user.id, params.theme_id).await?; let response: Vec = sources.into_iter().map(SourceResponse::from).collect(); Ok(Json(response)) } @@ -59,7 +68,7 @@ pub async fn create( ))); } - let source = db::sources::create(&state.pool, auth_user.id, &body.title, &body.url).await?; + let source = db::sources::create(&state.pool, auth_user.id, &body.title, &body.url, body.theme_id).await?; tracing::info!(user_id = %auth_user.id, source_id = %source.id, "Source created"); Ok((StatusCode::CREATED, Json(SourceResponse::from(source)))) @@ -141,7 +150,7 @@ async fn do_bulk_import( )); } - let created = db::sources::bulk_create(pool, user_id, valid_sources).await?; + let created = db::sources::bulk_create(pool, user_id, valid_sources, None).await?; let imported = created.len(); let skipped = valid_sources.len() - imported; @@ -236,7 +245,7 @@ pub async fn export_csv( auth_user: AuthUser, State(state): State, ) -> Result { - let sources = db::sources::list_for_user(&state.pool, auth_user.id).await?; + let sources = db::sources::list_for_user(&state.pool, auth_user.id, None).await?; let csv_content = csv_service::generate_csv(&sources); Ok(( diff --git a/backend/src/handlers/syntheses.rs b/backend/src/handlers/syntheses.rs index 2c11344..9f2bb73 100644 --- a/backend/src/handlers/syntheses.rs +++ b/backend/src/handlers/syntheses.rs @@ -52,13 +52,28 @@ pub async fn list( let limit = params.limit.unwrap_or(20).clamp(1, 100); let offset = params.offset.unwrap_or(0).max(0); - let syntheses = + let rows = db::syntheses::list_for_user(&state.pool, auth_user.id, limit, offset).await?; - let items: Vec = syntheses + let items: Vec = rows .into_iter() - .map(SynthesisListItem::try_from) - .collect::, _>>()?; + .map(|row| { + let theme_name = row.theme_name.clone(); + let synthesis = crate::models::synthesis::Synthesis { + id: row.id, + user_id: row.user_id, + week: row.week, + sections: row.sections, + status: row.status, + created_at: row.created_at, + job_id: row.job_id, + theme_id: row.theme_id, + }; + let mut item = SynthesisListItem::try_from(synthesis)?; + item.theme_name = theme_name; + Ok(item) + }) + .collect::, AppError>>()?; Ok(Json(ListResponse { items })) } diff --git a/backend/src/models/settings.rs b/backend/src/models/settings.rs index e29ce14..333987d 100644 --- a/backend/src/models/settings.rs +++ b/backend/src/models/settings.rs @@ -9,17 +9,12 @@ use uuid::Uuid; pub struct UserSettings { #[serde(skip_serializing)] pub user_id: Uuid, - pub theme: String, - pub max_age_days: i32, - 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, pub batch_size: i32, - pub summary_length: i32, pub source_extraction_window: i32, pub search_agent_behavior: String, pub ai_provider: String, @@ -34,17 +29,12 @@ pub struct UserSettings { /// Request body for `PUT /api/v1/settings`. #[derive(Debug, Deserialize)] pub struct UpdateSettingsRequest { - pub theme: String, - pub max_age_days: i32, - 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, pub batch_size: i32, - pub summary_length: i32, pub source_extraction_window: i32, pub search_agent_behavior: String, pub ai_provider: String, @@ -60,35 +50,6 @@ impl UpdateSettingsRequest { /// Returns `Ok(())` if all fields are within acceptable bounds, /// or `Err(message)` describing the first validation failure. pub fn validate(&self) -> Result<(), String> { - if self.theme.trim().is_empty() { - return Err("Theme cannot be empty".into()); - } - if self.theme.len() > 200 { - return Err("Theme must be at most 200 characters".into()); - } - if !(1..=365).contains(&self.max_age_days) { - return Err("max_age_days must be between 1 and 365".into()); - } - if self.categories.is_empty() { - return Err("Categories cannot be empty".into()); - } - if self.categories.len() > 20 { - return Err("At most 20 categories are allowed".into()); - } - for (i, cat) in self.categories.iter().enumerate() { - if cat.trim().is_empty() { - return Err(format!("Category at index {} cannot be empty", i)); - } - if cat.len() > 200 { - return Err(format!( - "Category at index {} must be at most 200 characters", - i - )); - } - } - if !(1..=50).contains(&self.max_items_per_category) { - return Err("max_items_per_category must be between 1 and 50".into()); - } if !(1..=10).contains(&self.max_articles_per_source) { return Err("max_articles_per_source must be between 1 and 10".into()); } @@ -101,9 +62,6 @@ impl UpdateSettingsRequest { if !(1..=20).contains(&self.batch_size) { return Err("batch_size must be between 1 and 20".into()); } - if !(1..=3).contains(&self.summary_length) { - return Err("summary_length must be between 1 and 3".into()); - } if !(1..=10).contains(&self.source_extraction_window) { return Err("source_extraction_window must be between 1 and 10".into()); } @@ -138,23 +96,12 @@ impl Default for UserSettings { fn default() -> Self { Self { user_id: Uuid::nil(), - theme: "Intelligence Artificielle".to_string(), - max_age_days: 7, - categories: vec![ - "Annonces majeures".to_string(), - "Recherche et innovation".to_string(), - "Industrie et entreprises".to_string(), - "Secteur public".to_string(), - "Opinions et analyses".to_string(), - ], - 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, - summary_length: 3, source_extraction_window: 3, search_agent_behavior: String::new(), ai_provider: String::new(), @@ -174,17 +121,12 @@ mod tests { /// Helper to create a valid request with all new fields populated. fn valid_request() -> UpdateSettingsRequest { UpdateSettingsRequest { - theme: "Intelligence Artificielle".into(), - max_age_days: 7, - 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, batch_size: 5, - summary_length: 3, source_extraction_window: 3, search_agent_behavior: String::new(), ai_provider: String::new(), @@ -201,93 +143,6 @@ mod tests { assert!(req.validate().is_ok()); } - #[test] - fn test_empty_theme() { - let req = UpdateSettingsRequest { - theme: " ".into(), - ..valid_request() - }; - let err = req.validate().unwrap_err(); - assert!(err.contains("Theme")); - } - - #[test] - fn test_theme_too_long() { - let req = UpdateSettingsRequest { - theme: "a".repeat(201), - ..valid_request() - }; - assert!(req.validate().is_err()); - } - - #[test] - fn test_max_age_days_below_range() { - let req = UpdateSettingsRequest { - max_age_days: 0, - ..valid_request() - }; - let err = req.validate().unwrap_err(); - assert!(err.contains("max_age_days")); - } - - #[test] - fn test_max_age_days_above_range() { - let req = UpdateSettingsRequest { - max_age_days: 366, - ..valid_request() - }; - assert!(req.validate().is_err()); - } - - #[test] - fn test_empty_categories() { - let req = UpdateSettingsRequest { - categories: vec![], - ..valid_request() - }; - let err = req.validate().unwrap_err(); - assert!(err.contains("Categories")); - } - - #[test] - fn test_too_many_categories() { - let cats: Vec = (0..21).map(|i| format!("Cat {}", i)).collect(); - let req = UpdateSettingsRequest { - categories: cats, - ..valid_request() - }; - let err = req.validate().unwrap_err(); - assert!(err.contains("20")); - } - - #[test] - fn test_empty_category_item() { - let req = UpdateSettingsRequest { - categories: vec!["Good".into(), " ".into()], - ..valid_request() - }; - let err = req.validate().unwrap_err(); - assert!(err.contains("index 1")); - } - - #[test] - fn test_max_items_below_range() { - let req = UpdateSettingsRequest { - max_items_per_category: 0, - ..valid_request() - }; - assert!(req.validate().is_err()); - } - - #[test] - fn test_max_items_above_range() { - let req = UpdateSettingsRequest { - max_items_per_category: 51, - ..valid_request() - }; - assert!(req.validate().is_err()); - } - #[test] fn test_search_agent_behavior_too_long() { let req = UpdateSettingsRequest { @@ -300,20 +155,12 @@ mod tests { #[test] fn test_boundary_values_valid() { let req = UpdateSettingsRequest { - theme: "A".into(), - max_age_days: 1, - categories: vec!["Cat".into()], - max_items_per_category: 1, search_agent_behavior: String::new(), ..valid_request() }; assert!(req.validate().is_ok()); let req = UpdateSettingsRequest { - theme: "A".into(), - max_age_days: 365, - categories: (0..20).map(|i| format!("Cat {}", i)).collect(), - max_items_per_category: 50, search_agent_behavior: "a".repeat(2000), ..valid_request() }; diff --git a/backend/src/models/source.rs b/backend/src/models/source.rs index cc1cacf..9ff2e78 100644 --- a/backend/src/models/source.rs +++ b/backend/src/models/source.rs @@ -14,6 +14,7 @@ pub struct Source { pub user_id: Uuid, pub title: String, pub url: String, + pub theme_id: Option, pub created_at: DateTime, } @@ -23,6 +24,7 @@ pub struct SourceResponse { pub id: Uuid, pub title: String, pub url: String, + pub theme_id: Option, pub created_at: DateTime, } @@ -32,6 +34,7 @@ impl From for SourceResponse { id: s.id, title: s.title, url: s.url, + theme_id: s.theme_id, created_at: s.created_at, } } @@ -42,6 +45,8 @@ impl From for SourceResponse { pub struct CreateSourceRequest { pub title: String, pub url: String, + #[serde(default)] + pub theme_id: Option, } impl CreateSourceRequest { @@ -108,6 +113,7 @@ mod tests { let req = CreateSourceRequest { title: "My Blog".into(), url: "https://example.com".into(), + theme_id: None, }; assert!(req.validate().is_ok()); } @@ -117,6 +123,7 @@ mod tests { let req = CreateSourceRequest { title: " ".into(), url: "https://example.com".into(), + theme_id: None, }; let err = req.validate().unwrap_err(); assert!(err.contains("Title")); @@ -127,6 +134,7 @@ mod tests { let req = CreateSourceRequest { title: "a".repeat(201), url: "https://example.com".into(), + theme_id: None, }; let err = req.validate().unwrap_err(); assert!(err.contains("200")); @@ -137,6 +145,7 @@ mod tests { let req = CreateSourceRequest { title: "Blog".into(), url: "".into(), + theme_id: None, }; let err = req.validate().unwrap_err(); assert!(err.contains("URL")); @@ -148,6 +157,7 @@ mod tests { let req = CreateSourceRequest { title: "Blog".into(), url: long_url, + theme_id: None, }; let err = req.validate().unwrap_err(); assert!(err.contains("1000")); @@ -158,6 +168,7 @@ mod tests { let req = CreateSourceRequest { title: "Blog".into(), url: "ftp://example.com".into(), + theme_id: None, }; let err = req.validate().unwrap_err(); assert!(err.contains("http")); @@ -168,6 +179,7 @@ mod tests { let req = CreateSourceRequest { title: "Blog".into(), url: "javascript:alert(1)".into(), + theme_id: None, }; let err = req.validate().unwrap_err(); assert!(err.contains("http")); @@ -178,6 +190,7 @@ mod tests { let req = CreateSourceRequest { title: "Blog".into(), url: "example.com".into(), + theme_id: None, }; let err = req.validate().unwrap_err(); assert!(err.contains("http")); @@ -188,6 +201,7 @@ mod tests { let req = CreateSourceRequest { title: "Blog".into(), url: "http://example.com".into(), + theme_id: None, }; assert!(req.validate().is_ok()); } @@ -197,6 +211,7 @@ mod tests { let req = CreateSourceRequest { title: "Blog".into(), url: "https://example.com/path?query=1".into(), + theme_id: None, }; assert!(req.validate().is_ok()); } @@ -206,6 +221,7 @@ mod tests { let req = CreateSourceRequest { title: "a".repeat(200), url: "https://example.com".into(), + theme_id: None, }; assert!(req.validate().is_ok()); } @@ -217,6 +233,7 @@ mod tests { let req = CreateSourceRequest { title: "Blog".into(), url, + theme_id: None, }; assert!(req.validate().is_ok()); } diff --git a/backend/src/models/synthesis.rs b/backend/src/models/synthesis.rs index 4ce58d5..caa2907 100644 --- a/backend/src/models/synthesis.rs +++ b/backend/src/models/synthesis.rs @@ -35,6 +35,7 @@ pub struct Synthesis { pub status: String, pub created_at: DateTime, pub job_id: Option, + pub theme_id: Option, } /// Response shape for `GET /api/v1/syntheses/:id`. @@ -86,6 +87,8 @@ pub struct SynthesisListItem { pub first_section_item_count: usize, pub sections_summary: Vec, pub job_id: Option, + pub theme_id: Option, + pub theme_name: Option, } /// Summary of a section for the synthesis list view. @@ -123,6 +126,8 @@ impl TryFrom for SynthesisListItem { first_section_item_count, sections_summary, job_id: s.job_id, + theme_id: s.theme_id, + theme_name: None, }) } } @@ -282,6 +287,7 @@ mod tests { status: "completed".into(), created_at: Utc::now(), job_id: None, + theme_id: None, }; let list_item = SynthesisListItem::try_from(synthesis).unwrap(); @@ -299,6 +305,7 @@ mod tests { status: "completed".into(), created_at: Utc::now(), job_id: None, + theme_id: None, }; let list_item = SynthesisListItem::try_from(synthesis).unwrap(); @@ -325,6 +332,7 @@ mod tests { status: "completed".into(), created_at: Utc::now(), job_id: None, + theme_id: None, }; let response = SynthesisResponse::try_from(synthesis).unwrap(); @@ -343,6 +351,7 @@ mod tests { status: "completed".into(), created_at: Utc::now(), job_id: None, + theme_id: None, }; assert!(SynthesisResponse::try_from(synthesis).is_err()); @@ -358,6 +367,7 @@ mod tests { status: "completed".into(), created_at: Utc::now(), job_id: None, + theme_id: None, }; let response = SynthesisResponse::try_from(synthesis).unwrap(); diff --git a/backend/src/services/csv.rs b/backend/src/services/csv.rs index aa67cd4..5bb1a17 100644 --- a/backend/src/services/csv.rs +++ b/backend/src/services/csv.rs @@ -259,6 +259,7 @@ mod tests { user_id: Uuid::new_v4(), title: "My Blog".into(), url: "https://blog.example.com".into(), + theme_id: None, created_at: Utc::now(), }, Source { @@ -266,6 +267,7 @@ mod tests { user_id: Uuid::new_v4(), title: "News".into(), url: "https://news.example.com".into(), + theme_id: None, created_at: Utc::now(), }, ]; @@ -284,6 +286,7 @@ mod tests { user_id: Uuid::new_v4(), title: "Blog, with commas".into(), url: "https://example.com".into(), + theme_id: None, created_at: Utc::now(), }]; @@ -306,6 +309,7 @@ mod tests { user_id: Uuid::new_v4(), title: "Simple Blog".into(), url: "https://blog.example.com".into(), + theme_id: None, created_at: Utc::now(), }, Source { @@ -313,6 +317,7 @@ mod tests { user_id: Uuid::new_v4(), title: "News, Quotes \"here\"".into(), url: "https://news.example.com".into(), + theme_id: None, created_at: Utc::now(), }, ]; diff --git a/backend/src/services/prompts.rs b/backend/src/services/prompts.rs index 930ebca..3ad70a2 100644 --- a/backend/src/services/prompts.rs +++ b/backend/src/services/prompts.rs @@ -6,7 +6,6 @@ //! //! Prompts are provider-agnostic and parameterized by user settings. -use crate::models::settings::UserSettings; use crate::models::source::Source; /// Build the system prompt and user prompt for the search pass (Pass 1). @@ -15,12 +14,21 @@ use crate::models::source::Source; /// matching the user's theme and categories, using web search grounding. /// /// # Arguments -/// * `settings` — User's configured settings (theme, categories, etc.) +/// * `theme` — The search topic +/// * `categories` — The list of categories +/// * `max_items_per_category` — Maximum items per category +/// * `max_age_days` — Maximum article age in days +/// * `search_agent_behavior` — Custom search behavior instructions /// * `sources` — User's custom sources to prioritize /// * `current_date` — Formatted date string for the prompt /// * `recent_domains` — Domains used in recent syntheses to avoid if possible +#[allow(clippy::too_many_arguments)] pub fn build_search_prompt( - settings: &UserSettings, + theme: &str, + categories: &[String], + max_items_per_category: i32, + max_age_days: i32, + search_agent_behavior: &str, sources: &[Source], current_date: &str, recent_domains: &[String], @@ -41,26 +49,25 @@ pub fn build_search_prompt( ) }; - let categories_text = settings - .categories + let categories_text = categories .iter() .enumerate() .map(|(i, cat)| format!("{}. {}", i + 1, cat)) .collect::>() .join("\n"); - let behavior = if settings.search_agent_behavior.is_empty() { + let behavior = if search_agent_behavior.is_empty() { "Tu peux egalement utiliser d'autres sources pertinentes trouvees via la recherche Google." .to_string() } else { - settings.search_agent_behavior.clone() + search_agent_behavior.to_string() }; let system_prompt = format!( "Tu es un assistant IA precis. Tu dois TOUJOURS fournir des URLs completes et exactes. \ Ne tronque jamais les URLs. Tu dois te concentrer UNIQUEMENT sur les actualites des {} \ derniers jours.", - settings.max_age_days + max_age_days ); let user_prompt = format!( @@ -82,13 +89,13 @@ pub fn build_search_prompt( Retourne le resultat au format JSON en utilisant les cles category_0, category_1, etc. \ correspondant a l'ordre des sections ci-dessus.", date = current_date, - theme = settings.theme, - days = settings.max_age_days, + theme = theme, + days = max_age_days, sources = sources_text, behavior = behavior, - count = settings.categories.len(), + count = categories.len(), categories = categories_text, - max_items = settings.max_items_per_category, + max_items = max_items_per_category, ); let user_prompt = if recent_domains.is_empty() { @@ -109,7 +116,7 @@ pub fn build_search_prompt( .collect::>() .join("\n"); user_prompt.replace( - &format!("Pour chaque categorie, fournis exactement {} actualites.", settings.max_items_per_category), + &format!("Pour chaque categorie, fournis exactement {} actualites.", max_items_per_category), &format!("Fournis le nombre d'articles suivant par categorie :\n{}", gaps_text), ) } else { @@ -172,25 +179,18 @@ pub fn build_article_classify_prompt( #[cfg(test)] mod tests { use super::*; + use crate::models::settings::UserSettings; use chrono::Utc; use uuid::Uuid; fn test_settings() -> UserSettings { UserSettings { user_id: Uuid::nil(), - theme: "Intelligence Artificielle".to_string(), - max_age_days: 7, - categories: vec![ - "Annonces majeures".to_string(), - "Recherche et innovation".to_string(), - ], - 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, - summary_length: 3, source_extraction_window: 3, search_agent_behavior: String::new(), ai_provider: String::new(), @@ -202,32 +202,45 @@ mod tests { } } + // Test theme/categories for search prompt tests + fn test_theme() -> String { + "Intelligence Artificielle".to_string() + } + fn test_categories() -> Vec { + vec![ + "Annonces majeures".to_string(), + "Recherche et innovation".to_string(), + ] + } + const TEST_MAX_ITEMS: i32 = 4; + const TEST_MAX_AGE: i32 = 7; + #[test] fn search_prompt_includes_theme() { - let settings = test_settings(); - let (_, user_prompt) = build_search_prompt(&settings, &[], "lundi 21 mars 2026", &[], None); + let cats = test_categories(); + let (_, user_prompt) = build_search_prompt(&test_theme(), &cats, TEST_MAX_ITEMS, TEST_MAX_AGE, "", &[], "lundi 21 mars 2026", &[], None); assert!(user_prompt.contains("Intelligence Artificielle")); } #[test] fn search_prompt_includes_date() { - let settings = test_settings(); - let (_, user_prompt) = build_search_prompt(&settings, &[], "lundi 21 mars 2026", &[], None); + let cats = test_categories(); + let (_, user_prompt) = build_search_prompt(&test_theme(), &cats, TEST_MAX_ITEMS, TEST_MAX_AGE, "", &[], "lundi 21 mars 2026", &[], None); assert!(user_prompt.contains("lundi 21 mars 2026")); } #[test] fn search_prompt_includes_max_age() { - let settings = test_settings(); - let (system, user_prompt) = build_search_prompt(&settings, &[], "lundi 21 mars 2026", &[], None); + let cats = test_categories(); + let (system, user_prompt) = build_search_prompt(&test_theme(), &cats, TEST_MAX_ITEMS, TEST_MAX_AGE, "", &[], "lundi 21 mars 2026", &[], None); assert!(user_prompt.contains("7 derniers jours")); assert!(system.contains("7")); } #[test] fn search_prompt_includes_categories() { - let settings = test_settings(); - let (_, user_prompt) = build_search_prompt(&settings, &[], "lundi 21 mars 2026", &[], None); + let cats = test_categories(); + let (_, user_prompt) = build_search_prompt(&test_theme(), &cats, TEST_MAX_ITEMS, TEST_MAX_AGE, "", &[], "lundi 21 mars 2026", &[], None); assert!(user_prompt.contains("1. Annonces majeures")); assert!(user_prompt.contains("2. Recherche et innovation")); assert!(user_prompt.contains("2 grandes sections")); @@ -235,14 +248,14 @@ mod tests { #[test] fn search_prompt_includes_max_items() { - let settings = test_settings(); - let (_, user_prompt) = build_search_prompt(&settings, &[], "lundi 21 mars 2026", &[], None); + let cats = test_categories(); + let (_, user_prompt) = build_search_prompt(&test_theme(), &cats, TEST_MAX_ITEMS, TEST_MAX_AGE, "", &[], "lundi 21 mars 2026", &[], None); assert!(user_prompt.contains("4 actualites")); } #[test] fn search_prompt_includes_custom_sources() { - let settings = test_settings(); + let cats = test_categories(); let sources = vec![ Source { id: Uuid::nil(), @@ -250,6 +263,7 @@ mod tests { title: "TechCrunch".into(), url: "https://techcrunch.com".into(), created_at: Utc::now(), + theme_id: None, }, Source { id: Uuid::nil(), @@ -257,10 +271,11 @@ mod tests { title: "The Verge".into(), url: "https://theverge.com".into(), created_at: Utc::now(), + theme_id: None, }, ]; - let (_, user_prompt) = build_search_prompt(&settings, &sources, "lundi 21 mars 2026", &[], None); + let (_, user_prompt) = build_search_prompt(&test_theme(), &cats, TEST_MAX_ITEMS, TEST_MAX_AGE, "", &sources, "lundi 21 mars 2026", &[], None); assert!(user_prompt.contains("TechCrunch (https://techcrunch.com)")); assert!(user_prompt.contains("The Verge (https://theverge.com)")); assert!(user_prompt.contains("sources personnalisees")); @@ -268,44 +283,40 @@ mod tests { #[test] fn search_prompt_no_sources_no_section() { - let settings = test_settings(); - let (_, user_prompt) = build_search_prompt(&settings, &[], "lundi 21 mars 2026", &[], None); + let cats = test_categories(); + let (_, user_prompt) = build_search_prompt(&test_theme(), &cats, TEST_MAX_ITEMS, TEST_MAX_AGE, "", &[], "lundi 21 mars 2026", &[], None); assert!(!user_prompt.contains("sources personnalisees")); } #[test] fn search_prompt_custom_behavior() { - let mut settings = test_settings(); - settings.search_agent_behavior = - "Concentre-toi sur les sources europeennes.".to_string(); - - let (_, user_prompt) = build_search_prompt(&settings, &[], "lundi 21 mars 2026", &[], None); + let cats = test_categories(); + let (_, user_prompt) = build_search_prompt(&test_theme(), &cats, TEST_MAX_ITEMS, TEST_MAX_AGE, "Concentre-toi sur les sources europeennes.", &[], "lundi 21 mars 2026", &[], None); assert!(user_prompt.contains("Concentre-toi sur les sources europeennes.")); assert!(!user_prompt.contains("recherche Google")); } #[test] fn search_prompt_default_behavior_when_empty() { - let settings = test_settings(); - let (_, user_prompt) = build_search_prompt(&settings, &[], "lundi 21 mars 2026", &[], None); + let cats = test_categories(); + let (_, user_prompt) = build_search_prompt(&test_theme(), &cats, TEST_MAX_ITEMS, TEST_MAX_AGE, "", &[], "lundi 21 mars 2026", &[], None); assert!(user_prompt.contains("recherche Google")); } #[test] fn search_prompt_warns_against_homepage_urls() { - let settings = test_settings(); - let (_, user_prompt) = build_search_prompt(&settings, &[], "lundi 21 mars 2026", &[], None); + let cats = test_categories(); + let (_, user_prompt) = build_search_prompt(&test_theme(), &cats, TEST_MAX_ITEMS, TEST_MAX_AGE, "", &[], "lundi 21 mars 2026", &[], None); assert!(user_prompt.contains("pages d'accueil")); assert!(user_prompt.contains("articles specifiques")); } #[test] fn search_prompt_includes_recent_domains_avoidance() { - let settings = test_settings(); - let sources = vec![]; + let cats = test_categories(); let date = "lundi 17 mars 2026"; let domains = vec!["techcrunch.com".to_string(), "theverge.com".to_string()]; - let (_, user_prompt) = build_search_prompt(&settings, &sources, date, &domains, None); + let (_, user_prompt) = build_search_prompt(&test_theme(), &cats, TEST_MAX_ITEMS, TEST_MAX_AGE, "", &[], date, &domains, None); assert!(user_prompt.contains("Evite si possible")); assert!(user_prompt.contains("techcrunch.com")); assert!(user_prompt.contains("theverge.com")); @@ -313,23 +324,21 @@ mod tests { #[test] fn search_prompt_no_avoidance_when_domains_empty() { - let settings = test_settings(); - let sources = vec![]; + let cats = test_categories(); let date = "lundi 17 mars 2026"; - let (_, user_prompt) = build_search_prompt(&settings, &sources, date, &[], None); + let (_, user_prompt) = build_search_prompt(&test_theme(), &cats, TEST_MAX_ITEMS, TEST_MAX_AGE, "", &[], date, &[], None); assert!(!user_prompt.contains("Evite si possible")); } #[test] fn search_prompt_with_category_gaps() { - let settings = test_settings(); - let sources = vec![]; + let cats = test_categories(); let date = "lundi 17 mars 2026"; let gaps = vec![ ("AI News".to_string(), 2), ("Cybersecurity".to_string(), 4), ]; - let (_, user_prompt) = build_search_prompt(&settings, &sources, date, &[], Some(&gaps)); + let (_, user_prompt) = build_search_prompt(&test_theme(), &cats, TEST_MAX_ITEMS, TEST_MAX_AGE, "", &[], date, &[], Some(&gaps)); assert!(user_prompt.contains("AI News : 2 articles")); assert!(user_prompt.contains("Cybersecurity : 4 articles")); assert!(!user_prompt.contains("exactement")); @@ -337,10 +346,9 @@ mod tests { #[test] fn search_prompt_without_gaps_uses_default() { - let settings = test_settings(); - let sources = vec![]; + let cats = test_categories(); let date = "lundi 17 mars 2026"; - let (_, user_prompt) = build_search_prompt(&settings, &sources, date, &[], None); + let (_, user_prompt) = build_search_prompt(&test_theme(), &cats, TEST_MAX_ITEMS, TEST_MAX_AGE, "", &[], date, &[], None); assert!(user_prompt.contains("exactement")); } diff --git a/backend/src/services/synthesis.rs b/backend/src/services/synthesis.rs index d28e243..f212e49 100644 --- a/backend/src/services/synthesis.rs +++ b/backend/src/services/synthesis.rs @@ -193,10 +193,11 @@ pub async fn run_generation( job_id: Uuid, state: AppState, user_id: Uuid, + theme_id: Uuid, tx: Arc>, provider_override: Option>, ) { - let result = run_generation_inner(job_id, &state, user_id, &tx, provider_override).await; + let result = run_generation_inner(job_id, &state, user_id, theme_id, &tx, provider_override).await; match result { Ok(synthesis_id) => { @@ -229,6 +230,7 @@ pub async fn run_generation_inner( job_id: Uuid, state: &AppState, user_id: Uuid, + theme_id: Uuid, tx: &watch::Sender, provider_override: Option>, ) -> Result { @@ -239,21 +241,26 @@ pub async fn run_generation_inner( emit_progress(tx, "sources", "Chargement des parametres...", 5); let settings = db::settings::get_or_create_default(&state.pool, user_id).await?; + // Load theme + let theme = db::themes::get_by_id(&state.pool, user_id, theme_id).await? + .ok_or_else(|| AppError::BadRequest("Theme introuvable.".into()))?; + let theme_categories: Vec = serde_json::from_value(theme.categories).unwrap_or_default(); + if settings.article_history_days > 0 { db::article_history::cleanup_old(&state.pool, user_id, settings.article_history_days).await.unwrap_or(0); db::llm_call_log::truncate_old(&state.pool, user_id, settings.article_history_days).await.ok(); } - let user_categories = if settings.categories.is_empty() { + let user_categories = if theme_categories.is_empty() { Vec::new() } else { - settings.categories.clone() + theme_categories.clone() }; let mut classification_categories = user_categories.clone(); classification_categories.push("Divers".to_string()); emit_progress(tx, "sources", "Chargement des sources...", 10); - let sources = db::sources::list_for_user(&state.pool, user_id).await?; + let sources = db::sources::list_for_user(&state.pool, user_id, Some(theme_id)).await?; emit_progress(tx, "sources", "Configuration du fournisseur IA...", 12); let (provider_name, provider) = if let Some(mock_provider) = provider_override { @@ -280,7 +287,7 @@ pub async fn run_generation_inner( let mut url_source: HashMap = HashMap::new(); let mut filled_counts: HashMap = HashMap::new(); let mut seen_urls: std::collections::HashSet = std::collections::HashSet::new(); - let max_total = (user_categories.len() + 1) * settings.max_items_per_category as usize; + let max_total = (user_categories.len() + 1) * theme.max_items_per_category as usize; let classify_schema = Arc::new(crate::services::llm::schema::build_article_classify_schema()); let model_research = Arc::new(model_research); let classification_categories = Arc::new(classification_categories); @@ -379,7 +386,7 @@ pub async fn run_generation_inner( if !wave_urls.is_empty() { let total_candidates = wave_urls.len(); let batch_size = settings.batch_size.max(1) as usize; - let snippet_size = match settings.summary_length { 1 => 500, 2 => 2000, _ => 4000 }; + let snippet_size = match theme.summary_length { 1 => 500, 2 => 2000, _ => 4000 }; let mut processed = 0usize; let mut candidates_iter = wave_urls.into_iter(); let mut done = false; @@ -419,7 +426,7 @@ pub async fn run_generation_inner( let client = state.http_client.clone(); let u = url.clone(); let su = source_url.clone(); - let mad = settings.max_age_days as i64; + let mad = theme.max_age_days as i64; scrape_set.spawn(async move { let result = scrape_single_article(&client, &u, mad).await; (u, su, result) @@ -464,7 +471,7 @@ pub async fn run_generation_inner( let uid = user_id; let jid = job_id; - let (sys, usr) = crate::services::prompts::build_article_classify_prompt(&title, &body_snippet, &cats, settings.summary_length); + let (sys, usr) = crate::services::prompts::build_article_classify_prompt(&title, &body_snippet, &cats, theme.summary_length); classify_set.spawn(async move { let llm_start = std::time::Instant::now(); @@ -508,7 +515,7 @@ pub async fn run_generation_inner( if let Some(date_str) = class_response.get("date").and_then(|d| d.as_str()) { if !date_str.is_empty() { if let Some(parsed) = scraper::parse_date_string(date_str) { - if scraper::is_article_too_old(Some(parsed), settings.max_age_days as i64) { + if scraper::is_article_too_old(Some(parsed), theme.max_age_days as i64) { tracing::info!(url = %final_url, date = date_str, "Article filtered by LLM-extracted date (too old)"); pending_traces.push(build_trace_entry(user_id, job_id, &ArticleTrace { url: &final_url, title: &page_title, source_type: "personalized_source", @@ -542,7 +549,7 @@ pub async fn run_generation_inner( let Some((final_cat_key, final_cat_name, llm_title, llm_summary)) = assign_category( &class_response, &page_title, &user_categories, &classification_categories, - &filled_counts, settings.max_items_per_category as usize, + &filled_counts, theme.max_items_per_category as usize, ) else { continue; }; @@ -588,7 +595,7 @@ pub async fn run_generation_inner( // === PHASE 2: Web Search Fallback === let category_gaps: Vec<(String, i32)> = user_categories.iter().filter_map(|cat| { let filled = filled_counts.get(cat).copied().unwrap_or(0); - let needed = (settings.max_items_per_category as usize).saturating_sub(filled); + let needed = (theme.max_items_per_category as usize).saturating_sub(filled); if needed > 0 { Some((cat.clone(), needed as i32)) } else { None } }).collect(); @@ -598,9 +605,9 @@ pub async fn run_generation_inner( emit_progress(tx, "websearch", "Recherche Brave Search...", 70); let brave_key = resolve_brave_key(state, user_id).await?; - let query = format!("{} actualites", settings.theme); + let query = format!("{} actualites", theme.theme); let brave_results = crate::services::brave_search::search( - &state.http_client, &brave_key, &query, 20, settings.max_age_days, + &state.http_client, &brave_key, &query, 20, theme.max_age_days, ).await?; tracing::info!(results = brave_results.len(), "Brave Search returned results"); @@ -658,7 +665,7 @@ pub async fn run_generation_inner( for url in &batch { let client = state.http_client.clone(); let u = url.clone(); - let mad = settings.max_age_days as i64; + let mad = theme.max_age_days as i64; scrape_set.spawn(async move { let result = scrape_single_article(&client, &u, mad).await; (u, result) @@ -695,7 +702,7 @@ pub async fn run_generation_inner( let model = Arc::clone(&model_research); let schema = Arc::clone(&classify_schema); let cats = Arc::clone(&classification_categories); - let snippet_size = match settings.summary_length { + let snippet_size = match theme.summary_length { 1 => 500, 2 => 2000, _ => 4000, @@ -707,7 +714,7 @@ pub async fn run_generation_inner( let uid = user_id; let jid = job_id; - let (sys, usr) = crate::services::prompts::build_article_classify_prompt(&title, &body_snippet, &cats, settings.summary_length); + let (sys, usr) = crate::services::prompts::build_article_classify_prompt(&title, &body_snippet, &cats, theme.summary_length); classify_set.spawn(async move { let llm_start = std::time::Instant::now(); @@ -750,7 +757,7 @@ pub async fn run_generation_inner( if let Some(date_str) = class_response.get("date").and_then(|d| d.as_str()) { if !date_str.is_empty() { if let Some(parsed) = scraper::parse_date_string(date_str) { - if scraper::is_article_too_old(Some(parsed), settings.max_age_days as i64) { + if scraper::is_article_too_old(Some(parsed), theme.max_age_days as i64) { tracing::info!(url = %final_url, date = date_str, "Article filtered by LLM-extracted date (too old)"); pending_traces.push(build_trace_entry(user_id, job_id, &ArticleTrace { url: &final_url, title: &page_title, source_type: "brave_search", @@ -785,7 +792,7 @@ pub async fn run_generation_inner( let Some((final_cat_key, final_cat_name, llm_title, llm_summary)) = assign_category( &class_response, &page_title, &user_categories, &classification_categories, - &filled_counts, settings.max_items_per_category as usize, + &filled_counts, theme.max_items_per_category as usize, ) else { continue; }; @@ -823,9 +830,9 @@ pub async fn run_generation_inner( emit_progress(tx, "websearch", "Recherche d'actualites...", 70); check_rate_limit(state, &user_rate_limiter, &provider_name).await?; - let search_schema = crate::services::llm::schema::build_category_schema(&user_categories, settings.max_items_per_category); + let search_schema = crate::services::llm::schema::build_category_schema(&user_categories, theme.max_items_per_category); let current_date = Utc::now().format("%A %d %B %Y").to_string(); - let (sys_prompt, usr_prompt) = crate::services::prompts::build_search_prompt(&settings, &[], ¤t_date, &[], Some(&category_gaps)); + let (sys_prompt, usr_prompt) = crate::services::prompts::build_search_prompt(&theme.theme, &user_categories, theme.max_items_per_category, theme.max_age_days, &settings.search_agent_behavior, &[], ¤t_date, &[], Some(&category_gaps)); let llm_start = std::time::Instant::now(); let raw_results = provider.call_llm(&model_websearch, &sys_prompt, &usr_prompt, &search_schema).await?; @@ -867,7 +874,7 @@ pub async fn run_generation_inner( // Scrape Phase 2 for validation emit_progress(tx, "websearch", "Verification des sources...", 80); for (cat_key, item) in phase2_items { - let (_body_text, _, final_url, drop_reason) = scrape_single_article(&state.http_client, &item.url, settings.max_age_days as i64).await; + let (_body_text, _, final_url, drop_reason) = scrape_single_article(&state.http_client, &item.url, theme.max_age_days as i64).await; if let Some(reason) = drop_reason { pending_traces.push(build_trace_entry(user_id, job_id, &ArticleTrace { @@ -929,7 +936,7 @@ pub async fn run_generation_inner( let sections_json = serde_json::to_value(&final_sections).map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to serialize: {}", e)))?; let sections_json = sanitize_json_null_bytes(sections_json); - let synthesis = db::syntheses::create(&state.pool, user_id, &get_iso_week_string(Utc::now().date_naive()), §ions_json, job_id).await?; + let synthesis = db::syntheses::create(&state.pool, user_id, &get_iso_week_string(Utc::now().date_naive()), §ions_json, job_id, Some(theme_id)).await?; if settings.article_history_days > 0 { for section in &final_sections { @@ -1805,8 +1812,8 @@ mod tests { #[test] fn rotate_sources_no_last_url() { let sources = vec![ - crate::models::source::Source { id: Uuid::new_v4(), user_id: Uuid::new_v4(), title: "A".into(), url: "https://a.com".into(), created_at: chrono::Utc::now() }, - crate::models::source::Source { id: Uuid::new_v4(), user_id: Uuid::new_v4(), title: "B".into(), url: "https://b.com".into(), created_at: chrono::Utc::now() }, + crate::models::source::Source { id: Uuid::new_v4(), user_id: Uuid::new_v4(), title: "A".into(), url: "https://a.com".into(), theme_id: None, created_at: chrono::Utc::now() }, + crate::models::source::Source { id: Uuid::new_v4(), user_id: Uuid::new_v4(), title: "B".into(), url: "https://b.com".into(), theme_id: None, created_at: chrono::Utc::now() }, ]; let result = rotate_sources(sources.clone(), None); assert_eq!(result.len(), 2); @@ -1816,9 +1823,9 @@ mod tests { #[test] fn rotate_sources_with_last_url() { let sources = vec![ - crate::models::source::Source { id: Uuid::new_v4(), user_id: Uuid::new_v4(), title: "A".into(), url: "https://a.com".into(), created_at: chrono::Utc::now() }, - crate::models::source::Source { id: Uuid::new_v4(), user_id: Uuid::new_v4(), title: "B".into(), url: "https://b.com".into(), created_at: chrono::Utc::now() }, - crate::models::source::Source { id: Uuid::new_v4(), user_id: Uuid::new_v4(), title: "C".into(), url: "https://c.com".into(), created_at: chrono::Utc::now() }, + crate::models::source::Source { id: Uuid::new_v4(), user_id: Uuid::new_v4(), title: "A".into(), url: "https://a.com".into(), theme_id: None, created_at: chrono::Utc::now() }, + crate::models::source::Source { id: Uuid::new_v4(), user_id: Uuid::new_v4(), title: "B".into(), url: "https://b.com".into(), theme_id: None, created_at: chrono::Utc::now() }, + crate::models::source::Source { id: Uuid::new_v4(), user_id: Uuid::new_v4(), title: "C".into(), url: "https://c.com".into(), theme_id: None, created_at: chrono::Utc::now() }, ]; let result = rotate_sources(sources, Some("https://a.com")); assert_eq!(result[0].url, "https://b.com"); @@ -1829,7 +1836,7 @@ mod tests { #[test] fn rotate_sources_last_url_not_found() { let sources = vec![ - crate::models::source::Source { id: Uuid::new_v4(), user_id: Uuid::new_v4(), title: "A".into(), url: "https://a.com".into(), created_at: chrono::Utc::now() }, + crate::models::source::Source { id: Uuid::new_v4(), user_id: Uuid::new_v4(), title: "A".into(), url: "https://a.com".into(), theme_id: None, created_at: chrono::Utc::now() }, ]; let result = rotate_sources(sources.clone(), Some("https://notfound.com")); assert_eq!(result[0].url, "https://a.com"); diff --git a/backend/tests/api_settings_test.rs b/backend/tests/api_settings_test.rs index 33f2d92..501df12 100644 --- a/backend/tests/api_settings_test.rs +++ b/backend/tests/api_settings_test.rs @@ -13,7 +13,7 @@ fn require_test_db() -> bool { std::env::var("TEST_DATABASE_URL").is_ok() } -// ── Auth requirement ───────────────────────────────────────────────────── +// -- Auth requirement --------------------------------------------------------- #[tokio::test] async fn get_settings_without_auth_returns_401() { @@ -42,17 +42,11 @@ async fn put_settings_without_auth_returns_401() { let app = common::TestApp::new().await; let body = serde_json::json!({ - "theme": "Test", - "max_age_days": 7, - "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, "batch_size": 5, - "summary_length": 3, "source_extraction_window": 3, "search_agent_behavior": "", "ai_provider": "", @@ -79,7 +73,7 @@ async fn put_settings_without_auth_returns_401() { ); } -// ── Default settings ───────────────────────────────────────────────────── +// -- Default settings --------------------------------------------------------- #[tokio::test] async fn get_settings_returns_defaults_on_first_access() { @@ -97,25 +91,12 @@ async fn get_settings_returns_defaults_on_first_access() { assert_eq!(status, StatusCode::OK, "GET /settings should return 200"); assert_eq!( - body["theme"], "Intelligence Artificielle", - "Default theme should be 'Intelligence Artificielle'" - ); - assert_eq!( - body["max_age_days"], 7, - "Default max_age_days should be 7" + body["max_articles_per_source"], 3, + "Default max_articles_per_source should be 3" ); - assert_eq!( - body["max_items_per_category"], 4, - "Default max_items_per_category should be 4" - ); - - // Check default categories - let categories = body["categories"].as_array().expect("categories should be an array"); - assert_eq!(categories.len(), 5, "Default should have 5 categories"); - assert_eq!(categories[0], "Annonces majeures"); } -// ── Update settings ────────────────────────────────────────────────────── +// -- Update settings ---------------------------------------------------------- #[tokio::test] async fn put_settings_with_valid_data_returns_200() { @@ -130,17 +111,11 @@ async fn put_settings_with_valid_data_returns_200() { .await; let update = serde_json::json!({ - "theme": "Cybersecurite", - "max_age_days": 14, - "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, "batch_size": 5, - "summary_length": 3, "source_extraction_window": 3, "search_agent_behavior": "Focus on CVEs", "ai_provider": "", @@ -159,16 +134,7 @@ async fn put_settings_with_valid_data_returns_200() { StatusCode::OK, "PUT /settings with valid data should return 200" ); - assert_eq!(body["theme"], "Cybersecurite"); - assert_eq!(body["max_age_days"], 14); - assert_eq!(body["max_items_per_category"], 6); assert_eq!(body["search_agent_behavior"], "Focus on CVEs"); - - let categories = body["categories"].as_array().expect("categories array"); - assert_eq!(categories.len(), 3); - assert_eq!(categories[0], "Vulnerabilites"); - assert_eq!(categories[1], "Patch Tuesday"); - assert_eq!(categories[2], "Threat Intel"); } #[tokio::test] @@ -189,17 +155,11 @@ async fn put_then_get_returns_updated_data() { // Update let update = serde_json::json!({ - "theme": "Economie", - "max_age_days": 30, - "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, "batch_size": 5, - "summary_length": 3, "source_extraction_window": 3, "search_agent_behavior": "Francophone sources", "ai_provider": "", @@ -213,271 +173,13 @@ async fn put_then_get_returns_updated_data() { .await; assert_eq!(put_status, StatusCode::OK); - // GET again — should reflect the update + // GET again -- should reflect the update let (get_status, body) = app.get_with_session("/api/v1/settings", &session).await; assert_eq!(get_status, StatusCode::OK); - assert_eq!(body["theme"], "Economie"); - assert_eq!(body["max_age_days"], 30); - assert_eq!(body["max_items_per_category"], 10); assert_eq!(body["search_agent_behavior"], "Francophone sources"); - - let categories = body["categories"].as_array().expect("categories array"); - assert_eq!(categories.len(), 2); - assert_eq!(categories[0], "Macro"); - assert_eq!(categories[1], "Finance"); } -// ── Validation errors ──────────────────────────────────────────────────── - -#[tokio::test] -async fn put_settings_empty_theme_returns_422() { - if !require_test_db() { - eprintln!("SKIPPED: TEST_DATABASE_URL not set"); - return; - } - - let app = common::TestApp::new().await; - let (_user_id, session) = app - .create_authenticated_user("settings-val-theme@example.com") - .await; - - let update = serde_json::json!({ - "theme": " ", - "max_age_days": 7, - "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, - "batch_size": 5, - "summary_length": 3, - "source_extraction_window": 3, - "search_agent_behavior": "", - "ai_provider": "", - "ai_model": "", - "ai_model_websearch": "", - "rate_limit_max_requests": null, - "rate_limit_time_window_seconds": null - }); - let (status, body) = app - .put_with_session("/api/v1/settings", &update, &session) - .await; - - assert_eq!( - status, - StatusCode::UNPROCESSABLE_ENTITY, - "Empty theme should return 422" - ); - assert_eq!(body["error"], "validation_error"); -} - -#[tokio::test] -async fn put_settings_too_many_categories_returns_422() { - if !require_test_db() { - eprintln!("SKIPPED: TEST_DATABASE_URL not set"); - return; - } - - let app = common::TestApp::new().await; - let (_user_id, session) = app - .create_authenticated_user("settings-val-cats@example.com") - .await; - - let categories: Vec = (0..21).map(|i| format!("Cat {}", i)).collect(); - let update = serde_json::json!({ - "theme": "AI", - "max_age_days": 7, - "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, - "batch_size": 5, - "summary_length": 3, - "source_extraction_window": 3, - "search_agent_behavior": "", - "ai_provider": "", - "ai_model": "", - "ai_model_websearch": "", - "rate_limit_max_requests": null, - "rate_limit_time_window_seconds": null - }); - let (status, body) = app - .put_with_session("/api/v1/settings", &update, &session) - .await; - - assert_eq!( - status, - StatusCode::UNPROCESSABLE_ENTITY, - "More than 20 categories should return 422" - ); - assert_eq!(body["error"], "validation_error"); -} - -#[tokio::test] -async fn put_settings_empty_categories_returns_422() { - if !require_test_db() { - eprintln!("SKIPPED: TEST_DATABASE_URL not set"); - return; - } - - let app = common::TestApp::new().await; - let (_user_id, session) = app - .create_authenticated_user("settings-val-empty-cats@example.com") - .await; - - let update = serde_json::json!({ - "theme": "AI", - "max_age_days": 7, - "categories": [], - "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, - "summary_length": 3, - "source_extraction_window": 3, - "search_agent_behavior": "", - "ai_provider": "", - "ai_model": "", - "ai_model_websearch": "", - "rate_limit_max_requests": null, - "rate_limit_time_window_seconds": null - }); - let (status, body) = app - .put_with_session("/api/v1/settings", &update, &session) - .await; - - assert_eq!( - status, - StatusCode::UNPROCESSABLE_ENTITY, - "Empty categories array should return 422" - ); - assert_eq!(body["error"], "validation_error"); -} - -#[tokio::test] -async fn put_settings_max_age_days_out_of_range_returns_422() { - if !require_test_db() { - eprintln!("SKIPPED: TEST_DATABASE_URL not set"); - return; - } - - let app = common::TestApp::new().await; - let (_user_id, session) = app - .create_authenticated_user("settings-val-age@example.com") - .await; - - // Below range - let update = serde_json::json!({ - "theme": "AI", - "max_age_days": 0, - "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, - "batch_size": 5, - "summary_length": 3, - "source_extraction_window": 3, - "search_agent_behavior": "", - "ai_provider": "", - "ai_model": "", - "ai_model_websearch": "", - "rate_limit_max_requests": null, - "rate_limit_time_window_seconds": null - }); - let (status, _) = app - .put_with_session("/api/v1/settings", &update, &session) - .await; - assert_eq!( - status, - StatusCode::UNPROCESSABLE_ENTITY, - "max_age_days=0 should return 422" - ); - - // Above range - let update2 = serde_json::json!({ - "theme": "AI", - "max_age_days": 366, - "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, - "batch_size": 5, - "summary_length": 3, - "source_extraction_window": 3, - "search_agent_behavior": "", - "ai_provider": "", - "ai_model": "", - "ai_model_websearch": "", - "rate_limit_max_requests": null, - "rate_limit_time_window_seconds": null - }); - let (status2, _) = app - .put_with_session("/api/v1/settings", &update2, &session) - .await; - assert_eq!( - status2, - StatusCode::UNPROCESSABLE_ENTITY, - "max_age_days=366 should return 422" - ); -} - -#[tokio::test] -async fn put_settings_max_items_out_of_range_returns_422() { - if !require_test_db() { - eprintln!("SKIPPED: TEST_DATABASE_URL not set"); - return; - } - - let app = common::TestApp::new().await; - let (_user_id, session) = app - .create_authenticated_user("settings-val-items@example.com") - .await; - - let update = serde_json::json!({ - "theme": "AI", - "max_age_days": 7, - "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, - "batch_size": 5, - "summary_length": 3, - "source_extraction_window": 3, - "search_agent_behavior": "", - "ai_provider": "", - "ai_model": "", - "ai_model_websearch": "", - "rate_limit_max_requests": null, - "rate_limit_time_window_seconds": null - }); - let (status, _) = app - .put_with_session("/api/v1/settings", &update, &session) - .await; - assert_eq!( - status, - StatusCode::UNPROCESSABLE_ENTITY, - "max_items_per_category=51 should return 422" - ); -} - -// ── Per-user isolation ─────────────────────────────────────────────────── +// -- Per-user isolation ------------------------------------------------------- #[tokio::test] async fn settings_are_per_user_isolated() { @@ -498,17 +200,11 @@ async fn settings_are_per_user_isolated() { // User A updates their settings let update_a = serde_json::json!({ - "theme": "User A Theme", - "max_age_days": 3, - "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, "batch_size": 5, - "summary_length": 3, "source_extraction_window": 3, "search_agent_behavior": "User A behavior", "ai_provider": "", @@ -524,17 +220,11 @@ async fn settings_are_per_user_isolated() { // User B updates their settings differently let update_b = serde_json::json!({ - "theme": "User B Theme", - "max_age_days": 14, - "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, "batch_size": 5, - "summary_length": 3, "source_extraction_window": 3, "search_agent_behavior": "User B behavior", "ai_provider": "", @@ -550,22 +240,14 @@ async fn settings_are_per_user_isolated() { // Verify User A sees only their settings let (_, body_a) = app.get_with_session("/api/v1/settings", &session_a).await; - assert_eq!(body_a["theme"], "User A Theme"); - assert_eq!(body_a["max_age_days"], 3); - let cats_a = body_a["categories"].as_array().unwrap(); - assert_eq!(cats_a.len(), 1); - assert_eq!(cats_a[0], "A-Category"); + assert_eq!(body_a["search_agent_behavior"], "User A behavior"); // Verify User B sees only their settings let (_, body_b) = app.get_with_session("/api/v1/settings", &session_b).await; - assert_eq!(body_b["theme"], "User B Theme"); - assert_eq!(body_b["max_age_days"], 14); - let cats_b = body_b["categories"].as_array().unwrap(); - assert_eq!(cats_b.len(), 2); - assert_eq!(cats_b[0], "B-Category-1"); + assert_eq!(body_b["search_agent_behavior"], "User B behavior"); } -// ── Boundary values ───────────────────────────────────────────────────── +// -- Boundary values ---------------------------------------------------------- #[tokio::test] async fn put_settings_boundary_values_succeed() { @@ -581,17 +263,11 @@ async fn put_settings_boundary_values_succeed() { // Minimum valid values let update_min = serde_json::json!({ - "theme": "A", - "max_age_days": 1, - "categories": ["C"], - "max_items_per_category": 1, - "max_articles_per_source": 3, - "max_links_per_source": 8, - + "max_articles_per_source": 1, + "max_links_per_source": 1, "use_brave_search": false, - "article_history_days": 90, - "batch_size": 5, - "summary_length": 1, + "article_history_days": 0, + "batch_size": 1, "source_extraction_window": 1, "search_agent_behavior": "", "ai_provider": "", @@ -606,19 +282,12 @@ async fn put_settings_boundary_values_succeed() { assert_eq!(status, StatusCode::OK, "Minimum boundary values should be accepted"); // Maximum valid values - let categories_max: Vec = (0..20).map(|i| format!("Cat {}", i)).collect(); let update_max = serde_json::json!({ - "theme": "a".repeat(200), - "max_age_days": 365, - "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, - "batch_size": 5, - "summary_length": 3, + "max_articles_per_source": 10, + "max_links_per_source": 30, + "use_brave_search": true, + "article_history_days": 365, + "batch_size": 20, "source_extraction_window": 10, "search_agent_behavior": "a".repeat(2000), "ai_provider": "", diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs index 2cb4f1c..a4c7a2a 100644 --- a/backend/tests/common/mod.rs +++ b/backend/tests/common/mod.rs @@ -436,7 +436,7 @@ impl TestApp { week: &str, sections_json: &serde_json::Value, ) -> uuid::Uuid { - let row = db::syntheses::create(&self.pool, user_id, week, sections_json, uuid::Uuid::new_v4()) + let row = db::syntheses::create(&self.pool, user_id, week, sections_json, uuid::Uuid::new_v4(), None) .await .expect("Failed to insert test synthesis"); row.id diff --git a/backend/tests/pipeline_test.rs b/backend/tests/pipeline_test.rs index a295c6a..135d3ef 100644 --- a/backend/tests/pipeline_test.rs +++ b/backend/tests/pipeline_test.rs @@ -45,22 +45,16 @@ async fn setup_user_with_settings( app: &common::TestApp, categories: Vec<&str>, max_items: i32, -) -> (uuid::Uuid, String) { +) -> (uuid::Uuid, String, uuid::Uuid) { let email = format!("pipeline-{}@test.com", uuid::Uuid::new_v4()); let (user_id, session) = app.create_authenticated_user(&email).await; - let categories_json: Vec = categories.iter().map(|c| serde_json::json!(c)).collect(); let settings = serde_json::json!({ - "theme": "Intelligence Artificielle", - "max_age_days": 365, - "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, - "summary_length": 3, "source_extraction_window": 3, "search_agent_behavior": "", "ai_provider": "", @@ -72,7 +66,21 @@ async fn setup_user_with_settings( let (status, _) = app.put_with_session("/api/v1/settings", &settings, &session).await; assert_eq!(status.as_u16(), 200, "Settings save should succeed"); - (user_id, session) + // Create a theme for the pipeline + let categories_json: Vec = categories.iter().map(|c| serde_json::json!(c)).collect(); + let theme_body = serde_json::json!({ + "name": "Test Theme", + "theme": "Intelligence Artificielle", + "categories": categories_json, + "max_items_per_category": max_items, + "max_age_days": 365, + "summary_length": 3 + }); + let (theme_status, theme_resp) = app.post_with_session("/api/v1/themes", &theme_body, &session).await; + assert_eq!(theme_status.as_u16(), 201, "Theme creation should succeed"); + let theme_id: uuid::Uuid = theme_resp["id"].as_str().unwrap().parse().unwrap(); + + (user_id, session, theme_id) } fn make_progress_channel() -> (Arc>, watch::Receiver) { @@ -89,11 +97,11 @@ async fn phase1_heuristic_extraction_classifies_articles() { let app = common::TestApp::new().await; let mock_server = setup_mock_server().await; - let (user_id, session) = setup_user_with_settings(&app, vec!["AI News"], 4).await; + let (user_id, session, theme_id) = setup_user_with_settings(&app, vec!["AI News"], 4).await; // Add a source pointing to wiremock (same host as article URLs) let source_url = format!("{}/blog", mock_server.uri()); - let source = serde_json::json!({"title": "Test Source", "url": source_url}); + let source = serde_json::json!({"title": "Test Source", "url": source_url, "theme_id": theme_id.to_string()}); let (status, _) = app.post_with_session("/api/v1/sources", &source, &session).await; assert!(status.is_success()); @@ -109,7 +117,7 @@ async fn phase1_heuristic_extraction_classifies_articles() { ); let result = synthesis::run_generation_inner( - job_id, &state, user_id, &tx, Some(mock_provider), + job_id, &state, user_id, theme_id, &tx, Some(mock_provider), ).await; assert!(result.is_ok(), "Generation should succeed: {:?}", result.err()); @@ -150,8 +158,8 @@ async fn phase2_search_fills_gaps_when_no_sources() { let app = common::TestApp::new().await; let mock_server = setup_mock_server().await; - // No sources — Phase 1 produces nothing - let (user_id, _session) = setup_user_with_settings(&app, vec!["AI News"], 2).await; + // No sources -- Phase 1 produces nothing + let (user_id, _session, theme_id) = setup_user_with_settings(&app, vec!["AI News"], 2).await; let mock_provider = MockLlmProvider::new() .with_default_category("AI News") @@ -169,7 +177,7 @@ async fn phase2_search_fills_gaps_when_no_sources() { ); let result = synthesis::run_generation_inner( - job_id, &state, user_id, &tx, Some(mock_provider), + job_id, &state, user_id, theme_id, &tx, Some(mock_provider), ).await; assert!(result.is_ok(), "Generation should succeed: {:?}", result.err()); @@ -194,10 +202,10 @@ async fn category_overflow_spills_to_autre() { let mock_server = setup_mock_server().await; // max_items_per_category=1, but LLM classifies all articles to "AI News" - let (user_id, session) = setup_user_with_settings(&app, vec!["AI News"], 1).await; + let (user_id, session, theme_id) = setup_user_with_settings(&app, vec!["AI News"], 1).await; let source_url = format!("{}/blog", mock_server.uri()); - let source = serde_json::json!({"title": "Test Source", "url": source_url}); + let source = serde_json::json!({"title": "Test Source", "url": source_url, "theme_id": theme_id.to_string()}); app.post_with_session("/api/v1/sources", &source, &session).await; let mock_provider = MockLlmProvider::new() @@ -212,7 +220,7 @@ async fn category_overflow_spills_to_autre() { ); let result = synthesis::run_generation_inner( - job_id, &state, user_id, &tx, Some(mock_provider), + job_id, &state, user_id, theme_id, &tx, Some(mock_provider), ).await; assert!(result.is_ok(), "Generation should succeed"); diff --git a/e2e/tests/generation-live.spec.ts b/e2e/tests/generation-live.spec.ts index 9b1dc3b..2ec98cf 100644 --- a/e2e/tests/generation-live.spec.ts +++ b/e2e/tests/generation-live.spec.ts @@ -132,10 +132,6 @@ test.describe('Live generation with OpenAI', () => { // Step 2: Configure settings const settingsResp = await apiCall(page, 'PUT', '/api/v1/settings', { - theme: 'Intelligence Artificielle', - max_age_days: 30, - categories: ['AI News'], - max_items_per_category: 4, max_articles_per_source: 3, max_links_per_source: 8, search_agent_behavior: '', @@ -145,11 +141,22 @@ test.describe('Live generation with OpenAI', () => { use_brave_search: false, article_history_days: 90, batch_size: 5, - summary_length: 3, source_extraction_window: 3, }); expect(settingsResp.status).toBe(200); + // Step 2b: Create a theme + const themeResp = await apiCall(page, 'POST', '/api/v1/themes', { + name: 'AI Theme', + theme: 'Intelligence Artificielle', + categories: ['AI News'], + max_items_per_category: 4, + max_age_days: 30, + summary_length: 3, + }); + expect(themeResp.status).toBe(201); + const themeId = themeResp.data.id; + // Step 3: Store the real OpenAI API key const keyResp = await apiCall(page, 'POST', '/api/v1/user/api-keys', { provider_name: 'openai', @@ -168,6 +175,7 @@ test.describe('Live generation with OpenAI', () => { const sourceResp = await apiCall(page, 'POST', '/api/v1/sources', { title: 'OpenAI Blog', url: 'https://openai.com/blog', + theme_id: themeId, }); expect(sourceResp.status).toBe(201); @@ -176,6 +184,7 @@ test.describe('Live generation with OpenAI', () => { page, 'POST', '/api/v1/syntheses/generate', + { theme_id: themeId }, ); expect(genResp.status).toBe(202); const jobId = genResp.data.job_id; diff --git a/frontend/src/__tests__/fixtures.ts b/frontend/src/__tests__/fixtures.ts index a76e43e..56de0ee 100644 --- a/frontend/src/__tests__/fixtures.ts +++ b/frontend/src/__tests__/fixtures.ts @@ -22,6 +22,8 @@ export const MOCK_SYNTHESIS_LIST_ITEM: SynthesisListItem = { { title: 'Recherche', count: 2 }, ], job_id: 'job-test-1', + theme_id: null, + theme_name: null, }; export const MOCK_SYNTHESIS_LIST: SynthesisListItem[] = [ @@ -57,7 +59,7 @@ export const MOCK_SOURCES: Source[] = [ // ---- Settings ---- export const MOCK_SETTINGS: UserSettings = { - ...DEFAULT_SETTINGS, theme: 'Intelligence Artificielle', ai_provider: 'gemini', ai_model: 'gemini-2.5-pro', ai_model_websearch: 'gemini-2.5-flash', + ...DEFAULT_SETTINGS, ai_provider: 'gemini', ai_model: 'gemini-2.5-pro', ai_model_websearch: 'gemini-2.5-flash', }; // ---- Providers ---- diff --git a/frontend/src/__tests__/settings-validation.test.ts b/frontend/src/__tests__/settings-validation.test.ts index 415d405..ec2794b 100644 --- a/frontend/src/__tests__/settings-validation.test.ts +++ b/frontend/src/__tests__/settings-validation.test.ts @@ -3,10 +3,7 @@ import { DEFAULT_SETTINGS, type UserSettings } from '~/types'; describe('Settings validation logic', () => { it('should have valid default settings', () => { - expect(DEFAULT_SETTINGS.theme).toBe('Intelligence Artificielle'); - expect(DEFAULT_SETTINGS.max_age_days).toBe(7); - expect(DEFAULT_SETTINGS.max_items_per_category).toBe(4); - expect(DEFAULT_SETTINGS.categories.length).toBeGreaterThan(0); + expect(DEFAULT_SETTINGS.max_articles_per_source).toBe(3); expect(DEFAULT_SETTINGS.ai_model).toBe(''); expect(DEFAULT_SETTINGS.ai_model_websearch).toBe(''); expect(DEFAULT_SETTINGS.ai_provider).toBe(''); @@ -14,60 +11,21 @@ describe('Settings validation logic', () => { expect(DEFAULT_SETTINGS.rate_limit_time_window_seconds).toBeNull(); }); - it('should filter empty categories before save', () => { - const settings: UserSettings = { - ...DEFAULT_SETTINGS, - categories: ['Category A', '', ' ', 'Category B', ''], - }; + it('should parse batch_size as integer with fallback', () => { + const parseBatchSize = (value: string): number => + parseInt(value) || 5; - const cleaned = settings.categories.filter((c) => c.trim() !== ''); - expect(cleaned).toEqual(['Category A', 'Category B']); + expect(parseBatchSize('10')).toBe(10); + expect(parseBatchSize('')).toBe(5); + expect(parseBatchSize('abc')).toBe(5); }); - it('should fallback to General when all categories are empty', () => { - const settings: UserSettings = { - ...DEFAULT_SETTINGS, - categories: ['', ' ', ''], - }; + it('should parse max_articles_per_source as integer with fallback', () => { + const parseMaxArticles = (value: string): number => + parseInt(value) || 3; - let cleaned = settings.categories.filter((c) => c.trim() !== ''); - if (cleaned.length === 0) { - cleaned = ['General']; - } - expect(cleaned).toEqual(['General']); - }); - - it('should enforce max 20 categories', () => { - const categories = Array.from({ length: 20 }, (_, i) => `Cat ${i + 1}`); - expect(categories.length).toBe(20); - - // Adding another should not be allowed - const shouldAdd = categories.length < 20; - expect(shouldAdd).toBe(false); - }); - - it('should enforce min 1 category', () => { - const categories = ['Only one']; - const shouldRemove = categories.length > 1; - expect(shouldRemove).toBe(false); - }); - - it('should parse max_age_days as integer with fallback', () => { - const parseMaxAge = (value: string): number => - parseInt(value) || 7; - - expect(parseMaxAge('14')).toBe(14); - expect(parseMaxAge('')).toBe(7); - expect(parseMaxAge('abc')).toBe(7); - expect(parseMaxAge('0')).toBe(7); - }); - - it('should parse max_items_per_category as integer with fallback', () => { - const parseMaxItems = (value: string): number => - parseInt(value) || 4; - - expect(parseMaxItems('10')).toBe(10); - expect(parseMaxItems('')).toBe(4); - expect(parseMaxItems('abc')).toBe(4); + expect(parseMaxArticles('5')).toBe(5); + expect(parseMaxArticles('')).toBe(3); + expect(parseMaxArticles('abc')).toBe(3); }); }); diff --git a/frontend/src/i18n/fr.ts b/frontend/src/i18n/fr.ts index b699ed6..5d14b57 100644 --- a/frontend/src/i18n/fr.ts +++ b/frontend/src/i18n/fr.ts @@ -67,7 +67,7 @@ const fr = { // Generate 'generate.title': 'Generer la Synthese Hebdomadaire', 'generate.description': - "Cette action va lancer l'analyse des actualites des {days} derniers jours sur le theme \"{theme}\" via l'IA.", + "Selectionnez un theme puis lancez la generation pour analyser les actualites via l'IA.", 'generate.note': 'Note : La generation peut prendre jusqu\'a 10 minutes.', 'generate.launch': 'Lancer la generation', 'generate.inProgress': 'Generation en cours...', diff --git a/frontend/src/pages/GenerateSynthesis.tsx b/frontend/src/pages/GenerateSynthesis.tsx index 7014815..7781869 100644 --- a/frontend/src/pages/GenerateSynthesis.tsx +++ b/frontend/src/pages/GenerateSynthesis.tsx @@ -248,7 +248,7 @@ const GenerateSynthesis: Component = () => {

- {t('generate.description', { days: String(settings().max_age_days), theme: settings().theme })} + {t('generate.description')}

diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index b5244e8..d8cbe42 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -8,7 +8,7 @@ import { createEffect, } from 'solid-js'; import { A } from '@solidjs/router'; -import { Settings as SettingsIcon, Save, Plus, Trash2, Info, Download, Upload } from 'lucide-solid'; +import { Settings as SettingsIcon, Save, Info, Download, Upload } from 'lucide-solid'; import Button from '~/components/ui/Button'; import { settingsApi } from '~/api/settings'; import { configApi } from '~/api/config'; @@ -135,16 +135,7 @@ const Settings: Component = () => { setMessage(null); try { - const cleanedSettings = { - ...settings(), - categories: settings().categories.filter((c) => c.trim() !== ''), - }; - - if (cleanedSettings.categories.length === 0) { - cleanedSettings.categories = ['General']; - } - - const saved = await settingsApi.update(cleanedSettings); + const saved = await settingsApi.update(settings()); setSettings(saved); setMessage({ type: 'success', text: t('settings.saved') }); } catch (err) { @@ -158,30 +149,6 @@ const Settings: Component = () => { } }; - const handleCategoryChange = (index: number, value: string) => { - setSettings((prev) => { - const newCategories = [...prev.categories]; - newCategories[index] = value; - return { ...prev, categories: newCategories }; - }); - }; - - const addCategory = () => { - if (settings().categories.length >= 20) return; - setSettings((prev) => ({ - ...prev, - categories: [...prev.categories, t('settings.newCategory')], - })); - }; - - const removeCategory = (index: number) => { - if (settings().categories.length <= 1) return; - setSettings((prev) => ({ - ...prev, - categories: prev.categories.filter((_, i) => i !== index), - })); - }; - const handleExport = async () => { try { const exportData: Record = { ...settings() }; @@ -222,7 +189,6 @@ const Settings: Component = () => { const merged: UserSettings = { ...DEFAULT_SETTINGS, ...data, - categories: Array.isArray(data.categories) ? data.categories : DEFAULT_SETTINGS.categories, }; setSettings(merged); @@ -283,170 +249,7 @@ const Settings: Component = () => {

- {/* ── Section 1: Contenu ── */} -
-

{t('settings.section.content')}

-

{t('settings.section.contentDesc')}

-
- {/* Theme */} -
- -
- - setSettings((prev) => ({ - ...prev, - theme: e.currentTarget.value, - })) - } - /> -
-

{t('settings.themeHelp')}

-
- - {/* Categories */} -
-
- - -
-
- - {(category, index) => ( -
- - {index() + 1}. - - - handleCategoryChange(index(), e.currentTarget.value) - } - /> - -
- )} -
-
-
- - {/* Max age days + Max items per category */} -
-
- -
- - setSettings((prev) => ({ - ...prev, - max_age_days: parseInt(e.currentTarget.value) || 7, - })) - } - /> -
-
- -
- -
- - setSettings((prev) => ({ - ...prev, - max_items_per_category: - parseInt(e.currentTarget.value) || 4, - })) - } - /> -
-
-
- - {/* Summary length slider */} -
- -

{t('settings.summaryLengthHelp')}

-
- {t('settings.summaryShort')} - - setSettings((prev) => ({ - ...prev, - summary_length: parseInt(e.currentTarget.value) || 3, - })) - } - /> - {t('settings.summaryDetailed')} -
-
- {settings().summary_length === 1 - ? t('settings.summaryShort') - : settings().summary_length === 2 - ? t('settings.summaryMedium') - : t('settings.summaryDetailed')} -
-
-
-
- - {/* ── Section 2: Sources ── */} + {/* ── Section 1: Sources ── */}

{t('settings.section.sources')}

{t('settings.section.sourcesDesc')}

diff --git a/frontend/src/types.ts b/frontend/src/types.ts index d847b5b..024f954 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -40,15 +40,11 @@ export interface VerifyResponse { // ---- Settings ---- export interface UserSettings { - theme: string; - 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; - summary_length: number; source_extraction_window: number; search_agent_behavior: string; ai_model: string; @@ -56,19 +52,14 @@ export interface UserSettings { ai_provider: string; rate_limit_max_requests: number | null; rate_limit_time_window_seconds: number | null; - categories: string[]; } export const DEFAULT_SETTINGS: UserSettings = { - theme: 'Intelligence Artificielle', - 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, - summary_length: 3, source_extraction_window: 3, search_agent_behavior: '', ai_model: '', @@ -76,13 +67,6 @@ export const DEFAULT_SETTINGS: UserSettings = { ai_provider: '', rate_limit_max_requests: null, rate_limit_time_window_seconds: null, - categories: [ - 'Annonces majeures', - 'Recherche et innovation', - 'Industrie et entreprises', - 'Secteur public', - 'Opinions et analyses', - ], }; // ---- Sources ---- @@ -147,6 +131,8 @@ export interface SynthesisListItem { first_section_item_count: number; sections_summary: SectionSummary[]; job_id: string | null; + theme_id: string | null; + theme_name: string | null; } export interface GenerateResponse {