gigafibre-fsm/services/targo-hub/lib/inbox-triage.js
louispaulb 9466645e17 fix(inbox): never resolve our own domains to a customer (wrong sender names)
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>
2026-06-15 06:51:04 -04:00

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 }