You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
794 lines
27 KiB
Markdown
794 lines
27 KiB
Markdown
# AI Weekly Synth -- Technical Specifications
|
|
|
|
## 1. Backend Tech Stack
|
|
|
|
| Dependency | Version | Purpose |
|
|
|---|---|---|
|
|
| axum | 0.8 | Web framework (macros, multipart) |
|
|
| tokio | 1 | Async runtime (full features) |
|
|
| tower | 0.5 | Middleware composition |
|
|
| tower-http | 0.6 | CORS, static files, tracing, headers |
|
|
| sqlx | 0.8 | Async Postgres driver (runtime-tokio, tls-rustls, uuid, chrono, json, migrate) |
|
|
| reqwest | 0.12 | HTTP client (JSON) |
|
|
| serde / serde_json | 1 | Serialization/deserialization |
|
|
| chrono | 0.4 | Date/time handling (serde feature) |
|
|
| aes-gcm | 0.10 | AES-256-GCM encryption |
|
|
| zeroize | 1 | Secure memory zeroing |
|
|
| sha2 | 0.10 | SHA-256 hashing |
|
|
| rand | 0.8 | Random number generation |
|
|
| base64 | 0.22 | Base64 encoding |
|
|
| hex | 0.4 | Hex encoding/decoding |
|
|
| async-trait | 0.1 | Async trait objects |
|
|
| tracing / tracing-subscriber | 0.1 / 0.3 | Structured logging (env-filter, json) |
|
|
| dotenvy | 0.15 | .env file loading |
|
|
| clap | 4 | CLI argument parsing |
|
|
| scraper | 0.22 | HTML parsing (CSS selectors) |
|
|
| ego-tree | 0.10 | Tree data structure (used by scraper) |
|
|
| url | 2 | URL parsing and validation |
|
|
| email_address | 0.2 | Email validation |
|
|
| anyhow | 1 | Error context |
|
|
| thiserror | 2 | Error type derivation |
|
|
| uuid | 1 | UUID v4 generation (serde feature) |
|
|
| dashmap | 6 | Concurrent hash maps |
|
|
| tokio-stream | 0.1 | Stream utilities for SSE |
|
|
| futures | 0.3 | Async stream combinators |
|
|
| printpdf | 0.7 | PDF generation |
|
|
|
|
**Dev dependencies**: tower (util), http-body-util, wiremock 0.6.
|
|
|
|
**Rust edition**: 2021.
|
|
|
|
---
|
|
|
|
## 2. Frontend Tech Stack
|
|
|
|
| Dependency | Version | Purpose |
|
|
|---|---|---|
|
|
| solid-js | ^1.9.0 | Reactive UI framework |
|
|
| @solidjs/router | ^0.15.0 | Client-side routing |
|
|
| lucide-solid | ^0.475.0 | Icon library |
|
|
| date-fns | ^4.1.0 | Date formatting |
|
|
| tailwindcss | ^4.1.0 | Utility-first CSS (v4) |
|
|
| @tailwindcss/vite | ^4.1.0 | Tailwind Vite plugin |
|
|
| vite | ^6.2.0 | Build tool and dev server |
|
|
| vite-plugin-solid | ^2.11.0 | SolidJS Vite integration |
|
|
| typescript | ~5.8.0 | Type checking |
|
|
| vitest | ^3.0.0 | Unit testing |
|
|
| @solidjs/testing-library | ^0.8.0 | Component testing |
|
|
| jsdom | ^25.0.0 | DOM environment for tests |
|
|
|
|
### Frontend Routes
|
|
|
|
| Path | Component | Auth | Description |
|
|
|---|---|---|---|
|
|
| /login | Login | Public | Login page |
|
|
| /register | Register | Public | Registration page |
|
|
| /auth/verify | AuthVerify | Public | Magic link verification |
|
|
| / | Home | Protected | Dashboard / synthesis list |
|
|
| /settings | Settings | Protected | User settings |
|
|
| /themes | ThemeManager | Protected | Theme CRUD + source management |
|
|
| /generate | GenerateSynthesis | Protected | Generation trigger + progress |
|
|
| /synthesis/:id | SynthesisDetail | Protected | Full synthesis view |
|
|
| /article-history | ArticleHistory | Protected | Article history browser |
|
|
| /llm-logs/:jobId | LlmLogs | Protected | LLM call log viewer |
|
|
| /admin/providers | AdminProviders | Admin | Provider configuration |
|
|
| /admin/rate-limits | AdminRateLimits | Admin | Rate limit configuration |
|
|
| /admin/users | AdminUsers | Admin | User management |
|
|
|
|
---
|
|
|
|
## 3. Database Schema
|
|
|
|
### 3.1 `users`
|
|
|
|
| Column | Type | Constraints |
|
|
|---|---|---|
|
|
| id | UUID | PK, DEFAULT gen_random_uuid() |
|
|
| email | TEXT | NOT NULL, UNIQUE |
|
|
| display_name | TEXT | nullable |
|
|
| role | TEXT | NOT NULL, DEFAULT 'user', CHECK (user/admin) |
|
|
| created_at | TIMESTAMPTZ | NOT NULL, DEFAULT now() |
|
|
| updated_at | TIMESTAMPTZ | NOT NULL, DEFAULT now() |
|
|
|
|
Indexes: `idx_users_email` on (email).
|
|
|
|
### 3.2 `sessions`
|
|
|
|
| Column | Type | Constraints |
|
|
|---|---|---|
|
|
| session_hash | TEXT | PK (SHA-256 of raw token) |
|
|
| user_id | UUID | NOT NULL, FK users(id) CASCADE |
|
|
| created_at | TIMESTAMPTZ | NOT NULL, DEFAULT now() |
|
|
| expires_at | TIMESTAMPTZ | NOT NULL |
|
|
| last_active_at | TIMESTAMPTZ | NOT NULL, DEFAULT now() |
|
|
| ip_address | TEXT | nullable |
|
|
| user_agent | TEXT | nullable |
|
|
|
|
Indexes: `idx_sessions_user_id`, `idx_sessions_expires_at`.
|
|
|
|
### 3.3 `magic_tokens`
|
|
|
|
| Column | Type | Constraints |
|
|
|---|---|---|
|
|
| id | UUID | PK, DEFAULT gen_random_uuid() |
|
|
| email | TEXT | NOT NULL |
|
|
| token_hash | TEXT | NOT NULL, UNIQUE |
|
|
| created_at | TIMESTAMPTZ | NOT NULL, DEFAULT now() |
|
|
| expires_at | TIMESTAMPTZ | NOT NULL |
|
|
| used | BOOLEAN | NOT NULL, DEFAULT false |
|
|
|
|
Indexes: `idx_magic_tokens_email`, `idx_magic_tokens_expires`.
|
|
|
|
### 3.4 `settings`
|
|
|
|
Per-user pipeline configuration. One row per user (user_id is the PK).
|
|
|
|
| Column | Type | Constraints |
|
|
|---|---|---|
|
|
| user_id | UUID | PK, FK users(id) CASCADE |
|
|
| max_articles_per_source | INTEGER | NOT NULL, DEFAULT 3 |
|
|
| max_links_per_source | INTEGER | NOT NULL, DEFAULT 8 |
|
|
| use_brave_search | BOOLEAN | NOT NULL, DEFAULT false |
|
|
| article_history_days | INTEGER | NOT NULL, DEFAULT 90 |
|
|
| batch_size | INTEGER | NOT NULL, DEFAULT 5 |
|
|
| source_extraction_window | INTEGER | NOT NULL, DEFAULT 3 |
|
|
| search_agent_behavior | TEXT | NOT NULL, DEFAULT '' |
|
|
| ai_provider | TEXT | NOT NULL, DEFAULT '' |
|
|
| ai_model | TEXT | NOT NULL, DEFAULT '' |
|
|
| ai_model_websearch | TEXT | NOT NULL, DEFAULT '' |
|
|
| rate_limit_max_requests | INTEGER | nullable |
|
|
| rate_limit_time_window_seconds | INTEGER | nullable |
|
|
| updated_at | TIMESTAMPTZ | NOT NULL, DEFAULT now() |
|
|
|
|
### 3.5 `themes`
|
|
|
|
Per-user topic configurations with content settings.
|
|
|
|
| Column | Type | Constraints |
|
|
|---|---|---|
|
|
| id | UUID | PK, DEFAULT gen_random_uuid() |
|
|
| user_id | UUID | NOT NULL, FK users(id) CASCADE |
|
|
| name | TEXT | NOT NULL |
|
|
| theme | TEXT | NOT NULL (search topic) |
|
|
| categories | JSONB | NOT NULL, DEFAULT '[]' |
|
|
| max_items_per_category | INTEGER | NOT NULL, DEFAULT 4 |
|
|
| max_age_days | INTEGER | NOT NULL, DEFAULT 7 |
|
|
| summary_length | INTEGER | NOT NULL, DEFAULT 3 |
|
|
| created_at | TIMESTAMPTZ | NOT NULL, DEFAULT now() |
|
|
| updated_at | TIMESTAMPTZ | NOT NULL, DEFAULT now() |
|
|
|
|
Indexes: `idx_themes_user_id`.
|
|
|
|
### 3.6 `sources`
|
|
|
|
User-curated news source URLs, optionally tied to a theme.
|
|
|
|
| Column | Type | Constraints |
|
|
|---|---|---|
|
|
| id | UUID | PK, DEFAULT gen_random_uuid() |
|
|
| user_id | UUID | NOT NULL, FK users(id) CASCADE |
|
|
| title | VARCHAR(200) | NOT NULL, CHECK length 1-200 |
|
|
| url | VARCHAR(1000) | NOT NULL, CHECK length <= 1000 |
|
|
| theme_id | UUID | nullable, FK themes(id) CASCADE |
|
|
| is_preferred | BOOLEAN | NOT NULL, DEFAULT false |
|
|
| created_at | TIMESTAMPTZ | NOT NULL, DEFAULT now() |
|
|
|
|
Indexes: `idx_sources_user_id`, UNIQUE `idx_sources_user_id_url` on (user_id, url).
|
|
|
|
### 3.7 `syntheses`
|
|
|
|
Generated synthesis results with JSONB section data.
|
|
|
|
| Column | Type | Constraints |
|
|
|---|---|---|
|
|
| id | UUID | PK, DEFAULT gen_random_uuid() |
|
|
| user_id | UUID | NOT NULL, FK users(id) CASCADE |
|
|
| week | VARCHAR(10) | NOT NULL (ISO week string) |
|
|
| sections | JSONB | NOT NULL, DEFAULT '[]' |
|
|
| status | VARCHAR(20) | NOT NULL, DEFAULT 'completed' |
|
|
| job_id | UUID | nullable |
|
|
| theme_id | UUID | nullable, FK themes(id) SET NULL |
|
|
| created_at | TIMESTAMPTZ | NOT NULL, DEFAULT now() |
|
|
|
|
Indexes: `idx_syntheses_user_id_created_at` on (user_id, created_at DESC).
|
|
|
|
JSONB structure for `sections`:
|
|
```json
|
|
[
|
|
{
|
|
"title": "Category Name",
|
|
"items": [
|
|
{ "title": "Article Title", "url": "https://...", "summary": "...", "date": "2026-03-25" }
|
|
]
|
|
}
|
|
]
|
|
```
|
|
|
|
### 3.8 `theme_schedules`
|
|
|
|
Automated generation schedules, one per theme.
|
|
|
|
| Column | Type | Constraints |
|
|
|---|---|---|
|
|
| id | UUID | PK, DEFAULT gen_random_uuid() |
|
|
| theme_id | UUID | NOT NULL, UNIQUE, FK themes(id) CASCADE |
|
|
| user_id | UUID | NOT NULL, FK users(id) CASCADE |
|
|
| enabled | BOOLEAN | NOT NULL, DEFAULT true |
|
|
| days | JSONB | NOT NULL, DEFAULT '[]' (e.g. ["mon","fri"]) |
|
|
| time_utc | TEXT | NOT NULL, DEFAULT '08:00' (HH:MM) |
|
|
| emails | JSONB | NOT NULL, DEFAULT '[]' (up to 3 addresses) |
|
|
| last_run_at | TIMESTAMPTZ | nullable |
|
|
| created_at | TIMESTAMPTZ | NOT NULL, DEFAULT now() |
|
|
| updated_at | TIMESTAMPTZ | NOT NULL, DEFAULT now() |
|
|
|
|
Indexes: `idx_theme_schedules_enabled` (partial, WHERE enabled = true).
|
|
|
|
### 3.9 `article_history`
|
|
|
|
Article URL deduplication and full provenance tracing.
|
|
|
|
| Column | Type | Constraints |
|
|
|---|---|---|
|
|
| id | UUID | PK, DEFAULT gen_random_uuid() |
|
|
| user_id | UUID | NOT NULL, FK users(id) CASCADE |
|
|
| url_hash | TEXT | NOT NULL (SHA-256 of normalized URL) |
|
|
| url | TEXT | NOT NULL |
|
|
| title | TEXT | NOT NULL, DEFAULT '' |
|
|
| source_type | TEXT | NOT NULL, DEFAULT 'unknown' |
|
|
| source_url | TEXT | nullable |
|
|
| category | TEXT | nullable |
|
|
| synthesis_id | UUID | nullable, FK syntheses(id) SET NULL |
|
|
| status | TEXT | NOT NULL, DEFAULT 'used' |
|
|
| scraped_ok | BOOLEAN | NOT NULL, DEFAULT true |
|
|
| job_id | UUID | NOT NULL |
|
|
| published_date | TEXT | nullable |
|
|
| created_at | TIMESTAMPTZ | NOT NULL, DEFAULT now() |
|
|
|
|
Indexes: `idx_article_history_user_url` on (user_id, url_hash), `idx_article_history_job_id`.
|
|
|
|
Status values: `used`, `filtered_history`, `filtered_diversity`, `filtered_not_article`, `filtered_too_old`, `filtered_empty`, `filtered_homepage`, `filtered_cross_phase_dedup`.
|
|
|
|
Source type values: `personalized_source`, `brave_search`, `web_search`.
|
|
|
|
### 3.10 `llm_call_log`
|
|
|
|
Full LLM interaction logging for debugging and analysis.
|
|
|
|
| Column | Type | Constraints |
|
|
|---|---|---|
|
|
| id | UUID | PK, DEFAULT gen_random_uuid() |
|
|
| user_id | UUID | NOT NULL, FK users(id) CASCADE |
|
|
| job_id | UUID | NOT NULL |
|
|
| call_type | TEXT | NOT NULL |
|
|
| model | TEXT | NOT NULL |
|
|
| system_prompt | TEXT | NOT NULL, DEFAULT '' |
|
|
| user_prompt | TEXT | NOT NULL, DEFAULT '' |
|
|
| response_body | TEXT | NOT NULL, DEFAULT '' |
|
|
| duration_ms | INTEGER | NOT NULL, DEFAULT 0 |
|
|
| article_url | TEXT | nullable |
|
|
| created_at | TIMESTAMPTZ | NOT NULL, DEFAULT now() |
|
|
|
|
Indexes: `idx_llm_call_log_job_id`, `idx_llm_call_log_user_id` on (user_id, created_at).
|
|
|
|
### 3.11 `admin_providers`
|
|
|
|
Admin-curated catalog of LLM providers and their models.
|
|
|
|
| Column | Type | Constraints |
|
|
|---|---|---|
|
|
| id | UUID | PK, DEFAULT gen_random_uuid() |
|
|
| provider_name | VARCHAR(50) | NOT NULL, UNIQUE |
|
|
| display_name | VARCHAR(100) | NOT NULL |
|
|
| models_scraping | JSONB | NOT NULL, DEFAULT '[]' |
|
|
| models_websearch | JSONB | NOT NULL, DEFAULT '[]' |
|
|
| is_enabled | BOOLEAN | NOT NULL, DEFAULT true |
|
|
| created_at | TIMESTAMPTZ | NOT NULL, DEFAULT now() |
|
|
| updated_at | TIMESTAMPTZ | NOT NULL, DEFAULT now() |
|
|
|
|
Indexes: `idx_admin_providers_enabled` (partial, WHERE is_enabled = true).
|
|
|
|
Seeded with: gemini, openai, anthropic.
|
|
|
|
JSONB model structure:
|
|
```json
|
|
[{"model_id": "gemini-2.5-pro", "display_name": "Gemini 2.5 Pro", "is_default": true}]
|
|
```
|
|
|
|
### 3.12 `admin_rate_limits`
|
|
|
|
Per-provider rate limit configuration.
|
|
|
|
| Column | Type | Constraints |
|
|
|---|---|---|
|
|
| id | UUID | PK, DEFAULT gen_random_uuid() |
|
|
| provider_name | VARCHAR(50) | NOT NULL, UNIQUE, FK admin_providers(provider_name) CASCADE |
|
|
| max_requests | INTEGER | NOT NULL, DEFAULT 30 |
|
|
| time_window_seconds | INTEGER | NOT NULL, DEFAULT 60 |
|
|
| updated_at | TIMESTAMPTZ | NOT NULL, DEFAULT now() |
|
|
|
|
Seeded defaults: gemini 29/60s, openai 50/60s, anthropic 40/60s.
|
|
|
|
### 3.13 `user_api_keys`
|
|
|
|
Encrypted user LLM API keys.
|
|
|
|
| Column | Type | Constraints |
|
|
|---|---|---|
|
|
| id | UUID | PK, DEFAULT gen_random_uuid() |
|
|
| user_id | UUID | NOT NULL, FK users(id) CASCADE |
|
|
| provider_name | VARCHAR(50) | NOT NULL |
|
|
| encrypted_key | BYTEA | NOT NULL |
|
|
| nonce | BYTEA | NOT NULL |
|
|
| key_prefix | VARCHAR(20) | NOT NULL |
|
|
| created_at | TIMESTAMPTZ | NOT NULL, DEFAULT now() |
|
|
| updated_at | TIMESTAMPTZ | NOT NULL, DEFAULT now() |
|
|
|
|
Constraint: UNIQUE(user_id, provider_name). Valid providers: gemini, openai, anthropic, brave_search.
|
|
|
|
### 3.14 `audit_log`
|
|
|
|
Admin mutation audit trail.
|
|
|
|
| Column | Type | Constraints |
|
|
|---|---|---|
|
|
| id | UUID | PK, DEFAULT gen_random_uuid() |
|
|
| admin_user_id | UUID | nullable, FK users(id) SET NULL |
|
|
| action | VARCHAR(100) | NOT NULL |
|
|
| target_type | VARCHAR(50) | nullable |
|
|
| target_id | VARCHAR(255) | nullable |
|
|
| details | JSONB | nullable |
|
|
| created_at | TIMESTAMPTZ | NOT NULL, DEFAULT now() |
|
|
|
|
Indexes: `idx_audit_log_created_at` (DESC), `idx_audit_log_admin_user`.
|
|
|
|
---
|
|
|
|
## 4. API Endpoints
|
|
|
|
All endpoints are prefixed with `/api/v1`. Responses are JSON. Errors follow the shape `{ "error": "message" }`.
|
|
|
|
### 4.1 Authentication
|
|
|
|
**POST /auth/register**
|
|
- Auth: Public
|
|
- Body: `{ email: string, display_name?: string, turnstile_token: string }`
|
|
- Response: `{ message: string }`
|
|
- Sends magic link email. Rate limited.
|
|
|
|
**POST /auth/login**
|
|
- Auth: Public
|
|
- Body: `{ email: string, turnstile_token: string }`
|
|
- Response: `{ message: string }`
|
|
- Sends magic link email. Rate limited.
|
|
|
|
**GET /auth/verify?token=...&email=...**
|
|
- Auth: Public
|
|
- Response: Redirect to frontend with session cookie set.
|
|
|
|
**POST /auth/verify**
|
|
- Auth: Public
|
|
- Body: `{ token: string, email: string }`
|
|
- Response: `{ message: string, user: User }`
|
|
- Sets `session` HttpOnly cookie (30-day expiry).
|
|
|
|
**POST /auth/logout**
|
|
- Auth: Authenticated
|
|
- Response: `{ message: string }`
|
|
- Clears session cookie and deletes DB session.
|
|
|
|
**GET /auth/me**
|
|
- Auth: Authenticated
|
|
- Response: `{ id, email, display_name, role, created_at }`
|
|
|
|
### 4.2 Settings
|
|
|
|
**GET /settings**
|
|
- Auth: Authenticated
|
|
- Response: `UserSettings` (creates defaults if not exists)
|
|
|
|
**PUT /settings**
|
|
- Auth: Authenticated
|
|
- Body: `UpdateSettingsRequest` (all fields required)
|
|
- Validation: max_articles_per_source 1-10, max_links_per_source 1-30, batch_size 1-20, source_extraction_window 1-10, article_history_days 0-365, search_agent_behavior max 2000 chars, ai_provider/ai_model/ai_model_websearch max 100 chars.
|
|
- Response: Updated `UserSettings`
|
|
|
|
### 4.3 Themes
|
|
|
|
**GET /themes**
|
|
- Auth: Authenticated
|
|
- Response: `ThemeResponse[]`
|
|
|
|
**POST /themes**
|
|
- Auth: Authenticated
|
|
- Body: `{ name, theme, categories: string[], max_items_per_category?, max_age_days?, summary_length? }`
|
|
- Validation: name non-empty max 200 chars, categories 1-20 non-empty entries, max_items 1-50, max_age 1-365, summary_length 1-3.
|
|
- Response: `ThemeResponse`
|
|
|
|
**PUT /themes/{id}**
|
|
- Auth: Authenticated (owner only)
|
|
- Body: `UpdateThemeRequest` (all fields optional)
|
|
- Response: `ThemeResponse`
|
|
|
|
**DELETE /themes/{id}**
|
|
- Auth: Authenticated (owner only)
|
|
- Response: 204 No Content
|
|
|
|
### 4.4 Schedules
|
|
|
|
**GET /themes/{id}/schedule**
|
|
- Auth: Authenticated (theme owner)
|
|
- Response: `ScheduleResponse` or 404
|
|
|
|
**PUT /themes/{id}/schedule**
|
|
- Auth: Authenticated (theme owner)
|
|
- Body: `{ enabled, days: string[], time_utc: "HH:MM", emails: string[] }`
|
|
- Validation: days from mon-sun, time HH:MM format, max 3 emails.
|
|
- Response: `ScheduleResponse`
|
|
|
|
**DELETE /themes/{id}/schedule**
|
|
- Auth: Authenticated (theme owner)
|
|
- Response: 204 No Content
|
|
|
|
### 4.5 Sources
|
|
|
|
**GET /sources?theme_id=...**
|
|
- Auth: Authenticated
|
|
- Response: `SourceResponse[]`
|
|
|
|
**POST /sources**
|
|
- Auth: Authenticated
|
|
- Body: `{ title, url, theme_id? }`
|
|
- Validation: title non-empty max 200, URL http(s) max 1000 chars.
|
|
- Response: `SourceResponse`
|
|
|
|
**PUT /sources/preferred**
|
|
- Auth: Authenticated
|
|
- Body: `{ source_ids: UUID[] }`
|
|
- Response: `{ updated: number }`
|
|
|
|
**DELETE /sources/{id}**
|
|
- Auth: Authenticated (owner only)
|
|
- Response: 204 No Content
|
|
|
|
**POST /sources/bulk**
|
|
- Auth: Authenticated
|
|
- Body: `{ sources: CreateSourceRequest[], theme_id? }`
|
|
- Response: `{ imported, skipped, errors }`
|
|
|
|
**POST /sources/import-csv**
|
|
- Auth: Authenticated
|
|
- Body: Multipart file upload (CSV: title,url)
|
|
- Response: `{ imported, skipped, errors }`
|
|
|
|
**GET /sources/export-csv**
|
|
- Auth: Authenticated
|
|
- Response: CSV file download
|
|
|
|
### 4.6 Generation
|
|
|
|
**POST /syntheses/generate**
|
|
- Auth: Authenticated
|
|
- Body: `{ theme_id: UUID }`
|
|
- Response: `{ job_id: UUID }`
|
|
- Creates job in JobStore, spawns background generation task. Returns 409 if user already has active job.
|
|
|
|
**GET /syntheses/generate/{job_id}/progress**
|
|
- Auth: Authenticated (job owner)
|
|
- Response: SSE stream of `ProgressEvent`
|
|
- Events: `progress` (step, message, percent), `complete` (synthesis_id), `error` (message).
|
|
|
|
**POST /syntheses/generate/{job_id}/stop**
|
|
- Auth: Authenticated (job owner)
|
|
- Response: `{ message: string }`
|
|
- Sets cooperative cancellation flag.
|
|
|
|
### 4.7 Syntheses
|
|
|
|
**GET /syntheses**
|
|
- Auth: Authenticated
|
|
- Response: `SynthesisListItem[]` (with section summaries, theme info)
|
|
|
|
**GET /syntheses/{id}**
|
|
- Auth: Authenticated (owner only)
|
|
- Response: `SynthesisResponse` (full sections data)
|
|
|
|
**DELETE /syntheses/{id}**
|
|
- Auth: Authenticated (owner only)
|
|
- Response: 204 No Content
|
|
|
|
**POST /syntheses/{id}/send-email**
|
|
- Auth: Authenticated
|
|
- Body: `{ email: string }`
|
|
- Response: `{ message: string }`
|
|
|
|
**GET /syntheses/{id}/export/markdown**
|
|
- Auth: Authenticated
|
|
- Response: Markdown file download
|
|
|
|
**GET /syntheses/{id}/export/pdf**
|
|
- Auth: Authenticated
|
|
- Response: PDF file download
|
|
|
|
### 4.8 Article History & Provenance
|
|
|
|
**GET /article-history?limit=&offset=&job_id=&status=**
|
|
- Auth: Authenticated
|
|
- Response: `{ items: ArticleHistoryEntry[], total: number }`
|
|
|
|
**DELETE /article-history**
|
|
- Auth: Authenticated
|
|
- Response: `{ deleted: number }`
|
|
|
|
**GET /syntheses/{id}/provenance**
|
|
- Auth: Authenticated
|
|
- Response: `ArticleHistoryEntry[]` (articles with status "used" for this synthesis's job_id)
|
|
|
|
### 4.9 LLM Call Logs
|
|
|
|
**GET /llm-logs/{job_id}**
|
|
- Auth: Authenticated
|
|
- Response: `LlmCallLogEntry[]`
|
|
|
|
### 4.10 User API Keys
|
|
|
|
**GET /user/api-keys**
|
|
- Auth: Authenticated
|
|
- Response: `ApiKeyResponse[]` (id, provider_name, key_prefix, timestamps; never the full key)
|
|
|
|
**POST /user/api-keys**
|
|
- Auth: Authenticated
|
|
- Body: `{ provider_name, api_key }`
|
|
- Validation: provider in (gemini, openai, anthropic, brave_search), key 8-500 chars.
|
|
- Response: `ApiKeyResponse`
|
|
- Encrypts key with AES-256-GCM before storage; upserts (one key per user per provider).
|
|
|
|
**DELETE /user/api-keys/{provider}**
|
|
- Auth: Authenticated
|
|
- Response: 204 No Content
|
|
|
|
**POST /user/api-keys/{provider}/test**
|
|
- Auth: Authenticated
|
|
- Response: `{ success: boolean, message: string }`
|
|
- Decrypts key, calls provider test endpoint.
|
|
|
|
**POST /user/api-keys/export**
|
|
- Auth: Authenticated
|
|
- Response: `{ keys: [{ provider_name, api_key }] }`
|
|
- Decrypts and returns all keys (used for backup/migration).
|
|
|
|
### 4.11 Public Configuration
|
|
|
|
**GET /config/providers**
|
|
- Auth: Authenticated
|
|
- Response: `ProviderConfigResponse[]` (enabled providers with model lists for scraping and websearch)
|
|
|
|
### 4.12 Admin Endpoints
|
|
|
|
All admin endpoints require `AdminUser` extractor (role = admin).
|
|
|
|
**GET /admin/providers**
|
|
- Response: `AdminProviderResponse[]`
|
|
|
|
**POST /admin/providers**
|
|
- Body: `CreateProviderRequest`
|
|
- Validation: provider_name in (gemini, openai, anthropic), at least one model per list, at most one default per list.
|
|
- Response: `AdminProviderResponse`
|
|
|
|
**PUT /admin/providers/{id}**
|
|
- Body: `UpdateProviderRequest` (all fields optional)
|
|
- Response: `AdminProviderResponse`
|
|
|
|
**DELETE /admin/providers/{id}**
|
|
- Response: 204 No Content
|
|
|
|
**GET /admin/rate-limits**
|
|
- Response: `RateLimitResponse[]`
|
|
|
|
**PUT /admin/rate-limits/{provider_name}**
|
|
- Body: `{ max_requests: 1-1000, time_window_seconds: 1-3600 }`
|
|
- Response: `RateLimitResponse`
|
|
- Hot-reloads the in-memory provider rate limiter.
|
|
|
|
**GET /admin/users**
|
|
- Response: `AdminUserResponse[]`
|
|
|
|
**PUT /admin/users/{id}/role**
|
|
- Body: `{ role: "user" | "admin" }`
|
|
- Response: `{ message: string }`
|
|
|
|
**GET /health**
|
|
- Auth: Public
|
|
- Response: `{ status: "ok" }`
|
|
|
|
---
|
|
|
|
## 5. Generation Pipeline Technical Flow
|
|
|
|
### Overview
|
|
|
|
The pipeline runs as a background tokio task spawned by `POST /syntheses/generate`. It has a 15-minute global timeout and supports cooperative cancellation via `AtomicBool`.
|
|
|
|
### Initialization
|
|
|
|
1. Load `UserSettings` from DB (or create defaults)
|
|
2. Cleanup old article history (entries older than `article_history_days` with dropped status) and truncate old LLM call logs
|
|
3. Load the target `Theme` (categories, max_items, max_age_days, summary_length)
|
|
4. Load user `Sources` for the theme
|
|
5. Decrypt user's LLM API key, create `Arc<dyn LlmProvider>` via factory
|
|
6. Resolve models: `ai_model` (for scraping/classification) and `ai_model_websearch` (for web search); user override or admin default fallback
|
|
7. Initialize per-user rate limiter (from settings or admin defaults)
|
|
8. Initialize tracking structures: `article_scraped` (category -> Vec<NewsItem>), `source_counts`, `url_source`, `filled_counts`, `seen_urls`, `pending_traces`
|
|
|
|
### Phase 1: Personalized Sources
|
|
|
|
Skipped if user has 0 sources for the theme.
|
|
|
|
**1a. Windowed source extraction**
|
|
|
|
- Query article_history for the last source used; reorder sources in a rolling window starting after that source
|
|
- Select up to `source_extraction_window` sources per generation
|
|
- For each source (bounded concurrency of 5): fetch page HTML, extract up to `max_links_per_source` article URLs via HTML parsing (same-domain, non-homepage, no static assets)
|
|
- Deduplicate URLs cross-source via `seen_urls`
|
|
- Batch-check `article_history` for already-seen URL hashes; filter matches (traced as `filtered_history`)
|
|
- Shuffle remaining candidates to interleave sources
|
|
- Track url -> source in `url_source`
|
|
|
|
**1b. Batch scrape + classify**
|
|
|
|
Processing in batches of `settings.batch_size`:
|
|
|
|
- **Batch assembly**: Pull up to batch_size candidates, skip if `source_counts[domain] >= max_articles_per_source` (traced as `filtered_diversity`)
|
|
- **Scrape** (JoinSet, parallel): SSRF check, 15s timeout, 5MB limit, HTML parsing, title/date/body extraction, soft-404 detection. Skip empty/too-old articles.
|
|
- **Classify** (JoinSet, parallel): Rate limit check (60s wait), send title + first 500 chars to LLM with categories list. LLM returns `{title, summary, category}`. Validate category via `assign_category()` (fallback to "Autre", drop if full).
|
|
- **LLM call logging**: Every LLM call is logged with full prompt, response, timing, and article URL.
|
|
- **Early exit**: Stop when total articles >= `(num_categories + 1) * max_items_per_category`.
|
|
- Batch-flush pending traces to `article_history`.
|
|
|
|
### Phase 2: Web Search Fallback
|
|
|
|
Skipped if all categories are filled to `max_items_per_category`.
|
|
|
|
**2a. Compute gaps**: For each category, `needed = max_items - filled`.
|
|
|
|
**2b. Path selection** based on `settings.use_brave_search`:
|
|
|
|
**Path A -- Brave Search** (`use_brave_search = true`):
|
|
- Decrypt user's Brave Search API key
|
|
- Query: `"{theme} actualites"`, up to 20 results, freshness mapped from `max_age_days` (pd/pw/pm/py)
|
|
- Filter results through `filter_phase2_url()`: homepage filter, cross-phase dedup, article history check, source diversity check
|
|
- Batch scrape + classify (same logic as Phase 1b, source_type = "brave_search")
|
|
|
|
**Path B -- LLM Web Search** (`use_brave_search = false`):
|
|
- Build search prompt with theme, categories, and gap counts
|
|
- Call LLM with `ai_model_websearch` model; returns structured JSON: `{category_0: [{title, url, summary}], ...}`
|
|
- Filter URLs through `filter_phase2_url()`
|
|
- Scrape each result sequentially to validate; keep LLM-provided title/summary (no re-classification)
|
|
- source_type = "web_search"
|
|
|
|
### Save & Record
|
|
|
|
1. Error if all article lists are empty
|
|
2. Order sections: user-defined categories first (in order), then "Autre" if non-empty
|
|
3. Sanitize: strip `\u0000` null bytes from JSON (PostgreSQL JSONB requirement)
|
|
4. Insert synthesis row: job_id, week (ISO week string), sections (JSONB), status "completed", theme_id
|
|
5. Record used articles: batch-insert `article_history` entries with status "used", synthesis_id, and correct source_type
|
|
|
|
---
|
|
|
|
## 6. LLM Provider Abstraction
|
|
|
|
### Trait Definition
|
|
|
|
```rust
|
|
#[async_trait]
|
|
pub trait LlmProvider: Send + Sync {
|
|
fn provider_id(&self) -> &str;
|
|
async fn call_llm(&self, model: &str, system_prompt: &str,
|
|
user_prompt: &str, response_schema: &Value)
|
|
-> Result<Value, AppError>;
|
|
}
|
|
```
|
|
|
|
All calls use structured JSON output (response_schema defines the expected shape).
|
|
|
|
### Implementations
|
|
|
|
| Provider | Module | API Endpoint | Auth Method |
|
|
|---|---|---|---|
|
|
| Google Gemini | `llm/gemini.rs` | `generativelanguage.googleapis.com` | Query param `?key=` |
|
|
| OpenAI | `llm/openai.rs` | `api.openai.com/v1/chat/completions` | Bearer token |
|
|
| Anthropic | `llm/anthropic.rs` | `api.anthropic.com/v1/messages` | `x-api-key` header |
|
|
| Mock | `llm/mock.rs` | N/A (in-memory) | N/A |
|
|
|
|
### Factory
|
|
|
|
`llm/factory.rs` provides `create_provider(provider_name, api_key, http_client) -> Arc<dyn LlmProvider>`. Matches on provider name string.
|
|
|
|
### Response Schema
|
|
|
|
`llm/schema.rs` builds JSON Schema definitions for:
|
|
- Classification/summarization: `{title, summary, category, is_article}`
|
|
- Web search: `{category_0: [{title, url, summary}], ...}` with per-category arrays
|
|
- Source link extraction: `{links: [{url}]}`
|
|
|
|
### Error Mapping
|
|
|
|
`map_provider_http_error()` translates HTTP status codes to `AppError` variants:
|
|
- 400 -> BadRequest
|
|
- 401/403 -> BadRequest (invalid key)
|
|
- 404 -> BadRequest (model not found)
|
|
- 429/529 -> RateLimited
|
|
- Other -> Internal
|
|
|
|
---
|
|
|
|
## 7. Background Tasks
|
|
|
|
### Session Cleanup
|
|
|
|
Runs hourly via `tokio::spawn`. Calls `db::sessions::delete_expired` to remove sessions past their `expires_at` timestamp.
|
|
|
|
### Job Store Cleanup
|
|
|
|
`JobStore::cleanup_expired` removes job entries older than 1 hour (the TTL constant). Called periodically. Releases user locks for expired jobs.
|
|
|
|
### Scheduler
|
|
|
|
Runs every minute via `tokio::spawn` with a 60-second interval. For each tick:
|
|
|
|
1. `current_day_code()` -> "mon" through "sun"
|
|
2. `find_due_schedules(pool, day, time)` -> queries enabled schedules matching current day and time (HH:MM)
|
|
3. For each due schedule:
|
|
- Skip if `job_store.has_active_job(user_id)` returns Some (manual generation in progress)
|
|
- Create a temporary `watch::channel` and `AtomicBool`
|
|
- Call `synthesis::run_generation_inner` directly (bypasses job store)
|
|
- On success: send emails to configured recipients (up to 3), mark schedule as run
|
|
- On failure: log error, do not mark as run
|
|
|
|
---
|
|
|
|
## 8. Configuration
|
|
|
|
### Environment Variables
|
|
|
|
| Variable | Required | Default | Description |
|
|
|---|---|---|---|
|
|
| DATABASE_URL | Yes | - | PostgreSQL connection string |
|
|
| MASTER_ENCRYPTION_KEY | Yes | - | 64 hex chars (32 bytes) for AES-256-GCM |
|
|
| APP_URL | Yes | - | Public URL (CORS, magic links, cookies). No trailing slash. |
|
|
| PORT | No | 8080 | HTTP server port |
|
|
| RUST_LOG | No | - | Logging filter (e.g., "info,ai_synth_backend=debug") |
|
|
| STATIC_DIR | No | ../frontend/dist | Path to built SolidJS files |
|
|
| RESEND_API_KEY | Yes | - | Resend email service API key |
|
|
| EMAIL_FROM | Yes | - | Sender address for emails |
|
|
| TURNSTILE_SECRET_KEY | Yes | - | Cloudflare Turnstile server secret |
|
|
| TURNSTILE_SITE_KEY | Yes | - | Cloudflare Turnstile client key |
|
|
| POSTGRES_PASSWORD | Yes | - | Used by docker-compose for DB container |
|
|
|
|
### Startup Validation
|
|
|
|
`AppConfig::validate()` checks at startup:
|
|
- `MASTER_ENCRYPTION_KEY` is exactly 64 hex characters
|
|
- `APP_URL` starts with http:// or https:// and has no trailing slash
|
|
|
|
The application refuses to start with invalid configuration.
|
|
|
|
### User Settings Model
|
|
|
|
Default values applied when a user has no saved settings:
|
|
|
|
| Setting | Default | Range |
|
|
|---|---|---|
|
|
| max_articles_per_source | 3 | 1-10 |
|
|
| max_links_per_source | 8 | 1-30 |
|
|
| use_brave_search | false | boolean |
|
|
| article_history_days | 90 | 0-365 |
|
|
| batch_size | 5 | 1-20 |
|
|
| source_extraction_window | 3 | 1-10 |
|
|
| search_agent_behavior | "" | max 2000 chars |
|
|
| ai_provider | "" | max 100 chars |
|
|
| ai_model | "" | max 100 chars |
|
|
| ai_model_websearch | "" | max 100 chars |
|
|
| rate_limit_max_requests | null | >= 1 if set |
|
|
| rate_limit_time_window_seconds | null | >= 1 if set |
|