"""Textes : accroches posées sur les visuels, carrousel, publications par canal. Limites vérifiées par le code.""" from __future__ import annotations import logging import re from pydantic import BaseModel, Field from .kit import Brief, BrandKit log = logging.getLogger("studio.textes") MENTION_IA = "Visuels créés avec l'IA (Gemini, Veo), sélectionnés et validés par notre équipe." LIMITES = {"titre": 42, "sous_titre": 90, "linkedin": 1300, "instagram": 2200, "hashtags": 5} class Accroche(BaseModel): titre: str = Field(description=f"{LIMITES['titre']} caractères maximum") sous_titre: str = Field(description=f"{LIMITES['sous_titre']} caractères maximum") class Accroches(BaseModel): linkedin: Accroche instagram_portrait: Accroche story: Accroche banniere: Accroche class Diapo(BaseModel): titre: str = Field(description=f"{LIMITES['titre']} caractères maximum") texte: str = Field(description="140 caractères maximum") class Textes(BaseModel): accroches: Accroches carrousel: list[Diapo] = Field(description="exactement 5 diapositives : couverture, 3 arguments, appel à l'action") post_linkedin: str = Field(description="post LinkedIn, 600 à 1200 caractères, première ligne qui accroche, sans hashtag") legende_instagram: str = Field(description="légende Instagram, 300 à 800 caractères, sans hashtag") hashtags: list[str] = Field(description="3 à 5 hashtags sans espace") texte_alternatif: str = Field(description="description du visuel pour l'accessibilité, 125 caractères maximum") PROMPT = """Rédige les textes de la campagne pour la marque {marque}. Ton : {ton} Règles : {regles} Mots interdits (ne jamais les employer, même dérivés) : {interdits} Brief : {brief} Écris en français. Pas d'emoji. Pas d'allégation chiffrée qui ne figure pas dans le brief.""" def generer_textes(models, brief: Brief, kit: BrandKit) -> tuple[Textes, list[str]]: prompt = PROMPT.format( marque=kit.marque, ton="; ".join(kit.ton), regles="; ".join(kit.regles), interdits=", ".join(kit.mots_interdits), brief=brief.model_dump_json(), ) textes = models.json(prompt, Textes, nature="textes") problemes = verifier(textes, kit) if problemes: # un seul tour de correction : le modèle reçoit la liste exacte des écarts log.warning("textes à corriger : %s", problemes) textes = models.json(prompt + "\n\nCorrige ces écarts de la version précédente :\n- " + "\n- ".join(problemes) + "\nVersion précédente :\n" + textes.model_dump_json(), Textes, nature="textes") problemes = verifier(textes, kit) return textes, problemes def verifier(t: Textes, kit: BrandKit) -> list[str]: """Écarts mesurables : longueurs, nombre de diapositives et de hashtags, mots interdits.""" p = [] for nom, a in t.accroches.model_dump().items(): if len(a["titre"]) > LIMITES["titre"]: p.append(f"accroche {nom} : titre de {len(a['titre'])} caractères > {LIMITES['titre']}") if len(a["sous_titre"]) > LIMITES["sous_titre"]: p.append(f"accroche {nom} : sous-titre de {len(a['sous_titre'])} caractères > {LIMITES['sous_titre']}") if len(t.carrousel) != 5: p.append(f"carrousel : {len(t.carrousel)} diapositives au lieu de 5") if len(t.post_linkedin) > LIMITES["linkedin"]: p.append(f"post LinkedIn de {len(t.post_linkedin)} caractères > {LIMITES['linkedin']}") if not 1 <= len(t.hashtags) <= LIMITES["hashtags"]: p.append(f"{len(t.hashtags)} hashtags (attendu 1 à {LIMITES['hashtags']})") tout = t.model_dump_json().lower() for mot in kit.mots_interdits: if re.search(r"\b" + re.escape(mot.lower()), tout): p.append(f"mot interdit employé : « {mot} »") return p def post_linkedin(t: Textes) -> str: tags = " ".join("#" + h.lstrip("#").replace(" ", "") for h in t.hashtags) return f"{t.post_linkedin.strip()}\n\n{tags}\n\n{MENTION_IA}\n"