Add Zona de marca brandbooks and strategist calendar generation.

Per-tenant brandbooks power ads, RRSS calendars, LinkedIn B2B and blog; Nov calendars now create from trends + client DNA instead of copying history.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-10 01:05:15 +02:00
parent 59f42eafe0
commit e49111f63e
13 changed files with 2907 additions and 500 deletions

571
src/lib/brandbook.ts Normal file
View File

@@ -0,0 +1,571 @@
/**
* Brandbook per tenant — source of truth for ads, RRSS calendars,
* LinkedIn B2B and corporate blog strategy.
* Stored in documents as type=brandbook (one active doc per tenant).
*/
import { brandBrief } from '@/lib/calendar-generate-brand';
export const BRANDBOOK_DOC_TYPE = 'brandbook';
export const BRANDBOOK_DOC_TITLE = 'Brandbook';
export type BrandbookSectionId =
| 'identidad'
| 'voz'
| 'visual'
| 'audiencia'
| 'oferta'
| 'pilares'
| 'canales'
| 'ads'
| 'b2b'
| 'blog'
| 'compliance'
| 'fuentes';
export type Brandbook = {
version: 1;
updated_at?: string;
/** Identity */
brand_name: string;
tagline: string;
mission: string;
vision: string;
values: string[];
vertical: string;
/** Voice */
tone: string;
personality: string[];
voice_do: string[];
voice_dont: string[];
vocabulary_prefer: string[];
vocabulary_avoid: string[];
/** Visual */
colors_primary: string[];
colors_secondary: string[];
fonts: string[];
visual_notes: string;
logo_usage: string;
/** Audience */
icp: string;
personas: Array<{ name: string; description: string; pains: string }>;
geo: string;
languages: string[];
/** Offer */
products_services: string[];
differentiators: string[];
proof_points: string[];
pricing_notes: string;
/** Content pillars */
pillars_rrss: string[];
series_recurrentes: string[];
formats_priority: string[];
/** Channels */
channels: {
instagram: string;
facebook: string;
linkedin: string;
tiktok: string;
blog: string;
other: string;
};
/** Ads */
ads_usps: string[];
ads_objections: string[];
ads_ctas: string[];
ads_offers: string[];
ads_angles: string[];
/** B2B / LinkedIn */
linkedin_themes: string[];
linkedin_formats: string[];
thought_leadership: string[];
buyer_roles: string[];
/** Blog corporativo */
blog_themes: string[];
blog_seo_keywords: string[];
blog_cadence: string;
/** Compliance */
claims_forbidden: string[];
legal_notes: string;
sector_rules: string;
/** Uploaded / pasted source material */
source_excerpts: Array<{
id: string;
filename: string;
excerpt: string;
added_at: string;
}>;
freeform_notes: string;
};
export const BRANDBOOK_SECTIONS: {
id: BrandbookSectionId;
label: string;
hint: string;
}[] = [
{
id: 'identidad',
label: 'Identidad',
hint: 'Nombre, misión, visión, valores, vertical',
},
{
id: 'voz',
label: 'Voz y tono',
hint: 'Cómo habla la marca; do/don’t y léxico',
},
{
id: 'visual',
label: 'Visual',
hint: 'Colores, tipografías, logo y notas de arte',
},
{
id: 'audiencia',
label: 'Audiencia',
hint: 'ICP, personas, geo e idiomas',
},
{
id: 'oferta',
label: 'Oferta',
hint: 'Productos/servicios, diferenciadores, prueba',
},
{
id: 'pilares',
label: 'Pilares de contenido',
hint: 'Series RRSS y formatos prioritarios',
},
{
id: 'canales',
label: 'Canales',
hint: 'Rol de cada red y blog',
},
{
id: 'ads',
label: 'Anuncios',
hint: 'USPs, objeciones, CTAs, ofertas y ángulos',
},
{
id: 'b2b',
label: 'LinkedIn B2B',
hint: 'Temas, formatos y thought leadership',
},
{
id: 'blog',
label: 'Blog corporativo',
hint: 'Temas, SEO y cadencia',
},
{
id: 'compliance',
label: 'Compliance',
hint: 'Claims prohibidos y notas legales',
},
{
id: 'fuentes',
label: 'Fuentes',
hint: 'Textos subidos / pegados del brandbook',
},
];
export function emptyBrandbook(label = ''): Brandbook {
return {
version: 1,
brand_name: label,
tagline: '',
mission: '',
vision: '',
values: [],
vertical: '',
tone: '',
personality: [],
voice_do: [],
voice_dont: [],
vocabulary_prefer: [],
vocabulary_avoid: [],
colors_primary: [],
colors_secondary: [],
fonts: [],
visual_notes: '',
logo_usage: '',
icp: '',
personas: [],
geo: 'España',
languages: ['es'],
products_services: [],
differentiators: [],
proof_points: [],
pricing_notes: '',
pillars_rrss: [],
series_recurrentes: [],
formats_priority: ['Reel', 'Carrusel', 'Stories', 'Post'],
channels: {
instagram: '',
facebook: '',
linkedin: '',
tiktok: '',
blog: '',
other: '',
},
ads_usps: [],
ads_objections: [],
ads_ctas: [],
ads_offers: [],
ads_angles: [],
linkedin_themes: [],
linkedin_formats: ['Carrusel', 'Documento', 'Post texto', 'Video corto'],
thought_leadership: [],
buyer_roles: [],
blog_themes: [],
blog_seo_keywords: [],
blog_cadence: '',
claims_forbidden: [],
legal_notes: '',
sector_rules: '',
source_excerpts: [],
freeform_notes: '',
};
}
/** Seed a usable brandbook from hard-coded brand DNA + sensible defaults. */
export function seedBrandbook(slug: string, label: string): Brandbook {
const brief = brandBrief(slug, label);
const base = emptyBrandbook(label);
const seeds: Record<string, Partial<Brandbook>> = {
bps: {
vertical: 'Escuela de idiomas',
tagline: 'Inglés real, cultura pop y certificaciones',
mission:
'Ayudar a alumnos a hablar inglés con confianza para trabajo, viajes y exámenes.',
tone: brief.tone,
personality: ['cercano', 'divertido', 'riguroso', 'pop'],
voice_do: [
'Explicar con ejemplos reales',
'Usar series FALSE FRIEND / SLANG / MTW',
'Micro-lecciones accionables',
],
voice_dont: [
'Listas genéricas de Black Friday',
'Tono académico seco',
'Copiar títulos mes a mes',
],
pillars_rrss: brief.pillars,
series_recurrentes: [
'FALSE FRIEND',
'SLANG',
'Same word different level',
'MTW',
'Canción del día',
'English Through TV',
'Fix it',
],
products_services: [
'Cursos generales',
'Intensivos',
'Trinity / IELTS / Cambridge',
'Inglés oposiciones',
'Madrid / Málaga',
],
ads_usps: [
'Metodología práctica',
'Profes nativos / preparados',
'Certificaciones oficiales',
],
ads_ctas: ['Test de nivel', 'Reservar plaza', 'WhatsApp'],
ads_objections: [
'No tengo tiempo',
'Ya probé y no aprendí',
'Es caro',
],
linkedin_themes: [
'Inglés profesional',
'Employability',
'Casos de alumnos',
],
blog_themes: [
'Guías de certificación',
'Errores comunes',
'Cómo elegir curso',
],
channels: {
instagram: 'Alcance + comunidad (Reels + Stories)',
facebook: 'Comunidad local / padres',
linkedin: 'B2B ligero / empleabilidad',
tiktok: 'Opcional micro-lecciones',
blog: 'SEO certificaciones y objeciones',
other: '',
},
},
'talento-y-deporte': {
vertical: brief.vertical,
tone: brief.tone,
pillars_rrss: brief.pillars,
series_recurrentes: [
'Nuevo alumno',
'Beca académica',
'Beca deportiva',
'Campus USA',
'Objeciones',
'English tips',
],
products_services: [
'Becas deportivas USA',
'Acompañamiento académico',
'Campus visits',
],
ads_usps: ['Becas reales', 'Acompañamiento end-to-end', 'Historias de alumnos'],
ads_ctas: ['Agenda llamada', 'Evalúa tu perfil'],
linkedin_themes: ['Educación internacional', 'Deporte + estudios'],
blog_themes: ['Guía becas USA', 'Requisitos TOEFL/SAT', 'Vida en campus'],
},
'halal-safety': {
vertical: brief.vertical,
tone: brief.tone,
pillars_rrss: brief.pillars,
products_services: [
'Certificación Halal',
'Consultoría exportación',
'Formación',
],
ads_usps: ['Autoridad técnica', 'Mercados export', 'Proceso claro'],
ads_ctas: ['Solicitar info', 'Auditoría'],
linkedin_themes: [
'Exportación Halal',
'Normativa',
'Casos sector alimentación/cosmética',
],
thought_leadership: [
'Tendencias mercados musulmanes',
'Compliance vs marketing Halal',
],
blog_themes: ['Guías de certificación', 'Mercados clave', 'FAQ técnica'],
buyer_roles: ['Quality', 'Export manager', 'CEO pyme alimentaria'],
},
'brava-dermoestetica': {
vertical: brief.vertical,
tone: brief.tone,
pillars_rrss: brief.pillars,
products_services: ['Peelings', 'Tratamientos facial', 'Rutinas'],
ads_usps: ['Resultados naturales', 'Equipo médico', 'Protocolos personalizados'],
ads_ctas: ['Pedir cita', 'Consulta valoración'],
claims_forbidden: [
'Promesas milagro sin matiz',
'Antes/después sin consentimiento',
],
sector_rules: 'Publicidad sanitaria: claims prudentes, sin garantías absolutas.',
},
noberplast: {
vertical: brief.vertical,
tone: brief.tone,
pillars_rrss: brief.pillars,
formats_priority: ['Carrusel', 'Post LinkedIn', 'Documento', 'Reel'],
linkedin_themes: [
'Plásticos técnicos',
'Casos industriales',
'Innovación materiales',
],
thought_leadership: [
'Sostenibilidad industrial',
'Especificaciones técnicas',
],
buyer_roles: ['Ingeniería', 'Compras', 'Producción'],
blog_themes: ['Fichas técnicas', 'Aplicaciones sectoriales', 'FAQ producto'],
channels: {
instagram: 'Marca / producto visual',
facebook: 'Bajo',
linkedin: 'Canal principal B2B',
tiktok: 'No prioritario',
blog: 'SEO técnico + captación inbound',
other: '',
},
},
'4ventures-hub': {
vertical: brief.vertical,
tone: brief.tone,
pillars_rrss: brief.pillars,
linkedin_themes: ['Ops de agencia', 'Growth', 'Casos clientes'],
thought_leadership: ['Centro de mando', 'Atribución', 'Contenido que vende'],
},
};
const resolvedSlug = slug === 'blue-parrot-school' ? 'bps' : slug;
const extra = seeds[resolvedSlug] || {
vertical: brief.vertical,
tone: brief.tone,
pillars_rrss: brief.pillars,
products_services: [],
freeform_notes: brief.notes,
};
return {
...base,
...extra,
brand_name: label,
vertical: extra.vertical || brief.vertical,
tone: extra.tone || brief.tone,
pillars_rrss: extra.pillars_rrss?.length ? extra.pillars_rrss : brief.pillars,
freeform_notes: extra.freeform_notes || brief.notes,
updated_at: new Date().toISOString(),
};
}
export function parseBrandbook(raw: unknown): Brandbook | null {
if (!raw || typeof raw !== 'object') return null;
const o = raw as Partial<Brandbook>;
if (o.version !== 1 && !o.brand_name && !o.vertical) {
// Allow loose objects that look like brandbooks
if (!('tone' in o) && !('pillars_rrss' in o) && !('mission' in o)) {
return null;
}
}
const empty = emptyBrandbook(String(o.brand_name || ''));
return {
...empty,
...o,
version: 1,
values: arr(o.values),
personality: arr(o.personality),
voice_do: arr(o.voice_do),
voice_dont: arr(o.voice_dont),
vocabulary_prefer: arr(o.vocabulary_prefer),
vocabulary_avoid: arr(o.vocabulary_avoid),
colors_primary: arr(o.colors_primary),
colors_secondary: arr(o.colors_secondary),
fonts: arr(o.fonts),
personas: Array.isArray(o.personas) ? o.personas : [],
languages: arr(o.languages).length ? arr(o.languages) : ['es'],
products_services: arr(o.products_services),
differentiators: arr(o.differentiators),
proof_points: arr(o.proof_points),
pillars_rrss: arr(o.pillars_rrss),
series_recurrentes: arr(o.series_recurrentes),
formats_priority: arr(o.formats_priority),
channels: { ...empty.channels, ...(o.channels || {}) },
ads_usps: arr(o.ads_usps),
ads_objections: arr(o.ads_objections),
ads_ctas: arr(o.ads_ctas),
ads_offers: arr(o.ads_offers),
ads_angles: arr(o.ads_angles),
linkedin_themes: arr(o.linkedin_themes),
linkedin_formats: arr(o.linkedin_formats),
thought_leadership: arr(o.thought_leadership),
buyer_roles: arr(o.buyer_roles),
blog_themes: arr(o.blog_themes),
blog_seo_keywords: arr(o.blog_seo_keywords),
claims_forbidden: arr(o.claims_forbidden),
source_excerpts: Array.isArray(o.source_excerpts) ? o.source_excerpts : [],
};
}
function arr(v: unknown): string[] {
if (!Array.isArray(v)) return [];
return v.map((x) => String(x || '').trim()).filter(Boolean);
}
/** Lines / commas → string[] for form fields. */
export function linesToList(text: string): string[] {
return text
.split(/\n|,/)
.map((s) => s.trim())
.filter(Boolean);
}
export function listToLines(list: string[]): string {
return (list || []).join('\n');
}
/** Heuristic: fold uploaded plain text into freeform + optional lists. */
export function ingestSourceText(
book: Brandbook,
filename: string,
text: string
): Brandbook {
const excerpt = text.slice(0, 12000).trim();
if (!excerpt) return book;
const id = `src_${Date.now().toString(36)}`;
const next: Brandbook = {
...book,
source_excerpts: [
{
id,
filename: filename || 'pegado.txt',
excerpt,
added_at: new Date().toISOString(),
},
...(book.source_excerpts || []),
].slice(0, 20),
freeform_notes: [book.freeform_notes, `--- ${filename} ---\n${excerpt.slice(0, 4000)}`]
.filter(Boolean)
.join('\n\n')
.slice(0, 50000),
updated_at: new Date().toISOString(),
};
// Light keyword pulls into empty fields
const lower = excerpt.toLowerCase();
if (!next.mission && /misi[oó]n/.test(lower)) {
const m = excerpt.match(/misi[oó]n[:\s]+(.{20,200})/i);
if (m) next.mission = m[1].trim();
}
if (!next.tone && /tono[:\s]+(.{10,120})/i.test(excerpt)) {
const m = excerpt.match(/tono[:\s]+(.{10,120})/i);
if (m) next.tone = m[1].trim();
}
return next;
}
/** Compact prompt block for strategists / Anthropic. */
export function brandbookToPrompt(book: Brandbook): string {
const lines = [
`Marca: ${book.brand_name}`,
book.tagline && `Tagline: ${book.tagline}`,
book.vertical && `Vertical: ${book.vertical}`,
book.mission && `Misión: ${book.mission}`,
book.tone && `Tono: ${book.tone}`,
book.personality.length && `Personalidad: ${book.personality.join(', ')}`,
book.voice_do.length && `Hacer: ${book.voice_do.join('; ')}`,
book.voice_dont.length && `Evitar: ${book.voice_dont.join('; ')}`,
book.series_recurrentes.length &&
`Series RRSS: ${book.series_recurrentes.join(', ')}`,
book.pillars_rrss.length && `Pilares: ${book.pillars_rrss.join(', ')}`,
book.products_services.length &&
`Oferta: ${book.products_services.join(', ')}`,
book.differentiators.length &&
`Diferenciadores: ${book.differentiators.join(', ')}`,
book.icp && `ICP: ${book.icp}`,
book.ads_usps.length && `USPs ads: ${book.ads_usps.join('; ')}`,
book.ads_ctas.length && `CTAs: ${book.ads_ctas.join('; ')}`,
book.ads_objections.length &&
`Objeciones: ${book.ads_objections.join('; ')}`,
book.linkedin_themes.length &&
`LinkedIn: ${book.linkedin_themes.join(', ')}`,
book.thought_leadership.length &&
`Thought leadership: ${book.thought_leadership.join(', ')}`,
book.blog_themes.length && `Blog: ${book.blog_themes.join(', ')}`,
book.blog_seo_keywords.length &&
`SEO: ${book.blog_seo_keywords.join(', ')}`,
book.claims_forbidden.length &&
`Prohibido: ${book.claims_forbidden.join('; ')}`,
book.legal_notes && `Legal: ${book.legal_notes}`,
book.freeform_notes && `Notas: ${book.freeform_notes.slice(0, 1500)}`,
].filter(Boolean);
return lines.join('\n');
}
export function brandbookCompleteness(book: Brandbook): {
score: number;
missing: string[];
} {
const checks: [string, boolean][] = [
['Identidad (misión/vertical)', !!(book.mission || book.vertical)],
['Voz/tono', !!book.tone],
['Audiencia/ICP', !!(book.icp || book.personas.length)],
['Oferta', book.products_services.length > 0],
['Pilares/series RRSS', !!(book.pillars_rrss.length || book.series_recurrentes.length)],
['Ads (USP o CTA)', !!(book.ads_usps.length || book.ads_ctas.length)],
['LinkedIn B2B o blog', !!(book.linkedin_themes.length || book.blog_themes.length)],
];
const missing = checks.filter(([, ok]) => !ok).map(([l]) => l);
const score = Math.round(
((checks.length - missing.length) / checks.length) * 100
);
return { score, missing };
}

View File

@@ -0,0 +1,125 @@
/** Shared brand DNA + title helpers for calendar strategy / generate. */
export type TenantBrandBrief = {
slug: string;
label: string;
vertical: string;
tone: string;
pillars: string[];
formats: { post: number; reel: number; stories: number; carrusel: number };
notes: string;
};
const BRANDS: Record<string, Omit<TenantBrandBrief, 'slug' | 'label'>> = {
'talento-y-deporte': {
vertical: 'Educación deportiva / becas USA',
tone: 'Inspirador, cercano, ambicioso; historias de alumnos',
pillars: [
'nuevo-alumno',
'beca-academica',
'beca-deportiva',
'campus',
'objeciones',
'tips-ingles',
],
formats: { post: 0.35, reel: 0.25, stories: 0.3, carrusel: 0.1 },
notes: 'Series Nuevo alumno, objeciones, campus USA, English tips.',
},
'halal-safety': {
vertical: 'Certificación Halal / exportación',
tone: 'Profesional, claro, autoridad técnica',
pillars: ['certificacion', 'exportacion', 'educativo', 'cifras', 'casos'],
formats: { post: 0.4, reel: 0.2, stories: 0.25, carrusel: 0.15 },
notes: 'Cifras, certificación, mercados, cosmética/alimentación Halal.',
},
'brava-dermoestetica': {
vertical: 'Clínica dermoestética',
tone: 'Cercano, experto, estacional (piel / tratamientos)',
pillars: ['tratamiento', 'educativo', 'antes-despues', 'promos', 'equipo'],
formats: { post: 0.3, reel: 0.25, stories: 0.3, carrusel: 0.15 },
notes: 'Peelings, rutinas, encuestas stories, trends, citas.',
},
bps: {
vertical: 'Escuela de idiomas',
tone: 'Educativo, pop culture, series (FALSE FRIEND, SLANG, MTW…)',
pillars: [
'educativo',
'slang',
'false-friend',
'certificaciones',
'cursos',
'cultura-pop',
],
// Target mix for growth 2026 (not legacy 2% reels)
formats: { post: 0.2, reel: 0.28, stories: 0.36, carrusel: 0.16 },
notes:
'ADN BPS: FALSE FRIEND, SLANG, Same word, canción, TV shows, matriculación Madrid/Málaga, Trinity/IELTS — crear ángulos nuevos, no repetir títulos.',
},
'blue-parrot-school': {
vertical: 'Escuela de idiomas',
tone: 'Educativo, pop culture, series recurrentes',
pillars: [
'educativo',
'slang',
'false-friend',
'certificaciones',
'cursos',
'cultura-pop',
],
formats: { post: 0.2, reel: 0.28, stories: 0.36, carrusel: 0.16 },
notes: 'Misma línea que BPS.',
},
noberplast: {
vertical: 'Plásticos industriales B2B',
tone: 'Técnico, B2B, LinkedIn-first',
pillars: ['producto', 'casos', 'industria', 'linkedin'],
formats: { post: 0.45, reel: 0.15, stories: 0.1, carrusel: 0.3 },
notes: 'Más LinkedIn y carruseles técnicos.',
},
'4ventures-hub': {
vertical: 'Agencia marketing',
tone: 'Directo, ops, thought leadership',
pillars: ['ops', 'casos', 'tips', 'equipo'],
formats: { post: 0.35, reel: 0.25, stories: 0.2, carrusel: 0.2 },
notes: 'Contenido de agencia.',
},
};
export function brandBrief(slug: string, label: string): TenantBrandBrief {
const base = BRANDS[slug] || {
vertical: 'Marca / servicios',
tone: 'Profesional y claro',
pillars: ['educativo', 'producto', 'comunidad', 'promo'],
formats: { post: 0.3, reel: 0.25, stories: 0.3, carrusel: 0.15 },
notes: 'Ajustar al ADN del cliente.',
};
return { slug, label, ...base };
}
export function cleanEditorialTitle(raw: string): string {
return String(raw || '')
.replace(/\s*\([^)]*\b\d+\s*(?:[.,]\d+)?\s*h(?:oras?)?[^)]*\)/gi, '')
.replace(/\s*\([^)]*\b\d+\s*min[^)]*\)/gi, '')
.replace(/\s*\(\s*0?\s*[hH]\s*\)/g, '')
.replace(/\s+/g, ' ')
.trim();
}
export function isJunkPreviewTitle(title: string): boolean {
const t = title.toLowerCase();
if (/^post\s+\d+\s*·/.test(t)) return true;
if (/^reel\s*·\s*(feed|stories|nuevo)/.test(t)) return true;
if (/^stories\s*·\s*(black friday|puente|cierre|feed|stories)/.test(t)) {
return true;
}
if (
/black friday|cierre de año comercial|puente$|· puente|· black friday/.test(
t
) &&
/^(post|reel|stories)\b/.test(t)
) {
return true;
}
if (/carrusel stories|carrusel feed/.test(t)) return true;
return false;
}

View File

@@ -1,176 +1,53 @@
/**
* Generate a month editorial preview from tenant history + light seasonality.
* Priority: same-month prior year → recent months → brand defaults.
* Uses Anthropic when ANTHROPIC_API_KEY is set; otherwise history remix.
* Month editorial preview — social strategist (trends + client DNA).
* Creates new angles; does not copy historical calendars day-for-day.
* Uses Anthropic when ANTHROPIC_API_KEY is set; else strategy heuristic.
*/
import type { RrssMonthPlan, RrssPiece } from '@/components/RrssMonthView';
import { emptyMonthPlan, newPieceId } from '@/lib/calendar-documents';
import { monthLabel } from '@/lib/calendar-import';
import {
brandBrief,
cleanEditorialTitle,
isJunkPreviewTitle,
} from '@/lib/calendar-generate-brand';
import {
extractClientDna,
monthTrendBeats,
strategistSystemPrompt,
strategyMonthPreview,
} from '@/lib/calendar-strategy';
import type { Brandbook } from '@/lib/brandbook';
export type TenantBrandBrief = {
export {
brandBrief,
cleanEditorialTitle,
isJunkPreviewTitle,
type TenantBrandBrief,
} from '@/lib/calendar-generate-brand';
export type GeneratePreviewInput = {
ym: string;
slug: string;
label: string;
vertical: string;
tone: string;
pillars: string[];
formats: { post: number; reel: number; stories: number; carrusel: number };
notes: string;
history: RrssMonthPlan[];
brandbook?: Brandbook | null;
};
const BRANDS: Record<string, Omit<TenantBrandBrief, 'slug' | 'label'>> = {
'talento-y-deporte': {
vertical: 'Educación deportiva / becas USA',
tone: 'Inspirador, cercano, ambicioso; historias de alumnos',
pillars: [
'nuevo-alumno',
'beca-academica',
'beca-deportiva',
'campus',
'objeciones',
'tips-ingles',
],
formats: { post: 0.45, reel: 0.15, stories: 0.3, carrusel: 0.1 },
notes: 'Series Nuevo alumno, objeciones, campus USA, English tips.',
},
'halal-safety': {
vertical: 'Certificación Halal / exportación',
tone: 'Profesional, claro, autoridad técnica',
pillars: ['certificacion', 'exportacion', 'educativo', 'cifras', 'casos'],
formats: { post: 0.5, reel: 0.1, stories: 0.25, carrusel: 0.15 },
notes: 'Cifras, certificación, mercados, cosmética/alimentación Halal.',
},
'brava-dermoestetica': {
vertical: 'Clínica dermoestética',
tone: 'Cercano, experto, estacional (piel / tratamientos)',
pillars: ['tratamiento', 'educativo', 'antes-despues', 'promos', 'equipo'],
formats: { post: 0.35, reel: 0.2, stories: 0.3, carrusel: 0.15 },
notes: 'Peelings, rutinas, encuestas stories, trends, citas.',
},
bps: {
vertical: 'Escuela de idiomas',
tone: 'Educativo, pop culture, series recurrentes (FALSE FRIEND, SLANG, MTW…)',
pillars: [
'educativo',
'slang',
'false-friend',
'certificaciones',
'cursos',
'cultura-pop',
],
formats: { post: 0.42, reel: 0.02, stories: 0.54, carrusel: 0.02 },
notes:
'Línea BPS: FALSE FRIEND, SLANG, Same word different level, canción del día, TV shows, matriculación Madrid/Málaga, Trinity/IELTS, oposiciones.',
},
'blue-parrot-school': {
vertical: 'Escuela de idiomas',
tone: 'Educativo, pop culture, series recurrentes',
pillars: [
'educativo',
'slang',
'false-friend',
'certificaciones',
'cursos',
'cultura-pop',
],
formats: { post: 0.42, reel: 0.02, stories: 0.54, carrusel: 0.02 },
notes: 'Misma línea que BPS.',
},
noberplast: {
vertical: 'Plásticos industriales B2B',
tone: 'Técnico, B2B, LinkedIn-first',
pillars: ['producto', 'casos', 'industria', 'linkedin'],
formats: { post: 0.5, reel: 0.1, stories: 0.1, carrusel: 0.3 },
notes: 'Más LinkedIn y carruseles técnicos.',
},
'4ventures-hub': {
vertical: 'Agencia marketing',
tone: 'Directo, ops, thought leadership',
pillars: ['ops', 'casos', 'tips', 'equipo'],
formats: { post: 0.4, reel: 0.2, stories: 0.2, carrusel: 0.2 },
notes: 'Contenido de agencia.',
},
export type GeneratePreviewResult = {
plan: RrssMonthPlan;
rationale: string;
source: 'anthropic' | 'strategy' | 'heuristic';
};
export function brandBrief(slug: string, label: string): TenantBrandBrief {
const base = BRANDS[slug] || {
vertical: 'Marca / servicios',
tone: 'Profesional y claro',
pillars: ['educativo', 'producto', 'comunidad', 'promo'],
formats: { post: 0.45, reel: 0.1, stories: 0.35, carrusel: 0.1 },
notes: 'Ajustar al histórico del cliente.',
};
return { slug, label, ...base };
}
/** Light season cues — never the main title unless history also used them. */
function seasonHints(ym: string): string[] {
const m = Number(ym.slice(5, 7));
const map: Record<number, string[]> = {
1: ['año nuevo', 'propósitos'],
2: ['San Valentín'],
5: ['Día de la Madre', 'pre-verano'],
6: ['verano', 'intensivos'],
7: ['verano', 'summer camp'],
8: ['verano', 'matriculación'],
9: ['vuelta al cole', 'matriculación'],
10: ['Halloween', 'otoño'],
11: ['Black Friday', 'invierno'],
12: ['Navidad'],
};
return map[m] || [];
}
function weekdaysInMonth(ym: string): string[] {
const [y, m] = ym.split('-').map(Number);
const days: string[] = [];
const dim = new Date(Date.UTC(y, m, 0)).getUTCDate();
for (let d = 1; d <= dim; d++) {
const dt = new Date(Date.UTC(y, m - 1, d));
const wd = dt.getUTCDay();
if (wd >= 1 && wd <= 5) {
days.push(`${ym}-${String(d).padStart(2, '0')}`);
}
}
return days;
}
function colorFor(format: string, kind: string, platform: string): string {
if (kind === 'story' || /stor/i.test(format)) return '#374151';
if (/linkedin/i.test(platform)) return '#7C66B8';
if (/reel/i.test(format)) return '#7C66B8';
if (/carrusel/i.test(format)) return '#0F9D8F';
return '#2071cf';
}
/** Strip production hour notes: (2h), (30min), OH, etc. */
export function cleanEditorialTitle(raw: string): string {
return String(raw || '')
.replace(/\s*\([^)]*\b\d+\s*(?:[.,]\d+)?\s*h(?:oras?)?[^)]*\)/gi, '')
.replace(/\s*\([^)]*\b\d+\s*min[^)]*\)/gi, '')
.replace(/\s*\(\s*0?\s*[hH]\s*\)/g, '')
.replace(/\s+/g, ' ')
.trim();
}
/** Detect titles from the old broken heuristic (season placeholders). */
export function isJunkPreviewTitle(title: string): boolean {
const t = title.toLowerCase();
if (/^post\s+\d+\s*·/.test(t)) return true;
if (/^reel\s*·\s*(feed|stories|nuevo)/.test(t)) return true;
if (/^stories\s*·\s*(black friday|puente|cierre|feed|stories)/.test(t)) {
return true;
}
if (
/black friday|cierre de año comercial|puente$|· puente|· black friday/.test(
t
) &&
/^(post|reel|stories)\b/.test(t)
) {
return true;
}
if (/carrusel stories|carrusel feed/.test(t)) return true;
return false;
}
function kindOf(format?: string, kind?: string): string {
const hay = `${format || ''} ${kind || ''}`.toLowerCase();
if (hay.includes('stor')) return 'story';
@@ -179,349 +56,42 @@ function kindOf(format?: string, kind?: string): string {
return 'post';
}
function formatLabel(kind: string): string {
if (kind === 'story') return 'Stories';
if (kind === 'reel') return 'Reel';
if (kind === 'carrusel') return 'Carrusel';
return 'Post';
}
type HistPiece = {
title: string;
format: string;
kind: string;
pillar: string;
platform: string;
time?: string;
hook?: string;
body?: string;
cta?: string;
day: number;
};
type HistoryModel = {
avgPerWeek: number;
storiesRatio: number;
poolByKind: Record<string, HistPiece[]>;
sameMonth: HistPiece[];
sampleTitles: string[];
sourceMonths: string[];
};
function collectHistory(plans: RrssMonthPlan[], targetYm: string): HistoryModel {
const targetMonth = targetYm.slice(5, 7);
const poolByKind: Record<string, HistPiece[]> = {
post: [],
story: [],
reel: [],
carrusel: [],
};
const sameMonth: HistPiece[] = [];
const sourceMonths: string[] = [];
let total = 0;
let stories = 0;
const sorted = [...plans].sort((a, b) =>
String(b.month).localeCompare(String(a.month))
);
for (const plan of sorted) {
if (!plan.pieces?.length) continue;
let used = 0;
for (const p of plan.pieces) {
const title = cleanEditorialTitle(p.title || '');
if (!title || isJunkPreviewTitle(title)) continue;
// Skip pure season-only junk that sneaked in as body-less placeholders
if (/^(black friday|puente|cierre de año)/i.test(title)) continue;
const kind = kindOf(p.format, p.kind);
const day = Number(String(p.date || '').slice(8, 10)) || 0;
const row: HistPiece = {
title,
format: p.format || formatLabel(kind),
kind,
pillar: (p.pillar || (kind === 'story' ? 'stories' : 'feed')).toLowerCase(),
platform: String(p.platform || 'instagram').toLowerCase(),
time: p.time,
hook: p.hook ? cleanEditorialTitle(p.hook) : undefined,
body: p.body,
cta: p.cta,
day,
};
poolByKind[kind] = poolByKind[kind] || [];
poolByKind[kind].push(row);
total += 1;
if (kind === 'story') stories += 1;
used += 1;
if (String(plan.month).slice(5, 7) === targetMonth) {
sameMonth.push(row);
}
}
if (used > 0) sourceMonths.push(plan.month);
}
const months = Math.max(1, sourceMonths.length);
const avgPerWeek = Math.max(
3,
Math.min(8, Math.round(total / months / 4) || 5)
);
return {
avgPerWeek,
storiesRatio: total ? stories / total : 0.5,
poolByKind,
sameMonth: sameMonth.sort((a, b) => a.day - b.day),
sampleTitles: [
...sameMonth.map((p) => p.title),
...poolByKind.post.map((p) => p.title),
...poolByKind.story.map((p) => p.title),
]
.filter(Boolean)
.slice(0, 24),
sourceMonths,
};
}
function pickFromPool(
pool: HistPiece[],
used: Set<string>,
i: number
): HistPiece | null {
if (!pool.length) return null;
for (let k = 0; k < pool.length; k++) {
const cand = pool[(i + k) % pool.length];
const key = cand.title.toLowerCase();
if (!used.has(key)) {
used.add(key);
return cand;
}
}
// Allow reuse if pool exhausted
return pool[i % pool.length];
}
function defaultSeries(brand: TenantBrandBrief, kind: string, i: number): string {
const series =
brand.slug === 'bps' || brand.slug === 'blue-parrot-school'
? kind === 'story'
? [
'FALSE FRIEND',
'SLANG',
'Cancion del día',
'English Through TV Shows',
'Encuesta esto o lo otro',
'Mind the word',
'Spelling',
'Story error',
'Nuevo curso Madrid y Málaga',
]
: [
'Same word different level',
'Vocabulario temático',
'Expresiones útiles',
'Cultura pop / series',
'Certificaciones (Trinity / IELTS)',
'Nuevo curso Madrid y Málaga',
'Errores comunes al aprender inglés',
'Idioms',
]
: brand.pillars.map((p) => p.replace(/-/g, ' '));
return series[i % series.length];
}
function pieceFromHist(
date: string,
src: HistPiece,
brand: TenantBrandBrief
): RrssPiece {
const kind = src.kind;
const format = src.format || formatLabel(kind);
return {
id: newPieceId(date, src.title),
date,
time: src.time || (kind === 'story' ? '09:00' : '10:30'),
platform: src.platform.includes('linkedin') ? 'linkedin' : 'instagram',
format,
pillar: src.pillar,
title: src.title.slice(0, 160),
hook: (src.hook || src.title).slice(0, 180),
body: [
`Propuesta editorial · ${brand.label}`,
`Serie / ángulo: ${src.title}`,
'Escribe aquí el copy final. Sin notas internas (WhatsApp, PENDIENTE, nombres).',
].join('\n\n'),
cta:
src.cta ||
(kind === 'story'
? 'Responde a la story'
: 'Enlace en la bio / test de nivel'),
status: 'planned',
kind,
color: colorFor(format, kind, src.platform),
};
}
export type GeneratePreviewInput = {
ym: string;
slug: string;
label: string;
history: RrssMonthPlan[];
};
export type GeneratePreviewResult = {
plan: RrssMonthPlan;
rationale: string;
source: 'anthropic' | 'heuristic';
};
function heuristicPreview(input: GeneratePreviewInput): GeneratePreviewResult {
const brand = brandBrief(input.slug, input.label);
const hist = collectHistory(input.history, input.ym);
const season = seasonHints(input.ym);
const weekdays = weekdaysInMonth(input.ym);
const usedTitles = new Set<string>();
const pieces: RrssPiece[] = [];
// 1) Prefer same-month prior year as skeleton (remap day → new month dates)
if (hist.sameMonth.length >= 6) {
for (const src of hist.sameMonth) {
const day = Math.min(
src.day || 1,
Number(weekdays[weekdays.length - 1]?.slice(8, 10) || 28)
);
const date = `${input.ym}-${String(day).padStart(2, '0')}`;
// Only weekdays
if (!weekdays.includes(date)) {
// snap to nearest weekday in month
const near =
weekdays.find((d) => Number(d.slice(8, 10)) >= day) ||
weekdays[weekdays.length - 1];
if (!near) continue;
pieces.push(pieceFromHist(near, src, brand));
} else {
pieces.push(pieceFromHist(date, src, brand));
}
usedTitles.add(src.title.toLowerCase());
}
}
// 2) If thin history, build cadence from recent pools (Stories/Post mix)
if (pieces.length < hist.avgPerWeek * 3) {
const target = Math.min(
weekdays.length,
Math.max(hist.avgPerWeek * 4, pieces.length + 8)
);
const storiesTarget = Math.round(target * (hist.storiesRatio || brand.formats.stories));
let storiesPlaced = pieces.filter((p) => p.kind === 'story').length;
let i = 0;
for (const date of weekdays) {
if (pieces.length >= target) break;
if (pieces.some((p) => p.date === date)) continue;
const wantStory = storiesPlaced < storiesTarget;
const kind = wantStory ? 'story' : 'post';
const pool =
hist.poolByKind[kind]?.length > 0
? hist.poolByKind[kind]
: hist.poolByKind.post || [];
let src = pickFromPool(pool, usedTitles, i);
if (!src) {
const title = defaultSeries(brand, kind, i);
src = {
title,
format: formatLabel(kind),
kind,
pillar: kind === 'story' ? 'stories' : 'feed',
platform: 'instagram',
day: Number(date.slice(8, 10)),
};
}
pieces.push(pieceFromHist(date, src, brand));
if (kind === 'story') storiesPlaced += 1;
i += 1;
}
}
// 3) Never invent season titles. History already carries real seasonal beats
// (e.g. BPS Nov → invierno / Navidad / Trinity). Do not append "Black Friday".
void season;
pieces.sort((a, b) => a.date.localeCompare(b.date));
// Dedupe same day+title
const seen = new Set<string>();
const uniq = pieces.filter((p) => {
const k = `${p.date}|${p.title.toLowerCase()}`;
if (seen.has(k)) return false;
seen.add(k);
return true;
});
const plan = emptyMonthPlan(input.ym, input.label);
plan.hero.lede = hist.sameMonth.length
? `Remix del histórico ${monthLabel(input.ym).split(' ')[0]} (años anteriores) + series recurrentes de ${brand.label}.`
: `Remix del histórico reciente de ${brand.label}. Ajusta títulos y copy.`;
plan.hero.chips = [
`${uniq.length} piezas`,
hist.sameMonth.length
? `base ${hist.sourceMonths.find((m) => m.slice(5, 7) === input.ym.slice(5, 7)) || 'mismo mes'}`
: 'histórico reciente',
brand.vertical,
];
plan.pieces = uniq;
const rationale = [
`Línea editorial de ${brand.label}: ${brand.notes}`,
hist.sameMonth.length
? `Plantilla principal = mismo mes de años anteriores (${hist.sameMonth.length} piezas base).`
: hist.sampleTitles.length
? `Sin mismo mes previo → remix de ${hist.sourceMonths.slice(0, 4).join(', ')}.`
: 'Sin histórico útil → series por defecto de la vertical.',
`Cadencia ~${hist.avgPerWeek} piezas/semana · ~${Math.round(
(hist.storiesRatio || 0) * 100
)}% stories.`,
hist.sampleTitles.length
? `Ejemplos históricos: ${hist.sampleTitles.slice(0, 6).join(' · ')}.`
: '',
'La estacionalidad solo aparece si ya estaba en el histórico del mismo mes (no se inventa Black Friday genérico).',
]
.filter(Boolean)
.join(' ');
return { plan, rationale, source: 'heuristic' };
}
async function anthropicPreview(
async function anthropicStrategist(
input: GeneratePreviewInput,
fallback: GeneratePreviewResult
): Promise<GeneratePreviewResult> {
const key = process.env.ANTHROPIC_API_KEY?.trim();
if (!key || key.startsWith('replace-')) return fallback;
const brand = brandBrief(input.slug, input.label);
const hist = collectHistory(input.history, input.ym);
const season = seasonHints(input.ym);
const prompt = `Eres planner RRSS de 4Ventures para ${brand.label} (${input.ym}).
Vertical: ${brand.vertical}. Tono: ${brand.tone}.
Línea editorial: ${brand.notes}
Cadencia histórica: ~${hist.avgPerWeek} piezas/semana, ~${Math.round(
(hist.storiesRatio || 0.5) * 100
)}% stories.
Títulos REALES del histórico (úsalos como plantilla; remix, no inventes Black Friday genérico):
${hist.sampleTitles.slice(0, 20).join('\n') || '(sin histórico)'}
Estacionalidad opcional (máx 1-2 piezas si encaja con la marca): ${
season.join(', ') || 'ninguna'
}.
Devuelve SOLO JSON: { "rationale": string, "pieces": [{ "date":"YYYY-MM-DD", "time":"HH:MM", "platform":"instagram", "format":"Post|Stories|Reel|Carrusel", "pillar":"feed|stories", "title":string, "hook":string, "body":string, "cta":string }] }
Reglas:
- Títulos al estilo del histórico (FALSE FRIEND, SLANG, Same word…, canción, matriculación, etc. si es BPS).
- PROHIBIDO títulos tipo "POST 1 · Black Friday" o "STORIES · puente".
- 14-28 piezas en laborables; body = notas de producción del ángulo, no caption final largo.
- Español.`;
const cleanHistory = input.history
.map((plan) => ({
...plan,
pieces: (plan.pieces || []).filter(
(p) => p.title && !isJunkPreviewTitle(p.title)
),
}))
.filter((p) => (p.pieces?.length || 0) >= 3);
const dna = extractClientDna(
input.slug,
input.label,
cleanHistory,
input.brandbook
);
const trends = monthTrendBeats(input.ym, dna.brand.vertical);
const prompt = strategistSystemPrompt({
ym: input.ym,
label: input.label,
dna,
trends,
});
try {
const Anthropic = (await import('@anthropic-ai/sdk')).default;
const client = new Anthropic({ apiKey: key });
const msg = await client.messages.create({
model: 'claude-sonnet-4-20250514',
max_tokens: 4000,
max_tokens: 4500,
messages: [{ role: 'user', content: prompt }],
});
const text = msg.content
@@ -531,9 +101,11 @@ Reglas:
if (!jsonMatch) return fallback;
const parsed = JSON.parse(jsonMatch[0]) as {
rationale?: string;
pieces?: Array<Partial<RrssPiece>>;
pieces?: Array<Partial<RrssPiece> & { objective_metric?: string }>;
};
if (!Array.isArray(parsed.pieces) || !parsed.pieces.length) return fallback;
if (!Array.isArray(parsed.pieces) || parsed.pieces.length < 8) {
return fallback;
}
const pieces: RrssPiece[] = parsed.pieces
.map((p) => {
@@ -549,7 +121,7 @@ Reglas:
return {
id: newPieceId(date, title),
date,
time: String(p.time || '10:30').slice(0, 5),
time: String(p.time || '13:00').slice(0, 5),
platform,
format,
pillar: String(p.pillar || (kind === 'story' ? 'stories' : 'feed')),
@@ -557,6 +129,9 @@ Reglas:
hook: String(p.hook || title).slice(0, 180),
body: String(p.body || title),
cta: String(p.cta || ''),
objective_metric: p.objective_metric
? String(p.objective_metric)
: undefined,
status: 'planned',
kind,
color: colorFor(format, kind, platform),
@@ -564,10 +139,14 @@ Reglas:
})
.filter(Boolean) as RrssPiece[];
if (pieces.length < 6) return fallback;
if (pieces.length < 8) return fallback;
const plan = emptyMonthPlan(input.ym, input.label);
plan.hero.lede = 'Preview IA anclado al histórico del cliente.';
plan.hero.chips = [`${pieces.length} piezas`, 'IA + histórico', brand.vertical];
plan.hero.lede = `Estrategia IA · ${monthLabel(input.ym)} · engagement 2026 + ADN ${input.label}.`;
plan.hero.chips = [
`${pieces.length} piezas`,
`${pieces.filter((p) => /reel/i.test(p.format || '')).length} Reels`,
'IA strategist',
];
plan.pieces = pieces.sort((a, b) => a.date.localeCompare(b.date));
return {
plan,
@@ -582,7 +161,6 @@ Reglas:
export async function generateMonthPreview(
input: GeneratePreviewInput
): Promise<GeneratePreviewResult> {
// Drop junk months (previous bad previews) from history
const cleanHistory = input.history
.map((plan) => ({
...plan,
@@ -592,8 +170,18 @@ export async function generateMonthPreview(
}))
.filter((p) => (p.pieces?.length || 0) >= 3);
const fallback = heuristicPreview({ ...input, history: cleanHistory });
return anthropicPreview({ ...input, history: cleanHistory }, fallback);
const strategist = strategyMonthPreview({
...input,
history: cleanHistory,
brandbook: input.brandbook,
});
const fallback: GeneratePreviewResult = {
plan: strategist.plan,
rationale: strategist.rationale,
source: 'strategy',
};
return anthropicStrategist({ ...input, history: cleanHistory }, fallback);
}
export function mergePreviewIntoPlan(
@@ -605,7 +193,9 @@ export function mergePreviewIntoPlan(
return preview;
}
const occupied = new Set(existing.pieces.map((p) => p.date.slice(0, 10)));
const extras = preview.pieces.filter((p) => !occupied.has(p.date.slice(0, 10)));
const extras = preview.pieces.filter(
(p) => !occupied.has(p.date.slice(0, 10))
);
return {
...existing,
pieces: [...existing.pieces, ...extras].sort((a, b) =>

View File

@@ -0,0 +1,755 @@
/**
* Social media strategist for editorial month previews.
* Creates NEW angles from: client DNA + month trends + 2026 engagement playbook.
* Does NOT copy historical titles day-for-day.
*/
import type { RrssMonthPlan, RrssPiece } from '@/components/RrssMonthView';
import { emptyMonthPlan, newPieceId } from '@/lib/calendar-documents';
import { monthLabel } from '@/lib/calendar-import';
import {
brandBrief,
cleanEditorialTitle,
type TenantBrandBrief,
} from '@/lib/calendar-generate-brand';
import {
brandbookToPrompt,
type Brandbook,
} from '@/lib/brandbook';
export type ClientDna = {
brand: TenantBrandBrief;
/** Recurring series names inferred from history (as DNA, not to copy). */
signatureSeries: string[];
storiesRatio: number;
avgPerWeek: number;
conversionPillars: string[];
voiceNotes: string;
/** Full brandbook prompt block when available. */
brandbookPrompt?: string;
};
export type TrendBeat = {
id: string;
label: string;
why: string;
formats: Array<'Reel' | 'Carrusel' | 'Stories' | 'Post'>;
kpi: 'alcance' | 'guardados' | 'comentarios' | 'compartidos' | 'conversion';
};
/** Spain / IG 2026 playbook (IAB Spain 2026 + education Reels research). */
export const PLAYBOOK_2026 = {
principle:
'Menos volumen, más relevancia (marcas ES −30% posts / +70% eficiencia). Reels = alcance; Carrusel = guardados; Stories = conversación.',
mix: {
/** Target share of pieces */
reel: 0.28,
carrusel: 0.18,
stories: 0.38,
post: 0.16,
},
hours: {
reel: '19:30',
carrusel: '11:00',
stories: '09:30',
post: '13:00',
} as Record<string, string>,
engagementHooks: [
'Test your friend / envía esto a…',
'Error que todos cometen',
'Micro-lección completa en <30s',
'Pronunciación nativa + caption SEO',
'Encuesta / caja de respuestas',
'Antes vs después / nivel A2→C1',
'Lo que nadie te cuenta de…',
],
};
export function monthTrendBeats(ym: string, vertical: string): TrendBeat[] {
const m = Number(ym.slice(5, 7));
const edu = /idioma|colegio|educaci|beca|alumno/i.test(vertical);
const clinic = /dermo|clínic|estetic/i.test(vertical);
const b2b = /b2b|industrial|plástic|certificaci[oó]n halal/i.test(vertical);
const commonNov: TrendBeat[] = [
{
id: 'reels-micro',
label: 'Micro-lección Reel (hook 3s + payoff)',
why: 'Reels lideran engagement 2026 en IG ES (+31% likes vs estático).',
formats: ['Reel'],
kpi: 'alcance',
},
{
id: 'carousel-save',
label: 'Carrusel “guarda esto” / test a un amigo',
why: 'Carruseles multiplican guardados; algoritmo premia saves y shares.',
formats: ['Carrusel'],
kpi: 'guardados',
},
{
id: 'stories-chat',
label: 'Stories conversación (encuesta / caja)',
why: 'Stories 2026: más replies (+88%), menos alcance → comunidad y DM.',
formats: ['Stories'],
kpi: 'comentarios',
},
];
if (m === 11) {
const nov: TrendBeat[] = [
...commonNov,
{
id: 'nov-cold-travel',
label: edu
? 'Frases para viajar / trabajar en invierno (EN/idioma)'
: 'Temporada fría / rutina noviembre',
why: 'Intención estacional real (viajes, puentes, indoor) sin forzar Black Friday genérico.',
formats: ['Reel', 'Carrusel', 'Stories'],
kpi: 'compartidos',
},
{
id: 'nov-bf-value',
label: edu
? 'Oferta formación con valor (no spam BF): packs / matriculación inteligente'
: b2b
? 'Cierre de año B2B / presupuesto 2027'
: 'Black Friday con utilidad (checklist, no solo descuento)',
why: 'Noviembre comercial; conversión solo si aporta utilidad y prueba social.',
formats: ['Carrusel', 'Stories', 'Reel'],
kpi: 'conversion',
},
{
id: 'nov-thanks-us',
label: edu
? 'Cultura US: Thanksgiving vocab / false friends festivos'
: 'Gratitud / comunidad de noviembre',
why: 'Anglo culture beat mid-late Nov → alto share en escuelas de idiomas.',
formats: ['Reel', 'Stories', 'Post'],
kpi: 'compartidos',
},
{
id: 'nov-xmas-soft',
label: edu
? 'Soft Navidad: tradiciones por país / spelling festivo'
: clinic
? 'Piel en frío + prep fiestas'
: 'Cierre de mes / preview diciembre',
why: 'Última semana Nov: puente emocional a Navidad sin saturar.',
formats: ['Carrusel', 'Stories', 'Reel'],
kpi: 'guardados',
},
{
id: 'nov-search-seo',
label: 'Contenido buscable (keywords en caption + texto en Reel)',
why: 'IG 2026 prioriza SEO interno: “cómo se dice…”, “diferencia entre…”.',
formats: ['Reel', 'Carrusel'],
kpi: 'alcance',
},
];
return nov;
}
// Generic month beats
return [
...commonNov,
{
id: 'season',
label: `Ángulo estacional ${monthLabel(ym).split(' ')[0]}`,
why: 'Anclar el mes al contexto del usuario sin clichés vacíos.',
formats: ['Reel', 'Stories', 'Carrusel'],
kpi: 'compartidos',
},
];
}
function weekdaysInMonth(ym: string): string[] {
const [y, m] = ym.split('-').map(Number);
const days: string[] = [];
const dim = new Date(Date.UTC(y, m, 0)).getUTCDate();
for (let d = 1; d <= dim; d++) {
const dt = new Date(Date.UTC(y, m - 1, d));
if (dt.getUTCDay() >= 1 && dt.getUTCDay() <= 5) {
days.push(`${ym}-${String(d).padStart(2, '0')}`);
}
}
return days;
}
function colorFor(format: string, kind: string): string {
if (kind === 'story' || /stor/i.test(format)) return '#374151';
if (/reel/i.test(format)) return '#7C66B8';
if (/carrusel/i.test(format)) return '#0F9D8F';
return '#2071cf';
}
function kindOf(format: string): string {
const f = format.toLowerCase();
if (f.includes('stor')) return 'story';
if (f.includes('reel')) return 'reel';
if (f.includes('carrusel')) return 'carrusel';
return 'post';
}
/** Build DNA from history without copying titles. */
export function extractClientDna(
slug: string,
label: string,
history: RrssMonthPlan[],
brandbook?: Brandbook | null
): ClientDna {
const brand = brandBrief(slug, label);
if (brandbook) {
if (brandbook.vertical) brand.vertical = brandbook.vertical;
if (brandbook.tone) brand.tone = brandbook.tone;
if (brandbook.pillars_rrss?.length) brand.pillars = brandbook.pillars_rrss;
if (brandbook.freeform_notes || brandbook.mission) {
brand.notes = [
brandbook.mission && `Misión: ${brandbook.mission}`,
brandbook.voice_do?.length && `Hacer: ${brandbook.voice_do.join('; ')}`,
brandbook.voice_dont?.length &&
`Evitar: ${brandbook.voice_dont.join('; ')}`,
brandbook.ads_usps?.length && `USPs: ${brandbook.ads_usps.join('; ')}`,
brandbook.claims_forbidden?.length &&
`No claims: ${brandbook.claims_forbidden.join('; ')}`,
brandbook.freeform_notes?.slice(0, 800),
]
.filter(Boolean)
.join(' · ');
}
}
const seriesCount = new Map<string, number>();
let total = 0;
let stories = 0;
const months = Math.max(1, history.length);
const classify = (title: string): string | null => {
const x = cleanEditorialTitle(title)
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '');
const rules: [RegExp, string][] = [
[/false\s*friend/, 'FALSE FRIEND'],
[/\bslang\b/, 'SLANG'],
[/mind the word|\bmtw\b/, 'MTW'],
[/same word/, 'Same word different level'],
[/cancion|canción/, 'Canción del día'],
[/tv show|english through|prison break|how i met/, 'English Through TV'],
[/encuesta|interactiva|verdadero/, 'Encuesta / interactiva'],
[/spelling/, 'Spelling'],
[/fix it|error|errores/, 'Errores / Fix it'],
[/nuevo curso|matricul|intensivo/, 'Matriculación'],
[/trinity|ielts|cambridge|aptis|certific/, 'Certificaciones'],
[/oposic|policia|renfe/, 'Oposiciones'],
[/erasmus/, 'Erasmus'],
[/gym talk/, 'Gym talk'],
[/idiom/, 'Idioms'],
[/vocab|survival|expresion|expresión/, 'Vocab / expresiones'],
[/nuevo alumno|alumno nuevo/, 'Nuevo alumno'],
[/peeling|tratamiento|piel/, 'Tratamiento'],
[/halal|certificacion|export/, 'Certificación / export'],
];
for (const [re, labelS] of rules) if (re.test(x)) return labelS;
return null;
};
for (const plan of history) {
for (const p of plan.pieces || []) {
if (!p.title) continue;
total += 1;
if (/stor/i.test(p.format || '') || p.kind === 'story') stories += 1;
const s = classify(p.title);
if (s) seriesCount.set(s, (seriesCount.get(s) || 0) + 1);
}
}
const inferred = [...seriesCount.entries()]
.sort((a, b) => b[1] - a[1])
.map(([k]) => k);
// Seed brand-line series so DNA isn't only whatever appeared most recently
const seedBySlug: Record<string, string[]> = {
bps: [
'FALSE FRIEND',
'SLANG',
'Same word different level',
'MTW',
'Canción del día',
'English Through TV',
'Errores / Fix it',
'Idioms',
'Spelling',
'Vocab / expresiones',
'Certificaciones',
'Matriculación',
],
'blue-parrot-school': [
'FALSE FRIEND',
'SLANG',
'Same word different level',
'MTW',
'Canción del día',
'English Through TV',
'Errores / Fix it',
'Idioms',
'Certificaciones',
'Matriculación',
],
'talento-y-deporte': [
'Nuevo alumno',
'Vocab / expresiones',
'Campus',
'Errores / Fix it',
],
};
const fromBook = brandbook?.series_recurrentes?.length
? brandbook.series_recurrentes
: brandbook?.pillars_rrss || [];
const seed = fromBook.length ? fromBook : seedBySlug[slug] || [];
const signatureSeries = [
...seed,
...inferred.filter((s) => !seed.includes(s)),
].slice(0, 12);
const conversionPillars = [
...inferred,
...signatureSeries,
].filter(
(s, i, arr) =>
arr.indexOf(s) === i &&
/matricul|certific|oposic|nuevo alumno|tratamiento|export|promo/i.test(s)
);
return {
brand,
signatureSeries:
signatureSeries.length > 0 ? signatureSeries : brand.pillars.map((p) => p),
storiesRatio: total ? stories / total : brand.formats.stories,
avgPerWeek: Math.max(4, Math.min(8, Math.round(total / months / 4) || 5)),
conversionPillars:
conversionPillars.length > 0
? conversionPillars
: ['Matriculación', 'Certificaciones'],
voiceNotes: brand.notes,
brandbookPrompt: brandbook ? brandbookToPrompt(brandbook) : undefined,
};
}
type Slot = {
date: string;
format: 'Reel' | 'Carrusel' | 'Stories' | 'Post';
role: 'reach' | 'save' | 'chat' | 'convert' | 'authority';
};
function buildMonthSlots(ym: string, dna: ClientDna): Slot[] {
const weekdays = weekdaysInMonth(ym);
const target = Math.min(
weekdays.length,
Math.max(16, Math.round(dna.avgPerWeek * 4 * 0.85)) // quality > quantity (−15% vs legacy)
);
const mix = PLAYBOOK_2026.mix;
const counts = {
Reel: Math.round(target * mix.reel),
Carrusel: Math.round(target * mix.carrusel),
Stories: Math.round(target * mix.stories),
Post: 0,
};
counts.Post = Math.max(0, target - counts.Reel - counts.Carrusel - counts.Stories);
// Round-robin interleave so the month isn't "all Reels then all Stories"
const bags: Record<Slot['format'], number> = { ...counts };
const formats: Slot['format'][] = ['Reel', 'Carrusel', 'Stories', 'Post'];
const queue: Array<Slot['format']> = [];
while (queue.length < target) {
let added = false;
for (const f of formats) {
if (bags[f] > 0) {
queue.push(f);
bags[f] -= 1;
added = true;
if (queue.length >= target) break;
}
}
if (!added) break;
}
const convertEvery = Math.max(4, Math.floor(target / 3));
const slots: Slot[] = [];
const step = Math.max(1, Math.floor(weekdays.length / Math.max(1, queue.length)));
let dayIdx = 0;
for (let qi = 0; qi < queue.length; qi++) {
const format = queue[qi];
const date = weekdays[Math.min(dayIdx, weekdays.length - 1)];
dayIdx += step;
if (dayIdx >= weekdays.length) dayIdx = qi % weekdays.length;
// Prefer unused weekdays
let d = date;
if (slots.some((s) => s.date === d)) {
d = weekdays.find((w) => !slots.some((s) => s.date === w)) || date;
}
const isConvert =
qi > 0 &&
qi % convertEvery === 0 &&
(format === 'Stories' || format === 'Carrusel' || format === 'Post');
const role: Slot['role'] = isConvert
? 'convert'
: format === 'Reel'
? 'reach'
: format === 'Carrusel'
? 'save'
: format === 'Stories'
? 'chat'
: 'authority';
slots.push({ date: d, format, role });
}
return slots.sort((a, b) => a.date.localeCompare(b.date));
}
/** Concrete creative banks — rotate by index so each month feels fresh. */
const EDU_ANGLES: Record<string, string[]> = {
'FALSE FRIEND': [
'FALSE FRIEND · “actually” no significa “actualmente” (y te delata)',
'FALSE FRIEND · “library” vs librería: el error clásico en Madrid',
'FALSE FRIEND · Thanksgiving: “grateful” no es “gratefulidad”',
'FALSE FRIEND · “exit” no es éxito (puerta vs logro)',
'FALSE FRIEND · “carpet” no es carpeta — mini test 10s',
],
SLANG: [
'SLANG · “I’m down” = me apunto (no “estoy abajo”)',
'SLANG · “lowkey / highkey” en 20 segundos',
'SLANG · “spill the tea” sin sonar raro',
'SLANG · “ghosting” en inglés real de oficina',
'SLANG · “bet” = ok / trato hecho',
],
MTW: [
'MTW · “get” tiene 12 vidas: las 5 que sí usas',
'MTW · “set” en el gym vs en la oficina',
'MTW · “run” no es solo correr',
'MTW · “make” vs “do” en 20s (sin tabla aburrida)',
'MTW · “take” en aeropuerto, oficina y cita',
],
'Same word different level': [
'Same word · “nice” A2 vs C1 (suena a robot o a nativo)',
'Same word · “sorry” 3 niveles de disculpa',
'Same word · “interesting” cuando NO quieres decir interesante',
'Same word · “good” → alternativas que suenan nativas',
],
'Canción del día': [
'Canción del día · lyric drop + 1 expresión para guardar',
'Canción del día · pronunciación del coro (slow + nativo)',
'Canción del día · false friend escondido en el estribillo',
'Canción del día · shadowing 15s (copia al cantante)',
],
'English Through TV': [
'English Through TV · 1 escena, 3 frases que sí se usan',
'English Through TV · cómo suena el sarcasm en sitcoms',
'English Through TV · fillers nativos (like, you know) sin abusar',
'English Through TV · “I’ll be there” vs “I’m coming”',
],
Spelling: [
'Spelling · palabras de noviembre que todo el mundo escribe mal',
'Spelling bee · 4 letras vs 1 error (challenge)',
'Spelling · “definitely” y otras trampas de examen',
],
'Errores / Fix it': [
'Fix it · “I have 25 years” en 8 segundos',
'Errores · “depend of” → la forma que sí funciona',
'Fix it · “I am agree” — corrección + frase alternativa',
'Errores · “people is” vs “people are” (rápido)',
],
Idioms: [
'Idiom · “break the ice” en networking (no en neveras)',
'Idiom · “under the weather” = flojo, no meteorólogo',
'Idiom · “hit the books” antes del examen',
'Idiom · “piece of cake” cuando el speaking sale bien',
],
'Vocab / expresiones': [
'Vocab invierno · cold, chilly, freezing (y cuándo cada uno)',
'Expresiones · pedir un favor sin sonar agresivo',
'Vocab · frases para el puente / viaje de noviembre',
'Expresiones · cómo decir “no me apetece” con educación',
],
Matriculación: [
'Matriculación · 3 señales de que este es tu mes para empezar',
'Intensivos nov-dic · quién debería (y quién no)',
],
Certificaciones: [
'Trinity/IELTS · el error #1 en speaking (y cómo arreglarlo)',
'Certificado · checklist 7 días antes del examen',
],
Oposiciones: [
'Inglés oposiciones · 1 hábito de 12 min que sí suma',
],
Erasmus: [
'Erasmus · frases de supervivencia la primera semana',
],
};
const GENERIC_CONVERT = [
'Por qué noviembre es el mes smart para matricularte (sin FOMO barato)',
'Test de nivel + plan: deja de “estudiar cuando pueda”',
'Madrid vs Málaga: qué grupo te encaja este trimestre',
];
function pickAngleTitle(
series: string,
beat: TrendBeat,
index: number,
format: Slot['format']
): string {
const bank = EDU_ANGLES[series];
let core: string;
if (bank?.length) {
core = bank[index % bank.length];
} else if (beat.id.includes('thanks')) {
core = `${series} · Thanksgiving vocab que no sale en el libro`;
} else if (beat.id.includes('cold')) {
core = `${series} · frases de frío / viaje que sí se usan`;
} else if (beat.id.includes('bf') || beat.kpi === 'conversion') {
core = GENERIC_CONVERT[index % GENERIC_CONVERT.length];
} else if (/encuesta|interactiva/i.test(series)) {
const polls = [
'Encuesta · ¿“actually” o “currently”? (elige y pelea)',
'Quiz · true/false: “I’m boring” vs “I’m bored”',
'Caja de respuestas · ¿qué false friend te delató?',
'Encuesta · ¿prefieres slang UK o US?',
];
core = polls[index % polls.length];
} else {
const fallbacks = [
`${series} · el detalle que cambia cómo te perciben`,
`${series} · ángulo ${beat.label.split('(')[0].trim().slice(0, 40)}`,
`${series} · lo que tu audiencia guarda (no scrollea)`,
`${series} · prueba social + micro-historia`,
`${series} · objeción resuelta en 20s`,
`${series} · checklist accionable`,
];
core = fallbacks[index % fallbacks.length];
}
const prefix =
format === 'Reel'
? 'REEL'
: format === 'Carrusel'
? 'CARRUSEL'
: format === 'Stories'
? 'STORIES'
: 'POST';
// Avoid double prefix if bank already has series label only
if (/^(REEL|CARRUSEL|STORIES|POST)\b/i.test(core)) return core.slice(0, 140);
return `${prefix} · ${core}`.slice(0, 140);
}
/** Creative angle factory — NEW titles, DNA-aligned. */
function inventAngle(
dna: ClientDna,
slot: Slot,
beat: TrendBeat,
index: number,
/** Extra offset only for title bank rotation (dedupe). */
bankOffset = 0
): { title: string; hook: string; body: string; cta: string; pillar: string } {
const series =
dna.signatureSeries[index % Math.max(1, dna.signatureSeries.length)] ||
'Educativo';
const brand = dna.brand.label;
const edu = /idioma|colegio|educaci/i.test(dna.brand.vertical);
const hooks = PLAYBOOK_2026.engagementHooks;
const hookStyle = hooks[index % hooks.length];
const bankIndex = index + bankOffset;
if (slot.role === 'convert') {
const pillar = dna.conversionPillars[index % dna.conversionPillars.length];
const title = edu
? pickAngleTitle(pillar, beat, bankIndex, slot.format)
: `${slot.format.toUpperCase()} · ${pillar}: beneficio claro + prueba social`;
if (slot.format === 'Stories') {
return {
title: title.startsWith('STORIES')
? title
: `STORIES · ${pillar}: encuesta “¿este mes o enero?”`,
hook: 'Una pregunta. Tu respuesta abre el DM.',
body: `Conversión ${brand}. ADN: ${pillar}. Sticker encuesta/caja → DM. KPI: mensajes. Sin spam BF genérico.`,
cta: 'Responde y te orientamos',
pillar: 'conversion',
};
}
return {
title,
hook: 'La diferencia entre “lo pienso” y “ya estoy dentro”.',
body: `Conversión útil. 3 motivos + prueba social + CTA test/matricula. ADN: ${pillar}. Tendencia: ${beat.why}`,
cta: 'Link en bio / test de nivel',
pillar: 'conversion',
};
}
const title = pickAngleTitle(series, beat, bankIndex, slot.format);
if (slot.format === 'Reel') {
return {
title,
hook: `${hookStyle}`,
body: [
`Objetivo: ALCANCE (Reels 2026 ES).`,
`ADN: ${series}. Tendencia: ${beat.label} — ${beat.why}`,
`Estructura: hook 3s → micro-lección → CTA share/save.`,
`Texto en pantalla + caption con keyword buscable.`,
].join('\n'),
cta: 'Comparte con alguien que lo necesite',
pillar: 'reach',
};
}
if (slot.format === 'Carrusel') {
return {
title: title.replace(/^REEL ·/, 'CARRUSEL ·').replace(/^POST ·/, 'CARRUSEL ·'),
hook: 'Guarda esto antes de que se te olvide.',
body: [
`Objetivo: GUARDADOS + shares.`,
`ADN: ${series}. ${beat.why}`,
`5 slides: hook polémico → valor → test “envía a un amigo”.`,
].join('\n'),
cta: 'Guarda + envía a tu compañero de estudio',
pillar: 'saves',
};
}
if (slot.format === 'Stories') {
return {
title: title.replace(/^(REEL|CARRUSEL|POST) ·/, 'STORIES ·'),
hook: 'Tú eliges: A o B',
body: [
`Objetivo: REPLIES (+88% conversación Stories 2026).`,
`ADN: ${series}. Sticker encuesta / quiz / caja.`,
beat.label,
].join('\n'),
cta: 'Responde a la story',
pillar: 'stories',
};
}
return {
title: title.replace(/^(REEL|CARRUSEL|STORIES) ·/, 'POST ·'),
hook: `Lo que nadie te explica de ${series.toLowerCase()}.`,
body: [
`Feed autoridad + SEO caption (“cómo se dice…”, “diferencia entre…”).`,
`ADN ${brand}: ${series}.`,
`Tendencia: ${beat.why}`,
].join('\n'),
cta: 'Comenta tu caso',
pillar: 'feed',
};
}
export type StrategyPreviewResult = {
plan: RrssMonthPlan;
rationale: string;
source: 'strategy';
trends: TrendBeat[];
dna: ClientDna;
};
export function strategyMonthPreview(input: {
ym: string;
slug: string;
label: string;
history: RrssMonthPlan[];
brandbook?: Brandbook | null;
}): StrategyPreviewResult {
const dna = extractClientDna(
input.slug,
input.label,
input.history,
input.brandbook
);
const trends = monthTrendBeats(input.ym, dna.brand.vertical);
const slots = buildMonthSlots(input.ym, dna);
const pieces: RrssPiece[] = [];
const usedCores = new Set<string>();
const coreKey = (t: string) =>
t.replace(/^(REEL|CARRUSEL|STORIES|POST)\s*·\s*/i, '').toLowerCase();
slots.forEach((slot, i) => {
const beat = trends[i % trends.length];
const useBeat =
slot.role === 'convert'
? trends.find((t) => t.kpi === 'conversion') || beat
: Number(slot.date.slice(8, 10)) >= 24
? trends.find((t) => t.id.includes('xmas')) || beat
: beat;
let angle = inventAngle(dna, slot, useBeat, i, 0);
let bump = 0;
while (usedCores.has(coreKey(angle.title)) && bump < 20) {
bump += 1;
angle = inventAngle(dna, slot, useBeat, i, bump);
}
if (usedCores.has(coreKey(angle.title))) {
angle = {
...angle,
title: `${angle.title} · ángulo ${i + 1}`.slice(0, 140),
};
}
usedCores.add(coreKey(angle.title));
const kind = kindOf(slot.format);
const time =
PLAYBOOK_2026.hours[slot.format.toLowerCase()] ||
PLAYBOOK_2026.hours.post;
pieces.push({
id: newPieceId(slot.date, angle.title),
date: slot.date,
time,
platform: 'instagram',
format: slot.format,
pillar: angle.pillar,
title: angle.title,
hook: angle.hook,
body: angle.body,
cta: angle.cta,
status: 'planned',
kind,
color: colorFor(slot.format, kind),
objective_metric: useBeat.kpi,
});
});
const plan = emptyMonthPlan(input.ym, input.label);
plan.hero.lede = `Estrategia ${monthLabel(input.ym)} · ${dna.brand.vertical}. Creado para engagement 2026 (no copia del histórico).`;
plan.hero.chips = [
`${pieces.length} piezas`,
`${pieces.filter((p) => p.format === 'Reel').length} Reels`,
`${pieces.filter((p) => p.format === 'Carrusel').length} carruseles`,
'strategist',
];
plan.pieces = pieces;
const rationale = [
`Estrategia (no copia): DNA de ${dna.brand.label} = series ${dna.signatureSeries.slice(0, 5).join(', ') || 'vertical'}.`,
`Playbook ES 2026 (IAB): menos posts, más eficiencia · Reels alcance · Carrusel guardados · Stories conversación.`,
`Tendencias ${monthLabel(input.ym)}: ${trends
.slice(0, 4)
.map((t) => t.label)
.join(' · ')}.`,
`Horas: Reels ${PLAYBOOK_2026.hours.reel} · Carrusel ${PLAYBOOK_2026.hours.carrusel} · Stories ${PLAYBOOK_2026.hours.stories} · Post ${PLAYBOOK_2026.hours.post}.`,
`Conversión (~${pieces.filter((p) => p.pillar === 'conversion').length} piezas) alineada a ${dna.conversionPillars.join('/')}, no spam genérico.`,
dna.brandbookPrompt
? 'Brandbook activo como constraint creativo.'
: dna.voiceNotes,
].join(' ');
return { plan, rationale, source: 'strategy', trends, dna };
}
export function strategistSystemPrompt(input: {
ym: string;
label: string;
dna: ClientDna;
trends: TrendBeat[];
}): string {
return `Eres Head of Social de 4Ventures. Crea un calendario NUEVO para ${input.label} (${input.ym}).
NO copies títulos viejos. USA el ADN / brandbook como constraint creativo.
ADN series: ${input.dna.signatureSeries.join(', ')}.
Voz: ${input.dna.brand.tone}. Vertical: ${input.dna.brand.vertical}.
${input.dna.brandbookPrompt ? `\nBRANDBOOK:\n${input.dna.brandbookPrompt}\n` : ''}
Playbook 2026 ES: ${PLAYBOOK_2026.principle}
Tendencias del mes:
${input.trends.map((t) => `- ${t.label}: ${t.why}`).join('\n')}
Devuelve SOLO JSON { rationale, pieces:[{date,time,platform,format,pillar,title,hook,body,cta,objective_metric}] }
Reglas: títulos creativos y específicos; mix ~28% Reel, 18% Carrusel, 38% Stories, resto Post; horas engagement (Reels noche ~19:30, Stories mañana, Carrusel mediodía); KPI por pieza; español; sin "POST 1 · Black Friday" vacío; respeta claims prohibidos del brandbook.`;
}

View File

@@ -5,6 +5,7 @@ export type DashboardModuleId =
| 'crm'
| 'seguimiento'
| 'estrategia'
| 'marca'
| 'calendario'
| 'ad-tracker'
| 'social'
@@ -51,6 +52,13 @@ export const DASHBOARD_MODULES: DashboardModuleDef[] = [
providers: [],
hint: 'Targets y presupuesto',
},
{
id: 'marca',
href: '/dashboard/marca',
label: 'Zona de marca',
providers: [],
hint: 'Brandbook: voz, oferta, ads, LinkedIn, blog',
},
{
id: 'calendario',
href: '/dashboard/calendario',