feat(assistant): Do Stuff — outil device_action (OLT) dans le copilote STAFF

Le chat STAFF peut désormais lancer les 7 opérations OLT à distance (Do Stuff)
par langage naturel : reboot · speed · suspend · unsuspend · activate · replace
· remove. Un seul outil device_action(action, serial|customer_id, [profileid],
[new_sn]) → résout l'ONU du client au besoin, APERÇU via olt-ops.plan (effet,
cible, avertissements, can_run), puis exécution via olt-ops.run.

Sécurité (écriture réseau conséquente) :
- staged comme WRITE → aperçu + bouton « Confirmer et exécuter » (jamais auto).
- double-verrou : confirm humain + run({confirm:true}) du module + idempotencyKey
  (fixé au plan → un double-clic rejoue la même clé) + acteur journalisé.
- permission reboot_provision (capacité « Équipement » existante, assignable).
- can_run honoré : une action non branchée pour la techno de l'ONU (ex. reboot
  tech-3 = TR-069) n'est PAS proposée — l'assistant l'explique.
- prompt : APPELER l'outil EST la proposition (pas de « voulez-vous ? » en texte).

Vérifié live : NL « suspends l'internet de C-LPB4 » → device_action(suspend)
staged, severity high, cible TPLGC4160688/OLT résolue. GET /staff-agent/tools
liste device_action (15 outils).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-20 07:26:13 -04:00
parent 19e2c1fea0
commit 473b360c4c
2 changed files with 57 additions and 3 deletions

View File

@ -27,5 +27,6 @@
{ "audience": "staff", "mode": "write", "permission": "assign_jobs", "title": "Reporter le rendez-vous", "type": "function", "function": { "name": "reschedule_job", "description": "STAFF/dispatch. Reporte un Dispatch Job à une nouvelle date (et heure). Conserve le technicien assigné si possible. ÉCRITURE — confirmée avant exécution.", "parameters": { "type": "object", "properties": { "job": { "type": "string", "description": "Identifiant du Dispatch Job" }, "date": { "type": "string", "description": "Nouvelle date AAAA-MM-JJ" }, "start": { "type": "string", "description": "Nouvelle heure HH:MM (optionnel)" } }, "required": ["job", "date"] } } },
{ "audience": "staff", "mode": "write", "permission": "assign_jobs", "title": "Proposer un RDV au client", "type": "function", "function": { "name": "proposer_rdv_client", "description": "STAFF/dispatch. Envoie au CLIENT un lien (par texto) pour qu'il choisisse LUI-MÊME son rendez-vous : soit il prend un créneau proposé (réservé 5 min), soit il propose ses 3 disponibilités. À utiliser après un diagnostic quand une intervention est requise mais qu'on laisse le client choisir l'heure, plutôt que d'assigner soi-même un technicien. ÉCRITURE — confirmée avant exécution. Requiert un Dispatch Job OUVERT : fournis job, ou ticket (Issue source), ou customer_id pour le retrouver.", "parameters": { "type": "object", "properties": { "job": { "type": "string", "description": "Identifiant du Dispatch Job (optionnel si ticket/customer_id fourni)" }, "ticket": { "type": "string", "description": "Ticket/Issue source (optionnel) — retrouve le job ouvert lié" }, "customer_id": { "type": "string", "description": "ID client (optionnel) — retrouve son dernier job ouvert" }, "message": { "type": "string", "description": "Message texto personnalisé (optionnel)" } }, "required": [] } } },
{ "audience": "staff", "mode": "write", "permission": "assign_jobs", "title": "Créer un horaire récurrent", "type": "function", "function": { "name": "create_recurring_shift", "description": "STAFF/planification. Définit le QUART RÉCURRENT (horaire hebdomadaire) d'un technicien — matérialisé automatiquement en assignations sur l'horizon. ÉCRITURE — confirmée avant exécution. days = jours travaillés parmi mon,tue,wed,thu,fri,sat,sun. « jours de semaine » = mon,tue,wed,thu,fri ; retire les exceptions demandées (ex. « sauf le vendredi » → mon,tue,wed,thu). « fin de semaine » = sat,sun. Convertis « 8-16h » en start 08:00 / end 16:00.", "parameters": { "type": "object", "properties": { "technicien_id": { "type": "string", "description": "id du technicien (TECH-xxxx), via list_technicians" }, "days": { "type": "array", "items": { "type": "string" }, "description": "Jours travaillés: mon,tue,wed,thu,fri,sat,sun" }, "start": { "type": "string", "description": "Heure de début HH:MM, ex. 08:00" }, "end": { "type": "string", "description": "Heure de fin HH:MM, ex. 16:00" } }, "required": ["technicien_id", "days", "start", "end"] } } },
{ "audience": "staff", "mode": "write", "title": "Suivre", "type": "function", "function": { "name": "follow_doc", "description": "STAFF. (Dé)suivre un job ou un ticket pour recevoir les notifications de mise à jour (l'utilisateur connecté devient abonné). ÉCRITURE légère — confirmée avant exécution.", "parameters": { "type": "object", "properties": { "doctype": { "type": "string", "enum": ["Dispatch Job", "Issue"], "description": "Type de document à suivre" }, "name": { "type": "string", "description": "Identifiant du job ou du ticket" }, "follow": { "type": "boolean", "description": "true = suivre (défaut), false = ne plus suivre" } }, "required": ["doctype", "name"] } } }
{ "audience": "staff", "mode": "write", "title": "Suivre", "type": "function", "function": { "name": "follow_doc", "description": "STAFF. (Dé)suivre un job ou un ticket pour recevoir les notifications de mise à jour (l'utilisateur connecté devient abonné). ÉCRITURE légère — confirmée avant exécution.", "parameters": { "type": "object", "properties": { "doctype": { "type": "string", "enum": ["Dispatch Job", "Issue"], "description": "Type de document à suivre" }, "name": { "type": "string", "description": "Identifiant du job ou du ticket" }, "follow": { "type": "boolean", "description": "true = suivre (défaut), false = ne plus suivre" } }, "required": ["doctype", "name"] } } },
{ "audience": "staff", "mode": "write", "permission": "reboot_provision", "title": "Action équipement (OLT)", "type": "function", "function": { "name": "device_action", "description": "STAFF/réseau (Do Stuff). Opération à distance sur l'ONU/modem d'un client via l'OLT. ÉCRITURE RÉSEAU — proposée avec un APERÇU (effet, cible, avertissements) puis CONFIRMÉE avant exécution ; idempotente ; l'acteur est journalisé. À utiliser après check_service pour un CORRECTIF À DISTANCE avant d'envoyer un technicien (ex. redémarrer un modem en ligne). Actions : reboot (redémarrer l'ONU) · speed (changer le forfait / profil de ligne, l'ONU redémarre) · suspend (couper l'internet, bascule VLAN) · unsuspend (rétablir l'internet) · activate (provisionner l'ONU) · replace (remplacer le n° de série) · remove (retirer / désactiver l'ONU). Fournis serial (n° de série ONU) OU customer_id (on résout l'ONU du client). profileid requis pour activate / speed ; new_sn requis pour replace.", "parameters": { "type": "object", "properties": { "action": { "type": "string", "enum": ["reboot", "speed", "suspend", "unsuspend", "activate", "replace", "remove"], "description": "Opération à exécuter sur l'ONU" }, "serial": { "type": "string", "description": "Numéro de série de l'ONU (ex. TPLGxxxx, RCMGxxxx)" }, "customer_id": { "type": "string", "description": "ID client (C-xxxx) — si le serial est inconnu, on résout l'ONU du client" }, "profileid": { "type": "string", "description": "N° de profil de ligne (requis pour activate et speed)" }, "new_sn": { "type": "string", "description": "Nouveau numéro de série (requis pour replace)" } }, "required": ["action"] } } }
]

View File

@ -40,6 +40,7 @@ for (const t of STAFF_TOOLS_RAW) META[t.function.name] = { mode: t.mode || 'read
// 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' }) }
@ -71,6 +72,20 @@ async function resolveTechName (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
@ -267,6 +282,42 @@ const WRITES = {
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 })
if (!pl.can_run) throw new Error(`« ${pl.label || action} » non exécutable pour ${serial}${(pl.warnings && pl.warnings.length) ? ' — ' + pl.warnings.join(' ') : ''}`)
const who = pl.customer ? ` · client ${pl.customer}` : ''
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)$/.test(action) ? 'high' : 'normal'
const idempotencyKey = `sa-${action}-${serial}-${Date.now()}` // fixé au plan → un double-clic Confirmer rejoue la MÊME clé (dédup module)
return { title: META.device_action.title, preview, severity, params: { serial, action, profileid: opts.profileid || '', new_sn: opts.new_sn || '', idempotencyKey } }
},
async exec (p, email) {
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 ──
@ -281,9 +332,11 @@ Aujourd'hui = ${todayET()}. Tu agis AU NOM de l'utilisateur connecté${email ? `
Tu transformes une commande en langage naturel en APPELS D'OUTILS.
RÈGLES :
- Utilise d'abord les outils de LECTURE (list_technicians, get_job, resolve_skill, resource_availability, find_slot, check_service) pour RÉSOUDRE et DIAGNOSTIQUER. Résous toujours un technicien nommé (« Simon ») en tech_id via list_technicians avant d'agir.
- PROBLÈME DE CONNEXION (« pas d'internet », « lent », « signal », « hors ligne », « coupé ») : appelle check_service AVANT de proposer un déplacement. Si le modem est EN LIGNE → propose d'abord un correctif À DISTANCE (redémarrage) ; 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.
- PROBLÈME DE CONNEXION (« pas d'internet », « lent », « signal », « 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.
- 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) 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.
- 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.`