'use strict' const cfg = require('./config') const { log, erpFetch } = require('./helpers') const TOOLS = require('./agent-tools.json') let _convContext = {} const erpList = async (doctype, fields, filters, { limit = 20, orderBy, wrapKey } = {}) => { let url = `/api/resource/${doctype}?filters=${encodeURIComponent(JSON.stringify(filters))}&fields=${encodeURIComponent(JSON.stringify(fields))}&limit_page_length=${limit}` if (orderBy) url += `&order_by=${orderBy}` const r = await erpFetch(url) if (r.status !== 200) return { error: `Failed to fetch ${doctype.toLowerCase()}` } const data = r.data.data || [] return wrapKey ? { [wrapKey]: data } : data } const getCustomerInfo = async (id) => { const r = await erpFetch(`/api/resource/Customer/${encodeURIComponent(id)}`) if (r.status !== 200) return { error: 'Customer not found' } const c = r.data.data const info = { name: c.name, customer_name: c.customer_name, customer_type: c.customer_type, territory: c.territory, language: c.language, cell_phone: c.cell_phone, tel_home: c.tel_home, tel_office: c.tel_office, email_billing: c.email_billing, contact_name: c.contact_name_legacy, is_commercial: c.is_commercial, is_bad_payer: c.is_bad_payer, disabled: c.disabled, creation: c.creation, } // Include churn/retention context for AI decision-making if (c.churn_status) info.churn_status = c.churn_status if (c.cancel_reason) info.cancel_reason = c.cancel_reason if (c.cancel_competitor) info.cancel_competitor = c.cancel_competitor if (c.cancel_date) info.cancel_date = c.cancel_date if (c.cancel_notes) info.cancel_notes = c.cancel_notes if (c.churn_risk_score) info.churn_risk_score = c.churn_risk_score return info } const getSubscriptions = (cid) => erpList('Subscription', ['name', 'item_code', 'custom_description', 'actual_price', 'billing_frequency', 'status', 'service_location', 'start_date'], { party_type: 'Customer', party: cid }, { orderBy: 'status+asc', wrapKey: 'subscriptions' }) const getInvoices = (cid, limit = 5) => erpList('Sales Invoice', ['name', 'posting_date', 'grand_total', 'outstanding_amount', 'status', 'due_date'], { customer: cid, docstatus: 1 }, { limit, orderBy: 'posting_date+desc', wrapKey: 'invoices' }) const getOutstandingBalance = async (cid) => { const data = await erpList('Sales Invoice', ['outstanding_amount'], { customer: cid, docstatus: 1, outstanding_amount: ['>', 0] }, { limit: 0 }) if (data.error) return data const total = data.reduce((s, i) => s + (parseFloat(i.outstanding_amount) || 0), 0) return { outstanding_balance: Math.round(total * 100) / 100, unpaid_invoices: data.length } } const getServiceLocations = (cid) => erpList('Service Location', ['name', 'address_line', 'city', 'postal_code', 'location_name', 'contact_name', 'contact_phone', 'connection_type', 'olt_port', 'network_id', 'status'], { customer: cid }, { wrapKey: 'locations' }) const getEquipment = (cid) => erpList('Service Equipment', ['name', 'serial_number', 'mac_address', 'ip_address', 'brand', 'model', 'status', 'service_location', 'firmware_version'], { customer: cid }, { wrapKey: 'equipment' }) const getOpenTickets = (cid) => erpList('Issue', ['name', 'subject', 'status', 'priority', 'opening_date', 'issue_type'], { customer: cid, status: ['in', ['Open', 'Replied']] }, { limit: 10, orderBy: 'opening_date+desc', wrapKey: 'tickets' }) const checkDeviceStatus = async (serial) => { const { getCached, fetchDeviceDetails } = require('./devices') const cached = getCached(serial) let summary if (cached && cached.summary.firmware && (Date.now() - new Date(cached.updatedAt).getTime()) < 120000) { summary = cached.summary log(`Device ${serial}: using cached data (${Math.floor((Date.now() - new Date(cached.updatedAt).getTime()) / 1000)}s old)`) } else { summary = await fetchDeviceDetails(serial) if (!summary || !summary.serial) return { error: 'Device not found in ACS', serial } } const lastInform = summary.lastInform ? new Date(summary.lastInform) : null const minutesAgo = lastInform ? Math.floor((Date.now() - lastInform.getTime()) / 60000) : null // Use 20 min threshold — TR-069 inform intervals vary (5m, 10m, 15m+) // 10 min was too aggressive and flagged online devices as offline let online = minutesAgo !== null && minutesAgo < 20 // Cross-check with SNMP OLT data — SNMP is real-time and more reliable than TR-069 lastInform let snmpStatus = null try { const oltSnmp = require('./olt-snmp') const onu = oltSnmp.getOnuBySerial(serial) if (onu) { snmpStatus = onu.status if (onu.status === 'online' && !online) { // SNMP says online but TR-069 says offline → trust SNMP log(`Device ${serial}: TR-069 says offline (${minutesAgo}m) but SNMP says online — trusting SNMP`) online = true } } } catch (_) { /* SNMP not available, rely on TR-069 only */ } let uptimeStr = null if (summary.uptime) { const h = Math.floor(summary.uptime / 3600) const d = Math.floor(h / 24) uptimeStr = d > 0 ? `${d} jours ${h % 24}h` : `${h}h${Math.floor((summary.uptime % 3600) / 60)}m` } const prev = getCached(serial)?.previous let stateChange = null if (prev) { const prevInform = prev.lastInform ? new Date(prev.lastInform) : null const prevOnline = prevInform ? (Date.now() - prevInform.getTime()) < 600000 : false if (prevOnline && !online) stateChange = 'went_offline' else if (!prevOnline && online) stateChange = 'came_online' } return { serial: summary.serial, model: `${summary.manufacturer} ${summary.model}`.trim(), firmware: summary.firmware, online, state_change: stateChange, snmp_status: snmpStatus, last_seen: lastInform ? lastInform.toISOString() : null, last_seen_minutes_ago: minutesAgo, uptime: uptimeStr, wan_ip: summary.ip, rx_power_dbm: summary.rxPower != null ? (summary.rxPower / 1000).toFixed(1) + ' dBm' : null, tx_power_dbm: summary.txPower != null ? (summary.txPower / 1000).toFixed(1) + ' dBm' : null, wifi_ssid: summary.ssid, wifi_clients: summary.wifi?.totalClients || 0, connected_devices: summary.hostsCount, optical_status: summary.opticalStatus, } } // ── Background connection verification ───────────────────────────────────── // Polls device status 3 times over ~90s, then pushes a diagnostic message // into the conversation. The agent tool returns immediately so the AI can // tell the customer "we're checking". const activeChecks = new Map() const startConnectionCheck = async ({ serial_number, customer_id }) => { const token = _convContext.token if (!token) return { error: 'No conversation context' } if (activeChecks.has(token + ':' + serial_number)) { return { status: 'already_checking', message: 'Une vérification est déjà en cours pour cet appareil.' } } activeChecks.set(token + ':' + serial_number, true) // Run background polling (don't await — returns immediately) runBackgroundCheck(token, serial_number, customer_id).catch(e => { log(`Background check error for ${serial_number}:`, e.message) activeChecks.delete(token + ':' + serial_number) }) return { status: 'checking', message: 'Vérification de la connexion lancée. Le diagnostic sera envoyé dans quelques instants.' } } async function runBackgroundCheck (convToken, serial, customerId) { const { getCached, fetchDeviceDetails } = require('./devices') const oltSnmp = require('./olt-snmp') const { getConversation, addMessage } = require('./conversation') const { sendSmsInternal } = require('./twilio') const results = [] for (let attempt = 0; attempt < 3; attempt++) { if (attempt > 0) await new Promise(r => setTimeout(r, 30000)) // 30s between checks let tr069Online = false, snmpOnline = false, snmpData = null, tr069Data = null try { const summary = await fetchDeviceDetails(serial) if (summary?.lastInform) { const age = (Date.now() - new Date(summary.lastInform).getTime()) / 60000 tr069Online = age < 20 tr069Data = { age: Math.round(age), uptime: summary.uptime, ip: summary.ip, rxPower: summary.rxPower } } } catch (_) {} try { const onu = oltSnmp.getOnuBySerial(serial) if (onu) { snmpOnline = onu.status === 'online' snmpData = { status: onu.status, lastOfflineCause: onu.lastOfflineCause, rxPowerOlt: onu.rxPowerOlt, rxPowerOnu: onu.rxPowerOnu, port: onu.port, oltName: onu.oltName } } } catch (_) {} const online = tr069Online || snmpOnline results.push({ attempt: attempt + 1, online, tr069Online, snmpOnline, tr069Data, snmpData, ts: new Date().toISOString() }) log(`BG check ${serial} #${attempt + 1}: online=${online} (TR069=${tr069Online}, SNMP=${snmpOnline})`) // If confirmed online on first check, no need to keep polling if (online && attempt === 0) break } activeChecks.delete(convToken + ':' + serial) // Analyze results const onlineCount = results.filter(r => r.online).length const lastResult = results[results.length - 1] const conv = getConversation(convToken) let diagText if (onlineCount === results.length) { // All checks say online diagText = 'Bonne nouvelle! Après vérification, votre modem est bien en ligne et fonctionnel. Si vous éprouvez des lenteurs, n\'hésitez pas à nous écrire.' } else if (onlineCount > 0) { // Intermittent diagText = 'Votre connexion semble intermittente — le modem alterne entre en ligne et hors ligne. Je vous suggère de débrancher le modem de la prise électrique pendant 30 secondes, puis de le rebrancher. Attendez environ 3 minutes pour qu\'il redémarre complètement.' } else { // Confirmed offline after all checks const cause = lastResult.snmpData?.lastOfflineCause || '' const lc = cause.toLowerCase() if (lc.includes('dying') || lc.includes('gasp')) { diagText = 'Votre modem semble hors ligne depuis un certain temps. La cause détectée est une perte d\'alimentation (Dying Gasp). Veuillez vérifier que le modem est bien branché à une prise fonctionnelle et que le bouton on/off est en position ON.' } else if (lc.includes('branch') || lc.includes('fiber cut') || lc.includes('fibre')) { diagText = 'Après vérification, une coupure de fibre a été détectée. Un technicien sera assigné pour intervenir. Nous vous tiendrons informé.' } else { diagText = 'Votre modem semble hors ligne depuis un certain temps. Je vous suggère de le débrancher de la prise électrique pendant 30 secondes, puis de le rebrancher. Attendez environ 3 minutes pour qu\'il redémarre complètement. Si le problème persiste, un agent prendra en charge votre dossier.' } } if (conv) { addMessage(conv, { from: 'agent', text: diagText, via: 'ai' }) // If customer came via SMS, also send SMS const lastCustomerMsg = [...conv.messages].reverse().find(m => m.from === 'customer') if (lastCustomerMsg?.via === 'sms' && conv.phone) { const chatLink = `${cfg.CLIENT_PUBLIC_URL}/c/${conv.token}` sendSmsInternal(conv.phone, `${diagText}\n\nContinuer en ligne: ${chatLink}`, conv.customer) .then(sid => { if (sid) conv.smsCount = (conv.smsCount || 0) + 1 }) .catch(e => log('BG check SMS error:', e.message)) } log(`BG check complete for ${serial}: ${onlineCount}/${results.length} online → sent diagnostic`) } } const createTicket = async ({ customer_id, subject, description, priority }) => { const r = await erpFetch('/api/resource/Issue', { method: 'POST', body: JSON.stringify({ customer: customer_id, subject, description: description || subject, priority: priority || 'Medium', status: 'Open', }), }) if (r.status < 200 || r.status >= 300) return { error: 'Failed to create ticket' } return { success: true, ticket_id: r.data?.data?.name, message: 'Ticket created successfully' } } const getChatLink = () => { if (_convContext.token) { return { link: `${cfg.CLIENT_PUBLIC_URL}/c/${_convContext.token}`, message: 'Voici le lien pour continuer la conversation en ligne' } } return { error: 'No active conversation token available' } } const checkOnuStatus = async (serial) => { const oltSnmp = require('./olt-snmp') const { getDeviceEvents } = require('./devices') // Get live ONU data from OLT SNMP cache const onu = oltSnmp.getOnuBySerial(serial) const events = await getDeviceEvents(serial, 10) if (!onu) { // No ONU found in OLT, check events only const recentEvents = events.map(e => ({ event: e.event, reason: e.reason, time: e.created_at, details: e.details, })) return { error: 'ONU not found in OLT SNMP cache', serial, recent_events: recentEvents } } const result = { serial: onu.serial, status: onu.status, olt: onu.oltName || null, port: onu.port, rx_power_olt: onu.rxPowerOlt != null ? (onu.rxPowerOlt / 100).toFixed(2) + ' dBm' : null, tx_power_olt: onu.txPowerOlt != null ? (onu.txPowerOlt / 100).toFixed(2) + ' dBm' : null, rx_power_onu: onu.rxPowerOnu != null ? (onu.rxPowerOnu / 100).toFixed(2) + ' dBm' : null, distance_m: onu.distance || null, temperature: onu.temperature || null, last_offline_cause: onu.lastOfflineCause || null, uptime: onu.uptime || null, recent_events: events.slice(0, 5).map(e => ({ event: e.event, reason: e.reason, time: e.created_at, })), } // Classify the alarm const cause = (onu.lastOfflineCause || '').toLowerCase() if (cause.includes('dying') || cause.includes('gasp')) { result.alarm_type = 'dying_gasp' result.alarm_description = 'Perte d\'alimentation électrique (Dying Gasp) — le modem a perdu le courant' } else if (cause.includes('branch') || cause.includes('fiber cut') || cause.includes('fibre')) { result.alarm_type = 'branch_fiber_cut' result.alarm_description = 'Coupure de fibre détectée (Branch Fiber Cut) — intervention terrain requise' } else if (cause.includes('losi') || cause.includes('los')) { result.alarm_type = 'loss_of_signal' result.alarm_description = 'Perte de signal optique (LOSi) — possible coupure ou dégradation fibre' } else if (cause.includes('lobi') || cause.includes('lob')) { result.alarm_type = 'loss_of_burst' result.alarm_description = 'Perte de burst (LOBi) — problème émetteur ONU' } else if (onu.status === 'offline' && cause) { result.alarm_type = 'other_offline' result.alarm_description = `Hors ligne: ${onu.lastOfflineCause}` } // ── Port neighbor correlation (automatic when offline) ── if (onu.status === 'offline' && onu.port) { const portData = oltSnmp.getPortNeighbors(serial) if (portData) { const neighbors = portData.neighbors || [] const offlineNeighbors = neighbors.filter(n => n.status === 'offline') const onlineNeighbors = neighbors.filter(n => n.status === 'online') result.port_context = { total_on_port: neighbors.length + 1, online_on_port: onlineNeighbors.length, offline_on_port: offlineNeighbors.length + 1, // +1 for target other_offline_causes: offlineNeighbors.map(n => oltSnmp.classifyOfflineCause(n.lastOfflineCause)), is_isolated: offlineNeighbors.length === 0, is_mass_outage: offlineNeighbors.length >= 3, } if (result.port_context.is_mass_outage) { result.port_context.mass_outage_warning = `ATTENTION: ${offlineNeighbors.length + 1} clients hors ligne sur le même port ${onu.port}. Probable panne en amont.` } } } return result } const execTool = async (name, input) => { try { const handlers = { get_customer_info: () => getCustomerInfo(input.customer_id), get_subscriptions: () => getSubscriptions(input.customer_id), get_invoices: () => getInvoices(input.customer_id, input.limit || 5), get_outstanding_balance: () => getOutstandingBalance(input.customer_id), get_service_locations: () => getServiceLocations(input.customer_id), get_equipment: () => getEquipment(input.customer_id), check_device_status: () => checkDeviceStatus(input.serial_number), check_onu_status: () => checkOnuStatus(input.serial_number), analyze_outage: () => require('./ai').analyzeOutage(input.serial_number), start_connection_check: () => startConnectionCheck({ serial_number: input.serial_number, customer_id: input.customer_id }), get_open_tickets: () => getOpenTickets(input.customer_id), create_ticket: () => createTicket(input), get_chat_link: () => getChatLink(), create_dispatch_job: () => require('./dispatch').agentCreateDispatchJob(input), } return handlers[name] ? await handlers[name]() : { error: 'Unknown tool: ' + name } } catch (e) { log(`Tool ${name} error:`, e.message) return { error: 'Tool execution failed: ' + e.message } } } // ── Build system prompt dynamically from agent-flows.json ──────────────────── const fs = require('fs') const path = require('path') const FLOWS_PATH = path.join(__dirname, '..', 'data', 'agent-flows.json') const FLOWS_DEFAULT = path.join(__dirname, 'agent-flows.json') let _flows = null function loadFlows () { try { // Try data/ (writable) first, fall back to lib/ (read-only default) const fp = fs.existsSync(FLOWS_PATH) ? FLOWS_PATH : FLOWS_DEFAULT _flows = JSON.parse(fs.readFileSync(fp, 'utf8')) log(`Agent flows loaded from ${fp} (v${_flows.version || 1}, ${_flows.intents?.length || 0} intents)`) } catch (e) { log('Failed to load agent-flows.json:', e.message) } return _flows } function getFlows () { return _flows || loadFlows() } function buildSystemPrompt () { const f = getFlows() if (!f) return 'Tu es un assistant de support client. Réponds en français.' const lines = [`Tu es l'assistant virtuel de ${f.persona.name}, ${f.persona.role}.`, '', 'Ton rôle:', '- Répondre aux questions des clients', '- Diagnostiquer les problèmes techniques', '- Créer des tickets ou dispatcher des techniciens quand nécessaire', `- Ton ton: ${f.persona.tone}`, ''] lines.push('Règles:') f.persona.rules.forEach(r => lines.push(`- ${r}`)) lines.push('- IMPORTANT: Ne JAMAIS dire au client que sa connexion est hors ligne sur la base d\'un seul check. Si check_device_status retourne online=false, appelle start_connection_check pour lancer une vérification en arrière-plan et dis au client "Nous vérifions votre connexion, un instant...". Le résultat sera envoyé automatiquement.') lines.push('- Si check_device_status retourne online=true, tu peux confirmer immédiatement que la connexion est fonctionnelle.') lines.push('- Si check_onu_status retourne port_context.is_mass_outage=true, informe le client qu\'une panne affecte son secteur et que nos équipes sont mobilisées. Utilise analyze_outage pour obtenir plus de détails.') lines.push('') lines.push('RÉTENTION CLIENT:') lines.push('- Si get_customer_info retourne un churn_status ou cancel_reason, le client est un ancien abonné ou à risque.') lines.push('- Si cancel_reason = "Compétiteur - Promotion" et cancel_competitor est renseigné, propose une comparaison de prix à long terme (les promos finissent, notre prix reste stable).') lines.push('- Si cancel_reason = "Qualité WiFi", propose un diagnostic WiFi et mentionne nos solutions mesh/répéteur/câblage.') lines.push('- Si cancel_reason = "Prix trop élevé", oriente vers les forfaits disponibles et les rabais de fidélité possibles.') lines.push('- Ne JAMAIS mentionner directement que tu vois les données de rétention. Sois naturel et bienveillant.') for (const intent of f.intents) { lines.push('', `FLOW: ${intent.label.toUpperCase()}`) lines.push(`Déclencheur: ${intent.trigger}`) lines.push('Étapes:') for (const step of intent.steps) { const prefix = step.parent ? ` Si ${step.branch}: ` : '- ' if (step.type === 'tool') { lines.push(`${prefix}Utilise ${step.tool}${step.note ? ` (${step.note})` : ''}`) } else if (step.type === 'condition') { lines.push(`${prefix}Vérifier: ${step.field} ${step.op} ${step.value}? [${step.label}]`) } else if (step.type === 'switch') { lines.push(`${prefix}Selon ${step.field}:`) } else if (step.type === 'respond') { lines.push(`${prefix}${step.label}: ${step.message}`) if (step.note) lines.push(` NOTE: ${step.note}`) } else if (step.type === 'action') { lines.push(`${prefix}${step.label}: appeler ${step.action}${step.params ? ' avec ' + JSON.stringify(step.params) : ''}`) if (step.message) lines.push(` → Dire au client: "${step.message}"`) } else if (step.type === 'goto') { lines.push(`${prefix}${step.label}`) } } } return lines.join('\n') } // Initialize on load loadFlows() const geminiChat = async (messages, tools) => { const url = `${cfg.AI_BASE_URL}chat/completions` const body = { model: cfg.AI_MODEL, max_tokens: 500, messages, tools } 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) { const delay = (attempt + 1) * 2000 log(`Rate limited (429), retrying in ${delay}ms`) await new Promise(r => setTimeout(r, delay)) continue } if (!res.ok) { const text = await res.text().catch(() => '') throw new Error(`Gemini API ${res.status}: ${text.substring(0, 200)}`) } return await res.json() } } const runAgent = async (customerId, customerName, messageHistory, { token } = {}) => { _convContext = { token: token || null } if (!cfg.AI_API_KEY) return 'Désolé, le service d\'assistance automatique n\'est pas disponible pour le moment. Un agent vous répondra sous peu.' const messages = [{ role: 'system', content: buildSystemPrompt() }] for (const m of messageHistory) { if (m.from === 'customer') messages.push({ role: 'user', content: m.text }) else if (m.from === 'agent') messages.push({ role: 'assistant', content: m.text }) } const firstUser = messages.find(m => m.role === 'user') if (firstUser) firstUser.content = `[Contexte: Client ${customerName} (${customerId})]\n\n${firstUser.content}` let response try { response = await geminiChat(messages, TOOLS) } catch (e) { log('Gemini API error:', e.message); return 'Désolé, une erreur est survenue. Un agent vous répondra sous peu.' } const currentMessages = [...messages] for (let i = 0; i < 5; i++) { const choice = response.choices?.[0] if (!choice || choice.finish_reason !== 'tool_calls') break const toolCalls = choice.message?.tool_calls if (!toolCalls?.length) break currentMessages.push(choice.message) for (const tc of toolCalls) { const args = JSON.parse(tc.function.arguments || '{}') log(`Agent tool call: ${tc.function.name}(${JSON.stringify(args)})`) const result = await execTool(tc.function.name, args) log(`Agent tool result: ${tc.function.name} →`, JSON.stringify(result).substring(0, 200)) currentMessages.push({ role: 'tool', tool_call_id: tc.id, content: JSON.stringify(result) }) } try { response = await geminiChat(currentMessages, TOOLS) } catch (e) { log('Gemini API error (tool loop):', e.message); return 'Désolé, une erreur est survenue pendant le diagnostic. Un agent vous répondra sous peu.' } } return response.choices?.[0]?.message?.content || 'Un agent vous répondra sous peu.' } // ── HTTP handler for /agent/* endpoints ────────────────────────────────────── const handleAgentApi = async (req, res, method, urlPath) => { const { json: jsonRes, parseBody: parse } = require('./helpers') if (urlPath === '/agent/flows' && method === 'GET') { const flows = getFlows() return jsonRes(res, 200, flows || { error: 'Flows not loaded' }) } if (urlPath === '/agent/flows' && method === 'PUT') { const body = await parse(req) try { // Validate structure if (!body.persona || !body.intents || !Array.isArray(body.intents)) { return jsonRes(res, 400, { error: 'Invalid flow: needs persona + intents[]' }) } body.version = (getFlows()?.version || 0) + 1 body.updated_at = new Date().toISOString() // Write to data/ (writable volume) const dir = path.dirname(FLOWS_PATH) if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) fs.writeFileSync(FLOWS_PATH, JSON.stringify(body, null, 2)) _flows = body log(`Agent flows updated (v${body.version}, ${body.intents.length} intents)`) return jsonRes(res, 200, { ok: true, version: body.version }) } catch (e) { return jsonRes(res, 500, { error: e.message }) } } if (urlPath === '/agent/prompt' && method === 'GET') { return jsonRes(res, 200, { prompt: buildSystemPrompt() }) } const { json: j } = require('./helpers') j(res, 404, { error: 'Agent endpoint not found' }) } module.exports = { runAgent, TOOLS, execTool, handleAgentApi, getFlows }