feat(01-03): API routers (calendar, generate, export, settings) e wiring main.py
- schemas/settings.py: Settings pydantic model con api_key, llm_model, nicchie_attive, tono
- routers/calendar.py: POST /api/calendar/generate, GET /api/calendar/formats
- routers/generate.py: POST /api/generate/bulk (202 + job_id), GET /job/{job_id}/status (polling), GET /job/{job_id}, POST /single
- routers/export.py: GET /api/export/{job_id}/csv (originale), POST /api/export/{job_id}/csv (modifiche inline)
- routers/settings.py: GET /api/settings/status (api_key_configured), GET /api/settings, PUT /api/settings
- main.py: include_router x4 PRIMA di SPAStaticFiles, copia prompt default al primo avvio
This commit is contained in:
246
backend/routers/generate.py
Normal file
246
backend/routers/generate.py
Normal file
@@ -0,0 +1,246 @@
|
||||
"""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,
|
||||
)
|
||||
Reference in New Issue
Block a user