feat: add max_links_per_source setting (default 8, was hardcoded 15)

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

@ -117,7 +117,7 @@ cd frontend && npx tsc --noEmit
- `GET /api/v1/admin/users` — user list
- `PUT /api/v1/admin/users/:id/role` — role management
## Database (26 migrations)
## Database (27 migrations)
Tables: `users`, `sessions`, `magic_link_tokens`, `user_settings`, `sources`, `syntheses`, `admin_providers`, `admin_rate_limits`, `user_api_keys`, `audit_log`
## Environment Variables

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

@ -18,6 +18,7 @@ struct SettingsRow {
categories: serde_json::Value,
max_items_per_category: i32,
max_articles_per_source: i32,
max_links_per_source: i32,
use_brave_search: bool,
article_history_days: i32,
batch_size: i32,
@ -47,6 +48,7 @@ impl TryFrom<SettingsRow> for UserSettings {
categories,
max_items_per_category: row.max_items_per_category,
max_articles_per_source: row.max_articles_per_source,
max_links_per_source: row.max_links_per_source,
use_brave_search: row.use_brave_search,
article_history_days: row.article_history_days,
batch_size: row.batch_size,
@ -78,10 +80,10 @@ pub async fn get_or_create_default(
let row = sqlx::query_as::<_, SettingsRow>(
r#"
INSERT INTO settings (user_id, theme, max_age_days, categories, max_items_per_category, search_agent_behavior, ai_provider, ai_model, ai_model_websearch, rate_limit_max_requests, rate_limit_time_window_seconds, max_articles_per_source, use_brave_search, article_history_days, batch_size, summary_length, source_extraction_window)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
INSERT INTO settings (user_id, theme, max_age_days, categories, max_items_per_category, search_agent_behavior, ai_provider, ai_model, ai_model_websearch, rate_limit_max_requests, rate_limit_time_window_seconds, max_articles_per_source, max_links_per_source, use_brave_search, article_history_days, batch_size, summary_length, source_extraction_window)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
ON CONFLICT (user_id) DO UPDATE SET user_id = settings.user_id
RETURNING user_id, theme, max_age_days, categories, max_items_per_category, search_agent_behavior, ai_provider, ai_model, ai_model_websearch, rate_limit_max_requests, rate_limit_time_window_seconds, max_articles_per_source, use_brave_search, article_history_days, batch_size, summary_length, source_extraction_window, updated_at
RETURNING user_id, theme, max_age_days, categories, max_items_per_category, search_agent_behavior, ai_provider, ai_model, ai_model_websearch, rate_limit_max_requests, rate_limit_time_window_seconds, max_articles_per_source, max_links_per_source, use_brave_search, article_history_days, batch_size, summary_length, source_extraction_window, updated_at
"#,
)
.bind(user_id)
@ -96,6 +98,7 @@ pub async fn get_or_create_default(
.bind(defaults.rate_limit_max_requests)
.bind(defaults.rate_limit_time_window_seconds)
.bind(defaults.max_articles_per_source)
.bind(defaults.max_links_per_source)
.bind(defaults.use_brave_search)
.bind(defaults.article_history_days)
.bind(defaults.batch_size)
@ -119,8 +122,8 @@ pub async fn upsert(
let row = sqlx::query_as::<_, SettingsRow>(
r#"
INSERT INTO settings (user_id, theme, max_age_days, categories, max_items_per_category, search_agent_behavior, ai_provider, ai_model, ai_model_websearch, rate_limit_max_requests, rate_limit_time_window_seconds, max_articles_per_source, use_brave_search, article_history_days, batch_size, summary_length, source_extraction_window)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
INSERT INTO settings (user_id, theme, max_age_days, categories, max_items_per_category, search_agent_behavior, ai_provider, ai_model, ai_model_websearch, rate_limit_max_requests, rate_limit_time_window_seconds, max_articles_per_source, max_links_per_source, use_brave_search, article_history_days, batch_size, summary_length, source_extraction_window)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
ON CONFLICT (user_id) DO UPDATE SET
theme = EXCLUDED.theme,
max_age_days = EXCLUDED.max_age_days,
@ -133,13 +136,14 @@ pub async fn upsert(
rate_limit_max_requests = EXCLUDED.rate_limit_max_requests,
rate_limit_time_window_seconds = EXCLUDED.rate_limit_time_window_seconds,
max_articles_per_source = EXCLUDED.max_articles_per_source,
max_links_per_source = EXCLUDED.max_links_per_source,
use_brave_search = EXCLUDED.use_brave_search,
article_history_days = EXCLUDED.article_history_days,
batch_size = EXCLUDED.batch_size,
summary_length = EXCLUDED.summary_length,
source_extraction_window = EXCLUDED.source_extraction_window,
updated_at = now()
RETURNING user_id, theme, max_age_days, categories, max_items_per_category, search_agent_behavior, ai_provider, ai_model, ai_model_websearch, rate_limit_max_requests, rate_limit_time_window_seconds, max_articles_per_source, use_brave_search, article_history_days, batch_size, summary_length, source_extraction_window, updated_at
RETURNING user_id, theme, max_age_days, categories, max_items_per_category, search_agent_behavior, ai_provider, ai_model, ai_model_websearch, rate_limit_max_requests, rate_limit_time_window_seconds, max_articles_per_source, max_links_per_source, use_brave_search, article_history_days, batch_size, summary_length, source_extraction_window, updated_at
"#,
)
.bind(user_id)
@ -154,6 +158,7 @@ pub async fn upsert(
.bind(req.rate_limit_max_requests)
.bind(req.rate_limit_time_window_seconds)
.bind(req.max_articles_per_source)
.bind(req.max_links_per_source)
.bind(req.use_brave_search)
.bind(req.article_history_days)
.bind(req.batch_size)

@ -14,6 +14,7 @@ pub struct UserSettings {
pub categories: Vec<String>,
pub max_items_per_category: i32,
pub max_articles_per_source: i32,
pub max_links_per_source: i32,
pub use_brave_search: bool,
pub article_history_days: i32,
@ -38,6 +39,7 @@ pub struct UpdateSettingsRequest {
pub categories: Vec<String>,
pub max_items_per_category: i32,
pub max_articles_per_source: i32,
pub max_links_per_source: i32,
pub use_brave_search: bool,
pub article_history_days: i32,
@ -90,6 +92,9 @@ impl UpdateSettingsRequest {
if !(1..=10).contains(&self.max_articles_per_source) {
return Err("max_articles_per_source must be between 1 and 10".into());
}
if !(1..=30).contains(&self.max_links_per_source) {
return Err("max_links_per_source must be between 1 and 30".into());
}
if !(0..=365).contains(&self.article_history_days) {
return Err("article_history_days must be between 0 and 365".into());
}
@ -144,6 +149,7 @@ impl Default for UserSettings {
],
max_items_per_category: 4,
max_articles_per_source: 3,
max_links_per_source: 8,
use_brave_search: false,
article_history_days: 90,
@ -173,6 +179,7 @@ mod tests {
categories: vec!["Category 1".into(), "Category 2".into()],
max_items_per_category: 4,
max_articles_per_source: 3,
max_links_per_source: 8,
use_brave_search: false,
article_history_days: 90,

@ -186,6 +186,7 @@ mod tests {
],
max_items_per_category: 4,
max_articles_per_source: 3,
max_links_per_source: 8,
use_brave_search: false,
article_history_days: 90,
batch_size: 5,

@ -291,7 +291,7 @@ pub async fn run_generation_inner(
let last_source = db::article_history::get_last_source_url(&state.pool, user_id).await.unwrap_or(None);
let rotated_sources = rotate_sources(sources.clone(), last_source.as_deref());
let max_links = 15usize;
let max_links = settings.max_links_per_source.max(1) as usize;
let window_size = settings.source_extraction_window.max(1) as usize;
// Process sources in waves of `window_size`

@ -47,6 +47,7 @@ async fn put_settings_without_auth_returns_401() {
"categories": ["Cat"],
"max_items_per_category": 4,
"max_articles_per_source": 3,
"max_links_per_source": 8,
"use_brave_search": false,
"article_history_days": 90,
@ -134,6 +135,7 @@ async fn put_settings_with_valid_data_returns_200() {
"categories": ["Vulnerabilites", "Patch Tuesday", "Threat Intel"],
"max_items_per_category": 6,
"max_articles_per_source": 3,
"max_links_per_source": 8,
"use_brave_search": false,
"article_history_days": 90,
@ -192,6 +194,7 @@ async fn put_then_get_returns_updated_data() {
"categories": ["Macro", "Finance"],
"max_items_per_category": 10,
"max_articles_per_source": 3,
"max_links_per_source": 8,
"use_brave_search": false,
"article_history_days": 90,
@ -244,6 +247,7 @@ async fn put_settings_empty_theme_returns_422() {
"categories": ["Cat"],
"max_items_per_category": 4,
"max_articles_per_source": 3,
"max_links_per_source": 8,
"use_brave_search": false,
"article_history_days": 90,
@ -288,6 +292,7 @@ async fn put_settings_too_many_categories_returns_422() {
"categories": categories,
"max_items_per_category": 4,
"max_articles_per_source": 3,
"max_links_per_source": 8,
"use_brave_search": false,
"article_history_days": 90,
@ -331,6 +336,7 @@ async fn put_settings_empty_categories_returns_422() {
"categories": [],
"max_items_per_category": 4,
"max_articles_per_source": 3,
"max_links_per_source": 8,
"use_brave_search": false,
"article_history_days": 90,
@ -375,6 +381,7 @@ async fn put_settings_max_age_days_out_of_range_returns_422() {
"categories": ["Cat"],
"max_items_per_category": 4,
"max_articles_per_source": 3,
"max_links_per_source": 8,
"use_brave_search": false,
"article_history_days": 90,
@ -404,6 +411,7 @@ async fn put_settings_max_age_days_out_of_range_returns_422() {
"categories": ["Cat"],
"max_items_per_category": 4,
"max_articles_per_source": 3,
"max_links_per_source": 8,
"use_brave_search": false,
"article_history_days": 90,
@ -445,6 +453,7 @@ async fn put_settings_max_items_out_of_range_returns_422() {
"categories": ["Cat"],
"max_items_per_category": 51,
"max_articles_per_source": 3,
"max_links_per_source": 8,
"use_brave_search": false,
"article_history_days": 90,
@ -494,6 +503,7 @@ async fn settings_are_per_user_isolated() {
"categories": ["A-Category"],
"max_items_per_category": 2,
"max_articles_per_source": 3,
"max_links_per_source": 8,
"use_brave_search": false,
"article_history_days": 90,
@ -519,6 +529,7 @@ async fn settings_are_per_user_isolated() {
"categories": ["B-Category-1", "B-Category-2"],
"max_items_per_category": 8,
"max_articles_per_source": 3,
"max_links_per_source": 8,
"use_brave_search": false,
"article_history_days": 90,
@ -575,6 +586,7 @@ async fn put_settings_boundary_values_succeed() {
"categories": ["C"],
"max_items_per_category": 1,
"max_articles_per_source": 3,
"max_links_per_source": 8,
"use_brave_search": false,
"article_history_days": 90,
@ -601,6 +613,7 @@ async fn put_settings_boundary_values_succeed() {
"categories": categories_max,
"max_items_per_category": 50,
"max_articles_per_source": 3,
"max_links_per_source": 8,
"use_brave_search": false,
"article_history_days": 90,

@ -631,6 +631,7 @@ async fn generate_pipeline_resolves_model_from_admin_config() {
"categories": ["Test Category"],
"max_items_per_category": 4,
"max_articles_per_source": 3,
"max_links_per_source": 8,
"use_brave_search": false,
"article_history_days": 90,

@ -56,6 +56,7 @@ async fn setup_user_with_settings(
"categories": categories_json,
"max_items_per_category": max_items,
"max_articles_per_source": 10,
"max_links_per_source": 8,
"use_brave_search": false,
"article_history_days": 90,
"batch_size": 5,

@ -137,6 +137,7 @@ test.describe('Live generation with OpenAI', () => {
categories: ['AI News'],
max_items_per_category: 4,
max_articles_per_source: 3,
max_links_per_source: 8,
search_agent_behavior: '',
ai_provider: 'openai',
ai_model: 'gpt-4o-mini',

@ -123,6 +123,8 @@ const fr = {
'settings.maxAgeDays': 'Anciennete maximum (jours)',
'settings.maxItems': 'Actualites max par categorie',
'settings.maxArticlesPerSource': 'Articles max par source',
'settings.maxLinksPerSource': 'Liens max par source',
'settings.maxLinksPerSourceHelp': 'Nombre maximum de liens extraits de chaque source. Les premiers liens sont generalement les plus recents.',
'settings.searchBehavior': "Comportement de l'agent de recherche",
'settings.searchBehaviorHelp':
"Personnalisez les instructions donnees a l'IA concernant sa methode de recherche.",

@ -431,6 +431,29 @@ const Settings: Component = () => {
</div>
</div>
<div>
<label for="maxLinksPerSource" class="block text-sm font-medium text-gray-700">
{t('settings.maxLinksPerSource')}
</label>
<p class="text-xs text-gray-500 mb-1">{t('settings.maxLinksPerSourceHelp')}</p>
<div class="mt-1">
<input
type="number"
id="maxLinksPerSource"
min="1"
max="30"
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().max_links_per_source}
onInput={(e) =>
setSettings((prev) => ({
...prev,
max_links_per_source: parseInt(e.currentTarget.value) || 8,
}))
}
/>
</div>
</div>
</div>
{/* Summary length slider */}

@ -44,6 +44,7 @@ export interface UserSettings {
max_age_days: number;
max_items_per_category: number;
max_articles_per_source: number;
max_links_per_source: number;
use_brave_search: boolean;
article_history_days: number;
batch_size: number;
@ -63,6 +64,7 @@ export const DEFAULT_SETTINGS: UserSettings = {
max_age_days: 7,
max_items_per_category: 4,
max_articles_per_source: 3,
max_links_per_source: 8,
use_brave_search: false,
article_history_days: 90,
batch_size: 5,

Loading…
Cancel
Save