Root cause: some F customer records carry our own/staff addresses in email_billing, so matchCustomer's `email_billing LIKE '%addr%'` resolved support@targo.ca -> "Guylaine Gagnon" and gilles@targointernet.com -> "Sylvie Juteau". Every thread on one of our addresses then inherited that wrong customer + name. - OWN_DOMAINS promoted to a single source in lib/helpers.js (was duplicated in conversation.js); inbox-triage.matchCustomer() now returns null for any own-domain address — a customer is never reachable at targo.ca / targointernet.com / gigafibre.ca. - conversation.js consumes the shared OWN_DOMAINS (removes the local copy). Also ran a one-shot repair (temporary endpoint, since removed) that unlinked the 6 already-contaminated threads and reset their display name to the real address. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
81 lines
5.1 KiB
JavaScript
81 lines
5.1 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, OWN_DOMAINS } = 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
|
|
// JAMAIS matcher une de NOS adresses (support@targo.ca, *@targointernet.com…) sur une fiche : des
|
|
// enregistrements F contiennent par erreur nos adresses dans email_billing → le LIKE liait support@targo.ca
|
|
// à « Guylaine Gagnon », gilles@ à « Sylvie Juteau ». Une fiche client n'est jamais joignable à nos domaines.
|
|
if (OWN_DOMAINS.test(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 }
|