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>
This commit is contained in:
parent
3a90dafb9f
commit
d1bd268a32
184
services/targo-hub/lib/roster-assistant.js
Normal file
184
services/targo-hub/lib/roster-assistant.js
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
'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
|
||||
}
|
||||
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 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.
|
||||
- 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 }
|
||||
|
|
@ -127,6 +127,8 @@ const server = http.createServer(async (req, res) => {
|
|||
// Admin view of ERPNext outbound Email Queue (view/delete/purge).
|
||||
if (path.startsWith('/email-queue')) return require('./lib/email-queue').handle(req, res, method, path, url)
|
||||
// Planification (Roster AI) — modèles de shifts, génération via solveur OR-Tools, pause/vacances.
|
||||
// Copilote roster (Gemini Flash) + politique de reprise — avant le catch-all /roster
|
||||
if (path === '/roster/assistant' || path === '/roster/policy') return require('./lib/roster-assistant').handle(req, res, method, path)
|
||||
if (path.startsWith('/roster')) return require('./lib/roster').handle(req, res, method, path, url)
|
||||
// Portail public de prise de RDV (staging) — page + API client, PUBLIC (pas de SSO).
|
||||
if (path === '/book' || path.startsWith('/book/')) return require('./lib/roster').handlePublicBooking(req, res, method, path, url)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user