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:
64
scripts/regen_bps_nov_preview.ts
Normal file
64
scripts/regen_bps_nov_preview.ts
Normal 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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
// 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)
|
||||
);
|
||||
} 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);
|
||||
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 {
|
||||
title = `POST ${n + 1} · ${pillar}${seasonBit ? ` · ${seasonBit}` : ''}`.slice(
|
||||
0,
|
||||
120
|
||||
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 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,
|
||||
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,
|
||||
hook,
|
||||
body,
|
||||
cta:
|
||||
kind === 'story'
|
||||
? 'Responde a la story'
|
||||
: /reel/i.test(format)
|
||||
? 'Guarda este post'
|
||||
: 'Enlace en la bio',
|
||||
status: 'planned',
|
||||
format: formatLabel(kind),
|
||||
kind,
|
||||
color: colorFor(format, kind, platform),
|
||||
});
|
||||
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 };
|
||||
|
||||
Reference in New Issue
Block a user