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>
This commit is contained in:
louispaulb 2026-07-20 18:18:47 -04:00
parent 9ce66eec9e
commit 0ec7a8cbba
2 changed files with 59 additions and 7 deletions

View File

@ -19,7 +19,8 @@
{ "audience": "staff", "mode": "read", "title": "Compétence requise (diagnostic)", "type": "function", "function": { "name": "resolve_skill", "description": "STAFF/dispatch. DIAGNOSTIC : à partir de la RAISON décrite en langage naturel (ex. « voir signal fibre », « pas d'internet », « installation », « ajout TV ») → détermine la/les COMPÉTENCE(S) terrain requise(s) + la confiance. LECTURE. À appeler AVANT de créer/assigner une intervention, pour cibler la ressource ; enchaîne ensuite find_slot (avec la compétence) pour la disponibilité. Si la raison est vague, pose UNE question avant.", "parameters": { "type": "object", "properties": { "text": { "type": "string", "description": "La raison / le problème décrit par l'utilisateur ou le client" }, "department": { "type": "string", "description": "Département/motif si déjà connu (optionnel)" } }, "required": ["text"] } } },
{ "audience": "staff", "mode": "read", "title": "Trouver un créneau", "type": "function", "function": { "name": "find_slot", "description": "STAFF/dispatch. Propose les meilleurs créneaux disponibles (technicien + date + heure) selon la durée et la compétence requise. LECTURE — ne réserve rien ; présente les options à l'utilisateur.", "parameters": { "type": "object", "properties": { "duration_h": { "type": "number", "description": "Durée en heures (défaut 1)" }, "skill": { "type": "string", "description": "Compétence requise (optionnel)" }, "after_date": { "type": "string", "description": "Chercher à partir de cette date AAAA-MM-JJ (défaut aujourd'hui)" } }, "required": [] } } },
{ "audience": "staff", "mode": "read", "title": "Ressources disponibles", "type": "function", "function": { "name": "resource_availability", "description": "STAFF/dispatch. DIAGNOSTIC : combien de techniciens QUALIFIÉS pour une compétence sont DISPONIBLES sur une période, et combien d'heures libres. LECTURE. À appeler après resolve_skill pour répondre « a-t-on la ressource ? » avant de proposer ; find_slot donnera ensuite les créneaux précis.", "parameters": { "type": "object", "properties": { "skill": { "type": "string", "description": "Compétence requise (issue de resolve_skill), ex. réparation, installation" }, "after_date": { "type": "string", "description": "Dès cette date AAAA-MM-JJ (défaut aujourd'hui)" }, "days": { "type": "number", "description": "Fenêtre en jours (défaut 7)" } }, "required": [] } } },
{ "audience": "staff", "mode": "read", "title": "État du service (modem/signal)", "type": "function", "function": { "name": "check_service", "description": "STAFF/dispatch. DIAGNOSTIC À DISTANCE : état LIVE du service d'un client — modem/ONU en ligne ou hors ligne, puissance du signal (Rx dBm), abonnement. LECTURE. À utiliser AVANT de proposer un déplacement pour un problème de connexion : si l'équipement 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é. Fournis customer (id C-xxxx) ou query (nom/adresse/téléphone/courriel).", "parameters": { "type": "object", "properties": { "customer": { "type": "string", "description": "ID client (C-xxxx) si connu" }, "query": { "type": "string", "description": "Sinon : nom / adresse / téléphone / courriel du client" } }, "required": [] } } },
{ "audience": "staff", "mode": "read", "title": "État du service (modem/signal)", "type": "function", "function": { "name": "check_service", "description": "STAFF/dispatch. DIAGNOSTIC À DISTANCE : état LIVE du service d'un client — modem/ONU en ligne ou hors ligne, puissance du signal (Rx dBm) si connue, abonnement. RAPIDE (statut passif). LECTURE. À utiliser AVANT de proposer un déplacement pour un problème de connexion : si l'équipement 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é. Si tu as besoin de la FORCE du signal et qu'elle est absente ici, appelle check_signal. Fournis customer (id C-xxxx) ou query (nom/adresse/téléphone/courriel).", "parameters": { "type": "object", "properties": { "customer": { "type": "string", "description": "ID client (C-xxxx) si connu" }, "query": { "type": "string", "description": "Sinon : nom / adresse / téléphone / courriel du client" } }, "required": [] } } },
{ "audience": "staff", "mode": "read", "title": "Signal en direct (comme F)", "type": "function", "function": { "name": "check_signal", "description": "STAFF. MESURE LE SIGNAL EN DIRECT de l'équipement d'un client — parité avec le « Do Stuff get » de F. FIBRE : interroge l'OLT → Rx/Tx dBm, distance, uptime. SANS-FIL (airOS) : signal + signal AP (dBm), CCQ, débit TX/RX, capacité airMAX du CPE. LENT (~10-30 s : interrogation en direct) → préviens « un instant, je mesure le signal… ». À appeler dès qu'on veut la QUALITÉ/FORCE du signal, ou si check_service ne l'a pas. NE conclus JAMAIS « signal non mesurable » sans avoir appelé cet outil. Fournis customer (id C-xxxx) ou serial.", "parameters": { "type": "object", "properties": { "customer": { "type": "string", "description": "ID client C-xxxx (via find_customer)" }, "serial": { "type": "string", "description": "N° de série de l'équipement (optionnel si customer fourni)" } }, "required": [] } } },
{ "audience": "staff", "mode": "read", "title": "Trouver un client", "type": "function", "function": { "name": "find_customer", "description": "STAFF. Recherche un client par nom / adresse / téléphone / courriel et renvoie les CANDIDATS probables. LECTURE. À appeler dès qu'un client n'est PAS déjà identifié par son id (C-xxxx) : UN seul candidat → prends son id et continue ; PLUSIEURS → NE DEVINE PAS, demande brièvement « lequel ? » — l'interface affiche la liste cliquable des candidats, ne les réénumère donc pas tous en texte. Enchaîne l'action avec l'id choisi.", "parameters": { "type": "object", "properties": { "query": { "type": "string", "description": "Nom, adresse, téléphone ou courriel (même partiel)" } }, "required": ["query"] } } },
{ "audience": "staff", "mode": "read", "title": "Aperçu client (360)", "type": "function", "function": { "name": "customer_overview", "description": "STAFF. Aperçu 360 d'un client : identité, SOLDE du compte, abonnements actifs, tickets ouverts. LECTURE. Utilise pour « c'est qui ? », « résume le compte », l'état général. Fournis customer (id C-xxxx obtenu via find_customer).", "parameters": { "type": "object", "properties": { "customer": { "type": "string", "description": "ID client C-xxxx (via find_customer)" } }, "required": ["customer"] } } },
{ "audience": "staff", "mode": "read", "title": "Solde et factures", "type": "function", "function": { "name": "customer_balance", "description": "STAFF. SOLDE du compte (montant total dû) + factures récentes (montants, dates, statut de paiement). LECTURE. Utilise pour « quel est le solde / le montant dû », « factures impayées ». Fournis customer (id C-xxxx).", "parameters": { "type": "object", "properties": { "customer": { "type": "string", "description": "ID client C-xxxx" } }, "required": ["customer"] } } },

View File

@ -136,12 +136,62 @@ async function read_check_service ({ customer, query } = {}) {
}
const s = r.summary || {}
let advice
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."
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.'
else advice = "Statut indéterminé — pas de lecture fiable de l'équipement."
// 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 } = {}) {
@ -196,7 +246,7 @@ async function read_customer_history ({ customer } = {}) {
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, 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 }
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']
@ -402,10 +452,11 @@ function systemPrompt (email) {
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) pour RÉSOUDRE, RENSEIGNER et DIAGNOSTIQUER. Résous toujours un technicien nommé (« Simon ») en tech_id via list_technicians avant d'agir.
- 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 , 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 », « 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.
- 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.