154 lines
6.9 KiB
TypeScript
154 lines
6.9 KiB
TypeScript
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 => `<p>${l}</p>`).join('');
|
|
const contenido = paragraphs || `<p>${clean || rawTitle}</p>`;
|
|
|
|
return {
|
|
titulo: rawTitle,
|
|
tipo: tipo,
|
|
seo_descripcion: seo,
|
|
contenido: contenido,
|
|
fecha_vencimiento: null,
|
|
selected_image_index: mediaCount > 0 ? 0 : undefined,
|
|
image_selection_reason: mediaCount > 0 ? 'Foto destacada principal' : undefined
|
|
};
|
|
}
|
|
|
|
export async function processWhatsAppMessage(
|
|
text: string,
|
|
mediaParam?: string | MediaInput[] | MediaInput,
|
|
mimeTypeParam?: string
|
|
): Promise<ProcessedPublication> {
|
|
// 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 <p>, <strong>, <ul>/<li> 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);
|
|
}
|