diff --git a/backend/main.py b/backend/main.py index 46e4550..1b173ec 100644 --- a/backend/main.py +++ b/backend/main.py @@ -5,6 +5,7 @@ It is passed only via Uvicorn's --root-path flag at runtime to avoid the double-path bug (Pitfall #4). """ +import shutil from contextlib import asynccontextmanager from pathlib import Path @@ -13,6 +14,7 @@ from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from backend.config import CAMPAIGNS_PATH, CONFIG_PATH, OUTPUTS_PATH, PROMPTS_PATH +from backend.routers import calendar, export, generate, settings # --------------------------------------------------------------------------- @@ -35,11 +37,27 @@ class SPAStaticFiles(StaticFiles): # Startup / shutdown lifecycle # --------------------------------------------------------------------------- +# Directory dei prompt di default (inclusa nel source) +_DEFAULT_PROMPTS_DIR = Path(__file__).parent / "data" / "prompts" + + @asynccontextmanager async def lifespan(app: FastAPI): - """Create required data directories on startup if they do not exist.""" + """Create required data directories on startup if they do not exist. + + Also copies default prompts to PROMPTS_PATH on first run (when empty). + """ + # Crea directory dati for directory in (PROMPTS_PATH, OUTPUTS_PATH, CAMPAIGNS_PATH, CONFIG_PATH): directory.mkdir(parents=True, exist_ok=True) + + # Copia prompt default al primo avvio (se PROMPTS_PATH è vuota) + if _DEFAULT_PROMPTS_DIR.exists() and not any(PROMPTS_PATH.glob("*.txt")): + for prompt_file in _DEFAULT_PROMPTS_DIR.glob("*.txt"): + dest = PROMPTS_PATH / prompt_file.name + if not dest.exists(): + shutil.copy2(prompt_file, dest) + yield # Nothing to clean up on shutdown @@ -68,6 +86,13 @@ async def health() -> dict: return {"status": "ok"} +# Include all API routers — ORDER MATTERS (registered before SPA catch-all) +app.include_router(calendar.router) +app.include_router(generate.router) +app.include_router(export.router) +app.include_router(settings.router) + + # --------------------------------------------------------------------------- # SPA catch-all mount (MUST be last — catches everything not matched above) # --------------------------------------------------------------------------- diff --git a/backend/routers/calendar.py b/backend/routers/calendar.py new file mode 100644 index 0000000..356c08e --- /dev/null +++ b/backend/routers/calendar.py @@ -0,0 +1,59 @@ +"""Router per la gestione del calendario editoriale. + +Endpoint: +- POST /api/calendar/generate — genera un calendario di 13 slot +- GET /api/calendar/formats — ritorna il mapping formati disponibili +""" + +from __future__ import annotations + +from fastapi import APIRouter + +from backend.schemas.calendar import CalendarRequest, CalendarResponse +from backend.services.calendar_service import CalendarService +from backend.services.format_selector import FormatSelector + + +router = APIRouter(prefix="/api/calendar", tags=["calendar"]) + +# Istanze dei servizi (create una volta alla startup del router) +_format_selector = FormatSelector() +_calendar_service = CalendarService(format_selector=_format_selector) + + +@router.post("/generate", response_model=CalendarResponse) +async def generate_calendar(request: CalendarRequest) -> CalendarResponse: + """Genera un calendario editoriale di 13 slot. + + Produce un piano di pubblicazione con: + - Distribuzione PN (valore, storytelling, news, riprova_sociale, coinvolgimento, promozione) + - Livelli Schwartz L1-L5 corretti per ogni tipo di contenuto + - Formato narrativo selezionato automaticamente + - Date di pubblicazione suggerite + - Rotazione nicchie (50% generico, 50% verticali) + + Args: + request: Parametri del calendario (obiettivo, settimane, nicchie, frequenza, data_inizio). + + Returns: + CalendarResponse con 13 slot ordinati per fase funnel. + """ + return _calendar_service.generate_calendar(request) + + +@router.get("/formats") +async def get_formats() -> dict: + """Ritorna il mapping completo dei formati narrativi disponibili. + + Utile per il frontend per visualizzare quale formato viene usato + per ogni combinazione tipo_contenuto x livello_schwartz. + + Returns: + Dict con le 30 combinazioni tipo x livello -> formato narrativo. + """ + return { + "mapping": _format_selector.get_mapping(), + "formati_disponibili": list( + set(_format_selector.get_mapping().values()) + ), + } diff --git a/backend/routers/export.py b/backend/routers/export.py new file mode 100644 index 0000000..d48574a --- /dev/null +++ b/backend/routers/export.py @@ -0,0 +1,150 @@ +"""Router per l'export del CSV Canva. + +Endpoint: +- GET /api/export/{job_id}/csv — scarica CSV originale con Content-Disposition attachment +- POST /api/export/{job_id}/csv — accetta dati modificati inline e rigenera il CSV +""" + +from __future__ import annotations + +import logging +from pathlib import Path + +from fastapi import APIRouter, HTTPException +from fastapi.responses import FileResponse, Response +from pydantic import BaseModel + +from backend.config import OUTPUTS_PATH +from backend.schemas.generate import PostResult +from backend.services.csv_builder import CSVBuilder +from backend.services.generation_pipeline import GenerationPipeline + + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/export", tags=["export"]) + +_csv_builder = CSVBuilder() + + +# --------------------------------------------------------------------------- +# Request schema per POST con dati modificati +# --------------------------------------------------------------------------- + +class ExportWithEditsRequest(BaseModel): + """Richiesta POST per rigenerare il CSV con dati modificati dall'utente.""" + results: list[PostResult] + + +# --------------------------------------------------------------------------- +# Endpoint +# --------------------------------------------------------------------------- + +@router.get("/{job_id}/csv") +async def download_csv_original(job_id: str) -> FileResponse: + """Scarica il CSV originale generato per un job. + + Cerca il file CSV in OUTPUTS_PATH/{job_id}.csv e lo serve come attachment. + + Args: + job_id: Identificatore del job (da POST /api/generate/bulk). + + Returns: + File CSV con Content-Disposition: attachment per il download automatico. + + Raises: + HTTPException 404: Se il file CSV non esiste (job non completato o non trovato). + """ + csv_path = OUTPUTS_PATH / f"{job_id}.csv" + + if not csv_path.exists(): + raise HTTPException( + status_code=404, + detail=f"File CSV per job '{job_id}' non trovato. " + "Assicurati che la generazione sia completata.", + ) + + return FileResponse( + path=str(csv_path), + media_type="text/csv; charset=utf-8", + headers={ + "Content-Disposition": f'attachment; filename="postgenerator_{job_id}.csv"', + }, + ) + + +@router.post("/{job_id}/csv") +async def download_csv_with_edits( + job_id: str, + request: ExportWithEditsRequest, +) -> Response: + """Rigenera il CSV con i dati modificati inline dall'utente. + + Accetta i PostResult aggiornati dal frontend (con modifiche al testo + delle slide, titoli, caption, etc.) e produce un nuovo CSV aggiornato. + + Il file viene salvato come OUTPUTS_PATH/{job_id}_edited.csv. + + Args: + job_id: Identificatore del job originale. + request: Lista di PostResult con i dati aggiornati dall'utente. + + Returns: + File CSV rigenerato con le modifiche inline. + + Raises: + HTTPException 404: Se il job originale (e quindi il calendario) non esiste. + """ + # Carica il calendario originale dal job per i metadati degli slot + job_path = OUTPUTS_PATH / f"{job_id}.json" + if not job_path.exists(): + raise HTTPException( + status_code=404, + detail=f"Job '{job_id}' non trovato. " + "Impossibile rigenerare il CSV senza i metadati originali.", + ) + + # Carica il calendario dal JSON del job + import json + from backend.schemas.calendar import CalendarResponse + + try: + with open(job_path, "r", encoding="utf-8") as f: + job_data = json.load(f) + calendar = CalendarResponse.model_validate(job_data["calendar"]) + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Errore nel caricamento del job: {str(e)}", + ) + + # Genera il CSV con i dati modificati + csv_content = _csv_builder.build_csv_content( + posts=request.results, + calendar=calendar, + job_id=job_id, + ) + + # Salva anche su disco come versione edited + edited_path = OUTPUTS_PATH / f"{job_id}_edited.csv" + edited_path.parent.mkdir(parents=True, exist_ok=True) + with open(edited_path, "w", newline="", encoding="utf-8-sig") as f: + f.write(csv_content) + + logger.info( + "CSV con modifiche generato | job_id=%s | righe=%d | path=%s", + job_id, + len([r for r in request.results if r.status == "success"]), + edited_path, + ) + + # Ritorna il CSV come response con Content-Disposition attachment + # Nota: build_csv_content include già il BOM (\ufeff), usiamo encode("utf-8") + # per non raddoppiare il BOM nel body + return Response( + content=csv_content.encode("utf-8"), + media_type="text/csv; charset=utf-8", + headers={ + "Content-Disposition": f'attachment; filename="postgenerator_{job_id}_edited.csv"', + }, + ) diff --git a/backend/routers/generate.py b/backend/routers/generate.py new file mode 100644 index 0000000..a8d3fe3 --- /dev/null +++ b/backend/routers/generate.py @@ -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, + ) diff --git a/backend/routers/settings.py b/backend/routers/settings.py new file mode 100644 index 0000000..e18ee11 --- /dev/null +++ b/backend/routers/settings.py @@ -0,0 +1,163 @@ +"""Router per la gestione della configurazione applicativa. + +Endpoint: +- GET /api/settings — ritorna la configurazione corrente (api_key mascherata) +- PUT /api/settings — salva la configurazione aggiornata +- GET /api/settings/status — ritorna se l'api_key è configurata (per abilitare/disabilitare UI) +""" + +from __future__ import annotations + +import json +import logging +from typing import Optional + +from fastapi import APIRouter +from pydantic import BaseModel + +from backend.config import CONFIG_PATH +from backend.schemas.settings import Settings + + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/settings", tags=["settings"]) + +# Percorso al file di configurazione +_SETTINGS_FILE = CONFIG_PATH / "settings.json" + + +# --------------------------------------------------------------------------- +# Response schemas +# --------------------------------------------------------------------------- + +class SettingsStatusResponse(BaseModel): + """Risposta per GET /status — usata dal frontend per abilitare/disabilitare il pulsante genera.""" + api_key_configured: bool + llm_model: str + + +class SettingsResponse(BaseModel): + """Risposta per GET / — api_key mascherata per sicurezza.""" + api_key_masked: Optional[str] # Solo ultimi 4 caratteri o None + llm_model: str + nicchie_attive: list[str] + lingua: str + frequenza_post: int + brand_name: Optional[str] + tono: Optional[str] + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _load_settings() -> Settings: + """Carica le impostazioni da disco. Ritorna i default se il file non esiste.""" + if not _SETTINGS_FILE.exists(): + return Settings() + + try: + data = json.loads(_SETTINGS_FILE.read_text(encoding="utf-8")) + return Settings.model_validate(data) + except Exception as e: + logger.warning("Errore caricamento settings: %s — uso default", str(e)) + return Settings() + + +def _save_settings(settings: Settings) -> None: + """Salva le impostazioni su disco.""" + _SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True) + _SETTINGS_FILE.write_text( + settings.model_dump_json(indent=2), + encoding="utf-8", + ) + + +def _mask_api_key(api_key: Optional[str]) -> Optional[str]: + """Maschera la chiave API mostrando solo gli ultimi 4 caratteri. + + Es: "sk-ant-api03-abc...xyz1234" -> "...1234" + """ + if not api_key: + return None + if len(api_key) <= 4: + return "****" + return f"...{api_key[-4:]}" + + +# --------------------------------------------------------------------------- +# Endpoint +# --------------------------------------------------------------------------- + +@router.get("/status", response_model=SettingsStatusResponse) +async def get_settings_status() -> SettingsStatusResponse: + """Ritorna lo stato della configurazione essenziale. + + Usato dal frontend per abilitare/disabilitare il pulsante "Genera". + + Returns: + SettingsStatusResponse con api_key_configured e llm_model. + """ + settings = _load_settings() + return SettingsStatusResponse( + api_key_configured=bool(settings.api_key), + llm_model=settings.llm_model, + ) + + +@router.get("/", response_model=SettingsResponse) +async def get_settings() -> SettingsResponse: + """Ritorna la configurazione corrente con api_key mascherata. + + La chiave API non viene mai inviata al frontend in chiaro — + solo gli ultimi 4 caratteri vengono mostrati per conferma. + + Returns: + SettingsResponse con tutti i parametri configurabili. + """ + settings = _load_settings() + return SettingsResponse( + api_key_masked=_mask_api_key(settings.api_key), + llm_model=settings.llm_model, + nicchie_attive=settings.nicchie_attive, + lingua=settings.lingua, + frequenza_post=settings.frequenza_post, + brand_name=settings.brand_name, + tono=settings.tono, + ) + + +@router.put("/", response_model=SettingsResponse) +async def update_settings(new_settings: Settings) -> SettingsResponse: + """Aggiorna e salva la configurazione. + + Nota: se api_key è None nel body, la chiave esistente viene mantenuta + (evita di sovrascrivere accidentalmente la chiave quando si aggiornano + altri parametri senza inviare la chiave). + + Args: + new_settings: Nuova configurazione da salvare. + + Returns: + SettingsResponse aggiornata con api_key mascherata. + """ + # Carica settings esistenti per merger (non sovrascrive api_key con None) + existing = _load_settings() + + # Se la nuova api_key è None, mantieni quella esistente + if new_settings.api_key is None: + new_settings = new_settings.model_copy(update={"api_key": existing.api_key}) + + _save_settings(new_settings) + logger.info("Settings aggiornate | model=%s | brand=%s", new_settings.llm_model, new_settings.brand_name) + + return SettingsResponse( + api_key_masked=_mask_api_key(new_settings.api_key), + llm_model=new_settings.llm_model, + nicchie_attive=new_settings.nicchie_attive, + lingua=new_settings.lingua, + frequenza_post=new_settings.frequenza_post, + brand_name=new_settings.brand_name, + tono=new_settings.tono, + ) diff --git a/backend/schemas/settings.py b/backend/schemas/settings.py new file mode 100644 index 0000000..17f4469 --- /dev/null +++ b/backend/schemas/settings.py @@ -0,0 +1,50 @@ +"""Pydantic schemas per la configurazione applicativa. + +Settings contiene la configurazione persistita in CONFIG_PATH/settings.json. +""" + +from __future__ import annotations + +from typing import Optional + +from pydantic import BaseModel, Field + +from backend.constants import NICCHIE_DEFAULT + + +class Settings(BaseModel): + """Configurazione applicativa persistita su disco. + + Salvata in CONFIG_PATH/settings.json. + """ + + api_key: Optional[str] = Field( + default=None, + description="Chiave API Anthropic. Se None, la generazione è disabilitata.", + ) + llm_model: str = Field( + default="claude-sonnet-4-5", + description="Modello Claude da usare per la generazione.", + ) + nicchie_attive: list[str] = Field( + default_factory=lambda: list(NICCHIE_DEFAULT), + description="Lista delle nicchie target attive per il calendario.", + ) + lingua: str = Field( + default="italiano", + description="Lingua dei contenuti generati.", + ) + frequenza_post: int = Field( + default=3, + ge=1, + le=7, + description="Numero di post a settimana (default: 3 — lun, mer, ven).", + ) + brand_name: Optional[str] = Field( + default=None, + description="Nome del brand/studio — usato nella CTA e nel brand voice.", + ) + tono: Optional[str] = Field( + default="diretto e concreto", + description="Tono di voce per i contenuti generati.", + )