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:
@@ -5,6 +5,7 @@ It is passed only via Uvicorn's --root-path flag at runtime to avoid the
|
|||||||
double-path bug (Pitfall #4).
|
double-path bug (Pitfall #4).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import shutil
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ from fastapi.responses import FileResponse
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from backend.config import CAMPAIGNS_PATH, CONFIG_PATH, OUTPUTS_PATH, PROMPTS_PATH
|
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
|
# Startup / shutdown lifecycle
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Directory dei prompt di default (inclusa nel source)
|
||||||
|
_DEFAULT_PROMPTS_DIR = Path(__file__).parent / "data" / "prompts"
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
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):
|
for directory in (PROMPTS_PATH, OUTPUTS_PATH, CAMPAIGNS_PATH, CONFIG_PATH):
|
||||||
directory.mkdir(parents=True, exist_ok=True)
|
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
|
yield
|
||||||
# Nothing to clean up on shutdown
|
# Nothing to clean up on shutdown
|
||||||
|
|
||||||
@@ -68,6 +86,13 @@ async def health() -> dict:
|
|||||||
return {"status": "ok"}
|
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)
|
# SPA catch-all mount (MUST be last — catches everything not matched above)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
59
backend/routers/calendar.py
Normal file
59
backend/routers/calendar.py
Normal file
@@ -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())
|
||||||
|
),
|
||||||
|
}
|
||||||
150
backend/routers/export.py
Normal file
150
backend/routers/export.py
Normal file
@@ -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"',
|
||||||
|
},
|
||||||
|
)
|
||||||
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,
|
||||||
|
)
|
||||||
163
backend/routers/settings.py
Normal file
163
backend/routers/settings.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
50
backend/schemas/settings.py
Normal file
50
backend/schemas/settings.py
Normal file
@@ -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.",
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user