'use strict' /** * staff-agent.js — Copilote OPS en langage naturel (Gemini function-calling), surface STAFF. * * BUT (parité UI↔NL) : le langage naturel peut faire ce que fait l'interface visuelle, en exposant * les ACTIONS EXISTANTES comme « outils » function-calling. NL et UI partagent UNE SEULE couche * d'action (les routes hub / fonctions que l'UI appelle déjà) → couverture(NL) = couverture(UI). * * RÉUTILISE l'existant (cf feedback_reuse_shared_components) : * - registre d'outils = services/targo-hub/lib/agent-tools.json (audience 'staff'), * - runtime Gemini function-calling = lib/ai.js `chat({tools, wantMessage})` (même boucle que * lib/agent.js répondeur SMS et lib/roster-assistant.js copilote roster), * - couche d'action = les routes hub EXISTANTES (roster.js/dispatch.js/conversation.js), appelées * en LOOPBACK autorisé (jeton de service + identité x-authentik-email) — zéro logique réécrite. * * SÉCURITÉ (cf feedback_ppa_double_charge_incident — une action auto peut coûter cher) : * - outils LECTURE : s'exécutent librement pendant le PLAN (résolution d'entités, aucun effet). * - outils ÉCRITURE/conséquents : NE s'exécutent PAS via le modèle. Ils sont mis en attente * (staged) avec un APERÇU. L'exécution réelle passe par POST /staff-agent/run APRÈS confirmation * humaine explicite (le modèle propose ; l'humain dispose). Pas d'endpoint arbitraire : /run mappe * type → exécuteur côté serveur (liste blanche), re-valide les permissions (parité usePermissions * via auth.effectiveCapabilities) et agit AU NOM de l'utilisateur connecté (x-authentik-email). * * Routes : * POST /staff-agent { text } → { ok, reply, actions:[{id,type,title,preview,params,permission,needs_confirmation}], transcript } * POST /staff-agent/run { actions } → { ok, results:[{type,ok,message|error}] } * GET /staff-agent/tools → { tools } (inventaire — parité/complétude) */ const cfg = require('./config') const { log, json, parseBody } = require('./helpers') const { toolsForModel, toolsByAudience } = require('./agent') const erp = require('./erp') // ── Registre : outils STAFF (audience 'staff') depuis le registre UNIQUE agent-tools.json ── const STAFF_TOOLS_RAW = toolsByAudience('staff') const STAFF_TOOLS = toolsForModel(STAFF_TOOLS_RAW) // envoyés au modèle : {type, function} seulement const META = {} // name → { mode, permission, title } for (const t of STAFF_TOOLS_RAW) META[t.function.name] = { mode: t.mode || 'read', permission: t.permission || null, title: t.title || t.function.name } // require paresseux (évite les cycles + n'alourdit pas le boot) — mêmes modules que l'UI appelle. const roster = () => require('./roster') const dispatch = () => require('./dispatch') const oltOps = () => require('./olt-ops') // Do Stuff : opérations OLT à distance (plan/run, confirm obligatoire) function todayET () { return new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) } // ── Loopback AUTORISÉ vers les routes hub EXISTANTES ───────────────────────── // Réutilise EXACTEMENT les handlers que le front appelle (couche d'action unique). On imite le proxy // nginx /ops/hub : Authorization = jeton de service + x-authentik-email = utilisateur connecté (→ // attribution, suivi, et vérifs d'identité des handlers). Pas d'endpoint fourni par le client : seuls // les chemins codés dans EXECUTORS sont atteignables. const PORT = cfg.PORT || 3300 async function hubCall (method, path, body, email) { const headers = { 'Content-Type': 'application/json' } if (process.env.HUB_SERVICE_TOKEN) headers.Authorization = 'Bearer ' + process.env.HUB_SERVICE_TOKEN if (email) headers['x-authentik-email'] = email const opt = { method, headers } if (body != null && method !== 'GET') opt.body = JSON.stringify(body) const r = await fetch(`http://127.0.0.1:${PORT}${path}`, opt) let data = null try { data = await r.json() } catch { /* corps non-JSON */ } return { status: r.status, ok: r.ok, data: data || {} } } // Un résultat hub → { ok, message } normalisé (les routes renvoient {ok:false,error} ou {error}). function hubResult (r, okMsg) { const ok = r.ok && r.data && r.data.ok !== false && !r.data.error return { ok, message: ok ? okMsg : ((r.data && r.data.error) || `échec (HTTP ${r.status})`), status: r.status, data: r.data } } async function resolveTechName (techId) { try { const techs = await roster().fetchTechnicians(); const t = techs.find(x => x.id === techId || x.name === techId); return t ? t.name : techId } catch { return techId } } // Résout l'ONU (n° de série) d'un client pour device_action — préfère un ONU/fibre non retiré ; ambigu si plusieurs. async function resolveCustomerOnu (customerId) { const rows = await erp.list('Service Equipment', { filters: [['customer', '=', String(customerId)]], fields: ['name', 'serial_number', 'equipment_type', 'olt_ip', 'status'], limit: 20, }).catch(() => []) const onus = (rows || []).filter(r => r.serial_number && (r.olt_ip || /onu|ont|fib|gpon/i.test(String(r.equipment_type || '')))) if (!onus.length) return { error: `Aucun ONU trouvé pour le client ${customerId} — fournis le numéro de série.` } const active = onus.filter(o => String(o.status || '').toLowerCase() !== 'retiré') const pick = active.length ? active : onus if (pick.length > 1) return { error: `Plusieurs ONU pour ${customerId} (${pick.map(o => o.serial_number).join(', ')}) — précise le numéro de série.` } return { serial: pick[0].serial_number } } // ── Outils LECTURE (exécutés pendant le PLAN — sans effet de bord) ─────────── async function read_list_technicians ({ query, skill } = {}) { let techs try { techs = await roster().fetchTechnicians() } catch (e) { return { error: 'techniciens indisponibles : ' + e.message } } const q = String(query || '').trim().toLowerCase() const sk = String(skill || '').trim().toLowerCase() let out = techs || [] if (q) out = out.filter(t => String(t.name || '').toLowerCase().includes(q) || String(t.id || '').toLowerCase().includes(q)) if (sk) out = out.filter(t => (t.skills || []).some(s => String(s).toLowerCase().includes(sk))) return { count: out.length, technicians: out.slice(0, 40).map(t => ({ id: t.id, name: t.name, status: t.status, skills: t.skills || [] })) } } async function read_get_job ({ job } = {}) { if (!job) return { error: 'job requis' } try { const d = await erp.get('Dispatch Job', job, { fields: ['name', 'subject', 'status', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h', 'priority', 'customer_name', 'service_location', 'job_type', 'booking_status'] }) if (!d || !d.name) return { error: 'job introuvable : ' + job } return { job: d.name, subject: d.subject, status: d.status, assigned_tech: d.assigned_tech || null, scheduled_date: d.scheduled_date || null, start_time: d.start_time || null, duration_h: d.duration_h, priority: d.priority, customer_name: d.customer_name || '', service_location: d.service_location || '', job_type: d.job_type, booking_status: d.booking_status || '' } } catch (e) { return { error: e.message } } } async function read_find_slot ({ duration_h, skill, after_date } = {}) { try { const slots = await dispatch().suggestSlots({ duration_h: Number(duration_h) || 1, skill: skill || '', after_date: after_date || todayET(), limit: 5 }) return { count: (slots || []).length, slots: (slots || []).slice(0, 5) } } catch (e) { return { error: e.message } } } async function read_resolve_skill ({ text, department } = {}) { try { const r = await require('./skill-resolver').resolveSkills({ text: text || '', department: department || '', useAI: true }) const skills = r.skills || [] return { skills, primary: r.primary || '', confidence: r.confidence, reason: r.reason || '', note: skills.length ? ('Compétence(s) requise(s) : ' + skills.join(', ') + '. Enchaîne find_slot avec skill=' + (r.primary || skills[0]) + ' pour la disponibilité.') : 'Aucune compétence terrain — traitable à distance (tous les agents).' } } catch (e) { return { error: e.message } } } async function read_resource_availability ({ skill, after_date, days } = {}) { try { const o = await dispatch().techOccupancy({ after_date: after_date || todayET(), days: Number(days) || 7, skill: skill || '' }) const techs = (o && o.techs) || [] const freeH = Math.round(techs.reduce((a, t) => a + (t.free_h || 0), 0) * 10) / 10 return { skill: skill || '(toute compétence)', window_days: (o && o.days) || 7, qualified_techs: techs.length, free_hours: freeH, techs: techs.slice(0, 8).map(t => ({ name: t.tech_name, free_h: Math.round((t.free_h || 0) * 10) / 10 })), note: techs.length ? (techs.length + ' tech(s) qualifié(s) « ' + (skill || 'tous') + ' » · ' + freeH + ' h libres sur ' + ((o && o.days) || 7) + ' jours') : 'Aucun tech qualifié disponible sur la période — élargis la fenêtre ou revois la compétence.' } } catch (e) { return { error: e.message } } } async function read_check_service ({ customer, query } = {}) { try { const r = await require('./ticket-collab').serviceStatus({ customer: customer || '', q: query || '' }) if (!r || r.ok === false) return { error: (r && r.error) || 'statut de service indisponible' } if (r.found === false) { const cands = (r.matches || []).slice(0, 6).map(m => ({ id: m.name, label: m.customer_name || m.name, caption: m.address || m.phone || m.email || '', icon: 'person' })) return { found: false, ambiguous: !!r.ambiguous, candidates: cands, note: r.ambiguous ? "Plusieurs clients correspondent — présente-les (l'interface affiche des boutons cliquables) et demande lequel ; ne devine pas." : 'Client/service introuvable.' } } const s = r.summary || {} let advice // Le signal (Rx/Tx dBm) n'est PAS toujours dans le statut passif (fibre tech-3 non couverte par la sonde locale) → // NE PAS conclure « non mesurable » : la QUALITÉ du signal se mesure EN DIRECT via check_signal (comme F). const needSignal = s.rxPower == null ? ' Pour la FORCE du signal (Rx/Tx dBm en direct), appelle check_signal — ne dis pas « non mesurable ».' : '' if (s.online === true) advice = 'Équipement EN LIGNE' + (s.rxPower != null ? ' (Rx ' + s.rxPower + ' dBm)' : '') + " → tente d'abord un correctif À DISTANCE (redémarrage / reprovision) ; un déplacement n'est probablement PAS nécessaire." + needSignal else if (s.online === false) advice = 'Équipement HORS LIGNE' + (s.rxPower != null ? ' (Rx ' + s.rxPower + ' dBm)' : '') + ' → signal faible/nul = DÉPLACEMENT probable (réparation fibre) ; sinon propose un redémarrage avant.' + needSignal else advice = "Statut indéterminé — pas de lecture fiable de l'équipement." + needSignal return { found: true, customer: r.customer && r.customer.name, customer_name: r.customer && r.customer.customer_name, online: s.online, rx_power_dbm: s.rxPower != null ? s.rxPower : null, signal: s.signal || null, status_label: s.label || '', source: s.source || '', subscriptions: (r.subscriptions || []).map(x => (x.plan || '?') + ' · ' + (x.status || '?')), advice } } catch (e) { return { error: e.message } } } // Qualité du signal FIBRE (Rx optique, dBm). Plage normale ~ -8 à -26 ; plus négatif = plus faible. function fibreAdvice (rx) { if (rx == null) return null if (rx > -8) return 'Rx élevé (' + rx + ' dBm) — atténuation très faible ; OK (vérifier sur-puissance si tout proche).' if (rx >= -25) return 'Rx BON (' + rx + ' dBm) — un correctif à distance suffit probablement, pas de déplacement.' if (rx >= -28) return 'Rx FAIBLE (' + rx + ' dBm) — à surveiller ; connecteur/soudure fibre à vérifier.' return 'Rx CRITIQUE (' + rx + ' dBm) — DÉPLACEMENT probable (réparation/soudure fibre).' } // Qualité du signal SANS-FIL (airOS, dBm). -50 excellent … < -78 critique. function wifiAdvice (s) { if (s == null) return null if (s >= -60) return 'Signal BON (' + s + ' dBm).' if (s >= -70) return 'Signal CORRECT (' + s + ' dBm) — peut faiblir par mauvais temps.' if (s >= -78) return 'Signal FAIBLE (' + s + ' dBm) — réalignement d’antenne probable.' return 'Signal CRITIQUE (' + s + ' dBm) — DÉPLACEMENT (réalignement / ligne de vue) probable.' } // SIGNAL EN DIRECT (parité « Do Stuff get » de F). FIBRE : dostuff n8n (tech-3, Rx/Tx dBm) → repli poller SNMP (tech-2). // SANS-FIL : ops_airos (pont F : signal/CCQ/débit/airMAX). Résout l'équipement du client depuis Service Equipment. async function read_check_signal ({ customer, serial } = {}) { try { let eq = null const F = ['name', 'serial_number', 'mac_address', 'ip_address', 'olt_ip', 'customer', 'status', 'equipment_type', 'brand', 'model'] if (serial) { const r = await erp.list('Service Equipment', { filters: [['serial_number', '=', String(serial)]], fields: F, limit: 1 }); eq = r && r[0] } if (!eq && customer) { const r = await erp.list('Service Equipment', { filters: [['customer', '=', String(customer)]], fields: F, limit: 20 }) const act = (r || []).filter(e => String(e.status || '').toLowerCase() !== 'retiré') eq = act.find(e => e.olt_ip && e.serial_number) || act.find(e => e.ip_address && e.serial_number) || act.find(e => e.olt_ip || e.ip_address) || act[0] || (r || [])[0] } if (!eq) return { error: 'Aucun équipement réseau pour ce client — signal non récupérable. Vérifie que l’appareil est rattaché (Service Equipment).' } const sn = eq.serial_number || '', olt = eq.olt_ip || '', ip = eq.ip_address || '', mac = eq.mac_address || '' const tc = require('./ticket-collab') // ── FIBRE (olt_ip) : F get = dostuff (OLT en direct). tech-3 = n8n ; tech-2 = repli sonde SNMP locale. ── if (olt) { const live = await tc.dostuffStatus({ sn, olt }) if (live && live.ok) return { found: true, kind: 'fibre', serial: sn, online: live.online, rx_dbm: live.rxPower, tx_dbm: live.txPower, signal: live.signal, distance_m: live.distance, uptime: live.uptime, wan_ip: live.wanIp, firmware: live.firmware, source: 'dostuff (OLT en direct, comme F)', advice: fibreAdvice(live.rxPower) } try { const onu = require('./olt-snmp').getOnuBySerial(String(sn)); if (onu && onu.rxPower != null) return { found: true, kind: 'fibre', serial: sn, rx_dbm: onu.rxPower, tx_dbm: (onu.txPower != null ? onu.txPower : null), source: 'olt-snmp (sonde locale, tech-2)', advice: fibreAdvice(onu.rxPower) } } catch (e) { /* poller indispo */ } return { found: true, kind: 'fibre', serial: sn, rx_dbm: null, online: (live && live.online != null) ? live.online : null, note: 'Signal live non remonté (OLT lente ou hors couverture). Réessaie, ou propose un redémarrage.', advice: null } } // ── SANS-FIL (airOS) : F get = ops_airos (pont F). Signal / CCQ / débit / capacité airMAX du CPE. ── if (ip || mac) { const air = await tc.airosSignal({ sn, ip, mac }) if (air && air.ok) return { found: true, kind: 'sans-fil', serial: sn, model: air.model, signal_dbm: air.signal, signal_ap_dbm: air.signalAp, ccq_pct: air.ccq, tx_rate: air.txRate, rx_rate: air.rxRate, airmax_down_mbps: air.capDown, airmax_up_mbps: air.capUp, uptime: air.uptime, source: 'ops_airos (CPE en direct, comme F)', advice: wifiAdvice(air.signal) } return { found: true, kind: 'sans-fil', serial: sn, note: (air && air.error) || 'CPE injoignable', advice: null } } return { found: true, serial: sn, note: 'Équipement sans coordonnées réseau (ni OLT ni IP de gestion) — signal non récupérable ; vérifie le rattachement de l’appareil.' } } catch (e) { return { error: e.message } } } // Recherche floue d'un client → CANDIDATS (l'UI les rend cliquables dans le fil). Réutilise /collab/customer-search // (même endpoint que l'ancien typeahead) MAIS c'est l'ASSISTANT qui liste les options — plus besoin d'un 2e champ. async function read_find_customer ({ query } = {}) { const q = String(query || '').trim() if (q.length < 2) return { error: 'requête trop courte (2 lettres min)' } try { const r = await hubCall('GET', `/collab/customer-search?q=${encodeURIComponent(q)}&team=1`) const matches = (r.data && r.data.matches) || [] const candidates = matches.filter(m => m.kind !== 'équipe').slice(0, 8).map(m => ({ id: m.name, label: m.customer_name || m.name, caption: [m.matched, m.address, m.email].filter(Boolean).join(' · ') || m.name, icon: m.matched === 'adresse' ? 'location_on' : m.matched === 'téléphone' ? 'call' : m.matched === 'courriel' ? 'mail' : 'person', })) if (!candidates.length) return { count: 0, candidates: [], note: `Aucun client pour « ${q} ».` } if (candidates.length === 1) return { count: 1, candidates, note: `Un seul client : ${candidates[0].label} (${candidates[0].id}) — utilise cet id et continue.` } return { count: candidates.length, candidates, note: `${candidates.length} clients possibles — demande « lequel ? » ; l'interface affiche la liste cliquable (ne les réénumère pas en texte).` } } catch (e) { return { error: e.message } } } // ── Client 360 (LECTURE) : réutilise les getters du répondeur client (lib/agent.execTool) — solde, factures, // abonnements, équipement — + historique tickets. Le client est résolu en amont par find_customer (id C-xxxx). const agentExec = (name, input) => require('./agent').execTool(name, input || {}) async function read_customer_overview ({ customer } = {}) { const cid = String(customer || '').trim(); if (!cid) return { error: 'customer (id C-xxxx) requis — résous-le via find_customer' } const [info, balance, subscriptions, tickets] = await Promise.all([ agentExec('get_customer_info', { customer_id: cid }), agentExec('get_outstanding_balance', { customer_id: cid }), agentExec('get_subscriptions', { customer_id: cid }), agentExec('get_open_tickets', { customer_id: cid }), ]) const solde = balance && typeof balance.outstanding_balance === 'number' ? balance.outstanding_balance : null return { customer: cid, info, balance, subscriptions, open_tickets: tickets, note: `Aperçu 360 — résume identité, SOLDE${solde != null ? ' (' + solde.toFixed(2) + ' $)' : ''}, abonnements, tickets ouverts.` } } async function read_customer_balance ({ customer } = {}) { const cid = String(customer || '').trim(); if (!cid) return { error: 'customer (id C-xxxx) requis — résous-le via find_customer' } const [balance, invoices] = await Promise.all([agentExec('get_outstanding_balance', { customer_id: cid }), agentExec('get_invoices', { customer_id: cid, limit: 6 })]) return { customer: cid, balance, invoices } } async function read_customer_subscriptions ({ customer } = {}) { const cid = String(customer || '').trim(); if (!cid) return { error: 'customer (id C-xxxx) requis — résous-le via find_customer' } return { customer: cid, ...(await agentExec('get_subscriptions', { customer_id: cid })) } } async function read_customer_equipment ({ customer } = {}) { const cid = String(customer || '').trim(); if (!cid) return { error: 'customer (id C-xxxx) requis — résous-le via find_customer' } return { customer: cid, ...(await agentExec('get_equipment', { customer_id: cid })) } } async function read_customer_history ({ customer } = {}) { const cid = String(customer || '').trim(); if (!cid) return { error: 'customer (id C-xxxx) requis — résous-le via find_customer' } try { const issues = await erp.list('Issue', { filters: [['customer', '=', cid]], fields: ['name', 'subject', 'status', 'creation'], orderBy: 'creation desc', limit: 20 }) const tickets = (issues || []).map(t => ({ id: t.name, subject: t.subject, status: t.status, date: String(t.creation || '').slice(0, 10) })) const openN = tickets.filter(t => !/closed|resolved|ferm|résolu|annul/i.test(String(t.status || ''))).length return { customer: cid, count: tickets.length, open_count: openN, tickets, note: tickets.length ? `${tickets.length} ticket(s) au dossier (${openN} ouvert(s)) — des problèmes sont déjà survenus.` : 'Aucun ticket au dossier — aucun problème enregistré pour ce client.' } } catch (e) { return { error: e.message } } } const READERS = { list_technicians: read_list_technicians, get_job: read_get_job, find_slot: read_find_slot, resolve_skill: read_resolve_skill, resource_availability: read_resource_availability, check_service: read_check_service, check_signal: read_check_signal, find_customer: read_find_customer, customer_overview: read_customer_overview, customer_balance: read_customer_balance, customer_subscriptions: read_customer_subscriptions, customer_equipment: read_customer_equipment, customer_history: read_customer_history } // ── Jours de la semaine — clés du weekly_schedule (= DOW_KEYS de roster.js) ── const DAY_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] const DAY_FR = { mon: 'Lun', tue: 'Mar', wed: 'Mer', thu: 'Jeu', fri: 'Ven', sat: 'Sam', sun: 'Dim' } const DAY_ALIASES = { mon: 'mon', tue: 'tue', wed: 'wed', thu: 'thu', fri: 'fri', sat: 'sat', sun: 'sun', monday: 'mon', tuesday: 'tue', wednesday: 'wed', thursday: 'thu', friday: 'fri', saturday: 'sat', sunday: 'sun', lun: 'mon', mar: 'tue', mer: 'wed', jeu: 'thu', ven: 'fri', sam: 'sat', dim: 'sun', lundi: 'mon', mardi: 'tue', mercredi: 'wed', jeudi: 'thu', vendredi: 'fri', samedi: 'sat', dimanche: 'sun', } function normDays (days) { const set = new Set() for (const d of (Array.isArray(days) ? days : [])) { const k = DAY_ALIASES[String(d || '').trim().toLowerCase()]; if (k) set.add(k) } return DAY_KEYS.filter(k => set.has(k)) // ordre lun→dim, dédupliqué } function normTime (t, dflt = '') { const m = String(t || '').trim().match(/^(\d{1,2})(?:\s*[:hH]\s*(\d{2}))?/) // "8", "8:00", "8h", "16h00" if (!m) return dflt return String(Math.min(23, parseInt(m[1], 10))).padStart(2, '0') + ':' + (m[2] || '00') } function buildWeeklySchedule (days, start, end) { const st = normTime(start), en = normTime(end) const sched = {} for (const k of normDays(days)) sched[k] = { start: st, end: en } return sched } // ── Outils ÉCRITURE — { permission, build(params)→{title,preview,params[,severity]}, exec(params,email) } ── // build : mis en attente pendant le plan (aperçu). exec : exécution réelle après confirmation (via /run), // chaque exec = fine enveloppe sur une route/fonction hub EXISTANTE. const WRITES = { create_job: { permission: 'create_jobs', async build (p) { const params = { subject: String(p.subject || '').trim(), customer_id: p.customer_id || '', service_location: p.service_location || '', address: p.address || '', priority: p.priority || '', job_type: p.job_type || '', notes: p.notes || '', auto_assign: p.auto_assign !== false } const preview = `« ${params.subject || 'Intervention'} »${params.job_type ? ' · ' + params.job_type : ''}${params.priority ? ' · priorité ' + params.priority : ''}${params.address ? ' · ' + params.address : ''}${params.customer_id ? ' · client ' + params.customer_id : ''} · ${params.auto_assign ? 'assignation auto' : 'non assigné'}` return { title: META.create_job.title, preview, params } }, async exec (p) { const r = await dispatch().agentCreateDispatchJob({ customer_id: p.customer_id || '', service_location: p.service_location || '', address: p.address || '', subject: p.subject, priority: p.priority || undefined, job_type: p.job_type || undefined, notes: p.notes || '', auto_assign: p.auto_assign }) return { ok: !!(r && r.success), message: (r && r.message) || 'job créé', job_id: r && r.job_id, assigned_tech: r && r.assigned_tech } }, }, assign_tech: { permission: 'assign_jobs', async build (p) { const name = await resolveTechName(p.tech_id) const params = { job: p.job, tech: p.tech_id, date: p.date || '', start: p.start || '' } return { title: META.assign_tech.title, preview: `${p.job} → ${name} (${p.tech_id})${p.date ? ' · ' + p.date : ''}${p.start ? ' à ' + p.start : ''}`, params } }, async exec (p, email) { const body = { job: p.job, tech: p.tech } if (p.date) body.date = p.date if (p.start) body.start = p.start return hubResult(await hubCall('POST', '/roster/assign-job', body, email), `${p.job} assigné à ${p.tech}`) }, }, proposer_rdv_client: { permission: 'assign_jobs', async build (p) { const ref = p.job || p.ticket || p.customer_id || 'le job ouvert du client' const params = { job: p.job || '', ticket: p.ticket || '', customer: p.customer_id || '', message: p.message || '' } return { title: META.proposer_rdv_client.title, preview: `Envoyer au client le lien de prise de RDV (il choisit un créneau réservé 5 min, ou propose 3 dispos) — ${ref}`, params } }, async exec (p, email) { const body = {} if (p.job) body.job = p.job if (p.ticket) body.ticket = p.ticket if (p.customer) body.customer = p.customer if (p.message) body.message = p.message const r = await hubCall('POST', '/roster/book/propose', body, email) const ok = r.ok && r.data && r.data.ok !== false && !r.data.error const msg = ok ? (r.data.sms ? 'Lien de RDV envoyé au client par texto.' : ('Lien de RDV prêt : ' + (r.data.url || '') + (r.data.note ? ' — ' + r.data.note : ''))) : ((r.data && r.data.error) || `échec (HTTP ${r.status})`) return { ok, message: msg, data: r.data } }, }, add_assistant: { permission: 'assign_jobs', async build (p) { const name = p.tech_name || await resolveTechName(p.tech_id) return { title: META.add_assistant.title, preview: `Renfort sur ${p.job} : ${name} (${p.tech_id})`, params: { job: p.job, tech_id: p.tech_id, tech_name: name } } }, async exec (p, email) { return hubResult(await hubCall('POST', '/roster/job/team', { job: p.job, add: { tech_id: p.tech_id, tech_name: p.tech_name || '' } }, email), `Assistant ${p.tech_id} ajouté à ${p.job}`) }, }, set_job_status: { permission: 'assign_jobs', async build (p) { return { title: META.set_job_status.title, preview: `${p.job} → statut « ${p.status} »`, params: { job: p.job, status: p.status }, severity: p.status === 'Cancelled' ? 'high' : 'normal' } }, async exec (p, email) { return hubResult(await hubCall('POST', '/roster/job/update', { job: p.job, patch: { status: p.status } }, email), `${p.job} → ${p.status}`) }, }, reschedule_job: { permission: 'assign_jobs', async build (p) { return { title: META.reschedule_job.title, preview: `${p.job} reporté au ${p.date}${p.start ? ' à ' + p.start : ''}`, params: { job: p.job, date: p.date, start: p.start || '' } } }, async exec (p, email) { // Conserve le tech assigné si possible (re-place l'heure via /roster/assign-job) ; sinon change la date via /roster/job/update. let tech = null try { const d = await erp.get('Dispatch Job', p.job, { fields: ['assigned_tech'] }); tech = d && d.assigned_tech } catch { /* fallback ci-dessous */ } if (tech) { const body = { job: p.job, tech, date: p.date } if (p.start) body.start = p.start return hubResult(await hubCall('POST', '/roster/assign-job', body, email), `${p.job} reporté au ${p.date}`) } return hubResult(await hubCall('POST', '/roster/job/update', { job: p.job, patch: { scheduled_date: p.date } }, email), `${p.job} reporté au ${p.date}`) }, }, create_recurring_shift: { permission: 'assign_jobs', async build (p) { const days = normDays(p.days) const schedule = buildWeeklySchedule(p.days, p.start, p.end) const name = await resolveTechName(p.technicien_id) const st = normTime(p.start), en = normTime(p.end) const worked = days.map(k => DAY_FR[k]).join(', ') || '(aucun jour)' const off = DAY_KEYS.filter(k => !days.includes(k)).map(k => DAY_FR[k]).join(', ') const preview = `${name} (${p.technicien_id}) : ${st}–${en} le ${worked}${off ? ` · repos ${off}` : ''}` // schedule pré-calculé porté dans les params → /run le pousse tel quel sur la route weekly-schedule. return { title: META.create_recurring_shift.title, preview, params: { technicien_id: p.technicien_id, schedule } } }, async exec (p, email) { if (!p.schedule || typeof p.schedule !== 'object' || !Object.keys(p.schedule).length) return { ok: false, message: 'horaire vide (aucun jour)' } return hubResult(await hubCall('POST', `/roster/technician/${encodeURIComponent(p.technicien_id)}/weekly-schedule`, { schedule: p.schedule }, email), 'Horaire récurrent enregistré (quarts matérialisés au prochain cycle)') }, }, follow_doc: { permission: null, // suivi = personnel/bas risque → tout staff authentifié async build (p) { const follow = p.follow !== false return { title: META.follow_doc.title, preview: `${follow ? 'Suivre' : 'Ne plus suivre'} ${p.doctype} ${p.name}`, params: { doctype: p.doctype, name: p.name, follow } } }, async exec (p, email) { return hubResult(await hubCall('POST', '/conversations/follow', { doctype: p.doctype, name: p.name, follow: p.follow !== false }, email), (p.follow !== false) ? 'Suivi activé' : 'Suivi retiré') }, }, // Do Stuff : opération à distance sur l'ONU via l'OLT (olt-ops). build = APERÇU via plan() (lecture seule) ; // exec = run({confirm:true}) — double-verrou (confirm humain + confirm:true module) + idempotence + acteur journalisé. device_action: { permission: 'reboot_provision', // capacité « Équipement › Reboot/provisionner » (déjà au catalogue, assignable par groupe) async build (p) { const action = String(p.action || '').trim() if (!oltOps().ACTIONS[action]) throw new Error(`action inconnue : « ${action} » (reboot, speed, suspend, unsuspend, activate, replace, remove)`) let serial = String(p.serial || '').trim() if (!serial && p.customer_id) { const r = await resolveCustomerOnu(p.customer_id); if (r.error) throw new Error(r.error); serial = r.serial } if (!serial) throw new Error('serial (n° de série ONU) ou customer_id requis') const opts = {} if (p.profileid != null && p.profileid !== '') opts.profileid = String(p.profileid) if (p.new_sn) opts.new_sn = String(p.new_sn) // APERÇU = plan olt-ops (LECTURE SEULE) : effet, cible réseau, avertissements, exécutable ? const pl = await oltOps().plan({ serial, action, ...opts }) const idempotencyKey = `sa-${action}-${serial}-${Date.now()}` // fixé au plan → un double-clic Confirmer rejoue la MÊME clé (dédup module) const who = pl.customer ? ` · client ${pl.customer}` : '' if (!pl.can_run) { // Repli REBOOT → TR-069 : les ONU tech-3 (TPLG, la plus grosse flotte) ne rebootent PAS via olt-ops (SNMP // Raisecom = tech-2). On passe alors par GenieACS (devices.rebootBySerial). Autres actions : non exécutables. if (action === 'reboot') { return { title: META.device_action.title, preview: `Redémarrer le modem (TR-069) — ONU ${serial}${who} · ⚠ coupe l'accès du client au moins ~3 min (le temps que le modem redémarre et se reconnecte).`, severity: 'high', params: { via: 'tr069', serial, action, idempotencyKey } } } throw new Error(`« ${pl.label || action} » non exécutable pour ${serial}${(pl.warnings && pl.warnings.length) ? ' — ' + pl.warnings.join(' ') : ''}`) } const warn = (pl.warnings && pl.warnings.length) ? ' ⚠ ' + pl.warnings.join(' ') : '' const preview = `${pl.label} — ONU ${pl.target.serial}${pl.target.olt ? ' · OLT ' + pl.target.olt : ''}${who}${(pl.effects && pl.effects.length) ? ' · ' + pl.effects.join(' ') : ''}${warn}` const severity = /^(suspend|remove|replace|activate|reboot|speed)$/.test(action) ? 'high' : 'normal' // reboot/speed = l'ONU redémarre (coupure) ⇒ conséquent return { title: META.device_action.title, preview, severity, params: { via: 'olt', serial, action, profileid: opts.profileid || '', new_sn: opts.new_sn || '', idempotencyKey } } }, async exec (p, email) { if (p.via === 'tr069') { // reboot via GenieACS (ONU tech-3) const r = await require('./devices').rebootBySerial(p.serial) return { ok: !!(r && r.ok), message: (r && r.ok) ? `✅ Redémarrage (TR-069) envoyé — ${p.serial}` : `échec redémarrage TR-069 : ${(r && r.error) || 'erreur'}`, data: r } } const opts = {} if (p.profileid) opts.profileid = p.profileid if (p.new_sn) opts.new_sn = p.new_sn const r = await oltOps().run({ serial: p.serial, action: p.action, confirm: true, idempotencyKey: p.idempotencyKey, actor: email || 'ops', ...opts }) const label = (oltOps().ACTIONS[p.action] && oltOps().ACTIONS[p.action].label) || p.action if (r && r.ok) { const extra = (r.mirrored && r.mirrored.length) ? ' · ' + r.mirrored.join(', ') : '' return { ok: true, message: `✅ ${label} — ${p.serial} (${r.method || '?'})${r.idempotent ? ' [déjà exécuté]' : ''}${extra}`, data: r } } return { ok: false, message: `échec « ${label} » : ${(r && (r.net_error || r.error)) || 'erreur réseau'}`, data: r } }, }, } // ── Runtime Gemini (function-calling) — même config/boucle que lib/agent.js & roster-assistant.js ── async function geminiChat (messages) { // reasoningEffort:'none' : gemini-2.5-flash SANS « thinking » — sinon ses jetons de réflexion mangent maxTokens et // renvoient une réponse VIDE (« Aucune action détectée »), surtout avec beaucoup d'outils (cf feedback_gemini_thinking_tokens). // maxTokens relevé (1200) pour laisser la place aux résumés client 360. const msg = await require('./ai').chat({ task: 'nl', messages, tools: STAFF_TOOLS, maxTokens: 1200, temperature: 0.1, wantMessage: true, reasoningEffort: 'none' }) return { choices: [{ message: msg }] } } function systemPrompt (email) { return `Tu es le COPILOTE OPS (dispatch/planification) de Gigafibre/TARGO, fournisseur Internet/TV/téléphonie au Québec. Aujourd'hui = ${todayET()}. Tu agis AU NOM de l'utilisateur connecté${email ? ` (${email})` : ''}. Tu transformes une commande en langage naturel en APPELS D'OUTILS. RÈGLES : - Utilise d'abord les outils de LECTURE (find_customer, customer_overview, customer_balance, customer_subscriptions, customer_equipment, customer_history, list_technicians, get_job, resolve_skill, resource_availability, find_slot, check_service, check_signal) pour RÉSOUDRE, RENSEIGNER et DIAGNOSTIQUER. Résous toujours un technicien nommé (« Simon ») en tech_id via list_technicians avant d'agir. - IDENTIFIER LE CLIENT : si le client n'est pas donné par son id (C-xxxx), appelle find_customer avec ce que tu as (nom, adresse, téléphone, courriel). UN seul candidat → prends son id et continue. PLUSIEURS → NE DEVINE PAS : demande brièvement « lequel ? » ; l'interface affiche la liste cliquable des candidats, donc NE les réénumère PAS tous en texte. Utilise ensuite l'id choisi pour l'action. - QUESTIONS CLIENT (solde, montant dû, factures, abonnements, forfait, équipement, historique, « c'est qui ? », « résume le compte », « un problème est-il déjà arrivé ? ») : résous d'abord le client (find_customer → id C-xxxx), puis appelle le bon lecteur — customer_overview (résumé 360 : identité + SOLDE + abonnements + tickets ouverts), customer_balance (solde + factures), customer_subscriptions, customer_equipment, customer_history (tickets passés = problème déjà survenu ?). LECTURE : réponds directement, en français, sans étape de confirmation. Cite le SOLDE en dollars quand on le demande. - PROBLÈME DE CONNEXION (« pas d'internet », « lent », « hors ligne », « coupé ») : appelle check_service AVANT de proposer un déplacement. Si le modem est EN LIGNE → propose d'abord un correctif À DISTANCE via device_action(reboot) ; s'il est HORS LIGNE avec signal faible/nul → un déplacement (réparation) est justifié. Dis à l'utilisateur ce que tu as constaté (en ligne/hors ligne · Rx dBm) avant de recommander. - QUALITÉ / FORCE DU SIGNAL (« quel est son signal ? », « signal fibre/sans-fil », « c'est bon comme signal ? », ou dès que check_service renvoie un signal nul) : appelle **check_signal** — il MESURE EN DIRECT (comme le « Do Stuff get » de F) : fibre = Rx/Tx dBm + distance via l'OLT ; sans-fil (airOS) = signal/CCQ/débit/capacité airMAX du CPE. C'est LENT (~10-30 s) → dis « un instant, je mesure le signal en direct… ». NE conclus JAMAIS « signal non mesurable / pas mesurable à distance » : appelle check_signal d'abord ; s'il ne remonte rien, dis-le précisément (OLT lente / hors couverture / CPE injoignable) et propose un redémarrage. - CORRECTIF À DISTANCE (Do Stuff) : device_action agit sur l'ONU du client via l'OLT — reboot (redémarrer), speed (changer le forfait), suspend/unsuspend (couper/rétablir l'internet), activate/replace/remove (provisionnement). Fournis serial OU customer_id (on résout l'ONU). Utilise-le pour éviter un déplacement quand c'est réparable à distance. ÉCRITURE RÉSEAU conséquente → toujours PROPOSÉE puis CONFIRMÉE ; n'affirme jamais que c'est fait avant confirmation. - INTERVENTION à créer/assigner : procède par ÉTAPES conversationnelles, ne saute PAS à la création. (1) Cerne la RAISON (si vague ou absente, pose UNE question courte plutôt que deviner). (2) resolve_skill → compétence requise. (3) resource_availability (combien de techs qualifiés dispo + heures libres) puis find_slot (créneaux précis) → DISPONIBILITÉ réelle. (4) SEULEMENT ensuite propose create_job / assign_tech / proposer_rdv_client. Résume ton diagnostic (raison → compétence → ressource dispo) AVANT de proposer, et inclus l'adresse dans create_job si fournie. - Les outils d'ÉCRITURE (create_job, assign_tech, proposer_rdv_client, add_assistant, set_job_status, reschedule_job, create_recurring_shift, follow_doc, device_action) NE S'EXÉCUTENT PAS tout de suite : ils sont PROPOSÉS puis CONFIRMÉS par l'utilisateur. Chaque appel te renvoie un APERÇU (staged=true). N'affirme JAMAIS qu'une action est faite ; annonce ce qui SERA fait après confirmation. - POUR PROPOSER une action d'écriture (même conséquente comme suspend/remove/reboot), tu DOIS APPELER l'outil : l'appel crée l'aperçu + le bouton « Confirmer et exécuter » que l'utilisateur clique. NE demande JAMAIS « voulez-vous que je procède ? » en TEXTE à la place de l'appel, et n'attends pas un « oui » avant d'appeler — l'APPEL EST la proposition, et rien ne s'exécute avant le clic de confirmation. (Poser une question ne vaut que s'il MANQUE une donnée pour construire l'appel : quel client/serial, quel job, quelle date.) - Horaire/quart RÉCURRENT : days parmi mon,tue,wed,thu,fri,sat,sun. « jours de semaine » = mon,tue,wed,thu,fri ; retire les exceptions (« sauf le vendredi » → mon,tue,wed,thu). « fin de semaine » = sat,sun. Convertis « 8-16h » en start 08:00 / end 16:00. Il FAUT un technicien : si aucun n'est nommé, DEMANDE-le. - S'il manque une information essentielle (quel job ? quel tech ? quelle date ?), pose UNE question courte au lieu d'appeler un outil d'écriture. - Réponds en français, bref. Après avoir proposé des actions, résume-les en une phrase et invite à confirmer.` } async function execToolPlan (name, args, ctx) { const meta = META[name] if (!meta) return { error: 'outil inconnu : ' + name } try { if (meta.mode === 'read') { const fn = READERS[name]; if (!fn) return { error: 'lecteur manquant : ' + name } const out = await fn(args || {}) if (out && Array.isArray(out.candidates) && out.candidates.length > 1) ctx.options = out.candidates // client ambigu → l'UI affiche des options cliquables return out } const w = WRITES[name] if (!w) return { error: 'exécuteur manquant : ' + name } const staged = await w.build(args || {}, ctx.email) const id = 'a' + (ctx.planned.length + 1) ctx.planned.push({ id, type: name, title: staged.title, preview: staged.preview, params: staged.params, permission: meta.permission || null, severity: staged.severity || 'normal', needs_confirmation: true }) return { staged: true, requires_confirmation: true, title: staged.title, preview: staged.preview, note: "Action PROPOSÉE, NON exécutée. L'utilisateur doit la confirmer. Ne dis pas qu'elle est faite." } } catch (e) { return { error: String(e.message || e) } } } // PLAN : boucle function-calling. Les LECTURES s'exécutent (résolution), les ÉCRITURES sont mises en attente. async function plan (text, email, history) { if (!cfg.AI_API_KEY) return { ok: false, error: "Le service IA n'est pas configuré (AI_API_KEY manquante)." } const planned = [] const ctx = { email, planned, options: [] } // options = candidats cliquables (désambiguïsation client/entité) à afficher dans le fil // Chat MULTI-TOURS : les tours précédents (user/assistant) donnent le contexte pour répondre à une question de // clarification (« oui, crée l'intervention »). Bornage : 10 derniers tours, 4000 car. chacun. const hist = Array.isArray(history) ? history.filter(m => m && (m.role === 'user' || m.role === 'assistant') && m.content).slice(-10).map(m => ({ role: m.role, content: String(m.content).slice(0, 4000) })) : [] const messages = [{ role: 'system', content: systemPrompt(email) }, ...hist, { role: 'user', content: String(text || '') }] let response try { response = await geminiChat(messages) } catch (e) { return { ok: false, error: 'IA : ' + e.message } } const cur = [...messages] for (let i = 0; i < 6; i++) { const msg = response.choices?.[0]?.message if (!msg) break const calls = msg.tool_calls // signal OpenAI-compat des appels d'outils (indépendant de finish_reason) if (!calls || !calls.length) break cur.push(msg) for (const tc of calls) { const args = (() => { try { return JSON.parse(tc.function.arguments || '{}') } catch { return {} } })() log(`[staff-agent] ${email || 'anon'} → ${tc.function.name}(${JSON.stringify(args)})`) const result = await execToolPlan(tc.function.name, args, ctx) cur.push({ role: 'tool', tool_call_id: tc.id, content: JSON.stringify(result).slice(0, 6000) }) } try { response = await geminiChat(cur) } catch (e) { return { ok: false, error: 'IA : ' + e.message } } } const reply = response.choices?.[0]?.message?.content || (planned.length ? 'Plan préparé — confirme pour exécuter.' : 'Aucune action détectée — reformule.') return { ok: true, reply, actions: planned, options: ctx.options, transcript: String(text || '') } } // RUN : exécute les actions CONFIRMÉES. type → exécuteur (liste blanche) + re-vérif permission + identité. async function run (actions, email) { const list = Array.isArray(actions) ? actions.slice(0, 12) : [] let caps = null try { caps = await require('./auth').effectiveCapabilities(email) } catch (e) { log('[staff-agent] perms indispo : ' + e.message) } const results = [] for (const a of list) { const type = a && a.type const w = WRITES[type] if (!w) { results.push({ type, ok: false, error: 'action inconnue' }); continue } // Permissions (parité usePermissions) : appliquées si Authentik est branché (prod). En dev/aperçu // (pas de token → caps.configured=false) on n'enforce pas mais on journalise — le gate du hub // (jeton de service) garantit déjà que seul un membre du staff authentifié atteint /staff-agent. const need = w.permission if (need && caps && caps.configured) { const allowed = caps.is_superuser || (caps.capabilities && caps.capabilities[need] === true) if (!allowed) { results.push({ type, ok: false, denied: true, error: `permission requise : ${need}` }); continue } } try { results.push({ type, ...(await w.exec(a.params || {}, email)) }) } catch (e) { results.push({ type, ok: false, error: String(e.message || e) }) } } return { ok: results.length > 0 && results.every(r => r.ok), results } } async function handle (req, res, method, path) { const email = String(req.headers['x-authentik-email'] || '').toLowerCase() if (path === '/staff-agent' && method === 'POST') { const b = await parseBody(req) try { return json(res, 200, await plan(b.text || '', email, b.history)) } catch (e) { return json(res, 200, { ok: false, error: 'Erreur copilote : ' + (e.message || e) }) } } if (path === '/staff-agent/run' && method === 'POST') { const b = await parseBody(req) try { return json(res, 200, await run(b.actions, email)) } catch (e) { return json(res, 200, { ok: false, error: 'Erreur exécution : ' + (e.message || e) }) } } if (path === '/staff-agent/tools' && method === 'GET') { return json(res, 200, { tools: STAFF_TOOLS_RAW.map(t => ({ name: t.function.name, mode: t.mode || 'read', permission: t.permission || null, title: t.title || t.function.name, description: t.function.description })) }) } return json(res, 404, { error: 'staff-agent: route inconnue ' + path }) } module.exports = { handle, plan, run, buildWeeklySchedule, normDays, normTime, STAFF_TOOLS_RAW }