/** * supabase/functions/ai-search/index.ts * Edge Function — Proxy seguro para Pinamar AI * * La API key de Anthropic vive en variables de entorno del servidor. * El frontend NUNCA la ve. * * Deploy: * supabase functions deploy ai-search --no-verify-jwt * * Variables de entorno a configurar en Supabase Dashboard * → Project Settings → Edge Functions → Secrets: * ANTHROPIC_API_KEY = sk-ant-... */ import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'; import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; // ── CORS — permite llamadas desde el dominio del portal ─────────────────── const CORS = { // Reemplazar con tu dominio real en producción, ej: 'https://pinamarpropiedades.com.ar' 'Access-Control-Allow-Origin': process.env.SITE_URL || '*', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', 'Access-Control-Allow-Methods': 'POST, OPTIONS', }; // ── Perfiles psicográficos (contexto estático) ──────────────────────────── const PERFILES_CONTEXT = ` PERFILES PSICOGRÁFICOS DEL PARTIDO DE PINAMAR: 1. "Refugio Inmaculado" (Cariló): Bosque, privacidad, escasez cuidada. Inversor UHNW, largo plazo. USD/m² lote: 5.500. Negatividad: 4%. Polarización baja. 2. "La Vidriera" (Costa Esmeralda): Barrios cerrados, estatus, náutica y golf. Alta polarización (24% negatividad). Oportunidad de revalorización. USD/m² lote: 3.200. 3. "El Pueblo del Mar" (Pinamar Centro): Alta liquidez, alquiler temporal, acceso a servicios. Negatividad 12%, mercado maduro. USD/m² depto: 2.200. 4. "El Horizonte Tranquilo" (Valeria del Mar / Ostende): Tranquilidad, horizonte abierto, revalorización. Negatividad 8%. USD/m² lote: 1.800. 5. "El Club Silvestre" (Pinamar Norte): Barrios golf, lote grande, familia activa. Negatividad 11%. USD/m² lote: 2.800. `; const SYSTEM_BASE = `Sos el asistente de búsqueda inmobiliaria de Pinamar Propiedades. Te llamás "Pinamar AI". Tu objetivo es ayudar al usuario a encontrar la propiedad ideal en el Partido de Pinamar combinando: 1. Su perfil psicográfico (estilo de vida deseado, zona emocional) 2. Datos técnicos duros (precio/m², estado según ficha, costo de reparaciones CAC, distancia al mar) ${PERFILES_CONTEXT} INSTRUCCIONES: - Respondé siempre en español rioplatense (voseo). - Cuando el usuario describa lo que busca, identificá el perfil psicográfico que mejor encaja y explicá brevemente por qué. - Recomendá entre 2 y 4 propiedades de la lista que mejor matcheen. Para cada una, explicá el nivel de match y datos técnicos relevantes. - Si mencionan un presupuesto, filtrá las propiedades dentro de ese rango. - Si preguntan por diferencias entre zonas, usá los datos de sentimiento y precios. - Cuando recomendés propiedades, incluí sus IDs entre corchetes: [uuid-de-la-prop] - Sé conciso pero preciso. Máximo 3 párrafos + recomendaciones. - Nunca inventés datos que no están en el contexto. Si no sabés algo, decilo. - Al final de cada respuesta con propiedades, preguntá si quiere ver la ficha técnica o comparar alguna.`; // ── Rate limiting simple (por IP, en memoria) ───────────────────────────── const rateLimits = new Map(); const RATE_LIMIT = 20; // requests por ventana const RATE_WINDOW = 60000; // 1 minuto en ms function checkRateLimit(ip: string): boolean { const now = Date.now(); const rl = rateLimits.get(ip) ?? { count: 0, reset: now + RATE_WINDOW }; if (now > rl.reset) { rl.count = 0; rl.reset = now + RATE_WINDOW; } rl.count++; rateLimits.set(ip, rl); return rl.count <= RATE_LIMIT; } // ── Handler principal ───────────────────────────────────────────────────── serve(async (req) => { // Preflight CORS if (req.method === 'OPTIONS') { return new Response('ok', { headers: CORS }); } // Solo POST if (req.method !== 'POST') { return new Response('Method not allowed', { status: 405, headers: CORS }); } // Rate limiting const ip = req.headers.get('x-forwarded-for') ?? 'unknown'; if (!checkRateLimit(ip)) { return new Response( JSON.stringify({ error: 'Demasiadas solicitudes. Intentá en un minuto.' }), { status: 429, headers: { ...CORS, 'Content-Type': 'application/json' } } ); } try { // Leer body del request const { messages, filtros } = await req.json(); if (!messages || !Array.isArray(messages)) { return new Response( JSON.stringify({ error: 'messages es requerido' }), { status: 400, headers: { ...CORS, 'Content-Type': 'application/json' } } ); } // Limitar tamaño del historial (evitar prompt injection por historial largo) const historialLimitado = messages.slice(-10); // ── Obtener propiedades desde Supabase ─────────────────────────────── const supabaseUrl = Deno.env.get('SUPABASE_URL')!; const supabaseKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; const supabase = createClient(supabaseUrl, supabaseKey); let propsContext = ''; let statsContext = ''; try { // Propiedades activas con filtros opcionales let query = supabase .from('v_propiedades_listado') .select('id,titulo,zona,barrio,perfil_psico,precio_usd,sup_terreno_m2,dist_mar_m,tiene_ficha,tiene_kyc,cac_estimado,permuta,cochera,piscina,barrio_cerrado') .eq('estado', 'activa') .limit(60); if (filtros?.zona) query = query.eq('zona', filtros.zona); if (filtros?.tipo) query = query.eq('tipo', filtros.tipo); const { data: props } = await query; if (props && props.length > 0) { propsContext = `\nPROPIEDADES DISPONIBLES (${props.length} activas):\n` + props.map(p => `[${p.id}] ${p.titulo} | ${p.zona}${p.barrio ? '/' + p.barrio : ''} | ` + `USD ${p.precio_usd?.toLocaleString() ?? 'consultar'} | ` + `${p.dist_mar_m ?? '?'}m al mar | ` + `Ficha: ${p.tiene_ficha ? 'SÍ' : 'NO'} | KYC: ${p.tiene_kyc ? 'SÍ' : 'NO'} | ` + `CAC: USD ${p.cac_estimado?.toLocaleString() ?? 0} | ` + `Permuta: ${p.permuta ? 'SÍ' : 'NO'}` ).join('\n'); } // Stats por zona const { data: stats } = await supabase.from('v_stats_zona').select('*'); if (stats && stats.length > 0) { statsContext = `\nESTADÍSTICAS POR ZONA:\n` + stats.map(s => `${s.zona}: ${s.total_props} props | precio prom USD ${s.precio_prom} | ` + `m² prom USD ${s.precio_m2_prom} | con ficha: ${s.props_con_ficha} | con KYC: ${s.props_con_kyc}` ).join('\n'); } // Indicadores de zona (precios, sentimiento, apreciación) const { data: indicadores } = await supabase.from('indicadores_zona').select('*'); if (indicadores && indicadores.length > 0) { statsContext += `\n\nINDICADORES DE MERCADO POR ZONA:\n` + indicadores.map(z => `${z.zona}: USD ${z.precio_m2_lote_usd}/m² lote | USD ${z.precio_m2_constr_usd}/m² constr | ` + `ticket prom USD ${z.precio_ticket_prom_usd} | apreciación ${z.apreciacion_pct_anual}% | ` + `sentimiento +${z.sentimiento_positivo_pct}% / -${z.sentimiento_negativo_pct}% | polarización ${z.polarizacion}` ).join('\n'); } // Barrios disponibles const { data: barrios } = await supabase .from('barrios') .select('nombre,zona,slug,precio_m2_lote_usd,cou_zonificacion,cou_fos,cou_altura_max_m,total_propiedades') .eq('activo', true) .order('orden'); if (barrios && barrios.length > 0) { statsContext += `\n\nBARRIOS DEL PARTIDO:\n` + barrios.map(b => `${b.nombre} (${b.zona}): USD ${b.precio_m2_lote_usd}/m² | ${b.total_propiedades} props | ` + `COU: ${b.cou_zonificacion||'N/A'} FOS ${b.cou_fos||'N/A'} altura ${b.cou_altura_max_m||'N/A'}m | ` + `URL: barrio.html?slug=${b.slug}` ).join('\n'); } } catch (dbErr) { console.error('DB error (non-fatal):', dbErr); // Continúa sin datos de BD — el AI responde con contexto estático } const systemPrompt = SYSTEM_BASE + propsContext + statsContext; // ── Llamar a Anthropic ─────────────────────────────────────────────── const anthropicKey = Deno.env.get('ANTHROPIC_API_KEY'); if (!anthropicKey) { return new Response( JSON.stringify({ error: 'Servicio de AI no configurado.' }), { status: 503, headers: { ...CORS, 'Content-Type': 'application/json' } } ); } const anthropicRes = await fetch('https://api.anthropic.com/v1/messages', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': anthropicKey, 'anthropic-version': '2023-06-01', }, body: JSON.stringify({ model: 'claude-sonnet-4-20250514', max_tokens: 1000, system: systemPrompt, messages: historialLimitado, }), }); if (!anthropicRes.ok) { const errBody = await anthropicRes.text(); console.error('Anthropic error:', anthropicRes.status, errBody); return new Response( JSON.stringify({ error: 'Error al consultar el AI. Intentá de nuevo.' }), { status: 502, headers: { ...CORS, 'Content-Type': 'application/json' } } ); } const data = await anthropicRes.json(); const respuesta = data.content?.map((b: any) => b.text || '').join('') ?? ''; return new Response( JSON.stringify({ respuesta }), { status: 200, headers: { ...CORS, 'Content-Type': 'application/json' } } ); } catch (err) { console.error('Edge function error:', err); return new Response( JSON.stringify({ error: 'Error interno del servidor.' }), { status: 500, headers: { ...CORS, 'Content-Type': 'application/json' } } ); } });