feat(creatives): add configurable per-client creative style and skill builder in UI and API
This commit is contained in:
194
src/app/api/creatives/style/route.ts
Normal file
194
src/app/api/creatives/style/route.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { isAgencyRole, userRole } from '@/lib/agency';
|
||||
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||
import { createClient as createAdminClient } from '@supabase/supabase-js';
|
||||
import {
|
||||
ClientCreativeStyle,
|
||||
compileClientSkillMarkdown,
|
||||
normalizeCreativeStyle,
|
||||
} from '@/lib/creative-style';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
function getAdmin() {
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const key = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
return createAdminClient(url, key);
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const role = userRole(user);
|
||||
if (!isAgencyRole(role)) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
let tenantId = searchParams.get('tenant_id') || searchParams.get('tenantId');
|
||||
if (!tenantId) {
|
||||
const ctx = await resolveActiveTenant(user);
|
||||
tenantId = ctx.tenantId;
|
||||
}
|
||||
|
||||
if (!tenantId) {
|
||||
return NextResponse.json({ error: 'tenant_id is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const admin = getAdmin();
|
||||
const { data: tenant, error } = await admin
|
||||
.from('tenants')
|
||||
.select(
|
||||
'id, name, slug, creatives_enabled, meta_ad_account_id, meta_page_id, meta_ig_user_id, slack_channel_id, drive_folder_id, default_landing_url, canva_templates, campaign_audience, campaign_products, creative_style, settings'
|
||||
)
|
||||
.eq('id', tenantId)
|
||||
.maybeSingle();
|
||||
|
||||
if (error || !tenant) {
|
||||
return NextResponse.json({ error: error?.message || 'Cliente no encontrado' }, { status: 404 });
|
||||
}
|
||||
|
||||
const rawStyle =
|
||||
tenant.creative_style && Object.keys(tenant.creative_style).length > 0
|
||||
? tenant.creative_style
|
||||
: (tenant.settings as any)?.creative_style;
|
||||
|
||||
const style = normalizeCreativeStyle(rawStyle, tenant.name || 'Cliente', tenant.slug || '');
|
||||
const compiledMarkdown = compileClientSkillMarkdown(
|
||||
style,
|
||||
tenant.name || 'Cliente',
|
||||
tenant.slug || 'cliente'
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
tenant_id: tenant.id,
|
||||
name: tenant.name,
|
||||
slug: tenant.slug,
|
||||
creatives_enabled: Boolean(tenant.creatives_enabled),
|
||||
meta_ad_account_id: tenant.meta_ad_account_id || '',
|
||||
meta_page_id: tenant.meta_page_id || '',
|
||||
meta_ig_user_id: tenant.meta_ig_user_id || '',
|
||||
slack_channel_id: tenant.slack_channel_id || '',
|
||||
drive_folder_id: tenant.drive_folder_id || '',
|
||||
default_landing_url: tenant.default_landing_url || '',
|
||||
canva_templates: tenant.canva_templates || {},
|
||||
campaign_audience: tenant.campaign_audience || {},
|
||||
campaign_products: tenant.campaign_products || {},
|
||||
style,
|
||||
compiled_skill: compiledMarkdown,
|
||||
});
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const role = userRole(user);
|
||||
if (!isAgencyRole(role)) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
let body: any;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const tenantId = body.tenant_id || body.tenantId;
|
||||
if (!tenantId) {
|
||||
return NextResponse.json({ error: 'tenant_id is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const admin = getAdmin();
|
||||
const { data: existing, error: getErr } = await admin
|
||||
.from('tenants')
|
||||
.select('id, name, slug, settings')
|
||||
.eq('id', tenantId)
|
||||
.maybeSingle();
|
||||
|
||||
if (getErr || !existing) {
|
||||
return NextResponse.json({ error: 'Cliente no encontrado' }, { status: 404 });
|
||||
}
|
||||
|
||||
const normalizedStyle: ClientCreativeStyle = normalizeCreativeStyle(
|
||||
body.style,
|
||||
existing.name || 'Cliente',
|
||||
existing.slug || ''
|
||||
);
|
||||
normalizedStyle.updated_at = new Date().toISOString();
|
||||
|
||||
const updatePayload: Record<string, any> = {
|
||||
creative_style: normalizedStyle,
|
||||
settings: {
|
||||
...(existing.settings || {}),
|
||||
creative_style: normalizedStyle,
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof body.creatives_enabled === 'boolean') {
|
||||
updatePayload.creatives_enabled = body.creatives_enabled;
|
||||
}
|
||||
if (typeof body.meta_ad_account_id === 'string') {
|
||||
updatePayload.meta_ad_account_id = body.meta_ad_account_id.trim() || null;
|
||||
}
|
||||
if (typeof body.meta_page_id === 'string') {
|
||||
updatePayload.meta_page_id = body.meta_page_id.trim() || null;
|
||||
}
|
||||
if (typeof body.meta_ig_user_id === 'string') {
|
||||
updatePayload.meta_ig_user_id = body.meta_ig_user_id.trim() || null;
|
||||
}
|
||||
if (typeof body.slack_channel_id === 'string') {
|
||||
updatePayload.slack_channel_id = body.slack_channel_id.trim() || null;
|
||||
}
|
||||
if (typeof body.drive_folder_id === 'string') {
|
||||
updatePayload.drive_folder_id = body.drive_folder_id.trim() || null;
|
||||
}
|
||||
if (typeof body.default_landing_url === 'string') {
|
||||
updatePayload.default_landing_url = body.default_landing_url.trim() || null;
|
||||
}
|
||||
if (body.canva_templates && typeof body.canva_templates === 'object') {
|
||||
updatePayload.canva_templates = body.canva_templates;
|
||||
}
|
||||
if (body.campaign_audience && typeof body.campaign_audience === 'object') {
|
||||
updatePayload.campaign_audience = body.campaign_audience;
|
||||
}
|
||||
if (body.campaign_products && typeof body.campaign_products === 'object') {
|
||||
updatePayload.campaign_products = body.campaign_products;
|
||||
}
|
||||
|
||||
const { error: updErr } = await admin
|
||||
.from('tenants')
|
||||
.update(updatePayload)
|
||||
.eq('id', tenantId);
|
||||
|
||||
if (updErr) {
|
||||
return NextResponse.json({ error: updErr.message }, { status: 500 });
|
||||
}
|
||||
|
||||
const compiledMarkdown = compileClientSkillMarkdown(
|
||||
normalizedStyle,
|
||||
existing.name || 'Cliente',
|
||||
existing.slug || 'cliente'
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
style: normalizedStyle,
|
||||
compiled_skill: compiledMarkdown,
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { authenticateIntegrationRequest } from '@/lib/integrations-auth';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { compileClientSkillMarkdown, normalizeCreativeStyle } from '@/lib/creative-style';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
@@ -17,10 +18,10 @@ export async function GET(req: NextRequest) {
|
||||
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
const adminClient = createClient(supabaseUrl, serviceRoleKey);
|
||||
|
||||
// 1. Obtener slug del cliente
|
||||
// 1. Obtener slug y creative_style del cliente desde BD
|
||||
const { data: tenant, error: tErr } = await adminClient
|
||||
.from('tenants')
|
||||
.select('id, name, slug')
|
||||
.select('id, name, slug, creative_style, settings')
|
||||
.eq('id', tenantId)
|
||||
.maybeSingle();
|
||||
|
||||
@@ -31,19 +32,32 @@ export async function GET(req: NextRequest) {
|
||||
const slug = (tenant.slug || 'bps').toLowerCase().trim();
|
||||
const formatParam = (req.nextUrl.searchParams.get('format') || 'estatico').toLowerCase().trim();
|
||||
|
||||
// 2. Resolver skill del cliente (skills/clients/<slug>.md)
|
||||
const skillsDir = path.join(process.cwd(), 'skills');
|
||||
const clientCandidates = [
|
||||
path.join(skillsDir, 'clients', `${slug}.md`),
|
||||
path.join(skillsDir, 'clients', slug === 'bps' ? 'blue-parrot-school.md' : `${slug}.md`),
|
||||
path.join(skillsDir, 'clients', 'blue-parrot-school.md'),
|
||||
];
|
||||
// 2. Resolver skill del cliente: primero desde BD (creative_style editable en la app), fallback a archivo en disco
|
||||
const rawDbStyle =
|
||||
tenant.creative_style && Object.keys(tenant.creative_style).length > 0
|
||||
? tenant.creative_style
|
||||
: (tenant.settings as any)?.creative_style;
|
||||
|
||||
let clientSkill = '';
|
||||
for (const p of clientCandidates) {
|
||||
if (fs.existsSync(p)) {
|
||||
clientSkill = fs.readFileSync(p, 'utf-8');
|
||||
break;
|
||||
const skillsDir = path.join(process.cwd(), 'skills');
|
||||
|
||||
if (rawDbStyle && Object.keys(rawDbStyle).length > 0) {
|
||||
const normalized = normalizeCreativeStyle(rawDbStyle, tenant.name || 'Cliente', slug);
|
||||
clientSkill = compileClientSkillMarkdown(normalized, tenant.name || 'Cliente', slug);
|
||||
} else {
|
||||
const clientCandidates = [
|
||||
path.join(skillsDir, 'clients', `${slug}.md`),
|
||||
path.join(skillsDir, 'clients', slug === 'bps' ? 'blue-parrot-school.md' : `${slug}.md`),
|
||||
];
|
||||
for (const p of clientCandidates) {
|
||||
if (fs.existsSync(p)) {
|
||||
clientSkill = fs.readFileSync(p, 'utf-8');
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!clientSkill) {
|
||||
const normalized = normalizeCreativeStyle(null, tenant.name || 'Cliente', slug);
|
||||
clientSkill = compileClientSkillMarkdown(normalized, tenant.name || 'Cliente', slug);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
Pencil,
|
||||
} from 'lucide-react';
|
||||
import { PageHeader, Surface, EmptyState, SegmentedControl } from '@/components/ui';
|
||||
import ClientCreativeStylePanel from '@/components/ClientCreativeStylePanel';
|
||||
|
||||
export type CreativeFormat = 'estatico' | 'carrusel' | 'video';
|
||||
|
||||
@@ -108,7 +109,7 @@ export default function AgencyCreativesView({
|
||||
tenantId: string;
|
||||
clientName: string;
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState<'kanban' | 'fatiga' | 'cola_claude'>('kanban');
|
||||
const [activeTab, setActiveTab] = useState<'kanban' | 'estilo_cliente' | 'fatiga' | 'cola_claude'>('kanban');
|
||||
const [creatives, setCreatives] = useState<CreativeItem[]>([]);
|
||||
const [rules, setRules] = useState<FatigueRules | null>(null);
|
||||
const [requests, setRequests] = useState<GenerationRequest[]>([]);
|
||||
@@ -428,6 +429,7 @@ export default function AgencyCreativesView({
|
||||
value={activeTab}
|
||||
options={[
|
||||
{ id: 'kanban', label: `Kanban de Estados (${creatives.length})` },
|
||||
{ id: 'estilo_cliente', label: 'Estilo y Brief del Cliente' },
|
||||
{ id: 'fatiga', label: 'Detector de Fatiga & Reglas' },
|
||||
{ id: 'cola_claude', label: `Cola de Claude (${requests.filter((r) => r.status === 'pendiente' || r.status === 'en_proceso').length} activas)` },
|
||||
]}
|
||||
@@ -441,6 +443,11 @@ export default function AgencyCreativesView({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* 0. ESTILO CREATIVO Y BRIEF DEL CLIENTE */}
|
||||
{activeTab === 'estilo_cliente' && (
|
||||
<ClientCreativeStylePanel tenantId={tenantId} clientName={clientName} />
|
||||
)}
|
||||
|
||||
{/* 1. VISTA KANBAN */}
|
||||
{activeTab === 'kanban' && (
|
||||
<div className="overflow-x-auto pb-6">
|
||||
|
||||
1988
src/components/ClientCreativeStylePanel.tsx
Normal file
1988
src/components/ClientCreativeStylePanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ import {
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import ClientIntegrationsPanel from '@/components/ClientIntegrationsPanel';
|
||||
import ClientCreativeStylePanel from '@/components/ClientCreativeStylePanel';
|
||||
import { PageHeader, SegmentedControl } from '@/components/ui';
|
||||
import {
|
||||
DASHBOARD_MODULES,
|
||||
@@ -27,7 +28,7 @@ import {
|
||||
suggestModulesFromIntegrations,
|
||||
} from '@/lib/tenant-modules';
|
||||
|
||||
type Tab = 'general' | 'conexiones' | 'accesos' | 'modulos';
|
||||
type Tab = 'general' | 'estilo' | 'conexiones' | 'accesos' | 'modulos';
|
||||
|
||||
type Integ = {
|
||||
provider: string;
|
||||
@@ -304,12 +305,17 @@ export default function ClientFicha({
|
||||
onChange={setTab}
|
||||
options={[
|
||||
{ id: 'general', label: 'General' },
|
||||
{ id: 'estilo', label: 'Estilo Creativo (IA & Ads)' },
|
||||
{ id: 'conexiones', label: 'Conexiones' },
|
||||
{ id: 'accesos', label: 'Accesos' },
|
||||
{ id: 'modulos', label: 'Módulos' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{tab === 'estilo' && (
|
||||
<ClientCreativeStylePanel tenantId={tenantId} clientName={name} />
|
||||
)}
|
||||
|
||||
{tab === 'general' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<section className="lg:col-span-2 space-y-4 rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
|
||||
650
src/lib/creative-style.ts
Normal file
650
src/lib/creative-style.ts
Normal file
@@ -0,0 +1,650 @@
|
||||
export type CreativeB2BPersona = {
|
||||
name: string;
|
||||
role: string;
|
||||
goal: string;
|
||||
pain_point: string;
|
||||
buying_trigger: string;
|
||||
};
|
||||
|
||||
export type CreativeB2CPersona = {
|
||||
name: string;
|
||||
age_range: string;
|
||||
situation: string;
|
||||
pain_point: string;
|
||||
goal: string;
|
||||
};
|
||||
|
||||
export type CreativeObjection = {
|
||||
objection: string;
|
||||
rebuttal: string;
|
||||
};
|
||||
|
||||
export type CreativeColorRule = {
|
||||
name: string;
|
||||
hex: string;
|
||||
usage: string;
|
||||
};
|
||||
|
||||
export type ClientCreativeStyle = {
|
||||
version: 1;
|
||||
updated_at?: string;
|
||||
|
||||
/** 1. Quién es, líneas de negocio y prioridades actuales */
|
||||
identity: {
|
||||
summary: string;
|
||||
website: string;
|
||||
b2b_offering: string;
|
||||
b2c_offering: string;
|
||||
usp_differentiators: string;
|
||||
current_priorities: string;
|
||||
price_and_offer_framing: string;
|
||||
};
|
||||
|
||||
/** 2. A quién le hablamos (Buyer Personas B2B / B2C y dolores) */
|
||||
audiences: {
|
||||
b2b_personas: CreativeB2BPersona[];
|
||||
b2c_personas: CreativeB2CPersona[];
|
||||
customer_frustrations: string;
|
||||
desired_transformation: string;
|
||||
};
|
||||
|
||||
/** 3. Cómo habla (Voz, tono y reglas de copywriting) */
|
||||
voice: {
|
||||
tone: string;
|
||||
posture: string;
|
||||
language_and_pronoun: string;
|
||||
copy_structure_rules: string;
|
||||
english_terms_policy: string;
|
||||
power_words: string[];
|
||||
allowed_ctas: string[];
|
||||
};
|
||||
|
||||
/** 4. Argumentos que funcionan, objeciones, prueba social y hooks */
|
||||
arguments: {
|
||||
winning_arguments: string[];
|
||||
objections: CreativeObjection[];
|
||||
proof_points: string[];
|
||||
winning_hooks: string[];
|
||||
preferred_angles: string[];
|
||||
};
|
||||
|
||||
/** 5. Prohibido (Líneas rojas de copy, competencia e imagen) */
|
||||
prohibitions: {
|
||||
forbidden_items: string[];
|
||||
competitors_never_mention: string[];
|
||||
emoji_policy: string;
|
||||
testimonials_policy: string;
|
||||
};
|
||||
|
||||
/** 6. Identidad visual, tipografía y reglas de layout */
|
||||
visual: {
|
||||
colors: CreativeColorRule[];
|
||||
surface_rules: string;
|
||||
typography_headline: string;
|
||||
typography_body: string;
|
||||
logo_rules: string;
|
||||
image_text_rules: string;
|
||||
};
|
||||
|
||||
/** 7. Dirección de arte para IA / Freepik Mystic */
|
||||
image_ai: {
|
||||
photo_style: string;
|
||||
rule_1_5_seconds: string;
|
||||
forbidden_scenes: string;
|
||||
people_count_rules: string;
|
||||
negative_space_prompt: string;
|
||||
};
|
||||
|
||||
/** 8. Reglas de audiencia por campaña e instrucciones extra para Claude */
|
||||
campaign_rules: {
|
||||
b2c_detection_rules: string;
|
||||
b2b_detection_rules: string;
|
||||
fallback_audience_rule: string;
|
||||
extra_claude_instructions: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const BPS_DEFAULT_CREATIVE_STYLE: ClientCreativeStyle = {
|
||||
version: 1,
|
||||
identity: {
|
||||
summary:
|
||||
'Academia de idiomas con sede en Madrid y Málaga, clases presenciales, online e in-company.',
|
||||
website: 'blueparrotschool.com',
|
||||
b2b_offering:
|
||||
'Programas de inglés profesional para equipos. Diferencia frente a apps conversacionales tipo Twenix: profesor fijo, sesiones de 60 minutos, programas por sector (Inglés Legal, IT, Financiero, Comercial Internacional), modalidad híbrida. Es Entidad Organizadora de FUNDAE (gestiona la bonificación directamente, no a través de un partner).',
|
||||
b2c_offering:
|
||||
'Cursos de inglés para adultos, preparación de exámenes oficiales (Cambridge, Aptis, IELTS), intensivos, clases 1to1 y plataforma online.',
|
||||
usp_differentiators:
|
||||
'Profesor fijo asignado + sesiones completas de 60 min (progreso estructural frente a conversación suelta de 15-20 min en apps). Gestión integral FUNDAE como Entidad Organizadora oficial. Especialización real por vertical profesional.',
|
||||
current_priorities:
|
||||
'B2C: captación de matrículas de adultos y preparación de certificados oficiales (Cambridge / Aptis). B2B: captación de empresas que quieren aprovechar su crédito anual FUNDAE con programas in-company u online estructurados.',
|
||||
price_and_offer_framing:
|
||||
'En B2B destacar el coste bonificado vía FUNDAE (coste cero o casi cero para la empresa, expresado en coste neto o gestión bonificada). En B2C destacar el valor del programa estructurado con profesor fijo, sin recurrir nunca a descuentos agresivos ni cuentas atrás.',
|
||||
},
|
||||
audiences: {
|
||||
b2b_personas: [
|
||||
{
|
||||
name: 'Marta (RRHH / Formación)',
|
||||
role: 'Responsable de RRHH o Desarrollo de Talento',
|
||||
goal: 'Quiere un proveedor llave en mano que no le dé trabajo administrativo.',
|
||||
pain_point: 'Proveedores que obligan a RRHH a gestionar papeleo FUNDAE, perseguir asistencias o lidiar con quejas por cambios constantes de profesor.',
|
||||
buying_trigger: 'Gestión FUNDAE 100% resuelta por Blue Parrot, informes periódicos de asistencia y progreso, y un único interlocutor.',
|
||||
},
|
||||
{
|
||||
name: 'Carlos (Socio / Director de Despacho o Consultora)',
|
||||
role: 'Director General o Socio en firma legal, financiera o consultoría',
|
||||
goal: 'Que su equipo hable con clientes internacionales con solvencia y sin hacer el ridículo.',
|
||||
pain_point: 'Su equipo sabe gramática pero se bloquea al negociar, redactar contratos o defender propuestas en inglés técnico.',
|
||||
buying_trigger: 'Compra rigor y especialización sectorial (Inglés Legal, Financiero, IT), no "clases de inglés general".',
|
||||
},
|
||||
{
|
||||
name: 'Lucía (Directora Comercial / Internacional)',
|
||||
role: 'Directora Comercial o de Expansión Internacional',
|
||||
goal: 'Resultados medibles en poco tiempo para presentaciones, negociación y llamadas internacionales.',
|
||||
pain_point: 'Las apps conversacionales entretienen pero el equipo sigue sin cerrar reuniones complejas en inglés.',
|
||||
buying_trigger: 'Sesiones de 60 minutos enfocadas en casos reales de su puesto de trabajo.',
|
||||
},
|
||||
],
|
||||
b2c_personas: [
|
||||
{
|
||||
name: 'Adulto Profesional / Opositor (25-45 años)',
|
||||
age_range: '25-45 años',
|
||||
situation: 'Trabaja o busca salto laboral / necesita certificar nivel oficial (Cambridge B2/C1, Aptis, IELTS).',
|
||||
pain_point: 'Ha fracasado o se ha estancado con apps y cursos grabados; siente que pierde el tiempo sin un profesor que le corrija y le marque ritmo.',
|
||||
goal: 'Avanzar de nivel de forma real o aprobar su examen oficial con horario compatible con su jornada.',
|
||||
},
|
||||
],
|
||||
customer_frustrations:
|
||||
'Estancamiento en el "nivel intermedio eterno" por usar apps de 15 minutos con profesores distintos cada día que solo dan charla superficial sin estructura ni seguimiento.',
|
||||
desired_transformation:
|
||||
'Pasar de evitar hablar en inglés o depender de traductores a desenvolverse con seguridad en reuniones de trabajo o aprobar su certificación oficial a la primera.',
|
||||
},
|
||||
voice: {
|
||||
tone: 'Profesional, seguro, directo. Cercano sin ser coloquial. Nunca infantil, nunca grito de descuento.',
|
||||
posture:
|
||||
'"Formación profesional estructurada", no "aprende inglés fácil". Vendemos programas, no clases sueltas.',
|
||||
language_and_pronoun:
|
||||
'Español de España, tú o usted según el público: tú en B2C, tú en B2B cuando hablamos a la persona, usted nunca.',
|
||||
copy_structure_rules:
|
||||
'Frases cortas. Un dato concreto vale más que un adjetivo. En primary_text: 2-4 frases, máx. 300 caracteres; la frase 1 amplía el hook, frases 2-3 dan un beneficio o dato concreto, frase final cierra con el CTA sin enlaces ni hashtags.',
|
||||
english_terms_policy:
|
||||
'Inglés en el copy: solo nombres propios de exámenes (Cambridge, Aptis, IELTS) o términos sin traducción natural (in-company). Nada de "boost", "unlock", "level up".',
|
||||
power_words: [
|
||||
'Profesor fijo',
|
||||
'60 minutos',
|
||||
'Entidad Organizadora FUNDAE',
|
||||
'Inglés de tu sector',
|
||||
'Programa estructurado',
|
||||
'Madrid y Málaga + Online',
|
||||
],
|
||||
allowed_ctas: [
|
||||
'Más información',
|
||||
'Solicitar presupuesto',
|
||||
'Reservar',
|
||||
'Contactar',
|
||||
'Registrarte',
|
||||
],
|
||||
},
|
||||
arguments: {
|
||||
winning_arguments: [
|
||||
'Profesor fijo + 60 minutos: progreso real, no mantenimiento.',
|
||||
'FUNDAE gestionado por nosotros: coste cero o casi cero para la empresa.',
|
||||
'Programas por sector: tu equipo aprende el inglés de su trabajo (Legal, IT, Financiero, Comercial).',
|
||||
'Presencial en Madrid y Málaga + online: híbrido de verdad.',
|
||||
'Informes de asistencia y progreso para RRHH.',
|
||||
],
|
||||
objections: [
|
||||
{
|
||||
objection: 'Ya usamos una app',
|
||||
rebuttal: 'La app mantiene, no avanza. Sin profesor fijo ni sesiones de 60 min no se sale del nivel intermedio.',
|
||||
},
|
||||
{
|
||||
objection: 'Es caro',
|
||||
rebuttal: 'Bonificación FUNDAE gestionada directamente por nosotros; coste mínimo por empleado y mes.',
|
||||
},
|
||||
{
|
||||
objection: 'No tenemos tiempo',
|
||||
rebuttal: 'Sesiones in-company u online integradas dentro del horario laboral.',
|
||||
},
|
||||
],
|
||||
proof_points: [
|
||||
'Entidad Organizadora acreditada de FUNDAE (gestión directa sin intermediarios).',
|
||||
'Sedes físicas propias en Madrid y Málaga + aula virtual en directo.',
|
||||
'Sesiones completas de 60 minutos con el mismo profesor todo el curso.',
|
||||
],
|
||||
winning_hooks: [
|
||||
'Tu equipo no necesita más inglés. Necesita el inglés de su trabajo.',
|
||||
'FUNDAE lo paga. Nosotros lo gestionamos.',
|
||||
'Un profesor. Sesenta minutos. Cada semana. Así se avanza.',
|
||||
'El inglés legal no se aprende en una app.',
|
||||
],
|
||||
preferred_angles: [
|
||||
'objeción tiempo',
|
||||
'coste FUNDAE',
|
||||
'contraste app vs programa real',
|
||||
'especialización por sector (Legal / IT / Finanzas)',
|
||||
'cero carga para RRHH',
|
||||
'certificación oficial (Cambridge / Aptis)',
|
||||
],
|
||||
},
|
||||
prohibitions: {
|
||||
forbidden_items: [
|
||||
'Prometer niveles o plazos cerrados ("B2 en 3 meses").',
|
||||
'Comparar nombrando explícitamente a la competencia (ni Twenix ni ninguna).',
|
||||
'Descuentos agresivos, cuentas atrás, "última oportunidad".',
|
||||
'Fotografía de stock genérica de "gente feliz con portátil" con aspecto de banco de imágenes.',
|
||||
'Deformar el logo, recolorearlo o recrearlo (se coloca siempre el archivo oficial en plantilla).',
|
||||
'Empezar un hook por "¿Sabías que", "Descubre" o "Imagina".',
|
||||
],
|
||||
competitors_never_mention: ['Twenix', 'Preply', 'Italki', 'Open English', 'What\'s Up!'],
|
||||
emoji_policy:
|
||||
'Prohibidos los emojis en creatividades de imagen. En copy (primary_text), como mucho uno al inicio y solo en B2C (nunca en B2B).',
|
||||
testimonials_policy:
|
||||
'Prohibidos testimonios inventados o con nombre y cargo de personas que no existen. Si no hay un caso real aprobado, no se usa testimonio.',
|
||||
},
|
||||
visual: {
|
||||
colors: [
|
||||
{
|
||||
name: 'Azul Principal',
|
||||
hex: '#2A4D9C',
|
||||
usage: 'Superficie base principal de marca y fondos corporativos.',
|
||||
},
|
||||
{
|
||||
name: 'Amarillo Acento',
|
||||
hex: '#FBC02D',
|
||||
usage: 'Solo para resaltar una palabra clave, un dato o el CTA.',
|
||||
},
|
||||
{
|
||||
name: 'Negro',
|
||||
hex: '#000000',
|
||||
usage: 'Reservado a mensajes de formalidad o urgencia (FUNDAE que caduca, manifiesto).',
|
||||
},
|
||||
{
|
||||
name: 'Blanco',
|
||||
hex: '#FFFFFF',
|
||||
usage: 'Superficie base limpia y tipografía sobre fondos oscuros.',
|
||||
},
|
||||
],
|
||||
surface_rules:
|
||||
'Azul (#2A4D9C) y blanco (#FFFFFF) como base. Amarillo (#FBC02D) solo para resaltar una palabra, un dato o el CTA. Negro (#000000) reservado a mensajes de formalidad o urgencia (FUNDAE que caduca, manifiesto). Ningún otro color.',
|
||||
typography_headline: 'Poppins Extrabold (siempre en titulares / hook). Nunca otra fuente.',
|
||||
typography_body: 'Poppins Regular (en cuerpo y línea de apoyo / support).',
|
||||
logo_rules:
|
||||
'Lockup oficial Blue Parrot + SCHOOL en amarillo; versión con filete blanco sobre fondos azul o negro. Área de protección respetada, esquina inferior o superior, nunca centrado ni dominando (máx. 12% del ancho).',
|
||||
image_text_rules:
|
||||
'Máximo 2 niveles en imagen (titular hook + línea de apoyo support). El titular no supera 8 palabras. Margen seguro mínimo del 8% en todos los lados; nunca texto en el 15% superior ni inferior.',
|
||||
},
|
||||
image_ai: {
|
||||
photo_style:
|
||||
'Fotografía real de aula, oficina o reunión con luz natural; personas adultas de 25-50 años en contexto de trabajo; sin poses de catálogo. Si es ilustración o composición gráfica, geometría limpia, mucho aire y una sola idea.',
|
||||
rule_1_5_seconds:
|
||||
'La imagen tiene que leerse como clase de idioma en 1,5 s. Siempre un elemento que lo diga sin texto: pizarra con esquemas, libro o cuaderno de ejercicios abierto, auriculares en una sesión online, aula con sillas. Y los roles claros: el profesor de pie, señalando, o con la pizarra detrás; el alumno sentado, escuchando o escribiendo.',
|
||||
forbidden_scenes:
|
||||
'Nada que parezca firma de documentos, entrevista de trabajo, reunión de banco, consulta médica o foto de catálogo sonriendo a cámara sin contexto de clase.',
|
||||
people_count_rules:
|
||||
'Una persona basta si el mensaje es individual (adulto en sesión online con auriculares y portátil, mirando a cámara o a la pantalla). Dos personas solo si el mensaje es la relación profesor-alumno. Nunca grupos genéricos.',
|
||||
negative_space_prompt:
|
||||
'the upper third of the frame is empty wall or sky. no text, no logos, no watermarks, photorealistic, vertical 4:5 composition with clear negative space for a headline.',
|
||||
},
|
||||
campaign_rules: {
|
||||
b2c_detection_rules:
|
||||
'Campañas B2C (Matrículas, Intensivos, Cambridge, Aptis, Adultos, Plataforma, Otros Idiomas): dolor personal, apps que no avanzan, examen oficial, horario. Habla siempre de "tu inglés".',
|
||||
b2b_detection_rules:
|
||||
'Campañas B2B (Empresas, FUNDAE, In-company, Equipos, nombres de sector): coste bonificado, programas por sector, informes para RRHH, el equipo que habla con clientes internacionales. Habla siempre de "tu equipo".',
|
||||
fallback_audience_rule:
|
||||
'Si el brief no trae audience y no se puede deducir del nombre de campaña, se pregunta en notes y se genera en B2C.',
|
||||
extra_claude_instructions: '',
|
||||
},
|
||||
};
|
||||
|
||||
export function createEmptyCreativeStyle(clientName: string, slug: string): ClientCreativeStyle {
|
||||
if (slug === 'bps' || slug === 'blue-parrot-school') {
|
||||
return structuredClone(BPS_DEFAULT_CREATIVE_STYLE);
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
identity: {
|
||||
summary: `${clientName} — definir actividad principal, sedes y propuesta de valor.`,
|
||||
website: '',
|
||||
b2b_offering: '',
|
||||
b2c_offering: '',
|
||||
usp_differentiators: '',
|
||||
current_priorities: '',
|
||||
price_and_offer_framing: '',
|
||||
},
|
||||
audiences: {
|
||||
b2b_personas: [],
|
||||
b2c_personas: [],
|
||||
customer_frustrations: '',
|
||||
desired_transformation: '',
|
||||
},
|
||||
voice: {
|
||||
tone: 'Profesional, directo y cercano.',
|
||||
posture: '',
|
||||
language_and_pronoun: 'Español de España, tratamiento de tú.',
|
||||
copy_structure_rules: 'Frases cortas y directas. Un dato concreto vale más que un adjetivo.',
|
||||
english_terms_policy: '',
|
||||
power_words: [],
|
||||
allowed_ctas: ['Más información', 'Solicitar presupuesto', 'Reservar', 'Contactar', 'Registrarte'],
|
||||
},
|
||||
arguments: {
|
||||
winning_arguments: [],
|
||||
objections: [],
|
||||
proof_points: [],
|
||||
winning_hooks: [],
|
||||
preferred_angles: ['dolor', 'solución', 'prueba social', 'objeción precio/tiempo', 'autoridad'],
|
||||
},
|
||||
prohibitions: {
|
||||
forbidden_items: [
|
||||
'Promesas irreales o plazos garantizados sin base.',
|
||||
'Fotografía de stock genérica con aspecto artificial.',
|
||||
'Deformar o recrear el logo mediante IA.',
|
||||
],
|
||||
competitors_never_mention: [],
|
||||
emoji_policy: 'Sin emojis en imagen. En copy, máximo uno al inicio si aplica.',
|
||||
testimonials_policy: 'Solo casos reales aprobados por el cliente; nunca testimonios ficticios.',
|
||||
},
|
||||
visual: {
|
||||
colors: [
|
||||
{ name: 'Primario', hex: '#111827', usage: 'Color base de marca' },
|
||||
{ name: 'Acento', hex: '#2563EB', usage: 'Resaltar palabra clave o CTA' },
|
||||
{ name: 'Blanco', hex: '#FFFFFF', usage: 'Fondos limpios y texto sobre oscuro' },
|
||||
],
|
||||
surface_rules: '',
|
||||
typography_headline: '',
|
||||
typography_body: '',
|
||||
logo_rules: '',
|
||||
image_text_rules:
|
||||
'Máximo 2 niveles (titular ≤ 8 palabras + apoyo ≤ 12 palabras). Margen mínimo del 8% en todos los lados.',
|
||||
},
|
||||
image_ai: {
|
||||
photo_style: 'Fotografía realista con luz natural, personas reales en contexto del servicio, sin poses forzadas.',
|
||||
rule_1_5_seconds: 'La escena debe mostrar claramente el producto o servicio en menos de 1,5 segundos sin necesidad de leer el texto.',
|
||||
forbidden_scenes: 'Escenas ambiguas que puedan confundirse con otro sector.',
|
||||
people_count_rules: '1 persona si el mensaje es individual; 2 personas si muestra interacción cliente-profesional.',
|
||||
negative_space_prompt:
|
||||
'the upper third of the frame is empty wall or sky. no text, no logos, no watermarks, photorealistic, vertical 4:5 composition with clear negative space for a headline.',
|
||||
},
|
||||
campaign_rules: {
|
||||
b2c_detection_rules: '',
|
||||
b2b_detection_rules: '',
|
||||
fallback_audience_rule: 'Si hay duda sobre la audiencia, priorizar el perfil principal del cliente.',
|
||||
extra_claude_instructions: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCreativeStyle(
|
||||
raw: Partial<ClientCreativeStyle> | null | undefined,
|
||||
clientName: string,
|
||||
slug: string
|
||||
): ClientCreativeStyle {
|
||||
const base = createEmptyCreativeStyle(clientName, slug);
|
||||
if (!raw || typeof raw !== 'object' || Object.keys(raw).length === 0) {
|
||||
return base;
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
updated_at: raw.updated_at || base.updated_at,
|
||||
identity: { ...base.identity, ...(raw.identity || {}) },
|
||||
audiences: {
|
||||
...base.audiences,
|
||||
...(raw.audiences || {}),
|
||||
b2b_personas: Array.isArray(raw.audiences?.b2b_personas)
|
||||
? raw.audiences!.b2b_personas
|
||||
: base.audiences.b2b_personas,
|
||||
b2c_personas: Array.isArray(raw.audiences?.b2c_personas)
|
||||
? raw.audiences!.b2c_personas
|
||||
: base.audiences.b2c_personas,
|
||||
},
|
||||
voice: {
|
||||
...base.voice,
|
||||
...(raw.voice || {}),
|
||||
power_words: Array.isArray(raw.voice?.power_words) ? raw.voice!.power_words : base.voice.power_words,
|
||||
allowed_ctas: Array.isArray(raw.voice?.allowed_ctas) ? raw.voice!.allowed_ctas : base.voice.allowed_ctas,
|
||||
},
|
||||
arguments: {
|
||||
...base.arguments,
|
||||
...(raw.arguments || {}),
|
||||
winning_arguments: Array.isArray(raw.arguments?.winning_arguments)
|
||||
? raw.arguments!.winning_arguments
|
||||
: base.arguments.winning_arguments,
|
||||
objections: Array.isArray(raw.arguments?.objections)
|
||||
? raw.arguments!.objections
|
||||
: base.arguments.objections,
|
||||
proof_points: Array.isArray(raw.arguments?.proof_points)
|
||||
? raw.arguments!.proof_points
|
||||
: base.arguments.proof_points,
|
||||
winning_hooks: Array.isArray(raw.arguments?.winning_hooks)
|
||||
? raw.arguments!.winning_hooks
|
||||
: base.arguments.winning_hooks,
|
||||
preferred_angles: Array.isArray(raw.arguments?.preferred_angles)
|
||||
? raw.arguments!.preferred_angles
|
||||
: base.arguments.preferred_angles,
|
||||
},
|
||||
prohibitions: {
|
||||
...base.prohibitions,
|
||||
...(raw.prohibitions || {}),
|
||||
forbidden_items: Array.isArray(raw.prohibitions?.forbidden_items)
|
||||
? raw.prohibitions!.forbidden_items
|
||||
: base.prohibitions.forbidden_items,
|
||||
competitors_never_mention: Array.isArray(raw.prohibitions?.competitors_never_mention)
|
||||
? raw.prohibitions!.competitors_never_mention
|
||||
: base.prohibitions.competitors_never_mention,
|
||||
},
|
||||
visual: {
|
||||
...base.visual,
|
||||
...(raw.visual || {}),
|
||||
colors: Array.isArray(raw.visual?.colors) ? raw.visual!.colors : base.visual.colors,
|
||||
},
|
||||
image_ai: { ...base.image_ai, ...(raw.image_ai || {}) },
|
||||
campaign_rules: { ...base.campaign_rules, ...(raw.campaign_rules || {}) },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compiles the structured ClientCreativeStyle into the exact Markdown skill
|
||||
* injected into Claude in WF3 (`/api/integrations/skills`).
|
||||
*/
|
||||
export function compileClientSkillMarkdown(
|
||||
style: ClientCreativeStyle,
|
||||
clientName: string,
|
||||
slug: string
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(`# Skill de cliente · ${clientName} (slug: ${slug})`);
|
||||
lines.push('');
|
||||
lines.push(
|
||||
`> Esta skill define cómo habla y cómo se ve ${clientName} en cualquier creatividad. Se inyecta como contexto fijo a Claude en cada petición de relevo. Si algo aquí contradice un brief puntual, manda esta skill.`
|
||||
);
|
||||
lines.push('');
|
||||
|
||||
// 1. Quién es
|
||||
lines.push('## Quién es');
|
||||
lines.push('');
|
||||
if (style.identity.summary) lines.push(style.identity.summary);
|
||||
lines.push('');
|
||||
if (style.identity.b2b_offering) {
|
||||
lines.push(`- **B2B (empresas):** ${style.identity.b2b_offering}`);
|
||||
}
|
||||
if (style.identity.b2c_offering) {
|
||||
lines.push(`- **B2C (particulares):** ${style.identity.b2c_offering}`);
|
||||
}
|
||||
if (style.identity.usp_differentiators) {
|
||||
lines.push(`- **Diferenciación clave:** ${style.identity.usp_differentiators}`);
|
||||
}
|
||||
if (style.identity.current_priorities) {
|
||||
lines.push(`- **Prioridades actuales y foco comercial:** ${style.identity.current_priorities}`);
|
||||
}
|
||||
if (style.identity.price_and_offer_framing) {
|
||||
lines.push(`- **Enfoque de precio / oferta:** ${style.identity.price_and_offer_framing}`);
|
||||
}
|
||||
if (style.identity.website) {
|
||||
lines.push('');
|
||||
lines.push(`Web: ${style.identity.website}`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
// 2. A quién le hablamos
|
||||
lines.push('## A quién le hablamos');
|
||||
lines.push('');
|
||||
if (style.audiences.b2b_personas.length > 0) {
|
||||
lines.push('**B2B**');
|
||||
for (const p of style.audiences.b2b_personas) {
|
||||
const details = [p.role, p.goal, p.pain_point, p.buying_trigger].filter(Boolean).join('. ');
|
||||
lines.push(`- *${p.name}* — ${details}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
if (style.audiences.b2c_personas.length > 0) {
|
||||
lines.push('**B2C**');
|
||||
for (const p of style.audiences.b2c_personas) {
|
||||
const details = [p.age_range, p.situation, p.pain_point, p.goal].filter(Boolean).join('. ');
|
||||
lines.push(`- *${p.name}* — ${details}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
if (style.audiences.customer_frustrations) {
|
||||
lines.push(`- **Frustración principal previa:** ${style.audiences.customer_frustrations}`);
|
||||
}
|
||||
if (style.audiences.desired_transformation) {
|
||||
lines.push(`- **Transformación que buscan:** ${style.audiences.desired_transformation}`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
// 3. Cómo habla
|
||||
lines.push('## Cómo habla');
|
||||
lines.push('');
|
||||
if (style.voice.tone) lines.push(`- **Tono:** ${style.voice.tone}`);
|
||||
if (style.voice.posture) lines.push(`- **Postura:** ${style.voice.posture}`);
|
||||
if (style.voice.language_and_pronoun) lines.push(`- **Idioma y tratamiento:** ${style.voice.language_and_pronoun}`);
|
||||
if (style.voice.copy_structure_rules) lines.push(`- **Estructura y estilo:** ${style.voice.copy_structure_rules}`);
|
||||
if (style.voice.english_terms_policy) lines.push(`- **Idiomas / anglicismos en el copy:** ${style.voice.english_terms_policy}`);
|
||||
if (style.voice.power_words.length > 0) {
|
||||
lines.push(`- **Conceptos fuerza:** ${style.voice.power_words.join(' · ')}`);
|
||||
}
|
||||
if (style.voice.allowed_ctas.length > 0) {
|
||||
lines.push(`- **CTAs permitidos:** ${style.voice.allowed_ctas.join(', ')}`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
// 4. Argumentos y objeciones
|
||||
if (style.arguments.winning_arguments.length > 0) {
|
||||
lines.push('### Argumentos que funcionan');
|
||||
style.arguments.winning_arguments.forEach((arg, idx) => {
|
||||
lines.push(`${idx + 1}. ${arg}`);
|
||||
});
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (style.arguments.objections.length > 0) {
|
||||
lines.push('### Objeciones a desactivar');
|
||||
for (const obj of style.arguments.objections) {
|
||||
lines.push(`- "${obj.objection}" → ${obj.rebuttal}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (style.arguments.proof_points.length > 0) {
|
||||
lines.push('### Pruebas y datos reales verificados');
|
||||
for (const pt of style.arguments.proof_points) {
|
||||
lines.push(`- ${pt}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (style.arguments.preferred_angles.length > 0) {
|
||||
lines.push(`### Ángulos recomendados para rotar\n- ${style.arguments.preferred_angles.join(' · ')}\n`);
|
||||
}
|
||||
|
||||
// 5. Prohibido
|
||||
lines.push('## Prohibido');
|
||||
for (const item of style.prohibitions.forbidden_items) {
|
||||
lines.push(`- ${item}`);
|
||||
}
|
||||
if (style.prohibitions.competitors_never_mention.length > 0) {
|
||||
lines.push(
|
||||
`- Prohibido mencionar a competidores (${style.prohibitions.competitors_never_mention.join(', ')}).`
|
||||
);
|
||||
}
|
||||
if (style.prohibitions.emoji_policy) {
|
||||
lines.push(`- **Emojis:** ${style.prohibitions.emoji_policy}`);
|
||||
}
|
||||
if (style.prohibitions.testimonials_policy) {
|
||||
lines.push(`- **Testimonios:** ${style.prohibitions.testimonials_policy}`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
// 6. Identidad visual + Dirección de arte IA
|
||||
lines.push('## Identidad visual');
|
||||
lines.push('');
|
||||
if (style.visual.colors.length > 0) {
|
||||
const paletteStr = style.visual.colors
|
||||
.map((c) => `${c.name} \`${c.hex}\` (${c.usage})`)
|
||||
.join(' · ');
|
||||
lines.push(`- **Paleta exacta y única:** ${paletteStr}`);
|
||||
}
|
||||
if (style.visual.surface_rules) {
|
||||
lines.push(`- **Uso de superficies:** ${style.visual.surface_rules}`);
|
||||
}
|
||||
if (style.visual.typography_headline || style.visual.typography_body) {
|
||||
lines.push(
|
||||
`- **Tipografía:** Titulares: ${style.visual.typography_headline || 'N/A'}. Cuerpo/Apoyo: ${style.visual.typography_body || 'N/A'}.`
|
||||
);
|
||||
}
|
||||
if (style.visual.logo_rules) {
|
||||
lines.push(`- **Logo:** ${style.visual.logo_rules}`);
|
||||
}
|
||||
if (style.image_ai.photo_style) {
|
||||
lines.push(`- **Estilo de imagen:** ${style.image_ai.photo_style}`);
|
||||
}
|
||||
if (style.image_ai.rule_1_5_seconds) {
|
||||
lines.push(`- **Regla de lectura en 1,5 s:** ${style.image_ai.rule_1_5_seconds}`);
|
||||
}
|
||||
if (style.image_ai.forbidden_scenes) {
|
||||
lines.push(`- **Escenas a evitar en imagen:** ${style.image_ai.forbidden_scenes}`);
|
||||
}
|
||||
if (style.image_ai.people_count_rules) {
|
||||
lines.push(`- **Personas en escena:** ${style.image_ai.people_count_rules}`);
|
||||
}
|
||||
if (style.image_ai.negative_space_prompt) {
|
||||
lines.push(`- **Espacio negativo (image_prompt):** ${style.image_ai.negative_space_prompt}`);
|
||||
}
|
||||
if (style.visual.image_text_rules) {
|
||||
lines.push(`- **Texto en imagen:** ${style.visual.image_text_rules}`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
// 7. Audiencia por campaña
|
||||
lines.push('## Audiencia por campaña');
|
||||
lines.push('');
|
||||
lines.push('El brief trae `audience`. Si no viene, se deduce del nombre de campaña:');
|
||||
if (style.campaign_rules.b2c_detection_rules) {
|
||||
lines.push(`- **B2C:** ${style.campaign_rules.b2c_detection_rules}`);
|
||||
}
|
||||
if (style.campaign_rules.b2b_detection_rules) {
|
||||
lines.push(`- **B2B:** ${style.campaign_rules.b2b_detection_rules}`);
|
||||
}
|
||||
if (style.campaign_rules.fallback_audience_rule) {
|
||||
lines.push(style.campaign_rules.fallback_audience_rule);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
// 8. Ejemplos de hooks
|
||||
if (style.arguments.winning_hooks.length > 0) {
|
||||
lines.push('## Ejemplos de hooks que han funcionado (referencia de registro, no para copiar)');
|
||||
for (const h of style.arguments.winning_hooks) {
|
||||
lines.push(`- "${h}"`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (style.campaign_rules.extra_claude_instructions?.trim()) {
|
||||
lines.push('## Instrucciones adicionales específicas del cliente');
|
||||
lines.push(style.campaign_rules.extra_claude_instructions.trim());
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
Reference in New Issue
Block a user