gigafibre-fsm/services/targo-hub/lib/agent.js
louispaulb 607ea54b5c refactor: reduce token count, DRY code, consolidate docs
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>
2026-04-13 08:39:58 -04:00

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 }