'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') 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 } } // ── 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 } } } const READERS = { list_technicians: read_list_technicians, get_job: read_get_job, find_slot: read_find_slot, resolve_skill: read_resolve_skill } // ── 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é') }, }, } // ── 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 }) 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 (list_technicians, get_job, find_slot, resolve_skill) pour RÉSOUDRE et DIAGNOSTIQUER. Résous toujours un technicien nommé (« Simon ») en tech_id via list_technicians avant d'agir. - 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) find_slot avec cette compétence → DISPONIBILITÉ réelle (techs qualifiés + créneaux). (4) SEULEMENT ensuite propose create_job / assign_tech / proposer_rdv_client. Résume ton diagnostic (raison → compétence → dispo) AVANT de proposer l'action, et inclus l'adresse dans create_job si elle est 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. - 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]; return fn ? await fn(args || {}) : { error: 'lecteur manquant : ' + name } } 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) { 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 } const messages = [{ role: 'system', content: systemPrompt(email) }, { 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, 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)) } 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 }