'use strict' /** * roster-assistant.js — Copilote de répartition (Gemini Flash, function-calling). * * Couche CONVERSATIONNELLE par-dessus le roster. À NE PAS confondre avec le * « Roster AI » = solveur OR-Tools (optimisation sous contraintes). Ici on traduit * du langage naturel ("Simon est malade aujourd'hui, quel impact ?") en : * 1) appels d'outils de données réelles (équipe du jour, jobs d'un tech), * 2) une réponse + des propositions de réassignation dans les créneaux dispo. * Le copilote PROPOSE ; le superviseur confirme (pas d'écriture auto pour l'instant). * * Réutilise le pattern geminiChat de lib/agent.js (endpoint OpenAI-compat Gemini). * * Routes : * POST /roster/assistant { message, history? } → { reply } * GET /roster/policy → { policy, options } * POST /roster/policy { ...politique } → { ok, policy } */ const fs = require('fs') const path = require('path') const cfg = require('./config') const { json, parseBody } = require('./helpers') const erp = require('./erp') const roster = require('./roster') // ── Politique de reprise (tech absent) — persistée fichier ────────────────── const POLICY_FILE = path.join(__dirname, '..', 'data', 'dispatch-policy.json') const DEFAULT_POLICY = { reschedule: 'auto_then_sms_then_super', // réassign auto → sinon SMS client → sinon superviseur sms_enabled: true, sms_quiet_hours: true, // pas d'envoi 21h–8h sms_max: 1, escalation: 'queue_sms', // file Ops + SMS superviseur // #56 — créneaux offerts au booking (page /book + vue agent). Lu par lib/roster.js getBookingPolicy(). booking: { lead_hours: 24, day_start: 8, day_end: 18, days_offered: [1, 2, 3, 4, 5], horizon_days: 21, max_per_day: 0, hold_minutes: 10 }, } const POLICY_OPTIONS = { reschedule: [ { value: 'auto_then_sms_then_super', label: 'Réassigner auto → sinon SMS client → sinon superviseur (recommandé)' }, { value: 'ask_client_first', label: 'Toujours demander au client (SMS lien de report) en premier' }, { value: 'escalate_first', label: 'Toujours escalader au superviseur en premier' }, ], escalation: [ { value: 'queue', label: 'File « À recontacter » dans Ops seulement' }, { value: 'queue_sms', label: 'File Ops + SMS au superviseur' }, ], // #56 — réglages des créneaux offerts (rendus en champs dans l'UI) booking_fields: [ { key: 'lead_hours', label: 'Délai minimum avant un RDV', unit: 'heures', min: 0, max: 168, hint: 'Aucun créneau offert avant ce délai (ex. 24 h pour préparer la tournée).' }, { key: 'day_start', label: 'Première heure offerte', unit: 'h', min: 0, max: 23 }, { key: 'day_end', label: 'Dernière heure offerte', unit: 'h', min: 1, max: 24 }, { key: 'horizon_days', label: 'Fenêtre de réservation', unit: 'jours', min: 1, max: 90, hint: 'Jusqu’à combien de jours à l’avance on propose.' }, { key: 'max_per_day', label: 'Max de créneaux affichés / jour', unit: '0 = illimité', min: 0, max: 50 }, { key: 'hold_minutes', label: 'Réservation temporaire (hold)', unit: 'minutes', min: 1, max: 60, hint: 'Durée pendant laquelle un créneau sélectionné est bloqué pour les autres.' }, ], weekdays: [{ v: 1, l: 'Lun' }, { v: 2, l: 'Mar' }, { v: 3, l: 'Mer' }, { v: 4, l: 'Jeu' }, { v: 5, l: 'Ven' }, { v: 6, l: 'Sam' }, { v: 0, l: 'Dim' }], } function getPolicy () { try { const f = JSON.parse(fs.readFileSync(POLICY_FILE, 'utf8')); return { ...DEFAULT_POLICY, ...f, booking: { ...DEFAULT_POLICY.booking, ...(f.booking || {}) } } } catch { return { ...DEFAULT_POLICY } } } function setPolicy (p) { const cur = getPolicy() const next = { ...cur, ...p } if (p && p.booking) next.booking = { ...cur.booking, ...p.booking } // fusion fine des réglages booking try { fs.mkdirSync(path.dirname(POLICY_FILE), { recursive: true }) } catch {} fs.writeFileSync(POLICY_FILE, JSON.stringify(next, null, 2)) return next } // ── Outils de données (réels, lecture) ────────────────────────────────────── async function jobsOnDate (date, techId) { const filters = [['scheduled_date', '=', date]] if (techId) filters.push(['assigned_tech', '=', techId]) return (await erp.list('Dispatch Job', { filters, fields: ['name', 'assigned_tech', 'service_location', 'start_time', 'duration_h', 'status', 'booking_status'], limit: 200, })) || [] } async function tool_etat_equipe ({ date }) { const techs = await roster.fetchTechnicians() const jobs = await jobsOnDate(date) const load = {} for (const j of jobs) load[j.assigned_tech] = (load[j.assigned_tech] || 0) + 1 return techs.map(t => ({ id: t.id, nom: t.name || t.id, statut: t.status, competences: t.skills || [], groupe: t.group || null, jobs_ce_jour: load[t.id] || 0, })) } async function tool_jobs_du_technicien ({ technicien_id, date }) { const jobs = await jobsOnDate(date, technicien_id) return jobs.map(j => ({ job: j.name, lieu: j.service_location, debut: j.start_time, duree_h: j.duration_h, statut: j.status, rdv: j.booking_status, })) } const TOOLS = [ { type: 'function', function: { name: 'etat_equipe', description: "État de l'équipe pour une date : tous les techniciens avec statut (Disponible/En pause), compétences, groupe/zone, et nombre de jobs déjà assignés ce jour-là (charge). Sert à voir qui est disponible et qui a de la capacité.", parameters: { type: 'object', properties: { date: { type: 'string', description: 'Date AAAA-MM-JJ' } }, required: ['date'] }, }, }, { type: 'function', function: { name: 'jobs_du_technicien', description: "Liste des rendez-vous/installations assignés à un technicien pour une date donnée (l'impact d'une absence). Donne lieu, heure, durée, statut.", parameters: { type: 'object', properties: { technicien_id: { type: 'string' }, date: { type: 'string', description: 'AAAA-MM-JJ' } }, required: ['technicien_id', 'date'] }, }, }, { type: 'function', function: { name: 'marquer_indisponibilite', description: "ACTION : rend un technicien indisponible (maladie/congé) pour une date — crée une absence APPROUVÉE qui le retire du planning. À utiliser UNIQUEMENT sur instruction explicite (ex: « Simon est malade aujourd'hui, marque-le »).", parameters: { type: 'object', properties: { technicien_id: { type: 'string' }, date: { type: 'string', description: 'AAAA-MM-JJ' }, type: { type: 'string', enum: ['Maladie', 'Congé', 'Indisponible', 'Pause'], description: 'défaut Maladie' } }, required: ['technicien_id', 'date'] }, }, }, { type: 'function', function: { name: 'reassigner_job', description: "ACTION : réassigne un rendez-vous/installation (Dispatch Job) à un autre technicien. UNIQUEMENT sur instruction explicite (ex: « réassigne DJ-… à TECH-… »).", parameters: { type: 'object', properties: { job: { type: 'string' }, technicien_id: { type: 'string' } }, required: ['job', 'technicien_id'] }, }, }, { type: 'function', function: { name: 'notifier_client_report', description: "ACTION : avise le client qu'un rendez-vous doit être reporté — envoie un SMS avec un lien pour qu'il choisisse un nouveau créneau, et passe le job en « À reporter ». Sur instruction explicite. Fournir job (et phone si connu).", parameters: { type: 'object', properties: { job: { type: 'string' }, phone: { type: 'string' } }, required: ['job'] }, }, }, ] // ── Outils d'ACTION (écriture, sur instruction explicite) ─────────────────── async function tool_marquer_indispo ({ technicien_id, date, type }) { const r = await erp.create('Tech Availability', { technician: technicien_id, from_date: date, to_date: date, availability_type: type || 'Maladie', status: 'Approuvé', reason: 'Copilote', }) if (r && r.ok === false) return { error: r.error || 'échec' } return { ok: true, message: `${technicien_id} marqué ${type || 'Maladie'} le ${date} (absence approuvée — retiré du planning)` } } async function tool_reassigner ({ job, technicien_id }) { const r = await erp.update('Dispatch Job', job, { assigned_tech: technicien_id }) if (r && r.ok === false) return { error: r.error || 'échec' } return { ok: true, message: `${job} réassigné à ${technicien_id}` } } async function tool_notifier_client ({ job, phone }) { // Réutilise l'endpoint hub (même process) pour le lien /book + SMS + statut. const r = await fetch('http://localhost:3300/roster/job/notify-reschedule', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ job, phone }), }).then(x => x.json()).catch(e => ({ ok: false, error: String(e.message || e) })) return r } async function execTool (name, args) { try { if (name === 'etat_equipe') return await tool_etat_equipe(args) if (name === 'jobs_du_technicien') return await tool_jobs_du_technicien(args) if (name === 'marquer_indisponibilite') return await tool_marquer_indispo(args) if (name === 'reassigner_job') return await tool_reassigner(args) if (name === 'notifier_client_report') return await tool_notifier_client(args) return { error: 'outil inconnu' } } catch (e) { return { error: String(e.message || e) } } } // ── Appel Gemini (OpenAI-compat) — même config que agent.js ───────────────── async function geminiChat (messages) { const url = `${cfg.AI_BASE_URL}chat/completions` const body = { model: cfg.AI_MODEL, max_tokens: 700, messages, tools: TOOLS } for (let attempt = 0; attempt < 3; attempt++) { const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${cfg.AI_API_KEY}` }, body: JSON.stringify(body), }) if (res.status === 429 && attempt < 2) { await new Promise(r => setTimeout(r, (attempt + 1) * 1500)); continue } if (!res.ok) throw new Error(`Gemini ${res.status}: ${(await res.text().catch(() => '')).slice(0, 200)}`) return await res.json() } } function systemPrompt () { const today = new Date().toISOString().slice(0, 10) const pol = getPolicy() return `Tu es le COPILOTE DE RÉPARTITION (dispatch) de Gigafibre/TARGO, fournisseur Internet/TV/téléphonie au Québec. Aujourd'hui = ${today}. Politique de reprise active : ${pol.reschedule}. Tu aides le superviseur à gérer les techniciens et les rendez-vous d'installation/service. RÈGLES : - Utilise TOUJOURS les outils pour obtenir les données réelles avant de répondre (ne devine pas). - Quand on signale un tech absent/malade : appelle etat_equipe(date) pour repérer le tech (par son nom) et la dispo des autres, puis jobs_du_technicien(id, date) pour l'impact. - Propose CONCRÈTEMENT : pour chaque job touché, suggère 1–2 techniciens candidats (Disponible, compétences compatibles, charge faible le même jour) OU un report. Mentionne les ids/noms réels. - Si tu ne trouves pas le tech nommé, dis-le et liste les techniciens proches. - Par défaut tu ANALYSES et PROPOSES. Mais tu peux EXÉCUTER sur instruction explicite : marquer_indisponibilite (rendre un tech absent un jour donné, ex « marque Simon malade aujourd'hui »), reassigner_job (changer le tech d'un RDV), et notifier_client_report (aviser le client par SMS qu'un RDV doit être reporté, avec lien pour choisir un nouveau créneau). Confirme TOUJOURS l'action réalisée. N'invente jamais une action non effectuée. - Réponds en français, bref et actionnable (puces).` } async function assistant (message, history = []) { if (!cfg.AI_API_KEY) return { reply: "Le service IA n'est pas configuré (AI_API_KEY manquante)." } const messages = [{ role: 'system', content: systemPrompt() }] for (const m of (history || []).slice(-8)) { if (m.role === 'user' || m.role === 'assistant') messages.push({ role: m.role, content: String(m.content || '') }) } messages.push({ role: 'user', content: String(message || '') }) let response = await geminiChat(messages) const cur = [...messages] for (let i = 0; i < 5; i++) { const choice = response.choices?.[0] if (!choice || choice.finish_reason !== 'tool_calls') break const calls = choice.message?.tool_calls if (!calls?.length) break cur.push(choice.message) for (const tc of calls) { const args = (() => { try { return JSON.parse(tc.function.arguments || '{}') } catch { return {} } })() const result = await execTool(tc.function.name, args) cur.push({ role: 'tool', tool_call_id: tc.id, content: JSON.stringify(result).slice(0, 6000) }) } response = await geminiChat(cur) } return { reply: response.choices?.[0]?.message?.content || 'Aucune réponse.' } } async function handle (req, res, method, path) { if (path === '/roster/assistant' && method === 'POST') { const b = await parseBody(req) try { return json(res, 200, await assistant(b.message, b.history)) } catch (e) { return json(res, 200, { reply: 'Erreur copilote : ' + (e.message || e) }) } } if (path === '/roster/policy' && method === 'GET') return json(res, 200, { policy: getPolicy(), options: POLICY_OPTIONS }) if (path === '/roster/policy' && method === 'POST') { const b = await parseBody(req) return json(res, 200, { ok: true, policy: setPolicy(b || {}) }) } return json(res, 404, { error: 'not found' }) } module.exports = { handle, assistant, getPolicy, setPolicy }