gigafibre-fsm/services/targo-hub/lib/staff-agent.js
louispaulb 0ec7a8cbba feat(assistant): check_signal — live signal parity with F ("Do Stuff get")
The staff Assistant reported "signal non mesurable à distance" for fibre modems
because check_service only reads passive status (TR-069 + local SNMP poller),
which has no signal for tech-3 XGS-PON ONUs (olt3/172.17.x). F gets it live via
the n8n dostuff GET — the assistant just never called it.

New read tool check_signal (parity with F's live "get"):
- fibre: dostuffStatus (n8n, OLT live → Rx/Tx dBm, distance, uptime, VoIP/IPTV/
  WAN/firmware); tech-2 fallback to olt-snmp getOnuBySerial.
- sans-fil (airOS): airosSignal via the F bridge (signal/signal-AP/CCQ/TX-RX
  rate/airMAX capacity).
- resolves the customer's equipment from Service Equipment (prefers ONU w/ olt_ip,
  else CPE w/ ip_address). Quality advice (fibre Rx thresholds, airOS dBm).
- check_service now points at check_signal when signal is null; system prompt
  forbids concluding "non mesurable" without calling check_signal first.

Verified live on the reported case (Frédérick Séguin C-710816574555903 →
TPLGA1E7FA90 @ OLT 172.17.0.7): Rx -16.74 / Tx -11.77 dBm, "bon", distance 2024 m.
check_signal registered in staff tools.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 18:18:47 -04:00

561 lines
45 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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 dantenne 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 lappareil 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 lappareil.' }
} 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 }