feat(02-01): backend prompts router with 4 CRUD endpoints

- GET /api/prompts — list all prompts with modified/default flag
- GET /api/prompts/{name} — read prompt content + required variables
- PUT /api/prompts/{name} — save modified prompt with validation
- POST /api/prompts/{name}/reset — restore prompt to default
- Lazy PromptService init to handle lifespan directory creation
- Router registered in main.py before SPA catch-all

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michele
2026-03-08 20:54:30 +01:00
parent 5ba641e7d6
commit 05972fa8f1
2 changed files with 214 additions and 1 deletions

View File

@@ -14,7 +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
from backend.routers import calendar, export, generate, prompts, settings
# ---------------------------------------------------------------------------
@@ -91,6 +91,7 @@ app.include_router(calendar.router)
app.include_router(generate.router)
app.include_router(export.router)
app.include_router(settings.router)
app.include_router(prompts.router)
# ---------------------------------------------------------------------------

212
backend/routers/prompts.py Normal file
View File

@@ -0,0 +1,212 @@
"""Router per la gestione dei prompt LLM.
Endpoint:
- GET /api/prompts — lista prompt disponibili con flag modificato/default
- GET /api/prompts/{name} — contenuto, variabili richieste, flag modificato
- PUT /api/prompts/{name} — salva contenuto modificato con validazione
- POST /api/prompts/{name}/reset — ripristina il prompt al contenuto default originale
"""
from __future__ import annotations
import logging
from pathlib import Path
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from backend.config import PROMPTS_PATH
from backend.services.prompt_service import PromptService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/prompts", tags=["prompts"])
# Directory dei prompt default (inclusi nel source, usati per confronto e reset)
_DEFAULT_PROMPTS_DIR = Path(__file__).parent.parent / "data" / "prompts"
# PromptService creato lazily perche' PROMPTS_PATH viene creato nel lifespan di FastAPI
_prompt_service: PromptService | None = None
def _get_prompt_service() -> PromptService:
"""Ritorna l'istanza PromptService, creandola al primo accesso.
La creazione e' lazy perche' la directory PROMPTS_PATH viene creata
durante il lifespan di FastAPI (main.py), non al momento dell'import.
"""
global _prompt_service
if _prompt_service is None:
# Assicura che la directory esista (normalmente gia' creata dal lifespan)
PROMPTS_PATH.mkdir(parents=True, exist_ok=True)
_prompt_service = PromptService(PROMPTS_PATH)
return _prompt_service
# ---------------------------------------------------------------------------
# Response / Request schemas
# ---------------------------------------------------------------------------
class PromptInfo(BaseModel):
"""Info sintetica di un prompt per la lista."""
name: str
modified: bool
class PromptListResponse(BaseModel):
"""Risposta per GET / — lista di tutti i prompt."""
prompts: list[PromptInfo]
class PromptDetailResponse(BaseModel):
"""Risposta per GET /{name} — contenuto completo di un prompt."""
name: str
content: str
variables: list[str]
modified: bool
class SavePromptRequest(BaseModel):
"""Body per PUT /{name} — salvataggio prompt modificato."""
content: str = Field(..., min_length=10, description="Contenuto del prompt")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _is_modified(name: str) -> bool:
"""Confronta il prompt corrente con il default originale.
Returns:
True se il contenuto differisce dal default o se il default non esiste
(il prompt e' considerato custom).
"""
default_path = _DEFAULT_PROMPTS_DIR / f"{name}.txt"
if not default_path.exists():
return True # No default = considered custom/modified
current = _get_prompt_service().load_prompt(name)
default = default_path.read_text(encoding="utf-8")
return current != default
# ---------------------------------------------------------------------------
# Endpoint
# ---------------------------------------------------------------------------
@router.get("/", response_model=PromptListResponse)
async def list_prompts() -> PromptListResponse:
"""Lista tutti i prompt .txt disponibili con flag modificato/default.
Returns:
PromptListResponse con la lista di PromptInfo.
"""
names = _get_prompt_service().list_prompts()
prompts = [
PromptInfo(name=name, modified=_is_modified(name))
for name in names
]
return PromptListResponse(prompts=prompts)
@router.get("/{name}", response_model=PromptDetailResponse)
async def get_prompt(name: str) -> PromptDetailResponse:
"""Leggi il contenuto di un prompt con le variabili richieste.
Args:
name: Nome del prompt senza estensione (es. "pas_valore")
Returns:
PromptDetailResponse con contenuto, variabili, e flag modificato.
Raises:
HTTPException 404: Se il prompt non esiste.
"""
if not _get_prompt_service().prompt_exists(name):
raise HTTPException(status_code=404, detail=f"Prompt '{name}' non trovato")
content = _get_prompt_service().load_prompt(name)
variables = _get_prompt_service().get_required_variables(name)
modified = _is_modified(name)
return PromptDetailResponse(
name=name,
content=content,
variables=variables,
modified=modified,
)
@router.put("/{name}", response_model=PromptDetailResponse)
async def save_prompt(name: str, body: SavePromptRequest) -> PromptDetailResponse:
"""Salva il contenuto modificato di un prompt.
Dopo il salvataggio, ritorna i dati aggiornati incluse le variabili
ricalcolate dal nuovo contenuto.
Args:
name: Nome del prompt senza estensione
body: SavePromptRequest con il contenuto da salvare
Returns:
PromptDetailResponse con i dati aggiornati.
Raises:
HTTPException 400: Se il nome contiene caratteri non validi.
"""
try:
_get_prompt_service().save_prompt(name, body.content)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# Ricarica variabili dal contenuto appena salvato
variables = _get_prompt_service().get_required_variables(name)
modified = _is_modified(name)
logger.info("Prompt '%s' salvato | variables=%s | modified=%s", name, variables, modified)
return PromptDetailResponse(
name=name,
content=body.content,
variables=variables,
modified=modified,
)
@router.post("/{name}/reset", response_model=PromptDetailResponse)
async def reset_prompt(name: str) -> PromptDetailResponse:
"""Ripristina un prompt al contenuto default originale.
Copia il file default dalla directory sorgente sovrascrivendo il corrente.
Args:
name: Nome del prompt senza estensione
Returns:
PromptDetailResponse con il contenuto ripristinato.
Raises:
HTTPException 404: Se il default non esiste per questo prompt.
"""
default_path = _DEFAULT_PROMPTS_DIR / f"{name}.txt"
if not default_path.exists():
raise HTTPException(
status_code=404,
detail=f"Nessun default disponibile per il prompt '{name}'",
)
# Leggi il default e sovrascrivilo nella directory runtime
default_content = default_path.read_text(encoding="utf-8")
_get_prompt_service().save_prompt(name, default_content)
variables = _get_prompt_service().get_required_variables(name)
logger.info("Prompt '%s' ripristinato al default | variables=%s", name, variables)
return PromptDetailResponse(
name=name,
content=default_content,
variables=variables,
modified=False, # Appena ripristinato = identico al default
)