- backend/services/calendar_service.py: genera 13 slot con distribuzione PN (4v+2s+2n+3r+1c+1p) e Schwartz (L5=3,L4=3,L3=4,L2=2,L1=1), ordina per funnel, ruota nicchie, calcola date
- backend/services/prompt_service.py: carica/compila/elenca prompt {{variabile}}, ValueError per variabili mancanti
- backend/data/prompts/system_prompt.txt: sistema prompt esperto content marketing B2B italiano
- backend/data/prompts/topic_generator.txt: generazione topic per slot calendario
- backend/data/prompts/pas_valore.txt: formato PAS per post valore educativo
- backend/data/prompts/listicle_valore.txt: formato Listicle per post valore
- backend/data/prompts/bab_storytelling.txt: formato BAB per post storytelling
- backend/data/prompts/aida_promozione.txt: formato AIDA per post promozionale
- backend/data/prompts/dato_news.txt: formato Dato+Implicazione per post news
297 lines
11 KiB
Python
297 lines
11 KiB
Python
"""CalendarService — genera il calendario editoriale di 13 slot PN + Schwartz.
|
|
|
|
Costruisce un piano di pubblicazione strategico con:
|
|
- Distribuzione Persuasion Nurturing corretta (4 valore, 2 storytelling, etc.)
|
|
- Livelli Schwartz assegnati in base al tipo contenuto
|
|
- Fasi campagna ordinate (Attira → Cattura → Coinvolgi → Converti)
|
|
- Date di pubblicazione suggerite con frequenza configurabile
|
|
- Rotazione nicchie (50% generico, 50% verticali in rotazione)
|
|
- Formato narrativo selezionato via FormatSelector
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date, timedelta
|
|
from itertools import cycle
|
|
from typing import Optional
|
|
|
|
from backend.constants import (
|
|
FASI_CAMPAGNA,
|
|
FUNZIONI_CONTENUTO,
|
|
NICCHIE_DEFAULT,
|
|
PERSUASION_DISTRIBUTION,
|
|
POST_PER_CICLO,
|
|
SCHWARTZ_DISTRIBUTION,
|
|
)
|
|
from backend.schemas.calendar import CalendarRequest, CalendarResponse, CalendarSlot
|
|
from backend.services.format_selector import FormatSelector
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Mapping tipo_contenuto -> funzione editoriale
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_TIPO_TO_FUNZIONE: dict[str, str] = {
|
|
"valore": "Educare",
|
|
"storytelling": "Intrattenere",
|
|
"news": "Intrattenere",
|
|
"riprova_sociale": "Persuadere",
|
|
"coinvolgimento": "Intrattenere",
|
|
"promozione": "Convertire",
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Mapping tipo_contenuto -> livello_schwartz con distribuzione corretta
|
|
# Ogni tipo ha una lista ordinata dei livelli che usa (in sequenza)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# La distribuzione deve sommare a SCHWARTZ_DISTRIBUTION totale
|
|
# L5=3, L4=3, L3=4, L2=2, L1=1
|
|
_TIPO_TO_LIVELLI: dict[str, list[str]] = {
|
|
"valore": ["L4", "L4", "L3", "L3"], # 4 slot: 2xL4, 2xL3
|
|
"storytelling": ["L5", "L5"], # 2 slot: 2xL5
|
|
"news": ["L5", "L4"], # 2 slot: 1xL5, 1xL4
|
|
"riprova_sociale": ["L3", "L3", "L2"], # 3 slot: 2xL3, 1xL2
|
|
"coinvolgimento": ["L2"], # 1 slot: 1xL2
|
|
"promozione": ["L1"], # 1 slot: 1xL1
|
|
}
|
|
|
|
# Verifica distribuzioni a load-time
|
|
_livelli_totali: dict[str, int] = {}
|
|
for _tipo, _livelli in _TIPO_TO_LIVELLI.items():
|
|
assert len(_livelli) == PERSUASION_DISTRIBUTION[_tipo], (
|
|
f"Tipo '{_tipo}': attesi {PERSUASION_DISTRIBUTION[_tipo]} livelli, "
|
|
f"trovati {len(_livelli)}"
|
|
)
|
|
for _l in _livelli:
|
|
_livelli_totali[_l] = _livelli_totali.get(_l, 0) + 1
|
|
|
|
for _livello, _count in _livelli_totali.items():
|
|
assert _count == SCHWARTZ_DISTRIBUTION[_livello], (
|
|
f"Livello '{_livello}': attesi {SCHWARTZ_DISTRIBUTION[_livello]}, "
|
|
f"trovati {_count}"
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Mapping livello_schwartz -> fase_campagna
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_LIVELLO_TO_FASE: dict[str, str] = {
|
|
"L5": "Attira",
|
|
"L4": "Cattura",
|
|
"L3": "Cattura", # L3 va in Cattura (upper-middle) ma alcuni in Coinvolgi
|
|
"L2": "Coinvolgi",
|
|
"L1": "Converti",
|
|
}
|
|
|
|
# Affinamento: L3 riprova_sociale va in "Coinvolgi" (conosce la soluzione/prodotto)
|
|
_TIPO_LIVELLO_TO_FASE: dict[tuple[str, str], str] = {
|
|
("riprova_sociale", "L3"): "Coinvolgi",
|
|
("valore", "L3"): "Coinvolgi",
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Giorni della settimana per pubblicazione (default: lun, mer, ven)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_PUBLISH_WEEKDAYS = [0, 2, 4] # 0=Lunedì, 2=Mercoledì, 4=Venerdì
|
|
|
|
|
|
class CalendarService:
|
|
"""Genera il calendario editoriale di 13 slot con distribuzione PN e Schwartz."""
|
|
|
|
def __init__(self, format_selector: Optional[FormatSelector] = None) -> None:
|
|
"""Inizializza il servizio con un FormatSelector (iniettabile per test).
|
|
|
|
Args:
|
|
format_selector: Istanza di FormatSelector. Se None, ne crea una di default.
|
|
"""
|
|
self._format_selector = format_selector or FormatSelector()
|
|
|
|
def generate_calendar(self, request: CalendarRequest) -> CalendarResponse:
|
|
"""Genera un calendario editoriale di 13 slot.
|
|
|
|
Processo:
|
|
1. Espande la distribuzione PN in 13 slot ordinati per funnel
|
|
2. Assegna livelli Schwartz per tipo
|
|
3. Seleziona formato narrativo via FormatSelector
|
|
4. Assegna funzione editoriale e fase campagna
|
|
5. Distribu nicchie (50% generico, 50% verticali in rotazione)
|
|
6. Calcola date di pubblicazione
|
|
|
|
Args:
|
|
request: Parametri di configurazione del calendario
|
|
|
|
Returns:
|
|
CalendarResponse con 13 slot ordinati per fase campagna
|
|
"""
|
|
nicchie = self._prepare_niches(request.nicchie)
|
|
data_inizio = self._parse_start_date(request.data_inizio)
|
|
date_pubblicazione = self._generate_dates(
|
|
data_inizio, POST_PER_CICLO, request.frequenza_post
|
|
)
|
|
|
|
# Crea gli slot base (tipo + livello)
|
|
raw_slots = self._build_raw_slots()
|
|
|
|
# Ordina per fase campagna (Attira → Cattura → Coinvolgi → Converti)
|
|
ordered_slots = self._sort_by_funnel(raw_slots)
|
|
|
|
# Assegna nicchie con rotazione
|
|
niched_slots = self._distribute_niches(ordered_slots, nicchie)
|
|
|
|
# Costruisci gli oggetti CalendarSlot finali
|
|
calendar_slots: list[CalendarSlot] = []
|
|
for indice, (tipo, livello, nicchia) in enumerate(niched_slots):
|
|
formato = self._format_selector.select_format(tipo, livello)
|
|
funzione = _TIPO_TO_FUNZIONE[tipo]
|
|
fase = _TIPO_LIVELLO_TO_FASE.get((tipo, livello), _LIVELLO_TO_FASE[livello])
|
|
|
|
slot = CalendarSlot(
|
|
indice=indice,
|
|
tipo_contenuto=tipo,
|
|
livello_schwartz=livello,
|
|
formato_narrativo=formato,
|
|
funzione=funzione,
|
|
fase_campagna=fase,
|
|
target_nicchia=nicchia,
|
|
data_pub_suggerita=date_pubblicazione[indice].isoformat(),
|
|
topic=None,
|
|
)
|
|
calendar_slots.append(slot)
|
|
|
|
return CalendarResponse(
|
|
campagna=request.obiettivo_campagna,
|
|
slots=calendar_slots,
|
|
totale_post=len(calendar_slots),
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Metodi privati
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _build_raw_slots(self) -> list[tuple[str, str]]:
|
|
"""Crea la lista di (tipo_contenuto, livello_schwartz) per tutti i 13 slot."""
|
|
slots: list[tuple[str, str]] = []
|
|
for tipo, livelli in _TIPO_TO_LIVELLI.items():
|
|
for livello in livelli:
|
|
slots.append((tipo, livello))
|
|
return slots
|
|
|
|
def _sort_by_funnel(
|
|
self, slots: list[tuple[str, str]]
|
|
) -> list[tuple[str, str]]:
|
|
"""Ordina gli slot per fase campagna (Attira → Cattura → Coinvolgi → Converti)."""
|
|
phase_order = {fase: i for i, fase in enumerate(FASI_CAMPAGNA)}
|
|
|
|
def slot_phase_key(slot: tuple[str, str]) -> int:
|
|
tipo, livello = slot
|
|
fase = _TIPO_LIVELLO_TO_FASE.get((tipo, livello), _LIVELLO_TO_FASE[livello])
|
|
return phase_order.get(fase, 99)
|
|
|
|
return sorted(slots, key=slot_phase_key)
|
|
|
|
@staticmethod
|
|
def _distribute_niches(
|
|
slots: list[tuple[str, str]],
|
|
nicchie: list[str],
|
|
) -> list[tuple[str, str, str]]:
|
|
"""Assegna nicchie agli slot con distribuzione 50% generico, 50% verticali.
|
|
|
|
Args:
|
|
slots: Lista di (tipo, livello)
|
|
nicchie: Lista nicchie disponibili (include "generico")
|
|
|
|
Returns:
|
|
Lista di (tipo, livello, nicchia)
|
|
"""
|
|
verticali = [n for n in nicchie if n != "generico"]
|
|
if not verticali:
|
|
# Se non ci sono verticali, tutto generico
|
|
return [(t, l, "generico") for t, l in slots]
|
|
|
|
verticali_cycle = cycle(verticali)
|
|
result: list[tuple[str, str, str]] = []
|
|
|
|
for i, (tipo, livello) in enumerate(slots):
|
|
if i % 2 == 0:
|
|
# Slot pari -> generico
|
|
nicchia = "generico"
|
|
else:
|
|
# Slot dispari -> verticale in rotazione
|
|
nicchia = next(verticali_cycle)
|
|
result.append((tipo, livello, nicchia))
|
|
|
|
return result
|
|
|
|
@staticmethod
|
|
def _prepare_niches(nicchie_input: list[str] | None) -> list[str]:
|
|
"""Prepara la lista nicchie assicurando che 'generico' sia sempre incluso."""
|
|
if not nicchie_input:
|
|
return list(NICCHIE_DEFAULT)
|
|
if "generico" not in nicchie_input:
|
|
return ["generico"] + list(nicchie_input)
|
|
return list(nicchie_input)
|
|
|
|
@staticmethod
|
|
def _parse_start_date(data_inizio: str | None) -> date:
|
|
"""Converte stringa YYYY-MM-DD in date, default a oggi."""
|
|
if data_inizio:
|
|
try:
|
|
return date.fromisoformat(data_inizio)
|
|
except ValueError:
|
|
pass
|
|
return date.today()
|
|
|
|
@staticmethod
|
|
def _generate_dates(
|
|
start: date,
|
|
count: int,
|
|
frequenza: int,
|
|
) -> list[date]:
|
|
"""Genera una lista di date di pubblicazione.
|
|
|
|
Con frequenza=3 usa lun/mer/ven. Con altre frequenze distribuisce
|
|
uniformemente nella settimana.
|
|
|
|
Args:
|
|
start: Data di inizio
|
|
count: Numero di date da generare
|
|
frequenza: Post per settimana
|
|
|
|
Returns:
|
|
Lista di 'count' date ordinate
|
|
"""
|
|
dates: list[date] = []
|
|
|
|
if frequenza == 3:
|
|
# Standard: lun, mer, ven
|
|
publish_days = _PUBLISH_WEEKDAYS
|
|
else:
|
|
# Distribuzione uniforme: calcola i giorni della settimana
|
|
step = max(1, 7 // frequenza)
|
|
publish_days = [i * step for i in range(frequenza)]
|
|
|
|
# Trova il primo giorno di pubblicazione >= start
|
|
current = start
|
|
day_cycle = cycle(sorted(publish_days))
|
|
next_day = next(day_cycle)
|
|
|
|
# Avanza fino al primo giorno valido
|
|
days_checked = 0
|
|
while current.weekday() != next_day and days_checked < 7:
|
|
current += timedelta(days=1)
|
|
days_checked += 1
|
|
|
|
# Genera le date
|
|
for _ in range(count):
|
|
dates.append(current)
|
|
# Passa al prossimo giorno di pubblicazione
|
|
next_day = next(day_cycle)
|
|
days_ahead = (next_day - current.weekday()) % 7
|
|
if days_ahead == 0:
|
|
days_ahead = 7
|
|
current = current + timedelta(days=days_ahead)
|
|
|
|
return dates
|