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 <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-09 23:57:41 +02:00
parent 8723753066
commit 59f42eafe0
3 changed files with 473 additions and 175 deletions

View File

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

View File

@@ -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,

View File

@@ -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<string, Omit<TenantBrandBrief, 'slug' | 'label'>> = {
'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<number, string[]> = {
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<string, number>();
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<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;
@@ -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<string>();
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<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 = `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<GeneratePreviewResult> {
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 };