fix(brandbook): no meter nombres de campaña en pilares/estilos

Los insights de ads solo aportan copy humano y ángulos semánticos;
se limpian etiquetas ops ya guardadas al parsear y al enriquecer.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-15 09:39:06 +02:00
parent d049fa0f3b
commit 89869c0e7c
4 changed files with 348 additions and 54 deletions

View File

@@ -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/.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);
if (/retarget|remarket|rmkt|warm.?audi/.test(lower)) {
angles.push('Retargeting / audiencia caliente');
}
return uniq(angles, 12);
if (/brand|branding|awareness.?brand/.test(lower)) {
angles.push('Branding / notoriedad');
}
}
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<Brandbook> = {};
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<string, unknown>)[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<string, unknown>)[key] = next;
}
} else if (!curClean.length) {
(out as Record<string, unknown>)[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<string, unknown>)[key] = next.trim();
if (!cur || looksLikeCampaignOpsLabel(cur)) {
(out as Record<string, unknown>)[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;
}

View File

@@ -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();

View File

@@ -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<string>();
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<string, unknown>)[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<string, unknown>)[key] = '';
}
}
return out;
}

View File

@@ -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[] {