feat(01-02): costanti dominio, schemas Pydantic, FormatSelector

- backend/constants.py: CANVA_FIELDS (33 col locked), PERSUASION_DISTRIBUTION (13), SCHWARTZ_DISTRIBUTION (13)
- backend/schemas/calendar.py: CalendarSlot, CalendarRequest, CalendarResponse
- backend/schemas/generate.py: SlideContent, GeneratedPost, TopicResult, GenerateRequest, PostResult, GenerateResponse
- backend/data/format_mapping.json: matrice 6 tipi x 5 livelli (30 combinazioni)
- backend/services/format_selector.py: FormatSelector con select_format e fallback PAS
- fix .gitignore: backend/data/prompts/ e format_mapping.json non erano ignorabili
This commit is contained in:
Michele
2026-03-08 01:54:58 +01:00
parent 50d5708016
commit 209d8962f7
8 changed files with 635 additions and 2 deletions

View File

@@ -0,0 +1 @@
"""Pydantic schemas per PostGenerator."""

108
backend/schemas/calendar.py Normal file
View File

@@ -0,0 +1,108 @@
"""Pydantic schemas per il calendario editoriale.
Questi modelli rappresentano il piano di pubblicazione dei 13 slot PN
generato da CalendarService, prima della generazione LLM del contenuto.
"""
from __future__ import annotations
from typing import Optional
from pydantic import BaseModel, Field
class CalendarSlot(BaseModel):
"""Un singolo slot del calendario editoriale con metadati strategici."""
indice: int = Field(
...,
ge=0,
lt=13,
description="Indice 0-based dello slot nel ciclo di 13 post",
)
tipo_contenuto: str = Field(
...,
description="Tipo Persuasion Nurturing: valore, storytelling, news, "
"riprova_sociale, coinvolgimento, promozione",
)
livello_schwartz: str = Field(
...,
description="Livello di consapevolezza del pubblico: L1-L5",
)
formato_narrativo: str = Field(
...,
description="Formato narrativo selezionato: PAS, AIDA, BAB, Listicle, "
"Storytelling, Dato_Implicazione, Obiezione_Risposta",
)
funzione: str = Field(
...,
description="Funzione editoriale: Intrattenere, Educare, Persuadere, Convertire",
)
fase_campagna: str = Field(
...,
description="Fase del funnel: Attira, Cattura, Coinvolgi, Converti",
)
target_nicchia: str = Field(
...,
description="Nicchia target: es. generico, dentisti, avvocati, ecommerce",
)
data_pub_suggerita: str = Field(
...,
description="Data di pubblicazione suggerita in formato YYYY-MM-DD",
)
topic: Optional[str] = Field(
default=None,
description="Topic specifico del post. None finché non generato dall'LLM "
"o specificato dall'utente.",
)
class CalendarRequest(BaseModel):
"""Richiesta per generare un calendario editoriale."""
obiettivo_campagna: str = Field(
...,
min_length=10,
description="Obiettivo principale della campagna (es. 'Acquisire nuovi "
"clienti dentisti nel Nord Italia')",
)
settimane: int = Field(
default=2,
ge=1,
le=12,
description="Durata del ciclo in settimane (default: 2 settimane per 13 post)",
)
nicchie: Optional[list[str]] = Field(
default=None,
description="Lista di nicchie target. Se None, usa NICCHIE_DEFAULT. "
"'generico' viene sempre incluso automaticamente.",
)
frequenza_post: int = Field(
default=3,
ge=1,
le=7,
description="Numero di post a settimana (default: 3 — lun, mer, ven)",
)
data_inizio: Optional[str] = Field(
default=None,
description="Data di inizio del calendario in formato YYYY-MM-DD. "
"Se None, usa la data corrente.",
)
class CalendarResponse(BaseModel):
"""Risposta con il calendario editoriale generato."""
campagna: str = Field(
...,
description="Riepilogo sintetico dell'obiettivo campagna",
)
slots: list[CalendarSlot] = Field(
...,
description="Lista di 13 slot del calendario ordinati per sequenza campagna "
"(Attira → Cattura → Coinvolgi → Converti)",
)
totale_post: int = Field(
...,
description="Numero totale di slot generati (sempre 13 per ciclo completo)",
)

187
backend/schemas/generate.py Normal file
View File

@@ -0,0 +1,187 @@
"""Pydantic schemas per la generazione LLM di post e caroselli.
Questi modelli rappresentano l'output del processo di generazione,
dalla singola slide fino alla risposta batch completa.
"""
from __future__ import annotations
from typing import Literal, Optional
from pydantic import BaseModel, Field
from backend.schemas.calendar import CalendarSlot
# ---------------------------------------------------------------------------
# Output LLM — struttura interna del carosello generato
# ---------------------------------------------------------------------------
class SlideContent(BaseModel):
"""Contenuto di una singola slide centrale (s2-s7) del carosello."""
headline: str = Field(
...,
max_length=80,
description="Titolo breve della slide (max 80 caratteri, impatto immediato)",
)
body: str = Field(
...,
max_length=300,
description="Testo corpo della slide (max 300 caratteri, concreto e diretto)",
)
image_keyword: str = Field(
...,
max_length=100,
description="Parola chiave per la ricerca immagine (es. 'dentista sorridente "
"studio moderno'). NON un URL — Phase 4 aggiungerà gli URL Unsplash.",
)
class GeneratedPost(BaseModel):
"""Carosello Instagram completo generato dall'LLM.
Struttura: cover + 6 slide centrali (s2-s7) + CTA = 8 slide totali.
Corrisponde esattamente ai campi CANVA_FIELDS per il CSV export.
"""
# --- Cover slide ---
cover_title: str = Field(
...,
max_length=80,
description="Titolo principale della cover — deve fermare lo scroll",
)
cover_subtitle: str = Field(
...,
max_length=150,
description="Sottotitolo della cover — contestualizza il titolo",
)
cover_image_keyword: str = Field(
...,
max_length=100,
description="Keyword per immagine cover (es. 'studio dentistico moderno arredamento')",
)
# --- Slide centrali (s2-s7) ---
slides: list[SlideContent] = Field(
...,
min_length=6,
max_length=6,
description="Esattamente 6 slide centrali (s2-s7 nel CSV Canva)",
)
# --- CTA slide ---
cta_text: str = Field(
...,
max_length=80,
description="Call-to-action principale — verbo d'azione + beneficio",
)
cta_subtext: str = Field(
...,
max_length=200,
description="Testo di supporto alla CTA — cosa fare concretamente",
)
cta_image_keyword: str = Field(
...,
max_length=100,
description="Keyword per immagine CTA (es. 'handshake accordo professionale')",
)
# --- Caption Instagram ---
caption_instagram: str = Field(
...,
max_length=2200,
description="Caption completa per Instagram: hook + testo + hashtag. "
"Max 2200 caratteri (limite Instagram).",
)
class TopicResult(BaseModel):
"""Risultato della generazione topic per uno slot del calendario.
Usato da LLMService.generate_topic() con il loop retry/validation standard.
L'LLM genera UN topic specifico per lo slot dato.
"""
topic: str = Field(
...,
min_length=5,
max_length=100,
description="Topic specifico e concreto per il post (max 100 caratteri). "
"Es: '3 errori che fanno perdere pazienti al tuo studio dentistico'",
)
# ---------------------------------------------------------------------------
# Request/Response per generazione
# ---------------------------------------------------------------------------
class GenerateRequest(BaseModel):
"""Richiesta per generare il contenuto di un singolo slot."""
slot: CalendarSlot = Field(
...,
description="Slot del calendario con metadati strategici",
)
obiettivo_campagna: str = Field(
...,
description="Obiettivo principale della campagna — mantiene coerenza tra post",
)
brand_name: Optional[str] = Field(
default=None,
description="Nome del brand/studio — usato nella CTA e nel brand voice",
)
tono: Optional[str] = Field(
default=None,
description="Tono di voce specifico (es. 'professionale ma amichevole', "
"'provocatorio', 'tecnico'). Se None, usa il default del prompt.",
)
class PostResult(BaseModel):
"""Risultato della generazione di un singolo post nel batch."""
slot_index: int = Field(
...,
ge=0,
description="Indice dello slot nel calendario (0-based)",
)
status: Literal["success", "failed", "pending"] = Field(
...,
description="Stato della generazione: success, failed (con errore), pending",
)
post: Optional[GeneratedPost] = Field(
default=None,
description="Post generato — presente solo se status='success'",
)
error: Optional[str] = Field(
default=None,
description="Messaggio di errore — presente solo se status='failed'",
)
class GenerateResponse(BaseModel):
"""Risposta batch con tutti i risultati di generazione del ciclo."""
campagna: str = Field(
...,
description="Riepilogo sintetico dell'obiettivo campagna",
)
results: list[PostResult] = Field(
...,
description="Lista di risultati per ogni slot del calendario",
)
total: int = Field(
...,
description="Numero totale di slot nel batch",
)
success_count: int = Field(
...,
ge=0,
description="Numero di post generati con successo",
)
failed_count: int = Field(
...,
ge=0,
description="Numero di post falliti — esclusi dal CSV export",
)