Files
postgenerator/backend/services/calendar_service.py
Michele ef9b9471fc feat(01-02): CalendarService, PromptService, 7 prompt .txt in italiano
- 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
2026-03-08 02:00:00 +01:00

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