gigafibre-fsm/services/targo-hub/lib/roster-assistant.js
louispaulb d1bd268a32 Copilote roster (Gemini Flash, function-calling) + politique de reprise configurable
lib/roster-assistant.js : couche conversationnelle sur le roster (distincte du solveur OR-Tools).
Outils data réels (etat_equipe, jobs_du_technicien) via roster.fetchTechnicians + Dispatch Job.
Ex: 'TECH-4776 malade le 16 juin' → résout le nom, liste les RDV impactés, propose des techs
dispos qualifiés. Routes /roster/assistant + /roster/policy (politique persistée fichier).
Réutilise le moteur geminiChat de lib/agent.js (gemini-2.5-flash). Testé OK avec données réelles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 11:08:26 -04:00

185 lines
8.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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 21h8h
sms_max: 1,
escalation: 'queue_sms', // file Ops + SMS superviseur
}
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' },
],
}
function getPolicy () {
try { return { ...DEFAULT_POLICY, ...JSON.parse(fs.readFileSync(POLICY_FILE, 'utf8')) } }
catch { return { ...DEFAULT_POLICY } }
}
function setPolicy (p) {
const next = { ...getPolicy(), ...p }
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'] },
},
},
]
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)
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 12 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.
- Tu PROPOSES seulement — c'est le superviseur qui confirmera (réassignation, SMS au client, escalade). N'invente pas d'actions effectuées.
- 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 }