From c271c240a2779923def7a13400c7263c7f22b8ec Mon Sep 17 00:00:00 2001 From: oabrivard Date: Tue, 24 Mar 2026 11:57:41 +0100 Subject: [PATCH] feat: add article_history table and article_history_days setting Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 2 +- .../20260324000015_add_article_history.sql | 12 ++++++++++++ backend/src/db/settings.rs | 17 +++++++++++------ backend/src/models/settings.rs | 9 +++++++++ backend/src/services/prompts.rs | 1 + 5 files changed, 34 insertions(+), 7 deletions(-) create mode 100644 backend/migrations/20260324000015_add_article_history.sql diff --git a/CLAUDE.md b/CLAUDE.md index 399cee5..954ef11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,7 +117,7 @@ cd frontend && npx tsc --noEmit - `GET /api/v1/admin/users` — user list - `PUT /api/v1/admin/users/:id/role` — role management -## Database (14 migrations) +## Database (15 migrations) Tables: `users`, `sessions`, `magic_link_tokens`, `user_settings`, `sources`, `syntheses`, `admin_providers`, `admin_rate_limits`, `user_api_keys`, `audit_log` ## Environment Variables diff --git a/backend/migrations/20260324000015_add_article_history.sql b/backend/migrations/20260324000015_add_article_history.sql new file mode 100644 index 0000000..7794e20 --- /dev/null +++ b/backend/migrations/20260324000015_add_article_history.sql @@ -0,0 +1,12 @@ +-- Article history table for cross-synthesis URL deduplication +CREATE TABLE article_history ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + url_hash TEXT NOT NULL, + url TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX idx_article_history_user_url ON article_history(user_id, url_hash); + +-- Setting for history TTL +ALTER TABLE settings ADD COLUMN article_history_days INTEGER NOT NULL DEFAULT 90; diff --git a/backend/src/db/settings.rs b/backend/src/db/settings.rs index bbed2a4..3f72179 100644 --- a/backend/src/db/settings.rs +++ b/backend/src/db/settings.rs @@ -21,6 +21,7 @@ struct SettingsRow { source_diversity_window: i32, use_llm_for_source_links: bool, use_llm_for_article_extraction: bool, + article_history_days: i32, search_agent_behavior: String, ai_provider: String, ai_model: String, @@ -48,6 +49,7 @@ impl TryFrom for UserSettings { source_diversity_window: row.source_diversity_window, use_llm_for_source_links: row.use_llm_for_source_links, use_llm_for_article_extraction: row.use_llm_for_article_extraction, + article_history_days: row.article_history_days, search_agent_behavior: row.search_agent_behavior, ai_provider: row.ai_provider, ai_model: row.ai_model, @@ -74,10 +76,10 @@ pub async fn get_or_create_default( let row = sqlx::query_as::<_, SettingsRow>( r#" - INSERT INTO settings (user_id, theme, max_age_days, categories, max_items_per_category, search_agent_behavior, ai_provider, ai_model, ai_model_writing, rate_limit_max_requests, rate_limit_time_window_seconds, max_articles_per_source, source_diversity_window, use_llm_for_source_links, use_llm_for_article_extraction) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + INSERT INTO settings (user_id, theme, max_age_days, categories, max_items_per_category, search_agent_behavior, ai_provider, ai_model, ai_model_writing, rate_limit_max_requests, rate_limit_time_window_seconds, max_articles_per_source, source_diversity_window, use_llm_for_source_links, use_llm_for_article_extraction, article_history_days) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) ON CONFLICT (user_id) DO UPDATE SET user_id = settings.user_id - RETURNING user_id, theme, max_age_days, categories, max_items_per_category, search_agent_behavior, ai_provider, ai_model, ai_model_writing, rate_limit_max_requests, rate_limit_time_window_seconds, max_articles_per_source, source_diversity_window, use_llm_for_source_links, use_llm_for_article_extraction, updated_at + RETURNING user_id, theme, max_age_days, categories, max_items_per_category, search_agent_behavior, ai_provider, ai_model, ai_model_writing, rate_limit_max_requests, rate_limit_time_window_seconds, max_articles_per_source, source_diversity_window, use_llm_for_source_links, use_llm_for_article_extraction, article_history_days, updated_at "#, ) .bind(user_id) @@ -95,6 +97,7 @@ pub async fn get_or_create_default( .bind(defaults.source_diversity_window) .bind(defaults.use_llm_for_source_links) .bind(defaults.use_llm_for_article_extraction) + .bind(defaults.article_history_days) .fetch_one(pool) .await?; @@ -113,8 +116,8 @@ pub async fn upsert( let row = sqlx::query_as::<_, SettingsRow>( r#" - INSERT INTO settings (user_id, theme, max_age_days, categories, max_items_per_category, search_agent_behavior, ai_provider, ai_model, ai_model_writing, rate_limit_max_requests, rate_limit_time_window_seconds, max_articles_per_source, source_diversity_window, use_llm_for_source_links, use_llm_for_article_extraction) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + INSERT INTO settings (user_id, theme, max_age_days, categories, max_items_per_category, search_agent_behavior, ai_provider, ai_model, ai_model_writing, rate_limit_max_requests, rate_limit_time_window_seconds, max_articles_per_source, source_diversity_window, use_llm_for_source_links, use_llm_for_article_extraction, article_history_days) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) ON CONFLICT (user_id) DO UPDATE SET theme = EXCLUDED.theme, max_age_days = EXCLUDED.max_age_days, @@ -130,8 +133,9 @@ pub async fn upsert( source_diversity_window = EXCLUDED.source_diversity_window, use_llm_for_source_links = EXCLUDED.use_llm_for_source_links, use_llm_for_article_extraction = EXCLUDED.use_llm_for_article_extraction, + article_history_days = EXCLUDED.article_history_days, updated_at = now() - RETURNING user_id, theme, max_age_days, categories, max_items_per_category, search_agent_behavior, ai_provider, ai_model, ai_model_writing, rate_limit_max_requests, rate_limit_time_window_seconds, max_articles_per_source, source_diversity_window, use_llm_for_source_links, use_llm_for_article_extraction, updated_at + RETURNING user_id, theme, max_age_days, categories, max_items_per_category, search_agent_behavior, ai_provider, ai_model, ai_model_writing, rate_limit_max_requests, rate_limit_time_window_seconds, max_articles_per_source, source_diversity_window, use_llm_for_source_links, use_llm_for_article_extraction, article_history_days, updated_at "#, ) .bind(user_id) @@ -149,6 +153,7 @@ pub async fn upsert( .bind(req.source_diversity_window) .bind(req.use_llm_for_source_links) .bind(req.use_llm_for_article_extraction) + .bind(req.article_history_days) .fetch_one(pool) .await?; diff --git a/backend/src/models/settings.rs b/backend/src/models/settings.rs index 2e5d7a0..4073117 100644 --- a/backend/src/models/settings.rs +++ b/backend/src/models/settings.rs @@ -16,6 +16,7 @@ pub struct UserSettings { pub source_diversity_window: i32, pub use_llm_for_source_links: bool, pub use_llm_for_article_extraction: bool, + pub article_history_days: i32, pub search_agent_behavior: String, pub ai_provider: String, pub ai_model: String, @@ -36,6 +37,7 @@ pub struct SettingsResponse { pub source_diversity_window: i32, pub use_llm_for_source_links: bool, pub use_llm_for_article_extraction: bool, + pub article_history_days: i32, pub search_agent_behavior: String, pub ai_provider: String, pub ai_model: String, @@ -55,6 +57,7 @@ impl From for SettingsResponse { source_diversity_window: s.source_diversity_window, use_llm_for_source_links: s.use_llm_for_source_links, use_llm_for_article_extraction: s.use_llm_for_article_extraction, + article_history_days: s.article_history_days, search_agent_behavior: s.search_agent_behavior, ai_provider: s.ai_provider, ai_model: s.ai_model, @@ -76,6 +79,7 @@ pub struct UpdateSettingsRequest { pub source_diversity_window: i32, pub use_llm_for_source_links: bool, pub use_llm_for_article_extraction: bool, + pub article_history_days: i32, pub search_agent_behavior: String, pub ai_provider: String, pub ai_model: String, @@ -125,6 +129,9 @@ impl UpdateSettingsRequest { if !(0..=10).contains(&self.source_diversity_window) { return Err("source_diversity_window must be between 0 and 10".into()); } + if !(0..=365).contains(&self.article_history_days) { + return Err("article_history_days must be between 0 and 365".into()); + } if self.search_agent_behavior.len() > 2000 { return Err("search_agent_behavior must be at most 2000 characters".into()); } @@ -170,6 +177,7 @@ impl Default for UserSettings { source_diversity_window: 3, use_llm_for_source_links: false, use_llm_for_article_extraction: false, + article_history_days: 90, search_agent_behavior: String::new(), ai_provider: String::new(), ai_model: String::new(), @@ -196,6 +204,7 @@ mod tests { source_diversity_window: 3, use_llm_for_source_links: false, use_llm_for_article_extraction: false, + article_history_days: 90, search_agent_behavior: String::new(), ai_provider: String::new(), ai_model: String::new(), diff --git a/backend/src/services/prompts.rs b/backend/src/services/prompts.rs index 5659664..e06ce72 100644 --- a/backend/src/services/prompts.rs +++ b/backend/src/services/prompts.rs @@ -283,6 +283,7 @@ mod tests { source_diversity_window: 3, use_llm_for_source_links: false, use_llm_for_article_extraction: false, + article_history_days: 90, search_agent_behavior: String::new(), ai_provider: String::new(), ai_model: String::new(),