diff --git a/src/lib/brandbook-ads-insights.ts b/src/lib/brandbook-ads-insights.ts index 5629fa8..de86f59 100644 --- a/src/lib/brandbook-ads-insights.ts +++ b/src/lib/brandbook-ads-insights.ts @@ -8,6 +8,17 @@ import { resolveMetaAccessToken } from '@/lib/ads-discovery'; import { resolveGoogleAdsAccessToken } from '@/lib/google-ads-auth'; import { serviceHeaders, supabaseUrl } from '@/lib/agency'; import type { Brandbook } from '@/lib/brandbook'; +import { + looksLikeCampaignOpsLabel, + scrubCampaignOpsFromBrandbook, + scrubOpsNoiseList, +} from '@/lib/brandbook-sanitize'; + +export { + looksLikeCampaignOpsLabel, + scrubCampaignOpsFromBrandbook, + scrubOpsNoiseList, +} from '@/lib/brandbook-sanitize'; const META_GRAPH = 'https://graph.facebook.com/v21.0'; const GADS_API = 'https://googleads.googleapis.com/v22'; @@ -432,29 +443,110 @@ LIMIT 50 return { headlines, descriptions, count }; } -/** Ángulos a partir de nombres de campaña (TOFU/MOFU/BOFU, producto, etc.). */ +/** Texto de copy publicitario legible (claim), no nombre interno. */ +function looksLikeHumanAdCopy(raw: string): boolean { + const s = String(raw || '').trim(); + if (s.length < 8 || s.length > 120) return false; + if (looksLikeCampaignOpsLabel(s)) return false; + // Prefer phrases with spaces / punctuation over codes + if (!/\s/.test(s) && s.length > 24) return false; + if (/^[a-z0-9]+([_-][a-z0-9]+){2,}$/i.test(s)) return false; + return true; +} + +/** + * Ángulos de embudo SOLO mapeados desde convenciones de nombre. + * Nunca se copian los nombres crudos de campaña al brandbook. + */ function anglesFromCampaignNames(names: string[]): string[] { const angles: string[] = []; for (const n of names) { const lower = n.toLowerCase(); - if (/tofu|awareness|traf[ií]co|alcance/.test(lower)) { - angles.push('TOFU / tráfico / awareness'); + if (/tofu|awareness|traf[ií]co|alcance|reach|prospect/.test(lower)) { + angles.push('Awareness / tráfico (TOFU)'); } - if (/mofu|consideraci|interacci|engagement/.test(lower)) { - angles.push('MOFU / consideración'); + if (/mofu|consideraci|interacci|engagement|traffic.?warm/.test(lower)) { + angles.push('Consideración / engagement (MOFU)'); } - if (/bofu|conversi|leads?|mensaje|whatsapp|ventas?/.test(lower)) { - angles.push('BOFU / conversión / leads'); + if (/bofu|conversi|leads?|mensaje|whatsapp|ventas?|purchase|sales/.test(lower)) { + angles.push('Conversión / leads (BOFU)'); + } + if (/retarget|remarket|rmkt|warm.?audi/.test(lower)) { + angles.push('Retargeting / audiencia caliente'); + } + if (/brand|branding|awareness.?brand/.test(lower)) { + angles.push('Branding / notoriedad'); } - if (/retarget|remarket|rmkt/.test(lower)) angles.push('Retargeting'); - // keep short campaign fragments as angle hints - const clean = n - .replace(/[_-]+/g, ' ') - .replace(/\s+/g, ' ') - .trim(); - if (clean.length >= 8 && clean.length <= 60) angles.push(clean); } - return uniq(angles, 12); + return uniq(angles, 8); +} + +/** + * Ángulos creativos derivados del copy observado (no de nombres de campaña). + */ +function creativeAnglesFromCopy(texts: string[]): string[] { + const blob = texts.join('\n').toLowerCase(); + const angles: string[] = []; + if (/prueba|gratis|demo|sin compromiso|trial/.test(blob)) { + angles.push('Oferta de prueba / demo'); + } + if (/garant[ií]a|devoluci[oó]n|riesgo\s*cero/.test(blob)) { + angles.push('Garantía / riesgo cero'); + } + if ( + /antes\s*y\s*despu[eé]s|resultados?|casos?\s*de\s*[eé]xito|\d+\s*%|\+\d+|clientes?\s+(satisfechos|reales)/.test( + blob + ) + ) { + angles.push('Resultados / prueba social'); + } + if (/urgencia|hoy|solo\s+hasta|plazas?\s+limit|ultima?\s+hora|24\s*h/.test(blob)) { + angles.push('Urgencia / escasez'); + } + if (/precio|dto\.?|descuento|oferta|promo|2x1|ahorra/.test(blob)) { + angles.push('Precio / promoción'); + } + if (/dolor|problema|cansad|hart[oa]|frustr|sin tiempo|no encuentra/.test(blob)) { + angles.push('Dolor / problema del cliente'); + } + if (/c[oó]mo\s+|gu[ií]a|tips?|consejos?|aprende|descubre/.test(blob)) { + angles.push('Educativo / cómo hacerlo'); + } + if (/whatsapp|reserva|pide\s+cita|agenda|ll[aá]manos|contacta/.test(blob)) { + angles.push('CTA directo / conversión'); + } + return uniq(angles, 10); +} + +/** + * Pilares de mensaje = temas editoriales recurrentes en el copy. + * Nunca nombres de campaña ni etiquetas TOFU/MOFU/BOFU. + */ +function messagingPillarsFromCopy(texts: string[]): string[] { + const blob = texts.join('\n').toLowerCase(); + const pillars: string[] = []; + if (/educa|aprende|consejo|gu[ií]a|tips?|c[oó]mo\s+/.test(blob)) { + pillars.push('Educación / utilidad'); + } + if (/testimon|opini[oó]n|rese[nñ]a|cliente\s+real|caso\s+de\s+[eé]xito/.test(blob)) { + pillars.push('Prueba social / testimonios'); + } + if (/producto|servicio|tratamiento|soluci[oó]n|beneficio/.test(blob)) { + pillars.push('Producto / beneficio'); + } + if (/oferta|promo|descuento|lanzamiento|novedad/.test(blob)) { + pillars.push('Oferta / novedades'); + } + if (/marca|valores?|compromiso|misi[oó]n|historia|somos/.test(blob)) { + pillars.push('Marca / valores'); + } + if (/comunidad|junto|contigo|familia|equipo/.test(blob)) { + pillars.push('Comunidad / cercanía'); + } + if (/b2b|empresa|profesional|negocio|equipos?\s+de\s+trabajo/.test(blob)) { + pillars.push('B2B / profesional'); + } + return uniq(pillars, 6); } /** @@ -551,28 +643,44 @@ export async function collectAdsInsightsForTenant( ); const ads_ctas = uniq([...metaCtas], 10); - const preferred_angles = anglesFromCampaignNames(campaignNames); - const ads_angles = preferred_angles; - const slogan_variants = uniq( - [...metaHeadlines, ...gadsHeadlines].map((h) => clip(h, 60)), + + // Ángulos: embudo (mapeado, sin nombres crudos) + ángulos creativos del copy + const copyPool = [ + ...metaBodies, + ...metaHeadlines, + ...gadsHeadlines, + ...gadsDescs, + ...igCaptions, + ...fbPosts, + ]; + const preferred_angles = uniq( + [ + ...creativeAnglesFromCopy(copyPool), + ...anglesFromCampaignNames(campaignNames), + ], 12 ); + const ads_angles = preferred_angles; - // USPs cortos: titulares Meta/Google (son claims ya usados en ads) - const ads_usps = uniq( + // Solo titulares que parecen claims humanos (nunca nombres de campaña) + const claimHeadlines = uniq( [...metaHeadlines, ...gadsHeadlines] - .filter((h) => h.length >= 8 && h.length <= 90) + .filter(looksLikeHumanAdCopy) .map((h) => clip(h, 90)), - 10 + 12 ); + const slogan_variants = claimHeadlines.slice(0, 12); + const ads_usps = claimHeadlines.slice(0, 10); const linkedin_themes = uniq( calendarSocial.linkedin .map((t) => clip(t.split(/[·|]/)[0] || t, 80)) - .filter((t) => t.length >= 8), + .filter((t) => t.length >= 8 && !looksLikeCampaignOpsLabel(t)), 8 ); + const messaging_pillars = messagingPillarsFromCopy(copyPool); + const lines: string[] = [ '=== COPY REAL DESDE ADS / RRSS (no inventado) ===', `Meta ads: ${metaCount} · Google ads: ${gadsCount} · IG captions: ${igCaptions.length} · FB posts: ${fbPosts.length} · Calendario RRSS: ${calendarSocial.texts.length}`, @@ -582,13 +690,27 @@ export async function collectAdsInsightsForTenant( } if (winning_hooks.length) { lines.push('', 'Hooks / primary texts / titulares observados:'); - winning_hooks.forEach((h, i) => lines.push(`${i + 1}. ${h}`)); + winning_hooks + .filter((h) => !looksLikeCampaignOpsLabel(h)) + .forEach((h, i) => lines.push(`${i + 1}. ${h}`)); } if (ads_ctas.length) { lines.push('', `CTAs Meta observados: ${ads_ctas.join(', ')}`); } if (preferred_angles.length) { - lines.push('', `Ángulos (nombres de campaña): ${preferred_angles.join(' · ')}`); + lines.push( + '', + `Ángulos (derivados de copy / embudo, no nombres crudos): ${preferred_angles.join(' · ')}` + ); + } + if (messaging_pillars.length) { + lines.push('', `Pilares de mensaje (temas en copy): ${messaging_pillars.join(' · ')}`); + } + if (campaignNames.length) { + lines.push( + '', + `Nombres de campaña (solo contexto interno, NO van a pilares/estilo): ${uniq(campaignNames, 8).join(' · ')}` + ); } if (igCaptions.length) { lines.push('', 'Captions IG recientes (muestra):'); @@ -611,12 +733,16 @@ export async function collectAdsInsightsForTenant( } if (gadsHeadlines.length) { lines.push('', 'Headlines Google Ads (muestra):'); - uniq(gadsHeadlines, 10).forEach((h, i) => lines.push(`${i + 1}. ${h}`)); + uniq(gadsHeadlines.filter(looksLikeHumanAdCopy), 10).forEach((h, i) => + lines.push(`${i + 1}. ${h}`) + ); } const partial: Partial = {}; - if (winning_hooks.length) partial.winning_hooks = winning_hooks; - if (example_good.length) partial.example_good = example_good; + const cleanHooks = winning_hooks.filter((h) => !looksLikeCampaignOpsLabel(h)); + const cleanExamples = example_good.filter((h) => !looksLikeCampaignOpsLabel(h)); + if (cleanHooks.length) partial.winning_hooks = cleanHooks; + if (cleanExamples.length) partial.example_good = cleanExamples; if (ads_ctas.length) { partial.ads_ctas = ads_ctas; partial.allowed_ctas = ads_ctas; @@ -629,27 +755,22 @@ export async function collectAdsInsightsForTenant( if (ads_usps.length) partial.ads_usps = ads_usps; if (linkedin_themes.length) partial.linkedin_themes = linkedin_themes; - // Campos editoriales derivados SOLO de copy observado (relleno si vacíos en apply) + // Editoriales: solo claims humanos / temas de copy (nunca nombres de campaña) if (ads_usps.length) { partial.usp_differentiators = ads_usps.slice(0, 5).join(' · '); partial.differentiators = ads_usps.slice(0, 8); partial.value_proposition = ads_usps.slice(0, 3).join(' · '); } - if (preferred_angles.length) { - partial.messaging_pillars = preferred_angles.slice(0, 6); - } else if (ads_usps.length) { - partial.messaging_pillars = ads_usps.slice(0, 5); + if (messaging_pillars.length) { + partial.messaging_pillars = messaging_pillars; } const proof = uniq( [...metaBodies, ...gadsDescs, ...igCaptions] - .filter((t) => /\d/.test(t) && t.length >= 20) + .filter((t) => /\d/.test(t) && t.length >= 20 && !looksLikeCampaignOpsLabel(t)) .map((t) => clip(t, 160)), 6 ); if (proof.length) partial.proof_points = proof; - if (slogan_variants.length && !partial.tagline) { - // no: tagline no se inventa desde un headline suelto - } return { partial, @@ -669,6 +790,7 @@ export async function collectAdsInsightsForTenant( * - Por defecto rellena vacíos. * - Si `overwriteAdsFields`, pisa hooks/CTAs/ángulos/usps/ejemplos con copy real * (prioridad sobre síntesis editorial previa). + * - Siempre limpia etiquetas operativas de campaña en campos editoriales. */ export function applyAdsInsightsToBrandbook( book: Brandbook, @@ -677,20 +799,26 @@ export function applyAdsInsightsToBrandbook( ): Brandbook { const overwrite = opts?.overwriteAdsFields !== false; const p = bundle.partial; - const out: Brandbook = { ...book }; + let out: Brandbook = scrubCampaignOpsFromBrandbook({ ...book }); const applyArr = ( key: keyof Brandbook, next?: string[], - mode: 'overwrite' | 'empty' = 'overwrite' + mode: 'overwrite' | 'empty' | 'replace_if_contaminated' = 'overwrite' ) => { if (!next?.length) return; - const cur = out[key] as string[] | undefined; + const cur = (out[key] as string[] | undefined) || []; + const curClean = scrubOpsNoiseList(cur); if (mode === 'overwrite') { - if (overwrite || !cur?.length) { + if (overwrite || !curClean.length) { (out as Record)[key] = next; } - } else if (!cur?.length) { + } else if (mode === 'replace_if_contaminated') { + // Si el campo estaba vacío o solo tenía ruido de campañas, sustituir + if (!curClean.length) { + (out as Record)[key] = next; + } + } else if (!curClean.length) { (out as Record)[key] = next; } }; @@ -698,10 +826,12 @@ export function applyAdsInsightsToBrandbook( const applyStrEmpty = (key: keyof Brandbook, next?: string) => { if (!next?.trim()) return; const cur = String(out[key] || '').trim(); - if (!cur) (out as Record)[key] = next.trim(); + if (!cur || looksLikeCampaignOpsLabel(cur)) { + (out as Record)[key] = next.trim(); + } }; - // Copy de ads: prioridad (overwrite) + // Copy de ads: prioridad (overwrite) — ya filtrado en collect applyArr('winning_hooks', p.winning_hooks, 'overwrite'); applyArr('example_good', p.example_good, 'overwrite'); applyArr('ads_ctas', p.ads_ctas, 'overwrite'); @@ -712,12 +842,13 @@ export function applyAdsInsightsToBrandbook( applyArr('ads_usps', p.ads_usps, 'overwrite'); applyArr('linkedin_themes', p.linkedin_themes, 'overwrite'); - // Editoriales: solo si vacíos + // Editoriales: rellenar si vacío O si solo había nombres de campaña applyStrEmpty('usp_differentiators', p.usp_differentiators); applyStrEmpty('value_proposition', p.value_proposition); - applyArr('differentiators', p.differentiators, 'empty'); - applyArr('messaging_pillars', p.messaging_pillars, 'empty'); - applyArr('proof_points', p.proof_points, 'empty'); + applyArr('differentiators', p.differentiators, 'replace_if_contaminated'); + applyArr('messaging_pillars', p.messaging_pillars, 'replace_if_contaminated'); + applyArr('proof_points', p.proof_points, 'replace_if_contaminated'); + out = scrubCampaignOpsFromBrandbook(out); return out; } diff --git a/src/lib/brandbook-enrich.ts b/src/lib/brandbook-enrich.ts index 8d1a42f..0cd46fb 100644 --- a/src/lib/brandbook-enrich.ts +++ b/src/lib/brandbook-enrich.ts @@ -27,6 +27,11 @@ import { applyAdsInsightsToBrandbook, collectAdsInsightsForTenant, } from '@/lib/brandbook-ads-insights'; +import { + looksLikeCampaignOpsLabel, + scrubCampaignOpsFromBrandbook, + scrubOpsNoiseList, +} from '@/lib/brandbook-sanitize'; import { driveConfigured, getTenantDriveFolderId, @@ -607,13 +612,15 @@ async function calendarSeriesHints( .pieces) : []; for (const p of pieces) { - if (p.series) series.add(String(p.series).trim()); - if (p.pillar) pillars.add(String(p.pillar).trim()); + const s = String(p.series || '').trim(); + const pillar = String(p.pillar || '').trim(); + if (s && !looksLikeCampaignOpsLabel(s)) series.add(s); + if (pillar && !looksLikeCampaignOpsLabel(pillar)) pillars.add(pillar); } } return { - series: [...series].filter(Boolean).slice(0, 24), - pillars: [...pillars].filter(Boolean).slice(0, 16), + series: scrubOpsNoiseList([...series]).slice(0, 24), + pillars: scrubOpsNoiseList([...pillars]).slice(0, 16), }; } catch { return { series: [], pillars: [] }; @@ -996,6 +1003,9 @@ export async function enrichTenantBrandbook(opts: { // Alias note for Superhuman / Drive matching (helps operators find docs) sources.push('superhuman_alias'); + // Quitar nombres de campaña / etiquetas ops que hayan contaminado pilares, estilos, etc. + book = scrubCampaignOpsFromBrandbook(book); + book.brand_name = book.brand_name || tenant.name; book.updated_at = new Date().toISOString(); diff --git a/src/lib/brandbook-sanitize.ts b/src/lib/brandbook-sanitize.ts new file mode 100644 index 0000000..bd29d27 --- /dev/null +++ b/src/lib/brandbook-sanitize.ts @@ -0,0 +1,149 @@ +/** + * Sanitiza campos de brandbook: no deben contener nombres internos de campaña + * (TOFU_LAL_ES_2024, CBO|IG, etc.) ni embudo como si fueran pilares editoriales. + */ +import type { Brandbook } from '@/lib/brandbook'; + +function uniq(arr: string[], max = 24): string[] { + const out: string[] = []; + const seen = new Set(); + for (const raw of arr) { + const s = String(raw || '') + .replace(/\s+/g, ' ') + .trim(); + if (s.length < 3) continue; + const key = s.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push(s); + if (out.length >= max) break; + } + return out; +} + +/** Ángulos/pilares semánticos humanos (no ruido de naming). */ +const ALLOWED_SEMANTIC_LABELS = + /^(awareness|consideraci[oó]n|conversi[oó]n|retargeting|branding|oferta de prueba|garant[ií]a|resultados|urgencia|precio|dolor|educativ|cta directo|educaci[oó]n|prueba social|producto|oferta \/|marca \/|comunidad|b2b)\b/i; + +/** + * Detecta etiquetas operativas de campañas/ads (nombres internos). + * Ej.: "TOFU_Prospecting_LAL_ES_2024", "CBO Conversión IG", "ASC | Leads WA". + */ +export function looksLikeCampaignOpsLabel(raw: string): boolean { + const s = String(raw || '').trim(); + if (s.length < 3) return true; + if (ALLOWED_SEMANTIC_LABELS.test(s)) return false; + + const lower = s.toLowerCase(); + const unders = (s.match(/_/g) || []).length; + const pipes = (s.match(/\|/g) || []).length; + if (unders >= 2 || pipes >= 1) return true; + if (/^[A-Z0-9][A-Z0-9_\-]{6,}$/.test(s) && /_/.test(s)) return true; + if (/\bact_\d+/i.test(s)) return true; + if (/^(camp(aign)?|ad[\s_-]?set|conjunto)\b/i.test(s)) return true; + + if (/^(tofu|mofu|bofu)([_\-\s].*)?$/i.test(s) && unders + pipes > 0) { + return true; + } + if ( + /\b(tofu|mofu|bofu|cbo|abo|asc|rmkt|lal)\b/i.test(lower) && + (unders >= 1 || + pipes >= 1 || + /\d{4}/.test(s) || + /\b(es|pt|en|ig|fb|meta|gads)\b/i.test(lower)) + ) { + return true; + } + + if ( + /^(tofu|mofu|bofu)\s*\/\s*(tr[aá]fico|consideraci|conversi)/i.test(lower) && + !/awareness|engagement|leads|audiencia/i.test(lower) + ) { + return true; + } + + if ( + /\b(20\d{2}|q[1-4])\b/i.test(lower) && + /\b(camp|ads?|meta|gads|fb|ig|google|cbo|asc)\b/i.test(lower) + ) { + return true; + } + + if (/^[a-z0-9]+([_-][a-z0-9]+){2,}$/i.test(s)) return true; + + return false; +} + +export function scrubOpsNoiseList(items: string[] | undefined): string[] { + if (!items?.length) return []; + return uniq( + items.filter( + (x) => !looksLikeCampaignOpsLabel(x) && String(x).trim().length >= 3 + ), + 24 + ); +} + +function looksLikeFunnelNotContentPillar(raw: string): boolean { + const s = String(raw || '').trim(); + if (looksLikeCampaignOpsLabel(s)) return true; + return /\b(tofu|mofu|bofu|retargeting|remarketing|cbo|abo|asc|lookalike|prospecting)\b/i.test( + s + ); +} + +function scrubContentPillars(items: string[] | undefined): string[] { + if (!items?.length) return []; + return uniq( + items.filter( + (x) => + !looksLikeFunnelNotContentPillar(x) && String(x).trim().length >= 3 + ), + 16 + ); +} + +/** + * Limpia campos editoriales contaminados con nombres de campaña / ops. + * Seguro en cada parse/enrich: solo quita ruido operativo. + */ +export function scrubCampaignOpsFromBrandbook(book: Brandbook): Brandbook { + const out: Brandbook = { ...book }; + out.messaging_pillars = scrubContentPillars(out.messaging_pillars); + out.pillars_rrss = scrubContentPillars(out.pillars_rrss); + + const listKeys: (keyof Brandbook)[] = [ + 'preferred_angles', + 'ads_angles', + 'slogan_variants', + 'ads_usps', + 'differentiators', + 'winning_hooks', + 'example_good', + 'proof_points', + 'linkedin_themes', + 'series_recurrentes', + 'power_words', + ]; + for (const key of listKeys) { + const cur = out[key]; + if (!Array.isArray(cur)) continue; + (out as Record)[key] = scrubOpsNoiseList(cur as string[]); + } + + for (const key of [ + 'usp_differentiators', + 'value_proposition', + 'photography_style', + 'illustration_style', + 'icon_style', + 'tone', + ] as const) { + const cur = String(out[key] || '').trim(); + if (cur && looksLikeCampaignOpsLabel(cur)) { + (out as Record)[key] = ''; + } + } + + return out; +} diff --git a/src/lib/brandbook.ts b/src/lib/brandbook.ts index 099ea7d..85525b2 100644 --- a/src/lib/brandbook.ts +++ b/src/lib/brandbook.ts @@ -8,6 +8,7 @@ import { brandSiblingNote, resolveBrandSlug, } from '@/lib/tenant-brand-aliases'; +import { scrubCampaignOpsFromBrandbook } from '@/lib/brandbook-sanitize'; export const BRANDBOOK_DOC_TYPE = 'brandbook'; export const BRANDBOOK_DOC_TITLE = 'Brandbook'; @@ -1740,7 +1741,7 @@ export function parseBrandbook(raw: unknown): Brandbook | null { return String(v); }; - return { + const parsed: Brandbook = { ...empty, ...partial, version: 2, @@ -1887,6 +1888,9 @@ export function parseBrandbook(raw: unknown): Brandbook | null { testimonials_policy: String(partial.testimonials_policy || ''), competitors_never_mention: arr(partial.competitors_never_mention), }; + + // Nombres de campaña / etiquetas ops no deben vivir en pilares, estilos, USPs, etc. + return scrubCampaignOpsFromBrandbook(parsed); } function arr(v: unknown): string[] {