'use strict' /** * AI Service — Internal ops intelligence powered by Gemini Flash * * This module handles all internal AI decision-making: * - Dispatch optimization (best tech, redistribution, schedule review) * - Customer diagnostics (troubleshooting suggestions) * - Marketing insights (segmentation, churn risk, offer suggestions) * - Natural language → structured actions * * PRIVACY: Uses Google Gemini API which does NOT train on API data. * Customer PII is minimized — only operational context is sent (no full addresses, * no payment info, no personal identifiers beyond first name + city). * * Architecture: * - Reuses same AI_API_KEY / AI_BASE_URL / AI_MODEL from config * - All responses are structured JSON (response_format: json_object) * - Model-agnostic: swap to Ollama by changing AI_BASE_URL to http://gpu-server:11434/v1/ * * Endpoints: * POST /ai/dispatch-suggest -> best tech recommendation for a job * POST /ai/dispatch-redistribute -> redistribute jobs when tech unavailable * POST /ai/dispatch-nlp -> natural language → dispatch action * POST /ai/diagnose -> customer troubleshooting from symptoms * POST /ai/summarize-day -> daily dispatch summary for a tech */ const cfg = require('./config') const { log, json, parseBody, erpFetch } = require('./helpers') // ── Core Gemini call (reuses same config as agent.js) ─────────────────────── async function aiCall (systemPrompt, userPrompt, { jsonMode = true, maxTokens = 8192, temperature = 0.3 } = {}) { if (!cfg.AI_API_KEY) throw new Error('AI_API_KEY not configured') const url = `${cfg.AI_BASE_URL}chat/completions` // Model chain: primary → fallback (handles 503 overload gracefully) const models = [cfg.AI_MODEL, cfg.AI_FALLBACK_MODEL].filter(Boolean) for (const model of models) { const body = { model, max_completion_tokens: maxTokens, temperature, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt }, ], } if (jsonMode) body.response_format = { type: 'json_object' } let modelFailed = false for (let attempt = 0; attempt < 3; attempt++) { const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${cfg.AI_API_KEY}` }, body: JSON.stringify(body), }) if (res.status === 429 && attempt < 2) { await new Promise(r => setTimeout(r, (attempt + 1) * 2000)) continue } if (res.status === 503) { log(`AI: ${model} returned 503 (overloaded), attempt ${attempt + 1}/3`) if (attempt < 2) { await new Promise(r => setTimeout(r, (attempt + 1) * 3000)) continue } modelFailed = true break } if (!res.ok) { const text = await res.text().catch(() => '') throw new Error(`AI API ${res.status}: ${text.substring(0, 200)}`) } const data = await res.json() const finish = data.choices?.[0]?.finish_reason const content = data.choices?.[0]?.message?.content if (!content) throw new Error('Empty AI response') log(`AI: model=${data.model || model}, finish=${finish}, ${content.length} chars, tokens=${data.usage?.completion_tokens || '?'}/${maxTokens}`) if (jsonMode) { try { return JSON.parse(content) } catch { const match = content.match(/```(?:json)?\s*([\s\S]*?)```/) if (match) { try { return JSON.parse(match[1].trim()) } catch { /* fall through */ } } return { raw: content.substring(0, 300), error: 'Failed to parse JSON' } } } return content } if (modelFailed && models.indexOf(model) < models.length - 1) { log(`AI: ${model} unavailable, falling back to ${models[models.indexOf(model) + 1]}`) continue } } throw new Error('AI API: all models unavailable (503 overload). Try again shortly.') } // ── PII minimizer — strip sensitive fields before sending to AI ───────────── function minimizeCustomer (c) { if (!c) return null return { id: c.name, first_name: (c.customer_name || '').split(' ')[0], type: c.customer_type, language: c.language, is_commercial: c.is_commercial, territory: c.territory, } } function minimizeTech (t) { return { id: t.id || t.technician_id, name: t.name || t.full_name, status: t.status, load_h: t.load, remaining_h: t.remainingCap || Math.max(0, 8 - (t.load || 0)), distance_km: t.distance, has_gps: !!t.gpsOnline, skills: t.tags || t.skills || [], queue_count: t.queue?.length || 0, } } function minimizeJob (j) { return { id: j.name || j.ticket_id, subject: j.subject, type: j.job_type, priority: j.priority, duration_h: j.duration_h, status: j.status, city: j.city || '', assigned_tech: j.assigned_tech, scheduled_date: j.scheduled_date, tags: j.tags || [], } } // ── Dispatch: Best tech suggestion ────────────────────────────────────────── const DISPATCH_SUGGEST_PROMPT = `Tu es un assistant de planification pour un fournisseur internet (ISP) au Québec. On te donne un travail à assigner et la liste des techniciens disponibles avec leur charge, distance et compétences. Analyse et retourne un JSON avec: { "recommendation": "tech_id du meilleur choix", "ranking": [ { "tech_id": "...", "tech_name": "...", "score": 1-10, "reasons_fr": ["raison 1", "raison 2"] } ], "reasoning_fr": "Explication courte de ton choix", "warnings": ["avertissement si surcharge ou conflit"] } Critères par ordre d'importance: 1. Compétences requises (skill match) — éliminatoire si manquant 2. Proximité géographique — moins de déplacement = plus efficace 3. Charge de travail — équilibrer entre techniciens 4. GPS en direct — préférer les techs localisés en temps réel 5. Priorité du travail — urgent = tech le plus proche même si chargé Réponds UNIQUEMENT en JSON valide, en français pour les textes.` async function dispatchSuggest (job, techs) { const userPrompt = JSON.stringify({ job: minimizeJob(job), technicians: techs.map(minimizeTech), current_date: new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }), }) return aiCall(DISPATCH_SUGGEST_PROMPT, userPrompt) } // ── Dispatch: Redistribute jobs (tech sick/absent) ────────────────────────── const REDISTRIBUTE_PROMPT = `Tu es un assistant de planification pour un ISP au Québec. Un technicien est indisponible et ses travaux doivent être redistribués aux autres techniciens. Analyse et retourne un JSON: { "plan": [ { "job_id": "...", "from_tech": "...", "to_tech": "...", "to_tech_name": "...", "reason_fr": "..." } ], "unassignable": [ { "job_id": "...", "reason_fr": "pourquoi aucun tech ne peut le prendre" } ], "summary_fr": "Résumé du plan de redistribution", "impact": { "overtime_risk": ["tech_ids qui risquent le surtemps"], "customer_delays": 0 } } Règles: - Ne pas surcharger un tech au-delà de 9h - Priorité: travaux urgents d'abord - Proximité: assigner au tech le plus proche du lieu - Si impossible: mettre dans unassignable avec raison` async function dispatchRedistribute (absentTech, jobs, availableTechs) { const userPrompt = JSON.stringify({ absent_tech: { id: absentTech.id, name: absentTech.name }, jobs_to_redistribute: jobs.map(minimizeJob), available_techs: availableTechs.map(minimizeTech), }) return aiCall(REDISTRIBUTE_PROMPT, userPrompt) } // ── Dispatch: Natural language → action ───────────────────────────────────── const NLP_DISPATCH_PROMPT = `Tu es un assistant de planification pour un ISP au Québec. L'utilisateur décrit une action en langage naturel. Extrais l'intention structurée. Retourne un JSON: { "action": "create_job" | "move_job" | "assign_tech" | "find_slot" | "cancel_job" | "unknown", "params": { "subject": "description du travail", "job_type": "Installation" | "Dépannage" | "Maintenance" | "Activation" | "Désactivation" | null, "priority": "low" | "medium" | "high" | "urgent" | null, "date": "YYYY-MM-DD" | "demain" | "lundi prochain" | null, "time_preference": "matin" | "après-midi" | "soir" | null, "zone": "zone ou ville mentionnée" | null, "tech_name": "nom du technicien si mentionné" | null, "duration_h": number | null, "customer_hint": "nom ou ID client si mentionné" | null, "job_id": "ID du travail si modification" | null }, "confidence": 0.0-1.0, "clarification_fr": "question à poser si info manquante" | null } Contexte: les types de travaux courants sont Installation Fibre, Installation Camera IP, Dépannage Internet, Activation, Maintenance préventive. Les zones sont des villes/quartiers du Québec (Laval, Montréal, Longueuil, Terrebonne, etc.)` async function dispatchNlp (text, context = {}) { const userPrompt = JSON.stringify({ user_input: text, current_date: new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }), available_techs: context.techNames || [], }) return aiCall(NLP_DISPATCH_PROMPT, userPrompt, { temperature: 0.1 }) } // ── Customer diagnostic ───────────────────────────────────────────────────── const DIAGNOSE_PROMPT = `Tu es un assistant technique pour un ISP fibre optique au Québec. On te donne les symptômes du client et les données techniques de son équipement. Retourne un JSON: { "diagnosis_fr": "Explication simple du problème pour l'agent", "severity": "low" | "medium" | "high" | "critical", "steps_fr": ["Étape 1", "Étape 2", "..."], "likely_cause_fr": "Cause probable", "needs_dispatch": true/false, "dispatch_type": "Dépannage" | "Maintenance" | null, "auto_fixable": true/false, "auto_fix_action": "reboot" | "config_push" | "firmware_update" | null } Causes courantes: - Dying Gasp = perte d'alimentation, vérifier prise + UPS - LOSi / Branch Fiber Cut = coupure fibre, dispatch obligatoire - Rx power < -28 dBm = signal faible, possible fibre sale ou courbée - Uptime très faible = redémarrages fréquents, modem défectueux probable - WiFi OK mais Internet non = problème WAN/provisioning` async function diagnose (symptoms, deviceData, customer) { const userPrompt = JSON.stringify({ symptoms, device: deviceData ? { model: deviceData.model, online: deviceData.online, uptime: deviceData.uptime, rx_power: deviceData.rx_power_dbm, tx_power: deviceData.tx_power_dbm, wifi_clients: deviceData.wifi_clients, wan_ip: deviceData.wan_ip ? 'present' : 'none', // don't send actual IP snmp_status: deviceData.snmp_status, last_offline_cause: deviceData.alarm_type || null, } : null, customer: minimizeCustomer(customer), }) return aiCall(DIAGNOSE_PROMPT, userPrompt) } // ── Daily summary for tech ────────────────────────────────────────────────── const SUMMARY_PROMPT = `Tu es un assistant de planification pour un ISP au Québec. Génère un résumé SMS court (max 300 caractères) de la journée d'un technicien. Retourne un JSON: { "sms_fr": "le message SMS", "total_jobs": number, "total_hours": number, "first_job_time": "HH:MM", "zones": ["villes/quartiers uniques"] }` async function summarizeDay (techName, jobs) { const userPrompt = JSON.stringify({ tech_name: techName, date: new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }), jobs: jobs.map(j => ({ subject: j.subject, type: j.job_type, duration_h: j.duration_h, city: j.city || '', time: j.scheduled_time || '', priority: j.priority, })), }) return aiCall(SUMMARY_PROMPT, userPrompt, { maxTokens: 300 }) } // ── Outage analysis — OLT port neighbor correlation ───────────────────────── const OUTAGE_PROMPT = `Tu es un expert réseau fibre optique (GPON) pour un ISP au Québec. On te donne les données SNMP d'un ONU (modem client) et de ses voisins sur le même port OLT. Analyse et retourne un JSON: { "severity": "low" | "medium" | "high" | "critical", "outage_type": "single_customer" | "power_outage" | "drop_cable" | "branch_fiber_cut" | "backbone_cut" | "olt_port_failure" | "unknown", "affected_count": number, "diagnosis_fr": "Explication claire du problème", "cause_fr": "Cause probable", "action_required": "none" | "customer_reboot" | "simple_tech" | "fiber_splicer" | "escalate_noc", "tech_skill_needed": null | "general" | "fiber_splicer", "auto_notify_customers": true/false, "notification_msg_fr": "Message SMS à envoyer aux clients affectés" | null, "evidence": ["fait 1", "fait 2"], "priority": "low" | "medium" | "high" | "urgent" } Règles de diagnostic: 1. UN SEUL client hors ligne + Dying Gasp → perte de courant chez le client (severity: low, action: none) 2. UN SEUL client + LOSi ou Branch Fiber Cut → câble de desserte coupé (severity: medium, action: simple_tech) 3. PLUSIEURS clients sur même port + Dying Gasp mixte → panne de courant secteur (severity: medium, action: none, notify) 4. PLUSIEURS clients + LOSi/Branch Fiber Cut → coupure fibre en amont du splitter (severity: high, action: fiber_splicer) 5. TOUS les clients du port hors ligne → panne OLT port ou coupure backbone (severity: critical, action: escalate_noc) 6. Client hors ligne SANS cause → modem éteint ou défectueux (severity: low, action: customer_reboot) 7. Signal optique faible (rx < -28 dBm) mais en ligne → fibre dégradée, maintenance préventive 8. Clients avec batterie (UPS) restent en ligne pendant panne de courant → confirme hypothèse power outage Note: Dying Gasp signifie que l'ONU a eu le temps d'envoyer un dernier message avant de perdre le courant. Les clients avec UPS/batterie continuent de fonctionner pendant une panne de courant et ne génèrent PAS de Dying Gasp.` async function analyzeOutage (serial) { const oltSnmp = require('./olt-snmp') // Get target ONU and port neighbors const portData = oltSnmp.getPortNeighbors(serial) if (!portData) return { error: 'ONU not found in SNMP cache', serial } const { target, neighbors, port, oltName } = portData const allOnPort = [target, ...neighbors] const total = allOnPort.length const offlineOnus = allOnPort.filter(o => o.status === 'offline') const onlineOnus = allOnPort.filter(o => o.status === 'online') // Build port summary for AI (no customer PII — only serial prefixes + technical data) const portSummary = { olt: oltName, port, total_onus: total, online_count: onlineOnus.length, offline_count: offlineOnus.length, target_onu: { serial_prefix: target.serial?.substring(0, 8) || '?', status: target.status, offline_cause: target.lastOfflineCause || null, rx_power_olt: target.rxPowerOlt != null ? (target.rxPowerOlt / 100) : null, rx_power_onu: target.rxPowerOnu != null ? (target.rxPowerOnu / 100) : null, distance_m: target.distance || null, uptime: target.uptime || null, }, offline_neighbors: offlineOnus.filter(o => o.serial !== target.serial).map(o => ({ serial_prefix: o.serial?.substring(0, 8) || '?', offline_cause: o.lastOfflineCause || null, rx_power_olt: o.rxPowerOlt != null ? (o.rxPowerOlt / 100) : null, distance_m: o.distance || null, })), online_neighbors_sample: onlineOnus.slice(0, 5).map(o => ({ rx_power_olt: o.rxPowerOlt != null ? (o.rxPowerOlt / 100) : null, distance_m: o.distance || null, uptime: o.uptime || null, })), } // Classify offline causes for quick stats const causes = {} for (const o of offlineOnus) { const cause = oltSnmp.classifyOfflineCause(o.lastOfflineCause) causes[cause] = (causes[cause] || 0) + 1 } portSummary.offline_causes_summary = causes // If simple case (only target offline, clear cause) — skip AI, return deterministic if (offlineOnus.length === 1 && offlineOnus[0].serial === target.serial) { const cause = oltSnmp.classifyOfflineCause(target.lastOfflineCause) if (cause === 'dying_gasp') { return { severity: 'low', outage_type: 'single_customer', affected_count: 1, diagnosis_fr: 'Le modem du client a perdu le courant (Dying Gasp). Aucun autre client sur ce port n\'est affecté.', cause_fr: 'Perte d\'alimentation électrique chez le client', action_required: 'none', tech_skill_needed: null, auto_notify_customers: false, notification_msg_fr: null, evidence: [`1 seul ONU hors ligne sur ${total}`, 'Cause: Dying Gasp', `${onlineOnus.length} voisins en ligne`], priority: 'low', port_summary: portSummary, ai_used: false, } } if (cause === 'branch_fiber_cut' || cause === 'loss_of_signal') { return { severity: 'medium', outage_type: 'drop_cable', affected_count: 1, diagnosis_fr: `Coupure de fibre détectée sur le câble de desserte du client (${target.lastOfflineCause}). Un technicien peut réparer.`, cause_fr: 'Câble de desserte endommagé ou déconnecté', action_required: 'simple_tech', tech_skill_needed: 'general', auto_notify_customers: false, notification_msg_fr: null, evidence: [`1 seul ONU hors ligne sur ${total}`, `Cause: ${target.lastOfflineCause}`, `${onlineOnus.length} voisins en ligne — pas une coupure en amont`], priority: 'medium', port_summary: portSummary, ai_used: false, } } } // Complex case — multiple customers affected or unclear cause → use AI const result = await aiCall(OUTAGE_PROMPT, JSON.stringify(portSummary)) result.port_summary = portSummary result.ai_used = true return result } // ── HTTP handler ──────────────────────────────────────────────────────────── async function handle (req, res, method, urlPath) { const sub = urlPath.replace('/ai/', '') // POST /ai/dispatch-suggest if (sub === 'dispatch-suggest' && method === 'POST') { try { const body = await parseBody(req) if (!body.job) return json(res, 400, { error: 'Missing job' }) // If techs not provided, fetch them let techs = body.technicians if (!techs) { const dispatch = require('./dispatch') const dateStr = body.job.scheduled_date || new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) techs = await dispatch.getTechsWithLoad(dateStr) await dispatch.enrichWithGps(techs) // Compute distances to job const jobCoords = body.job.latitude && body.job.longitude ? [parseFloat(body.job.longitude), parseFloat(body.job.latitude)] : null if (jobCoords) { techs.forEach(t => { const pos = t.gpsOnline ? t.gpsCoords : t.coords const dx = (pos[0] - jobCoords[0]) * 80, dy = (pos[1] - jobCoords[1]) * 111 t.distance = Math.sqrt(dx * dx + dy * dy) t.remainingCap = Math.max(0, 8 - (t.load || 0)) }) } } const result = await dispatchSuggest(body.job, techs) return json(res, 200, result) } catch (e) { log('AI dispatch-suggest error:', e.message) return json(res, 500, { error: e.message }) } } // POST /ai/dispatch-redistribute if (sub === 'dispatch-redistribute' && method === 'POST') { try { const body = await parseBody(req) if (!body.absent_tech_id) return json(res, 400, { error: 'Missing absent_tech_id' }) const dispatch = require('./dispatch') const dateStr = body.date || new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) const allTechs = await dispatch.getTechsWithLoad(dateStr) await dispatch.enrichWithGps(allTechs) const absentTech = allTechs.find(t => t.id === body.absent_tech_id) if (!absentTech) return json(res, 404, { error: 'Tech not found' }) const jobsToMove = absentTech.queue || [] const availableTechs = allTechs.filter(t => t.id !== body.absent_tech_id && t.status !== 'off') // Add distance info relative to each job availableTechs.forEach(t => { t.remainingCap = Math.max(0, 8 - (t.load || 0)) }) const result = await dispatchRedistribute(absentTech, jobsToMove, availableTechs) return json(res, 200, result) } catch (e) { log('AI redistribute error:', e.message) return json(res, 500, { error: e.message }) } } // POST /ai/dispatch-nlp if (sub === 'dispatch-nlp' && method === 'POST') { try { const body = await parseBody(req) if (!body.text) return json(res, 400, { error: 'Missing text' }) // Provide tech names for context let techNames = body.tech_names || [] if (!techNames.length) { try { const techRes = await erpFetch('/api/resource/Dispatch Technician?fields=["full_name"]&limit_page_length=50') if (techRes.status === 200) techNames = (techRes.data.data || []).map(t => t.full_name) } catch (_) {} } const result = await dispatchNlp(body.text, { techNames }) return json(res, 200, result) } catch (e) { log('AI NLP error:', e.message) return json(res, 500, { error: e.message }) } } // POST /ai/diagnose if (sub === 'diagnose' && method === 'POST') { try { const body = await parseBody(req) if (!body.symptoms) return json(res, 400, { error: 'Missing symptoms' }) const result = await diagnose(body.symptoms, body.device || null, body.customer || null) return json(res, 200, result) } catch (e) { log('AI diagnose error:', e.message) return json(res, 500, { error: e.message }) } } // POST /ai/summarize-day if (sub === 'summarize-day' && method === 'POST') { try { const body = await parseBody(req) if (!body.tech_id) return json(res, 400, { error: 'Missing tech_id' }) const dispatch = require('./dispatch') const dateStr = body.date || new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) const techs = await dispatch.getTechsWithLoad(dateStr) const tech = techs.find(t => t.id === body.tech_id) if (!tech) return json(res, 404, { error: 'Tech not found' }) const result = await summarizeDay(tech.name, tech.queue || []) return json(res, 200, result) } catch (e) { log('AI summarize error:', e.message) return json(res, 500, { error: e.message }) } } // POST /ai/outage-analysis — analyze outage with port neighbor correlation if (sub === 'outage-analysis' && method === 'POST') { try { const body = await parseBody(req) if (!body.serial) return json(res, 400, { error: 'Missing serial' }) const result = await analyzeOutage(body.serial) return json(res, 200, result) } catch (e) { log('AI outage-analysis error:', e.message) return json(res, 500, { error: e.message }) } } // GET /ai/port-health-by-serial?serial=X — port neighbor context from a serial number if (sub === 'port-health-by-serial' && method === 'GET') { try { const url = new URL(req.url, 'http://localhost') const serial = url.searchParams.get('serial') if (!serial) return json(res, 400, { error: 'Missing serial' }) const oltSnmp = require('./olt-snmp') const neighbors = oltSnmp.getPortNeighbors(serial) if (!neighbors || !neighbors.length) return json(res, 200, { total: 0, online: 0, offline: 0, neighbors: [], is_mass_outage: false }) // The target serial's port info const target = neighbors.find(n => n.serial === serial || n.serial?.replace(/-/g, '') === serial?.replace(/-/g, '')) const total = neighbors.length const onlineCount = neighbors.filter(n => n.status === 'online').length const offlineCount = total - onlineCount return json(res, 200, { total, online: onlineCount, offline: offlineCount, is_mass_outage: offlineCount >= 3, is_warning: offlineCount >= 2, target_status: target?.status || 'unknown', olt: target?.oltName || null, port: target?.port || null, neighbors: neighbors.map(n => ({ serial_prefix: n.serial?.substring(0, 10) + '...', is_self: n.serial === serial || n.serial?.replace(/-/g, '') === serial?.replace(/-/g, ''), status: n.status, cause: n.lastOfflineCause || null, distance_m: n.distance || null, })), }) } catch (e) { log('AI port-health-by-serial error:', e.message) return json(res, 200, { total: 0, online: 0, offline: 0, neighbors: [], is_mass_outage: false }) } } // GET /ai/port-health/:olt/:port — raw port health without AI if (sub.startsWith('port-health/') && method === 'GET') { try { const parts = sub.replace('port-health/', '').split('/') const oltName = decodeURIComponent(parts[0]) const port = decodeURIComponent(parts.slice(1).join('/')) const oltSnmp = require('./olt-snmp') const health = oltSnmp.getPortHealth(oltName, port) if (!health) return json(res, 404, { error: 'OLT or port not found' }) // Strip full serials for privacy — only return prefixes health.onus = health.onus.map(o => ({ serial_prefix: o.serial?.substring(0, 8), status: o.status, offline_cause: o.lastOfflineCause || null, rx_power_olt: o.rxPowerOlt != null ? (o.rxPowerOlt / 100) : null, distance_m: o.distance || null, uptime: o.uptime || null, })) return json(res, 200, health) } catch (e) { log('AI port-health error:', e.message) return json(res, 500, { error: e.message }) } } // GET /ai/active-outages — list currently tracked outage incidents if (sub === 'active-outages' && method === 'GET') { try { const { getActiveIncidents } = require('./outage-monitor') return json(res, 200, { incidents: getActiveIncidents() }) } catch (e) { return json(res, 200, { incidents: [] }) } } // POST /ai/customer-by-serial — reverse lookup: serial → customer if (sub === 'customer-by-serial' && method === 'POST') { try { const body = await parseBody(req) if (!body.serial) return json(res, 400, { error: 'Missing serial' }) const { getCustomerBySerial } = require('./outage-monitor') const cust = await getCustomerBySerial(body.serial) if (!cust) return json(res, 404, { error: 'No customer linked to this serial' }) return json(res, 200, cust) } catch (e) { return json(res, 500, { error: e.message }) } } // GET /ai/outage-log?since=ISO&limit=200 — telemetry event log (CrateDB staging) if (sub === 'outage-log' && method === 'GET') { try { const { getEventLog } = require('./outage-monitor') const url = new URL(req.url, 'http://localhost') const since = url.searchParams.get('since') || null const limit = parseInt(url.searchParams.get('limit')) || 200 return json(res, 200, { events: getEventLog(since, limit) }) } catch (e) { return json(res, 200, { events: [] }) } } // GET /ai/privacy — returns privacy policy for AI features if (sub === 'privacy' && method === 'GET') { return json(res, 200, PRIVACY_INFO) } return json(res, 404, { error: 'AI endpoint not found' }) } // ── Privacy disclosure (served via API for UI display) ────────────────────── const PRIVACY_INFO = { provider: 'Google Gemini API', model: 'gemini-2.5-flash', data_training: false, data_retention_days: 0, pii_sent: false, summary_fr: "L'intelligence artificielle est utilisée uniquement pour optimiser les opérations internes (planification, diagnostic). Aucune donnée personnelle de client (adresse complète, téléphone, courriel, paiement) n'est transmise au service d'IA. Seules des informations opérationnelles anonymisées (prénom, ville, type de service) sont utilisées. Google confirme que les données envoyées via son API ne sont pas utilisées pour entraîner ses modèles.", summary_en: "AI is used solely to optimize internal operations (scheduling, diagnostics). No customer personal data (full address, phone, email, payment info) is sent to the AI service. Only anonymized operational info (first name, city, service type) is used. Google confirms that data sent via its API is not used to train its models.", google_policy_url: 'https://ai.google.dev/gemini-api/terms', controls: [ 'PII stripping before every AI call (lib/ai.js minimizeCustomer)', 'No payment or billing data ever sent to AI', 'All AI suggestions require human confirmation', 'AI never auto-executes actions on customer accounts', 'Full audit log of AI calls available', 'Can switch to self-hosted model (Ollama) for full data sovereignty', ], } // ── Exports ───────────────────────────────────────────────────────────────── module.exports = { handle, aiCall, dispatchSuggest, dispatchRedistribute, dispatchNlp, diagnose, analyzeOutage, summarizeDay, minimizeCustomer, minimizeJob, minimizeTech, PRIVACY_INFO, }