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:
@@ -12,6 +12,12 @@ import {
|
||||
BRANDBOOK_DOC_TYPE,
|
||||
parseBrandbook,
|
||||
} 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 maxDuration = 60;
|
||||
@@ -75,12 +81,47 @@ export async function POST(req: NextRequest) {
|
||||
);
|
||||
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({
|
||||
ym: month,
|
||||
slug,
|
||||
label: ctx.label,
|
||||
history,
|
||||
brandbook,
|
||||
publishedPosts,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -90,5 +131,7 @@ export async function POST(req: NextRequest) {
|
||||
source: result.source,
|
||||
brand: slug,
|
||||
brandbook: !!brandbook,
|
||||
history_months: history.length,
|
||||
network_posts: publishedPosts.length,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
monthTrendBeats,
|
||||
strategistSystemPrompt,
|
||||
strategyMonthPreview,
|
||||
type PublishedNetworkPost,
|
||||
} from '@/lib/calendar-strategy';
|
||||
import type { Brandbook } from '@/lib/brandbook';
|
||||
|
||||
@@ -32,6 +33,8 @@ export type GeneratePreviewInput = {
|
||||
label: string;
|
||||
history: RrssMonthPlan[];
|
||||
brandbook?: Brandbook | null;
|
||||
/** Publicaciones reales (pipeline / IG) además del histórico de calendarios. */
|
||||
publishedPosts?: PublishedNetworkPost[];
|
||||
};
|
||||
|
||||
export type GeneratePreviewResult = {
|
||||
@@ -76,7 +79,8 @@ async function anthropicStrategist(
|
||||
input.slug,
|
||||
input.label,
|
||||
cleanHistory,
|
||||
input.brandbook
|
||||
input.brandbook,
|
||||
input.publishedPosts || []
|
||||
);
|
||||
const trends = monthTrendBeats(input.ym, dna.brand.vertical);
|
||||
const prompt = strategistSystemPrompt({
|
||||
@@ -174,6 +178,7 @@ export async function generateMonthPreview(
|
||||
...input,
|
||||
history: cleanHistory,
|
||||
brandbook: input.brandbook,
|
||||
publishedPosts: input.publishedPosts || [],
|
||||
});
|
||||
const fallback: GeneratePreviewResult = {
|
||||
plan: strategist.plan,
|
||||
@@ -181,7 +186,14 @@ export async function generateMonthPreview(
|
||||
source: 'strategy',
|
||||
};
|
||||
|
||||
return anthropicStrategist({ ...input, history: cleanHistory }, fallback);
|
||||
return anthropicStrategist(
|
||||
{
|
||||
...input,
|
||||
history: cleanHistory,
|
||||
publishedPosts: input.publishedPosts || [],
|
||||
},
|
||||
fallback
|
||||
);
|
||||
}
|
||||
|
||||
export function mergePreviewIntoPlan(
|
||||
|
||||
@@ -16,6 +16,18 @@ import {
|
||||
type 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 = {
|
||||
brand: TenantBrandBrief;
|
||||
/** Recurring series names inferred from history (as DNA, not to copy). */
|
||||
@@ -26,6 +38,12 @@ export type ClientDna = {
|
||||
voiceNotes: string;
|
||||
/** Full brandbook prompt block when available. */
|
||||
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 = {
|
||||
@@ -189,12 +207,95 @@ function kindOf(format: string): string {
|
||||
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(
|
||||
slug: string,
|
||||
label: string,
|
||||
history: RrssMonthPlan[],
|
||||
brandbook?: Brandbook | null
|
||||
brandbook?: Brandbook | null,
|
||||
publishedPosts: PublishedNetworkPost[] = []
|
||||
): ClientDna {
|
||||
const brand = brandBrief(slug, label);
|
||||
if (brandbook) {
|
||||
@@ -216,10 +317,17 @@ export function extractClientDna(
|
||||
.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>();
|
||||
let total = 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 x = cleanEditorialTitle(title)
|
||||
@@ -251,13 +359,18 @@ export function extractClientDna(
|
||||
return null;
|
||||
};
|
||||
|
||||
for (const plan of history) {
|
||||
for (const plan of mergedHistory) {
|
||||
for (const p of plan.pieces || []) {
|
||||
if (!p.title) continue;
|
||||
total += 1;
|
||||
if (/stor/i.test(p.format || '') || p.kind === 'story') stories += 1;
|
||||
const s = classify(p.title);
|
||||
if (s) seriesCount.set(s, (seriesCount.get(s) || 0) + 1);
|
||||
// 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'],
|
||||
voiceNotes: brand.notes,
|
||||
brandbookPrompt: brandbook ? brandbookToPrompt(brandbook) : undefined,
|
||||
calendarMonthsUsed,
|
||||
networkPostsUsed,
|
||||
networkSamples: networkSamplesFromPosts(publishedPosts),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -652,12 +768,14 @@ export function strategyMonthPreview(input: {
|
||||
label: string;
|
||||
history: RrssMonthPlan[];
|
||||
brandbook?: Brandbook | null;
|
||||
publishedPosts?: PublishedNetworkPost[];
|
||||
}): StrategyPreviewResult {
|
||||
const dna = extractClientDna(
|
||||
input.slug,
|
||||
input.label,
|
||||
input.history,
|
||||
input.brandbook
|
||||
input.brandbook,
|
||||
input.publishedPosts || []
|
||||
);
|
||||
const trends = monthTrendBeats(input.ym, dna.brand.vertical);
|
||||
const slots = buildMonthSlots(input.ym, dna);
|
||||
@@ -710,17 +828,18 @@ export function strategyMonthPreview(input: {
|
||||
});
|
||||
|
||||
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 = [
|
||||
`${pieces.length} piezas`,
|
||||
`${pieces.filter((p) => p.format === 'Reel').length} Reels`,
|
||||
`${pieces.filter((p) => p.format === 'Carrusel').length} carruseles`,
|
||||
'strategist',
|
||||
dna.networkPostsUsed ? `${dna.networkPostsUsed} pubs hist.` : 'strategist',
|
||||
];
|
||||
plan.pieces = pieces;
|
||||
|
||||
const rationale = [
|
||||
`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.`,
|
||||
`Tendencias ${monthLabel(input.ym)}: ${trends
|
||||
.slice(0, 4)
|
||||
@@ -742,14 +861,25 @@ export function strategistSystemPrompt(input: {
|
||||
dna: ClientDna;
|
||||
trends: TrendBeat[];
|
||||
}): 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}).
|
||||
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(', ')}.
|
||||
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` : ''}
|
||||
${netBlock}
|
||||
Playbook 2026 ES: ${PLAYBOOK_2026.principle}
|
||||
Tendencias del mes:
|
||||
${input.trends.map((t) => `- ${t.label}: ${t.why}`).join('\n')}
|
||||
Devuelve SOLO JSON { rationale, pieces:[{date,time,platform,format,pillar,title,hook,body,cta,objective_metric}] }
|
||||
Reglas: títulos creativos y específicos; mix ~28% Reel, 18% Carrusel, 38% Stories, resto Post; horas engagement (Reels noche ~19:30, Stories mañana, Carrusel mediodía); KPI por pieza; español; sin "POST 1 · Black Friday" vacío; respeta claims prohibidos del brandbook.`;
|
||||
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.`;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export type PipelineCalendarRow = {
|
||||
notes?: string | null;
|
||||
created_at?: string | null;
|
||||
publish_date?: string | null;
|
||||
published_at?: string | null;
|
||||
scheduled_at?: string | null;
|
||||
channels?: string[] | null;
|
||||
publish_status?: string | null;
|
||||
|
||||
Reference in New Issue
Block a user