Complète la boucle diagnostic (raison→compétence→RESSOURCE dispo→créneau) demandée : nouveau lecteur resource_availability (skill[, after_date, days]) → nb de techs QUALIFIÉS + heures libres sur la période (via dispatch.techOccupancy). Prompt mis à jour : après resolve_skill, appeler resource_availability (combien dispo) puis find_slot (créneaux) avant de proposer. Vérifié : skill=réparation → 12 techs, 431 h libres/7j ; outil listé dans /staff-agent/tools. Déployé, hub sain. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
362 lines
24 KiB
JavaScript
362 lines
24 KiB
JavaScript
'use strict'
|
||
/**
|
||
* staff-agent.js — Copilote OPS en langage naturel (Gemini function-calling), surface STAFF.
|
||
*
|
||
* BUT (parité UI↔NL) : le langage naturel peut faire ce que fait l'interface visuelle, en exposant
|
||
* les ACTIONS EXISTANTES comme « outils » function-calling. NL et UI partagent UNE SEULE couche
|
||
* d'action (les routes hub / fonctions que l'UI appelle déjà) → couverture(NL) = couverture(UI).
|
||
*
|
||
* RÉUTILISE l'existant (cf feedback_reuse_shared_components) :
|
||
* - registre d'outils = services/targo-hub/lib/agent-tools.json (audience 'staff'),
|
||
* - runtime Gemini function-calling = lib/ai.js `chat({tools, wantMessage})` (même boucle que
|
||
* lib/agent.js répondeur SMS et lib/roster-assistant.js copilote roster),
|
||
* - couche d'action = les routes hub EXISTANTES (roster.js/dispatch.js/conversation.js), appelées
|
||
* en LOOPBACK autorisé (jeton de service + identité x-authentik-email) — zéro logique réécrite.
|
||
*
|
||
* SÉCURITÉ (cf feedback_ppa_double_charge_incident — une action auto peut coûter cher) :
|
||
* - outils LECTURE : s'exécutent librement pendant le PLAN (résolution d'entités, aucun effet).
|
||
* - outils ÉCRITURE/conséquents : NE s'exécutent PAS via le modèle. Ils sont mis en attente
|
||
* (staged) avec un APERÇU. L'exécution réelle passe par POST /staff-agent/run APRÈS confirmation
|
||
* humaine explicite (le modèle propose ; l'humain dispose). Pas d'endpoint arbitraire : /run mappe
|
||
* type → exécuteur côté serveur (liste blanche), re-valide les permissions (parité usePermissions
|
||
* via auth.effectiveCapabilities) et agit AU NOM de l'utilisateur connecté (x-authentik-email).
|
||
*
|
||
* Routes :
|
||
* POST /staff-agent { text } → { ok, reply, actions:[{id,type,title,preview,params,permission,needs_confirmation}], transcript }
|
||
* POST /staff-agent/run { actions } → { ok, results:[{type,ok,message|error}] }
|
||
* GET /staff-agent/tools → { tools } (inventaire — parité/complétude)
|
||
*/
|
||
const cfg = require('./config')
|
||
const { log, json, parseBody } = require('./helpers')
|
||
const { toolsForModel, toolsByAudience } = require('./agent')
|
||
const erp = require('./erp')
|
||
|
||
// ── Registre : outils STAFF (audience 'staff') depuis le registre UNIQUE agent-tools.json ──
|
||
const STAFF_TOOLS_RAW = toolsByAudience('staff')
|
||
const STAFF_TOOLS = toolsForModel(STAFF_TOOLS_RAW) // envoyés au modèle : {type, function} seulement
|
||
const META = {} // name → { mode, permission, title }
|
||
for (const t of STAFF_TOOLS_RAW) META[t.function.name] = { mode: t.mode || 'read', permission: t.permission || null, title: t.title || t.function.name }
|
||
|
||
// require paresseux (évite les cycles + n'alourdit pas le boot) — mêmes modules que l'UI appelle.
|
||
const roster = () => require('./roster')
|
||
const dispatch = () => require('./dispatch')
|
||
|
||
function todayET () { return new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) }
|
||
|
||
// ── Loopback AUTORISÉ vers les routes hub EXISTANTES ─────────────────────────
|
||
// Réutilise EXACTEMENT les handlers que le front appelle (couche d'action unique). On imite le proxy
|
||
// nginx /ops/hub : Authorization = jeton de service + x-authentik-email = utilisateur connecté (→
|
||
// attribution, suivi, et vérifs d'identité des handlers). Pas d'endpoint fourni par le client : seuls
|
||
// les chemins codés dans EXECUTORS sont atteignables.
|
||
const PORT = cfg.PORT || 3300
|
||
async function hubCall (method, path, body, email) {
|
||
const headers = { 'Content-Type': 'application/json' }
|
||
if (process.env.HUB_SERVICE_TOKEN) headers.Authorization = 'Bearer ' + process.env.HUB_SERVICE_TOKEN
|
||
if (email) headers['x-authentik-email'] = email
|
||
const opt = { method, headers }
|
||
if (body != null && method !== 'GET') opt.body = JSON.stringify(body)
|
||
const r = await fetch(`http://127.0.0.1:${PORT}${path}`, opt)
|
||
let data = null
|
||
try { data = await r.json() } catch { /* corps non-JSON */ }
|
||
return { status: r.status, ok: r.ok, data: data || {} }
|
||
}
|
||
// Un résultat hub → { ok, message } normalisé (les routes renvoient {ok:false,error} ou {error}).
|
||
function hubResult (r, okMsg) {
|
||
const ok = r.ok && r.data && r.data.ok !== false && !r.data.error
|
||
return { ok, message: ok ? okMsg : ((r.data && r.data.error) || `échec (HTTP ${r.status})`), status: r.status, data: r.data }
|
||
}
|
||
|
||
async function resolveTechName (techId) {
|
||
try { const techs = await roster().fetchTechnicians(); const t = techs.find(x => x.id === techId || x.name === techId); return t ? t.name : techId }
|
||
catch { return techId }
|
||
}
|
||
|
||
// ── Outils LECTURE (exécutés pendant le PLAN — sans effet de bord) ───────────
|
||
async function read_list_technicians ({ query, skill } = {}) {
|
||
let techs
|
||
try { techs = await roster().fetchTechnicians() } catch (e) { return { error: 'techniciens indisponibles : ' + e.message } }
|
||
const q = String(query || '').trim().toLowerCase()
|
||
const sk = String(skill || '').trim().toLowerCase()
|
||
let out = techs || []
|
||
if (q) out = out.filter(t => String(t.name || '').toLowerCase().includes(q) || String(t.id || '').toLowerCase().includes(q))
|
||
if (sk) out = out.filter(t => (t.skills || []).some(s => String(s).toLowerCase().includes(sk)))
|
||
return { count: out.length, technicians: out.slice(0, 40).map(t => ({ id: t.id, name: t.name, status: t.status, skills: t.skills || [] })) }
|
||
}
|
||
async function read_get_job ({ job } = {}) {
|
||
if (!job) return { error: 'job requis' }
|
||
try {
|
||
const d = await erp.get('Dispatch Job', job, { fields: ['name', 'subject', 'status', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h', 'priority', 'customer_name', 'service_location', 'job_type', 'booking_status'] })
|
||
if (!d || !d.name) return { error: 'job introuvable : ' + job }
|
||
return { job: d.name, subject: d.subject, status: d.status, assigned_tech: d.assigned_tech || null, scheduled_date: d.scheduled_date || null, start_time: d.start_time || null, duration_h: d.duration_h, priority: d.priority, customer_name: d.customer_name || '', service_location: d.service_location || '', job_type: d.job_type, booking_status: d.booking_status || '' }
|
||
} catch (e) { return { error: e.message } }
|
||
}
|
||
async function read_find_slot ({ duration_h, skill, after_date } = {}) {
|
||
try {
|
||
const slots = await dispatch().suggestSlots({ duration_h: Number(duration_h) || 1, skill: skill || '', after_date: after_date || todayET(), limit: 5 })
|
||
return { count: (slots || []).length, slots: (slots || []).slice(0, 5) }
|
||
} catch (e) { return { error: e.message } }
|
||
}
|
||
async function read_resolve_skill ({ text, department } = {}) {
|
||
try {
|
||
const r = await require('./skill-resolver').resolveSkills({ text: text || '', department: department || '', useAI: true })
|
||
const skills = r.skills || []
|
||
return { skills, primary: r.primary || '', confidence: r.confidence, reason: r.reason || '', note: skills.length ? ('Compétence(s) requise(s) : ' + skills.join(', ') + '. Enchaîne find_slot avec skill=' + (r.primary || skills[0]) + ' pour la disponibilité.') : 'Aucune compétence terrain — traitable à distance (tous les agents).' }
|
||
} catch (e) { return { error: e.message } }
|
||
}
|
||
async function read_resource_availability ({ skill, after_date, days } = {}) {
|
||
try {
|
||
const o = await dispatch().techOccupancy({ after_date: after_date || todayET(), days: Number(days) || 7, skill: skill || '' })
|
||
const techs = (o && o.techs) || []
|
||
const freeH = Math.round(techs.reduce((a, t) => a + (t.free_h || 0), 0) * 10) / 10
|
||
return { skill: skill || '(toute compétence)', window_days: (o && o.days) || 7, qualified_techs: techs.length, free_hours: freeH, techs: techs.slice(0, 8).map(t => ({ name: t.tech_name, free_h: Math.round((t.free_h || 0) * 10) / 10 })), note: techs.length ? (techs.length + ' tech(s) qualifié(s) « ' + (skill || 'tous') + ' » · ' + freeH + ' h libres sur ' + ((o && o.days) || 7) + ' jours') : 'Aucun tech qualifié disponible sur la période — élargis la fenêtre ou revois la compétence.' }
|
||
} catch (e) { return { error: e.message } }
|
||
}
|
||
const READERS = { list_technicians: read_list_technicians, get_job: read_get_job, find_slot: read_find_slot, resolve_skill: read_resolve_skill, resource_availability: read_resource_availability }
|
||
|
||
// ── Jours de la semaine — clés du weekly_schedule (= DOW_KEYS de roster.js) ──
|
||
const DAY_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
|
||
const DAY_FR = { mon: 'Lun', tue: 'Mar', wed: 'Mer', thu: 'Jeu', fri: 'Ven', sat: 'Sam', sun: 'Dim' }
|
||
const DAY_ALIASES = {
|
||
mon: 'mon', tue: 'tue', wed: 'wed', thu: 'thu', fri: 'fri', sat: 'sat', sun: 'sun',
|
||
monday: 'mon', tuesday: 'tue', wednesday: 'wed', thursday: 'thu', friday: 'fri', saturday: 'sat', sunday: 'sun',
|
||
lun: 'mon', mar: 'tue', mer: 'wed', jeu: 'thu', ven: 'fri', sam: 'sat', dim: 'sun',
|
||
lundi: 'mon', mardi: 'tue', mercredi: 'wed', jeudi: 'thu', vendredi: 'fri', samedi: 'sat', dimanche: 'sun',
|
||
}
|
||
function normDays (days) {
|
||
const set = new Set()
|
||
for (const d of (Array.isArray(days) ? days : [])) { const k = DAY_ALIASES[String(d || '').trim().toLowerCase()]; if (k) set.add(k) }
|
||
return DAY_KEYS.filter(k => set.has(k)) // ordre lun→dim, dédupliqué
|
||
}
|
||
function normTime (t, dflt = '') {
|
||
const m = String(t || '').trim().match(/^(\d{1,2})(?:\s*[:hH]\s*(\d{2}))?/) // "8", "8:00", "8h", "16h00"
|
||
if (!m) return dflt
|
||
return String(Math.min(23, parseInt(m[1], 10))).padStart(2, '0') + ':' + (m[2] || '00')
|
||
}
|
||
function buildWeeklySchedule (days, start, end) {
|
||
const st = normTime(start), en = normTime(end)
|
||
const sched = {}
|
||
for (const k of normDays(days)) sched[k] = { start: st, end: en }
|
||
return sched
|
||
}
|
||
|
||
// ── Outils ÉCRITURE — { permission, build(params)→{title,preview,params[,severity]}, exec(params,email) } ──
|
||
// build : mis en attente pendant le plan (aperçu). exec : exécution réelle après confirmation (via /run),
|
||
// chaque exec = fine enveloppe sur une route/fonction hub EXISTANTE.
|
||
const WRITES = {
|
||
create_job: {
|
||
permission: 'create_jobs',
|
||
async build (p) {
|
||
const params = { subject: String(p.subject || '').trim(), customer_id: p.customer_id || '', service_location: p.service_location || '', address: p.address || '', priority: p.priority || '', job_type: p.job_type || '', notes: p.notes || '', auto_assign: p.auto_assign !== false }
|
||
const preview = `« ${params.subject || 'Intervention'} »${params.job_type ? ' · ' + params.job_type : ''}${params.priority ? ' · priorité ' + params.priority : ''}${params.address ? ' · ' + params.address : ''}${params.customer_id ? ' · client ' + params.customer_id : ''} · ${params.auto_assign ? 'assignation auto' : 'non assigné'}`
|
||
return { title: META.create_job.title, preview, params }
|
||
},
|
||
async exec (p) {
|
||
const r = await dispatch().agentCreateDispatchJob({ customer_id: p.customer_id || '', service_location: p.service_location || '', address: p.address || '', subject: p.subject, priority: p.priority || undefined, job_type: p.job_type || undefined, notes: p.notes || '', auto_assign: p.auto_assign })
|
||
return { ok: !!(r && r.success), message: (r && r.message) || 'job créé', job_id: r && r.job_id, assigned_tech: r && r.assigned_tech }
|
||
},
|
||
},
|
||
assign_tech: {
|
||
permission: 'assign_jobs',
|
||
async build (p) {
|
||
const name = await resolveTechName(p.tech_id)
|
||
const params = { job: p.job, tech: p.tech_id, date: p.date || '', start: p.start || '' }
|
||
return { title: META.assign_tech.title, preview: `${p.job} → ${name} (${p.tech_id})${p.date ? ' · ' + p.date : ''}${p.start ? ' à ' + p.start : ''}`, params }
|
||
},
|
||
async exec (p, email) {
|
||
const body = { job: p.job, tech: p.tech }
|
||
if (p.date) body.date = p.date
|
||
if (p.start) body.start = p.start
|
||
return hubResult(await hubCall('POST', '/roster/assign-job', body, email), `${p.job} assigné à ${p.tech}`)
|
||
},
|
||
},
|
||
proposer_rdv_client: {
|
||
permission: 'assign_jobs',
|
||
async build (p) {
|
||
const ref = p.job || p.ticket || p.customer_id || 'le job ouvert du client'
|
||
const params = { job: p.job || '', ticket: p.ticket || '', customer: p.customer_id || '', message: p.message || '' }
|
||
return { title: META.proposer_rdv_client.title, preview: `Envoyer au client le lien de prise de RDV (il choisit un créneau réservé 5 min, ou propose 3 dispos) — ${ref}`, params }
|
||
},
|
||
async exec (p, email) {
|
||
const body = {}
|
||
if (p.job) body.job = p.job
|
||
if (p.ticket) body.ticket = p.ticket
|
||
if (p.customer) body.customer = p.customer
|
||
if (p.message) body.message = p.message
|
||
const r = await hubCall('POST', '/roster/book/propose', body, email)
|
||
const ok = r.ok && r.data && r.data.ok !== false && !r.data.error
|
||
const msg = ok
|
||
? (r.data.sms ? 'Lien de RDV envoyé au client par texto.' : ('Lien de RDV prêt : ' + (r.data.url || '') + (r.data.note ? ' — ' + r.data.note : '')))
|
||
: ((r.data && r.data.error) || `échec (HTTP ${r.status})`)
|
||
return { ok, message: msg, data: r.data }
|
||
},
|
||
},
|
||
add_assistant: {
|
||
permission: 'assign_jobs',
|
||
async build (p) {
|
||
const name = p.tech_name || await resolveTechName(p.tech_id)
|
||
return { title: META.add_assistant.title, preview: `Renfort sur ${p.job} : ${name} (${p.tech_id})`, params: { job: p.job, tech_id: p.tech_id, tech_name: name } }
|
||
},
|
||
async exec (p, email) {
|
||
return hubResult(await hubCall('POST', '/roster/job/team', { job: p.job, add: { tech_id: p.tech_id, tech_name: p.tech_name || '' } }, email), `Assistant ${p.tech_id} ajouté à ${p.job}`)
|
||
},
|
||
},
|
||
set_job_status: {
|
||
permission: 'assign_jobs',
|
||
async build (p) {
|
||
return { title: META.set_job_status.title, preview: `${p.job} → statut « ${p.status} »`, params: { job: p.job, status: p.status }, severity: p.status === 'Cancelled' ? 'high' : 'normal' }
|
||
},
|
||
async exec (p, email) {
|
||
return hubResult(await hubCall('POST', '/roster/job/update', { job: p.job, patch: { status: p.status } }, email), `${p.job} → ${p.status}`)
|
||
},
|
||
},
|
||
reschedule_job: {
|
||
permission: 'assign_jobs',
|
||
async build (p) {
|
||
return { title: META.reschedule_job.title, preview: `${p.job} reporté au ${p.date}${p.start ? ' à ' + p.start : ''}`, params: { job: p.job, date: p.date, start: p.start || '' } }
|
||
},
|
||
async exec (p, email) {
|
||
// Conserve le tech assigné si possible (re-place l'heure via /roster/assign-job) ; sinon change la date via /roster/job/update.
|
||
let tech = null
|
||
try { const d = await erp.get('Dispatch Job', p.job, { fields: ['assigned_tech'] }); tech = d && d.assigned_tech } catch { /* fallback ci-dessous */ }
|
||
if (tech) {
|
||
const body = { job: p.job, tech, date: p.date }
|
||
if (p.start) body.start = p.start
|
||
return hubResult(await hubCall('POST', '/roster/assign-job', body, email), `${p.job} reporté au ${p.date}`)
|
||
}
|
||
return hubResult(await hubCall('POST', '/roster/job/update', { job: p.job, patch: { scheduled_date: p.date } }, email), `${p.job} reporté au ${p.date}`)
|
||
},
|
||
},
|
||
create_recurring_shift: {
|
||
permission: 'assign_jobs',
|
||
async build (p) {
|
||
const days = normDays(p.days)
|
||
const schedule = buildWeeklySchedule(p.days, p.start, p.end)
|
||
const name = await resolveTechName(p.technicien_id)
|
||
const st = normTime(p.start), en = normTime(p.end)
|
||
const worked = days.map(k => DAY_FR[k]).join(', ') || '(aucun jour)'
|
||
const off = DAY_KEYS.filter(k => !days.includes(k)).map(k => DAY_FR[k]).join(', ')
|
||
const preview = `${name} (${p.technicien_id}) : ${st}–${en} le ${worked}${off ? ` · repos ${off}` : ''}`
|
||
// schedule pré-calculé porté dans les params → /run le pousse tel quel sur la route weekly-schedule.
|
||
return { title: META.create_recurring_shift.title, preview, params: { technicien_id: p.technicien_id, schedule } }
|
||
},
|
||
async exec (p, email) {
|
||
if (!p.schedule || typeof p.schedule !== 'object' || !Object.keys(p.schedule).length) return { ok: false, message: 'horaire vide (aucun jour)' }
|
||
return hubResult(await hubCall('POST', `/roster/technician/${encodeURIComponent(p.technicien_id)}/weekly-schedule`, { schedule: p.schedule }, email), 'Horaire récurrent enregistré (quarts matérialisés au prochain cycle)')
|
||
},
|
||
},
|
||
follow_doc: {
|
||
permission: null, // suivi = personnel/bas risque → tout staff authentifié
|
||
async build (p) {
|
||
const follow = p.follow !== false
|
||
return { title: META.follow_doc.title, preview: `${follow ? 'Suivre' : 'Ne plus suivre'} ${p.doctype} ${p.name}`, params: { doctype: p.doctype, name: p.name, follow } }
|
||
},
|
||
async exec (p, email) {
|
||
return hubResult(await hubCall('POST', '/conversations/follow', { doctype: p.doctype, name: p.name, follow: p.follow !== false }, email), (p.follow !== false) ? 'Suivi activé' : 'Suivi retiré')
|
||
},
|
||
},
|
||
}
|
||
|
||
// ── Runtime Gemini (function-calling) — même config/boucle que lib/agent.js & roster-assistant.js ──
|
||
async function geminiChat (messages) {
|
||
const msg = await require('./ai').chat({ task: 'nl', messages, tools: STAFF_TOOLS, maxTokens: 900, temperature: 0.1, wantMessage: true })
|
||
return { choices: [{ message: msg }] }
|
||
}
|
||
|
||
function systemPrompt (email) {
|
||
return `Tu es le COPILOTE OPS (dispatch/planification) de Gigafibre/TARGO, fournisseur Internet/TV/téléphonie au Québec.
|
||
Aujourd'hui = ${todayET()}. Tu agis AU NOM de l'utilisateur connecté${email ? ` (${email})` : ''}.
|
||
Tu transformes une commande en langage naturel en APPELS D'OUTILS.
|
||
RÈGLES :
|
||
- Utilise d'abord les outils de LECTURE (list_technicians, get_job, resolve_skill, resource_availability, find_slot) pour RÉSOUDRE et DIAGNOSTIQUER. Résous toujours un technicien nommé (« Simon ») en tech_id via list_technicians avant d'agir.
|
||
- INTERVENTION à créer/assigner : procède par ÉTAPES conversationnelles, ne saute PAS à la création. (1) Cerne la RAISON (si vague ou absente, pose UNE question courte plutôt que deviner). (2) resolve_skill → compétence requise. (3) resource_availability (combien de techs qualifiés dispo + heures libres) puis find_slot (créneaux précis) → DISPONIBILITÉ réelle. (4) SEULEMENT ensuite propose create_job / assign_tech / proposer_rdv_client. Résume ton diagnostic (raison → compétence → ressource dispo) AVANT de proposer, et inclus l'adresse dans create_job si fournie.
|
||
- Les outils d'ÉCRITURE (create_job, assign_tech, proposer_rdv_client, add_assistant, set_job_status, reschedule_job, create_recurring_shift, follow_doc) NE S'EXÉCUTENT PAS tout de suite : ils sont PROPOSÉS puis CONFIRMÉS par l'utilisateur. Chaque appel te renvoie un APERÇU (staged=true). N'affirme JAMAIS qu'une action est faite ; annonce ce qui SERA fait après confirmation.
|
||
- Horaire/quart RÉCURRENT : days parmi mon,tue,wed,thu,fri,sat,sun. « jours de semaine » = mon,tue,wed,thu,fri ; retire les exceptions (« sauf le vendredi » → mon,tue,wed,thu). « fin de semaine » = sat,sun. Convertis « 8-16h » en start 08:00 / end 16:00. Il FAUT un technicien : si aucun n'est nommé, DEMANDE-le.
|
||
- S'il manque une information essentielle (quel job ? quel tech ? quelle date ?), pose UNE question courte au lieu d'appeler un outil d'écriture.
|
||
- Réponds en français, bref. Après avoir proposé des actions, résume-les en une phrase et invite à confirmer.`
|
||
}
|
||
|
||
async function execToolPlan (name, args, ctx) {
|
||
const meta = META[name]
|
||
if (!meta) return { error: 'outil inconnu : ' + name }
|
||
try {
|
||
if (meta.mode === 'read') { const fn = READERS[name]; return fn ? await fn(args || {}) : { error: 'lecteur manquant : ' + name } }
|
||
const w = WRITES[name]
|
||
if (!w) return { error: 'exécuteur manquant : ' + name }
|
||
const staged = await w.build(args || {}, ctx.email)
|
||
const id = 'a' + (ctx.planned.length + 1)
|
||
ctx.planned.push({ id, type: name, title: staged.title, preview: staged.preview, params: staged.params, permission: meta.permission || null, severity: staged.severity || 'normal', needs_confirmation: true })
|
||
return { staged: true, requires_confirmation: true, title: staged.title, preview: staged.preview, note: "Action PROPOSÉE, NON exécutée. L'utilisateur doit la confirmer. Ne dis pas qu'elle est faite." }
|
||
} catch (e) { return { error: String(e.message || e) } }
|
||
}
|
||
|
||
// PLAN : boucle function-calling. Les LECTURES s'exécutent (résolution), les ÉCRITURES sont mises en attente.
|
||
async function plan (text, email) {
|
||
if (!cfg.AI_API_KEY) return { ok: false, error: "Le service IA n'est pas configuré (AI_API_KEY manquante)." }
|
||
const planned = []
|
||
const ctx = { email, planned }
|
||
const messages = [{ role: 'system', content: systemPrompt(email) }, { role: 'user', content: String(text || '') }]
|
||
let response
|
||
try { response = await geminiChat(messages) } catch (e) { return { ok: false, error: 'IA : ' + e.message } }
|
||
const cur = [...messages]
|
||
for (let i = 0; i < 6; i++) {
|
||
const msg = response.choices?.[0]?.message
|
||
if (!msg) break
|
||
const calls = msg.tool_calls // signal OpenAI-compat des appels d'outils (indépendant de finish_reason)
|
||
if (!calls || !calls.length) break
|
||
cur.push(msg)
|
||
for (const tc of calls) {
|
||
const args = (() => { try { return JSON.parse(tc.function.arguments || '{}') } catch { return {} } })()
|
||
log(`[staff-agent] ${email || 'anon'} → ${tc.function.name}(${JSON.stringify(args)})`)
|
||
const result = await execToolPlan(tc.function.name, args, ctx)
|
||
cur.push({ role: 'tool', tool_call_id: tc.id, content: JSON.stringify(result).slice(0, 6000) })
|
||
}
|
||
try { response = await geminiChat(cur) } catch (e) { return { ok: false, error: 'IA : ' + e.message } }
|
||
}
|
||
const reply = response.choices?.[0]?.message?.content || (planned.length ? 'Plan préparé — confirme pour exécuter.' : 'Aucune action détectée — reformule.')
|
||
return { ok: true, reply, actions: planned, transcript: String(text || '') }
|
||
}
|
||
|
||
// RUN : exécute les actions CONFIRMÉES. type → exécuteur (liste blanche) + re-vérif permission + identité.
|
||
async function run (actions, email) {
|
||
const list = Array.isArray(actions) ? actions.slice(0, 12) : []
|
||
let caps = null
|
||
try { caps = await require('./auth').effectiveCapabilities(email) } catch (e) { log('[staff-agent] perms indispo : ' + e.message) }
|
||
const results = []
|
||
for (const a of list) {
|
||
const type = a && a.type
|
||
const w = WRITES[type]
|
||
if (!w) { results.push({ type, ok: false, error: 'action inconnue' }); continue }
|
||
// Permissions (parité usePermissions) : appliquées si Authentik est branché (prod). En dev/aperçu
|
||
// (pas de token → caps.configured=false) on n'enforce pas mais on journalise — le gate du hub
|
||
// (jeton de service) garantit déjà que seul un membre du staff authentifié atteint /staff-agent.
|
||
const need = w.permission
|
||
if (need && caps && caps.configured) {
|
||
const allowed = caps.is_superuser || (caps.capabilities && caps.capabilities[need] === true)
|
||
if (!allowed) { results.push({ type, ok: false, denied: true, error: `permission requise : ${need}` }); continue }
|
||
}
|
||
try { results.push({ type, ...(await w.exec(a.params || {}, email)) }) }
|
||
catch (e) { results.push({ type, ok: false, error: String(e.message || e) }) }
|
||
}
|
||
return { ok: results.length > 0 && results.every(r => r.ok), results }
|
||
}
|
||
|
||
async function handle (req, res, method, path) {
|
||
const email = String(req.headers['x-authentik-email'] || '').toLowerCase()
|
||
if (path === '/staff-agent' && method === 'POST') {
|
||
const b = await parseBody(req)
|
||
try { return json(res, 200, await plan(b.text || '', email)) }
|
||
catch (e) { return json(res, 200, { ok: false, error: 'Erreur copilote : ' + (e.message || e) }) }
|
||
}
|
||
if (path === '/staff-agent/run' && method === 'POST') {
|
||
const b = await parseBody(req)
|
||
try { return json(res, 200, await run(b.actions, email)) }
|
||
catch (e) { return json(res, 200, { ok: false, error: 'Erreur exécution : ' + (e.message || e) }) }
|
||
}
|
||
if (path === '/staff-agent/tools' && method === 'GET') {
|
||
return json(res, 200, { tools: STAFF_TOOLS_RAW.map(t => ({ name: t.function.name, mode: t.mode || 'read', permission: t.permission || null, title: t.title || t.function.name, description: t.function.description })) })
|
||
}
|
||
return json(res, 404, { error: 'staff-agent: route inconnue ' + path })
|
||
}
|
||
|
||
module.exports = { handle, plan, run, buildWeeklySchedule, normDays, normTime, STAFF_TOOLS_RAW }
|