From 59f42eafe04971c5d8926eb9c7b4535fb889b812 Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Fri, 9 Oct 2026 23:57:41 +0200 Subject: [PATCH] Anchor calendar previews to each client historical editorial line. Remix same-month prior years and recurring series instead of generic season placeholders like Black Friday. Co-authored-by: Cursor --- scripts/regen_bps_nov_preview.ts | 64 +++ src/app/api/calendar/preview/route.ts | 10 +- src/lib/calendar-generate.ts | 574 ++++++++++++++++++-------- 3 files changed, 473 insertions(+), 175 deletions(-) create mode 100644 scripts/regen_bps_nov_preview.ts diff --git a/scripts/regen_bps_nov_preview.ts b/scripts/regen_bps_nov_preview.ts new file mode 100644 index 0000000..9f955fd --- /dev/null +++ b/scripts/regen_bps_nov_preview.ts @@ -0,0 +1,64 @@ +import { config } from 'dotenv'; +import { resolve } from 'path'; +config({ path: resolve(process.cwd(), '.env.local') }); +import { generateMonthPreview } from '../src/lib/calendar-generate'; +import type { RrssMonthPlan } from '../src/components/RrssMonthView'; + +async function main() { + const url = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const key = process.env.SUPABASE_SERVICE_ROLE_KEY!; + const h = { apikey: key, Authorization: `Bearer ${key}` }; + const tid = '8bec977a-6032-4759-aa7f-b5042081f450'; + + const r = await fetch( + `${url}/rest/v1/documents?tenant_id=eq.${tid}&type=eq.calendar&select=id,content&order=created_at.desc&limit=48`, + { headers: h } + ); + const docs = (await r.json()) as { id: string; content: unknown }[]; + const history: RrssMonthPlan[] = []; + let novId: string | null = null; + for (const d of docs) { + const c = + typeof d.content === 'string' ? JSON.parse(d.content) : d.content; + const plan = c as RrssMonthPlan; + if (plan?.month === '2026-11') novId = d.id; + else if (plan?.month && Array.isArray(plan.pieces)) history.push(plan); + } + + const result = await generateMonthPreview({ + ym: '2026-11', + slug: 'bps', + label: 'Blue Parrot School', + history, + }); + console.log('source', result.source); + console.log('rationale\n', result.rationale); + console.log('pieces', result.plan.pieces.length); + for (const p of result.plan.pieces) { + console.log(p.date, p.format.padEnd(8), '|', p.title); + } + + if (!novId) { + console.log('No Nov doc to patch'); + return; + } + const patch = await fetch(`${url}/rest/v1/documents?id=eq.${novId}`, { + method: 'PATCH', + headers: { + ...h, + 'Content-Type': 'application/json', + Prefer: 'return=minimal', + }, + body: JSON.stringify({ + content: result.plan, + title: 'RRSS Noviembre 2026', + updated_at: new Date().toISOString(), + }), + }); + console.log('patched', patch.status); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/src/app/api/calendar/preview/route.ts b/src/app/api/calendar/preview/route.ts index 3cab31b..f269be4 100644 --- a/src/app/api/calendar/preview/route.ts +++ b/src/app/api/calendar/preview/route.ts @@ -46,7 +46,7 @@ export async function POST(req: NextRequest) { const docs = await restGet<{ content?: unknown }[]>( `/rest/v1/documents?tenant_id=eq.${encodeURIComponent( ctx.tenantId - )}&type=eq.calendar&select=content&order=created_at.desc&limit=24` + )}&type=eq.calendar&select=content&order=created_at.desc&limit=48` ); const history: RrssMonthPlan[] = []; for (const d of docs.data || []) { @@ -55,6 +55,14 @@ export async function POST(req: NextRequest) { history.push(plan as unknown as RrssMonthPlan); } } + // Prefer same calendar month from prior years first (e.g. 2025-11 before 2026-10) + const mm = month.slice(5, 7); + history.sort((a, b) => { + const aSame = a.month.slice(5, 7) === mm ? 0 : 1; + const bSame = b.month.slice(5, 7) === mm ? 0 : 1; + if (aSame !== bSame) return aSame - bSame; + return String(b.month).localeCompare(String(a.month)); + }); const result = await generateMonthPreview({ ym: month, diff --git a/src/lib/calendar-generate.ts b/src/lib/calendar-generate.ts index f30458c..17cb6c8 100644 --- a/src/lib/calendar-generate.ts +++ b/src/lib/calendar-generate.ts @@ -1,10 +1,11 @@ /** - * Generate a month editorial preview from tenant history + brand + seasonality. - * Uses Anthropic when ANTHROPIC_API_KEY is set; otherwise heuristic fallback. + * 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. */ import type { RrssMonthPlan, RrssPiece } from '@/components/RrssMonthView'; import { emptyMonthPlan, newPieceId } from '@/lib/calendar-documents'; -import { monthLabel, slugify } from '@/lib/calendar-import'; +import { monthLabel } from '@/lib/calendar-import'; export type TenantBrandBrief = { slug: string; @@ -28,51 +29,65 @@ const BRANDS: Record> = { 'objeciones', 'tips-ingles', ], - formats: { post: 0.35, reel: 0.25, stories: 0.3, carrusel: 0.1 }, - notes: - 'Series Nuevo alumno, objeciones reales, campus USA, English tips. Evitar notas de producción en copy.', + 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.45, reel: 0.15, stories: 0.25, carrusel: 0.15 }, - notes: 'Cifras, procesos de certificación, mercados, cosmética/alimentación Halal.', + 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.3, reel: 0.25, stories: 0.3, carrusel: 0.15 }, - notes: 'Peelings, rutinas, encuestas stories, trends reels, citas en bio.', + formats: { post: 0.35, reel: 0.2, stories: 0.3, carrusel: 0.15 }, + notes: 'Peelings, rutinas, encuestas stories, trends, citas.', }, bps: { - vertical: 'Colegio / idiomas', - tone: 'Familiar, educativo, comunidad', - pillars: ['comunidad', 'educativo', 'eventos', 'familias', 'idiomas'], - formats: { post: 0.35, reel: 0.2, stories: 0.35, carrusel: 0.1 }, - notes: 'Blue Parrot School: familias, inglés, eventos escolares.', + 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: 'Colegio / idiomas', - tone: 'Familiar, educativo, comunidad', - pillars: ['comunidad', 'educativo', 'eventos', 'familias', 'idiomas'], - formats: { post: 0.35, reel: 0.2, stories: 0.35, carrusel: 0.1 }, - notes: 'Blue Parrot School: familias, inglés, eventos escolares.', + 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 que reels lifestyle.', + 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 y detrás de cámaras.', + notes: 'Contenido de agencia.', }, }; @@ -81,27 +96,26 @@ export function brandBrief(slug: string, label: string): TenantBrandBrief { vertical: 'Marca / servicios', tone: 'Profesional y claro', pillars: ['educativo', 'producto', 'comunidad', 'promo'], - formats: { post: 0.4, reel: 0.2, stories: 0.25, carrusel: 0.15 }, + 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 = { - 1: ['año nuevo', 'propósitos', 'vuelta a la rutina'], - 2: ['San Valentín', 'invierno', 'cuidado intensivo'], - 3: ['primavera', 'renovación', 'marzo'], - 4: ['primavera', 'Semana Santa / puente'], - 5: ['mayo', 'comuniones / eventos', 'pre-verano'], - 6: ['verano', 'fin de curso', 'export / ferias'], - 7: ['verano', 'vacaciones', 'contenido ligero'], - 8: ['agosto', 'poco ruido', 'evergreen + tips'], - 9: ['vuelta al cole', 'septiembre', 'nuevos ciclos'], - 10: ['otoño', 'Halloween', 'rutinas de temporada'], - 11: ['Black Friday', 'puente', 'cierre de año comercial'], - 12: ['Navidad', 'balance del año', 'agradecimiento'], + 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] || []; } @@ -112,7 +126,7 @@ function weekdaysInMonth(ym: string): 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(); // 0 Sun + const wd = dt.getUTCDay(); if (wd >= 1 && wd <= 5) { days.push(`${ym}-${String(d).padStart(2, '0')}`); } @@ -120,47 +134,6 @@ function weekdaysInMonth(ym: string): string[] { return days; } -function pickFormat( - i: number, - mix: TenantBrandBrief['formats'] -): { format: string; kind: string } { - const r = (i * 37) % 100; - const post = mix.post * 100; - const reel = post + mix.reel * 100; - const stories = reel + mix.stories * 100; - if (r < post) return { format: 'Post', kind: 'post' }; - if (r < reel) return { format: 'Reel', kind: 'reel' }; - if (r < stories) return { format: 'Stories', kind: 'story' }; - return { format: 'Carrusel', kind: 'post' }; -} - -function analyzeHistory(plans: RrssMonthPlan[]): { - avgPerWeek: number; - topPillars: string[]; - sampleTitles: string[]; -} { - const pieces = plans.flatMap((p) => p.pieces || []); - if (!pieces.length) { - return { avgPerWeek: 4, topPillars: [], sampleTitles: [] }; - } - const months = Math.max(1, plans.length); - const avgPerWeek = Math.max(2, Math.round(pieces.length / months / 4)); - const pillarCount = new Map(); - for (const p of pieces) { - const key = (p.pillar || 'feed').toLowerCase(); - pillarCount.set(key, (pillarCount.get(key) || 0) + 1); - } - const topPillars = [...pillarCount.entries()] - .sort((a, b) => b[1] - a[1]) - .slice(0, 5) - .map(([k]) => k); - const sampleTitles = pieces - .slice(-12) - .map((p) => p.title) - .filter(Boolean); - return { avgPerWeek, topPillars, sampleTitles }; -} - 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'; @@ -168,6 +141,225 @@ function colorFor(format: string, kind: string, platform: string): string { 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'; + if (hay.includes('reel')) return 'reel'; + if (hay.includes('carrusel') || hay.includes('carousel')) return 'carrusel'; + 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; + sameMonth: HistPiece[]; + sampleTitles: string[]; + sourceMonths: string[]; +}; + +function collectHistory(plans: RrssMonthPlan[], targetYm: string): HistoryModel { + const targetMonth = targetYm.slice(5, 7); + const poolByKind: Record = { + 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, + 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; @@ -183,95 +375,112 @@ export type GeneratePreviewResult = { function heuristicPreview(input: GeneratePreviewInput): GeneratePreviewResult { const brand = brandBrief(input.slug, input.label); - const hist = analyzeHistory(input.history); + const hist = collectHistory(input.history, input.ym); const season = seasonHints(input.ym); const weekdays = weekdaysInMonth(input.ym); - const perWeek = Math.min(6, Math.max(3, hist.avgPerWeek)); - const target = Math.min(weekdays.length, perWeek * 4); - const pillars = - hist.topPillars.length > 0 ? hist.topPillars : brand.pillars; - + const usedTitles = new Set(); const pieces: RrssPiece[] = []; - const step = Math.max(1, Math.floor(weekdays.length / target)); - for (let i = 0, n = 0; n < target && i < weekdays.length; i += step, n++) { - const date = weekdays[i]; - const { format, kind } = pickFormat(n, brand.formats); - const pillar = pillars[n % pillars.length]; - const seasonBit = season[n % Math.max(1, season.length)] || ''; - let title: string; - if (kind === 'story') { - title = `STORIES · ${seasonBit || pillar}`.slice(0, 120); - } else if (/reel/i.test(format)) { - title = `REEL · ${pillar}${seasonBit ? ` · ${seasonBit}` : ''}`.slice( - 0, - 120 - ); - } else if (/carrusel/i.test(format)) { - title = `POST ${n + 1} · Carrusel ${pillar}`.slice(0, 120); - } else if (pillar.includes('nuevo-alumno') || pillar.includes('alumno')) { - title = `Nuevo alumno · historia ${n + 1}`.slice(0, 120); - } else { - title = `POST ${n + 1} · ${pillar}${seasonBit ? ` · ${seasonBit}` : ''}`.slice( - 0, - 120 + + // 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()); } - const platform = - pillar.includes('linkedin') || /linkedin/i.test(title) - ? 'linkedin' - : 'instagram'; - const hook = `${brand.label}: ángulo «${pillar}»${ - seasonBit ? ` en clave ${seasonBit}` : '' - }.`; - const body = [ - `Borrador editorial para ${monthLabel(input.ym)}.`, - `Pilar: ${pillar}. Tono: ${brand.tone}.`, - seasonBit ? `Estacionalidad: ${seasonBit}.` : '', - 'Sustituye este texto por el copy final. No incluyas notas internas (WhatsApp, PENDIENTE, nombres).', - ] - .filter(Boolean) - .join('\n\n'); - pieces.push({ - id: newPieceId(date, title), - date, - time: kind === 'story' ? '09:00' : /reel/i.test(format) ? '18:00' : '10:30', - platform, - format, - pillar, - title, - hook, - body, - cta: - kind === 'story' - ? 'Responde a la story' - : /reel/i.test(format) - ? 'Guarda este post' - : 'Enlace en la bio', - status: 'planned', - kind, - color: colorFor(format, kind, platform), - }); } + // 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(); + 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 = `Preview ${brand.vertical}. Ajusta piezas antes de guardar.`; + 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 = [ - `${pieces.length} piezas`, - ...season.slice(0, 2), + `${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 = pieces; + plan.pieces = uniq; const rationale = [ - `Marca: ${brand.label} (${brand.vertical}).`, - `Tono: ${brand.tone}.`, + `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 - ? `Patrón histórico: ~${hist.avgPerWeek} piezas/semana; pilares frecuentes: ${pillars.join(', ')}.` - : `Sin histórico suficiente → mix por defecto de la vertical.`, - season.length - ? `Estacionalidad ${monthLabel(input.ym)}: ${season.join(', ')}.` + ? `Ejemplos históricos: ${hist.sampleTitles.slice(0, 6).join(' · ')}.` : '', - brand.notes, + 'La estacionalidad solo aparece si ya estaba en el histórico del mismo mes (no se inventa Black Friday genérico).', ] .filter(Boolean) .join(' '); @@ -287,15 +496,25 @@ async function anthropicPreview( if (!key || key.startsWith('replace-')) return fallback; const brand = brandBrief(input.slug, input.label); - const hist = analyzeHistory(input.history); + const hist = collectHistory(input.history, input.ym); const season = seasonHints(input.ym); - const prompt = `Eres planner RRSS de 4Ventures. Genera un calendario editorial JSON para ${brand.label} (${input.ym}). + const prompt = `Eres planner RRSS de 4Ventures para ${brand.label} (${input.ym}). Vertical: ${brand.vertical}. Tono: ${brand.tone}. -Pilares: ${brand.pillars.join(', ')}. -Estacionalidad: ${season.join(', ') || 'ninguna especial'}. -Histórico: ~${hist.avgPerWeek} piezas/semana. Títulos recientes: ${hist.sampleTitles.slice(-8).join(' | ') || 'n/a'}. -Devuelve SOLO JSON: { "rationale": string, "pieces": [{ "date":"YYYY-MM-DD", "time":"HH:MM", "platform":"instagram|facebook|linkedin", "format":"Post|Reel|Stories|Carrusel", "pillar":string, "title":string, "hook":string, "body":string, "cta":string }] } -Reglas: 12-20 piezas en días laborables; body = copy público (sin WhatsApp/PENDIENTE/nombres internos); títulos con POST/REEL/STORIES cuando aplique; español.`; +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.`; try { const Anthropic = (await import('@anthropic-ai/sdk')).default; @@ -322,13 +541,10 @@ Reglas: 12-20 piezas en días laborables; body = copy público (sin WhatsApp/PEN if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !date.startsWith(input.ym)) { return null; } + const title = cleanEditorialTitle(String(p.title || '')); + if (!title || isJunkPreviewTitle(title)) return null; const format = String(p.format || 'Post'); - const kind = /stor/i.test(format) - ? 'story' - : /reel/i.test(format) - ? 'reel' - : 'post'; - const title = String(p.title || format).slice(0, 160); + const kind = kindOf(format, p.kind); const platform = String(p.platform || 'instagram').toLowerCase(); return { id: newPieceId(date, title), @@ -336,9 +552,9 @@ Reglas: 12-20 piezas en días laborables; body = copy público (sin WhatsApp/PEN time: String(p.time || '10:30').slice(0, 5), platform, format, - pillar: String(p.pillar || 'feed'), - title, - hook: String(p.hook || title), + pillar: String(p.pillar || (kind === 'story' ? 'stories' : 'feed')), + title: title.slice(0, 160), + hook: String(p.hook || title).slice(0, 180), body: String(p.body || title), cta: String(p.cta || ''), status: 'planned', @@ -348,11 +564,11 @@ Reglas: 12-20 piezas en días laborables; body = copy público (sin WhatsApp/PEN }) .filter(Boolean) as RrssPiece[]; - if (!pieces.length) return fallback; + if (pieces.length < 6) return fallback; const plan = emptyMonthPlan(input.ym, input.label); - plan.hero.lede = 'Preview generado con IA · revisa antes de guardar.'; - plan.hero.chips = [`${pieces.length} piezas`, 'IA', brand.vertical]; - plan.pieces = pieces; + plan.hero.lede = 'Preview IA anclado al histórico del cliente.'; + plan.hero.chips = [`${pieces.length} piezas`, 'IA + histórico', brand.vertical]; + plan.pieces = pieces.sort((a, b) => a.date.localeCompare(b.date)); return { plan, rationale: parsed.rationale || fallback.rationale, @@ -366,8 +582,18 @@ Reglas: 12-20 piezas en días laborables; body = copy público (sin WhatsApp/PEN export async function generateMonthPreview( input: GeneratePreviewInput ): Promise { - const fallback = heuristicPreview(input); - return anthropicPreview(input, fallback); + // Drop junk months (previous bad previews) from history + 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 fallback = heuristicPreview({ ...input, history: cleanHistory }); + return anthropicPreview({ ...input, history: cleanHistory }, fallback); } export function mergePreviewIntoPlan( @@ -388,4 +614,4 @@ export function mergePreviewIntoPlan( }; } -export { slugify, monthLabel }; +export { monthLabel };