"""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