Copilote: agit vraiment sur les absences (gerer_absence + IROPS rematch) + gig dispo

Bug: le copilote comprenait l'absence mais n'agissait pas → aucun impact. Causes:
1) prompt « par défaut analyse » → ne déclenchait pas l'action ;
2) marquer une absence n'excluait PAS des créneaux (techGaps ne testait que le statut
   global En pause, pas les Tech Availability approuvées par jour) ;
3) loadBookingData calculait unavail mais ne le retournait pas (oubli) → garde inerte.

Fixes:
- roster.js: loadBookingData inclut unavail (buildUnavailability = En pause + absence_from/until
  + Tech Availability approuvées) ; techGaps exclut le tech absent ce jour-là ; export bookingSlots/fetchTemplates.
- roster-assistant.js: nouvel outil gerer_absence = crée l'absence (par jour) + trouve les RDV
  impactés + RÉASSIGNE auto à un autre tech libre du même créneau (IROPS), renvoie les
  « à reporter ». Nouvel outil ajouter_disponibilite (tech à l'acte ouvre un créneau). Prompt
  orienté ACTION (signaler une absence = instruction d'exécution).
- Validé prod (lab): copilote crée l'absence ✓, booking exclut le tech absent (109→104) ✓,
  rematch DJ-…→Antoine même créneau ✓ ; données de test nettoyées.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-06-04 14:43:03 -04:00
parent 43c67e3a18
commit 88b2702489
2 changed files with 86 additions and 8 deletions

View File

@ -115,11 +115,27 @@ const TOOLS = [
parameters: { type: 'object', properties: { technicien_id: { type: 'string' }, date: { type: 'string', description: 'AAAA-MM-JJ' } }, required: ['technicien_id', 'date'] }, parameters: { type: 'object', properties: { technicien_id: { type: 'string' }, date: { type: 'string', description: 'AAAA-MM-JJ' } }, required: ['technicien_id', 'date'] },
}, },
}, },
{
type: 'function',
function: {
name: 'gerer_absence',
description: "ACTION PRINCIPALE quand un tech est absent/malade/ne peut se présenter. Fait TOUT d'un coup : (1) crée l'absence approuvée pour la date (le retire des créneaux ce jour), (2) trouve ses RDV impactés, (3) RÉASSIGNE automatiquement chaque RDV à un autre tech libre du même créneau, (4) renvoie ceux sans couverture (à reporter). À appeler dès qu'on te signale une absence (ex: « Simon est absent aujourd'hui »), date = aujourd'hui si non précisée.",
parameters: { type: 'object', properties: { technicien_id: { type: 'string', description: 'id du tech (ex TECH-4693), obtenu via etat_equipe' }, date: { type: 'string', description: 'AAAA-MM-JJ (defaut = date du jour)' }, type: { type: 'string', enum: ['Maladie', 'Congé', 'Indisponible'], description: 'defaut Maladie' } }, required: ['technicien_id'] },
},
},
{
type: 'function',
function: {
name: 'ajouter_disponibilite',
description: "ACTION : un technicien (souvent payé à l'acte) se rend DISPONIBLE et ajoute un créneau pour une date → ouvre de nouveaux créneaux au booking. À appeler quand on te dit « X est dispo demain » / « ajoute X tel jour ».",
parameters: { type: 'object', properties: { technicien_id: { type: 'string' }, date: { type: 'string', description: 'AAAA-MM-JJ' }, shift_template: { type: 'string', description: 'optionnel — nom du modèle de shift (sinon un modèle Jour par défaut)' } }, required: ['technicien_id', 'date'] },
},
},
{ {
type: 'function', type: 'function',
function: { function: {
name: 'marquer_indisponibilite', 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 »).", description: "ACTION (bas niveau) : crée seulement une absence approuvée, SANS calculer l'impact ni réassigner. Préférer gerer_absence dans la plupart des cas.",
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'] }, 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'] },
}, },
}, },
@ -164,10 +180,65 @@ async function tool_notifier_client ({ job, phone }) {
return r return r
} }
// ACTION COMBINÉE : tech absent → crée l'absence (par jour) + calcule l'impact +
// réassigne automatiquement chaque RDV à un autre tech LIBRE du même créneau (IROPS),
// sinon le marque « à reporter » (le superviseur décidera d'aviser le client).
async function tool_gerer_absence ({ technicien_id, date, type }) {
const today = new Date().toISOString().slice(0, 10)
const d = date || today
const av = await erp.create('Tech Availability', {
technician: technicien_id, from_date: d, to_date: d,
availability_type: type || 'Maladie', status: 'Approuvé', reason: 'Copilote',
})
if (av && av.ok === false) return { error: av.error || 'échec création absence' }
const jobs = (await erp.list('Dispatch Job', {
filters: [['assigned_tech', '=', technicien_id], ['scheduled_date', '=', d]],
fields: ['name', 'service_location', 'start_time', 'duration_h', 'booking_status'], limit: 200,
})) || []
const planifies = jobs.filter(j => j.start_time)
const reassignes = []; const a_reporter = []
for (const j of planifies) {
const start = (j.start_time || '').slice(0, 5); const dur = Number(j.duration_h) || 1
// bookingSlots exclut déjà le tech absent (absence approuvée ci-dessus) → cherche un autre tech libre ce créneau
const slots = await roster.bookingSlots({ duration: dur, start: d, days: 1, limit: 300, ignorePolicy: true })
const cand = slots.find(s => s.start === start && s.tech !== technicien_id)
if (cand) {
const u = await erp.update('Dispatch Job', j.name, { assigned_tech: cand.tech })
if (u && u.ok === false) a_reporter.push({ job: j.name, lieu: j.service_location, heure: start, raison: 'échec réassignation' })
else reassignes.push({ job: j.name, lieu: j.service_location, heure: start, vers: cand.tech_name || cand.tech })
} else {
a_reporter.push({ job: j.name, lieu: j.service_location, heure: start, raison: 'aucun tech libre ce créneau' })
}
}
return {
ok: true, technicien: technicien_id, date: d, type: type || 'Maladie',
jobs_impactes: planifies.length, reassignes, a_reporter,
message: `${technicien_id} marqué « ${type || 'Maladie'} » le ${d} (retiré des créneaux ce jour). ${planifies.length} RDV impacté(s) : ${reassignes.length} réassigné(s) automatiquement à un autre tech, ${a_reporter.length} sans couverture (à reporter — proposer d'aviser le client).`,
}
}
// ACTION : un tech à l'acte (gig) se rend disponible → ajoute un créneau (Shift Assignment
// publié) sur une date → de nouveaux créneaux apparaissent immédiatement au booking.
async function tool_ajouter_disponibilite ({ technicien_id, date, shift_template }) {
const tpls = await roster.fetchTemplates()
if (!tpls || !tpls.length) return { error: 'aucun modèle de shift actif — créez-en un dans Planification' }
const tpl = (shift_template && tpls.find(t => t.name === shift_template || t.template_name === shift_template)) ||
tpls.find(t => /jour|day/i.test(t.template_name || '')) || tpls[0]
const r = await erp.create('Shift Assignment', {
technician: technicien_id, assignment_date: date,
shift_template: tpl.name, zone: tpl.zone || '', hours: tpl.hours || 8,
status: 'Publié', source: 'Copilote',
})
if (r && r.ok === false) return { error: r.error || 'échec' }
return { ok: true, message: `${technicien_id} ajouté en disponibilité le ${date} sur « ${tpl.template_name || tpl.name} » (${tpl.start_time || '?'}${tpl.end_time || '?'}) — créneaux ouverts au booking.` }
}
async function execTool (name, args) { async function execTool (name, args) {
try { try {
if (name === 'etat_equipe') return await tool_etat_equipe(args) if (name === 'etat_equipe') return await tool_etat_equipe(args)
if (name === 'jobs_du_technicien') return await tool_jobs_du_technicien(args) if (name === 'jobs_du_technicien') return await tool_jobs_du_technicien(args)
if (name === 'gerer_absence') return await tool_gerer_absence(args)
if (name === 'ajouter_disponibilite') return await tool_ajouter_disponibilite(args)
if (name === 'marquer_indisponibilite') return await tool_marquer_indispo(args) if (name === 'marquer_indisponibilite') return await tool_marquer_indispo(args)
if (name === 'reassigner_job') return await tool_reassigner(args) if (name === 'reassigner_job') return await tool_reassigner(args)
if (name === 'notifier_client_report') return await tool_notifier_client(args) if (name === 'notifier_client_report') return await tool_notifier_client(args)
@ -197,12 +268,17 @@ function systemPrompt () {
return `Tu es le COPILOTE DE RÉPARTITION (dispatch) de Gigafibre/TARGO, fournisseur Internet/TV/téléphonie au Québec. 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}. Aujourd'hui = ${today}. Politique de reprise active : ${pol.reschedule}.
Tu aides le superviseur à gérer les techniciens et les rendez-vous d'installation/service. Tu aides le superviseur à gérer les techniciens et les rendez-vous d'installation/service.
Tu es ORIENTÉ ACTION : quand on te signale un fait, tu AGIS (tu n'accuses pas seulement réception).
RÈGLES : RÈGLES :
- Utilise TOUJOURS les outils pour obtenir les données réelles avant de répondre (ne devine pas). - Utilise TOUJOURS les outils pour les données réelles (ne devine pas). Déduis la date = aujourd'hui si non précisée.
- 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. - Signaler une ABSENCE = une INSTRUCTION D'EXÉCUTION. Dès qu'on dit qu'un tech est absent/malade/ne peut venir :
- 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. 1) etat_equipe(date) pour trouver son id (par son nom),
- Si tu ne trouves pas le tech nommé, dis-le et liste les techniciens proches. 2) appelle IMMÉDIATEMENT gerer_absence(technicien_id, date) qui crée l'absence, réassigne ses RDV aux techs libres, et renvoie ceux sans couverture.
- 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. Ne te contente JAMAIS de dire « c'est noté » sans appeler gerer_absence.
- Un tech qui se rend DISPONIBLE / qu'on veut ajouter un jour = appelle ajouter_disponibilite(technicien_id, date).
- Après gerer_absence : résume ce qui a été RÉASSIGNÉ (job tech) et liste les RDV « à reporter ». Pour ceux-, PROPOSE d'aviser le client (notifier_client_report) mais ne l'exécute QUE si le superviseur dit oui (un SMS part au client).
- reassigner_job (changer le tech d'un RDV précis) sur demande. Si tu ne trouves pas le tech nommé, dis-le et liste les proches.
- Confirme TOUJOURS l'action réalisée avec les vrais ids/noms. N'invente jamais une action non effectuée.
- Réponds en français, bref et actionnable (puces).` - Réponds en français, bref et actionnable (puces).`
} }

View File

@ -288,6 +288,7 @@ async function loadBookingData (start, days) {
const asgs = await fetchAssignments(start, days) const asgs = await fetchAssignments(start, days)
const techs = await fetchTechnicians() const techs = await fetchTechnicians()
const templates = await fetchTemplates() const templates = await fetchTemplates()
const unavail = await buildUnavailability(techs, dates) // En pause + absence_from/until + Tech Availability approuvées (par jour)
const techById = Object.fromEntries(techs.map(t => [t.id, t])) const techById = Object.fromEntries(techs.map(t => [t.id, t]))
const tplByName = Object.fromEntries(templates.map(t => [t.name, t])) const tplByName = Object.fromEntries(templates.map(t => [t.name, t]))
const jobs = await erp.list('Dispatch Job', { const jobs = await erp.list('Dispatch Job', {
@ -300,12 +301,13 @@ async function loadBookingData (start, days) {
const k = j.assigned_tech + '|' + j.scheduled_date const k = j.assigned_tech + '|' + j.scheduled_date
;(booked[k] || (booked[k] = [])).push({ s: timeToH(j.start_time), e: timeToH(j.start_time) + (Number(j.duration_h) || 1) }) ;(booked[k] || (booked[k] = [])).push({ s: timeToH(j.start_time), e: timeToH(j.start_time) + (Number(j.duration_h) || 1) })
} }
return { asgs, techById, tplByName, booked } return { asgs, techById, tplByName, booked, unavail }
} }
// Trous libres d'un tech (dans son shift, moins jobs pointés), filtrés compétence/zone. // Trous libres d'un tech (dans son shift, moins jobs pointés), filtrés compétence/zone.
function techGaps (a, d, skill, zone) { function techGaps (a, d, skill, zone) {
const t = d.techById[a.tech]; if (!t || t.status === PAUSE_STATUS) return null const t = d.techById[a.tech]; if (!t || t.status === PAUSE_STATUS) return null
if (d.unavail && d.unavail[a.tech] && d.unavail[a.tech].has(a.date)) return null // absence/congé approuvé ce jour-là → pas de créneaux
if (skill && !(t.skills || []).includes(skill)) return null if (skill && !(t.skills || []).includes(skill)) return null
if (zone && a.zone && a.zone !== zone) return null if (zone && a.zone && a.zone !== zone) return null
const tpl = d.tplByName[a.shift]; if (!tpl) return null const tpl = d.tplByName[a.shift]; if (!tpl) return null
@ -737,4 +739,4 @@ async function handle (req, res, method, path, url) {
return json(res, 404, { error: 'roster: route inconnue ' + path }) return json(res, 404, { error: 'roster: route inconnue ' + path })
} }
module.exports = { handle, handlePublicBooking, generate, publish, coverage, fetchTechnicians } module.exports = { handle, handlePublicBooking, generate, publish, coverage, fetchTechnicians, fetchTemplates, bookingSlots }