gigafibre-fsm/services/targo-hub/lib/inbox-triage.js
louispaulb 19d31025a6 feat(inbox+historique): sender-identity fix, single-source taxonomy, dispatch history & leaderboards
Inbox identity/grouping (fixes mislabeled threads):
- customerName = real From display name, then a customer matched BY EMAIL,
  then the raw address — never the AI's content-guessed name (a gilles@ relay
  showed as "Sylvie Juteau").
- never group a thread by one of our own domains (support@targo.ca,
  *@targointernet.com): re-ingested outbound was collapsing unrelated threads
  into one mislabeled "Guylaine Gagnon" thread.

Refactor: queues/TYPES/QUEUE_OF/CAT centralized in lib/categories.js (were
drifting across conversation.js + inbox-triage.js; telephonie/television had
no matching triage type). Removed dead export findConversationByEmail.

Feat: addMessage stamps msg.agent on outbound replies -> per-agent stats.

Historique (/historique, HistoriquePage.vue): tournées par technicien (ALL
statuses incl. Completed/Cancelled — the board hid them), tech leaderboard,
inbox leaderboard. Hub: /dispatch/history, /dispatch/leaderboard,
/conversations/leaderboard.

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

77 lines
4.7 KiB
JavaScript

// ── Triage IA d'un courriel entrant (Phase 3 — Inbox→Ticket→Task) ──
// classifyEmail({from,subject,body}) : client connu ? type ? faut-il suggérer un ticket ?
// SANS dépendance à la boîte mail (testable seul + réutilisable par le poller une fois la boîte branchée).
const cfg = require('./config')
const { log } = require('./helpers')
const erp = require('./erp')
function extractEmail (from) {
const m = /<([^>]+)>/.exec(from || '')
const raw = (m ? m[1] : String(from || '')).trim().toLowerCase()
return /.+@.+\..+/.test(raw) ? raw : ''
}
// Match client DÉTERMINISTE par courriel (email_id, puis email_billing qui peut contenir plusieurs adresses).
async function matchCustomer (email) {
if (!email) return null
try {
let r = await erp.list('Customer', { filters: [['email_id', '=', email]], fields: ['name', 'customer_name'], limit: 1 })
if (r && r[0]) return r[0]
r = await erp.list('Customer', { filters: [['email_billing', 'like', '%' + email + '%']], fields: ['name', 'customer_name'], limit: 1 })
if (r && r[0]) return r[0]
} catch (e) { log('triage matchCustomer error:', e.message) }
return null
}
// Triage courriel = PII (contenu client) + gros volume → routé par tâche 'triage' (bascule possible vers le modèle LOCAL).
// reasoningEffort:'none' : sans ça, gemini-2.5-flash « pense » et ses jetons mangent maxTokens → sur un long courriel, le JSON est tronqué, JSON.parse échoue et on retombe SILENCIEUSEMENT sur les heuristiques regex (triage IA inopérant). Le triage n'a pas besoin de raisonnement.
const aiChat = async (messages) => require('./ai').chat({ task: 'triage', maxTokens: 300, temperature: 0, reasoningEffort: 'none', messages })
const { TYPES, CAT } = require('./categories') // taxonomie = SOURCE UNIQUE (voir lib/categories.js)
// Heuristiques de repli (regex → type) si l'IA est indisponible/illisible. Propre au triage (pas dans la taxonomie partagée).
const HEUR = [
{ t: 'facturation', re: /factur|paiement|invoice|solde|rembours|prélèv|carte de cr/i },
{ t: 'vente', re: /nouveau client|abonn|forfait|devis|soumission|s'abonner|disponib|couvertur|d[ée]m[ée]nage|prix/i },
{ t: 'installation', re: /installation|rendez-?vous|technicien|raccord|branch/i },
{ t: 'support', re: /lent|panne|coup[ée]|ne fonctionne|probl[èe]me|pixel|wifi|internet|connexion|signal|bug|hors service/i },
]
function heuristicType (text) { for (const h of HEUR) if (h.re.test(text)) return h.t; return '' }
async function classifyEmail ({ from, subject, body } = {}) {
const email = extractEmail(from)
const customer = await matchCustomer(email)
const text = `${subject || ''}\n${String(body || '').slice(0, 1500)}`
let type = '', suggestTicket = true, suggestedTitle = String(subject || '').slice(0, 120), serviceAffected = '', reason = ''
if (cfg.AI_API_KEY) {
try {
const sys = 'Tu tries les courriels entrants d\'un fournisseur Internet/télécom (TARGO / Gigafibre). Réponds UNIQUEMENT en JSON, sans texte autour : {"type":"support|facturation|vente|installation|autre","service_affected":"internet|tv|telephone|aucun","suggest_ticket":true|false,"title":"titre court actionnable en français","reason":"une phrase"}. Mets suggest_ticket=false si c\'est un pourriel, une pub, une réponse automatique, une notification no-reply, ou tout courriel SANS action requise.'
const out = await aiChat([{ role: 'system', content: sys }, { role: 'user', content: text }])
const j = JSON.parse((out.match(/\{[\s\S]*\}/) || ['{}'])[0])
if (TYPES.includes(j.type)) type = j.type
if (typeof j.suggest_ticket === 'boolean') suggestTicket = j.suggest_ticket
if (j.title) suggestedTitle = String(j.title).slice(0, 120)
serviceAffected = j.service_affected || ''
reason = j.reason || ''
} catch (e) { log('classifyEmail AI error:', e.message) }
}
if (!type) type = heuristicType(text) || 'autre'
// BRUIT : no-reply / pourriel / notifications automatiques / infolettres → pas de ticket, pas de notif, masqué par défaut.
const noise = /no-?reply|noreply|do-?not-?reply|mailer-daemon|postmaster|unsubscribe|d[ée]sabonn|notification@|newsletter|infolettre|bounce|via google|automated|calendar-notification|@docs\.google|@drive\.google/i.test(`${from} ${subject}`)
if (noise) suggestTicket = false
return {
email,
is_customer: !!customer,
customer: customer ? customer.name : null,
customer_name: customer ? (customer.customer_name || customer.name) : null,
type,
category: CAT[type] || 'Autre',
service_affected: serviceAffected,
suggest_ticket: suggestTicket,
suggested_title: suggestedTitle,
reason,
noise,
}
}
module.exports = { classifyEmail, matchCustomer, extractEmail }