Phase 5: Generation pipeline with SSE progress, syntheses CRUD
Backend: - Full 2-pass generation pipeline: LLM search -> URL scraping -> LLM rewrite - Async generation with tokio::spawn, JobStore with per-user concurrency limit - SSE progress streaming via axum::response::Sse + tokio::sync::watch - Syntheses CRUD: list (paginated), get (ownership check), delete - Prompt construction ported from original geminiService.ts - Parallel URL scraping with bounded concurrency (max 10) - Graceful partial failure handling (some URLs fail -> continue) - 36 new unit tests, 16 integration tests Frontend: - Home dashboard: synthesis card grid, week badges, delete with confirmation - Generate page: SSE-driven progress bar, step checklist, auto-redirect - Synthesis detail: section-by-section display, external links, delete - SSE client helper with auto-reconnect (exponential backoff) - Date utilities with French locale formatting Critical fixes applied: - SSE EventSource now sends credentials (withCredentials: true) - Gemini error logging sanitized to prevent API key leak in logs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>master
parent
439e547367
commit
aa6f1ba76b
@ -0,0 +1,12 @@
|
||||
-- Create the syntheses table for storing generated news syntheses.
|
||||
|
||||
CREATE TABLE syntheses (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
week VARCHAR(10) NOT NULL,
|
||||
sections JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'completed',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_syntheses_user_id_created_at ON syntheses(user_id, created_at DESC);
|
||||
@ -0,0 +1,123 @@
|
||||
//! Database queries for the `syntheses` table.
|
||||
//!
|
||||
//! All queries enforce ownership isolation via `user_id` to ensure users
|
||||
//! can only access their own syntheses.
|
||||
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::errors::AppError;
|
||||
use crate::models::synthesis::Synthesis;
|
||||
|
||||
/// List syntheses for a user, ordered by creation date (newest first).
|
||||
///
|
||||
/// Supports pagination via `limit` and `offset`.
|
||||
pub async fn list_for_user(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<Synthesis>, AppError> {
|
||||
let rows = sqlx::query_as::<_, Synthesis>(
|
||||
r#"
|
||||
SELECT id, user_id, week, sections, status, created_at
|
||||
FROM syntheses
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Get a synthesis by ID (without ownership check).
|
||||
///
|
||||
/// Used internally when ownership is verified by other means.
|
||||
pub async fn get_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Synthesis>, AppError> {
|
||||
let row = sqlx::query_as::<_, Synthesis>(
|
||||
r#"
|
||||
SELECT id, user_id, week, sections, status, created_at
|
||||
FROM syntheses
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
/// Get a synthesis by ID, but only if it belongs to the given user.
|
||||
///
|
||||
/// Returns `None` if the synthesis does not exist or belongs to another user.
|
||||
pub async fn get_by_id_for_user(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> Result<Option<Synthesis>, AppError> {
|
||||
let row = sqlx::query_as::<_, Synthesis>(
|
||||
r#"
|
||||
SELECT id, user_id, week, sections, status, created_at
|
||||
FROM syntheses
|
||||
WHERE id = $1 AND user_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
/// Create a new synthesis record.
|
||||
///
|
||||
/// The `sections_json` should be a valid JSONB value representing
|
||||
/// a `Vec<NewsSection>`.
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
week: &str,
|
||||
sections_json: &serde_json::Value,
|
||||
) -> Result<Synthesis, AppError> {
|
||||
let row = sqlx::query_as::<_, Synthesis>(
|
||||
r#"
|
||||
INSERT INTO syntheses (user_id, week, sections, status)
|
||||
VALUES ($1, $2, $3, 'completed')
|
||||
RETURNING id, user_id, week, sections, status, created_at
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(week)
|
||||
.bind(sections_json)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
/// Delete a synthesis by ID, but only if it belongs to the given user.
|
||||
///
|
||||
/// Returns `true` if a row was deleted, `false` if no matching row was found
|
||||
/// (either the ID doesn't exist or it belongs to a different user).
|
||||
pub async fn delete(pool: &PgPool, id: Uuid, user_id: Uuid) -> Result<bool, AppError> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
DELETE FROM syntheses
|
||||
WHERE id = $1 AND user_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(user_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
@ -0,0 +1,130 @@
|
||||
//! Generation handlers: trigger generation and stream progress via SSE.
|
||||
//!
|
||||
//! - `POST /api/v1/syntheses/generate` — start async generation
|
||||
//! - `GET /api/v1/syntheses/generate/:job_id/progress` — SSE progress stream
|
||||
|
||||
use std::convert::Infallible;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
use tokio_stream::wrappers::WatchStream;
|
||||
use tokio_stream::StreamExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::app_state::AppState;
|
||||
use crate::errors::AppError;
|
||||
use crate::middleware::auth::AuthUser;
|
||||
use crate::services::synthesis::{self, ProgressEvent};
|
||||
|
||||
/// Response body for `POST /api/v1/syntheses/generate`.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct GenerateResponse {
|
||||
pub job_id: Uuid,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// `POST /api/v1/syntheses/generate`
|
||||
///
|
||||
/// Triggers an asynchronous synthesis generation. Returns immediately
|
||||
/// with a 202 Accepted status and a `job_id` that can be used to
|
||||
/// subscribe to progress events via SSE.
|
||||
///
|
||||
/// Rejects the request if the user already has a generation in progress.
|
||||
pub async fn trigger_generate(
|
||||
auth_user: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
// Check if user already has an active job
|
||||
if let Some(existing_job_id) = state.job_store.has_active_job(auth_user.id) {
|
||||
tracing::warn!(
|
||||
user_id = %auth_user.id,
|
||||
existing_job_id = %existing_job_id,
|
||||
"User tried to start generation while one is already in progress"
|
||||
);
|
||||
return Err(AppError::BadRequest(
|
||||
"Une generation est deja en cours. Veuillez attendre qu'elle se termine.".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Create the job in the store
|
||||
let (job_id, tx) = state
|
||||
.job_store
|
||||
.create_job(auth_user.id)
|
||||
.ok_or_else(|| {
|
||||
AppError::BadRequest(
|
||||
"Une generation est deja en cours. Veuillez attendre qu'elle se termine.".into(),
|
||||
)
|
||||
})?;
|
||||
|
||||
tracing::info!(
|
||||
user_id = %auth_user.id,
|
||||
job_id = %job_id,
|
||||
"Starting synthesis generation"
|
||||
);
|
||||
|
||||
// Spawn the generation pipeline as a background task
|
||||
let state_clone = state.clone();
|
||||
let user_id = auth_user.id;
|
||||
tokio::spawn(async move {
|
||||
synthesis::run_generation(job_id, state_clone, user_id, tx).await;
|
||||
});
|
||||
|
||||
Ok((
|
||||
StatusCode::ACCEPTED,
|
||||
Json(GenerateResponse {
|
||||
job_id,
|
||||
message: "Generation demarree.".into(),
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
/// `GET /api/v1/syntheses/generate/:job_id/progress`
|
||||
///
|
||||
/// Server-Sent Events (SSE) endpoint that streams generation progress.
|
||||
///
|
||||
/// Event types:
|
||||
/// - `progress`: `{type: "progress", step: "...", message: "...", percent: N}`
|
||||
/// - `complete`: `{type: "complete", synthesis_id: "..."}`
|
||||
/// - `error`: `{type: "error", message: "..."}`
|
||||
///
|
||||
/// The stream includes a keepalive ping every 15 seconds to prevent
|
||||
/// connection timeouts through reverse proxies.
|
||||
pub async fn progress_stream(
|
||||
auth_user: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
Path(job_id): Path<Uuid>,
|
||||
) -> Result<Sse<impl tokio_stream::Stream<Item = Result<Event, Infallible>>>, AppError> {
|
||||
// Get the watch receiver, verifying ownership
|
||||
let rx = state
|
||||
.job_store
|
||||
.subscribe(job_id, auth_user.id)
|
||||
.ok_or_else(|| {
|
||||
AppError::NotFound("Generation introuvable ou deja terminee.".into())
|
||||
})?;
|
||||
|
||||
// Convert the watch stream to an SSE event stream.
|
||||
// The watch channel immediately delivers the latest value on subscribe,
|
||||
// so clients that reconnect get caught up instantly.
|
||||
let stream = WatchStream::new(rx).map(|event| {
|
||||
let event_type = match &event {
|
||||
ProgressEvent::Progress { .. } => "progress",
|
||||
ProgressEvent::Complete { .. } => "complete",
|
||||
ProgressEvent::Error { .. } => "error",
|
||||
};
|
||||
|
||||
let data = serde_json::to_string(&event).unwrap_or_default();
|
||||
|
||||
Ok(Event::default().event(event_type).data(data))
|
||||
});
|
||||
|
||||
Ok(Sse::new(stream).keep_alive(
|
||||
KeepAlive::new()
|
||||
.interval(Duration::from_secs(15))
|
||||
.text("ping"),
|
||||
))
|
||||
}
|
||||
@ -0,0 +1,99 @@
|
||||
//! Syntheses CRUD handlers.
|
||||
//!
|
||||
//! - `GET /api/v1/syntheses` — list user's syntheses (paginated)
|
||||
//! - `GET /api/v1/syntheses/:id` — get synthesis detail
|
||||
//! - `DELETE /api/v1/syntheses/:id` — delete a synthesis
|
||||
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::app_state::AppState;
|
||||
use crate::db;
|
||||
use crate::errors::AppError;
|
||||
use crate::middleware::auth::AuthUser;
|
||||
use crate::models::synthesis::{SynthesisListItem, SynthesisResponse};
|
||||
|
||||
/// Query parameters for `GET /api/v1/syntheses`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListQuery {
|
||||
/// Maximum number of syntheses to return (default: 20, max: 100).
|
||||
pub limit: Option<i64>,
|
||||
/// Number of syntheses to skip (default: 0).
|
||||
pub offset: Option<i64>,
|
||||
}
|
||||
|
||||
/// Response for `GET /api/v1/syntheses`.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ListResponse {
|
||||
pub items: Vec<SynthesisListItem>,
|
||||
}
|
||||
|
||||
/// `GET /api/v1/syntheses`
|
||||
///
|
||||
/// Returns a paginated list of the authenticated user's syntheses,
|
||||
/// ordered by creation date (newest first). Each item includes a
|
||||
/// preview of the first section.
|
||||
pub async fn list(
|
||||
auth_user: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<ListQuery>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let limit = params.limit.unwrap_or(20).clamp(1, 100);
|
||||
let offset = params.offset.unwrap_or(0).max(0);
|
||||
|
||||
let syntheses =
|
||||
db::syntheses::list_for_user(&state.pool, auth_user.id, limit, offset).await?;
|
||||
|
||||
let items: Vec<SynthesisListItem> = syntheses
|
||||
.into_iter()
|
||||
.map(SynthesisListItem::try_from)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(Json(ListResponse { items }))
|
||||
}
|
||||
|
||||
/// `GET /api/v1/syntheses/:id`
|
||||
///
|
||||
/// Returns the full synthesis detail including all sections and items.
|
||||
/// Enforces ownership: a user can only view their own syntheses.
|
||||
pub async fn get(
|
||||
auth_user: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let synthesis = db::syntheses::get_by_id_for_user(&state.pool, id, auth_user.id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Synthese introuvable.".into()))?;
|
||||
|
||||
let response = SynthesisResponse::try_from(synthesis)?;
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
/// `DELETE /api/v1/syntheses/:id`
|
||||
///
|
||||
/// Deletes a synthesis by ID. Enforces ownership: a user can only
|
||||
/// delete their own syntheses. Returns 204 No Content on success.
|
||||
pub async fn delete(
|
||||
auth_user: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let deleted = db::syntheses::delete(&state.pool, id, auth_user.id).await?;
|
||||
|
||||
if !deleted {
|
||||
return Err(AppError::NotFound("Synthese introuvable.".into()));
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
user_id = %auth_user.id,
|
||||
synthesis_id = %id,
|
||||
"Synthesis deleted"
|
||||
);
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@ -0,0 +1,290 @@
|
||||
//! Synthesis model and related types.
|
||||
//!
|
||||
//! A synthesis is a structured collection of news sections, each containing
|
||||
//! categorized news items. Generated by the two-pass LLM pipeline and stored
|
||||
//! as JSONB in the database.
|
||||
|
||||
use chrono::{DateTime, Datelike, IsoWeek, NaiveDate, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A single news item within a section.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct NewsItem {
|
||||
pub title: String,
|
||||
pub url: String,
|
||||
pub summary: String,
|
||||
}
|
||||
|
||||
/// A named section containing a list of news items.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct NewsSection {
|
||||
pub title: String,
|
||||
pub items: Vec<NewsItem>,
|
||||
}
|
||||
|
||||
/// Database row for a synthesis record.
|
||||
#[derive(Debug, Clone, sqlx::FromRow)]
|
||||
pub struct Synthesis {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub week: String,
|
||||
pub sections: serde_json::Value,
|
||||
pub status: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Response shape for `GET /api/v1/syntheses/:id`.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SynthesisResponse {
|
||||
pub id: Uuid,
|
||||
pub week: String,
|
||||
pub sections: Vec<NewsSection>,
|
||||
pub status: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl TryFrom<Synthesis> for SynthesisResponse {
|
||||
type Error = crate::errors::AppError;
|
||||
|
||||
fn try_from(s: Synthesis) -> Result<Self, Self::Error> {
|
||||
let sections: Vec<NewsSection> =
|
||||
serde_json::from_value(s.sections).map_err(|e| {
|
||||
crate::errors::AppError::Internal(anyhow::anyhow!(
|
||||
"Failed to parse synthesis sections: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
id: s.id,
|
||||
week: s.week,
|
||||
sections,
|
||||
status: s.status,
|
||||
created_at: s.created_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Response shape for `GET /api/v1/syntheses` (list view).
|
||||
///
|
||||
/// Includes a preview of the first section to give users context
|
||||
/// without loading the full synthesis payload.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SynthesisListItem {
|
||||
pub id: Uuid,
|
||||
pub week: String,
|
||||
pub status: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub first_section_title: Option<String>,
|
||||
pub first_section_item_count: usize,
|
||||
}
|
||||
|
||||
impl TryFrom<Synthesis> for SynthesisListItem {
|
||||
type Error = crate::errors::AppError;
|
||||
|
||||
fn try_from(s: Synthesis) -> Result<Self, Self::Error> {
|
||||
let sections: Vec<NewsSection> =
|
||||
serde_json::from_value(s.sections).unwrap_or_default();
|
||||
|
||||
let first = sections.first();
|
||||
let first_section_title = first.map(|sec| sec.title.clone());
|
||||
let first_section_item_count = first.map(|sec| sec.items.len()).unwrap_or(0);
|
||||
|
||||
Ok(Self {
|
||||
id: s.id,
|
||||
week: s.week,
|
||||
status: s.status,
|
||||
created_at: s.created_at,
|
||||
first_section_title,
|
||||
first_section_item_count,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate an ISO 8601 week string (e.g., "2026-W12") for a given date.
|
||||
///
|
||||
/// Uses `chrono::Datelike::iso_week()` to get the ISO week number
|
||||
/// and `iso_week().year()` for the ISO year (which can differ from
|
||||
/// the calendar year near year boundaries).
|
||||
pub fn get_iso_week_string(date: NaiveDate) -> String {
|
||||
let iso: IsoWeek = date.iso_week();
|
||||
format!("{}-W{:02}", iso.year(), iso.week())
|
||||
}
|
||||
|
||||
/// Scraped data for a news item, used during the rewrite pass.
|
||||
///
|
||||
/// Combines the original LLM-generated item with content scraped
|
||||
/// from the source URL.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ScrapedNewsItem {
|
||||
pub title: String,
|
||||
pub url: String,
|
||||
pub summary: String,
|
||||
#[serde(rename = "scrapedContent")]
|
||||
pub scraped_content: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn iso_week_string_mid_year() {
|
||||
let date = NaiveDate::from_ymd_opt(2026, 3, 21).unwrap();
|
||||
let week = get_iso_week_string(date);
|
||||
assert_eq!(week, "2026-W12");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_week_string_start_of_year() {
|
||||
// January 1, 2026 is a Thursday, ISO week 1
|
||||
let date = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
|
||||
let week = get_iso_week_string(date);
|
||||
assert_eq!(week, "2026-W01");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_week_string_end_of_year_cross_boundary() {
|
||||
// December 31, 2025 is a Wednesday.
|
||||
// ISO week: 2026-W01 (ISO year can differ from calendar year)
|
||||
let date = NaiveDate::from_ymd_opt(2025, 12, 31).unwrap();
|
||||
let week = get_iso_week_string(date);
|
||||
assert_eq!(week, "2026-W01");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_week_string_week_53() {
|
||||
// 2020-12-31 is a Thursday, ISO week 53 of 2020
|
||||
let date = NaiveDate::from_ymd_opt(2020, 12, 31).unwrap();
|
||||
let week = get_iso_week_string(date);
|
||||
assert_eq!(week, "2020-W53");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn news_item_serialization_roundtrip() {
|
||||
let item = NewsItem {
|
||||
title: "Test Article".into(),
|
||||
url: "https://example.com/article".into(),
|
||||
summary: "A brief summary of the article content.".into(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&item).unwrap();
|
||||
assert_eq!(json["title"], "Test Article");
|
||||
assert_eq!(json["url"], "https://example.com/article");
|
||||
|
||||
let deserialized: NewsItem = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(deserialized, item);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn news_section_serialization_roundtrip() {
|
||||
let section = NewsSection {
|
||||
title: "Major Announcements".into(),
|
||||
items: vec![
|
||||
NewsItem {
|
||||
title: "Article 1".into(),
|
||||
url: "https://example.com/1".into(),
|
||||
summary: "Summary 1".into(),
|
||||
},
|
||||
NewsItem {
|
||||
title: "Article 2".into(),
|
||||
url: "https://example.com/2".into(),
|
||||
summary: "Summary 2".into(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(§ion).unwrap();
|
||||
let deserialized: NewsSection = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(deserialized, section);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthesis_list_item_from_synthesis_with_sections() {
|
||||
let sections = serde_json::json!([
|
||||
{
|
||||
"title": "AI News",
|
||||
"items": [
|
||||
{"title": "Article 1", "url": "https://a.com", "summary": "Sum 1"},
|
||||
{"title": "Article 2", "url": "https://b.com", "summary": "Sum 2"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Research",
|
||||
"items": [
|
||||
{"title": "Paper 1", "url": "https://c.com", "summary": "Sum 3"}
|
||||
]
|
||||
}
|
||||
]);
|
||||
|
||||
let synthesis = Synthesis {
|
||||
id: Uuid::nil(),
|
||||
user_id: Uuid::nil(),
|
||||
week: "2026-W12".into(),
|
||||
sections,
|
||||
status: "completed".into(),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
|
||||
let list_item = SynthesisListItem::try_from(synthesis).unwrap();
|
||||
assert_eq!(list_item.first_section_title, Some("AI News".into()));
|
||||
assert_eq!(list_item.first_section_item_count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthesis_list_item_from_synthesis_empty_sections() {
|
||||
let synthesis = Synthesis {
|
||||
id: Uuid::nil(),
|
||||
user_id: Uuid::nil(),
|
||||
week: "2026-W12".into(),
|
||||
sections: serde_json::json!([]),
|
||||
status: "completed".into(),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
|
||||
let list_item = SynthesisListItem::try_from(synthesis).unwrap();
|
||||
assert_eq!(list_item.first_section_title, None);
|
||||
assert_eq!(list_item.first_section_item_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthesis_response_from_synthesis() {
|
||||
let sections = serde_json::json!([
|
||||
{
|
||||
"title": "AI News",
|
||||
"items": [
|
||||
{"title": "Art", "url": "https://a.com", "summary": "Sum"}
|
||||
]
|
||||
}
|
||||
]);
|
||||
|
||||
let synthesis = Synthesis {
|
||||
id: Uuid::nil(),
|
||||
user_id: Uuid::nil(),
|
||||
week: "2026-W12".into(),
|
||||
sections,
|
||||
status: "completed".into(),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
|
||||
let response = SynthesisResponse::try_from(synthesis).unwrap();
|
||||
assert_eq!(response.sections.len(), 1);
|
||||
assert_eq!(response.sections[0].title, "AI News");
|
||||
assert_eq!(response.sections[0].items[0].title, "Art");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthesis_response_from_invalid_json_fails() {
|
||||
let synthesis = Synthesis {
|
||||
id: Uuid::nil(),
|
||||
user_id: Uuid::nil(),
|
||||
week: "2026-W12".into(),
|
||||
sections: serde_json::json!("not an array"),
|
||||
status: "completed".into(),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
|
||||
assert!(SynthesisResponse::try_from(synthesis).is_err());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,263 @@
|
||||
//! Prompt construction for the two-pass LLM generation pipeline.
|
||||
//!
|
||||
//! Builds system and user prompts for:
|
||||
//! - **Search pass** (Pass 1): web search and initial article discovery
|
||||
//! - **Rewrite pass** (Pass 2): rewrite summaries using scraped content
|
||||
//!
|
||||
//! Prompts are provider-agnostic and parameterized by user settings.
|
||||
|
||||
use crate::models::settings::UserSettings;
|
||||
use crate::models::source::Source;
|
||||
use crate::models::synthesis::ScrapedNewsItem;
|
||||
|
||||
/// Build the system prompt and user prompt for the search pass (Pass 1).
|
||||
///
|
||||
/// The search pass instructs the LLM to find recent news articles
|
||||
/// matching the user's theme and categories, using web search grounding.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `settings` — User's configured settings (theme, categories, etc.)
|
||||
/// * `sources` — User's custom sources to prioritize
|
||||
/// * `current_date` — Formatted date string for the prompt
|
||||
pub fn build_search_prompt(
|
||||
settings: &UserSettings,
|
||||
sources: &[Source],
|
||||
current_date: &str,
|
||||
) -> (String, String) {
|
||||
let sources_text = if sources.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
let list = sources
|
||||
.iter()
|
||||
.map(|s| format!("- {} ({})", s.title, s.url))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
format!(
|
||||
"\nEn plus des sources par defaut, tu DOIS imperativement consulter \
|
||||
et integrer les informations provenant de ces sources personnalisees :\n{}\n",
|
||||
list
|
||||
)
|
||||
};
|
||||
|
||||
let categories_text = settings
|
||||
.categories
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, cat)| format!("{}. {}", i + 1, cat))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
let behavior = if settings.search_agent_behavior.is_empty() {
|
||||
"Tu peux egalement utiliser d'autres sources pertinentes trouvees via la recherche Google."
|
||||
.to_string()
|
||||
} else {
|
||||
settings.search_agent_behavior.clone()
|
||||
};
|
||||
|
||||
let system_prompt = format!(
|
||||
"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 {} \
|
||||
derniers jours.",
|
||||
settings.max_age_days
|
||||
);
|
||||
|
||||
let user_prompt = format!(
|
||||
"Aujourd'hui, nous sommes le {date}.\n\
|
||||
Tu es un expert en analyse de l'actualite sur le theme : \"{theme}\".\n\
|
||||
Ta tache est de rechercher les actualites STRICTEMENT des {days} derniers jours.\n\
|
||||
Ne retourne AUCUNE actualite datant de plus de {days} jours.\n\n\
|
||||
Tu DOIS imperativement t'appuyer sur le contenu des sites web pertinents pour ce theme.\
|
||||
{sources}\
|
||||
{behavior}\n\n\
|
||||
La synthese doit etre divisee en {count} grandes sections :\n\
|
||||
{categories}\n\n\
|
||||
Pour chaque categorie, fournis au maximum {max_items} actualites.\n\
|
||||
Pour chaque actualite, fournis un titre provisoire, l'URL source exacte et complete, \
|
||||
et un resume provisoire.\n\
|
||||
Retourne le resultat au format JSON en utilisant les cles category_0, category_1, etc. \
|
||||
correspondant a l'ordre des sections ci-dessus.",
|
||||
date = current_date,
|
||||
theme = settings.theme,
|
||||
days = settings.max_age_days,
|
||||
sources = sources_text,
|
||||
behavior = behavior,
|
||||
count = settings.categories.len(),
|
||||
categories = categories_text,
|
||||
max_items = settings.max_items_per_category,
|
||||
);
|
||||
|
||||
(system_prompt, user_prompt)
|
||||
}
|
||||
|
||||
/// Build the system prompt and user prompt for the rewrite pass (Pass 2).
|
||||
///
|
||||
/// The rewrite pass takes scraped article content and asks the LLM to
|
||||
/// rewrite titles and summaries to faithfully reflect the actual content.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `scraped_data` — Map of category key to scraped news items with content
|
||||
pub fn build_rewrite_prompt(
|
||||
scraped_data: &std::collections::HashMap<String, Vec<ScrapedNewsItem>>,
|
||||
) -> (String, String) {
|
||||
let system_prompt =
|
||||
"Tu es un assistant IA precis. Tu dois generer des titres et resumes fideles \
|
||||
au contenu fourni."
|
||||
.to_string();
|
||||
|
||||
let data_json = serde_json::to_string_pretty(scraped_data).unwrap_or_default();
|
||||
|
||||
let user_prompt = format!(
|
||||
"Tu es un expert en analyse de l'actualite.\n\
|
||||
Voici une liste d'articles d'actualite classes par categorie, avec leur contenu textuel \
|
||||
brut extrait des sites web ('scrapedContent').\n\
|
||||
Ta tache est de reecrire le 'title' et le 'summary' (4 ou 5 lignes) pour chaque article \
|
||||
afin qu'ils refletent EXACTEMENT et FIDELEMENT le contenu textuel fourni.\n\
|
||||
Si le 'scrapedContent' est vide ou insuffisant, utilise le titre et le resume originaux \
|
||||
pour faire au mieux.\n\
|
||||
Conserve EXACTEMENT les memes URLs. Ne supprime aucun article de cette liste.\n\n\
|
||||
Donnees des articles :\n{data}",
|
||||
data = data_json,
|
||||
);
|
||||
|
||||
(system_prompt, user_prompt)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn test_settings() -> UserSettings {
|
||||
UserSettings {
|
||||
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,
|
||||
search_agent_behavior: String::new(),
|
||||
updated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_prompt_includes_theme() {
|
||||
let settings = test_settings();
|
||||
let (_, user_prompt) = build_search_prompt(&settings, &[], "lundi 21 mars 2026");
|
||||
assert!(user_prompt.contains("Intelligence Artificielle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_prompt_includes_date() {
|
||||
let settings = test_settings();
|
||||
let (_, user_prompt) = build_search_prompt(&settings, &[], "lundi 21 mars 2026");
|
||||
assert!(user_prompt.contains("lundi 21 mars 2026"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_prompt_includes_max_age() {
|
||||
let settings = test_settings();
|
||||
let (system, user_prompt) = build_search_prompt(&settings, &[], "lundi 21 mars 2026");
|
||||
assert!(user_prompt.contains("7 derniers jours"));
|
||||
assert!(system.contains("7"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_prompt_includes_categories() {
|
||||
let settings = test_settings();
|
||||
let (_, user_prompt) = build_search_prompt(&settings, &[], "lundi 21 mars 2026");
|
||||
assert!(user_prompt.contains("1. Annonces majeures"));
|
||||
assert!(user_prompt.contains("2. Recherche et innovation"));
|
||||
assert!(user_prompt.contains("2 grandes sections"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_prompt_includes_max_items() {
|
||||
let settings = test_settings();
|
||||
let (_, user_prompt) = build_search_prompt(&settings, &[], "lundi 21 mars 2026");
|
||||
assert!(user_prompt.contains("4 actualites"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_prompt_includes_custom_sources() {
|
||||
let settings = test_settings();
|
||||
let sources = vec![
|
||||
Source {
|
||||
id: Uuid::nil(),
|
||||
user_id: Uuid::nil(),
|
||||
title: "TechCrunch".into(),
|
||||
url: "https://techcrunch.com".into(),
|
||||
created_at: Utc::now(),
|
||||
},
|
||||
Source {
|
||||
id: Uuid::nil(),
|
||||
user_id: Uuid::nil(),
|
||||
title: "The Verge".into(),
|
||||
url: "https://theverge.com".into(),
|
||||
created_at: Utc::now(),
|
||||
},
|
||||
];
|
||||
|
||||
let (_, user_prompt) = build_search_prompt(&settings, &sources, "lundi 21 mars 2026");
|
||||
assert!(user_prompt.contains("TechCrunch (https://techcrunch.com)"));
|
||||
assert!(user_prompt.contains("The Verge (https://theverge.com)"));
|
||||
assert!(user_prompt.contains("sources personnalisees"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_prompt_no_sources_no_section() {
|
||||
let settings = test_settings();
|
||||
let (_, user_prompt) = build_search_prompt(&settings, &[], "lundi 21 mars 2026");
|
||||
assert!(!user_prompt.contains("sources personnalisees"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_prompt_custom_behavior() {
|
||||
let mut settings = test_settings();
|
||||
settings.search_agent_behavior =
|
||||
"Concentre-toi sur les sources europeennes.".to_string();
|
||||
|
||||
let (_, user_prompt) = build_search_prompt(&settings, &[], "lundi 21 mars 2026");
|
||||
assert!(user_prompt.contains("Concentre-toi sur les sources europeennes."));
|
||||
assert!(!user_prompt.contains("recherche Google"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_prompt_default_behavior_when_empty() {
|
||||
let settings = test_settings();
|
||||
let (_, user_prompt) = build_search_prompt(&settings, &[], "lundi 21 mars 2026");
|
||||
assert!(user_prompt.contains("recherche Google"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_prompt_includes_instructions() {
|
||||
let mut data = std::collections::HashMap::new();
|
||||
data.insert(
|
||||
"category_0".to_string(),
|
||||
vec![ScrapedNewsItem {
|
||||
title: "Test Article".into(),
|
||||
url: "https://example.com".into(),
|
||||
summary: "A summary".into(),
|
||||
scraped_content: "Full article text here...".into(),
|
||||
}],
|
||||
);
|
||||
|
||||
let (system, user_prompt) = build_rewrite_prompt(&data);
|
||||
assert!(system.contains("fideles"));
|
||||
assert!(user_prompt.contains("scrapedContent"));
|
||||
assert!(user_prompt.contains("Test Article"));
|
||||
assert!(user_prompt.contains("https://example.com"));
|
||||
assert!(user_prompt.contains("Ne supprime aucun article"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_prompt_with_empty_data() {
|
||||
let data = std::collections::HashMap::new();
|
||||
let (_, user_prompt) = build_rewrite_prompt(&data);
|
||||
// Should still produce a valid prompt with empty data
|
||||
assert!(user_prompt.contains("Donnees des articles"));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,862 @@
|
||||
//! Synthesis generation pipeline and job management.
|
||||
//!
|
||||
//! Orchestrates the two-pass LLM pipeline:
|
||||
//! 1. Search pass: LLM generates initial articles via web search
|
||||
//! 2. Scrape: Validate and fetch content from source URLs
|
||||
//! 3. Rewrite pass: LLM rewrites titles/summaries using scraped content
|
||||
//!
|
||||
//! Progress is reported via `tokio::sync::watch` channels per job,
|
||||
//! consumed by SSE endpoints for real-time client updates.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use chrono::Utc;
|
||||
use dashmap::DashMap;
|
||||
use serde::Serialize;
|
||||
use tokio::sync::watch;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::app_state::AppState;
|
||||
use crate::db;
|
||||
use crate::errors::AppError;
|
||||
use crate::models::synthesis::{
|
||||
get_iso_week_string, NewsItem, NewsSection, ScrapedNewsItem,
|
||||
};
|
||||
use crate::services::encryption;
|
||||
use crate::services::llm::factory::create_provider;
|
||||
use crate::services::llm::schema::build_category_schema;
|
||||
use crate::services::prompts;
|
||||
use crate::services::scraper;
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
// Progress Events
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Progress event sent to SSE clients during generation.
|
||||
///
|
||||
/// The `watch` channel always holds the latest event, and new subscribers
|
||||
/// immediately receive the current state.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum ProgressEvent {
|
||||
/// Generation is in progress.
|
||||
#[serde(rename = "progress")]
|
||||
Progress {
|
||||
step: String,
|
||||
message: String,
|
||||
percent: u8,
|
||||
},
|
||||
/// Generation completed successfully.
|
||||
#[serde(rename = "complete")]
|
||||
Complete { synthesis_id: Uuid },
|
||||
/// Generation failed with an error.
|
||||
#[serde(rename = "error")]
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
// Job Store
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Entry in the job store, holding the progress channel and metadata.
|
||||
struct JobEntry {
|
||||
/// Sender side of the watch channel for progress updates.
|
||||
/// Wrapped in Arc so it can be shared with the background task
|
||||
/// without cloning the Sender itself.
|
||||
tx: Arc<watch::Sender<ProgressEvent>>,
|
||||
/// A receiver kept alive to prevent the channel from closing.
|
||||
/// Without at least one receiver, `Sender::send()` returns an error
|
||||
/// and does NOT update the stored value.
|
||||
_rx: watch::Receiver<ProgressEvent>,
|
||||
/// User who owns this job.
|
||||
user_id: Uuid,
|
||||
/// When the job was created (for TTL cleanup).
|
||||
created_at: Instant,
|
||||
}
|
||||
|
||||
/// In-memory store for active generation jobs.
|
||||
///
|
||||
/// Uses `DashMap` for lock-free concurrent access. Jobs are keyed by
|
||||
/// a random UUID and automatically cleaned up after a TTL.
|
||||
#[derive(Clone)]
|
||||
pub struct JobStore {
|
||||
inner: Arc<DashMap<Uuid, JobEntry>>,
|
||||
}
|
||||
|
||||
/// Jobs expire after 1 hour (allows SSE reconnection).
|
||||
const JOB_TTL: Duration = Duration::from_secs(3600);
|
||||
|
||||
impl JobStore {
|
||||
/// Create a new empty job store.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(DashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new job for a user, returning the job ID and the watch Sender.
|
||||
///
|
||||
/// Returns `None` if the user already has an active job.
|
||||
pub fn create_job(&self, user_id: Uuid) -> Option<(Uuid, Arc<watch::Sender<ProgressEvent>>)> {
|
||||
// Check if user already has an active job
|
||||
for entry in self.inner.iter() {
|
||||
if entry.value().user_id == user_id {
|
||||
// Check if the job is still active (not completed/failed)
|
||||
let current = entry.value().tx.borrow().clone();
|
||||
match current {
|
||||
ProgressEvent::Complete { .. } | ProgressEvent::Error { .. } => {
|
||||
// Job finished, allow creating a new one
|
||||
continue;
|
||||
}
|
||||
ProgressEvent::Progress { .. } => {
|
||||
return None; // Active job exists
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let job_id = Uuid::new_v4();
|
||||
let (tx, rx) = watch::channel(ProgressEvent::Progress {
|
||||
step: "init".into(),
|
||||
message: "Initialisation...".into(),
|
||||
percent: 0,
|
||||
});
|
||||
|
||||
let tx = Arc::new(tx);
|
||||
|
||||
self.inner.insert(
|
||||
job_id,
|
||||
JobEntry {
|
||||
tx: Arc::clone(&tx),
|
||||
_rx: rx,
|
||||
user_id,
|
||||
created_at: Instant::now(),
|
||||
},
|
||||
);
|
||||
|
||||
Some((job_id, tx))
|
||||
}
|
||||
|
||||
/// Get a watch receiver for a job, if it exists and belongs to the given user.
|
||||
pub fn subscribe(&self, job_id: Uuid, user_id: Uuid) -> Option<watch::Receiver<ProgressEvent>> {
|
||||
self.inner.get(&job_id).and_then(|entry| {
|
||||
if entry.value().user_id == user_id {
|
||||
Some(entry.value().tx.subscribe())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if a user has an active (in-progress) job.
|
||||
pub fn has_active_job(&self, user_id: Uuid) -> Option<Uuid> {
|
||||
for entry in self.inner.iter() {
|
||||
if entry.value().user_id == user_id {
|
||||
let current = entry.value().tx.borrow().clone();
|
||||
if matches!(current, ProgressEvent::Progress { .. }) {
|
||||
return Some(*entry.key());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Remove expired jobs (older than TTL).
|
||||
pub fn cleanup_expired(&self) {
|
||||
let now = Instant::now();
|
||||
self.inner.retain(|_, entry| {
|
||||
now.duration_since(entry.created_at) < JOB_TTL
|
||||
});
|
||||
}
|
||||
|
||||
/// Remove a specific job.
|
||||
pub fn remove(&self, job_id: &Uuid) {
|
||||
self.inner.remove(job_id);
|
||||
}
|
||||
|
||||
/// Get the number of active jobs (for testing/monitoring).
|
||||
pub fn len(&self) -> usize {
|
||||
self.inner.len()
|
||||
}
|
||||
|
||||
/// Check if the store is empty (for testing).
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.inner.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
// Generation Pipeline
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Run the full two-pass generation pipeline for a user.
|
||||
///
|
||||
/// This is the core orchestration function. It is spawned as a background
|
||||
/// tokio task and communicates progress via the `watch` channel.
|
||||
///
|
||||
/// # Steps
|
||||
/// 1. Load user settings
|
||||
/// 2. Load user sources
|
||||
/// 3. Resolve provider + decrypt API key
|
||||
/// 4. Build schema from categories
|
||||
/// 5. Rate limit check (pass 1)
|
||||
/// 6. LLM search pass
|
||||
/// 7. Parse structured output
|
||||
/// 8. Validate/scrape URLs (parallel, bounded concurrency)
|
||||
/// 9. Rate limit check (pass 2)
|
||||
/// 10. LLM rewrite pass
|
||||
/// 11. Parse final output
|
||||
/// 12. Save synthesis to DB
|
||||
/// 13. Complete
|
||||
pub async fn run_generation(
|
||||
job_id: Uuid,
|
||||
state: AppState,
|
||||
user_id: Uuid,
|
||||
tx: Arc<watch::Sender<ProgressEvent>>,
|
||||
) {
|
||||
let result = run_generation_inner(job_id, &state, user_id, &tx).await;
|
||||
|
||||
match result {
|
||||
Ok(synthesis_id) => {
|
||||
tx.send(ProgressEvent::Complete { synthesis_id }).ok();
|
||||
tracing::info!(job_id = %job_id, synthesis_id = %synthesis_id, "Generation completed");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(job_id = %job_id, error = %e, "Generation failed");
|
||||
// Sanitize error message — never expose API keys or internal details
|
||||
let safe_message = sanitize_error_message(&e.to_string());
|
||||
tx.send(ProgressEvent::Error {
|
||||
message: safe_message,
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the job in the store for 5 minutes after completion
|
||||
// to allow SSE reconnection
|
||||
let store = state.job_store.clone();
|
||||
let jid = job_id;
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_secs(300)).await;
|
||||
store.remove(&jid);
|
||||
});
|
||||
}
|
||||
|
||||
/// Inner implementation of the generation pipeline, returning a Result.
|
||||
async fn run_generation_inner(
|
||||
_job_id: Uuid,
|
||||
state: &AppState,
|
||||
user_id: Uuid,
|
||||
tx: &watch::Sender<ProgressEvent>,
|
||||
) -> Result<Uuid, AppError> {
|
||||
// Step 1: Load user settings
|
||||
emit_progress(tx, "settings", "Chargement des parametres...", 5);
|
||||
let settings = db::settings::get_or_create_default(&state.pool, user_id).await?;
|
||||
|
||||
if settings.categories.is_empty() {
|
||||
return Err(AppError::BadRequest(
|
||||
"Aucune categorie configuree. Veuillez configurer vos parametres.".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Step 2: Load user sources
|
||||
emit_progress(tx, "sources", "Chargement des sources...", 10);
|
||||
let sources = db::sources::list_for_user(&state.pool, user_id).await?;
|
||||
|
||||
// Step 3: Resolve provider + decrypt API key
|
||||
emit_progress(tx, "provider", "Configuration du fournisseur IA...", 15);
|
||||
let (provider_name, api_key) = resolve_provider_and_key(state, user_id).await?;
|
||||
|
||||
let provider = create_provider(&provider_name, api_key, &state.http_client)?;
|
||||
|
||||
// Step 4: Build schema from categories
|
||||
let schema = build_category_schema(&settings.categories);
|
||||
|
||||
// Step 5: Rate limit check (pass 1)
|
||||
if !state.provider_rate_limiter.check(&provider_name) {
|
||||
return Err(AppError::RateLimited(
|
||||
"Limite de requetes atteinte. Veuillez reessayer dans quelques instants.".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Step 6: LLM search pass
|
||||
emit_progress(tx, "search", "Recherche d'actualites en cours...", 30);
|
||||
let current_date = Utc::now()
|
||||
.format("%A %d %B %Y")
|
||||
.to_string();
|
||||
let (system_prompt, user_prompt) =
|
||||
prompts::build_search_prompt(&settings, &sources, ¤t_date);
|
||||
|
||||
let model = resolve_model(state, &provider_name).await?;
|
||||
|
||||
let raw_results = provider
|
||||
.generate_search_pass(&model, &system_prompt, &user_prompt, &schema)
|
||||
.await?;
|
||||
|
||||
// Step 7: Parse structured output into (category_key, Vec<NewsItem>)
|
||||
emit_progress(tx, "parsing", "Analyse des resultats...", 40);
|
||||
let parsed = parse_llm_output(&raw_results, &settings.categories)?;
|
||||
|
||||
// Step 8: Validate/scrape URLs (parallel, bounded concurrency)
|
||||
emit_progress(tx, "scraping", "Verification des sources...", 45);
|
||||
let scraped = scrape_articles(state, &parsed, settings.max_age_days as i64, tx).await;
|
||||
|
||||
// Step 9: Rate limit check (pass 2)
|
||||
if !state.provider_rate_limiter.check(&provider_name) {
|
||||
return Err(AppError::RateLimited(
|
||||
"Limite de requetes atteinte pour la passe de reecriture. Veuillez reessayer.".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Step 10: LLM rewrite pass
|
||||
emit_progress(tx, "rewrite", "Redaction des resumes...", 80);
|
||||
let (rewrite_system, rewrite_user) = prompts::build_rewrite_prompt(&scraped);
|
||||
|
||||
let final_results = provider
|
||||
.generate_rewrite_pass(&model, &rewrite_system, &rewrite_user, &schema)
|
||||
.await?;
|
||||
|
||||
// Step 11: Parse final output
|
||||
emit_progress(tx, "finalizing", "Finalisation...", 90);
|
||||
let final_sections = build_final_sections(&final_results, &settings.categories)?;
|
||||
|
||||
// Step 12: Save synthesis to DB
|
||||
emit_progress(tx, "saving", "Sauvegarde de la synthese...", 95);
|
||||
let week = get_iso_week_string(Utc::now().date_naive());
|
||||
let sections_json = serde_json::to_value(&final_sections).map_err(|e| {
|
||||
AppError::Internal(anyhow::anyhow!("Failed to serialize sections: {}", e))
|
||||
})?;
|
||||
|
||||
let synthesis =
|
||||
db::syntheses::create(&state.pool, user_id, &week, §ions_json).await?;
|
||||
|
||||
Ok(synthesis.id)
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
// Helper Functions
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Emit a progress event via the watch channel.
|
||||
fn emit_progress(tx: &watch::Sender<ProgressEvent>, step: &str, message: &str, percent: u8) {
|
||||
tx.send(ProgressEvent::Progress {
|
||||
step: step.into(),
|
||||
message: message.into(),
|
||||
percent,
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
/// Resolve the LLM provider and decrypt the user's API key.
|
||||
///
|
||||
/// Looks up the user's API key for the first available provider.
|
||||
async fn resolve_provider_and_key(
|
||||
state: &AppState,
|
||||
user_id: Uuid,
|
||||
) -> Result<(String, String), AppError> {
|
||||
let keys = db::api_keys::list_for_user(&state.pool, user_id).await?;
|
||||
|
||||
if keys.is_empty() {
|
||||
return Err(AppError::BadRequest(
|
||||
"Aucune cle API configuree. Veuillez ajouter une cle API dans vos parametres.".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Use the first available key
|
||||
let key_record = &keys[0];
|
||||
let master_key = encryption::MasterKey::from_hex(&state.config.master_encryption_key)?;
|
||||
let api_key = encryption::decrypt(
|
||||
&master_key,
|
||||
&key_record.encrypted_key,
|
||||
&key_record.nonce,
|
||||
)?;
|
||||
|
||||
Ok((key_record.provider_name.clone(), api_key))
|
||||
}
|
||||
|
||||
/// Resolve the model to use for a given provider.
|
||||
///
|
||||
/// Looks up the first enabled model for the provider from the admin config.
|
||||
/// Falls back to sensible defaults if no admin-configured models exist.
|
||||
async fn resolve_model(state: &AppState, provider_name: &str) -> Result<String, AppError> {
|
||||
// Try to get the first enabled model from admin config
|
||||
let model = sqlx::query_scalar::<_, String>(
|
||||
r#"
|
||||
SELECT model_id FROM admin_provider_models
|
||||
WHERE provider = $1 AND enabled = true
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(provider_name)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
match model {
|
||||
Some(m) => Ok(m),
|
||||
None => {
|
||||
// Fall back to sensible defaults
|
||||
match provider_name {
|
||||
"gemini" => Ok("gemini-2.5-pro".into()),
|
||||
"openai" => Ok("gpt-4o".into()),
|
||||
"anthropic" => Ok("claude-sonnet-4-20250514".into()),
|
||||
_ => Err(AppError::BadRequest(format!(
|
||||
"Aucun modele configure pour le fournisseur '{}'",
|
||||
provider_name
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the LLM's structured JSON output into category-keyed news items.
|
||||
///
|
||||
/// Expects the output to have keys like `category_0`, `category_1`, etc.
|
||||
/// Each key maps to an array of `{title, url, summary}` objects.
|
||||
fn parse_llm_output(
|
||||
raw: &serde_json::Value,
|
||||
categories: &[String],
|
||||
) -> Result<Vec<(String, Vec<NewsItem>)>, AppError> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
for (i, _cat) in categories.iter().enumerate() {
|
||||
let key = format!("category_{}", i);
|
||||
let items_val = raw.get(&key).cloned().unwrap_or(serde_json::json!([]));
|
||||
|
||||
let items: Vec<NewsItem> = serde_json::from_value(items_val).unwrap_or_default();
|
||||
result.push((key, items));
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Scrape articles in parallel with bounded concurrency.
|
||||
///
|
||||
/// For each category, scrapes all article URLs. Failed scrapes are
|
||||
/// handled gracefully — the article is kept with empty scraped content
|
||||
/// rather than being discarded.
|
||||
async fn scrape_articles(
|
||||
state: &AppState,
|
||||
parsed: &[(String, Vec<NewsItem>)],
|
||||
max_age_days: i64,
|
||||
tx: &watch::Sender<ProgressEvent>,
|
||||
) -> HashMap<String, Vec<ScrapedNewsItem>> {
|
||||
let mut result: HashMap<String, Vec<ScrapedNewsItem>> = HashMap::new();
|
||||
|
||||
// Collect all (category_key, item) pairs for parallel processing
|
||||
let mut tasks = Vec::new();
|
||||
for (cat_key, items) in parsed {
|
||||
for item in items {
|
||||
tasks.push((cat_key.clone(), item.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
let total = tasks.len();
|
||||
if total == 0 {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Use JoinSet for bounded concurrency (max 10 concurrent scrapes)
|
||||
let mut join_set = tokio::task::JoinSet::new();
|
||||
let mut pending = tasks.into_iter().peekable();
|
||||
let mut completed = 0usize;
|
||||
|
||||
// Seed the JoinSet with up to 10 initial tasks
|
||||
let max_concurrent = 10;
|
||||
for _ in 0..max_concurrent {
|
||||
if let Some((cat_key, item)) = pending.next() {
|
||||
let client = state.http_client.clone();
|
||||
let url = item.url.clone();
|
||||
let mad = max_age_days;
|
||||
join_set.spawn(async move {
|
||||
let scraped = scrape_single_article(&client, &url, mad).await;
|
||||
(cat_key, item, scraped)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Process results and spawn new tasks as slots open
|
||||
while let Some(join_result) = join_set.join_next().await {
|
||||
completed += 1;
|
||||
|
||||
// Update progress (45% to 75% range for scraping)
|
||||
let pct = 45 + ((completed as u32 * 30) / total as u32).min(30);
|
||||
emit_progress(
|
||||
tx,
|
||||
"scraping",
|
||||
&format!("Verification des sources ({}/{})...", completed, total),
|
||||
pct as u8,
|
||||
);
|
||||
|
||||
if let Ok((cat_key, item, scraped_content)) = join_result {
|
||||
let scraped_item = ScrapedNewsItem {
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
summary: item.summary,
|
||||
scraped_content,
|
||||
};
|
||||
|
||||
result
|
||||
.entry(cat_key)
|
||||
.or_default()
|
||||
.push(scraped_item);
|
||||
}
|
||||
|
||||
// Spawn next task if available
|
||||
if let Some((cat_key, item)) = pending.next() {
|
||||
let client = state.http_client.clone();
|
||||
let url = item.url.clone();
|
||||
let mad = max_age_days;
|
||||
join_set.spawn(async move {
|
||||
let scraped = scrape_single_article(&client, &url, mad).await;
|
||||
(cat_key, item, scraped)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Scrape a single article URL, returning the body text or an empty string on failure.
|
||||
///
|
||||
/// Handles all failure modes gracefully:
|
||||
/// - Network errors → empty content (article kept)
|
||||
/// - Soft 404 → article excluded (empty content)
|
||||
/// - Article too old → article excluded (empty content)
|
||||
async fn scrape_single_article(
|
||||
http_client: &reqwest::Client,
|
||||
url: &str,
|
||||
max_age_days: i64,
|
||||
) -> String {
|
||||
match scraper::scrape_url(http_client, url).await {
|
||||
Ok(content) => {
|
||||
if !content.ok || content.is_soft_404 {
|
||||
tracing::warn!(url = url, "Soft 404 or error page detected, skipping content");
|
||||
return String::new();
|
||||
}
|
||||
|
||||
if scraper::is_article_too_old(content.published_date, max_age_days) {
|
||||
tracing::warn!(url = url, "Article too old, skipping content");
|
||||
return String::new();
|
||||
}
|
||||
|
||||
content.body_text
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(url = url, error = %e, "Failed to scrape URL, keeping article with empty content");
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the final sections array from the LLM's rewrite output.
|
||||
///
|
||||
/// Maps `category_N` keys back to the user's category names.
|
||||
fn build_final_sections(
|
||||
raw: &serde_json::Value,
|
||||
categories: &[String],
|
||||
) -> Result<Vec<NewsSection>, AppError> {
|
||||
let mut sections = Vec::new();
|
||||
|
||||
for (i, cat_name) in categories.iter().enumerate() {
|
||||
let key = format!("category_{}", i);
|
||||
let items_val = raw.get(&key).cloned().unwrap_or(serde_json::json!([]));
|
||||
|
||||
let items: Vec<NewsItem> = serde_json::from_value(items_val).unwrap_or_default();
|
||||
|
||||
sections.push(NewsSection {
|
||||
title: cat_name.clone(),
|
||||
items,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(sections)
|
||||
}
|
||||
|
||||
/// Sanitize error messages to prevent leaking sensitive information.
|
||||
///
|
||||
/// Removes potential API keys, internal paths, and other sensitive data.
|
||||
fn sanitize_error_message(msg: &str) -> String {
|
||||
// If the message contains common API key patterns, replace with generic message
|
||||
if msg.contains("API key")
|
||||
|| msg.contains("api_key")
|
||||
|| msg.contains("AIza")
|
||||
|| msg.contains("sk-")
|
||||
|| msg.contains("PERMISSION_DENIED")
|
||||
{
|
||||
return "Erreur d'authentification avec le fournisseur IA. Verifiez votre cle API.".into();
|
||||
}
|
||||
|
||||
if msg.contains("rate limit") || msg.contains("quota") || msg.contains("429") {
|
||||
return "Limite de requetes du fournisseur IA atteinte. Reessayez plus tard.".into();
|
||||
}
|
||||
|
||||
if msg.contains("Database") || msg.contains("sqlx") || msg.contains("postgres") {
|
||||
return "Erreur interne du serveur. Veuillez reessayer.".into();
|
||||
}
|
||||
|
||||
// For other errors, truncate and sanitize
|
||||
let truncated = if msg.len() > 200 {
|
||||
format!("{}...", &msg[..200])
|
||||
} else {
|
||||
msg.to_string()
|
||||
};
|
||||
|
||||
truncated
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── JobStore tests ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn job_store_create_and_subscribe() {
|
||||
let store = JobStore::new();
|
||||
let user_id = Uuid::new_v4();
|
||||
|
||||
let (job_id, tx) = store.create_job(user_id).unwrap();
|
||||
assert_eq!(store.len(), 1);
|
||||
|
||||
// Subscribe
|
||||
let rx = store.subscribe(job_id, user_id);
|
||||
assert!(rx.is_some());
|
||||
|
||||
// Wrong user cannot subscribe
|
||||
let other_user = Uuid::new_v4();
|
||||
assert!(store.subscribe(job_id, other_user).is_none());
|
||||
|
||||
// Check active job
|
||||
assert_eq!(store.has_active_job(user_id), Some(job_id));
|
||||
assert_eq!(store.has_active_job(other_user), None);
|
||||
|
||||
drop(tx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_store_prevents_duplicate_active_jobs() {
|
||||
let store = JobStore::new();
|
||||
let user_id = Uuid::new_v4();
|
||||
|
||||
let result1 = store.create_job(user_id);
|
||||
assert!(result1.is_some());
|
||||
|
||||
// Second job for same user should fail
|
||||
let result2 = store.create_job(user_id);
|
||||
assert!(result2.is_none());
|
||||
|
||||
// Different user should succeed
|
||||
let other_user = Uuid::new_v4();
|
||||
let result3 = store.create_job(other_user);
|
||||
assert!(result3.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_store_allows_new_job_after_completion() {
|
||||
let store = JobStore::new();
|
||||
let user_id = Uuid::new_v4();
|
||||
|
||||
let (_job_id, tx) = store.create_job(user_id).unwrap();
|
||||
|
||||
// Complete the job
|
||||
tx.send(ProgressEvent::Complete {
|
||||
synthesis_id: Uuid::new_v4(),
|
||||
})
|
||||
.ok();
|
||||
|
||||
// Should now allow a new job
|
||||
let result2 = store.create_job(user_id);
|
||||
assert!(result2.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_store_allows_new_job_after_error() {
|
||||
let store = JobStore::new();
|
||||
let user_id = Uuid::new_v4();
|
||||
|
||||
let (_job_id, tx) = store.create_job(user_id).unwrap();
|
||||
|
||||
// Fail the job
|
||||
tx.send(ProgressEvent::Error {
|
||||
message: "test error".into(),
|
||||
})
|
||||
.ok();
|
||||
|
||||
// Should now allow a new job
|
||||
let result2 = store.create_job(user_id);
|
||||
assert!(result2.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_store_cleanup_expired() {
|
||||
let store = JobStore::new();
|
||||
let user_id = Uuid::new_v4();
|
||||
|
||||
// Create a job and manually set its created_at to the past
|
||||
let (_job_id, _tx) = store.create_job(user_id).unwrap();
|
||||
assert_eq!(store.len(), 1);
|
||||
|
||||
// Cleanup should not remove recent jobs
|
||||
store.cleanup_expired();
|
||||
assert_eq!(store.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_store_remove() {
|
||||
let store = JobStore::new();
|
||||
let user_id = Uuid::new_v4();
|
||||
|
||||
let (job_id, _tx) = store.create_job(user_id).unwrap();
|
||||
assert_eq!(store.len(), 1);
|
||||
|
||||
store.remove(&job_id);
|
||||
assert!(store.is_empty());
|
||||
}
|
||||
|
||||
// ── ProgressEvent serialization tests ────────────────────────
|
||||
|
||||
#[test]
|
||||
fn progress_event_serialization_progress() {
|
||||
let event = ProgressEvent::Progress {
|
||||
step: "search".into(),
|
||||
message: "Searching...".into(),
|
||||
percent: 30,
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&event).unwrap();
|
||||
assert_eq!(json["type"], "progress");
|
||||
assert_eq!(json["step"], "search");
|
||||
assert_eq!(json["message"], "Searching...");
|
||||
assert_eq!(json["percent"], 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_event_serialization_complete() {
|
||||
let synthesis_id = Uuid::nil();
|
||||
let event = ProgressEvent::Complete { synthesis_id };
|
||||
|
||||
let json = serde_json::to_value(&event).unwrap();
|
||||
assert_eq!(json["type"], "complete");
|
||||
assert_eq!(
|
||||
json["synthesis_id"],
|
||||
"00000000-0000-0000-0000-000000000000"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_event_serialization_error() {
|
||||
let event = ProgressEvent::Error {
|
||||
message: "Something went wrong".into(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&event).unwrap();
|
||||
assert_eq!(json["type"], "error");
|
||||
assert_eq!(json["message"], "Something went wrong");
|
||||
}
|
||||
|
||||
// ── parse_llm_output tests ───────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn parse_llm_output_valid() {
|
||||
let raw = serde_json::json!({
|
||||
"category_0": [
|
||||
{"title": "Art 1", "url": "https://a.com", "summary": "Sum 1"},
|
||||
{"title": "Art 2", "url": "https://b.com", "summary": "Sum 2"}
|
||||
],
|
||||
"category_1": [
|
||||
{"title": "Art 3", "url": "https://c.com", "summary": "Sum 3"}
|
||||
]
|
||||
});
|
||||
|
||||
let categories = vec!["AI News".into(), "Research".into()];
|
||||
let result = parse_llm_output(&raw, &categories).unwrap();
|
||||
|
||||
assert_eq!(result.len(), 2);
|
||||
assert_eq!(result[0].0, "category_0");
|
||||
assert_eq!(result[0].1.len(), 2);
|
||||
assert_eq!(result[1].0, "category_1");
|
||||
assert_eq!(result[1].1.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_llm_output_missing_category() {
|
||||
let raw = serde_json::json!({
|
||||
"category_0": [
|
||||
{"title": "Art 1", "url": "https://a.com", "summary": "Sum 1"}
|
||||
]
|
||||
// category_1 is missing
|
||||
});
|
||||
|
||||
let categories = vec!["AI News".into(), "Research".into()];
|
||||
let result = parse_llm_output(&raw, &categories).unwrap();
|
||||
|
||||
assert_eq!(result.len(), 2);
|
||||
assert_eq!(result[0].1.len(), 1);
|
||||
assert_eq!(result[1].1.len(), 0); // Missing category → empty
|
||||
}
|
||||
|
||||
// ── build_final_sections tests ───────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn build_final_sections_maps_names() {
|
||||
let raw = serde_json::json!({
|
||||
"category_0": [
|
||||
{"title": "Art", "url": "https://a.com", "summary": "Sum"}
|
||||
],
|
||||
"category_1": []
|
||||
});
|
||||
|
||||
let categories = vec!["Annonces majeures".into(), "Recherche".into()];
|
||||
let sections = build_final_sections(&raw, &categories).unwrap();
|
||||
|
||||
assert_eq!(sections.len(), 2);
|
||||
assert_eq!(sections[0].title, "Annonces majeures");
|
||||
assert_eq!(sections[0].items.len(), 1);
|
||||
assert_eq!(sections[1].title, "Recherche");
|
||||
assert_eq!(sections[1].items.len(), 0);
|
||||
}
|
||||
|
||||
// ── sanitize_error_message tests ─────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn sanitize_hides_api_key_references() {
|
||||
let msg = "Invalid API key: AIzaSyB-test-key";
|
||||
let sanitized = sanitize_error_message(msg);
|
||||
assert!(sanitized.contains("cle API"));
|
||||
assert!(!sanitized.contains("AIza"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_hides_rate_limit_details() {
|
||||
let msg = "Resource exhausted: rate limit exceeded for project 12345";
|
||||
let sanitized = sanitize_error_message(msg);
|
||||
assert!(sanitized.contains("Limite"));
|
||||
assert!(!sanitized.contains("12345"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_hides_database_details() {
|
||||
let msg = "Database connection to postgres://user:pass@localhost failed";
|
||||
let sanitized = sanitize_error_message(msg);
|
||||
assert!(sanitized.contains("Erreur interne"));
|
||||
assert!(!sanitized.contains("postgres"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_truncates_long_messages() {
|
||||
let msg = "x".repeat(300);
|
||||
let sanitized = sanitize_error_message(&msg);
|
||||
assert!(sanitized.len() < 210);
|
||||
assert!(sanitized.ends_with("..."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_passes_normal_messages() {
|
||||
let msg = "Generation failed due to network timeout";
|
||||
let sanitized = sanitize_error_message(msg);
|
||||
assert_eq!(sanitized, msg);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,604 @@
|
||||
//! Integration tests for the syntheses endpoints (Phase 5).
|
||||
//!
|
||||
//! Tests:
|
||||
//! - GET /api/v1/syntheses — list user's syntheses (paginated)
|
||||
//! - GET /api/v1/syntheses/:id — get synthesis detail
|
||||
//! - DELETE /api/v1/syntheses/:id — delete a synthesis
|
||||
//! - POST /api/v1/syntheses/generate — trigger generation
|
||||
//!
|
||||
//! Covers authentication, CRUD, ownership isolation, pagination,
|
||||
//! and generation trigger behaviour.
|
||||
//!
|
||||
//! Requires a running Postgres instance. Set `TEST_DATABASE_URL` to run.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
|
||||
fn require_test_db() -> bool {
|
||||
std::env::var("TEST_DATABASE_URL").is_ok()
|
||||
}
|
||||
|
||||
/// Helper: build a sample sections JSON value with one section and two items.
|
||||
fn sample_sections() -> serde_json::Value {
|
||||
serde_json::json!([
|
||||
{
|
||||
"title": "AI News",
|
||||
"items": [
|
||||
{
|
||||
"title": "Article 1",
|
||||
"url": "https://example.com/1",
|
||||
"summary": "Summary of article 1"
|
||||
},
|
||||
{
|
||||
"title": "Article 2",
|
||||
"url": "https://example.com/2",
|
||||
"summary": "Summary of article 2"
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
/// Helper: build a multi-section JSON value.
|
||||
fn multi_sections() -> serde_json::Value {
|
||||
serde_json::json!([
|
||||
{
|
||||
"title": "Major Announcements",
|
||||
"items": [
|
||||
{
|
||||
"title": "Big Launch",
|
||||
"url": "https://example.com/launch",
|
||||
"summary": "A big product launch"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Research",
|
||||
"items": [
|
||||
{
|
||||
"title": "New Paper",
|
||||
"url": "https://example.com/paper",
|
||||
"summary": "A new research paper"
|
||||
},
|
||||
{
|
||||
"title": "Breakthrough",
|
||||
"url": "https://example.com/breakthrough",
|
||||
"summary": "A scientific breakthrough"
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Auth (3 tests)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_syntheses_without_auth_returns_401() {
|
||||
if !require_test_db() {
|
||||
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
}
|
||||
|
||||
let app = common::TestApp::new().await;
|
||||
let (status, body) = app.get("/api/v1/syntheses").await;
|
||||
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"GET /syntheses without auth should return 401"
|
||||
);
|
||||
assert_eq!(body["error"], "unauthorized");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_synthesis_by_id_without_auth_returns_401() {
|
||||
if !require_test_db() {
|
||||
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
}
|
||||
|
||||
let app = common::TestApp::new().await;
|
||||
let fake_id = uuid::Uuid::new_v4();
|
||||
let (status, body) = app
|
||||
.get(&format!("/api/v1/syntheses/{}", fake_id))
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"GET /syntheses/:id without auth should return 401"
|
||||
);
|
||||
assert_eq!(body["error"], "unauthorized");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_synthesis_without_auth_returns_401() {
|
||||
if !require_test_db() {
|
||||
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
}
|
||||
|
||||
let app = common::TestApp::new().await;
|
||||
let fake_id = uuid::Uuid::new_v4();
|
||||
|
||||
// DELETE without session — use raw request to include CSRF header
|
||||
let req = axum::http::Request::builder()
|
||||
.method(axum::http::Method::DELETE)
|
||||
.uri(&format!("/api/v1/syntheses/{}", fake_id))
|
||||
.header("X-Requested-With", "XMLHttpRequest")
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let response = app.raw_request(req).await;
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"DELETE /syntheses/:id without auth should return 401"
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// CRUD (6 tests)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_syntheses_returns_empty_list_initially() {
|
||||
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("synth-empty@example.com")
|
||||
.await;
|
||||
|
||||
let (status, body) = app
|
||||
.get_with_session("/api/v1/syntheses", &session)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let items = body["items"].as_array().expect("items should be an array");
|
||||
assert!(items.is_empty(), "New user should have no syntheses");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_syntheses_returns_inserted_synthesis() {
|
||||
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("synth-list@example.com")
|
||||
.await;
|
||||
|
||||
// Insert a synthesis directly via the helper
|
||||
let sections = sample_sections();
|
||||
let synth_id = app
|
||||
.insert_test_synthesis(user_id, "2026-W12", §ions)
|
||||
.await;
|
||||
|
||||
let (status, body) = app
|
||||
.get_with_session("/api/v1/syntheses", &session)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let items = body["items"].as_array().expect("items array");
|
||||
assert_eq!(items.len(), 1, "Should have exactly 1 synthesis");
|
||||
|
||||
let item = &items[0];
|
||||
assert_eq!(item["id"], synth_id.to_string());
|
||||
assert_eq!(item["week"], "2026-W12");
|
||||
assert_eq!(item["status"], "completed");
|
||||
// first_section_title should be "AI News"
|
||||
assert_eq!(item["first_section_title"], "AI News");
|
||||
assert_eq!(item["first_section_item_count"], 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_synthesis_by_id_returns_full_detail() {
|
||||
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("synth-detail@example.com")
|
||||
.await;
|
||||
|
||||
let sections = multi_sections();
|
||||
let synth_id = app
|
||||
.insert_test_synthesis(user_id, "2026-W11", §ions)
|
||||
.await;
|
||||
|
||||
let (status, body) = app
|
||||
.get_with_session(&format!("/api/v1/syntheses/{}", synth_id), &session)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["id"], synth_id.to_string());
|
||||
assert_eq!(body["week"], "2026-W11");
|
||||
assert_eq!(body["status"], "completed");
|
||||
|
||||
let resp_sections = body["sections"].as_array().expect("sections array");
|
||||
assert_eq!(resp_sections.len(), 2);
|
||||
assert_eq!(resp_sections[0]["title"], "Major Announcements");
|
||||
assert_eq!(
|
||||
resp_sections[0]["items"].as_array().unwrap().len(),
|
||||
1
|
||||
);
|
||||
assert_eq!(resp_sections[1]["title"], "Research");
|
||||
assert_eq!(
|
||||
resp_sections[1]["items"].as_array().unwrap().len(),
|
||||
2
|
||||
);
|
||||
|
||||
// Verify nested item details
|
||||
let first_item = &resp_sections[0]["items"][0];
|
||||
assert_eq!(first_item["title"], "Big Launch");
|
||||
assert_eq!(first_item["url"], "https://example.com/launch");
|
||||
assert_eq!(first_item["summary"], "A big product launch");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_synthesis_nonexistent_returns_404() {
|
||||
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("synth-404@example.com")
|
||||
.await;
|
||||
|
||||
let fake_id = uuid::Uuid::new_v4();
|
||||
let (status, body) = app
|
||||
.get_with_session(&format!("/api/v1/syntheses/{}", fake_id), &session)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::NOT_FOUND,
|
||||
"Non-existent synthesis should return 404"
|
||||
);
|
||||
assert_eq!(body["error"], "not_found");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_synthesis_returns_204() {
|
||||
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("synth-delete@example.com")
|
||||
.await;
|
||||
|
||||
let sections = sample_sections();
|
||||
let synth_id = app
|
||||
.insert_test_synthesis(user_id, "2026-W12", §ions)
|
||||
.await;
|
||||
|
||||
let (status, _) = app
|
||||
.delete_with_session(&format!("/api/v1/syntheses/{}", synth_id), &session)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::NO_CONTENT,
|
||||
"DELETE /syntheses/:id should return 204"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_then_get_returns_empty_list() {
|
||||
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("synth-del-list@example.com")
|
||||
.await;
|
||||
|
||||
let sections = sample_sections();
|
||||
let synth_id = app
|
||||
.insert_test_synthesis(user_id, "2026-W12", §ions)
|
||||
.await;
|
||||
|
||||
// Delete it
|
||||
let (del_status, _) = app
|
||||
.delete_with_session(&format!("/api/v1/syntheses/{}", synth_id), &session)
|
||||
.await;
|
||||
assert_eq!(del_status, StatusCode::NO_CONTENT);
|
||||
|
||||
// List should be empty now
|
||||
let (status, body) = app
|
||||
.get_with_session("/api/v1/syntheses", &session)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let items = body["items"].as_array().expect("items array");
|
||||
assert!(items.is_empty(), "After deletion, list should be empty");
|
||||
|
||||
// GET by id should also 404
|
||||
let (get_status, get_body) = app
|
||||
.get_with_session(&format!("/api/v1/syntheses/{}", synth_id), &session)
|
||||
.await;
|
||||
assert_eq!(get_status, StatusCode::NOT_FOUND);
|
||||
assert_eq!(get_body["error"], "not_found");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Ownership Isolation (2 tests)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn user_a_syntheses_not_visible_to_user_b() {
|
||||
if !require_test_db() {
|
||||
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
}
|
||||
|
||||
let app = common::TestApp::new().await;
|
||||
let (user_a_id, session_a) = app
|
||||
.create_authenticated_user("synth-owner-a@example.com")
|
||||
.await;
|
||||
let (_user_b_id, session_b) = app
|
||||
.create_authenticated_user("synth-owner-b@example.com")
|
||||
.await;
|
||||
|
||||
// Insert a synthesis for User A
|
||||
let sections = sample_sections();
|
||||
let synth_id = app
|
||||
.insert_test_synthesis(user_a_id, "2026-W12", §ions)
|
||||
.await;
|
||||
|
||||
// User B lists syntheses — should be empty
|
||||
let (status_b, body_b) = app
|
||||
.get_with_session("/api/v1/syntheses", &session_b)
|
||||
.await;
|
||||
assert_eq!(status_b, StatusCode::OK);
|
||||
let items_b = body_b["items"].as_array().expect("items array");
|
||||
assert!(
|
||||
items_b.is_empty(),
|
||||
"User B should not see User A's syntheses"
|
||||
);
|
||||
|
||||
// User B tries to access User A's synthesis by ID — should 404
|
||||
let (get_status, get_body) = app
|
||||
.get_with_session(&format!("/api/v1/syntheses/{}", synth_id), &session_b)
|
||||
.await;
|
||||
assert_eq!(
|
||||
get_status,
|
||||
StatusCode::NOT_FOUND,
|
||||
"User B should not access User A's synthesis"
|
||||
);
|
||||
assert_eq!(get_body["error"], "not_found");
|
||||
|
||||
// User A can still see their synthesis
|
||||
let (status_a, body_a) = app
|
||||
.get_with_session("/api/v1/syntheses", &session_a)
|
||||
.await;
|
||||
assert_eq!(status_a, StatusCode::OK);
|
||||
let items_a = body_a["items"].as_array().expect("items array");
|
||||
assert_eq!(items_a.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn user_b_cannot_delete_user_a_synthesis() {
|
||||
if !require_test_db() {
|
||||
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
}
|
||||
|
||||
let app = common::TestApp::new().await;
|
||||
let (user_a_id, session_a) = app
|
||||
.create_authenticated_user("synth-del-a@example.com")
|
||||
.await;
|
||||
let (_user_b_id, session_b) = app
|
||||
.create_authenticated_user("synth-del-b@example.com")
|
||||
.await;
|
||||
|
||||
// Insert a synthesis for User A
|
||||
let sections = sample_sections();
|
||||
let synth_id = app
|
||||
.insert_test_synthesis(user_a_id, "2026-W12", §ions)
|
||||
.await;
|
||||
|
||||
// User B tries to delete User A's synthesis — should 404
|
||||
let (del_status, del_body) = app
|
||||
.delete_with_session(&format!("/api/v1/syntheses/{}", synth_id), &session_b)
|
||||
.await;
|
||||
assert_eq!(
|
||||
del_status,
|
||||
StatusCode::NOT_FOUND,
|
||||
"User B should not be able to delete User A's synthesis"
|
||||
);
|
||||
assert_eq!(del_body["error"], "not_found");
|
||||
|
||||
// User A's synthesis should still exist
|
||||
let (get_status, _) = app
|
||||
.get_with_session(&format!("/api/v1/syntheses/{}", synth_id), &session_a)
|
||||
.await;
|
||||
assert_eq!(
|
||||
get_status,
|
||||
StatusCode::OK,
|
||||
"User A's synthesis should still exist after User B's delete attempt"
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Pagination (2 tests)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_syntheses_limit_returns_at_most_n() {
|
||||
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("synth-limit@example.com")
|
||||
.await;
|
||||
|
||||
// Insert 3 syntheses
|
||||
let sections = sample_sections();
|
||||
for week in &["2026-W10", "2026-W11", "2026-W12"] {
|
||||
app.insert_test_synthesis(user_id, week, §ions).await;
|
||||
}
|
||||
|
||||
// Request with limit=2
|
||||
let (status, body) = app
|
||||
.get_with_session("/api/v1/syntheses?limit=2", &session)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let items = body["items"].as_array().expect("items array");
|
||||
assert_eq!(
|
||||
items.len(),
|
||||
2,
|
||||
"limit=2 should return at most 2 syntheses"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_syntheses_offset_skips_first_item() {
|
||||
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("synth-offset@example.com")
|
||||
.await;
|
||||
|
||||
// Insert 3 syntheses (created in order, newest-first in response)
|
||||
let sections = sample_sections();
|
||||
for week in &["2026-W10", "2026-W11", "2026-W12"] {
|
||||
app.insert_test_synthesis(user_id, week, §ions).await;
|
||||
// Small delay to ensure different created_at timestamps
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
|
||||
// Get all to see the order
|
||||
let (_, all_body) = app
|
||||
.get_with_session("/api/v1/syntheses", &session)
|
||||
.await;
|
||||
let all_items = all_body["items"].as_array().unwrap();
|
||||
assert_eq!(all_items.len(), 3);
|
||||
|
||||
// Get with offset=1 — should skip the first (newest)
|
||||
let (status, body) = app
|
||||
.get_with_session("/api/v1/syntheses?offset=1", &session)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let items = body["items"].as_array().expect("items array");
|
||||
assert_eq!(items.len(), 2, "offset=1 should skip 1 and return 2");
|
||||
|
||||
// The first item in the offset response should match the second in the full list
|
||||
assert_eq!(items[0]["id"], all_items[1]["id"]);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Generation Trigger (3 tests)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn generate_without_auth_returns_401() {
|
||||
if !require_test_db() {
|
||||
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
}
|
||||
|
||||
let app = common::TestApp::new().await;
|
||||
let body = serde_json::json!({});
|
||||
let (status, resp) = app.post("/api/v1/syntheses/generate", &body).await;
|
||||
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"POST /syntheses/generate without auth should return 401"
|
||||
);
|
||||
assert_eq!(resp["error"], "unauthorized");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn generate_returns_202_with_job_id() {
|
||||
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("synth-gen@example.com")
|
||||
.await;
|
||||
|
||||
let body = serde_json::json!({});
|
||||
let (status, resp) = app
|
||||
.post_with_session("/api/v1/syntheses/generate", &body, &session)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::ACCEPTED,
|
||||
"POST /syntheses/generate should return 202 Accepted"
|
||||
);
|
||||
// Response should contain a job_id (UUID)
|
||||
let job_id = resp["job_id"].as_str().expect("job_id should be a string");
|
||||
assert!(
|
||||
uuid::Uuid::parse_str(job_id).is_ok(),
|
||||
"job_id should be a valid UUID"
|
||||
);
|
||||
assert!(
|
||||
resp["message"].as_str().is_some(),
|
||||
"Response should contain a message"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn generate_twice_returns_error_for_second() {
|
||||
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("synth-gen-dup@example.com")
|
||||
.await;
|
||||
|
||||
let body = serde_json::json!({});
|
||||
|
||||
// First call should succeed with 202
|
||||
let (status1, resp1) = app
|
||||
.post_with_session("/api/v1/syntheses/generate", &body, &session)
|
||||
.await;
|
||||
assert_eq!(status1, StatusCode::ACCEPTED);
|
||||
assert!(resp1["job_id"].as_str().is_some());
|
||||
|
||||
// Second call should fail — one job at a time
|
||||
let (status2, resp2) = app
|
||||
.post_with_session("/api/v1/syntheses/generate", &body, &session)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status2,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Second generate call should be rejected while one is in progress"
|
||||
);
|
||||
assert_eq!(resp2["error"], "bad_request");
|
||||
}
|
||||
@ -0,0 +1,81 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { ProgressEvent } from '~/types';
|
||||
|
||||
// We test SSE event parsing logic in isolation, since EventSource
|
||||
// requires a real HTTP connection that vitest/jsdom does not support.
|
||||
// Instead we test the parsing logic that the SSE handler would use.
|
||||
|
||||
describe('SSE event parsing', () => {
|
||||
it('should parse a progress event JSON payload', () => {
|
||||
const raw = '{"step":"search","message":"Recherche d\'actualites en cours...","percent":10}';
|
||||
const parsed: ProgressEvent = JSON.parse(raw);
|
||||
expect(parsed.step).toBe('search');
|
||||
expect(parsed.message).toBe("Recherche d'actualites en cours...");
|
||||
expect(parsed.percent).toBe(10);
|
||||
});
|
||||
|
||||
it('should parse a scraping progress event', () => {
|
||||
const raw = '{"step":"scraping","message":"Verification des sources (3/12)...","percent":40}';
|
||||
const parsed: ProgressEvent = JSON.parse(raw);
|
||||
expect(parsed.step).toBe('scraping');
|
||||
expect(parsed.percent).toBe(40);
|
||||
});
|
||||
|
||||
it('should parse a rewrite progress event', () => {
|
||||
const raw = '{"step":"rewrite","message":"Redaction des resumes...","percent":75}';
|
||||
const parsed: ProgressEvent = JSON.parse(raw);
|
||||
expect(parsed.step).toBe('rewrite');
|
||||
expect(parsed.percent).toBe(75);
|
||||
});
|
||||
|
||||
it('should parse a saving progress event', () => {
|
||||
const raw = '{"step":"saving","message":"Sauvegarde...","percent":95}';
|
||||
const parsed: ProgressEvent = JSON.parse(raw);
|
||||
expect(parsed.step).toBe('saving');
|
||||
expect(parsed.percent).toBe(95);
|
||||
});
|
||||
|
||||
it('should parse a complete event', () => {
|
||||
const raw = '{"synthesis_id":"abc123"}';
|
||||
const parsed = JSON.parse(raw) as { synthesis_id: string };
|
||||
expect(parsed.synthesis_id).toBe('abc123');
|
||||
});
|
||||
|
||||
it('should parse an error event', () => {
|
||||
const raw = '{"message":"Quota depasse. Reessayez plus tard."}';
|
||||
const parsed = JSON.parse(raw) as { message: string };
|
||||
expect(parsed.message).toBe('Quota depasse. Reessayez plus tard.');
|
||||
});
|
||||
|
||||
it('should handle malformed JSON gracefully', () => {
|
||||
const raw = 'not valid json{';
|
||||
expect(() => JSON.parse(raw)).toThrow();
|
||||
});
|
||||
|
||||
it('should handle empty data gracefully', () => {
|
||||
const raw = '';
|
||||
expect(() => JSON.parse(raw)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SSE step ordering', () => {
|
||||
const STEPS = ['search', 'scraping', 'rewrite', 'saving'];
|
||||
|
||||
it('should identify all steps before current as completed', () => {
|
||||
const currentStep = 'rewrite';
|
||||
const completedSteps = STEPS.slice(0, STEPS.indexOf(currentStep));
|
||||
expect(completedSteps).toEqual(['search', 'scraping']);
|
||||
});
|
||||
|
||||
it('should return empty for first step', () => {
|
||||
const currentStep = 'search';
|
||||
const completedSteps = STEPS.slice(0, STEPS.indexOf(currentStep));
|
||||
expect(completedSteps).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return all but last for saving step', () => {
|
||||
const currentStep = 'saving';
|
||||
const completedSteps = STEPS.slice(0, STEPS.indexOf(currentStep));
|
||||
expect(completedSteps).toEqual(['search', 'scraping', 'rewrite']);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,60 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { extractWeekNumber, formatDate, formatDateLong } from '~/utils/dates';
|
||||
|
||||
describe('extractWeekNumber', () => {
|
||||
it('should extract week number from standard ISO week string', () => {
|
||||
expect(extractWeekNumber('2026-W12')).toBe('12');
|
||||
});
|
||||
|
||||
it('should extract single-digit week number', () => {
|
||||
expect(extractWeekNumber('2026-W3')).toBe('3');
|
||||
});
|
||||
|
||||
it('should extract week number with leading zero', () => {
|
||||
expect(extractWeekNumber('2026-W01')).toBe('01');
|
||||
});
|
||||
|
||||
it('should return raw input for invalid format', () => {
|
||||
expect(extractWeekNumber('invalid')).toBe('invalid');
|
||||
});
|
||||
|
||||
it('should return raw input for empty string', () => {
|
||||
expect(extractWeekNumber('')).toBe('');
|
||||
});
|
||||
|
||||
it('should handle week 52', () => {
|
||||
expect(extractWeekNumber('2025-W52')).toBe('52');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDate', () => {
|
||||
it('should format ISO date string as dd MMM yyyy in French', () => {
|
||||
const result = formatDate('2026-03-21T10:00:00Z');
|
||||
// date-fns fr locale: "21 mars 2026" but abbreviated month
|
||||
expect(result).toMatch(/21/);
|
||||
expect(result).toMatch(/2026/);
|
||||
});
|
||||
|
||||
it('should return raw string for invalid date', () => {
|
||||
expect(formatDate('not-a-date')).toBe('not-a-date');
|
||||
});
|
||||
|
||||
it('should handle ISO date without time', () => {
|
||||
const result = formatDate('2026-01-15');
|
||||
expect(result).toMatch(/15/);
|
||||
expect(result).toMatch(/2026/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDateLong', () => {
|
||||
it('should format ISO date string with full month name in French', () => {
|
||||
const result = formatDateLong('2026-03-21T10:00:00Z');
|
||||
expect(result).toMatch(/21/);
|
||||
expect(result).toMatch(/mars/);
|
||||
expect(result).toMatch(/2026/);
|
||||
});
|
||||
|
||||
it('should return raw string for invalid date', () => {
|
||||
expect(formatDateLong('bad-date')).toBe('bad-date');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,21 @@
|
||||
import { api } from './client';
|
||||
import type { SynthesisListItem, Synthesis, GenerateResponse } from '~/types';
|
||||
|
||||
const API_BASE = '/api/v1';
|
||||
|
||||
export const synthesesApi = {
|
||||
list: (limit = 50, offset = 0): Promise<SynthesisListItem[]> =>
|
||||
api.get<SynthesisListItem[]>(`/syntheses?limit=${limit}&offset=${offset}`),
|
||||
|
||||
get: (id: string): Promise<Synthesis> =>
|
||||
api.get<Synthesis>(`/syntheses/${id}`),
|
||||
|
||||
remove: (id: string): Promise<void> =>
|
||||
api.delete<void>(`/syntheses/${id}`),
|
||||
|
||||
generate: (): Promise<GenerateResponse> =>
|
||||
api.post<GenerateResponse>('/syntheses/generate'),
|
||||
|
||||
progressUrl: (jobId: string): string =>
|
||||
`${API_BASE}/syntheses/generate/${jobId}/progress`,
|
||||
};
|
||||
@ -0,0 +1,336 @@
|
||||
import {
|
||||
type Component,
|
||||
createSignal,
|
||||
createEffect,
|
||||
onMount,
|
||||
Show,
|
||||
For,
|
||||
} from 'solid-js';
|
||||
import { useNavigate } from '@solidjs/router';
|
||||
import { AlertCircle, CheckCircle, Circle, Loader2 } from 'lucide-solid';
|
||||
import { useI18n } from '~/i18n';
|
||||
import { synthesesApi } from '~/api/syntheses';
|
||||
import { settingsApi } from '~/api/settings';
|
||||
import { isApiError, DEFAULT_SETTINGS } from '~/types';
|
||||
import type { UserSettings, ProgressEvent } from '~/types';
|
||||
import { createSSEConnection, type SSEConnection, type SSEStatus } from '~/utils/sse';
|
||||
import LoadingSpinner from '~/components/ui/LoadingSpinner';
|
||||
|
||||
interface StepInfo {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const STEPS: StepInfo[] = [
|
||||
{ key: 'search', label: 'generate.step.search' },
|
||||
{ key: 'scraping', label: 'generate.step.scraping' },
|
||||
{ key: 'rewrite', label: 'generate.step.rewrite' },
|
||||
{ key: 'saving', label: 'generate.step.saving' },
|
||||
];
|
||||
|
||||
const GenerateSynthesis: Component = () => {
|
||||
const { t } = useI18n();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [settings, setSettings] = createSignal<UserSettings>({ ...DEFAULT_SETTINGS });
|
||||
const [loadingSettings, setLoadingSettings] = createSignal(true);
|
||||
const [generating, setGenerating] = createSignal(false);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [success, setSuccess] = createSignal(false);
|
||||
const [sseConnection, setSSEConnection] = createSignal<SSEConnection | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const data = await settingsApi.get();
|
||||
setSettings(data);
|
||||
} catch (err) {
|
||||
if (isApiError(err) && err.status !== 404) {
|
||||
// Non-404 means a real error; 404 means no settings yet, use defaults
|
||||
}
|
||||
} finally {
|
||||
setLoadingSettings(false);
|
||||
}
|
||||
});
|
||||
|
||||
const currentStep = (): string | null => {
|
||||
const conn = sseConnection();
|
||||
if (!conn) return null;
|
||||
const progress = conn.latestProgress();
|
||||
return progress?.step ?? null;
|
||||
};
|
||||
|
||||
const currentPercent = (): number => {
|
||||
const conn = sseConnection();
|
||||
if (!conn) return 0;
|
||||
const progress = conn.latestProgress();
|
||||
return progress?.percent ?? 0;
|
||||
};
|
||||
|
||||
const currentMessage = (): string => {
|
||||
const conn = sseConnection();
|
||||
if (!conn) return '';
|
||||
const progress = conn.latestProgress();
|
||||
return progress?.message ?? '';
|
||||
};
|
||||
|
||||
const sseStatus = (): SSEStatus => {
|
||||
const conn = sseConnection();
|
||||
if (!conn) return 'idle';
|
||||
return conn.status();
|
||||
};
|
||||
|
||||
// Collect completed steps from progress events
|
||||
const completedSteps = (): Set<string> => {
|
||||
const conn = sseConnection();
|
||||
if (!conn) return new Set();
|
||||
const events = conn.events();
|
||||
const completed = new Set<string>();
|
||||
const progressEvents = events
|
||||
.filter((e) => e.type === 'progress')
|
||||
.map((e) => (e as { type: 'progress'; data: ProgressEvent }).data);
|
||||
|
||||
// Mark all steps before the current one as completed
|
||||
const current = currentStep();
|
||||
for (const step of STEPS) {
|
||||
if (step.key === current) break;
|
||||
// If we've received any progress event for a later step, earlier ones are done
|
||||
const hasLaterEvent = progressEvents.some((pe) => {
|
||||
const stepIndex = STEPS.findIndex((s) => s.key === pe.step);
|
||||
const thisIndex = STEPS.findIndex((s) => s.key === step.key);
|
||||
return stepIndex > thisIndex;
|
||||
});
|
||||
if (hasLaterEvent) {
|
||||
completed.add(step.key);
|
||||
}
|
||||
}
|
||||
|
||||
// Also mark steps that had a 100%-equivalent high percent
|
||||
for (const pe of progressEvents) {
|
||||
const stepIndex = STEPS.findIndex((s) => s.key === pe.step);
|
||||
const currentStepIndex = current ? STEPS.findIndex((s) => s.key === current) : -1;
|
||||
if (stepIndex >= 0 && stepIndex < currentStepIndex) {
|
||||
completed.add(pe.step);
|
||||
}
|
||||
}
|
||||
|
||||
return completed;
|
||||
};
|
||||
|
||||
const stepStatus = (stepKey: string): 'done' | 'in-progress' | 'pending' => {
|
||||
const conn = sseConnection();
|
||||
if (!conn) return 'pending';
|
||||
|
||||
if (sseStatus() === 'complete') return 'done';
|
||||
if (completedSteps().has(stepKey)) return 'done';
|
||||
if (currentStep() === stepKey) return 'in-progress';
|
||||
|
||||
// Check if this step comes before the current step
|
||||
const thisIndex = STEPS.findIndex((s) => s.key === stepKey);
|
||||
const currentIndex = currentStep()
|
||||
? STEPS.findIndex((s) => s.key === currentStep())
|
||||
: -1;
|
||||
if (thisIndex < currentIndex) return 'done';
|
||||
|
||||
return 'pending';
|
||||
};
|
||||
|
||||
// Auto-redirect on completion
|
||||
createEffect(() => {
|
||||
const conn = sseConnection();
|
||||
if (!conn) return;
|
||||
const synthId = conn.completedSynthesisId();
|
||||
if (synthId) {
|
||||
setSuccess(true);
|
||||
setTimeout(() => {
|
||||
navigate(`/synthesis/${synthId}`);
|
||||
}, 2000);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle SSE errors
|
||||
createEffect(() => {
|
||||
const conn = sseConnection();
|
||||
if (!conn) return;
|
||||
const errMsg = conn.errorMessage();
|
||||
if (errMsg) {
|
||||
setError(errMsg);
|
||||
setGenerating(false);
|
||||
}
|
||||
});
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (generating()) return;
|
||||
|
||||
setGenerating(true);
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
|
||||
try {
|
||||
const response = await synthesesApi.generate();
|
||||
const url = synthesesApi.progressUrl(response.job_id);
|
||||
const conn = createSSEConnection(url);
|
||||
setSSEConnection(conn);
|
||||
} catch (err) {
|
||||
setGenerating(false);
|
||||
if (isApiError(err)) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError(t('generate.error'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
// Close existing connection
|
||||
const conn = sseConnection();
|
||||
if (conn) {
|
||||
conn.close();
|
||||
}
|
||||
setSSEConnection(null);
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
setGenerating(false);
|
||||
};
|
||||
|
||||
const isInProgress = () => generating() && !success() && !error();
|
||||
|
||||
return (
|
||||
<Show when={!loadingSettings()} fallback={<LoadingSpinner />}>
|
||||
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
||||
{t('generate.title')}
|
||||
</h3>
|
||||
<div class="mt-2 max-w-xl text-sm text-gray-500">
|
||||
<p innerHTML={t('generate.description', {
|
||||
days: String(settings().max_age_days),
|
||||
theme: settings().theme,
|
||||
})} />
|
||||
<p class="mt-2 text-xs text-gray-400">
|
||||
{t('generate.note')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
<Show when={error()}>
|
||||
<div class="mt-4 bg-red-50 border-l-4 border-red-400 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<AlertCircle class="h-5 w-5 text-red-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-red-700">{error()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Success display */}
|
||||
<Show when={success()}>
|
||||
<div class="mt-4 bg-green-50 border-l-4 border-green-400 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<CheckCircle class="h-5 w-5 text-green-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-green-700">{t('generate.complete')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Progress display */}
|
||||
<Show when={isInProgress() && sseConnection()}>
|
||||
<div class="mt-6 space-y-4">
|
||||
{/* Progress bar */}
|
||||
<div>
|
||||
<div class="flex justify-between text-sm text-gray-600 mb-1">
|
||||
<span>{currentMessage()}</span>
|
||||
<span>{currentPercent()}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-3">
|
||||
<div
|
||||
class="bg-indigo-600 rounded-full h-3 transition-all duration-500 ease-out"
|
||||
style={{ width: `${currentPercent()}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step checklist */}
|
||||
<div class="space-y-3 mt-4">
|
||||
<For each={STEPS}>
|
||||
{(step) => {
|
||||
const status = () => stepStatus(step.key);
|
||||
return (
|
||||
<div class="flex items-center gap-3">
|
||||
<Show when={status() === 'done'}>
|
||||
<CheckCircle class="h-5 w-5 text-green-500 flex-shrink-0" />
|
||||
</Show>
|
||||
<Show when={status() === 'in-progress'}>
|
||||
<Loader2 class="h-5 w-5 text-indigo-600 animate-spin flex-shrink-0" />
|
||||
</Show>
|
||||
<Show when={status() === 'pending'}>
|
||||
<Circle class="h-5 w-5 text-gray-300 flex-shrink-0" />
|
||||
</Show>
|
||||
<span
|
||||
class={`text-sm ${
|
||||
status() === 'done'
|
||||
? 'text-green-700'
|
||||
: status() === 'in-progress'
|
||||
? 'text-indigo-700 font-medium'
|
||||
: 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{t(step.label as Parameters<typeof t>[0])}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{/* Can leave note */}
|
||||
<p class="text-xs text-gray-400 mt-4 italic">
|
||||
{t('generate.canLeave')}
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div class="mt-5">
|
||||
<Show
|
||||
when={!error()}
|
||||
fallback={
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRetry}
|
||||
class="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:text-sm w-full sm:w-auto"
|
||||
>
|
||||
{t('generate.retry')}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
disabled={generating() || success()}
|
||||
class="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed w-full sm:w-auto"
|
||||
>
|
||||
<Show when={generating() && !success()}>
|
||||
<Loader2 class="animate-spin -ml-1 mr-2 h-5 w-5 text-white" />
|
||||
</Show>
|
||||
{generating() && !success()
|
||||
? t('generate.inProgress')
|
||||
: t('generate.launch')}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
export default GenerateSynthesis;
|
||||
@ -0,0 +1,191 @@
|
||||
import {
|
||||
type Component,
|
||||
createSignal,
|
||||
onMount,
|
||||
Show,
|
||||
For,
|
||||
} from 'solid-js';
|
||||
import { useParams, useNavigate, A } from '@solidjs/router';
|
||||
import { ArrowLeft, ExternalLink, Trash2, AlertTriangle } from 'lucide-solid';
|
||||
import { useI18n } from '~/i18n';
|
||||
import { synthesesApi } from '~/api/syntheses';
|
||||
import { isApiError } from '~/types';
|
||||
import type { Synthesis, NewsItem as NewsItemType } from '~/types';
|
||||
import { extractWeekNumber, formatDateLong } from '~/utils/dates';
|
||||
import LoadingSpinner from '~/components/ui/LoadingSpinner';
|
||||
|
||||
const NewsItemCard: Component<{ item: NewsItemType }> = (props) => {
|
||||
return (
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-100 p-6 hover:shadow-md transition-shadow">
|
||||
<h3 class="text-lg font-semibold text-indigo-700 mb-2">
|
||||
<a
|
||||
href={props.item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="hover:underline flex items-center gap-2"
|
||||
>
|
||||
{props.item.title}
|
||||
<ExternalLink class="h-4 w-4 text-gray-400 flex-shrink-0" />
|
||||
</a>
|
||||
</h3>
|
||||
<p class="text-gray-700 leading-relaxed text-sm">
|
||||
{props.item.summary}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Section: Component<{ title: string; items: NewsItemType[] }> = (props) => {
|
||||
return (
|
||||
<Show when={props.items && props.items.length > 0}>
|
||||
<div class="mb-10">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-6 border-b pb-2">
|
||||
{props.title}
|
||||
</h2>
|
||||
<div class="space-y-6">
|
||||
<For each={props.items}>
|
||||
{(item) => <NewsItemCard item={item} />}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
const SynthesisDetail: Component = () => {
|
||||
const { t } = useI18n();
|
||||
const params = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [synthesis, setSynthesis] = createSignal<Synthesis | null>(null);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = createSignal(false);
|
||||
const [isDeleting, setIsDeleting] = createSignal(false);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const data = await synthesesApi.get(params.id);
|
||||
setSynthesis(data);
|
||||
} catch (err) {
|
||||
if (isApiError(err) && err.status === 404) {
|
||||
setError(t('synthesis.notFound'));
|
||||
} else if (isApiError(err)) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError(t('synthesis.loadError'));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
const handleDelete = async () => {
|
||||
const synth = synthesis();
|
||||
if (!synth) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await synthesesApi.remove(synth.id);
|
||||
navigate('/');
|
||||
} catch (err) {
|
||||
if (isApiError(err)) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError(t('synthesis.deleteError'));
|
||||
}
|
||||
setIsDeleting(false);
|
||||
setShowDeleteConfirm(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Show when={!loading()} fallback={<LoadingSpinner />}>
|
||||
<Show
|
||||
when={!error() && synthesis()}
|
||||
fallback={
|
||||
<div class="max-w-4xl mx-auto px-4 py-12 text-center">
|
||||
<p class="text-red-600 mb-4">{error()}</p>
|
||||
<A href="/" class="text-indigo-600 hover:text-indigo-800 font-medium">
|
||||
← {t('synthesis.backToHome')}
|
||||
</A>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(synth) => (
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-8">
|
||||
{/* Top bar: back link + delete */}
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<A
|
||||
href="/"
|
||||
class="inline-flex items-center text-sm font-medium text-indigo-600 hover:text-indigo-800"
|
||||
>
|
||||
<ArrowLeft class="mr-2 h-4 w-4" />
|
||||
{t('synthesis.backLink')}
|
||||
</A>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
class="inline-flex items-center text-sm font-medium text-red-600 hover:text-red-800"
|
||||
>
|
||||
<Trash2 class="mr-1 h-4 w-4" />
|
||||
{t('synthesis.delete')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Delete confirmation banner */}
|
||||
<Show when={showDeleteConfirm()}>
|
||||
<div class="mb-6 bg-red-50 border border-red-200 rounded-lg p-4 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div class="flex items-center text-red-800">
|
||||
<AlertTriangle class="h-5 w-5 mr-2 flex-shrink-0" />
|
||||
<p class="text-sm">{t('synthesis.deleteConfirmMessage')}</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
disabled={isDeleting()}
|
||||
class="px-3 py-1.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
{t('synthesis.deleteCancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting()}
|
||||
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-white bg-red-600 border border-transparent rounded-md hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
<Show when={isDeleting()}>
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2" />
|
||||
</Show>
|
||||
{t('synthesis.deleteConfirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Title + date badge */}
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<h1 class="text-3xl font-extrabold text-gray-900 tracking-tight">
|
||||
{t('synthesis.title', { week: extractWeekNumber(synth().week) })}
|
||||
</h1>
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-gray-100 text-gray-800">
|
||||
{t('synthesis.generatedAt', { date: formatDateLong(synth().created_at) })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sections */}
|
||||
<div class="space-y-12">
|
||||
<For each={synth().sections}>
|
||||
{(section) => (
|
||||
<Section title={section.title} items={section.items} />
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
export default SynthesisDetail;
|
||||
@ -0,0 +1,33 @@
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { fr } from 'date-fns/locale';
|
||||
|
||||
/**
|
||||
* Extract the week number from an ISO week string like "2026-W12".
|
||||
* Returns the numeric string (e.g. "12") or the raw input if parsing fails.
|
||||
*/
|
||||
export function extractWeekNumber(week: string): string {
|
||||
const match = week.match(/-W(\d{1,2})$/);
|
||||
return match ? match[1] : week;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an ISO date string (e.g. "2026-03-21T10:00:00Z") as "21 mars 2026".
|
||||
*/
|
||||
export function formatDate(isoDate: string): string {
|
||||
try {
|
||||
return format(parseISO(isoDate), 'dd MMM yyyy', { locale: fr });
|
||||
} catch {
|
||||
return isoDate;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an ISO date string as "21 mars 2026" (full month name).
|
||||
*/
|
||||
export function formatDateLong(isoDate: string): string {
|
||||
try {
|
||||
return format(parseISO(isoDate), 'dd MMMM yyyy', { locale: fr });
|
||||
} catch {
|
||||
return isoDate;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,145 @@
|
||||
import { createSignal, onCleanup } from 'solid-js';
|
||||
import type { ProgressEvent } from '~/types';
|
||||
|
||||
export type SSEStatus = 'idle' | 'connecting' | 'connected' | 'complete' | 'error';
|
||||
|
||||
export interface SSECompleteEvent {
|
||||
type: 'complete';
|
||||
synthesis_id: string;
|
||||
}
|
||||
|
||||
export interface SSEErrorEvent {
|
||||
type: 'error';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface SSEProgressEvent {
|
||||
type: 'progress';
|
||||
data: ProgressEvent;
|
||||
}
|
||||
|
||||
export type SSEEvent = SSEProgressEvent | SSECompleteEvent | SSEErrorEvent;
|
||||
|
||||
export interface SSEConnection {
|
||||
events: () => SSEEvent[];
|
||||
status: () => SSEStatus;
|
||||
latestProgress: () => ProgressEvent | null;
|
||||
completedSynthesisId: () => string | null;
|
||||
errorMessage: () => string | null;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
const BASE_RETRY_DELAY = 1000;
|
||||
|
||||
export function createSSEConnection(url: string): SSEConnection {
|
||||
const [events, setEvents] = createSignal<SSEEvent[]>([]);
|
||||
const [status, setStatus] = createSignal<SSEStatus>('idle');
|
||||
const [latestProgress, setLatestProgress] = createSignal<ProgressEvent | null>(null);
|
||||
const [completedSynthesisId, setCompletedSynthesisId] = createSignal<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = createSignal<string | null>(null);
|
||||
|
||||
let eventSource: EventSource | null = null;
|
||||
let retryCount = 0;
|
||||
let retryTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let closed = false;
|
||||
|
||||
function connect() {
|
||||
if (closed) return;
|
||||
|
||||
setStatus('connecting');
|
||||
eventSource = new EventSource(url, { withCredentials: true });
|
||||
|
||||
eventSource.onopen = () => {
|
||||
setStatus('connected');
|
||||
retryCount = 0;
|
||||
};
|
||||
|
||||
eventSource.addEventListener('progress', (e: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data) as ProgressEvent;
|
||||
const event: SSEProgressEvent = { type: 'progress', data };
|
||||
setEvents((prev) => [...prev, event]);
|
||||
setLatestProgress(data);
|
||||
} catch {
|
||||
// Ignore malformed events
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener('complete', (e: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data) as { synthesis_id: string };
|
||||
const event: SSECompleteEvent = { type: 'complete', synthesis_id: data.synthesis_id };
|
||||
setEvents((prev) => [...prev, event]);
|
||||
setCompletedSynthesisId(data.synthesis_id);
|
||||
setStatus('complete');
|
||||
cleanup();
|
||||
} catch {
|
||||
// Ignore malformed events
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener('error', (e: MessageEvent) => {
|
||||
// Named 'error' event from server (not connection error)
|
||||
try {
|
||||
const data = JSON.parse(e.data) as { message: string };
|
||||
const event: SSEErrorEvent = { type: 'error', message: data.message };
|
||||
setEvents((prev) => [...prev, event]);
|
||||
setErrorMessage(data.message);
|
||||
setStatus('error');
|
||||
cleanup();
|
||||
} catch {
|
||||
// Ignore malformed events
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.onerror = () => {
|
||||
// Connection error -- attempt reconnect if not already completed or explicitly errored
|
||||
if (closed || status() === 'complete' || status() === 'error') return;
|
||||
|
||||
cleanup();
|
||||
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
retryCount++;
|
||||
const delay = BASE_RETRY_DELAY * Math.pow(2, retryCount - 1);
|
||||
retryTimeout = setTimeout(() => {
|
||||
connect();
|
||||
}, delay);
|
||||
} else {
|
||||
setErrorMessage('La connexion au serveur a ete perdue. Veuillez reessayer.');
|
||||
setStatus('error');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
closed = true;
|
||||
if (retryTimeout) {
|
||||
clearTimeout(retryTimeout);
|
||||
retryTimeout = null;
|
||||
}
|
||||
cleanup();
|
||||
}
|
||||
|
||||
connect();
|
||||
|
||||
onCleanup(() => {
|
||||
close();
|
||||
});
|
||||
|
||||
return {
|
||||
events,
|
||||
status,
|
||||
latestProgress,
|
||||
completedSynthesisId,
|
||||
errorMessage,
|
||||
close,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue