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
This commit is contained in:
Michele
2026-03-08 02:00:00 +01:00
parent f6d9215b52
commit ef9b9471fc
9 changed files with 917 additions and 0 deletions

View File

@@ -0,0 +1,78 @@
Crea un carosello Instagram nel formato AIDA (Attenzione → Interesse → Desiderio → Azione) per un post PROMOZIONALE.
CONTESTO:
- Obiettivo campagna: {{obiettivo_campagna}}
- Topic del post: {{topic}}
- Nicchia target: {{target_nicchia}}
- Livello consapevolezza: {{livello_schwartz}}
- Brand/Studio: {{brand_name}}
- Call-to-action: {{call_to_action}}
FORMATO AIDA — COME APPLICARLO ALLE 8 SLIDE:
1. COVER (ATTENZIONE): Cattura l'attenzione con un beneficio specifico o un numero impattante
2. Slide 2 (ATTENZIONE→INTERESSE): Conferma che stai parlando proprio a loro — identifica il problema/desiderio
3. Slide 3 (INTERESSE): Presenta la soluzione — cos'è, per chi è, cosa risolve
4. Slide 4 (INTERESSE): Come funziona — processo semplificato in 2-3 passi
5. Slide 5 (DESIDERIO): Prova sociale — risultati di chi l'ha già usato, numeri concreti
6. Slide 6 (DESIDERIO): Benefici specifici — cosa ottieni concretamente tu
7. Slide 7 (DESIDERIO→AZIONE): Urgenza e scarsità (se applicabile) o garanzia/rischio zero
8. CTA (AZIONE): Istruzione chiara e diretta — un'azione sola, senza ambiguità
REGOLE PER QUESTO TIPO DI POST:
- È il post più commerciale — il lettore è già pronto (L1: Pronto all'acquisto)
- Ogni slide deve spingere verso l'azione finale, senza distrazioni
- La CTA deve essere UNA sola e chiarissima: cosa fare, come, perché adesso
- Elimina ogni elemento che non serve alla conversione
- Usa il nome brand {{brand_name}} in modo naturale, non ossessivo
- Il tono è diretto e sicuro — non arrogante, ma deciso
CAPTION INSTAGRAM:
- Inizia con il beneficio principale in grassetto (usa solo il testo, no markdown nel JSON)
- Presenta l'offerta in modo chiaro e diretto
- Aggiungi urgenza se applicabile
- Chiudi con la stessa CTA del carosello
- Aggiungi 5-8 hashtag di conversione e di nicchia per {{target_nicchia}}
SCHEMA OUTPUT JSON:
Rispondi SOLO con questo JSON (nessun testo fuori dal JSON):
{
"cover_title": "Beneficio principale o dato d'impatto, max 60 caratteri",
"cover_subtitle": "A chi è rivolto e cosa risolve, max 120 caratteri",
"cover_image_keyword": "keyword per immagine professionale e aspirazionale",
"slides": [
{
"headline": "Il problema che riconoscono subito, max 70 caratteri",
"body": "Identifica la situazione attuale del lettore — lui deve annuire, max 250 caratteri",
"image_keyword": "keyword immagine per il problema/situazione attuale"
},
{
"headline": "La soluzione: [nome/tipo], max 70 caratteri",
"body": "Cos'è e per chi è — spiegazione semplice e diretta, max 250 caratteri",
"image_keyword": "keyword immagine per la soluzione/prodotto"
},
{
"headline": "Come funziona in 3 passi, max 70 caratteri",
"body": "Il processo semplificato — 1. Cosa fai, 2. Cosa succede, 3. Risultato, max 250 caratteri",
"image_keyword": "keyword immagine per il processo/step"
},
{
"headline": "Chi lo usa già ottiene..., max 70 caratteri",
"body": "Risultati concreti con numeri — prova sociale specifica e credibile, max 250 caratteri",
"image_keyword": "keyword immagine per il successo/testimonial"
},
{
"headline": "Cosa ottieni tu concretamente, max 70 caratteri",
"body": "Benefici specifici per {{target_nicchia}} — elenca 2-3 risultati tangibili, max 250 caratteri",
"image_keyword": "keyword immagine per i benefici/risultati"
},
{
"headline": "Perché agire adesso, max 70 caratteri",
"body": "Urgenza, scarsità o garanzia — un elemento che riduce il rischio percepito, max 250 caratteri",
"image_keyword": "keyword immagine per urgenza/garanzia"
}
],
"cta_text": "{{call_to_action}}, max 60 caratteri",
"cta_subtext": "Istruzione operativa: dove cliccare, cosa succede dopo, max 180 caratteri",
"cta_image_keyword": "keyword immagine per la CTA — suggerisce l'azione",
"caption_instagram": "Caption promozionale con beneficio in apertura, offerta chiara, urgenza e hashtag, max 2000 caratteri"
}

View File

@@ -0,0 +1,76 @@
Crea un carosello Instagram nel formato BAB (Before → After → Bridge) per un post di STORYTELLING.
CONTESTO:
- Obiettivo campagna: {{obiettivo_campagna}}
- Topic del post: {{topic}}
- Nicchia target: {{target_nicchia}}
- Livello consapevolezza: {{livello_schwartz}}
- Brand/Studio: {{brand_name}}
FORMATO BAB — COME APPLICARLO ALLE 8 SLIDE:
1. COVER: Apri con la situazione di partenza (BEFORE) — il lettore deve riconoscersi
2. Slide 2: Approfondisci il BEFORE — racconta la storia, il dolore, le difficoltà quotidiane
3. Slide 3: Il momento di svolta — il punto in cui tutto è cambiato (transizione BEFORE → AFTER)
4. Slide 4: Il AFTER — come è diventata la vita/il lavoro dopo il cambiamento
5. Slide 5: AFTER in dettaglio — risultati concreti, numeri, miglioramenti specifici
6. Slide 6: Il BRIDGE — cosa ha reso possibile questo cambiamento (il metodo/approccio)
7. Slide 7: Lezione chiave — cosa può imparare il lettore da questa storia
8. CTA: Invito all'azione per iniziare il proprio percorso di cambiamento
REGOLE PER QUESTO TIPO DI POST:
- È una storia di trasformazione — deve avere tensione narrativa e risoluzione
- Il protagonista è il cliente/imprenditore della nicchia {{target_nicchia}}, non il brand
- Usa dettagli specifici: nomi immaginari ma credibili, numeri reali, situazioni riconoscibili
- Il tono è narrativo ed emotivo, non tecnico
- Il lettore deve pensare "anch'io voglio questa trasformazione"
- La storia deve essere verosimile e aspirazionale, non miracolosa
CAPTION INSTAGRAM:
- Inizia con una domanda empatica che risuona con la situazione BEFORE
- Racconta il cuore della storia in 3-4 righe (senza spoilerare tutto il carosello)
- Chiudi con un invito a leggere le slide per la storia completa
- Aggiungi 5-8 hashtag narrativi e di nicchia per {{target_nicchia}}
SCHEMA OUTPUT JSON:
Rispondi SOLO con questo JSON (nessun testo fuori dal JSON):
{
"cover_title": "Apertura narrativa che cattura, max 60 caratteri — il BEFORE che risuona",
"cover_subtitle": "Contestualizza la storia per {{target_nicchia}}, max 120 caratteri",
"cover_image_keyword": "keyword per immagine evocativa del BEFORE",
"slides": [
{
"headline": "Il prima: [situazione di partenza], max 70 caratteri",
"body": "Descrizione vivida della situazione BEFORE, concreta e riconoscibile, max 250 caratteri",
"image_keyword": "keyword immagine che evoca il problema iniziale"
},
{
"headline": "Il momento critico, max 70 caratteri",
"body": "Il punto più basso della storia — il dolore al massimo, max 250 caratteri",
"image_keyword": "keyword immagine per il momento di crisi"
},
{
"headline": "Il punto di svolta, max 70 caratteri",
"body": "Cosa ha fatto scattare il cambiamento — la decisione, l'incontro, la scoperta, max 250 caratteri",
"image_keyword": "keyword immagine per la svolta/trasformazione"
},
{
"headline": "Il dopo: [risultato principale], max 70 caratteri",
"body": "Come è cambiata la situazione — risultati concreti con numeri, max 250 caratteri",
"image_keyword": "keyword immagine che evoca il successo/il dopo"
},
{
"headline": "I numeri della trasformazione, max 70 caratteri",
"body": "Dettagli specifici del AFTER — tempo risparmiato, clienti guadagnati, fatturato, max 250 caratteri",
"image_keyword": "keyword immagine per i risultati/successo"
},
{
"headline": "Il metodo che ha reso possibile tutto, max 70 caratteri",
"body": "Il BRIDGE — l'approccio concreto che ha generato la trasformazione, max 250 caratteri",
"image_keyword": "keyword immagine per il metodo/processo"
}
],
"cta_text": "Inizia la tua trasformazione oggi, max 60 caratteri",
"cta_subtext": "Il primo passo concreto che può fare il lettore adesso, max 180 caratteri",
"cta_image_keyword": "keyword immagine ispirazionale per la CTA",
"caption_instagram": "Caption narrativa con hook empatico, cuore della storia e invito all'azione, max 2000 caratteri"
}

View File

@@ -0,0 +1,76 @@
Crea un carosello Instagram nel formato DATO + IMPLICAZIONE per un post di NEWS o AGGIORNAMENTO DI SETTORE.
CONTESTO:
- Obiettivo campagna: {{obiettivo_campagna}}
- Topic del post: {{topic}}
- Nicchia target: {{target_nicchia}}
- Livello consapevolezza: {{livello_schwartz}}
- Brand/Studio: {{brand_name}}
FORMATO DATO + IMPLICAZIONE — COME APPLICARLO ALLE 8 SLIDE:
1. COVER (DATO): Apri con un dato, statistica o notizia che colpisce — deve creare urgenza informativa
2. Slide 2: Contestualizza il dato — da dove viene, cosa significa nel contesto di {{target_nicchia}}
3. Slide 3: Prima implicazione — cosa cambia per chi lavora in questo settore
4. Slide 4: Seconda implicazione — chi rischia di più se non si adatta
5. Slide 5: L'opportunità nascosta — chi può trarre vantaggio da questo cambiamento
6. Slide 6: Cosa fare concretamente — 2-3 azioni pratiche in risposta al dato
7. Slide 7: La previsione — dove andrà questo trend nei prossimi 6-12 mesi
8. CTA: Invito ad approfondire o a discuterne nei commenti
REGOLE PER QUESTO TIPO DI POST:
- Il dato deve essere reale, credibile e verificabile — non inventare statistiche
- Se usi un dato, cita la fonte (es. "secondo una ricerca Istat 2024")
- Il focus è sull'IMPLICAZIONE pratica, non sul dato in sé
- Il lettore deve sentire urgenza: "devo fare qualcosa a riguardo"
- Il tono è informativo e autorevole, ma non allarmistico
- Distingui tra rischi e opportunità — dai una prospettiva bilanciata
CAPTION INSTAGRAM:
- Inizia con il dato come hook immediato (numeri in evidenza)
- Spiega brevemente perché questo dato è rilevante per {{target_nicchia}}
- Chiudi con una domanda aperta per stimolare i commenti
- Aggiungi 5-8 hashtag di news e di settore per {{target_nicchia}}
SCHEMA OUTPUT JSON:
Rispondi SOLO con questo JSON (nessun testo fuori dal JSON):
{
"cover_title": "Il dato o la notizia in modo impattante, max 60 caratteri — usa i numeri",
"cover_subtitle": "Perché questo dato è importante per {{target_nicchia}}, max 120 caratteri",
"cover_image_keyword": "keyword per immagine che evoca dati/ricerca/trend",
"slides": [
{
"headline": "Cosa significa questo dato, max 70 caratteri",
"body": "Fonte e contesto del dato — dove è emerso, quando, su quale campione, max 250 caratteri",
"image_keyword": "keyword immagine per statistiche/report/dati"
},
{
"headline": "Prima implicazione: cosa cambia, max 70 caratteri",
"body": "Il primo cambiamento concreto per chi lavora in questo settore, max 250 caratteri",
"image_keyword": "keyword immagine per cambiamento/impatto"
},
{
"headline": "Chi rischia di più, max 70 caratteri",
"body": "Quali professionisti o aziende di {{target_nicchia}} sono più esposti, max 250 caratteri",
"image_keyword": "keyword immagine per rischio/vulnerabilità"
},
{
"headline": "L'opportunità che si apre, max 70 caratteri",
"body": "Come chi si muove adesso può trasformare questo trend in vantaggio competitivo, max 250 caratteri",
"image_keyword": "keyword immagine per opportunità/crescita"
},
{
"headline": "Cosa fare adesso in 3 mosse, max 70 caratteri",
"body": "Le 3 azioni pratiche che puoi fare questa settimana in risposta a questo trend, max 250 caratteri",
"image_keyword": "keyword immagine per azione/piano/strategia"
},
{
"headline": "Dove andremo nei prossimi 12 mesi, max 70 caratteri",
"body": "Previsione concreta — cosa aspettarsi nel settore di {{target_nicchia}}, max 250 caratteri",
"image_keyword": "keyword immagine per futuro/previsione/trend"
}
],
"cta_text": "Dimmi la tua opinione nei commenti, max 60 caratteri",
"cta_subtext": "Domanda specifica per stimolare la discussione e aumentare la reach, max 180 caratteri",
"cta_image_keyword": "keyword immagine per discussione/community/dialogo",
"caption_instagram": "Caption con dato in apertura, rilevanza per la nicchia, domanda finale e hashtag di settore, max 2000 caratteri"
}

View File

@@ -0,0 +1,76 @@
Crea un carosello Instagram nel formato LISTICLE (lista numerata) per un post di VALORE EDUCATIVO.
CONTESTO:
- Obiettivo campagna: {{obiettivo_campagna}}
- Topic del post: {{topic}}
- Nicchia target: {{target_nicchia}}
- Livello consapevolezza: {{livello_schwartz}}
- Brand/Studio: {{brand_name}}
FORMATO LISTICLE — COME APPLICARLO ALLE 8 SLIDE:
1. COVER: Annuncia il numero e il beneficio della lista (es. "6 modi per..." o "I 6 errori che...")
2. Slide 2: Punto 1 della lista — il più importante o sorprendente (cattura l'attenzione)
3. Slide 3: Punto 2 della lista
4. Slide 4: Punto 3 della lista
5. Slide 5: Punto 4 della lista
6. Slide 6: Punto 5 della lista
7. Slide 7: Punto 6 della lista — chiudi con il più azionabile o il più potente
8. CTA: Invito a salvare il post e ad applicare i consigli
REGOLE PER QUESTO TIPO DI POST:
- Ogni slide è UN punto della lista — titolo numerato + spiegazione pratica
- Inizia ogni headline con il numero: "1. Titolo", "2. Titolo", ecc.
- Ogni punto deve essere autonomo e comprensibile da solo
- Il valore deve essere immediatamente applicabile — non teorico
- I punti devono essere ordinati per importanza o logicità
- Usa esempi concreti nel testo body di ogni slide
CAPTION INSTAGRAM:
- Inizia con il numero totale e il beneficio principale (es. "6 strategie che ogni studio dentistico dovrebbe usare")
- Elenca brevemente 2-3 punti della lista nel testo
- Chiudi con invito a salvare e condividere
- Aggiungi 5-8 hashtag rilevanti per {{target_nicchia}}
SCHEMA OUTPUT JSON:
Rispondi SOLO con questo JSON (nessun testo fuori dal JSON):
{
"cover_title": "Titolo listicle con numero, max 60 caratteri (es: '6 errori che...')",
"cover_subtitle": "Sottotitolo che specifica il beneficio, max 120 caratteri",
"cover_image_keyword": "keyword per immagine cover, descrittiva e specifica",
"slides": [
{
"headline": "1. [titolo punto 1], max 70 caratteri",
"body": "Spiegazione punto 1, concreta e pratica, max 250 caratteri",
"image_keyword": "keyword immagine per il punto 1"
},
{
"headline": "2. [titolo punto 2], max 70 caratteri",
"body": "Spiegazione punto 2, max 250 caratteri",
"image_keyword": "keyword immagine per il punto 2"
},
{
"headline": "3. [titolo punto 3], max 70 caratteri",
"body": "Spiegazione punto 3, max 250 caratteri",
"image_keyword": "keyword immagine per il punto 3"
},
{
"headline": "4. [titolo punto 4], max 70 caratteri",
"body": "Spiegazione punto 4, max 250 caratteri",
"image_keyword": "keyword immagine per il punto 4"
},
{
"headline": "5. [titolo punto 5], max 70 caratteri",
"body": "Spiegazione punto 5, max 250 caratteri",
"image_keyword": "keyword immagine per il punto 5"
},
{
"headline": "6. [titolo punto 6], max 70 caratteri",
"body": "Spiegazione punto 6, il più azionabile, max 250 caratteri",
"image_keyword": "keyword immagine per il punto 6"
}
],
"cta_text": "Salva questo post e inizia da..., max 60 caratteri",
"cta_subtext": "Invito concreto all'azione con il primo passo, max 180 caratteri",
"cta_image_keyword": "keyword immagine CTA",
"caption_instagram": "Caption completa con hook, lista brevissima, invito a salvare e hashtag, max 2000 caratteri"
}

View File

@@ -0,0 +1,75 @@
Crea un carosello Instagram nel formato PAS (Problema → Agitazione → Soluzione) per un post di VALORE EDUCATIVO.
CONTESTO:
- Obiettivo campagna: {{obiettivo_campagna}}
- Topic del post: {{topic}}
- Nicchia target: {{target_nicchia}}
- Livello consapevolezza: {{livello_schwartz}}
- Brand/Studio: {{brand_name}}
FORMATO PAS — COME APPLICARLO ALLE 8 SLIDE:
1. COVER: Presenta il PROBLEMA in modo che il lettore si riconosca immediatamente
2. Slide 2: Approfondisci il problema — quanto è comune, perché succede
3. Slide 3: AGITAZIONE — quali sono le conseguenze se non risolvi il problema
4. Slide 4: Agitazione — il costo (economico, emotivo, di tempo) di non agire
5. Slide 5: SOLUZIONE — il primo passo concreto
6. Slide 6: Soluzione — il secondo passo concreto
7. Slide 7: Soluzione — risultato atteso, prova che funziona
8. CTA: Cosa fare adesso per iniziare
REGOLE PER QUESTO TIPO DI POST:
- Focus sul valore: stai EDUCANDO, non vendendo
- Ogni slide deve contenere UN concetto azionabile
- Usa numeri e percentuali quando possibile (aumenta la credibilità)
- Il tono è quello di un esperto che aiuta, non di un venditore
- La CTA deve portare ad approfondire, non comprare
CAPTION INSTAGRAM:
- Inizia con una domanda o affermazione provocatoria (1 riga — l'hook)
- Sviluppa il valore in 3-4 righe
- Chiudi con invito all'azione
- Aggiungi 5-8 hashtag rilevanti per {{target_nicchia}}
SCHEMA OUTPUT JSON:
Rispondi SOLO con questo JSON (nessun testo fuori dal JSON):
{
"cover_title": "Titolo che ferma lo scroll, max 60 caratteri",
"cover_subtitle": "Sottotitolo che contestualizza, max 120 caratteri",
"cover_image_keyword": "keyword per immagine cover, descrittiva e specifica",
"slides": [
{
"headline": "Titolo slide 2, max 70 caratteri",
"body": "Testo slide 2, max 250 caratteri, concreto e diretto",
"image_keyword": "keyword immagine slide 2"
},
{
"headline": "Titolo slide 3, max 70 caratteri",
"body": "Testo slide 3, max 250 caratteri",
"image_keyword": "keyword immagine slide 3"
},
{
"headline": "Titolo slide 4, max 70 caratteri",
"body": "Testo slide 4, max 250 caratteri",
"image_keyword": "keyword immagine slide 4"
},
{
"headline": "Titolo slide 5, max 70 caratteri",
"body": "Testo slide 5, max 250 caratteri",
"image_keyword": "keyword immagine slide 5"
},
{
"headline": "Titolo slide 6, max 70 caratteri",
"body": "Testo slide 6, max 250 caratteri",
"image_keyword": "keyword immagine slide 6"
},
{
"headline": "Titolo slide 7, max 70 caratteri",
"body": "Testo slide 7, max 250 caratteri",
"image_keyword": "keyword immagine slide 7"
}
],
"cta_text": "Call-to-action principale, max 60 caratteri",
"cta_subtext": "Testo di supporto CTA, max 180 caratteri",
"cta_image_keyword": "keyword immagine CTA",
"caption_instagram": "Caption completa per Instagram con hook, sviluppo e hashtag, max 2000 caratteri"
}

View File

@@ -0,0 +1,33 @@
Sei un esperto di content marketing B2B per PMI italiane con 10 anni di esperienza nella creazione di caroselli Instagram che generano lead qualificati.
La tua specialità è trasformare concetti complessi in contenuti semplici, diretti e coinvolgenti per imprenditori e manager italiani.
TONO E STILE:
- Usa il "tu" diretto — parla alla persona, non a un'audience generica
- Sii diretto e concreto: vai subito al punto, senza giri di parole
- Provocatorio ma costruttivo: metti in discussione le credenze errate senza essere arrogante
- Evita il gergo tecnico inutile — se usi un termine, spiegalo subito
- Scrivi come parleresti a un imprenditore intelligente durante un caffè
REGOLE CONTENUTO:
- "Cosa fare" non "come farlo in dettaglio" — dai direzione, non un manuale
- Benefici concreti e misurabili: "risparmi 3 ore a settimana", non "risparmi tempo"
- Un concetto per slide — non sovraccaricare
- Il lettore deve pensare "questo si applica ESATTAMENTE a me"
- Mai usare statistiche vaghe come "molte aziende..." — sii specifico o non citarle
STRUTTURA CAROSELLO (8 slide):
1. COVER: Fermo lo scroll con un titolo che colpisce e un sottotitolo che contestualizza
2-7. SLIDE CENTRALI: Sviluppo del tema (1 idea per slide, concreta e azionabile)
8. CTA: Chiudi con una call-to-action chiara che dice esattamente cosa fare
LINGUA:
- Scrivi ESCLUSIVAMENTE in italiano naturale
- NON tradurre dall'inglese — pensa e scrivi direttamente in italiano
- Usa vocabolario quotidiano, non accademico
OUTPUT:
- Rispondi SEMPRE con JSON valido secondo lo schema fornito
- Non aggiungere testo fuori dal JSON
- Non usare markdown dentro i valori JSON (no asterischi, no hashtag)
- Tutti i campi sono obbligatori — non lasciare campi vuoti

View File

@@ -0,0 +1,38 @@
Genera UN topic specifico e concreto per un post Instagram carosello.
CONTESTO CAMPAGNA:
- Obiettivo: {{obiettivo_campagna}}
- Tipo di contenuto: {{tipo_contenuto}}
- Livello consapevolezza pubblico: {{livello_schwartz}}
- Nicchia target: {{target_nicchia}}
- Fase del funnel: {{fase_campagna}}
GUIDA PER IL LIVELLO DI CONSAPEVOLEZZA:
- L5 (Inconsapevole): Il pubblico non sa di avere il problema. Parla di sintomi, risultati desiderati, storie di cambiamento. NON menzionare il problema direttamente.
- L4 (Consapevole del problema): Sa che il problema esiste ma non sa come risolverlo. Puoi nominare il problema, aiutalo a capire le cause.
- L3 (Consapevole della soluzione): Conosce i tipi di soluzione. Aiutalo a capire quale approccio è giusto per lui.
- L2 (Consapevole del prodotto): Conosce la tua categoria di soluzione. Differenziati, supera le obiezioni.
- L1 (Pronto all'acquisto): Quasi convinto. Spingi all'azione con urgenza e prova sociale.
REQUISITI DEL TOPIC:
- Deve essere irresistibile per {{target_nicchia}} — devono pensare "questo parla di me"
- Coerente con il tipo {{tipo_contenuto}} e la fase {{fase_campagna}}
- Concreto e specifico, non generico
- Tra 5 e 100 caratteri — diretto e chiaro
- In italiano naturale
ESEMPI DI TOPIC BUONI:
- "3 motivi per cui i tuoi pazienti scelgono un altro studio dentistico"
- "Come ho salvato 15 ore a settimana eliminando una sola abitudine"
- "Il dato che fa paura: 7 PMI su 10 chiudono entro 5 anni per questo motivo"
- "Perché il tuo preventivo viene ignorato (e come cambiarlo in 2 mosse)"
ESEMPI DI TOPIC DA EVITARE:
- "Come migliorare il tuo business" (troppo generico)
- "Strategie di marketing digitale" (non specifico per la nicchia)
- "Tips per crescere" (vago e scontato)
Rispondi SOLO con questo JSON:
{
"topic": "il topic qui, in italiano, max 100 caratteri"
}

View File

@@ -0,0 +1,296 @@
"""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

View File

@@ -0,0 +1,169 @@
"""PromptService — carica, lista e compila prompt .txt con variabili.
Gestisce i file .txt dei prompt LLM nella directory PROMPTS_PATH.
Usa la sintassi {{variabile}} per i placeholder (doppia graffa).
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Optional
# Pattern per trovare le variabili {{nome}} nei template
_VARIABLE_PATTERN = re.compile(r"\{\{(\w+)\}\}")
class PromptService:
"""Servizio per gestire i prompt .txt del sistema di generazione.
Fornisce metodi per:
- Elencare i prompt disponibili
- Caricare il contenuto di un prompt
- Compilare un prompt sostituendo le variabili {{...}}
- Salvare un prompt (per l'editor di Phase 2)
- Estrarre la lista di variabili richieste da un template
"""
def __init__(self, prompts_dir: Path) -> None:
"""Inizializza il servizio con la directory dei prompt.
Args:
prompts_dir: Path alla directory contenente i file .txt dei prompt.
Tipicamente PROMPTS_PATH da backend.config.
Raises:
FileNotFoundError: Se la directory non esiste.
"""
if not prompts_dir.exists():
raise FileNotFoundError(
f"Directory prompt non trovata: {prompts_dir}. "
"Verifica che PROMPTS_PATH sia configurato correttamente."
)
if not prompts_dir.is_dir():
raise NotADirectoryError(
f"Il percorso non è una directory: {prompts_dir}"
)
self._prompts_dir = prompts_dir
def list_prompts(self) -> list[str]:
"""Elenca tutti i prompt .txt disponibili nella directory.
Returns:
Lista di nomi file senza estensione, ordinata alfabeticamente.
Es: ['aida_promozione', 'bab_storytelling', 'system_prompt', ...]
"""
return sorted(
p.stem for p in self._prompts_dir.glob("*.txt") if p.is_file()
)
def load_prompt(self, name: str) -> str:
"""Carica il contenuto grezzo di un prompt .txt.
Args:
name: Nome del prompt senza estensione (es. "pas_valore")
Returns:
Contenuto testuale del file prompt
Raises:
FileNotFoundError: Se il file non esiste
"""
path = self._get_path(name)
if not path.exists():
available = self.list_prompts()
raise FileNotFoundError(
f"Prompt '{name}' non trovato in {self._prompts_dir}. "
f"Prompt disponibili: {available}"
)
return path.read_text(encoding="utf-8")
def compile_prompt(self, name: str, variables: dict[str, str]) -> str:
"""Carica un prompt e sostituisce tutte le variabili {{nome}} con i valori forniti.
Args:
name: Nome del prompt senza estensione
variables: Dizionario { nome_variabile: valore }
Returns:
Testo del prompt con tutte le variabili sostituite
Raises:
FileNotFoundError: Se il prompt non esiste
ValueError: Se una variabile nel template non ha corrispondenza nel dict
"""
template = self.load_prompt(name)
# Verifica che tutte le variabili del template siano nel dict
required = set(_VARIABLE_PATTERN.findall(template))
provided = set(variables.keys())
missing = required - provided
if missing:
raise ValueError(
f"Variabili mancanti per il prompt '{name}': {sorted(missing)}. "
f"Fornire: {sorted(required)}"
)
def replace_var(match: re.Match) -> str:
var_name = match.group(1)
return variables[var_name]
return _VARIABLE_PATTERN.sub(replace_var, template)
def save_prompt(self, name: str, content: str) -> None:
"""Salva il contenuto di un prompt nel file .txt.
Usato dall'editor di prompt in Phase 2.
Args:
name: Nome del prompt senza estensione
content: Contenuto testuale da salvare
Raises:
ValueError: Se il nome contiene caratteri non sicuri
"""
# Sicurezza: validazione nome file (solo lettere, cifre, underscore, trattino)
if not re.match(r"^[\w\-]+$", name):
raise ValueError(
f"Nome prompt non valido: '{name}'. "
"Usa solo lettere, cifre, underscore e trattino."
)
path = self._get_path(name)
path.write_text(content, encoding="utf-8")
def get_required_variables(self, name: str) -> list[str]:
"""Analizza il template e ritorna la lista delle variabili richieste.
Args:
name: Nome del prompt senza estensione
Returns:
Lista ordinata di nomi variabile (senza doppie graffe)
Es: ['brand_name', 'livello_schwartz', 'obiettivo_campagna', 'target_nicchia', 'topic']
Raises:
FileNotFoundError: Se il prompt non esiste
"""
template = self.load_prompt(name)
variables = sorted(set(_VARIABLE_PATTERN.findall(template)))
return variables
def prompt_exists(self, name: str) -> bool:
"""Verifica se un prompt esiste.
Args:
name: Nome del prompt senza estensione
Returns:
True se il file esiste
"""
return self._get_path(name).exists()
# ---------------------------------------------------------------------------
# Metodi privati
# ---------------------------------------------------------------------------
def _get_path(self, name: str) -> Path:
"""Costruisce il percorso completo per un file prompt."""
return self._prompts_dir / f"{name}.txt"