13 KiB
Design: AI Weekly Synth v2 Changes
Date: 2026-03-21 Source: docs/functional-spec-v2.md Scope: 8 changes to implement on the existing Rust/SolidJS codebase
Context
The v2 functional spec describes improvements to the original React/Firebase app. The codebase has already been rewritten to Rust (Axum) + SolidJS + Postgres. Several v2 features were implemented during the rewrite (dynamic sections, "par IA" title, search agent behavior, CSV import/export, 4-5 line summaries). This design covers the remaining gaps.
Dropped from v2 spec:
- Danger Zone (Section 3.6) — per-item delete is sufficient; bulk delete is high risk, low convenience
- Firestore rules (Section 7) — N/A, we use Postgres
- Cross-provider model mixing — single provider with two model selections instead
Spec divergences (design-time enhancements):
- Settings export can optionally include API keys (not in functional spec, added for self-hosted backup use case)
- Rate limit overrides are nullable (use admin defaults when unset) rather than hardcoded 29/60000
- Title extraction priority is
<title>->og:title-><h1>-> AI fallback (per project owner preference, differs from functional spec)
Change 1: Settings Export/Import
What
Two buttons in the Settings page header: "Exporter" (download JSON) and "Importer" (upload JSON). A checkbox "Inclure les cles API" controls whether decrypted API keys are included in the export.
Backend
New endpoint for decrypted key export:
POST /api/v1/user/api-keys/export— returns decrypted keys as[{ provider_name, api_key }]. Uses POST (not GET) to require CSRF token. Rate-limited to 3 calls per minute. Audit-logged.
Import uses the existing PUT /api/v1/settings (updated in Change 2 to accept new fields) and POST /api/v1/user/api-keys per key.
Frontend
- Download and Upload icons in Settings header
- Checkbox: "Inclure les cles API" with warning: "Les cles API seront incluses en clair dans le fichier. Ne partagez pas ce fichier."
- On export: fetch settings + optionally POST to export keys, merge into JSON, trigger download as
settings.json - On import: parse JSON, validate shape, populate form + call API key endpoints, show success banner ("N'oubliez pas d'enregistrer")
- Missing fields fall back to defaults (merge over
DEFAULT_SETTINGS)
Dependency: Requires Change 2 (new settings fields) to be implemented first so the import can handle all fields.
Files to modify
backend/src/handlers/api_keys.rs— addexport_keyshandlerbackend/src/router.rs— add routefrontend/src/pages/Settings.tsx— add export/import UIfrontend/src/api/apiKeys.ts— addexportKeys()methodfrontend/src/i18n/fr.ts— add translation keys
Change 2: Provider/Model Selection + Two Model Dropdowns
What
The backend settings table currently has NO ai_provider or ai_model columns — the frontend types have them but they aren't persisted. This change adds provider/model persistence plus a second model for writing.
Backend
Migration (20260321000010_add_settings_v2_fields.sql):
ALTER TABLE settings
ADD COLUMN ai_provider VARCHAR(100) NOT NULL DEFAULT '',
ADD COLUMN ai_model VARCHAR(100) NOT NULL DEFAULT '',
ADD COLUMN ai_model_writing VARCHAR(100) NOT NULL DEFAULT '';
Note: table is called settings (not user_settings).
Model changes (models/settings.rs):
- Add
ai_provider,ai_model,ai_model_writingtoUserSettings,SettingsResponse,UpdateSettingsRequest - Validation: if
ai_model_writingis empty, fall back toai_modelat generation time
DB changes (db/settings.rs):
- Update
get_or_create_defaultandupsertqueries to include new columns
Pipeline changes (services/synthesis.rs):
- Update
resolve_model()to read user'sai_providerandai_modelfrom settings first, fall back to admin default model only if user hasn't selected one - Pass 1 uses
ai_model, Pass 2 usesai_model_writing(orai_modelif writing model is empty)
Frontend
- Settings page: single provider dropdown + two model dropdowns
- "Modele d'IA (Recherche et Extraction)" — uses
ai_model - "Modele d'IA (Redaction et Synthese)" — uses
ai_model_writing
- "Modele d'IA (Recherche et Extraction)" — uses
- Both model dropdowns filtered by selected provider
- If only one provider configured, hide provider dropdown
- Help text per spec sections 3.3 and 3.4
Files to modify
backend/migrations/20260321000010_add_settings_v2_fields.sql— new migrationbackend/src/models/settings.rs— add 3 fields + validationbackend/src/db/settings.rs— update queriesbackend/src/handlers/settings.rs— update response/request handlingbackend/src/services/synthesis.rs— use user's provider/model selectionfrontend/src/types.ts— already has fields, verify alignmentfrontend/src/pages/Settings.tsx— two model dropdowns with labels/helpfrontend/src/i18n/fr.ts— labels and help text
Change 3: User Rate Limit Overrides
What
Two number fields in Settings: "Requetes maximum" and "Fenetre de temps (secondes)". Override the admin-configured defaults for the user's provider. Uses seconds (not milliseconds) to match the existing admin_rate_limits.time_window_seconds convention.
Backend
Migration (same file as Change 2):
ALTER TABLE settings
ADD COLUMN rate_limit_max_requests INTEGER,
ADD COLUMN rate_limit_time_window_seconds INTEGER;
Both columns are nullable — NULL means "use admin default".
Model changes (models/settings.rs):
- Add
rate_limit_max_requests: Option<i32>andrate_limit_time_window_seconds: Option<i32> - Validation: if set, max_requests >= 1, time_window_seconds >= 1
Pipeline integration (services/synthesis.rs):
- When the generation pipeline needs a rate limit slot, create a temporary per-generation rate limiter using the user's overrides (if set) or the admin defaults for that provider. This avoids modifying the global
ProviderRateLimiterwhich is shared across all users.
Frontend
- New "Limitation de taux" section in Settings (below categories, separated by a rule)
- Two number inputs: "Requetes maximum" (min: 1) and "Fenetre de temps (secondes)" (min: 1)
- Help text: "Configurez le nombre maximum de requetes autorisees pendant la fenetre de temps specifiee. Laissez vide pour utiliser les valeurs par defaut de l'administrateur."
- Show effective rate when values are set: "{max_requests} requetes / {time_window} secondes"
- A "Reinitialiser" link to clear overrides (sets both to null)
Files to modify
backend/migrations/20260321000010_add_settings_v2_fields.sql— add columnsbackend/src/models/settings.rs— add nullable fields + validationbackend/src/db/settings.rs— update queriesbackend/src/services/synthesis.rs— per-generation rate limiterfrontend/src/types.ts— add optional fields toUserSettingsfrontend/src/pages/Settings.tsx— rate limit sectionfrontend/src/i18n/fr.ts— labels and help text
Change 4: Original Title Preservation
What
During scraping, extract the original article title with priority: <title> -> og:title -> first <h1> -> AI-provided title as fallback. Pass this to the rewrite prompt so the AI preserves it, with language rules.
Backend
Scraper changes (services/scraper.rs):
- Refactor
extract_page_title()to implement the full priority chain:<title>element text (already exists)<meta property="og:title">content attribute- First
<h1>element text - Returns
Noneif all are empty (pipeline uses AI-provided title as fallback)
Model changes (models/synthesis.rs):
- Add
original_title: StringtoScrapedNewsItem(populated from scraper result or AI-provided title as fallback)
Prompt changes (services/prompts.rs):
- Update
build_rewrite_prompt()to includeoriginal_titleper item - Add language rules: "Utilise le titre original comme base. Les titres en anglais doivent rester en anglais. Les titres en francais doivent rester en francais. Les titres dans d'autres langues doivent etre traduits en francais."
Files to modify
backend/src/services/scraper.rs— enhanced title extraction with priority chainbackend/src/models/synthesis.rs—ScrapedNewsItem.original_titlebackend/src/services/prompts.rs— rewrite prompt with title preservation
Change 5: Enhanced Broken Link Detection
What
Add two detection layers to the scraper, on top of the existing title/H1 keyword matching.
Layer 1: Canonical/OG URL + redirect error path detection
After parsing HTML:
- Extract
<link rel="canonical" href="...">and<meta property="og:url" content="..."> - Capture the final URL after redirects from
response.url()(reqwest provides this) - If any of these URLs' path contains
/404,/404.html,/error, or/not-found, mark as soft-404
Layer 2: Short-page body text analysis
If extracted body text has fewer than 1500 characters, scan it for error phrases:
- English: "page not found", "could not be found", "the requested page", "does not exist"
- French: "page introuvable", "page non trouvee", "n'existe pas", "n'a pas ete trouvee"
- Combined: "404" appearing near "not found" or "error" (within 50 chars)
If any match, mark as soft-404.
Layer 3: noindex meta tag (currently missing, add it)
- Check for
<meta name="robots" content="noindex">— if present, mark as soft-404
Files to modify
backend/src/services/scraper.rs— adddetect_canonical_error(),detect_short_page_error(),detect_noindex(), capture final redirect URL, integrate all intoscrape_url()
Change 6: Deep-Link URL Enforcement
What
After the LLM returns URLs in the search pass, reject any URL whose path is / or empty (homepage URLs). Filter before scraping.
Backend
- Add a filter step in
services/synthesis.rsbetween parsing LLM output and scraping - For each
NewsItem, parse URL withurl::Url, check ifpath()is/or empty - Log rejected URLs at
warnlevel, continue with remaining items - Update search prompt in
services/prompts.rs: add "Ne retourne JAMAIS des URLs de pages d'accueil (homepage). Fournis toujours des liens directs vers des articles specifiques."
Files to modify
backend/src/services/synthesis.rs— post-LLM URL filterbackend/src/services/prompts.rs— reinforce deep-link instruction
Change 7: Database Migration
Single migration file covering Changes 2 and 3:
File: backend/migrations/20260321000010_add_settings_v2_fields.sql
ALTER TABLE settings
ADD COLUMN ai_provider VARCHAR(100) NOT NULL DEFAULT '',
ADD COLUMN ai_model VARCHAR(100) NOT NULL DEFAULT '',
ADD COLUMN ai_model_writing VARCHAR(100) NOT NULL DEFAULT '',
ADD COLUMN rate_limit_max_requests INTEGER,
ADD COLUMN rate_limit_time_window_seconds INTEGER;
Change 8: Empty Sections Message
What
When a synthesis has empty or null sections, show "Aucune section trouvee dans cette synthese." instead of rendering nothing.
Frontend
- In
SynthesisDetail.tsx: wrap sections rendering in<Show when={sections.length > 0} fallback={...}>with italic centered message
Backend
- In
models/synthesis.rs: updateSynthesisResponse::try_fromto handle null/invalid sections gracefully (return empty vec instead of error) - In
services/email.rs: add fallback text in email template when sections is empty
Files to modify
frontend/src/pages/SynthesisDetail.tsx— empty state fallbackfrontend/src/i18n/fr.ts— add'synthesis.noSections'keybackend/src/models/synthesis.rs— graceful null/empty sections handlingbackend/src/services/email.rs— email template empty state
Implementation Order
Changes should be implemented in this order due to dependencies:
- Change 7 (migration) — adds all new columns
- Change 2 (provider/model selection) — depends on migration
- Change 3 (rate limit overrides) — depends on migration
- Change 1 (settings export/import) — depends on Change 2 for full field support
- Changes 4, 5, 6 (scraper/pipeline improvements) — independent of each other
- Change 8 (empty sections) — independent, can be done anytime
Verification Plan
Backend
cd backend && cargo check # compiles
cd backend && cargo test --lib # all unit tests pass
Frontend
cd frontend && npx tsc --noEmit # type-checks
cd frontend && npx vitest run # all tests pass
cd frontend && npx vite build # production build succeeds
Manual Testing
- Settings: select provider, pick research model, pick writing model, save, reload — values persisted
- Settings: set rate limit overrides, save — verify generation respects them
- Settings: export JSON (with/without keys), import JSON — form populated correctly
- Generate: verify original titles preserved in output
- Generate: verify homepage URLs filtered out
- Generate: verify broken links with canonical /404 paths rejected
- Generate: verify noindex pages rejected
- View synthesis with empty sections: see fallback message