From 209d8962f756c4661fdfd54e16b44808d48513e6 Mon Sep 17 00:00:00 2001 From: Michele Date: Sun, 8 Mar 2026 01:54:58 +0100 Subject: [PATCH] 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 --- .gitignore | 10 +- backend/constants.py | 194 ++++++++++++++++++++++++++++ backend/data/format_mapping.json | 45 +++++++ backend/schemas/__init__.py | 1 + backend/schemas/calendar.py | 108 ++++++++++++++++ backend/schemas/generate.py | 187 +++++++++++++++++++++++++++ backend/services/__init__.py | 1 + backend/services/format_selector.py | 91 +++++++++++++ 8 files changed, 635 insertions(+), 2 deletions(-) create mode 100644 backend/constants.py create mode 100644 backend/data/format_mapping.json create mode 100644 backend/schemas/__init__.py create mode 100644 backend/schemas/calendar.py create mode 100644 backend/schemas/generate.py create mode 100644 backend/services/__init__.py create mode 100644 backend/services/format_selector.py diff --git a/.gitignore b/.gitignore index aebf8aa..6e6f0b7 100644 --- a/.gitignore +++ b/.gitignore @@ -31,8 +31,14 @@ Thumbs.db *.log npm-debug.log* -# Data (local dev) -data/ +# Data (local dev - runtime generata, non sorgente) +# Nota: backend/data/prompts/ e backend/data/format_mapping.json sono file +# sorgente e DEVONO essere committati. +# Solo la directory data/ a radice del progetto (dati Supabase, ecc.) +/data/ *.db *.sqlite +# Sottocartelle runtime in backend/data (output generati, campagne, config utente) backend/data/outputs/ +backend/data/campaigns/ +backend/data/config/ diff --git a/backend/constants.py b/backend/constants.py new file mode 100644 index 0000000..5144dfc --- /dev/null +++ b/backend/constants.py @@ -0,0 +1,194 @@ +"""Costanti di dominio LOCKED per PostGenerator. + +ATTENZIONE: Queste costanti sono fondamentali per la coerenza del sistema. +Non modificare CANVA_FIELDS, PERSUASION_DISTRIBUTION o SCHWARTZ_DISTRIBUTION +senza aggiornare anche il CSV builder e tutti i prompt LLM. +""" + +# --------------------------------------------------------------------------- +# Configurazione ciclo editoriale +# --------------------------------------------------------------------------- + +POST_PER_CICLO: int = 13 +"""Numero fisso di post per ciclo editoriale completo.""" + +# --------------------------------------------------------------------------- +# Distribuzione Persuasion Nurturing (PN) — 13 slot per ciclo +# --------------------------------------------------------------------------- + +PERSUASION_DISTRIBUTION: dict[str, int] = { + "valore": 4, + "storytelling": 2, + "news": 2, + "riprova_sociale": 3, + "coinvolgimento": 1, + "promozione": 1, +} +"""Distribuzione tipo_contenuto per ciclo di 13 post. + +Totale: sum(values) == 13 +""" + +# Verifica a load-time +assert sum(PERSUASION_DISTRIBUTION.values()) == POST_PER_CICLO, ( + f"PERSUASION_DISTRIBUTION deve sommare a {POST_PER_CICLO}, " + f"ora somma a {sum(PERSUASION_DISTRIBUTION.values())}" +) + + +# --------------------------------------------------------------------------- +# Distribuzione livelli Schwartz — 13 slot per ciclo +# --------------------------------------------------------------------------- + +SCHWARTZ_DISTRIBUTION: dict[str, int] = { + "L5": 3, # Inconsapevole del problema — storytelling + news + "L4": 3, # Consapevole del problema — valore + news + "L3": 4, # Consapevole della soluzione — valore + riprova_sociale + "L2": 2, # Consapevole del prodotto — riprova_sociale + coinvolgimento + "L1": 1, # Pronto all'acquisto — promozione +} +"""Distribuzione livelli consapevolezza Schwartz per ciclo di 13 post. + +L5+L4 = 6 (fase Attira/Cattura — top of funnel) +L3 = 4 (fase Coinvolgi — middle of funnel) +L2+L1 = 3 (fase Converti — bottom of funnel) +Totale: sum(values) == 13 +""" + +# Verifica a load-time +assert sum(SCHWARTZ_DISTRIBUTION.values()) == POST_PER_CICLO, ( + f"SCHWARTZ_DISTRIBUTION deve sommare a {POST_PER_CICLO}, " + f"ora somma a {sum(SCHWARTZ_DISTRIBUTION.values())}" +) + + +# --------------------------------------------------------------------------- +# Colonne CSV per Canva Bulk Create — LOCKED +# --------------------------------------------------------------------------- + +CANVA_FIELDS: list[str] = [ + # --- Metadati slot (8 colonne) --- + "campagna", + "fase_campagna", + "tipo_contenuto", + "formato_narrativo", + "funzione", + "livello_schwartz", + "target_nicchia", + "data_pub_suggerita", + # --- Cover slide (3 colonne) --- + "cover_title", + "cover_subtitle", + "cover_image_keyword", + # --- Slide 2 (3 colonne) --- + "s2_headline", + "s2_body", + "s2_image_keyword", + # --- Slide 3 (3 colonne) --- + "s3_headline", + "s3_body", + "s3_image_keyword", + # --- Slide 4 (3 colonne) --- + "s4_headline", + "s4_body", + "s4_image_keyword", + # --- Slide 5 (3 colonne) --- + "s5_headline", + "s5_body", + "s5_image_keyword", + # --- Slide 6 (3 colonne) --- + "s6_headline", + "s6_body", + "s6_image_keyword", + # --- Slide 7 (3 colonne) --- + "s7_headline", + "s7_body", + "s7_image_keyword", + # --- CTA slide (3 colonne) --- + "cta_text", + "cta_subtext", + "cta_image_keyword", + # --- Extra (1 colonna) --- + "caption_instagram", +] +"""Lista ORDINATA di tutte le colonne del CSV per Canva Bulk Create. + +Struttura: +- 8 metadati slot +- 24 campi slide (8 slide x 3 campi: headline/title, body/subtitle, image_keyword) + Nota: image_keyword contiene parole chiave testuali (NON URL). + Gli URL Unsplash verranno aggiunti in Phase 4. +- 1 caption Instagram +Totale: 33 colonne + +LOCKED: Non aggiungere/rimuovere colonne senza aggiornare tutti i prompt LLM +e il CSV builder. +""" + +# Verifica a load-time +_expected_count = 8 + 24 + 1 # 33 +assert len(CANVA_FIELDS) == _expected_count, ( + f"CANVA_FIELDS deve avere {_expected_count} elementi, " + f"ne ha {len(CANVA_FIELDS)}" +) + + +# --------------------------------------------------------------------------- +# Formati narrativi disponibili (7 formati) +# --------------------------------------------------------------------------- + +FORMATI_NARRATIVI: list[str] = [ + "PAS", # Problema → Agitazione → Soluzione + "AIDA", # Attenzione → Interesse → Desiderio → Azione + "BAB", # Before → After → Bridge + "Listicle", # Lista numerata di punti/consigli + "Storytelling", # Narrativa emotiva di trasformazione + "Dato_Implicazione", # Dato/statistica → Implicazione → Azione + "Obiezione_Risposta", # Obiezione comune → Confutazione → Soluzione +] +"""I 7 formati narrativi supportati per i caroselli Instagram.""" + + +# --------------------------------------------------------------------------- +# Funzioni contenuto (4 macro-funzioni editoriali) +# --------------------------------------------------------------------------- + +FUNZIONI_CONTENUTO: list[str] = [ + "Intrattenere", + "Educare", + "Persuadere", + "Convertire", +] +"""Le 4 macro-funzioni editoriali di ogni post.""" + + +# --------------------------------------------------------------------------- +# Fasi campagna (funnel AIDA semplificato) +# --------------------------------------------------------------------------- + +FASI_CAMPAGNA: list[str] = [ + "Attira", # Top of funnel — inconsapevoli (L5) + "Cattura", # Upper middle — consapevoli del problema (L4+L3) + "Coinvolgi", # Lower middle — consapevoli della soluzione (L3+L2) + "Converti", # Bottom of funnel — pronti all'acquisto (L1+L2) +] +"""Le 4 fasi del funnel di acquisizione clienti.""" + + +# --------------------------------------------------------------------------- +# Nicchie target predefinite +# --------------------------------------------------------------------------- + +NICCHIE_DEFAULT: list[str] = [ + "generico", + "dentisti", + "avvocati", + "ecommerce", + "local_business", + "agenzie", +] +"""Lista di nicchie target predefinite. + +"generico" è sempre incluso e viene usato per il 50% degli slot. +Le altre nicchie vengono ruotate per il restante 50%. +""" diff --git a/backend/data/format_mapping.json b/backend/data/format_mapping.json new file mode 100644 index 0000000..912a22f --- /dev/null +++ b/backend/data/format_mapping.json @@ -0,0 +1,45 @@ +{ + "_comment": "Matrice di mapping tipo_contenuto x livello_schwartz -> formato_narrativo. Scegliere il formato più efficace per ogni combinazione in base al livello di consapevolezza del pubblico.", + "valore": { + "L5": "Listicle", + "L4": "PAS", + "L3": "PAS", + "L2": "Obiezione_Risposta", + "L1": "AIDA" + }, + "storytelling": { + "L5": "BAB", + "L4": "Storytelling", + "L3": "BAB", + "L2": "Storytelling", + "L1": "AIDA" + }, + "news": { + "L5": "Dato_Implicazione", + "L4": "Dato_Implicazione", + "L3": "PAS", + "L2": "Obiezione_Risposta", + "L1": "AIDA" + }, + "riprova_sociale": { + "L5": "Storytelling", + "L4": "BAB", + "L3": "Obiezione_Risposta", + "L2": "PAS", + "L1": "AIDA" + }, + "coinvolgimento": { + "L5": "Listicle", + "L4": "PAS", + "L3": "Obiezione_Risposta", + "L2": "Obiezione_Risposta", + "L1": "AIDA" + }, + "promozione": { + "L5": "AIDA", + "L4": "AIDA", + "L3": "PAS", + "L2": "PAS", + "L1": "AIDA" + } +} diff --git a/backend/schemas/__init__.py b/backend/schemas/__init__.py new file mode 100644 index 0000000..20d7607 --- /dev/null +++ b/backend/schemas/__init__.py @@ -0,0 +1 @@ +"""Pydantic schemas per PostGenerator.""" diff --git a/backend/schemas/calendar.py b/backend/schemas/calendar.py new file mode 100644 index 0000000..1ed48f2 --- /dev/null +++ b/backend/schemas/calendar.py @@ -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)", + ) diff --git a/backend/schemas/generate.py b/backend/schemas/generate.py new file mode 100644 index 0000000..4624b55 --- /dev/null +++ b/backend/schemas/generate.py @@ -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", + ) diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..39beb48 --- /dev/null +++ b/backend/services/__init__.py @@ -0,0 +1 @@ +"""Servizi di dominio per PostGenerator.""" diff --git a/backend/services/format_selector.py b/backend/services/format_selector.py new file mode 100644 index 0000000..a5b9629 --- /dev/null +++ b/backend/services/format_selector.py @@ -0,0 +1,91 @@ +"""FormatSelector — mappa tipo_contenuto x livello_schwartz -> formato_narrativo. + +Carica la matrice di mapping da format_mapping.json e seleziona il formato +narrativo più efficace per ogni combinazione di tipo e livello. +""" + +from __future__ import annotations + +import json +from pathlib import Path + + +# Percorso default al file JSON (relativo a questo modulo) +_DEFAULT_MAPPING_PATH = Path(__file__).parent.parent / "data" / "format_mapping.json" + +# Fallback se la combinazione non è presente nella matrice +_FALLBACK_FORMAT = "PAS" + + +class FormatSelector: + """Seleziona il formato narrativo ottimale per un dato tipo_contenuto e livello_schwartz. + + Carica la matrice di mapping da un file JSON e la mantiene in memoria. + La selezione è deterministica e basata sulla tabella (nessuna logica LLM). + + Esempio: + selector = FormatSelector() + formato = selector.select_format("valore", "L4") # -> "PAS" + formato = selector.select_format("storytelling", "L5") # -> "BAB" + """ + + def __init__(self, mapping_path: Path | None = None) -> None: + """Carica il mapping da file JSON. + + Args: + mapping_path: Percorso al file format_mapping.json. + Default: backend/data/format_mapping.json + """ + path = mapping_path or _DEFAULT_MAPPING_PATH + if not path.exists(): + raise FileNotFoundError( + f"File format_mapping.json non trovato: {path}. " + "Assicurati che backend/data/format_mapping.json esista." + ) + + with path.open(encoding="utf-8") as f: + raw = json.load(f) + + # Filtra i commenti (chiavi che iniziano con "_") + self._mapping: dict[str, dict[str, str]] = { + k: v for k, v in raw.items() if not k.startswith("_") + } + + def select_format(self, tipo_contenuto: str, livello_schwartz: str) -> str: + """Ritorna il formato narrativo per la combinazione data. + + Args: + tipo_contenuto: Tipo PN (es. "valore", "storytelling", "promozione") + livello_schwartz: Livello consapevolezza (es. "L1", "L3", "L5") + + Returns: + Nome del formato narrativo (es. "PAS", "BAB", "AIDA"). + Ritorna "PAS" come fallback se la combinazione non è nella matrice. + """ + tipo_map = self._mapping.get(tipo_contenuto) + if tipo_map is None: + return _FALLBACK_FORMAT + + return tipo_map.get(livello_schwartz, _FALLBACK_FORMAT) + + def get_mapping(self) -> dict[str, dict[str, str]]: + """Ritorna la tabella di mapping completa. + + Returns: + Dizionario { tipo_contenuto: { livello_schwartz: formato_narrativo } } + """ + return dict(self._mapping) + + def get_supported_types(self) -> list[str]: + """Ritorna la lista dei tipi_contenuto supportati dalla matrice.""" + return list(self._mapping.keys()) + + def get_supported_levels(self) -> list[str]: + """Ritorna la lista dei livelli_schwartz supportati dalla matrice. + + Inferisce i livelli dal primo tipo disponibile (la matrice è consistente). + """ + if not self._mapping: + return [] + first_type = next(iter(self._mapping.values())) + return sorted(first_type.keys())