feat(assistant): capacités CLIENT 360 (solde, factures, abonnements, équipement, historique)

Le copilote STAFF peut répondre à toute question client. 5 lecteurs ajoutés
(audience:staff), réutilisant les getters du répondeur client (agent.execTool) :
- customer_overview : résumé 360 (identité + SOLDE + abonnements + tickets ouverts)
- customer_balance  : solde du compte + factures récentes
- customer_subscriptions / customer_equipment
- customer_history  : tickets passés → « un problème est-il déjà survenu ? »
Client résolu via find_customer (id C-xxxx) ; prompt mis à jour (règle QUESTIONS CLIENT).

Corrige aussi un bug LATENT : geminiChat n'avait pas reasoningEffort:'none' →
avec 35 outils le « thinking » de gemini-2.5-flash mangeait maxTokens et renvoyait
une réponse VIDE (« Aucune action détectée »). Thinking coupé + maxTokens 900→1200.

Vérifié live : « solde de C-LPB4 » → « 0 $, aucune facture impayée » ; « résume le
compte » → 360 (Louis-Paul Bourdon, solde, forfaits, 4 tickets) ; « un problème
déjà arrivé ? » → « 7 problèmes, dont 4 ouverts ». Régression OK (check_service, suspend).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-20 09:17:39 -04:00
parent 69290d7b07
commit b40cf7d059
2 changed files with 48 additions and 3 deletions

View File

@ -21,6 +21,11 @@
{ "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": "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"] } } },
{ "audience": "staff", "mode": "read", "title": "Abonnements client", "type": "function", "function": { "name": "customer_subscriptions", "description": "STAFF. Abonnements / services du client : forfaits, prix, fréquence de facturation, statut. LECTURE. Fournis customer (id C-xxxx).", "parameters": { "type": "object", "properties": { "customer": { "type": "string", "description": "ID client C-xxxx" } }, "required": ["customer"] } } },
{ "audience": "staff", "mode": "read", "title": "Équipement client", "type": "function", "function": { "name": "customer_equipment", "description": "STAFF. Équipement installé (ONU / modem / routeur) : n° de série, MAC, statut. LECTURE. Fournis customer (id C-xxxx). Pour l'état LIVE (en ligne / signal), utilise plutôt check_service.", "parameters": { "type": "object", "properties": { "customer": { "type": "string", "description": "ID client C-xxxx" } }, "required": ["customer"] } } },
{ "audience": "staff", "mode": "read", "title": "Historique / problèmes client", "type": "function", "function": { "name": "customer_history", "description": "STAFF. Historique des tickets du client (tous statuts, récents d'abord) → répond « un problème est-il DÉJÀ survenu chez ce client ? ». LECTURE. Fournis customer (id C-xxxx).", "parameters": { "type": "object", "properties": { "customer": { "type": "string", "description": "ID client C-xxxx" } }, "required": ["customer"] } } },
{ "audience": "staff", "mode": "write", "permission": "create_jobs", "title": "Créer un job", "type": "function", "function": { "name": "create_job", "description": "STAFF/dispatch. Crée un Dispatch Job (intervention). ÉCRITURE — proposée puis confirmée par l'utilisateur avant exécution. auto_assign=true assigne au meilleur tech disponible.", "parameters": { "type": "object", "properties": { "subject": { "type": "string", "description": "Description du travail" }, "customer_id": { "type": "string", "description": "ID client (optionnel)" }, "service_location": { "type": "string", "description": "Nom du lieu de service (optionnel)" }, "address": { "type": "string", "description": "Adresse civique de l'intervention (texte libre, ex. « 2338 rue Ste-Clotilde ») — si aucun lieu de service lié" }, "priority": { "type": "string", "enum": ["low", "medium", "high"], "description": "Priorité (défaut high pour urgence, medium sinon)" }, "job_type": { "type": "string", "enum": ["Installation", "Réparation", "Maintenance", "Retrait", "Dépannage", "Autre"], "description": "Type de travail (défaut Dépannage)" }, "notes": { "type": "string", "description": "Contexte additionnel" }, "auto_assign": { "type": "boolean", "description": "Assigner automatiquement au meilleur tech dispo (défaut true)" } }, "required": ["subject"] } } },
{ "audience": "staff", "mode": "write", "permission": "assign_jobs", "title": "Assigner un technicien", "type": "function", "function": { "name": "assign_tech", "description": "STAFF/dispatch. Assigne OU réassigne un Dispatch Job à un technicien (pose aussi l'heure au premier trou libre du quart). ÉCRITURE — confirmée avant exécution. Résous d'abord le tech via list_technicians pour obtenir tech_id.", "parameters": { "type": "object", "properties": { "job": { "type": "string", "description": "Identifiant du Dispatch Job" }, "tech_id": { "type": "string", "description": "id du technicien (TECH-xxxx), via list_technicians" }, "date": { "type": "string", "description": "Date AAAA-MM-JJ (optionnel)" }, "start": { "type": "string", "description": "Heure HH:MM (optionnel)" } }, "required": ["job", "tech_id"] } } },
{ "audience": "staff", "mode": "write", "permission": "assign_jobs", "title": "Ajouter un assistant", "type": "function", "function": { "name": "add_assistant", "description": "STAFF/dispatch. Ajoute un technicien en renfort (assistant) sur un job — le tech assigné (lead) reste inchangé. ÉCRITURE — confirmée avant exécution.", "parameters": { "type": "object", "properties": { "job": { "type": "string", "description": "Identifiant du Dispatch Job" }, "tech_id": { "type": "string", "description": "id du technicien assistant (TECH-xxxx)" }, "tech_name": { "type": "string", "description": "Nom du technicien (optionnel, pour l'affichage)" } }, "required": ["job", "tech_id"] } } },

View File

@ -160,7 +160,43 @@ async function read_find_customer ({ query } = {}) {
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 } }
}
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 }
// ── 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, 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']
@ -354,7 +390,10 @@ const WRITES = {
// ── Runtime Gemini (function-calling) — même config/boucle que lib/agent.js & roster-assistant.js ──
async function geminiChat (messages) {
const msg = await require('./ai').chat({ task: 'nl', messages, tools: STAFF_TOOLS, maxTokens: 900, temperature: 0.1, wantMessage: true })
// 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 }] }
}
@ -363,8 +402,9 @@ 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, 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.
- 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.
- 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.
- 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.