"""Router per la generazione di post carosello via LLM. Endpoint: - POST /api/generate/bulk — avvia generazione batch (async, ritorna job_id immediatamente) - GET /api/generate/job/{job_id}/status — stato e progresso per polling - GET /api/generate/job/{job_id} — risultati completi job - POST /api/generate/single — genera un singolo post """ from __future__ import annotations import json import logging from pathlib import Path from typing import Optional from fastapi import APIRouter, BackgroundTasks, HTTPException from pydantic import BaseModel from backend.config import CONFIG_PATH, OUTPUTS_PATH, PROMPTS_PATH from backend.schemas.calendar import CalendarRequest from backend.schemas.generate import GenerateRequest, GenerateResponse, PostResult from backend.services.calendar_service import CalendarService from backend.services.csv_builder import CSVBuilder from backend.services.format_selector import FormatSelector from backend.services.generation_pipeline import GenerationPipeline, JobStatus from backend.services.llm_service import LLMService from backend.services.prompt_service import PromptService logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/generate", tags=["generate"]) # --------------------------------------------------------------------------- # Response schemas specifici del router # --------------------------------------------------------------------------- class BulkGenerateResponse(BaseModel): """Risposta per POST /bulk — ritorna subito il job_id.""" job_id: str message: str = "Generazione avviata in background. Usa /job/{job_id}/status per monitorare." class JobStatusResponse(BaseModel): """Risposta per GET /job/{job_id}/status — stato per polling.""" job_id: str status: str # "running" | "completed" | "failed" total: int completed: int current_post: int results: list[PostResult] error: Optional[str] = None # --------------------------------------------------------------------------- # Helpers per ottenere istanze dei servizi # --------------------------------------------------------------------------- def _load_api_key() -> Optional[str]: """Carica l'API key da settings.json.""" settings_path = CONFIG_PATH / "settings.json" if settings_path.exists(): try: data = json.loads(settings_path.read_text(encoding="utf-8")) return data.get("api_key") except Exception: return None # Fallback a env var import os return os.getenv("ANTHROPIC_API_KEY") or None def _get_pipeline() -> GenerationPipeline: """Crea e ritorna una GenerationPipeline con i servizi configurati. Questa funzione viene chiamata a ogni request per costruire la pipeline con l'API key corrente (permette di aggiornare la key senza restart). """ api_key = _load_api_key() if not api_key: raise HTTPException( status_code=400, detail="API key Anthropic non configurata. Vai in Impostazioni per aggiungere la tua API key.", ) llm_service = LLMService(api_key=api_key) prompt_service = PromptService(prompts_dir=PROMPTS_PATH) calendar_service = CalendarService() format_selector = FormatSelector() csv_builder = CSVBuilder() return GenerationPipeline( llm_service=llm_service, prompt_service=prompt_service, calendar_service=calendar_service, format_selector=format_selector, csv_builder=csv_builder, outputs_path=OUTPUTS_PATH, ) # Pipeline singleton in-memory per tracciare i job durante la sessione # (supporta restart via caricamento da disco in get_job_status) _pipeline_instance: Optional[GenerationPipeline] = None def _get_or_create_pipeline() -> GenerationPipeline: """Ritorna il pipeline singleton o ne crea uno nuovo. Il singleton viene ricreato se l'API key cambia. """ global _pipeline_instance if _pipeline_instance is None: _pipeline_instance = _get_pipeline() return _pipeline_instance # --------------------------------------------------------------------------- # Endpoint # --------------------------------------------------------------------------- @router.post("/bulk", status_code=202, response_model=BulkGenerateResponse) async def generate_bulk(request: CalendarRequest) -> BulkGenerateResponse: """Avvia la generazione batch di 13 post in background. Verifica che l'API key sia configurata, poi avvia immediatamente la generazione in background e ritorna il job_id. Il frontend deve fare polling su GET /job/{job_id}/status ogni 2 secondi finché status != "running". Args: request: Parametri del calendario (obiettivo, nicchie, frequenza, etc.). Returns: 202 Accepted con job_id per il polling. Raises: HTTPException 400: Se l'API key non è configurata. """ # Verifica API key prima di tutto api_key = _load_api_key() if not api_key: raise HTTPException( status_code=400, detail="API key Anthropic non configurata. Vai in Impostazioni per aggiungere la tua API key.", ) pipeline = _get_or_create_pipeline() job_id = pipeline.generate_bulk_async(request) return BulkGenerateResponse(job_id=job_id) @router.get("/job/{job_id}/status", response_model=JobStatusResponse) async def get_job_status(job_id: str) -> JobStatusResponse: """Ritorna lo stato corrente del job per polling. Chiamato ogni 2 secondi dal frontend finché status != "running". Ritorna anche i risultati parziali (post completati fino a quel momento). Args: job_id: Identificatore del job (da POST /bulk). Returns: Stato con total, completed, current_post e risultati parziali. Raises: HTTPException 404: Se il job non esiste. """ pipeline = _get_or_create_pipeline() status = pipeline.get_job_status(job_id) if status is None: raise HTTPException( status_code=404, detail=f"Job '{job_id}' non trovato.", ) return JobStatusResponse( job_id=status.job_id, status=status.status, total=status.total, completed=status.completed, current_post=status.current_post, results=status.results, error=status.error, ) @router.get("/job/{job_id}", response_model=GenerateResponse) async def get_job_results(job_id: str) -> GenerateResponse: """Ritorna i risultati completi di un job completato. Args: job_id: Identificatore del job. Returns: GenerateResponse con tutti i PostResult e conteggi success/failed. Raises: HTTPException 404: Se il job non esiste o non è ancora completato. """ pipeline = _get_or_create_pipeline() results = pipeline.get_job_results(job_id) if results is None: raise HTTPException( status_code=404, detail=f"Job '{job_id}' non trovato.", ) return results @router.post("/single", response_model=PostResult) async def generate_single(request: GenerateRequest) -> PostResult: """Genera un singolo post carosello. Utile per rigenerare post falliti o per anteprima di uno slot specifico. Args: request: Slot con metadati + obiettivo campagna + brand_name opzionale. Returns: PostResult con status="success" e post generato, o status="failed" con errore. Raises: HTTPException 400: Se l'API key non è configurata. """ api_key = _load_api_key() if not api_key: raise HTTPException( status_code=400, detail="API key Anthropic non configurata.", ) pipeline = _get_or_create_pipeline() return pipeline.generate_single( slot=request.slot, obiettivo_campagna=request.obiettivo_campagna, brand_name=request.brand_name, tono=request.tono, )