Backend services: - targo-hub: extract deepGetValue to helpers.js, DRY disconnect reasons lookup map, compact CAPABILITIES, consolidate vision.js prompts/schemas, extract dispatch scoring weights, trim section dividers across 9 files - modem-bridge: extract getSession() helper (6 occurrences), resetIdleTimer(), consolidate DM query factory, fix duplicate username fill bug, trim headers (server.js -36%, tplink-session.js -47%, docker-compose.yml -57%) Frontend: - useWifiDiagnostic: extract THRESHOLDS const, split processDiagnostic into 6 focused helpers (processOnlineStatus, processWanIPs, processRadios, processMeshNodes, processClients, checkRadioIssues) - EquipmentDetail: merge duplicate ROLE_LABELS, remove verbose comments Documentation (17 → 13 files, -1,400 lines): - New consolidated README.md (architecture, services, dependencies, auth) - Merge ECOSYSTEM-OVERVIEW into ARCHITECTURE.md - Merge MIGRATION-PLAN + ARCHITECTURE-COMPARE + FIELD-GAP + CHANGELOG → MIGRATION.md - Merge COMPETITIVE-ANALYSIS into PLATFORM-STRATEGY.md - Update ROADMAP.md with current phase status - Delete CONTEXT.md (absorbed into README) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
530 lines
25 KiB
JavaScript
530 lines
25 KiB
JavaScript
'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 }
|