// ── Résolveur « raison → compétence(s) requises » — SOURCE UNIQUE ───────────── // Pont réutilisable entre TROIS systèmes qui, jusqu'ici, ne se parlaient pas : // 1. la RAISON d'une demande (chip de département, type de job, ou texte dicté) ; // 2. la/les COMPÉTENCE(S) réelle(s) d'un technicien (installation, tv, réparation…) ; // 3. la DISPONIBILITÉ filtrée par compétence (dénominateur des bandes d'occupation). // // Appelable de PARTOUT (ticket, courriel, fiche client) via le composant front // et l'endpoint /roster/resolve-skills. Un seul endroit à // éditer pour faire évoluer la correspondance raison→compétence. // // N'importe RIEN de roster.js/dispatch.js (pour éviter tout cycle) — au contraire, // roster.js ré-exporte `deptToSkill` d'ici pour ne pas dupliquer la logique. const cfg = require('./config') const { log } = require('./helpers') // Vocabulaire CANONIQUE des compétences terrain (= sorties possibles de deptToSkill). // Une demande à distance / facturation / vente ne requiert AUCUNE compétence → []. const SKILL_VOCAB = ['installation', 'réparation', 'tv', 'telephone', 'épissure', 'monteur', 'netadmin', 'support'] function normSkill (s) { return String(s || '').trim().toLowerCase() } function stripAccents (s) { return String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '') } // ── Prédicat PARTAGÉ « ce technicien a-t-il la compétence ? » ────────────────── // Sémantique IDENTIQUE à celle qui vivait (dupliquée) dans dispatch.js techOccupancy // + suggestSlots : match exact OU inclusion (≥3 car.) tolérant aux variantes. // techSkills accepte un TABLEAU (fetchTechnicians) OU une CSV brute (champ ERP / _user_tags). function techHasSkill (techSkills, want) { const w = normSkill(want) if (!w) return true const list = Array.isArray(techSkills) ? techSkills : String(techSkills || '').split(/[,;]/) return list.some(s => { s = normSkill(s) return s && (s === w || (s.length >= 3 && (s.includes(w) || w.includes(s)))) }) } // Plusieurs compétences requises → le tech doit les avoir TOUTES (aligné sur techCovers du solveur). function techHasSkills (techSkills, wants) { const ws = (Array.isArray(wants) ? wants : [wants]).map(normSkill).filter(Boolean) if (!ws.length) return true return ws.every(w => techHasSkill(techSkills, w)) } // ── Repli déterministe : texte/département legacy → UNE compétence ───────────── // (déplacé de roster.js — logique pure, réutilisée partout ; roster.js ré-exporte.) function deptToSkill (txt) { const d = stripAccents(txt).toLowerCase() if (!d) return '' if (/teleph/.test(d)) return 'telephone' if (/\btv\b|tele|televis/.test(d)) return 'tv' if (/fusion|episs/.test(d)) return 'épissure' if (/monteur|aerien/.test(d)) return 'monteur' if (/netadmin|net admin/.test(d)) return 'netadmin' // Diagnostic / panne / dépannage → réparation, AVANT l'installation : « voir signal fibre », « panne », « internet lent », // « coupé », « ne fonctionne pas » = une RÉPARATION, pas une pose. (« fibre » seul = une techno, PAS une intention d'installer.) if (/repar|desinstall|panne|signal|lent|coupe|coupure|hors.?service|ne fonctionne|fonctionne pas|marche pas|sans internet|pas d.?internet|no internet|deconnect|intermitten|verifi|diagnostic|depann|probleme|trouble|\bbris\b|defect/.test(d)) return 'réparation' if (/install|raccord|activ|nouveau|nouvelle|mise en service|branchement/.test(d)) return 'installation' return '' } // ── Pont DÉPARTEMENT/CATÉGORIE → compétence(s) ──────────────────────────────── // Clés = catégories de departments.js (SPA) ET valeurs CAT de categories.js (hub). // [] = aucune compétence terrain (traité à distance / au bureau) → dénominateur = tous les techs. const DEPARTMENT_SKILLS = { Installation: ['installation'], 'Installation Fibre': ['installation'], Réparation: ['réparation'], Reparation: ['réparation'], Télévision: ['tv'], Television: ['tv'], Téléphonie: ['telephone'], Telephonie: ['telephone'], Monteur: ['monteur'], Épissure: ['épissure'], Fusionneur: ['épissure'], 'Net Admin': ['netadmin'], Support: ['support'], // ne lister que les agents ayant la compétence « support » (pas les techs sans compétence) Facturation: [], Commercial: [], // vente / bureau Vente: [], Autre: [], } function deptSkills (dep) { if (!dep) return null if (Object.prototype.hasOwnProperty.call(DEPARTMENT_SKILLS, dep)) return DEPARTMENT_SKILLS[dep] // tolérant aux accents/casse (« telephonie » → « Téléphonie ») const key = Object.keys(DEPARTMENT_SKILLS).find(k => stripAccents(k).toLowerCase() === stripAccents(dep).toLowerCase()) return key ? DEPARTMENT_SKILLS[key] : null } // ── Chemin IA : texte libre dicté → compétence(s) ───────────────────────────── // Réutilise le PATRON de inbox-triage.classifyEmail : JSON strict + reasoningEffort:'none' // (sinon gemini « pense » et tronque le JSON → parse échoue silencieusement). Focalisé sur // LA compétence (pas de match client), contraint au vocabulaire réel des techs. async function aiSkills (text) { if (!cfg.AI_API_KEY) return null const sys = 'Tu assignes la ou les COMPÉTENCE(S) technique(s) requises pour traiter la demande d\'un client d\'un fournisseur Internet/télécom par fibre (TARGO / Gigafibre). ' + 'Choisis UNIQUEMENT parmi cette liste : ' + SKILL_VOCAB.join(', ') + '. ' + 'Un simple dépannage à distance, une question de facturation ou une vente ne requièrent AUCUNE compétence terrain → réponds "skills":[]. ' + 'Réponds UNIQUEMENT en JSON, sans texte autour : {"skills":["…"],"reason":"une courte phrase en français"}.' try { const out = await require('./ai').chat({ task: 'triage', maxTokens: 150, temperature: 0, reasoningEffort: 'none', messages: [{ role: 'system', content: sys }, { role: 'user', content: String(text || '').slice(0, 900) }] }) const j = JSON.parse((out.match(/\{[\s\S]*\}/) || ['{}'])[0]) const skills = (Array.isArray(j.skills) ? j.skills : []).map(normSkill).filter(s => SKILL_VOCAB.includes(s)) return { skills, reason: String(j.reason || '').slice(0, 200) } } catch (e) { log('aiSkills error:', e.message); return null } } // ── Résolveur unifié ────────────────────────────────────────────────────────── // resolveSkills({ text, department, jobType, useAI }) → { skills, primary, confidence, source, department, reason } // Priorité : chip/département explicite (sûr) > type de job / regex sur le texte (moyen) > IA sur texte libre (moyen). // confidence : 'high' (chip) · 'medium' (règle/IA) · 'low' (rien trouvé → aucune compétence, tous les techs). async function resolveSkills ({ text = '', department = '', jobType = '', useAI = false } = {}) { // 1. Département / chip explicite = intention claire de l'agent. const ds = deptSkills(department) if (ds) return { skills: ds, primary: ds[0] || '', confidence: 'high', source: 'chip', department, reason: '' } // 2. Type de job explicite (Installation/Réparation…), puis regex sur le texte. const rule = deptToSkill(jobType) || deptToSkill(text) if (rule) return { skills: [rule], primary: rule, confidence: 'medium', source: 'rule', department, reason: '' } // 3. IA sur le texte dicté (seulement si demandé ET clé dispo). if (useAI && String(text || '').trim().length >= 4) { const ai = await aiSkills(text) if (ai && ai.skills.length) return { skills: ai.skills, primary: ai.skills[0], confidence: 'medium', source: 'ai', department, reason: ai.reason } if (ai) return { skills: [], primary: '', confidence: 'low', source: 'ai', department, reason: ai.reason } } // 4. Rien de fiable → aucune compétence (le dénominateur reste « tous les techs »). return { skills: [], primary: '', confidence: 'low', source: 'none', department, reason: '' } } module.exports = { SKILL_VOCAB, normSkill, techHasSkill, techHasSkills, deptToSkill, deptSkills, DEPARTMENT_SKILLS, aiSkills, resolveSkills, }