From 714f250eca8fc911c1826cf9d119c676e6217330 Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Tue, 13 Oct 2026 13:53:27 +0200 Subject: [PATCH] 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 --- src/app/api/calendar/preview/route.ts | 43 ++++++++ src/lib/calendar-generate.ts | 16 ++- src/lib/calendar-strategy.ts | 148 ++++++++++++++++++++++++-- src/lib/pipeline-calendar.ts | 1 + 4 files changed, 197 insertions(+), 11 deletions(-) diff --git a/src/app/api/calendar/preview/route.ts b/src/app/api/calendar/preview/route.ts index 1ca34a7..4e411bb 100644 --- a/src/app/api/calendar/preview/route.ts +++ b/src/app/api/calendar/preview/route.ts @@ -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( + `/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, }); } diff --git a/src/lib/calendar-generate.ts b/src/lib/calendar-generate.ts index 85e2fe6..3420faa 100644 --- a/src/lib/calendar-generate.ts +++ b/src/lib/calendar-generate.ts @@ -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( diff --git a/src/lib/calendar-strategy.ts b/src/lib/calendar-strategy.ts index be22404..7b1aa57 100644 --- a/src/lib/calendar-strategy.ts +++ b/src/lib/calendar-strategy.ts @@ -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(); + 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(); + 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(); 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.`; } diff --git a/src/lib/pipeline-calendar.ts b/src/lib/pipeline-calendar.ts index b8bc956..feeaec7 100644 --- a/src/lib/pipeline-calendar.ts +++ b/src/lib/pipeline-calendar.ts @@ -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;