diff --git a/backend/data/prompts/aida_promozione.txt b/backend/data/prompts/aida_promozione.txt new file mode 100644 index 0000000..21c1b77 --- /dev/null +++ b/backend/data/prompts/aida_promozione.txt @@ -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" +} diff --git a/backend/data/prompts/bab_storytelling.txt b/backend/data/prompts/bab_storytelling.txt new file mode 100644 index 0000000..ac60fe6 --- /dev/null +++ b/backend/data/prompts/bab_storytelling.txt @@ -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" +} diff --git a/backend/data/prompts/dato_news.txt b/backend/data/prompts/dato_news.txt new file mode 100644 index 0000000..680c684 --- /dev/null +++ b/backend/data/prompts/dato_news.txt @@ -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" +} diff --git a/backend/data/prompts/listicle_valore.txt b/backend/data/prompts/listicle_valore.txt new file mode 100644 index 0000000..eaf2d3c --- /dev/null +++ b/backend/data/prompts/listicle_valore.txt @@ -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" +} diff --git a/backend/data/prompts/pas_valore.txt b/backend/data/prompts/pas_valore.txt new file mode 100644 index 0000000..f157ac2 --- /dev/null +++ b/backend/data/prompts/pas_valore.txt @@ -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" +} diff --git a/backend/data/prompts/system_prompt.txt b/backend/data/prompts/system_prompt.txt new file mode 100644 index 0000000..3cb111a --- /dev/null +++ b/backend/data/prompts/system_prompt.txt @@ -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 diff --git a/backend/data/prompts/topic_generator.txt b/backend/data/prompts/topic_generator.txt new file mode 100644 index 0000000..cbe7870 --- /dev/null +++ b/backend/data/prompts/topic_generator.txt @@ -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" +} diff --git a/backend/services/calendar_service.py b/backend/services/calendar_service.py new file mode 100644 index 0000000..b0e45c7 --- /dev/null +++ b/backend/services/calendar_service.py @@ -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 diff --git a/backend/services/prompt_service.py b/backend/services/prompt_service.py new file mode 100644 index 0000000..f454463 --- /dev/null +++ b/backend/services/prompt_service.py @@ -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"