Feed editorial calendar generation with past calendars and live network posts.

Brandbook plus calendar history and published pipeline/IG pieces now shape series mix and Claude strategist prompts without copying old titles.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-13 13:53:27 +02:00
parent 3afec6402f
commit 714f250eca
4 changed files with 197 additions and 11 deletions

View File

@@ -12,6 +12,12 @@ import {
BRANDBOOK_DOC_TYPE, BRANDBOOK_DOC_TYPE,
parseBrandbook, parseBrandbook,
} from '@/lib/brandbook'; } from '@/lib/brandbook';
import {
pipelinePublishDay,
type PipelineCalendarRow,
} from '@/lib/pipeline-calendar';
import type { PublishedNetworkPost } from '@/lib/calendar-strategy';
import { normalizeApprovalStatus } from '@/lib/approval-status';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export const maxDuration = 60; export const maxDuration = 60;
@@ -75,12 +81,47 @@ export async function POST(req: NextRequest) {
); );
const brandbook = parseBrandbook(bb.data?.[0]?.content); const brandbook = parseBrandbook(bb.data?.[0]?.content);
// Publicaciones reales en redes (pipeline): publicadas o con rastro IG
const pipe = await restGet<PipelineCalendarRow[]>(
`/rest/v1/creative_pipeline?tenant_id=eq.${encodeURIComponent(
ctx.tenantId
)}&select=id,title,format,month,status,caption,notes,created_at,scheduled_at,channels,publish_status,external_ref,published_at&order=created_at.desc&limit=400`
);
const publishedPosts: PublishedNetworkPost[] = [];
for (const row of pipe.data || []) {
const st = normalizeApprovalStatus(row.status);
const ref = String(row.external_ref || '').toLowerCase();
// Solo piezas ya salidas / sync IG (no borradores del pipeline)
if (
st !== 'publicada' &&
row.publish_status !== 'published' &&
!ref.startsWith('ig-')
) {
continue;
}
const day =
pipelinePublishDay(row) ||
row.published_at?.slice(0, 10) ||
row.created_at?.slice(0, 10) ||
null;
publishedPosts.push({
title: row.title,
caption: row.caption,
format: row.format,
day,
month: row.month || (day ? day.slice(0, 7) : null),
status: row.status,
channels: row.channels,
});
}
const result = await generateMonthPreview({ const result = await generateMonthPreview({
ym: month, ym: month,
slug, slug,
label: ctx.label, label: ctx.label,
history, history,
brandbook, brandbook,
publishedPosts,
}); });
return NextResponse.json({ return NextResponse.json({
@@ -90,5 +131,7 @@ export async function POST(req: NextRequest) {
source: result.source, source: result.source,
brand: slug, brand: slug,
brandbook: !!brandbook, brandbook: !!brandbook,
history_months: history.length,
network_posts: publishedPosts.length,
}); });
} }

View File

@@ -16,6 +16,7 @@ import {
monthTrendBeats, monthTrendBeats,
strategistSystemPrompt, strategistSystemPrompt,
strategyMonthPreview, strategyMonthPreview,
type PublishedNetworkPost,
} from '@/lib/calendar-strategy'; } from '@/lib/calendar-strategy';
import type { Brandbook } from '@/lib/brandbook'; import type { Brandbook } from '@/lib/brandbook';
@@ -32,6 +33,8 @@ export type GeneratePreviewInput = {
label: string; label: string;
history: RrssMonthPlan[]; history: RrssMonthPlan[];
brandbook?: Brandbook | null; brandbook?: Brandbook | null;
/** Publicaciones reales (pipeline / IG) además del histórico de calendarios. */
publishedPosts?: PublishedNetworkPost[];
}; };
export type GeneratePreviewResult = { export type GeneratePreviewResult = {
@@ -76,7 +79,8 @@ async function anthropicStrategist(
input.slug, input.slug,
input.label, input.label,
cleanHistory, cleanHistory,
input.brandbook input.brandbook,
input.publishedPosts || []
); );
const trends = monthTrendBeats(input.ym, dna.brand.vertical); const trends = monthTrendBeats(input.ym, dna.brand.vertical);
const prompt = strategistSystemPrompt({ const prompt = strategistSystemPrompt({
@@ -174,6 +178,7 @@ export async function generateMonthPreview(
...input, ...input,
history: cleanHistory, history: cleanHistory,
brandbook: input.brandbook, brandbook: input.brandbook,
publishedPosts: input.publishedPosts || [],
}); });
const fallback: GeneratePreviewResult = { const fallback: GeneratePreviewResult = {
plan: strategist.plan, plan: strategist.plan,
@@ -181,7 +186,14 @@ export async function generateMonthPreview(
source: 'strategy', source: 'strategy',
}; };
return anthropicStrategist({ ...input, history: cleanHistory }, fallback); return anthropicStrategist(
{
...input,
history: cleanHistory,
publishedPosts: input.publishedPosts || [],
},
fallback
);
} }
export function mergePreviewIntoPlan( export function mergePreviewIntoPlan(

View File

@@ -16,6 +16,18 @@ import {
type Brandbook, type Brandbook,
} from '@/lib/brandbook'; } from '@/lib/brandbook';
/** Publicación real en redes (pipeline / IG sync) usada como señal creativa. */
export type PublishedNetworkPost = {
title: string;
caption?: string | null;
format?: string | null;
/** YYYY-MM-DD when known */
day?: string | null;
month?: string | null;
status?: string | null;
channels?: string[] | null;
};
export type ClientDna = { export type ClientDna = {
brand: TenantBrandBrief; brand: TenantBrandBrief;
/** Recurring series names inferred from history (as DNA, not to copy). */ /** Recurring series names inferred from history (as DNA, not to copy). */
@@ -26,6 +38,12 @@ export type ClientDna = {
voiceNotes: string; voiceNotes: string;
/** Full brandbook prompt block when available. */ /** Full brandbook prompt block when available. */
brandbookPrompt?: string; brandbookPrompt?: string;
/** Cuántos meses de calendario editorial alimentaron el ADN. */
calendarMonthsUsed: number;
/** Cuántas publicaciones reales en redes alimentaron el ADN. */
networkPostsUsed: number;
/** Muestras de captiones/títulos publicados (no copiar literal). */
networkSamples: string[];
}; };
export type TrendBeat = { export type TrendBeat = {
@@ -189,12 +207,95 @@ function kindOf(format: string): string {
return 'post'; return 'post';
} }
/** Build DNA from history without copying titles. */ /**
* Convierte publicaciones reales (pipeline / IG) en planes mensuales sintéticos
* para reutilizar el extractor de series/formatos del calendario editorial.
*/
export function plansFromPublishedPosts(
posts: PublishedNetworkPost[],
label = 'Redes'
): RrssMonthPlan[] {
const byMonth = new Map<string, RrssPiece[]>();
for (const post of posts) {
const day =
(post.day && post.day.slice(0, 10)) ||
(post.month && /^\d{4}-\d{2}$/.test(post.month)
? `${post.month}-15`
: null);
if (!day || !/^\d{4}-\d{2}-\d{2}$/.test(day)) continue;
const title = cleanEditorialTitle(
(post.title || post.caption || '').split('\n')[0] || ''
);
if (!title || title.length < 4) continue;
const ym = day.slice(0, 7);
const fmtRaw = (post.format || '').toLowerCase();
let format = 'Post';
let kind = 'post';
if (/reel|video/.test(fmtRaw)) {
format = 'Reel';
kind = 'reel';
} else if (/carrusel|carousel/.test(fmtRaw)) {
format = 'Carrusel';
kind = 'carrusel';
} else if (/stor/.test(fmtRaw)) {
format = 'Stories';
kind = 'story';
}
const piece: RrssPiece = {
id: `net-${ym}-${byMonth.get(ym)?.length || 0}`,
date: day,
platform: (post.channels || []).includes('facebook')
? 'facebook'
: 'instagram',
format,
title: title.slice(0, 140),
body: (post.caption || '').slice(0, 400) || undefined,
status: 'published',
kind,
color: colorFor(format, kind),
};
if (!byMonth.has(ym)) byMonth.set(ym, []);
byMonth.get(ym)!.push(piece);
}
return [...byMonth.entries()]
.sort((a, b) => b[0].localeCompare(a[0]))
.map(([ym, pieces]) => {
const plan = emptyMonthPlan(ym, label);
plan.hero.lede = `Histórico publicado en redes · ${ym}`;
plan.hero.chips = ['network-history', `${pieces.length} pubs`];
plan.pieces = pieces;
return plan;
});
}
function networkSamplesFromPosts(
posts: PublishedNetworkPost[],
limit = 18
): string[] {
const out: string[] = [];
const seen = new Set<string>();
for (const post of posts) {
const line = cleanEditorialTitle(
(post.title || post.caption || '').split('\n')[0] || ''
);
if (!line || line.length < 8) continue;
const key = line.toLowerCase().slice(0, 60);
if (seen.has(key)) continue;
seen.add(key);
const fmt = (post.format || 'post').toLowerCase();
out.push(`[${fmt}] ${line.slice(0, 110)}`);
if (out.length >= limit) break;
}
return out;
}
/** Build DNA from calendar history + network publications without copying titles. */
export function extractClientDna( export function extractClientDna(
slug: string, slug: string,
label: string, label: string,
history: RrssMonthPlan[], history: RrssMonthPlan[],
brandbook?: Brandbook | null brandbook?: Brandbook | null,
publishedPosts: PublishedNetworkPost[] = []
): ClientDna { ): ClientDna {
const brand = brandBrief(slug, label); const brand = brandBrief(slug, label);
if (brandbook) { if (brandbook) {
@@ -216,10 +317,17 @@ export function extractClientDna(
.join(' · '); .join(' · ');
} }
} }
const networkPlans = plansFromPublishedPosts(publishedPosts, label);
// Calendarios editoriales primero; redes aportan series/formatos reales.
const mergedHistory = [...history, ...networkPlans];
const calendarMonthsUsed = history.length;
const networkPostsUsed = publishedPosts.length;
const seriesCount = new Map<string, number>(); const seriesCount = new Map<string, number>();
let total = 0; let total = 0;
let stories = 0; let stories = 0;
const months = Math.max(1, history.length); const months = Math.max(1, mergedHistory.length);
const classify = (title: string): string | null => { const classify = (title: string): string | null => {
const x = cleanEditorialTitle(title) const x = cleanEditorialTitle(title)
@@ -251,13 +359,18 @@ export function extractClientDna(
return null; return null;
}; };
for (const plan of history) { for (const plan of mergedHistory) {
for (const p of plan.pieces || []) { for (const p of plan.pieces || []) {
if (!p.title) continue; if (!p.title) continue;
total += 1; total += 1;
if (/stor/i.test(p.format || '') || p.kind === 'story') stories += 1; if (/stor/i.test(p.format || '') || p.kind === 'story') stories += 1;
const s = classify(p.title); const s = classify(p.title);
if (s) seriesCount.set(s, (seriesCount.get(s) || 0) + 1); if (s) seriesCount.set(s, (seriesCount.get(s) || 0) + 1);
// captions de redes también aportan serie
if (p.body) {
const s2 = classify(p.body);
if (s2) seriesCount.set(s2, (seriesCount.get(s2) || 0) + 1);
}
} }
} }
@@ -330,6 +443,9 @@ export function extractClientDna(
: ['Matriculación', 'Certificaciones'], : ['Matriculación', 'Certificaciones'],
voiceNotes: brand.notes, voiceNotes: brand.notes,
brandbookPrompt: brandbook ? brandbookToPrompt(brandbook) : undefined, brandbookPrompt: brandbook ? brandbookToPrompt(brandbook) : undefined,
calendarMonthsUsed,
networkPostsUsed,
networkSamples: networkSamplesFromPosts(publishedPosts),
}; };
} }
@@ -652,12 +768,14 @@ export function strategyMonthPreview(input: {
label: string; label: string;
history: RrssMonthPlan[]; history: RrssMonthPlan[];
brandbook?: Brandbook | null; brandbook?: Brandbook | null;
publishedPosts?: PublishedNetworkPost[];
}): StrategyPreviewResult { }): StrategyPreviewResult {
const dna = extractClientDna( const dna = extractClientDna(
input.slug, input.slug,
input.label, input.label,
input.history, input.history,
input.brandbook input.brandbook,
input.publishedPosts || []
); );
const trends = monthTrendBeats(input.ym, dna.brand.vertical); const trends = monthTrendBeats(input.ym, dna.brand.vertical);
const slots = buildMonthSlots(input.ym, dna); const slots = buildMonthSlots(input.ym, dna);
@@ -710,17 +828,18 @@ export function strategyMonthPreview(input: {
}); });
const plan = emptyMonthPlan(input.ym, input.label); 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.lede = `Estrategia ${monthLabel(input.ym)} · ${dna.brand.vertical}. ADN = Zona de marca + calendarios + pubs reales (sin copiar).`;
plan.hero.chips = [ plan.hero.chips = [
`${pieces.length} piezas`, `${pieces.length} piezas`,
`${pieces.filter((p) => p.format === 'Reel').length} Reels`, `${pieces.filter((p) => p.format === 'Reel').length} Reels`,
`${pieces.filter((p) => p.format === 'Carrusel').length} carruseles`, `${pieces.filter((p) => p.format === 'Carrusel').length} carruseles`,
'strategist', dna.networkPostsUsed ? `${dna.networkPostsUsed} pubs hist.` : 'strategist',
]; ];
plan.pieces = pieces; plan.pieces = pieces;
const rationale = [ const rationale = [
`Estrategia (no copia): DNA de ${dna.brand.label} = series ${dna.signatureSeries.slice(0, 5).join(', ') || 'vertical'}.`, `Estrategia (no copia): DNA de ${dna.brand.label} = series ${dna.signatureSeries.slice(0, 5).join(', ') || 'vertical'}.`,
`Fuentes ADN: ${dna.calendarMonthsUsed} mes(es) de calendario editorial · ${dna.networkPostsUsed} publicación(es) reales en redes · ${dna.brandbookPrompt ? 'brandbook' : 'sin brandbook'}.`,
`Playbook ES 2026 (IAB): menos posts, más eficiencia · Reels alcance · Carrusel guardados · Stories conversación.`, `Playbook ES 2026 (IAB): menos posts, más eficiencia · Reels alcance · Carrusel guardados · Stories conversación.`,
`Tendencias ${monthLabel(input.ym)}: ${trends `Tendencias ${monthLabel(input.ym)}: ${trends
.slice(0, 4) .slice(0, 4)
@@ -742,14 +861,25 @@ export function strategistSystemPrompt(input: {
dna: ClientDna; dna: ClientDna;
trends: TrendBeat[]; trends: TrendBeat[];
}): string { }): string {
const netBlock =
input.dna.networkSamples.length > 0
? `\nPUBLICACIONES REALES EN REDES (muestras; NO copies literal — inspira series, tono y formatos):\n${input.dna.networkSamples
.map((s) => `- ${s}`)
.join('\n')}\n`
: '';
return `Eres Head of Social de 4Ventures. Crea un calendario NUEVO para ${input.label} (${input.ym}). 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. NO copies títulos viejos ni captiones publicadas. Usa como constraint creativo:
1) Zona de marca / brandbook
2) Histórico de calendarios editoriales (${input.dna.calendarMonthsUsed} meses)
3) Publicaciones reales ya salidas en redes (${input.dna.networkPostsUsed} piezas)
ADN series: ${input.dna.signatureSeries.join(', ')}. ADN series: ${input.dna.signatureSeries.join(', ')}.
Voz: ${input.dna.brand.tone}. Vertical: ${input.dna.brand.vertical}. Voz: ${input.dna.brand.tone}. Vertical: ${input.dna.brand.vertical}.
Volumen guía: ~${input.dna.avgPerWeek} piezas/semana · Stories ratio hist. ${(input.dna.storiesRatio * 100).toFixed(0)}%.
${input.dna.brandbookPrompt ? `\nBRANDBOOK:\n${input.dna.brandbookPrompt}\n` : ''} ${input.dna.brandbookPrompt ? `\nBRANDBOOK:\n${input.dna.brandbookPrompt}\n` : ''}
${netBlock}
Playbook 2026 ES: ${PLAYBOOK_2026.principle} Playbook 2026 ES: ${PLAYBOOK_2026.principle}
Tendencias del mes: Tendencias del mes:
${input.trends.map((t) => `- ${t.label}: ${t.why}`).join('\n')} ${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}] } 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.`; Reglas: títulos creativos y específicos (nuevos); rota series del ADN; 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

@@ -12,6 +12,7 @@ export type PipelineCalendarRow = {
notes?: string | null; notes?: string | null;
created_at?: string | null; created_at?: string | null;
publish_date?: string | null; publish_date?: string | null;
published_at?: string | null;
scheduled_at?: string | null; scheduled_at?: string | null;
channels?: string[] | null; channels?: string[] | null;
publish_status?: string | null; publish_status?: string | null;