From 05972fa8f15349f594e515da97fba639bfa4c2b7 Mon Sep 17 00:00:00 2001 From: Michele Date: Sun, 8 Mar 2026 20:54:30 +0100 Subject: [PATCH] feat(02-01): backend prompts router with 4 CRUD endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/main.py | 3 +- backend/routers/prompts.py | 212 +++++++++++++++++++++++++++++++++++++ 2 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 backend/routers/prompts.py diff --git a/backend/main.py b/backend/main.py index 1b173ec..cc654d6 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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) # --------------------------------------------------------------------------- diff --git a/backend/routers/prompts.py b/backend/routers/prompts.py new file mode 100644 index 0000000..e758863 --- /dev/null +++ b/backend/routers/prompts.py @@ -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 + )