feat: multi-theme Phase 1 — settings split, sources/syntheses theme_id, pipeline theme-aware

Remove content settings from settings table (moved to themes).
Add theme_id to sources and syntheses. Pipeline loads content
settings from the selected theme. Generate endpoint requires theme_id.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
master
oabrivard 3 months ago
parent 10b8d950b9
commit 196005a27b

@ -13,16 +13,11 @@ use crate::models::settings::{UpdateSettingsRequest, UserSettings};
#[derive(Debug, sqlx::FromRow)] #[derive(Debug, sqlx::FromRow)]
struct SettingsRow { struct SettingsRow {
user_id: Uuid, user_id: Uuid,
theme: String,
max_age_days: i32,
categories: serde_json::Value,
max_items_per_category: i32,
max_articles_per_source: i32, max_articles_per_source: i32,
max_links_per_source: i32, max_links_per_source: i32,
use_brave_search: bool, use_brave_search: bool,
article_history_days: i32, article_history_days: i32,
batch_size: i32, batch_size: i32,
summary_length: i32,
source_extraction_window: i32, source_extraction_window: i32,
search_agent_behavior: String, search_agent_behavior: String,
ai_provider: String, ai_provider: String,
@ -37,22 +32,13 @@ impl TryFrom<SettingsRow> for UserSettings {
type Error = AppError; type Error = AppError;
fn try_from(row: SettingsRow) -> Result<Self, Self::Error> { fn try_from(row: SettingsRow) -> Result<Self, Self::Error> {
let categories: Vec<String> = serde_json::from_value(row.categories).map_err(|e| {
AppError::Internal(anyhow::anyhow!("Failed to parse categories JSON: {}", e))
})?;
Ok(Self { Ok(Self {
user_id: row.user_id, 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_articles_per_source: row.max_articles_per_source,
max_links_per_source: row.max_links_per_source, max_links_per_source: row.max_links_per_source,
use_brave_search: row.use_brave_search, use_brave_search: row.use_brave_search,
article_history_days: row.article_history_days, article_history_days: row.article_history_days,
batch_size: row.batch_size, batch_size: row.batch_size,
summary_length: row.summary_length,
source_extraction_window: row.source_extraction_window, source_extraction_window: row.source_extraction_window,
search_agent_behavior: row.search_agent_behavior, search_agent_behavior: row.search_agent_behavior,
ai_provider: row.ai_provider, ai_provider: row.ai_provider,
@ -74,23 +60,16 @@ pub async fn get_or_create_default(
user_id: Uuid, user_id: Uuid,
) -> Result<UserSettings, AppError> { ) -> Result<UserSettings, AppError> {
let defaults = UserSettings::default(); 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>( let row = sqlx::query_as::<_, SettingsRow>(
r#" 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) 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, $14, $15, $16, $17, $18) 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 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(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.search_agent_behavior)
.bind(&defaults.ai_provider) .bind(&defaults.ai_provider)
.bind(&defaults.ai_model) .bind(&defaults.ai_model)
@ -102,7 +81,6 @@ pub async fn get_or_create_default(
.bind(defaults.use_brave_search) .bind(defaults.use_brave_search)
.bind(defaults.article_history_days) .bind(defaults.article_history_days)
.bind(defaults.batch_size) .bind(defaults.batch_size)
.bind(defaults.summary_length)
.bind(defaults.source_extraction_window) .bind(defaults.source_extraction_window)
.fetch_one(pool) .fetch_one(pool)
.await?; .await?;
@ -116,19 +94,11 @@ pub async fn upsert(
user_id: Uuid, user_id: Uuid,
req: &UpdateSettingsRequest, req: &UpdateSettingsRequest,
) -> Result<UserSettings, AppError> { ) -> Result<UserSettings, AppError> {
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>( let row = sqlx::query_as::<_, SettingsRow>(
r#" 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) 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, $14, $15, $16, $17, $18) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
ON CONFLICT (user_id) DO UPDATE SET 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, search_agent_behavior = EXCLUDED.search_agent_behavior,
ai_provider = EXCLUDED.ai_provider, ai_provider = EXCLUDED.ai_provider,
ai_model = EXCLUDED.ai_model, ai_model = EXCLUDED.ai_model,
@ -140,17 +110,12 @@ pub async fn upsert(
use_brave_search = EXCLUDED.use_brave_search, use_brave_search = EXCLUDED.use_brave_search,
article_history_days = EXCLUDED.article_history_days, article_history_days = EXCLUDED.article_history_days,
batch_size = EXCLUDED.batch_size, batch_size = EXCLUDED.batch_size,
summary_length = EXCLUDED.summary_length,
source_extraction_window = EXCLUDED.source_extraction_window, source_extraction_window = EXCLUDED.source_extraction_window,
updated_at = now() 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(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.search_agent_behavior)
.bind(&req.ai_provider) .bind(&req.ai_provider)
.bind(&req.ai_model) .bind(&req.ai_model)
@ -162,7 +127,6 @@ pub async fn upsert(
.bind(req.use_brave_search) .bind(req.use_brave_search)
.bind(req.article_history_days) .bind(req.article_history_days)
.bind(req.batch_size) .bind(req.batch_size)
.bind(req.summary_length)
.bind(req.source_extraction_window) .bind(req.source_extraction_window)
.fetch_one(pool) .fetch_one(pool)
.await?; .await?;

@ -10,18 +10,35 @@ use crate::errors::AppError;
use crate::models::source::Source; use crate::models::source::Source;
/// List all sources for a given user, ordered by creation date (newest first). /// 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<Vec<Source>, AppError> { ///
let sources = sqlx::query_as::<_, Source>( /// When `theme_id` is `Some`, filters sources to those belonging to the given theme.
r#" pub async fn list_for_user(pool: &PgPool, user_id: Uuid, theme_id: Option<Uuid>) -> Result<Vec<Source>, AppError> {
SELECT id, user_id, title, url, created_at let sources = if let Some(tid) = theme_id {
FROM sources sqlx::query_as::<_, Source>(
WHERE user_id = $1 r#"
ORDER BY created_at DESC SELECT id, user_id, title, url, theme_id, created_at
"#, FROM sources
) WHERE user_id = $1 AND theme_id = $2
.bind(user_id) ORDER BY created_at DESC
.fetch_all(pool) "#,
.await?; )
.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) Ok(sources)
} }
@ -35,17 +52,19 @@ pub async fn create(
user_id: Uuid, user_id: Uuid,
title: &str, title: &str,
url: &str, url: &str,
theme_id: Option<Uuid>,
) -> Result<Source, AppError> { ) -> Result<Source, AppError> {
let source = sqlx::query_as::<_, Source>( let source = sqlx::query_as::<_, Source>(
r#" r#"
INSERT INTO sources (user_id, title, url) INSERT INTO sources (user_id, title, url, theme_id)
VALUES ($1, $2, $3) VALUES ($1, $2, $3, $4)
RETURNING id, user_id, title, url, created_at RETURNING id, user_id, title, url, theme_id, created_at
"#, "#,
) )
.bind(user_id) .bind(user_id)
.bind(title) .bind(title)
.bind(url) .bind(url)
.bind(theme_id)
.fetch_one(pool) .fetch_one(pool)
.await?; .await?;
@ -79,21 +98,23 @@ pub async fn bulk_create(
pool: &PgPool, pool: &PgPool,
user_id: Uuid, user_id: Uuid,
sources: &[(String, String)], sources: &[(String, String)],
theme_id: Option<Uuid>,
) -> Result<Vec<Source>, AppError> { ) -> Result<Vec<Source>, AppError> {
let mut created = Vec::new(); let mut created = Vec::new();
for (title, url) in sources { for (title, url) in sources {
let result = sqlx::query_as::<_, Source>( let result = sqlx::query_as::<_, Source>(
r#" r#"
INSERT INTO sources (user_id, title, url) INSERT INTO sources (user_id, title, url, theme_id)
VALUES ($1, $2, $3) VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, url) DO NOTHING 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(user_id)
.bind(title.as_str()) .bind(title.as_str())
.bind(url.as_str()) .bind(url.as_str())
.bind(theme_id)
.fetch_optional(pool) .fetch_optional(pool)
.await?; .await?;

@ -9,21 +9,38 @@ use uuid::Uuid;
use crate::errors::AppError; use crate::errors::AppError;
use crate::models::synthesis::Synthesis; 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<chrono::Utc>,
pub job_id: Option<Uuid>,
pub theme_id: Option<Uuid>,
pub theme_name: Option<String>,
}
/// List syntheses for a user, ordered by creation date (newest first). /// List syntheses for a user, ordered by creation date (newest first).
/// ///
/// Supports pagination via `limit` and `offset`. /// Supports pagination via `limit` and `offset`.
/// Includes `theme_name` via LEFT JOIN with the themes table.
pub async fn list_for_user( pub async fn list_for_user(
pool: &PgPool, pool: &PgPool,
user_id: Uuid, user_id: Uuid,
limit: i64, limit: i64,
offset: i64, offset: i64,
) -> Result<Vec<Synthesis>, AppError> { ) -> Result<Vec<SynthesisWithThemeName>, AppError> {
let rows = sqlx::query_as::<_, Synthesis>( let rows = sqlx::query_as::<_, SynthesisWithThemeName>(
r#" r#"
SELECT id, user_id, week, sections, status, created_at, job_id SELECT s.id, s.user_id, s.week, s.sections, s.status, s.created_at, s.job_id, s.theme_id,
FROM syntheses t.name AS theme_name
WHERE user_id = $1 FROM syntheses s
ORDER BY created_at DESC 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 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<Option<Synthesis>, AppError> { pub async fn get_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Synthesis>, AppError> {
let row = sqlx::query_as::<_, Synthesis>( let row = sqlx::query_as::<_, Synthesis>(
r#" 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 FROM syntheses
WHERE id = $1 WHERE id = $1
"#, "#,
@ -64,7 +81,7 @@ pub async fn get_by_id_for_user(
) -> Result<Option<Synthesis>, AppError> { ) -> Result<Option<Synthesis>, AppError> {
let row = sqlx::query_as::<_, Synthesis>( let row = sqlx::query_as::<_, Synthesis>(
r#" 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 FROM syntheses
WHERE id = $1 AND user_id = $2 WHERE id = $1 AND user_id = $2
"#, "#,
@ -87,18 +104,20 @@ pub async fn create(
week: &str, week: &str,
sections_json: &serde_json::Value, sections_json: &serde_json::Value,
job_id: Uuid, job_id: Uuid,
theme_id: Option<Uuid>,
) -> Result<Synthesis, AppError> { ) -> Result<Synthesis, AppError> {
let row = sqlx::query_as::<_, Synthesis>( let row = sqlx::query_as::<_, Synthesis>(
r#" r#"
INSERT INTO syntheses (user_id, week, sections, status, job_id) INSERT INTO syntheses (user_id, week, sections, status, job_id, theme_id)
VALUES ($1, $2, $3, 'completed', $4) VALUES ($1, $2, $3, 'completed', $4, $5)
RETURNING id, user_id, week, sections, status, created_at, job_id RETURNING id, user_id, week, sections, status, created_at, job_id, theme_id
"#, "#,
) )
.bind(user_id) .bind(user_id)
.bind(week) .bind(week)
.bind(sections_json) .bind(sections_json)
.bind(job_id) .bind(job_id)
.bind(theme_id)
.fetch_one(pool) .fetch_one(pool)
.await?; .await?;

@ -17,6 +17,8 @@ use tokio_stream::wrappers::WatchStream;
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
use uuid::Uuid; use uuid::Uuid;
use serde::Deserialize;
use crate::app_state::AppState; use crate::app_state::AppState;
use crate::errors::AppError; use crate::errors::AppError;
use crate::middleware::auth::AuthUser; use crate::middleware::auth::AuthUser;
@ -29,6 +31,12 @@ pub struct GenerateResponse {
pub message: String, 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` /// `POST /api/v1/syntheses/generate`
/// ///
/// Triggers an asynchronous synthesis generation. Returns immediately /// Triggers an asynchronous synthesis generation. Returns immediately
@ -39,6 +47,7 @@ pub struct GenerateResponse {
pub async fn trigger_generate( pub async fn trigger_generate(
auth_user: AuthUser, auth_user: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Json(body): Json<GenerateRequest>,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
// Check if user already has an active job // Check if user already has an active job
if let Some(existing_job_id) = state.job_store.has_active_job(auth_user.id) { 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 // Spawn the generation pipeline as a background task
let state_clone = state.clone(); let state_clone = state.clone();
let user_id = auth_user.id; let user_id = auth_user.id;
let theme_id = body.theme_id;
let tx_for_panic = Arc::clone(&tx); let tx_for_panic = Arc::clone(&tx);
let state_for_panic = state.clone(); let state_for_panic = state.clone();
let join_handle = tokio::spawn(async move { let join_handle = tokio::spawn(async move {
let timeout_duration = std::time::Duration::from_secs(900); 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(()) => {} Ok(()) => {}
Err(_) => { Err(_) => {
tracing::error!(job_id = %job_id, user_id = %user_id, "Generation timed out after 15 minutes"); tracing::error!(job_id = %job_id, user_id = %user_id, "Generation timed out after 15 minutes");

@ -7,10 +7,11 @@
//! - `POST /api/v1/sources/import-csv` — import from CSV file upload //! - `POST /api/v1/sources/import-csv` — import from CSV file upload
//! - `GET /api/v1/sources/export-csv` — download sources as CSV //! - `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::http::StatusCode;
use axum::response::IntoResponse; use axum::response::IntoResponse;
use axum::Json; use axum::Json;
use serde::Deserialize;
use uuid::Uuid; use uuid::Uuid;
use crate::app_state::AppState; use crate::app_state::AppState;
@ -25,15 +26,23 @@ use crate::services::csv as csv_service;
/// Maximum number of sources a user can have. /// Maximum number of sources a user can have.
const MAX_SOURCES_PER_USER: i64 = 100; const MAX_SOURCES_PER_USER: i64 = 100;
/// Query parameters for `GET /api/v1/sources`.
#[derive(Debug, Deserialize)]
pub struct SourceListQuery {
pub theme_id: Option<Uuid>,
}
/// `GET /api/v1/sources` /// `GET /api/v1/sources`
/// ///
/// Returns all sources belonging to the authenticated user, /// Returns all sources belonging to the authenticated user,
/// ordered by creation date (newest first). /// ordered by creation date (newest first).
/// Optionally filters by `theme_id` query parameter.
pub async fn list( pub async fn list(
auth_user: AuthUser, auth_user: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Query(params): Query<SourceListQuery>,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
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<SourceResponse> = sources.into_iter().map(SourceResponse::from).collect(); let response: Vec<SourceResponse> = sources.into_iter().map(SourceResponse::from).collect();
Ok(Json(response)) 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"); tracing::info!(user_id = %auth_user.id, source_id = %source.id, "Source created");
Ok((StatusCode::CREATED, Json(SourceResponse::from(source)))) 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 imported = created.len();
let skipped = valid_sources.len() - imported; let skipped = valid_sources.len() - imported;
@ -236,7 +245,7 @@ pub async fn export_csv(
auth_user: AuthUser, auth_user: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
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); let csv_content = csv_service::generate_csv(&sources);
Ok(( Ok((

@ -52,13 +52,28 @@ pub async fn list(
let limit = params.limit.unwrap_or(20).clamp(1, 100); let limit = params.limit.unwrap_or(20).clamp(1, 100);
let offset = params.offset.unwrap_or(0).max(0); 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?; db::syntheses::list_for_user(&state.pool, auth_user.id, limit, offset).await?;
let items: Vec<SynthesisListItem> = syntheses let items: Vec<SynthesisListItem> = rows
.into_iter() .into_iter()
.map(SynthesisListItem::try_from) .map(|row| {
.collect::<Result<Vec<_>, _>>()?; 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::<Result<Vec<_>, AppError>>()?;
Ok(Json(ListResponse { items })) Ok(Json(ListResponse { items }))
} }

@ -9,17 +9,12 @@ use uuid::Uuid;
pub struct UserSettings { pub struct UserSettings {
#[serde(skip_serializing)] #[serde(skip_serializing)]
pub user_id: Uuid, pub user_id: Uuid,
pub theme: String,
pub max_age_days: i32,
pub categories: Vec<String>,
pub max_items_per_category: i32,
pub max_articles_per_source: i32, pub max_articles_per_source: i32,
pub max_links_per_source: i32, pub max_links_per_source: i32,
pub use_brave_search: bool, pub use_brave_search: bool,
pub article_history_days: i32, pub article_history_days: i32,
pub batch_size: i32, pub batch_size: i32,
pub summary_length: i32,
pub source_extraction_window: i32, pub source_extraction_window: i32,
pub search_agent_behavior: String, pub search_agent_behavior: String,
pub ai_provider: String, pub ai_provider: String,
@ -34,17 +29,12 @@ pub struct UserSettings {
/// Request body for `PUT /api/v1/settings`. /// Request body for `PUT /api/v1/settings`.
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct UpdateSettingsRequest { pub struct UpdateSettingsRequest {
pub theme: String,
pub max_age_days: i32,
pub categories: Vec<String>,
pub max_items_per_category: i32,
pub max_articles_per_source: i32, pub max_articles_per_source: i32,
pub max_links_per_source: i32, pub max_links_per_source: i32,
pub use_brave_search: bool, pub use_brave_search: bool,
pub article_history_days: i32, pub article_history_days: i32,
pub batch_size: i32, pub batch_size: i32,
pub summary_length: i32,
pub source_extraction_window: i32, pub source_extraction_window: i32,
pub search_agent_behavior: String, pub search_agent_behavior: String,
pub ai_provider: String, pub ai_provider: String,
@ -60,35 +50,6 @@ impl UpdateSettingsRequest {
/// Returns `Ok(())` if all fields are within acceptable bounds, /// Returns `Ok(())` if all fields are within acceptable bounds,
/// or `Err(message)` describing the first validation failure. /// or `Err(message)` describing the first validation failure.
pub fn validate(&self) -> Result<(), String> { 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) { if !(1..=10).contains(&self.max_articles_per_source) {
return Err("max_articles_per_source must be between 1 and 10".into()); 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) { if !(1..=20).contains(&self.batch_size) {
return Err("batch_size must be between 1 and 20".into()); 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) { if !(1..=10).contains(&self.source_extraction_window) {
return Err("source_extraction_window must be between 1 and 10".into()); return Err("source_extraction_window must be between 1 and 10".into());
} }
@ -138,23 +96,12 @@ impl Default for UserSettings {
fn default() -> Self { fn default() -> Self {
Self { Self {
user_id: Uuid::nil(), 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_articles_per_source: 3,
max_links_per_source: 8, max_links_per_source: 8,
use_brave_search: false, use_brave_search: false,
article_history_days: 90, article_history_days: 90,
batch_size: 5, batch_size: 5,
summary_length: 3,
source_extraction_window: 3, source_extraction_window: 3,
search_agent_behavior: String::new(), search_agent_behavior: String::new(),
ai_provider: String::new(), ai_provider: String::new(),
@ -174,17 +121,12 @@ mod tests {
/// Helper to create a valid request with all new fields populated. /// Helper to create a valid request with all new fields populated.
fn valid_request() -> UpdateSettingsRequest { fn valid_request() -> UpdateSettingsRequest {
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_articles_per_source: 3,
max_links_per_source: 8, max_links_per_source: 8,
use_brave_search: false, use_brave_search: false,
article_history_days: 90, article_history_days: 90,
batch_size: 5, batch_size: 5,
summary_length: 3,
source_extraction_window: 3, source_extraction_window: 3,
search_agent_behavior: String::new(), search_agent_behavior: String::new(),
ai_provider: String::new(), ai_provider: String::new(),
@ -201,93 +143,6 @@ mod tests {
assert!(req.validate().is_ok()); 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<String> = (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] #[test]
fn test_search_agent_behavior_too_long() { fn test_search_agent_behavior_too_long() {
let req = UpdateSettingsRequest { let req = UpdateSettingsRequest {
@ -300,20 +155,12 @@ mod tests {
#[test] #[test]
fn test_boundary_values_valid() { fn test_boundary_values_valid() {
let req = UpdateSettingsRequest { let req = UpdateSettingsRequest {
theme: "A".into(),
max_age_days: 1,
categories: vec!["Cat".into()],
max_items_per_category: 1,
search_agent_behavior: String::new(), search_agent_behavior: String::new(),
..valid_request() ..valid_request()
}; };
assert!(req.validate().is_ok()); assert!(req.validate().is_ok());
let req = UpdateSettingsRequest { 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), search_agent_behavior: "a".repeat(2000),
..valid_request() ..valid_request()
}; };

@ -14,6 +14,7 @@ pub struct Source {
pub user_id: Uuid, pub user_id: Uuid,
pub title: String, pub title: String,
pub url: String, pub url: String,
pub theme_id: Option<Uuid>,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
} }
@ -23,6 +24,7 @@ pub struct SourceResponse {
pub id: Uuid, pub id: Uuid,
pub title: String, pub title: String,
pub url: String, pub url: String,
pub theme_id: Option<Uuid>,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
} }
@ -32,6 +34,7 @@ impl From<Source> for SourceResponse {
id: s.id, id: s.id,
title: s.title, title: s.title,
url: s.url, url: s.url,
theme_id: s.theme_id,
created_at: s.created_at, created_at: s.created_at,
} }
} }
@ -42,6 +45,8 @@ impl From<Source> for SourceResponse {
pub struct CreateSourceRequest { pub struct CreateSourceRequest {
pub title: String, pub title: String,
pub url: String, pub url: String,
#[serde(default)]
pub theme_id: Option<Uuid>,
} }
impl CreateSourceRequest { impl CreateSourceRequest {
@ -108,6 +113,7 @@ mod tests {
let req = CreateSourceRequest { let req = CreateSourceRequest {
title: "My Blog".into(), title: "My Blog".into(),
url: "https://example.com".into(), url: "https://example.com".into(),
theme_id: None,
}; };
assert!(req.validate().is_ok()); assert!(req.validate().is_ok());
} }
@ -117,6 +123,7 @@ mod tests {
let req = CreateSourceRequest { let req = CreateSourceRequest {
title: " ".into(), title: " ".into(),
url: "https://example.com".into(), url: "https://example.com".into(),
theme_id: None,
}; };
let err = req.validate().unwrap_err(); let err = req.validate().unwrap_err();
assert!(err.contains("Title")); assert!(err.contains("Title"));
@ -127,6 +134,7 @@ mod tests {
let req = CreateSourceRequest { let req = CreateSourceRequest {
title: "a".repeat(201), title: "a".repeat(201),
url: "https://example.com".into(), url: "https://example.com".into(),
theme_id: None,
}; };
let err = req.validate().unwrap_err(); let err = req.validate().unwrap_err();
assert!(err.contains("200")); assert!(err.contains("200"));
@ -137,6 +145,7 @@ mod tests {
let req = CreateSourceRequest { let req = CreateSourceRequest {
title: "Blog".into(), title: "Blog".into(),
url: "".into(), url: "".into(),
theme_id: None,
}; };
let err = req.validate().unwrap_err(); let err = req.validate().unwrap_err();
assert!(err.contains("URL")); assert!(err.contains("URL"));
@ -148,6 +157,7 @@ mod tests {
let req = CreateSourceRequest { let req = CreateSourceRequest {
title: "Blog".into(), title: "Blog".into(),
url: long_url, url: long_url,
theme_id: None,
}; };
let err = req.validate().unwrap_err(); let err = req.validate().unwrap_err();
assert!(err.contains("1000")); assert!(err.contains("1000"));
@ -158,6 +168,7 @@ mod tests {
let req = CreateSourceRequest { let req = CreateSourceRequest {
title: "Blog".into(), title: "Blog".into(),
url: "ftp://example.com".into(), url: "ftp://example.com".into(),
theme_id: None,
}; };
let err = req.validate().unwrap_err(); let err = req.validate().unwrap_err();
assert!(err.contains("http")); assert!(err.contains("http"));
@ -168,6 +179,7 @@ mod tests {
let req = CreateSourceRequest { let req = CreateSourceRequest {
title: "Blog".into(), title: "Blog".into(),
url: "javascript:alert(1)".into(), url: "javascript:alert(1)".into(),
theme_id: None,
}; };
let err = req.validate().unwrap_err(); let err = req.validate().unwrap_err();
assert!(err.contains("http")); assert!(err.contains("http"));
@ -178,6 +190,7 @@ mod tests {
let req = CreateSourceRequest { let req = CreateSourceRequest {
title: "Blog".into(), title: "Blog".into(),
url: "example.com".into(), url: "example.com".into(),
theme_id: None,
}; };
let err = req.validate().unwrap_err(); let err = req.validate().unwrap_err();
assert!(err.contains("http")); assert!(err.contains("http"));
@ -188,6 +201,7 @@ mod tests {
let req = CreateSourceRequest { let req = CreateSourceRequest {
title: "Blog".into(), title: "Blog".into(),
url: "http://example.com".into(), url: "http://example.com".into(),
theme_id: None,
}; };
assert!(req.validate().is_ok()); assert!(req.validate().is_ok());
} }
@ -197,6 +211,7 @@ mod tests {
let req = CreateSourceRequest { let req = CreateSourceRequest {
title: "Blog".into(), title: "Blog".into(),
url: "https://example.com/path?query=1".into(), url: "https://example.com/path?query=1".into(),
theme_id: None,
}; };
assert!(req.validate().is_ok()); assert!(req.validate().is_ok());
} }
@ -206,6 +221,7 @@ mod tests {
let req = CreateSourceRequest { let req = CreateSourceRequest {
title: "a".repeat(200), title: "a".repeat(200),
url: "https://example.com".into(), url: "https://example.com".into(),
theme_id: None,
}; };
assert!(req.validate().is_ok()); assert!(req.validate().is_ok());
} }
@ -217,6 +233,7 @@ mod tests {
let req = CreateSourceRequest { let req = CreateSourceRequest {
title: "Blog".into(), title: "Blog".into(),
url, url,
theme_id: None,
}; };
assert!(req.validate().is_ok()); assert!(req.validate().is_ok());
} }

@ -35,6 +35,7 @@ pub struct Synthesis {
pub status: String, pub status: String,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub job_id: Option<Uuid>, pub job_id: Option<Uuid>,
pub theme_id: Option<Uuid>,
} }
/// Response shape for `GET /api/v1/syntheses/:id`. /// Response shape for `GET /api/v1/syntheses/:id`.
@ -86,6 +87,8 @@ pub struct SynthesisListItem {
pub first_section_item_count: usize, pub first_section_item_count: usize,
pub sections_summary: Vec<SectionSummary>, pub sections_summary: Vec<SectionSummary>,
pub job_id: Option<Uuid>, pub job_id: Option<Uuid>,
pub theme_id: Option<Uuid>,
pub theme_name: Option<String>,
} }
/// Summary of a section for the synthesis list view. /// Summary of a section for the synthesis list view.
@ -123,6 +126,8 @@ impl TryFrom<Synthesis> for SynthesisListItem {
first_section_item_count, first_section_item_count,
sections_summary, sections_summary,
job_id: s.job_id, job_id: s.job_id,
theme_id: s.theme_id,
theme_name: None,
}) })
} }
} }
@ -282,6 +287,7 @@ mod tests {
status: "completed".into(), status: "completed".into(),
created_at: Utc::now(), created_at: Utc::now(),
job_id: None, job_id: None,
theme_id: None,
}; };
let list_item = SynthesisListItem::try_from(synthesis).unwrap(); let list_item = SynthesisListItem::try_from(synthesis).unwrap();
@ -299,6 +305,7 @@ mod tests {
status: "completed".into(), status: "completed".into(),
created_at: Utc::now(), created_at: Utc::now(),
job_id: None, job_id: None,
theme_id: None,
}; };
let list_item = SynthesisListItem::try_from(synthesis).unwrap(); let list_item = SynthesisListItem::try_from(synthesis).unwrap();
@ -325,6 +332,7 @@ mod tests {
status: "completed".into(), status: "completed".into(),
created_at: Utc::now(), created_at: Utc::now(),
job_id: None, job_id: None,
theme_id: None,
}; };
let response = SynthesisResponse::try_from(synthesis).unwrap(); let response = SynthesisResponse::try_from(synthesis).unwrap();
@ -343,6 +351,7 @@ mod tests {
status: "completed".into(), status: "completed".into(),
created_at: Utc::now(), created_at: Utc::now(),
job_id: None, job_id: None,
theme_id: None,
}; };
assert!(SynthesisResponse::try_from(synthesis).is_err()); assert!(SynthesisResponse::try_from(synthesis).is_err());
@ -358,6 +367,7 @@ mod tests {
status: "completed".into(), status: "completed".into(),
created_at: Utc::now(), created_at: Utc::now(),
job_id: None, job_id: None,
theme_id: None,
}; };
let response = SynthesisResponse::try_from(synthesis).unwrap(); let response = SynthesisResponse::try_from(synthesis).unwrap();

@ -259,6 +259,7 @@ mod tests {
user_id: Uuid::new_v4(), user_id: Uuid::new_v4(),
title: "My Blog".into(), title: "My Blog".into(),
url: "https://blog.example.com".into(), url: "https://blog.example.com".into(),
theme_id: None,
created_at: Utc::now(), created_at: Utc::now(),
}, },
Source { Source {
@ -266,6 +267,7 @@ mod tests {
user_id: Uuid::new_v4(), user_id: Uuid::new_v4(),
title: "News".into(), title: "News".into(),
url: "https://news.example.com".into(), url: "https://news.example.com".into(),
theme_id: None,
created_at: Utc::now(), created_at: Utc::now(),
}, },
]; ];
@ -284,6 +286,7 @@ mod tests {
user_id: Uuid::new_v4(), user_id: Uuid::new_v4(),
title: "Blog, with commas".into(), title: "Blog, with commas".into(),
url: "https://example.com".into(), url: "https://example.com".into(),
theme_id: None,
created_at: Utc::now(), created_at: Utc::now(),
}]; }];
@ -306,6 +309,7 @@ mod tests {
user_id: Uuid::new_v4(), user_id: Uuid::new_v4(),
title: "Simple Blog".into(), title: "Simple Blog".into(),
url: "https://blog.example.com".into(), url: "https://blog.example.com".into(),
theme_id: None,
created_at: Utc::now(), created_at: Utc::now(),
}, },
Source { Source {
@ -313,6 +317,7 @@ mod tests {
user_id: Uuid::new_v4(), user_id: Uuid::new_v4(),
title: "News, Quotes \"here\"".into(), title: "News, Quotes \"here\"".into(),
url: "https://news.example.com".into(), url: "https://news.example.com".into(),
theme_id: None,
created_at: Utc::now(), created_at: Utc::now(),
}, },
]; ];

@ -6,7 +6,6 @@
//! //!
//! Prompts are provider-agnostic and parameterized by user settings. //! Prompts are provider-agnostic and parameterized by user settings.
use crate::models::settings::UserSettings;
use crate::models::source::Source; use crate::models::source::Source;
/// Build the system prompt and user prompt for the search pass (Pass 1). /// 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. /// matching the user's theme and categories, using web search grounding.
/// ///
/// # Arguments /// # 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 /// * `sources` — User's custom sources to prioritize
/// * `current_date` — Formatted date string for the prompt /// * `current_date` — Formatted date string for the prompt
/// * `recent_domains` — Domains used in recent syntheses to avoid if possible /// * `recent_domains` — Domains used in recent syntheses to avoid if possible
#[allow(clippy::too_many_arguments)]
pub fn build_search_prompt( 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], sources: &[Source],
current_date: &str, current_date: &str,
recent_domains: &[String], recent_domains: &[String],
@ -41,26 +49,25 @@ pub fn build_search_prompt(
) )
}; };
let categories_text = settings let categories_text = categories
.categories
.iter() .iter()
.enumerate() .enumerate()
.map(|(i, cat)| format!("{}. {}", i + 1, cat)) .map(|(i, cat)| format!("{}. {}", i + 1, cat))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n"); .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." "Tu peux egalement utiliser d'autres sources pertinentes trouvees via la recherche Google."
.to_string() .to_string()
} else { } else {
settings.search_agent_behavior.clone() search_agent_behavior.to_string()
}; };
let system_prompt = format!( let system_prompt = format!(
"Tu es un assistant IA precis. Tu dois TOUJOURS fournir des URLs completes et exactes. \ "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 {} \ Ne tronque jamais les URLs. Tu dois te concentrer UNIQUEMENT sur les actualites des {} \
derniers jours.", derniers jours.",
settings.max_age_days max_age_days
); );
let user_prompt = format!( 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. \ Retourne le resultat au format JSON en utilisant les cles category_0, category_1, etc. \
correspondant a l'ordre des sections ci-dessus.", correspondant a l'ordre des sections ci-dessus.",
date = current_date, date = current_date,
theme = settings.theme, theme = theme,
days = settings.max_age_days, days = max_age_days,
sources = sources_text, sources = sources_text,
behavior = behavior, behavior = behavior,
count = settings.categories.len(), count = categories.len(),
categories = categories_text, categories = categories_text,
max_items = settings.max_items_per_category, max_items = max_items_per_category,
); );
let user_prompt = if recent_domains.is_empty() { let user_prompt = if recent_domains.is_empty() {
@ -109,7 +116,7 @@ pub fn build_search_prompt(
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n"); .join("\n");
user_prompt.replace( 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), &format!("Fournis le nombre d'articles suivant par categorie :\n{}", gaps_text),
) )
} else { } else {
@ -172,25 +179,18 @@ pub fn build_article_classify_prompt(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::models::settings::UserSettings;
use chrono::Utc; use chrono::Utc;
use uuid::Uuid; use uuid::Uuid;
fn test_settings() -> UserSettings { fn test_settings() -> UserSettings {
UserSettings { UserSettings {
user_id: Uuid::nil(), 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_articles_per_source: 3,
max_links_per_source: 8, max_links_per_source: 8,
use_brave_search: false, use_brave_search: false,
article_history_days: 90, article_history_days: 90,
batch_size: 5, batch_size: 5,
summary_length: 3,
source_extraction_window: 3, source_extraction_window: 3,
search_agent_behavior: String::new(), search_agent_behavior: String::new(),
ai_provider: 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<String> {
vec![
"Annonces majeures".to_string(),
"Recherche et innovation".to_string(),
]
}
const TEST_MAX_ITEMS: i32 = 4;
const TEST_MAX_AGE: i32 = 7;
#[test] #[test]
fn search_prompt_includes_theme() { fn search_prompt_includes_theme() {
let settings = test_settings(); let cats = test_categories();
let (_, user_prompt) = build_search_prompt(&settings, &[], "lundi 21 mars 2026", &[], None); 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")); assert!(user_prompt.contains("Intelligence Artificielle"));
} }
#[test] #[test]
fn search_prompt_includes_date() { fn search_prompt_includes_date() {
let settings = test_settings(); let cats = test_categories();
let (_, user_prompt) = build_search_prompt(&settings, &[], "lundi 21 mars 2026", &[], None); 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")); assert!(user_prompt.contains("lundi 21 mars 2026"));
} }
#[test] #[test]
fn search_prompt_includes_max_age() { fn search_prompt_includes_max_age() {
let settings = test_settings(); let cats = test_categories();
let (system, user_prompt) = build_search_prompt(&settings, &[], "lundi 21 mars 2026", &[], None); 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!(user_prompt.contains("7 derniers jours"));
assert!(system.contains("7")); assert!(system.contains("7"));
} }
#[test] #[test]
fn search_prompt_includes_categories() { fn search_prompt_includes_categories() {
let settings = test_settings(); let cats = test_categories();
let (_, user_prompt) = build_search_prompt(&settings, &[], "lundi 21 mars 2026", &[], None); 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("1. Annonces majeures"));
assert!(user_prompt.contains("2. Recherche et innovation")); assert!(user_prompt.contains("2. Recherche et innovation"));
assert!(user_prompt.contains("2 grandes sections")); assert!(user_prompt.contains("2 grandes sections"));
@ -235,14 +248,14 @@ mod tests {
#[test] #[test]
fn search_prompt_includes_max_items() { fn search_prompt_includes_max_items() {
let settings = test_settings(); let cats = test_categories();
let (_, user_prompt) = build_search_prompt(&settings, &[], "lundi 21 mars 2026", &[], None); 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")); assert!(user_prompt.contains("4 actualites"));
} }
#[test] #[test]
fn search_prompt_includes_custom_sources() { fn search_prompt_includes_custom_sources() {
let settings = test_settings(); let cats = test_categories();
let sources = vec![ let sources = vec![
Source { Source {
id: Uuid::nil(), id: Uuid::nil(),
@ -250,6 +263,7 @@ mod tests {
title: "TechCrunch".into(), title: "TechCrunch".into(),
url: "https://techcrunch.com".into(), url: "https://techcrunch.com".into(),
created_at: Utc::now(), created_at: Utc::now(),
theme_id: None,
}, },
Source { Source {
id: Uuid::nil(), id: Uuid::nil(),
@ -257,10 +271,11 @@ mod tests {
title: "The Verge".into(), title: "The Verge".into(),
url: "https://theverge.com".into(), url: "https://theverge.com".into(),
created_at: Utc::now(), 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("TechCrunch (https://techcrunch.com)"));
assert!(user_prompt.contains("The Verge (https://theverge.com)")); assert!(user_prompt.contains("The Verge (https://theverge.com)"));
assert!(user_prompt.contains("sources personnalisees")); assert!(user_prompt.contains("sources personnalisees"));
@ -268,44 +283,40 @@ mod tests {
#[test] #[test]
fn search_prompt_no_sources_no_section() { fn search_prompt_no_sources_no_section() {
let settings = test_settings(); let cats = test_categories();
let (_, user_prompt) = build_search_prompt(&settings, &[], "lundi 21 mars 2026", &[], None); 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")); assert!(!user_prompt.contains("sources personnalisees"));
} }
#[test] #[test]
fn search_prompt_custom_behavior() { fn search_prompt_custom_behavior() {
let mut settings = test_settings(); let cats = test_categories();
settings.search_agent_behavior = 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);
"Concentre-toi sur les sources europeennes.".to_string();
let (_, user_prompt) = build_search_prompt(&settings, &[], "lundi 21 mars 2026", &[], None);
assert!(user_prompt.contains("Concentre-toi sur les sources europeennes.")); assert!(user_prompt.contains("Concentre-toi sur les sources europeennes."));
assert!(!user_prompt.contains("recherche Google")); assert!(!user_prompt.contains("recherche Google"));
} }
#[test] #[test]
fn search_prompt_default_behavior_when_empty() { fn search_prompt_default_behavior_when_empty() {
let settings = test_settings(); let cats = test_categories();
let (_, user_prompt) = build_search_prompt(&settings, &[], "lundi 21 mars 2026", &[], None); 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")); assert!(user_prompt.contains("recherche Google"));
} }
#[test] #[test]
fn search_prompt_warns_against_homepage_urls() { fn search_prompt_warns_against_homepage_urls() {
let settings = test_settings(); let cats = test_categories();
let (_, user_prompt) = build_search_prompt(&settings, &[], "lundi 21 mars 2026", &[], None); 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("pages d'accueil"));
assert!(user_prompt.contains("articles specifiques")); assert!(user_prompt.contains("articles specifiques"));
} }
#[test] #[test]
fn search_prompt_includes_recent_domains_avoidance() { fn search_prompt_includes_recent_domains_avoidance() {
let settings = test_settings(); let cats = test_categories();
let sources = vec![];
let date = "lundi 17 mars 2026"; let date = "lundi 17 mars 2026";
let domains = vec!["techcrunch.com".to_string(), "theverge.com".to_string()]; 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("Evite si possible"));
assert!(user_prompt.contains("techcrunch.com")); assert!(user_prompt.contains("techcrunch.com"));
assert!(user_prompt.contains("theverge.com")); assert!(user_prompt.contains("theverge.com"));
@ -313,23 +324,21 @@ mod tests {
#[test] #[test]
fn search_prompt_no_avoidance_when_domains_empty() { fn search_prompt_no_avoidance_when_domains_empty() {
let settings = test_settings(); let cats = test_categories();
let sources = vec![];
let date = "lundi 17 mars 2026"; 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")); assert!(!user_prompt.contains("Evite si possible"));
} }
#[test] #[test]
fn search_prompt_with_category_gaps() { fn search_prompt_with_category_gaps() {
let settings = test_settings(); let cats = test_categories();
let sources = vec![];
let date = "lundi 17 mars 2026"; let date = "lundi 17 mars 2026";
let gaps = vec![ let gaps = vec![
("AI News".to_string(), 2), ("AI News".to_string(), 2),
("Cybersecurity".to_string(), 4), ("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("AI News : 2 articles"));
assert!(user_prompt.contains("Cybersecurity : 4 articles")); assert!(user_prompt.contains("Cybersecurity : 4 articles"));
assert!(!user_prompt.contains("exactement")); assert!(!user_prompt.contains("exactement"));
@ -337,10 +346,9 @@ mod tests {
#[test] #[test]
fn search_prompt_without_gaps_uses_default() { fn search_prompt_without_gaps_uses_default() {
let settings = test_settings(); let cats = test_categories();
let sources = vec![];
let date = "lundi 17 mars 2026"; 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")); assert!(user_prompt.contains("exactement"));
} }

@ -193,10 +193,11 @@ pub async fn run_generation(
job_id: Uuid, job_id: Uuid,
state: AppState, state: AppState,
user_id: Uuid, user_id: Uuid,
theme_id: Uuid,
tx: Arc<watch::Sender<ProgressEvent>>, tx: Arc<watch::Sender<ProgressEvent>>,
provider_override: Option<Arc<dyn crate::services::llm::LlmProvider>>, provider_override: Option<Arc<dyn crate::services::llm::LlmProvider>>,
) { ) {
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 { match result {
Ok(synthesis_id) => { Ok(synthesis_id) => {
@ -229,6 +230,7 @@ pub async fn run_generation_inner(
job_id: Uuid, job_id: Uuid,
state: &AppState, state: &AppState,
user_id: Uuid, user_id: Uuid,
theme_id: Uuid,
tx: &watch::Sender<ProgressEvent>, tx: &watch::Sender<ProgressEvent>,
provider_override: Option<Arc<dyn crate::services::llm::LlmProvider>>, provider_override: Option<Arc<dyn crate::services::llm::LlmProvider>>,
) -> Result<Uuid, AppError> { ) -> Result<Uuid, AppError> {
@ -239,21 +241,26 @@ pub async fn run_generation_inner(
emit_progress(tx, "sources", "Chargement des parametres...", 5); emit_progress(tx, "sources", "Chargement des parametres...", 5);
let settings = db::settings::get_or_create_default(&state.pool, user_id).await?; 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<String> = serde_json::from_value(theme.categories).unwrap_or_default();
if settings.article_history_days > 0 { if settings.article_history_days > 0 {
db::article_history::cleanup_old(&state.pool, user_id, settings.article_history_days).await.unwrap_or(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(); 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() Vec::new()
} else { } else {
settings.categories.clone() theme_categories.clone()
}; };
let mut classification_categories = user_categories.clone(); let mut classification_categories = user_categories.clone();
classification_categories.push("Divers".to_string()); classification_categories.push("Divers".to_string());
emit_progress(tx, "sources", "Chargement des sources...", 10); 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); emit_progress(tx, "sources", "Configuration du fournisseur IA...", 12);
let (provider_name, provider) = if let Some(mock_provider) = provider_override { 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<String, String> = HashMap::new(); let mut url_source: HashMap<String, String> = HashMap::new();
let mut filled_counts: HashMap<String, usize> = HashMap::new(); let mut filled_counts: HashMap<String, usize> = HashMap::new();
let mut seen_urls: std::collections::HashSet<String> = std::collections::HashSet::new(); let mut seen_urls: std::collections::HashSet<String> = 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 classify_schema = Arc::new(crate::services::llm::schema::build_article_classify_schema());
let model_research = Arc::new(model_research); let model_research = Arc::new(model_research);
let classification_categories = Arc::new(classification_categories); let classification_categories = Arc::new(classification_categories);
@ -379,7 +386,7 @@ pub async fn run_generation_inner(
if !wave_urls.is_empty() { if !wave_urls.is_empty() {
let total_candidates = wave_urls.len(); let total_candidates = wave_urls.len();
let batch_size = settings.batch_size.max(1) as usize; 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 processed = 0usize;
let mut candidates_iter = wave_urls.into_iter(); let mut candidates_iter = wave_urls.into_iter();
let mut done = false; let mut done = false;
@ -419,7 +426,7 @@ pub async fn run_generation_inner(
let client = state.http_client.clone(); let client = state.http_client.clone();
let u = url.clone(); let u = url.clone();
let su = source_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 { scrape_set.spawn(async move {
let result = scrape_single_article(&client, &u, mad).await; let result = scrape_single_article(&client, &u, mad).await;
(u, su, result) (u, su, result)
@ -464,7 +471,7 @@ pub async fn run_generation_inner(
let uid = user_id; let uid = user_id;
let jid = job_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 { classify_set.spawn(async move {
let llm_start = std::time::Instant::now(); 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 let Some(date_str) = class_response.get("date").and_then(|d| d.as_str()) {
if !date_str.is_empty() { if !date_str.is_empty() {
if let Some(parsed) = scraper::parse_date_string(date_str) { 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)"); 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 { pending_traces.push(build_trace_entry(user_id, job_id, &ArticleTrace {
url: &final_url, title: &page_title, source_type: "personalized_source", 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( let Some((final_cat_key, final_cat_name, llm_title, llm_summary)) = assign_category(
&class_response, &page_title, &user_categories, &classification_categories, &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 { ) else {
continue; continue;
}; };
@ -588,7 +595,7 @@ pub async fn run_generation_inner(
// === PHASE 2: Web Search Fallback === // === PHASE 2: Web Search Fallback ===
let category_gaps: Vec<(String, i32)> = user_categories.iter().filter_map(|cat| { let category_gaps: Vec<(String, i32)> = user_categories.iter().filter_map(|cat| {
let filled = filled_counts.get(cat).copied().unwrap_or(0); 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 } if needed > 0 { Some((cat.clone(), needed as i32)) } else { None }
}).collect(); }).collect();
@ -598,9 +605,9 @@ pub async fn run_generation_inner(
emit_progress(tx, "websearch", "Recherche Brave Search...", 70); emit_progress(tx, "websearch", "Recherche Brave Search...", 70);
let brave_key = resolve_brave_key(state, user_id).await?; 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( 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?; ).await?;
tracing::info!(results = brave_results.len(), "Brave Search returned results"); tracing::info!(results = brave_results.len(), "Brave Search returned results");
@ -658,7 +665,7 @@ pub async fn run_generation_inner(
for url in &batch { for url in &batch {
let client = state.http_client.clone(); let client = state.http_client.clone();
let u = url.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 { scrape_set.spawn(async move {
let result = scrape_single_article(&client, &u, mad).await; let result = scrape_single_article(&client, &u, mad).await;
(u, result) (u, result)
@ -695,7 +702,7 @@ pub async fn run_generation_inner(
let model = Arc::clone(&model_research); let model = Arc::clone(&model_research);
let schema = Arc::clone(&classify_schema); let schema = Arc::clone(&classify_schema);
let cats = Arc::clone(&classification_categories); let cats = Arc::clone(&classification_categories);
let snippet_size = match settings.summary_length { let snippet_size = match theme.summary_length {
1 => 500, 1 => 500,
2 => 2000, 2 => 2000,
_ => 4000, _ => 4000,
@ -707,7 +714,7 @@ pub async fn run_generation_inner(
let uid = user_id; let uid = user_id;
let jid = job_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 { classify_set.spawn(async move {
let llm_start = std::time::Instant::now(); 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 let Some(date_str) = class_response.get("date").and_then(|d| d.as_str()) {
if !date_str.is_empty() { if !date_str.is_empty() {
if let Some(parsed) = scraper::parse_date_string(date_str) { 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)"); 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 { pending_traces.push(build_trace_entry(user_id, job_id, &ArticleTrace {
url: &final_url, title: &page_title, source_type: "brave_search", 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( let Some((final_cat_key, final_cat_name, llm_title, llm_summary)) = assign_category(
&class_response, &page_title, &user_categories, &classification_categories, &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 { ) else {
continue; continue;
}; };
@ -823,9 +830,9 @@ pub async fn run_generation_inner(
emit_progress(tx, "websearch", "Recherche d'actualites...", 70); emit_progress(tx, "websearch", "Recherche d'actualites...", 70);
check_rate_limit(state, &user_rate_limiter, &provider_name).await?; 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 current_date = Utc::now().format("%A %d %B %Y").to_string();
let (sys_prompt, usr_prompt) = crate::services::prompts::build_search_prompt(&settings, &[], &current_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, &[], &current_date, &[], Some(&category_gaps));
let llm_start = std::time::Instant::now(); let llm_start = std::time::Instant::now();
let raw_results = provider.call_llm(&model_websearch, &sys_prompt, &usr_prompt, &search_schema).await?; 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 // Scrape Phase 2 for validation
emit_progress(tx, "websearch", "Verification des sources...", 80); emit_progress(tx, "websearch", "Verification des sources...", 80);
for (cat_key, item) in phase2_items { 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 { if let Some(reason) = drop_reason {
pending_traces.push(build_trace_entry(user_id, job_id, &ArticleTrace { 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 = 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 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()), &sections_json, job_id).await?; let synthesis = db::syntheses::create(&state.pool, user_id, &get_iso_week_string(Utc::now().date_naive()), &sections_json, job_id, Some(theme_id)).await?;
if settings.article_history_days > 0 { if settings.article_history_days > 0 {
for section in &final_sections { for section in &final_sections {
@ -1805,8 +1812,8 @@ mod tests {
#[test] #[test]
fn rotate_sources_no_last_url() { fn rotate_sources_no_last_url() {
let sources = vec![ 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() },
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: "B".into(), url: "https://b.com".into(), theme_id: None, created_at: chrono::Utc::now() },
]; ];
let result = rotate_sources(sources.clone(), None); let result = rotate_sources(sources.clone(), None);
assert_eq!(result.len(), 2); assert_eq!(result.len(), 2);
@ -1816,9 +1823,9 @@ mod tests {
#[test] #[test]
fn rotate_sources_with_last_url() { fn rotate_sources_with_last_url() {
let sources = vec![ 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() },
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: "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(), 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")); let result = rotate_sources(sources, Some("https://a.com"));
assert_eq!(result[0].url, "https://b.com"); assert_eq!(result[0].url, "https://b.com");
@ -1829,7 +1836,7 @@ mod tests {
#[test] #[test]
fn rotate_sources_last_url_not_found() { fn rotate_sources_last_url_not_found() {
let sources = vec![ 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")); let result = rotate_sources(sources.clone(), Some("https://notfound.com"));
assert_eq!(result[0].url, "https://a.com"); assert_eq!(result[0].url, "https://a.com");

@ -13,7 +13,7 @@ fn require_test_db() -> bool {
std::env::var("TEST_DATABASE_URL").is_ok() std::env::var("TEST_DATABASE_URL").is_ok()
} }
// ── Auth requirement ───────────────────────────────────────────────────── // -- Auth requirement ---------------------------------------------------------
#[tokio::test] #[tokio::test]
async fn get_settings_without_auth_returns_401() { 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 app = common::TestApp::new().await;
let body = serde_json::json!({ let body = serde_json::json!({
"theme": "Test",
"max_age_days": 7,
"categories": ["Cat"],
"max_items_per_category": 4,
"max_articles_per_source": 3, "max_articles_per_source": 3,
"max_links_per_source": 8, "max_links_per_source": 8,
"use_brave_search": false, "use_brave_search": false,
"article_history_days": 90, "article_history_days": 90,
"batch_size": 5, "batch_size": 5,
"summary_length": 3,
"source_extraction_window": 3, "source_extraction_window": 3,
"search_agent_behavior": "", "search_agent_behavior": "",
"ai_provider": "", "ai_provider": "",
@ -79,7 +73,7 @@ async fn put_settings_without_auth_returns_401() {
); );
} }
// ── Default settings ───────────────────────────────────────────────────── // -- Default settings ---------------------------------------------------------
#[tokio::test] #[tokio::test]
async fn get_settings_returns_defaults_on_first_access() { 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!(status, StatusCode::OK, "GET /settings should return 200");
assert_eq!( assert_eq!(
body["theme"], "Intelligence Artificielle", body["max_articles_per_source"], 3,
"Default theme should be 'Intelligence Artificielle'" "Default max_articles_per_source should be 3"
);
assert_eq!(
body["max_age_days"], 7,
"Default max_age_days should be 7"
); );
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] #[tokio::test]
async fn put_settings_with_valid_data_returns_200() { async fn put_settings_with_valid_data_returns_200() {
@ -130,17 +111,11 @@ async fn put_settings_with_valid_data_returns_200() {
.await; .await;
let update = serde_json::json!({ 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_articles_per_source": 3,
"max_links_per_source": 8, "max_links_per_source": 8,
"use_brave_search": false, "use_brave_search": false,
"article_history_days": 90, "article_history_days": 90,
"batch_size": 5, "batch_size": 5,
"summary_length": 3,
"source_extraction_window": 3, "source_extraction_window": 3,
"search_agent_behavior": "Focus on CVEs", "search_agent_behavior": "Focus on CVEs",
"ai_provider": "", "ai_provider": "",
@ -159,16 +134,7 @@ async fn put_settings_with_valid_data_returns_200() {
StatusCode::OK, StatusCode::OK,
"PUT /settings with valid data should return 200" "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"); 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] #[tokio::test]
@ -189,17 +155,11 @@ async fn put_then_get_returns_updated_data() {
// Update // Update
let update = serde_json::json!({ 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_articles_per_source": 3,
"max_links_per_source": 8, "max_links_per_source": 8,
"use_brave_search": false, "use_brave_search": false,
"article_history_days": 90, "article_history_days": 90,
"batch_size": 5, "batch_size": 5,
"summary_length": 3,
"source_extraction_window": 3, "source_extraction_window": 3,
"search_agent_behavior": "Francophone sources", "search_agent_behavior": "Francophone sources",
"ai_provider": "", "ai_provider": "",
@ -213,271 +173,13 @@ async fn put_then_get_returns_updated_data() {
.await; .await;
assert_eq!(put_status, StatusCode::OK); 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; let (get_status, body) = app.get_with_session("/api/v1/settings", &session).await;
assert_eq!(get_status, StatusCode::OK); 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"); 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 ──────────────────────────────────────────────────── // -- Per-user isolation -------------------------------------------------------
#[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<String> = (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 ───────────────────────────────────────────────────
#[tokio::test] #[tokio::test]
async fn settings_are_per_user_isolated() { async fn settings_are_per_user_isolated() {
@ -498,17 +200,11 @@ async fn settings_are_per_user_isolated() {
// User A updates their settings // User A updates their settings
let update_a = serde_json::json!({ 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_articles_per_source": 3,
"max_links_per_source": 8, "max_links_per_source": 8,
"use_brave_search": false, "use_brave_search": false,
"article_history_days": 90, "article_history_days": 90,
"batch_size": 5, "batch_size": 5,
"summary_length": 3,
"source_extraction_window": 3, "source_extraction_window": 3,
"search_agent_behavior": "User A behavior", "search_agent_behavior": "User A behavior",
"ai_provider": "", "ai_provider": "",
@ -524,17 +220,11 @@ async fn settings_are_per_user_isolated() {
// User B updates their settings differently // User B updates their settings differently
let update_b = serde_json::json!({ 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_articles_per_source": 3,
"max_links_per_source": 8, "max_links_per_source": 8,
"use_brave_search": false, "use_brave_search": false,
"article_history_days": 90, "article_history_days": 90,
"batch_size": 5, "batch_size": 5,
"summary_length": 3,
"source_extraction_window": 3, "source_extraction_window": 3,
"search_agent_behavior": "User B behavior", "search_agent_behavior": "User B behavior",
"ai_provider": "", "ai_provider": "",
@ -550,22 +240,14 @@ async fn settings_are_per_user_isolated() {
// Verify User A sees only their settings // Verify User A sees only their settings
let (_, body_a) = app.get_with_session("/api/v1/settings", &session_a).await; 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["search_agent_behavior"], "User A behavior");
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");
// Verify User B sees only their settings // Verify User B sees only their settings
let (_, body_b) = app.get_with_session("/api/v1/settings", &session_b).await; 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["search_agent_behavior"], "User B behavior");
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");
} }
// ── Boundary values ───────────────────────────────────────────────────── // -- Boundary values ----------------------------------------------------------
#[tokio::test] #[tokio::test]
async fn put_settings_boundary_values_succeed() { async fn put_settings_boundary_values_succeed() {
@ -581,17 +263,11 @@ async fn put_settings_boundary_values_succeed() {
// Minimum valid values // Minimum valid values
let update_min = serde_json::json!({ let update_min = serde_json::json!({
"theme": "A", "max_articles_per_source": 1,
"max_age_days": 1, "max_links_per_source": 1,
"categories": ["C"],
"max_items_per_category": 1,
"max_articles_per_source": 3,
"max_links_per_source": 8,
"use_brave_search": false, "use_brave_search": false,
"article_history_days": 90, "article_history_days": 0,
"batch_size": 5, "batch_size": 1,
"summary_length": 1,
"source_extraction_window": 1, "source_extraction_window": 1,
"search_agent_behavior": "", "search_agent_behavior": "",
"ai_provider": "", "ai_provider": "",
@ -606,19 +282,12 @@ async fn put_settings_boundary_values_succeed() {
assert_eq!(status, StatusCode::OK, "Minimum boundary values should be accepted"); assert_eq!(status, StatusCode::OK, "Minimum boundary values should be accepted");
// Maximum valid values // Maximum valid values
let categories_max: Vec<String> = (0..20).map(|i| format!("Cat {}", i)).collect();
let update_max = serde_json::json!({ let update_max = serde_json::json!({
"theme": "a".repeat(200), "max_articles_per_source": 10,
"max_age_days": 365, "max_links_per_source": 30,
"categories": categories_max, "use_brave_search": true,
"max_items_per_category": 50, "article_history_days": 365,
"max_articles_per_source": 3, "batch_size": 20,
"max_links_per_source": 8,
"use_brave_search": false,
"article_history_days": 90,
"batch_size": 5,
"summary_length": 3,
"source_extraction_window": 10, "source_extraction_window": 10,
"search_agent_behavior": "a".repeat(2000), "search_agent_behavior": "a".repeat(2000),
"ai_provider": "", "ai_provider": "",

@ -436,7 +436,7 @@ impl TestApp {
week: &str, week: &str,
sections_json: &serde_json::Value, sections_json: &serde_json::Value,
) -> uuid::Uuid { ) -> 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 .await
.expect("Failed to insert test synthesis"); .expect("Failed to insert test synthesis");
row.id row.id

@ -45,22 +45,16 @@ async fn setup_user_with_settings(
app: &common::TestApp, app: &common::TestApp,
categories: Vec<&str>, categories: Vec<&str>,
max_items: i32, max_items: i32,
) -> (uuid::Uuid, String) { ) -> (uuid::Uuid, String, uuid::Uuid) {
let email = format!("pipeline-{}@test.com", uuid::Uuid::new_v4()); let email = format!("pipeline-{}@test.com", uuid::Uuid::new_v4());
let (user_id, session) = app.create_authenticated_user(&email).await; let (user_id, session) = app.create_authenticated_user(&email).await;
let categories_json: Vec<serde_json::Value> = categories.iter().map(|c| serde_json::json!(c)).collect();
let settings = serde_json::json!({ 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_articles_per_source": 10,
"max_links_per_source": 8, "max_links_per_source": 8,
"use_brave_search": false, "use_brave_search": false,
"article_history_days": 90, "article_history_days": 90,
"batch_size": 5, "batch_size": 5,
"summary_length": 3,
"source_extraction_window": 3, "source_extraction_window": 3,
"search_agent_behavior": "", "search_agent_behavior": "",
"ai_provider": "", "ai_provider": "",
@ -72,7 +66,21 @@ async fn setup_user_with_settings(
let (status, _) = app.put_with_session("/api/v1/settings", &settings, &session).await; let (status, _) = app.put_with_session("/api/v1/settings", &settings, &session).await;
assert_eq!(status.as_u16(), 200, "Settings save should succeed"); assert_eq!(status.as_u16(), 200, "Settings save should succeed");
(user_id, session) // Create a theme for the pipeline
let categories_json: Vec<serde_json::Value> = 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::Sender<synthesis::ProgressEvent>>, watch::Receiver<synthesis::ProgressEvent>) { fn make_progress_channel() -> (Arc<watch::Sender<synthesis::ProgressEvent>>, watch::Receiver<synthesis::ProgressEvent>) {
@ -89,11 +97,11 @@ async fn phase1_heuristic_extraction_classifies_articles() {
let app = common::TestApp::new().await; let app = common::TestApp::new().await;
let mock_server = setup_mock_server().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) // Add a source pointing to wiremock (same host as article URLs)
let source_url = format!("{}/blog", mock_server.uri()); 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; let (status, _) = app.post_with_session("/api/v1/sources", &source, &session).await;
assert!(status.is_success()); assert!(status.is_success());
@ -109,7 +117,7 @@ async fn phase1_heuristic_extraction_classifies_articles() {
); );
let result = synthesis::run_generation_inner( 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; ).await;
assert!(result.is_ok(), "Generation should succeed: {:?}", result.err()); 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 app = common::TestApp::new().await;
let mock_server = setup_mock_server().await; let mock_server = setup_mock_server().await;
// No sources Phase 1 produces nothing // No sources -- Phase 1 produces nothing
let (user_id, _session) = setup_user_with_settings(&app, vec!["AI News"], 2).await; let (user_id, _session, theme_id) = setup_user_with_settings(&app, vec!["AI News"], 2).await;
let mock_provider = MockLlmProvider::new() let mock_provider = MockLlmProvider::new()
.with_default_category("AI News") .with_default_category("AI News")
@ -169,7 +177,7 @@ async fn phase2_search_fills_gaps_when_no_sources() {
); );
let result = synthesis::run_generation_inner( 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; ).await;
assert!(result.is_ok(), "Generation should succeed: {:?}", result.err()); 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; let mock_server = setup_mock_server().await;
// max_items_per_category=1, but LLM classifies all articles to "AI News" // 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_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; app.post_with_session("/api/v1/sources", &source, &session).await;
let mock_provider = MockLlmProvider::new() let mock_provider = MockLlmProvider::new()
@ -212,7 +220,7 @@ async fn category_overflow_spills_to_autre() {
); );
let result = synthesis::run_generation_inner( 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; ).await;
assert!(result.is_ok(), "Generation should succeed"); assert!(result.is_ok(), "Generation should succeed");

@ -132,10 +132,6 @@ test.describe('Live generation with OpenAI', () => {
// Step 2: Configure settings // Step 2: Configure settings
const settingsResp = await apiCall(page, 'PUT', '/api/v1/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_articles_per_source: 3,
max_links_per_source: 8, max_links_per_source: 8,
search_agent_behavior: '', search_agent_behavior: '',
@ -145,11 +141,22 @@ test.describe('Live generation with OpenAI', () => {
use_brave_search: false, use_brave_search: false,
article_history_days: 90, article_history_days: 90,
batch_size: 5, batch_size: 5,
summary_length: 3,
source_extraction_window: 3, source_extraction_window: 3,
}); });
expect(settingsResp.status).toBe(200); 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 // Step 3: Store the real OpenAI API key
const keyResp = await apiCall(page, 'POST', '/api/v1/user/api-keys', { const keyResp = await apiCall(page, 'POST', '/api/v1/user/api-keys', {
provider_name: 'openai', provider_name: 'openai',
@ -168,6 +175,7 @@ test.describe('Live generation with OpenAI', () => {
const sourceResp = await apiCall(page, 'POST', '/api/v1/sources', { const sourceResp = await apiCall(page, 'POST', '/api/v1/sources', {
title: 'OpenAI Blog', title: 'OpenAI Blog',
url: 'https://openai.com/blog', url: 'https://openai.com/blog',
theme_id: themeId,
}); });
expect(sourceResp.status).toBe(201); expect(sourceResp.status).toBe(201);
@ -176,6 +184,7 @@ test.describe('Live generation with OpenAI', () => {
page, page,
'POST', 'POST',
'/api/v1/syntheses/generate', '/api/v1/syntheses/generate',
{ theme_id: themeId },
); );
expect(genResp.status).toBe(202); expect(genResp.status).toBe(202);
const jobId = genResp.data.job_id; const jobId = genResp.data.job_id;

@ -22,6 +22,8 @@ export const MOCK_SYNTHESIS_LIST_ITEM: SynthesisListItem = {
{ title: 'Recherche', count: 2 }, { title: 'Recherche', count: 2 },
], ],
job_id: 'job-test-1', job_id: 'job-test-1',
theme_id: null,
theme_name: null,
}; };
export const MOCK_SYNTHESIS_LIST: SynthesisListItem[] = [ export const MOCK_SYNTHESIS_LIST: SynthesisListItem[] = [
@ -57,7 +59,7 @@ export const MOCK_SOURCES: Source[] = [
// ---- Settings ---- // ---- Settings ----
export const MOCK_SETTINGS: UserSettings = { 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 ---- // ---- Providers ----

@ -3,10 +3,7 @@ import { DEFAULT_SETTINGS, type UserSettings } from '~/types';
describe('Settings validation logic', () => { describe('Settings validation logic', () => {
it('should have valid default settings', () => { it('should have valid default settings', () => {
expect(DEFAULT_SETTINGS.theme).toBe('Intelligence Artificielle'); expect(DEFAULT_SETTINGS.max_articles_per_source).toBe(3);
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.ai_model).toBe(''); expect(DEFAULT_SETTINGS.ai_model).toBe('');
expect(DEFAULT_SETTINGS.ai_model_websearch).toBe(''); expect(DEFAULT_SETTINGS.ai_model_websearch).toBe('');
expect(DEFAULT_SETTINGS.ai_provider).toBe(''); expect(DEFAULT_SETTINGS.ai_provider).toBe('');
@ -14,60 +11,21 @@ describe('Settings validation logic', () => {
expect(DEFAULT_SETTINGS.rate_limit_time_window_seconds).toBeNull(); expect(DEFAULT_SETTINGS.rate_limit_time_window_seconds).toBeNull();
}); });
it('should filter empty categories before save', () => { it('should parse batch_size as integer with fallback', () => {
const settings: UserSettings = { const parseBatchSize = (value: string): number =>
...DEFAULT_SETTINGS, parseInt(value) || 5;
categories: ['Category A', '', ' ', 'Category B', ''],
};
const cleaned = settings.categories.filter((c) => c.trim() !== ''); expect(parseBatchSize('10')).toBe(10);
expect(cleaned).toEqual(['Category A', 'Category B']); expect(parseBatchSize('')).toBe(5);
expect(parseBatchSize('abc')).toBe(5);
}); });
it('should fallback to General when all categories are empty', () => { it('should parse max_articles_per_source as integer with fallback', () => {
const settings: UserSettings = { const parseMaxArticles = (value: string): number =>
...DEFAULT_SETTINGS, parseInt(value) || 3;
categories: ['', ' ', ''],
};
let cleaned = settings.categories.filter((c) => c.trim() !== ''); expect(parseMaxArticles('5')).toBe(5);
if (cleaned.length === 0) { expect(parseMaxArticles('')).toBe(3);
cleaned = ['General']; expect(parseMaxArticles('abc')).toBe(3);
}
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);
}); });
}); });

@ -67,7 +67,7 @@ const fr = {
// Generate // Generate
'generate.title': 'Generer la Synthese Hebdomadaire', 'generate.title': 'Generer la Synthese Hebdomadaire',
'generate.description': '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.note': 'Note : La generation peut prendre jusqu\'a 10 minutes.',
'generate.launch': 'Lancer la generation', 'generate.launch': 'Lancer la generation',
'generate.inProgress': 'Generation en cours...', 'generate.inProgress': 'Generation en cours...',

@ -248,7 +248,7 @@ const GenerateSynthesis: Component = () => {
</h3> </h3>
<div class="mt-2 max-w-xl text-sm text-gray-500"> <div class="mt-2 max-w-xl text-sm text-gray-500">
<p class="text-gray-600"> <p class="text-gray-600">
{t('generate.description', { days: String(settings().max_age_days), theme: settings().theme })} {t('generate.description')}
</p> </p>
<Show when={settings().ai_provider}> <Show when={settings().ai_provider}>
<p class="mt-2 text-sm text-gray-500"> <p class="mt-2 text-sm text-gray-500">

@ -8,7 +8,7 @@ import {
createEffect, createEffect,
} from 'solid-js'; } from 'solid-js';
import { A } from '@solidjs/router'; 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 Button from '~/components/ui/Button';
import { settingsApi } from '~/api/settings'; import { settingsApi } from '~/api/settings';
import { configApi } from '~/api/config'; import { configApi } from '~/api/config';
@ -135,16 +135,7 @@ const Settings: Component = () => {
setMessage(null); setMessage(null);
try { try {
const cleanedSettings = { const saved = await settingsApi.update(settings());
...settings(),
categories: settings().categories.filter((c) => c.trim() !== ''),
};
if (cleanedSettings.categories.length === 0) {
cleanedSettings.categories = ['General'];
}
const saved = await settingsApi.update(cleanedSettings);
setSettings(saved); setSettings(saved);
setMessage({ type: 'success', text: t('settings.saved') }); setMessage({ type: 'success', text: t('settings.saved') });
} catch (err) { } 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 () => { const handleExport = async () => {
try { try {
const exportData: Record<string, unknown> = { ...settings() }; const exportData: Record<string, unknown> = { ...settings() };
@ -222,7 +189,6 @@ const Settings: Component = () => {
const merged: UserSettings = { const merged: UserSettings = {
...DEFAULT_SETTINGS, ...DEFAULT_SETTINGS,
...data, ...data,
categories: Array.isArray(data.categories) ? data.categories : DEFAULT_SETTINGS.categories,
}; };
setSettings(merged); setSettings(merged);
@ -283,170 +249,7 @@ const Settings: Component = () => {
</div> </div>
</Show> </Show>
{/* ── Section 1: Contenu ── */} {/* ── Section 1: Sources ── */}
<div class="mb-8">
<h2 class="text-xl font-semibold text-gray-900 mb-1">{t('settings.section.content')}</h2>
<p class="text-sm text-gray-500 mb-4">{t('settings.section.contentDesc')}</p>
<div class="bg-white shadow-sm rounded-lg border border-gray-200 p-6 space-y-6">
{/* Theme */}
<div>
<label for="theme" class="block text-sm font-medium text-gray-700">
{t('settings.theme')}
</label>
<div class="mt-1">
<input
type="text"
id="theme"
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md py-2 px-3 border"
value={settings().theme}
onInput={(e) =>
setSettings((prev) => ({
...prev,
theme: e.currentTarget.value,
}))
}
/>
</div>
<p class="mt-2 text-sm text-gray-500">{t('settings.themeHelp')}</p>
</div>
{/* Categories */}
<div>
<div class="flex justify-between items-center mb-4">
<label class="block text-sm font-medium text-gray-700">
{t('settings.categories')}
</label>
<button
type="button"
onClick={addCategory}
disabled={settings().categories.length >= 20}
class="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-md text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
>
<Plus class="h-4 w-4 mr-1" />
{t('settings.addCategory')}
</button>
</div>
<div class="space-y-3">
<For each={settings().categories}>
{(category, index) => (
<div class="flex items-center gap-2">
<span class="text-gray-500 font-medium w-6">
{index() + 1}.
</span>
<input
type="text"
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md py-2 px-3 border"
value={category}
onInput={(e) =>
handleCategoryChange(index(), e.currentTarget.value)
}
/>
<button
type="button"
onClick={() => removeCategory(index())}
disabled={settings().categories.length <= 1}
class="p-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
title={t('settings.removeCategory')}
>
<Trash2 class="h-5 w-5" />
</button>
</div>
)}
</For>
</div>
</div>
{/* Max age days + Max items per category */}
<div class="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-2">
<div>
<label
for="maxAgeDays"
class="block text-sm font-medium text-gray-700"
>
{t('settings.maxAgeDays')}
</label>
<div class="mt-1">
<input
type="number"
id="maxAgeDays"
min="1"
max="365"
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-24 sm:text-sm border-gray-300 rounded-md py-2 px-3 border"
value={settings().max_age_days}
onInput={(e) =>
setSettings((prev) => ({
...prev,
max_age_days: parseInt(e.currentTarget.value) || 7,
}))
}
/>
</div>
</div>
<div>
<label
for="maxItemsPerCategory"
class="block text-sm font-medium text-gray-700"
>
{t('settings.maxItems')}
</label>
<div class="mt-1">
<input
type="number"
id="maxItemsPerCategory"
min="1"
max="20"
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-24 sm:text-sm border-gray-300 rounded-md py-2 px-3 border"
value={settings().max_items_per_category}
onInput={(e) =>
setSettings((prev) => ({
...prev,
max_items_per_category:
parseInt(e.currentTarget.value) || 4,
}))
}
/>
</div>
</div>
</div>
{/* Summary length slider */}
<div>
<label for="summaryLength" class="block text-sm font-medium text-gray-700">
{t('settings.summaryLength')}
</label>
<p class="text-xs text-gray-500 mb-2">{t('settings.summaryLengthHelp')}</p>
<div class="flex items-center gap-4">
<span class="text-xs text-gray-500">{t('settings.summaryShort')}</span>
<input
type="range"
id="summaryLength"
min="1"
max="3"
step="1"
class="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-indigo-600"
value={settings().summary_length}
onInput={(e) =>
setSettings((prev) => ({
...prev,
summary_length: parseInt(e.currentTarget.value) || 3,
}))
}
/>
<span class="text-xs text-gray-500">{t('settings.summaryDetailed')}</span>
</div>
<div class="text-center text-xs text-gray-500 mt-1">
{settings().summary_length === 1
? t('settings.summaryShort')
: settings().summary_length === 2
? t('settings.summaryMedium')
: t('settings.summaryDetailed')}
</div>
</div>
</div>
</div>
{/* ── Section 2: Sources ── */}
<div class="mb-8"> <div class="mb-8">
<h2 class="text-xl font-semibold text-gray-900 mb-1">{t('settings.section.sources')}</h2> <h2 class="text-xl font-semibold text-gray-900 mb-1">{t('settings.section.sources')}</h2>
<p class="text-sm text-gray-500 mb-4">{t('settings.section.sourcesDesc')}</p> <p class="text-sm text-gray-500 mb-4">{t('settings.section.sourcesDesc')}</p>

@ -40,15 +40,11 @@ export interface VerifyResponse {
// ---- Settings ---- // ---- Settings ----
export interface UserSettings { export interface UserSettings {
theme: string;
max_age_days: number;
max_items_per_category: number;
max_articles_per_source: number; max_articles_per_source: number;
max_links_per_source: number; max_links_per_source: number;
use_brave_search: boolean; use_brave_search: boolean;
article_history_days: number; article_history_days: number;
batch_size: number; batch_size: number;
summary_length: number;
source_extraction_window: number; source_extraction_window: number;
search_agent_behavior: string; search_agent_behavior: string;
ai_model: string; ai_model: string;
@ -56,19 +52,14 @@ export interface UserSettings {
ai_provider: string; ai_provider: string;
rate_limit_max_requests: number | null; rate_limit_max_requests: number | null;
rate_limit_time_window_seconds: number | null; rate_limit_time_window_seconds: number | null;
categories: string[];
} }
export const DEFAULT_SETTINGS: UserSettings = { export const DEFAULT_SETTINGS: UserSettings = {
theme: 'Intelligence Artificielle',
max_age_days: 7,
max_items_per_category: 4,
max_articles_per_source: 3, max_articles_per_source: 3,
max_links_per_source: 8, max_links_per_source: 8,
use_brave_search: false, use_brave_search: false,
article_history_days: 90, article_history_days: 90,
batch_size: 5, batch_size: 5,
summary_length: 3,
source_extraction_window: 3, source_extraction_window: 3,
search_agent_behavior: '', search_agent_behavior: '',
ai_model: '', ai_model: '',
@ -76,13 +67,6 @@ export const DEFAULT_SETTINGS: UserSettings = {
ai_provider: '', ai_provider: '',
rate_limit_max_requests: null, rate_limit_max_requests: null,
rate_limit_time_window_seconds: null, rate_limit_time_window_seconds: null,
categories: [
'Annonces majeures',
'Recherche et innovation',
'Industrie et entreprises',
'Secteur public',
'Opinions et analyses',
],
}; };
// ---- Sources ---- // ---- Sources ----
@ -147,6 +131,8 @@ export interface SynthesisListItem {
first_section_item_count: number; first_section_item_count: number;
sections_summary: SectionSummary[]; sections_summary: SectionSummary[];
job_id: string | null; job_id: string | null;
theme_id: string | null;
theme_name: string | null;
} }
export interface GenerateResponse { export interface GenerateResponse {

Loading…
Cancel
Save