Feat: descripcion de averia limpia con IA para el Portal del cliente (con cache)

This commit is contained in:
2026-08-24 20:30:23 +00:00
parent ac48fe0965
commit dc9485a13a

View File

@@ -2280,6 +2280,52 @@ app.post("/public/portal/:token/interest", async (req, res) => {
// ==========================================
// 🔗 PORTAL PÚBLICO DEL CLIENTE
// ==========================================
// 🧽 LIMPIEZA DE AVERÍA PARA EL CLIENTE (con IA + caché en raw_data)
// El campo "Descripción" va acumulando avisos automáticos, cambios de cita, códigos internos, etc.
// Aquí lo convertimos en una frase breve y sin ruido para que la vea el cliente en el Portal.
async function getClientFriendlyDescription(service) {
const rawDesc = (service.raw_data?.["Descripción"] || service.raw_data?.["DESCRIPCION"] || "").toString().trim();
if (!rawDesc) return "Aviso de reparación";
const rawHash = crypto.createHash('md5').update(rawDesc).digest('hex');
// Si ya la limpiamos antes y el texto original no ha cambiado, reutilizamos la versión guardada
if (service.raw_data?.portal_desc_hash === rawHash && service.raw_data?.portal_desc_clean) {
return service.raw_data.portal_desc_clean;
}
try {
const completion = await openai.chat.completions.create({
model: OPENAI_MODEL || "gpt-4o-mini",
messages: [
{
role: "system",
content: "Redactas, para el CLIENTE de una empresa de reparaciones del hogar, una frase muy breve (máximo 18 palabras) que describa SOLO el problema técnico o avería en su domicilio. No menciones fechas, horas, citas, cambios de cita, nombres de operarios o agentes, teléfonos, códigos de expediente, aseguradoras, ni el estado del trámite administrativo. Si el texto no describe ningún problema técnico claro, responde exactamente: \"Revisión técnica en el domicilio.\". Responde en español, en una sola frase, sin comillas ni markdown."
},
{ role: "user", content: rawDesc.slice(0, 2000) }
],
temperature: 0.2,
max_tokens: 60
});
const clean = completion.choices?.[0]?.message?.content?.trim();
if (!clean) throw new Error("La IA no devolvió texto");
// Guardamos en caché de forma asíncrona (no bloqueamos la respuesta al cliente ni si falla)
pool.query(`
UPDATE scraped_services
SET raw_data = jsonb_set(jsonb_set(COALESCE(raw_data, '{}'::jsonb), '{portal_desc_clean}', $1::jsonb), '{portal_desc_hash}', $2::jsonb)
WHERE id = $3
`, [JSON.stringify(clean), JSON.stringify(rawHash), service.id]).catch(e => console.error("⚠️ No se pudo cachear la avería limpia:", e.message));
return clean;
} catch (e) {
console.error("❌ Error limpiando avería con IA:", e.message);
// Si la IA falla, devolvemos algo razonable en lugar del volcado crudo completo
return rawDesc.length > 150 ? rawDesc.slice(0, 150).trim() + "..." : rawDesc;
}
}
app.get("/public/portal/:token", async (req, res) => {
try {
const { token } = req.params;
@@ -2331,11 +2377,12 @@ app.get("/public/portal/:token", async (req, res) => {
`, [ownerId, `%${phoneMatch}%`]);
}
const formattedServices = qServices.rows.map(s => {
const formattedServices = await Promise.all(qServices.rows.map(async s => {
return {
id: s.id,
title: s.is_urgent ? `🚨 URGENTE: #${s.service_ref}` : `Expediente #${s.service_ref}`,
description: s.raw_data?.["Descripción"] || s.raw_data?.["DESCRIPCION"] || "Aviso de reparación",
description_clean: await getClientFriendlyDescription(s),
status_name: s.real_status_name || "En gestión",
is_final: s.is_status_final || false,
scheduled_date: s.raw_data?.scheduled_date || "",
@@ -2344,7 +2391,7 @@ app.get("/public/portal/:token", async (req, res) => {
worker_phone: s.worker_phone || null,
raw_data: s.raw_data
};
});
}));
// 4. BUSCAR PRESUPUESTOS CON SUS ARTÍCULOS
let cleanPhoneSearch = String(client.phone || "").replace(/[^0-9]/g, "");