//! 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, ) -> Result { // 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, Path(job_id): Path, ) -> Result>>, 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"), )) }