import { GoogleGenAI, Type } from '@google/genai';
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY || '' });
export interface MediaInput {
base64: string;
mimeType: string;
id?: string;
}
export interface ProcessedPublication {
titulo: string;
tipo: 'noticia' | 'evento' | 'aviso';
seo_descripcion: string;
contenido: string;
fecha_vencimiento: string | null;
selected_image_index?: number;
image_selection_reason?: string;
}
/**
* Fallback parser in case Gemini API is rate-limited (429) or unavailable
*/
function fallbackRuleBasedParser(text: string, mediaCount: number): ProcessedPublication {
const clean = (text || '').trim();
const lines = clean.split('\n').map(l => l.trim()).filter(Boolean);
let rawTitle = lines[0] || 'Nueva Publicación';
rawTitle = rawTitle.replace(/^[#*_\-•]+\s*/, '').replace(/[*_#]/g, '').trim();
if (rawTitle.length > 70) {
rawTitle = rawTitle.substring(0, 67) + '...';
}
const lower = clean.toLowerCase();
let tipo: 'noticia' | 'evento' | 'aviso' = 'noticia';
if (lower.includes('evento') || lower.includes('invitación') || lower.includes('invitacion') || lower.includes('jornada') || lower.includes('taller') || lower.includes('festival') || lower.includes('feria') || lower.includes('convocatoria')) {
tipo = 'evento';
} else if (lower.includes('se vende') || lower.includes('se arrienda') || lower.includes('clasificado') || lower.includes('empleo') || lower.includes('vacante') || lower.includes('servicio')) {
tipo = 'aviso';
}
const seo = clean.replace(/\n+/g, ' ').substring(0, 150) || rawTitle;
// Format content as clean HTML paragraphs
const paragraphs = lines.map(l => `
{
// Normalize mediaParam into an array of MediaInput
let mediaList: MediaInput[] = [];
if (Array.isArray(mediaParam)) {
mediaList = mediaParam;
} else if (typeof mediaParam === 'string' && mimeTypeParam) {
mediaList = [{ base64: mediaParam, mimeType: mimeTypeParam }];
} else if (mediaParam && typeof mediaParam === 'object' && 'base64' in mediaParam) {
mediaList = [mediaParam as MediaInput];
}
if (!process.env.GEMINI_API_KEY) {
console.warn('[Gemini] Sin GEMINI_API_KEY configurada, usando parser de respaldo');
return fallbackRuleBasedParser(text, mediaList.length);
}
const systemInstruction = `
Eres un asistente editorial experto y editor gráfico de Almeira Web.
Analiza los mensajes y recursos multimedia recibidos desde WhatsApp para estructurarlos como publicaciones del portal.
Debes estructurar el mensaje en base a lo siguiente:
1. titulo: Asigna un título claro, profesional y conciso (máx. 70 caracteres).
2. tipo: Determina la categoría exacta:
- "noticia": Noticias generales, política, actualidad, comunicados y reportajes (dimensiones recomendadas de imagen: 640x360 px, relación 16:9).
- "evento": Invitaciones a actividades, jornadas, ferias, foros, congresos, festivales o convocatorias con fecha (dimensiones de imagen: 640x640 px, relación 1:1).
- "aviso": Clasificados, ofertas de empleo, compra/venta, servicios profesionales, comunicados comunitarios (dimensiones de imagen: 640x640 px, relación 1:1).
3. seo_descripcion: Un resumen conciso de máximo 150 caracteres optimizado para redes y motores de búsqueda.
4. contenido: El texto detallado de la publicación en HTML válido y limpio (usa , , /- si es apropiado), preservando nombres, fechas, contactos y enlaces originales.
5. fecha_vencimiento: Si el texto indica fecha límite del evento o del aviso (Ej. "hasta el 30 de noviembre", o "el próximo sábado"), devuélvela en formato YYYY-MM-DD. Si no aplica o es una noticia general, pasa null.
6. selected_image_index: Si se proporcionaron múltiples imágenes candidatas (#1, #2, #3...), selecciona el índice base 0 de la imagen MÁS ADECUADA y de mayor calidad periodística/visual para ser la IMAGEN DESTACADA (la más nítida, bien compuesta, representativa del tema central y sin recortes desfavorables). Si solo hay 1 imagen, devuelve 0. Si no hay imágenes, pasa null.
7. image_selection_reason: Breve explicación (máximo 1 línea) del motivo por el cual esa imagen es la más adecuada para portada.
`;
// Prepara los contenidos
const contents: any[] = [
{ text: `Mensaje de WhatsApp: \n\n${text || '(Publicación basada en las imágenes adjuntas)'}` }
];
mediaList.forEach((media, idx) => {
contents.push({ text: `\n[Candidata a Imagen Destacada #${idx + 1}]` });
contents.push({
inlineData: {
data: media.base64,
mimeType: media.mimeType
}
});
});
const candidateModels = [
'gemini-2.5-flash',
'gemini-2.0-flash',
'gemini-1.5-flash',
'gemini-2.0-flash-lite'
];
for (const modelName of candidateModels) {
try {
const response = await ai.models.generateContent({
model: modelName,
contents: contents,
config: {
systemInstruction: systemInstruction,
responseMimeType: "application/json",
responseSchema: {
type: Type.OBJECT,
properties: {
titulo: { type: Type.STRING },
tipo: { type: Type.STRING, enum: ["noticia", "evento", "aviso"] },
seo_descripcion: { type: Type.STRING, description: "Máximo 150 caracteres" },
contenido: { type: Type.STRING, description: "Contenido en HTML" },
fecha_vencimiento: { type: Type.STRING, description: "Fecha opcional YYYY-MM-DD", nullable: true },
selected_image_index: { type: Type.INTEGER, description: "Índice base 0 de la mejor imagen seleccionada", nullable: true },
image_selection_reason: { type: Type.STRING, description: "Motivo de selección de imagen", nullable: true }
},
required: ["titulo", "tipo", "seo_descripcion", "contenido"]
}
}
});
if (response.text) {
const result = JSON.parse(response.text) as ProcessedPublication;
return result;
}
} catch (err: any) {
console.warn(`[Gemini] Error con modelo ${modelName}:`, err?.message);
}
}
// Si todos los modelos de Gemini superaron cuota (429), usamos el fallback seguro para no fallar el webhook
console.warn('[Gemini] Modelos de IA agotados por cuota, activando fallback editorial inteligente');
return fallbackRuleBasedParser(text, mediaList.length);
}