gigafibre-fsm/services/targo-hub/lib/roster-assistant.js
louispaulb 7f3ad56188 Booking #56: politique de créneaux offerts + holds temporaires
- getBookingPolicy() (sous-objet 'booking' du fichier policy): lead_hours, day_start/end,
  days_offered, horizon_days, max_per_day, hold_minutes. Appliquée dans bookingSlots →
  cohérent pour /book + vue agent + fit. ignorePolicy au moment de confirmer (slot encore libre).
- Holds en mémoire (Map TTL): /roster/book/hold {date,start,minutes|release} → fenêtre retirée
  des dispos des autres pendant le hold; libérée à la confirmation. Évite le double-booking
  pendant qu'un agent/client choisit.
- roster-assistant: DEFAULT_POLICY.booking + booking_fields/weekdays (descripteurs UI), fusion fine.
- Testé: plage 10-14h filtre bien; hold 10:00 dispo 12→11.

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

250 lines
13 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
// #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 à lavance 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 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.
- 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 }