feat(skills): update BPS & static skills, add audience to brief, inline angle/hook editing and montage notes

This commit is contained in:
Marcos Fargas
2026-10-12 20:32:15 +02:00
parent 34ce46774d
commit 317a3aa46d
9 changed files with 277 additions and 55 deletions

View File

@@ -57,6 +57,15 @@ Web: blueparrotschool.com
- **Tipografía:** Poppins. Extrabold en titulares, Regular en cuerpo. Nunca otra fuente.
- **Logo:** 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.
- **Estilo de imagen:** fotografía real de aula, oficina o reunión con luz natural; personas adultas de 25-50 en contexto de trabajo; sin poses de catálogo. Si es ilustración o composición gráfica, geometría limpia, mucho aire, una sola idea.
- **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. Nada que parezca firma de documentos, entrevista de trabajo, reunión de banco o consulta.
- **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.
## Audiencia por campaña
El brief trae `audience`. Si no viene, se deduce del nombre de campaña:
- **B2C** (`Matrículas`, `Intensivos`, `Cambridge`, `Aptis`, `Adultos`): dolor personal, apps que no avanzan, examen, horario. Habla de "tu inglés".
- **B2B** (`Empresas`, `FUNDAE`, `In-company`, `Equipos`, nombres de sector): coste bonificado, programas por sector, informes para RRHH, el equipo que habla con clientes. Habla de "tu equipo".
Si hay duda, se pregunta en `notes` y se genera en B2C.
- **Texto en imagen:** máximo 2 niveles (titular + apoyo). El titular no supera 8 palabras. Nada de texto tocando bordes: margen mínimo 8 % del lado corto.
## Ejemplos de hooks que han funcionado (referencia de registro, no para copiar)

View File

@@ -57,6 +57,15 @@ Web: blueparrotschool.com
- **Tipografía:** Poppins. Extrabold en titulares, Regular en cuerpo. Nunca otra fuente.
- **Logo:** 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.
- **Estilo de imagen:** fotografía real de aula, oficina o reunión con luz natural; personas adultas de 25-50 en contexto de trabajo; sin poses de catálogo. Si es ilustración o composición gráfica, geometría limpia, mucho aire, una sola idea.
- **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. Nada que parezca firma de documentos, entrevista de trabajo, reunión de banco o consulta.
- **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.
## Audiencia por campaña
El brief trae `audience`. Si no viene, se deduce del nombre de campaña:
- **B2C** (`Matrículas`, `Intensivos`, `Cambridge`, `Aptis`, `Adultos`): dolor personal, apps que no avanzan, examen, horario. Habla de "tu inglés".
- **B2B** (`Empresas`, `FUNDAE`, `In-company`, `Equipos`, nombres de sector): coste bonificado, programas por sector, informes para RRHH, el equipo que habla con clientes. Habla de "tu equipo".
Si hay duda, se pregunta en `notes` y se genera en B2C.
- **Texto en imagen:** máximo 2 niveles (titular + apoyo). El titular no supera 8 palabras. Nada de texto tocando bordes: margen mínimo 8 % del lado corto.
## Ejemplos de hooks que han funcionado (referencia de registro, no para copiar)

View File

@@ -16,10 +16,13 @@ Un objeto JSON con exactamente estos campos:
"cta": "uno de: 'Más información', 'Solicitar presupuesto', 'Reservar', 'Contactar', 'Registrarte'",
"image_prompt": "prompt en inglés para el generador de imagen, SIN texto ni logo, describiendo escena, personas, luz, encuadre y paleta",
"layout": "una de: 'foto-con-franja' (foto + franja de color con titular), 'tipografico' (solo color y texto), 'dato-grande' (una cifra enorme + contexto)",
"composition_notes": "instrucciones de montaje para diseño en 3-5 líneas: dónde va la franja o el texto, qué palabra va en color de acento, posición del logo, qué recortar. Es lo que ve el diseñador en la tarjeta.",
"rationale": "una frase: por qué este ángulo puede relevar a la fatigada"
}
```
Y fuera de las variantes, en `notes`, el diagnóstico de la fatigada en 2-3 frases (qué métrica se movió, si es mensaje o saturación) y cualquier dato que faltara para decidir mejor.
## Reglas de ángulo
- Cada variante ataca un **ángulo distinto** entre sí y distinto del de la creatividad fatigada (viene en `avoid_angles`).
@@ -45,6 +48,9 @@ Un objeto JSON con exactamente estos campos:
- Siempre en inglés, 40-80 palabras.
- Describe: sujeto, acción, entorno, luz, encuadre (vertical 4:5, espacio libre arriba o abajo para texto), paleta en términos de la skill del cliente.
- **La escena tiene que contar el servicio sin texto.** Incluye siempre un objeto o gesto que identifique la actividad (lo que diga la skill del cliente) y deja claros los roles si hay dos personas. Evita escenas ambiguas que puedan leerse como otra cosa: firma de papeles, entrevista, reunión de banco.
- Pide explícitamente el aire para el titular con una fracción: `the upper third of the frame is empty wall or sky`. "Negative space" a secas no basta.
- Prefiere una persona a dos cuando el mensaje es individual; nunca más de dos.
- Termina siempre con: `no text, no logos, no watermarks, photorealistic, vertical 4:5 composition with clear negative space for a headline`.
- Nunca pidas personas reconocibles, marcas de terceros, ni niños.
- Una generación por variante. No pidas variaciones.

View File

@@ -16,10 +16,13 @@ Un objeto JSON con exactamente estos campos:
"cta": "uno de: 'Más información', 'Solicitar presupuesto', 'Reservar', 'Contactar', 'Registrarte'",
"image_prompt": "prompt en inglés para el generador de imagen, SIN texto ni logo, describiendo escena, personas, luz, encuadre y paleta",
"layout": "una de: 'foto-con-franja' (foto + franja de color con titular), 'tipografico' (solo color y texto), 'dato-grande' (una cifra enorme + contexto)",
"composition_notes": "instrucciones de montaje para diseño en 3-5 líneas: dónde va la franja o el texto, qué palabra va en color de acento, posición del logo, qué recortar. Es lo que ve el diseñador en la tarjeta.",
"rationale": "una frase: por qué este ángulo puede relevar a la fatigada"
}
```
Y fuera de las variantes, en `notes`, el diagnóstico de la fatigada en 2-3 frases (qué métrica se movió, si es mensaje o saturación) y cualquier dato que faltara para decidir mejor.
## Reglas de ángulo
- Cada variante ataca un **ángulo distinto** entre sí y distinto del de la creatividad fatigada (viene en `avoid_angles`).
@@ -45,6 +48,9 @@ Un objeto JSON con exactamente estos campos:
- Siempre en inglés, 40-80 palabras.
- Describe: sujeto, acción, entorno, luz, encuadre (vertical 4:5, espacio libre arriba o abajo para texto), paleta en términos de la skill del cliente.
- **La escena tiene que contar el servicio sin texto.** Incluye siempre un objeto o gesto que identifique la actividad (lo que diga la skill del cliente) y deja claros los roles si hay dos personas. Evita escenas ambiguas que puedan leerse como otra cosa: firma de papeles, entrevista, reunión de banco.
- Pide explícitamente el aire para el titular con una fracción: `the upper third of the frame is empty wall or sky`. "Negative space" a secas no basta.
- Prefiere una persona a dos cuando el mensaje es individual; nunca más de dos.
- Termina siempre con: `no text, no logos, no watermarks, photorealistic, vertical 4:5 composition with clear negative space for a headline`.
- Nunca pidas personas reconocibles, marcas de terceros, ni niños.
- Una generación por variante. No pidas variaciones.

View File

@@ -84,10 +84,20 @@ export async function POST(req: NextRequest) {
// Get tenant slug
const { data: tenant } = await admin
.from('tenants')
.select('slug, name')
.select('slug, name, campaign_audience')
.eq('id', tenantId)
.maybeSingle();
const campaignName = body.campaign_name || null;
const campaignAudienceMap = (tenant as any)?.campaign_audience || {};
let audience: 'b2b' | 'b2c' | null = body.audience || null;
if (!audience && campaignName && campaignAudienceMap && typeof campaignAudienceMap === 'object' && campaignAudienceMap[campaignName]) {
const val = String(campaignAudienceMap[campaignName]).toLowerCase().trim();
if (val === 'b2b' || val === 'b2c') {
audience = val;
}
}
const brief = {
tenant_id: tenantId,
client_id: tenantId,
@@ -96,8 +106,9 @@ export async function POST(req: NextRequest) {
source_creative_id: body.source_creative_id || null,
format: body.format || 'estatico',
variants_requested: Number(body.variants_requested || 3),
campaign_name: body.campaign_name || null,
campaign_name: campaignName,
adset_id: body.adset_id || null,
audience,
notes: body.notes || null,
};

View File

@@ -48,7 +48,12 @@ export async function POST(req: NextRequest) {
}
}
const toInsert = items.map((c: any) => ({
const toInsert = items.map((c: any) => {
const meta = { ...(c.meta || {}) };
if (c.composition_notes) {
meta.composition_notes = c.composition_notes;
}
return {
tenant_id: tenantId,
campaign_name: c.campaign_name || null,
adset_id: c.adset_id || null,
@@ -66,8 +71,9 @@ export async function POST(req: NextRequest) {
created_by: 'claude', // creadas por Claude
parent_id: c.parent_id || defaultParentId,
generation_request_id: c.generation_request_id || genRequestId || null,
meta: c.meta || {},
}));
meta,
};
});
const { data: inserted, error } = await adminClient
.from('creatives')

View File

@@ -30,7 +30,7 @@ export async function POST(req: NextRequest) {
// 1. Obtener la creatividad a marcar
const { data: creative, error: getErr } = await adminClient
.from('creatives')
.select('*, tenant:tenants(slug, name)')
.select('*, tenant:tenants(slug, name, campaign_audience)')
.eq('id', creativeId)
.eq('tenant_id', tenantId)
.maybeSingle();
@@ -77,7 +77,17 @@ export async function POST(req: NextRequest) {
if (rules?.auto_request_relief) {
const avoidAngles = creative.angle ? [creative.angle] : [];
const clientSlug = creative.tenant?.slug || 'bps';
const clientSlug = (creative.tenant as any)?.slug || 'bps';
const campaignAudienceMap = (creative.tenant as any)?.campaign_audience || {};
const campaignName = creative.campaign_name || '';
let audience: 'b2b' | 'b2c' | null = null;
if (campaignName && campaignAudienceMap && typeof campaignAudienceMap === 'object' && campaignAudienceMap[campaignName]) {
const val = String(campaignAudienceMap[campaignName]).toLowerCase().trim();
if (val === 'b2b' || val === 'b2c') {
audience = val;
}
}
const deadline = new Date();
deadline.setDate(deadline.getDate() + 1); // 24 horas después
@@ -94,6 +104,7 @@ export async function POST(req: NextRequest) {
avoid_angles: avoidAngles,
campaign_name: creative.campaign_name || 'General',
adset_id: creative.adset_id || null,
audience,
language: 'es',
deadline: deadline.toISOString(),
};

View File

@@ -4,6 +4,8 @@ import { createClient } from '@supabase/supabase-js';
import fs from 'fs';
import path from 'path';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const auth = await authenticateIntegrationRequest(req, { requireTenant: true });
if (!auth.ok) {
@@ -61,7 +63,8 @@ export async function GET(req: NextRequest) {
}
}
return NextResponse.json({
return NextResponse.json(
{
data: {
client_skill: clientSkill,
format_skill: formatSkill,
@@ -69,5 +72,11 @@ export async function GET(req: NextRequest) {
// Compatibilidad en primer nivel
client_skill: clientSkill,
format_skill: formatSkill,
});
},
{
headers: {
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
},
}
);
}

View File

@@ -25,6 +25,7 @@ import {
BarChart2,
FileCheck2,
Filter,
Pencil,
} from 'lucide-react';
import { PageHeader, Surface, EmptyState, SegmentedControl } from '@/components/ui';
@@ -49,7 +50,8 @@ export type CreativeItem = {
parent_id?: string | null;
generation_request_id?: string | null;
asset_final: boolean;
created_by?: string;
title?: string | null;
meta?: Record<string, any> | null;
created_at: string;
updated_at: string;
metrics?: {
@@ -132,6 +134,42 @@ export default function AgencyCreativesView({
const [detailCreative, setDetailCreative] = useState<CreativeItem | null>(null);
const [detailEvents, setDetailEvents] = useState<any[]>([]);
// Edición inline de ángulo y hook
const [inlineEditingId, setInlineEditingId] = useState<string | null>(null);
const [inlineAngle, setInlineAngle] = useState('');
const [inlineHook, setInlineHook] = useState('');
const [savingInline, setSavingInline] = useState(false);
const handleSaveInline = async (creativeId: string) => {
setSavingInline(true);
try {
const res = await fetch(`/api/creatives/${creativeId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
angle: inlineAngle.trim() || null,
hook: inlineHook.trim() || null,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || 'Error al guardar');
}
setCreatives((prev) =>
prev.map((c) =>
c.id === creativeId
? { ...c, angle: inlineAngle.trim() || null, hook: inlineHook.trim() || null }
: c
)
);
setInlineEditingId(null);
} catch (err: any) {
alert(err.message);
} finally {
setSavingInline(false);
}
};
// 1. Cargar datos
const loadData = useCallback(async () => {
setLoading(true);
@@ -477,31 +515,134 @@ export default function AgencyCreativesView({
</div>
</div>
{/* Info Titular y Copy */}
<div className="space-y-1 mb-2.5">
{/* Info Titular, Hook, Montaje y Copy con edición inline */}
{inlineEditingId === c.id ? (
<div
className="p-2.5 rounded-xl bg-gray-50 border border-brand-300 space-y-2 mb-3 text-xs"
onClick={(e) => e.stopPropagation()}
>
<div>
<label className="text-[10px] font-semibold text-gray-500 block uppercase tracking-wider">
Ángulo
</label>
<input
type="text"
value={inlineAngle}
onChange={(e) => setInlineAngle(e.target.value)}
placeholder="ej: dolor_tiempo, prueba_social, certificados..."
className="w-full mt-0.5 px-2 py-1 text-xs rounded border border-gray-300 bg-white"
/>
</div>
<div>
<label className="text-[10px] font-semibold text-gray-500 block uppercase tracking-wider">
Hook / Gancho
</label>
<textarea
rows={2}
value={inlineHook}
onChange={(e) => setInlineHook(e.target.value)}
placeholder="Gancho de la creatividad o nombre del anuncio..."
className="w-full mt-0.5 px-2 py-1 text-xs rounded border border-gray-300 bg-white resize-none"
/>
</div>
<div className="flex items-center justify-end gap-1.5 pt-1">
<button
type="button"
disabled={savingInline}
onClick={() => setInlineEditingId(null)}
className="px-2 py-1 rounded text-[11px] font-medium text-gray-600 hover:bg-gray-200"
>
Cancelar
</button>
<button
type="button"
disabled={savingInline}
onClick={() => void handleSaveInline(c.id)}
className="px-2.5 py-1 rounded text-[11px] font-semibold text-white bg-brand-500 hover:bg-brand-600"
>
{savingInline ? 'Guardando…' : 'Guardar'}
</button>
</div>
</div>
) : (
<div className="space-y-1.5 mb-2.5">
{/* Titular */}
<h4 className="text-xs font-bold text-gray-900 line-clamp-1">
{c.headline || c.hook || 'Sin titular'}
{c.headline || c.title || 'Sin titular'}
</h4>
{/* Hook con botón de edición rápida */}
<div className="flex items-start justify-between gap-1 group/hook">
<p className="text-[11px] text-gray-700 leading-snug">
<span className="font-semibold text-gray-900">Hook: </span>
{c.hook ? (
<span>{c.hook}</span>
) : (
<span className="text-amber-600 italic font-medium">Sin hook definido</span>
)}
</p>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setInlineEditingId(c.id);
setInlineAngle(c.angle || '');
setInlineHook(c.hook || '');
}}
className="text-gray-400 hover:text-brand-600 shrink-0 p-0.5 rounded transition-colors"
title="Editar Hook y Ángulo"
>
<Pencil className="w-3 h-3" />
</button>
</div>
{/* Montaje / composition_notes debajo del hook */}
{c.meta?.composition_notes && (
<div className="rounded-lg bg-amber-50/90 p-2 border border-amber-200/60 text-[11px] space-y-0.5">
<span className="font-semibold text-amber-950 block text-[10px] uppercase tracking-wider">
Montaje
</span>
<p className="text-amber-900 whitespace-pre-line leading-relaxed text-[11px]">
{c.meta.composition_notes}
</p>
</div>
)}
{/* Copy principal */}
{c.primary_text ? (
<p className="text-[11px] text-gray-600 line-clamp-2 leading-relaxed">
{c.primary_text}
</p>
) : null}
</div>
{/* Meta: Ángulo & Campaña */}
<div className="flex flex-wrap items-center gap-1 mb-3 text-[10px] text-gray-500">
<div className="flex flex-wrap items-center gap-1 pt-1 text-[10px] text-gray-500">
{c.angle ? (
<span className="rounded-md bg-gray-100 px-1.5 py-0.5 font-medium text-gray-700">
<span className="rounded-md bg-purple-50 px-1.5 py-0.5 font-medium text-purple-700 border border-purple-200">
{c.angle}
</span>
) : null}
) : (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setInlineEditingId(c.id);
setInlineAngle('');
setInlineHook(c.hook || '');
}}
className="rounded-md bg-amber-50 hover:bg-amber-100 px-1.5 py-0.5 text-amber-700 font-medium border border-amber-200"
>
+ Definir ángulo
</button>
)}
{c.campaign_name ? (
<span className="rounded-md bg-gray-50 px-1.5 py-0.5 text-gray-600 border border-gray-200 line-clamp-1 max-w-[150px]">
{c.campaign_name}
</span>
) : null}
</div>
</div>
)}
{/* Métricas si existen */}
{c.metrics ? (
@@ -1084,6 +1225,20 @@ export default function AgencyCreativesView({
{detailCreative.primary_text ? (
<p className="text-xs text-gray-600 pt-1">{detailCreative.primary_text}</p>
) : null}
{detailCreative.hook ? (
<p className="text-xs text-gray-700 pt-1"><strong>Hook:</strong> {detailCreative.hook}</p>
) : null}
{detailCreative.angle ? (
<p className="text-xs text-purple-700 pt-0.5"><strong>Ángulo:</strong> {detailCreative.angle}</p>
) : null}
{detailCreative.meta?.composition_notes ? (
<div className="mt-2 rounded-lg bg-amber-50 p-2.5 border border-amber-200 text-xs text-amber-950">
<span className="font-bold text-[10px] uppercase tracking-wider block text-amber-900">Montaje</span>
<p className="whitespace-pre-line text-amber-900 text-[11px] mt-0.5 leading-relaxed">
{detailCreative.meta.composition_notes}
</p>
</div>
) : null}
</div>
<div>