Router un ticket de support vers la bonne équipe
Envoyer chaque ticket de support entrant à l’équipe capable d’y répondre.
Pas besoin d’IA générative
négligeableInstantanéVos données restent chez vous
Le verdict
N1 l’emporte parce que ses données d’entraînement existent déjà : l’archive des tickets résolus, chacun étiqueté par l’équipe qui l’a fermé, est à un export de distance, et elle route les formulations que la liste de mots-clés de N0 n’avait pas prévues. Elle remet aussi le ticket ambigu sur la table : `rank` rend l’équipe suivante avec sa probabilité, là où N0 la laissait tomber sans le dire. N2 achète la reconnaissance des paraphrases au prix d’un fichier de modèle, d’un processus chaud et d’un index à ré-encoder à chaque mise à jour : ce prix se paie le jour où vos agents repêchent des problèmes connus dans la file par défaut, pas avant. Une réserve sur l’archive, et c’est le premier piège de production : l’équipe qui ferme un ticket n’est pas toujours celle qui aurait dû le recevoir — escalade, réaffectation après un aller-retour, équipes fusionnées depuis. Entraînez sur les tickets jamais réaffectés, ou sur la première équipe qui a répondu, et réentraînez quand l’organigramme change.
TF-IDF et classifieur linéaire entraîné sur l’archive
Pas besoin d’IA générative
Modèle classique légerN1négligeableInstantané
Python
"""
Router un ticket avec TF-IDF et un classifieur linéaire entraîné sur
l'archive.
Niveau N1. Les règles par mots-clés du niveau N0 connaissent les mots que
quelqu'un a imaginés. Celui-ci connaît les mots que le service a réellement
reçus : il est entraîné sur les tickets auxquels les équipes ont déjà répondu,
si bien que c'est le vocabulaire des clients qui décide, et non celui de
l'auteur des règles.
Ce qu'il garde du niveau N0, délibérément : une équipe par défaut. Un
classifieur renvoie toujours quelque chose, et sa classe la plus probable sur
un ticket dont il n'a aucune idée reste une classe. Le plancher de confiance,
plus bas, est ce qui ramène ce haussement d'épaules dans la file par défaut,
au lieu d'une mauvaise file.
Les données d'entraînement sont l'archive exportée : les tickets résolus et
l'équipe qui a résolu chacun d'eux.
"""
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
DEFAULT_TEAM = "general"
def train(tickets: list[str], teams: list[str]):
"""
`teams` is the team that actually handled each past ticket.
Word pairs as well as single words, because "mot de passe" and "en
retard" carry more than the words they are made of. Classes are weighted
by their rarity: an archive is never balanced, and an unweighted model
learns to answer the busiest team.
"""
model = make_pipeline(
TfidfVectorizer(strip_accents="unicode", ngram_range=(1, 2), sublinear_tf=True),
# C loosens the penalty. With the default C=1, two of the three unseen
# tickets in the test stay under the 0.5 floor; with C=10 all three
# clear it. Three tickets is a demonstration, not a calibration: on a
# real archive this value is chosen by cross-validation, and it moves
# when the archive does.
LogisticRegression(class_weight="balanced", max_iter=1000, C=10),
)
model.fit(tickets, teams)
return model
def rank(model, ticket: str) -> list[tuple[str, float]]:
"""
Every team with the probability the model gives it, best first.
A support desk needs the runner-up: two teams at almost the same score is
exactly the ambiguous ticket of N0, and here it is visible instead of
being silently resolved by a priority order.
"""
probabilities = model.predict_proba([ticket])[0]
ranked = sorted(zip(model.classes_, probabilities), key=lambda pair: -pair[1])
return [(str(team), float(probability)) for team, probability in ranked]
def route(model, ticket: str, min_confidence: float = 0.5,
default_team: str = DEFAULT_TEAM) -> str:
"""
The team that gets the ticket, or the default queue below the floor.
The floor is yours to set. Raise it and more tickets are read by a human
before they move; lower it and more tickets are moved on a hunch. Nothing
in the model can make that choice for you.
"""
team, confidence = rank(model, ticket)[0]
return team if confidence >= min_confidence else default_teamJavaScript
/**
* Router un ticket avec TF-IDF et un classifieur linéaire entraîné sur
* l'archive.
*
* Niveau N1. Les règles par mots-clés du niveau N0 connaissent les mots que
* quelqu'un a imaginés. Celui-ci connaît les mots que le service a réellement
* reçus : il est entraîné sur les tickets auxquels les équipes ont déjà répondu,
* si bien que c'est le vocabulaire des clients qui décide, et non celui de
* l'auteur des règles.
*
* Écrit à la main plutôt que tiré d'une bibliothèque, et écrit pour apprendre ce
* que l'extrait Python apprend avec scikit-learn : les mêmes mots et paires de
* mots, la même pondération TF-IDF, le même objectif pénalisé. Le modèle est une
* table de poids, et chaque poids peut être imprimé et discuté quand quelqu'un
* demande pourquoi son ticket a changé de file.
*
* Ce qu'il garde du niveau N0, délibérément : une équipe par défaut. Un
* classifieur renvoie toujours quelque chose, et sa classe la plus probable sur
* un ticket dont il n'a aucune idée reste une classe. Le plancher de confiance
* est ce qui ramène ce haussement d'épaules dans la file par défaut, au lieu
* d'une mauvaise file.
*/
export const DEFAULT_TEAM = 'general';
// The penalty, as scikit-learn's C: the same value as the Python snippet.
// Three tickets is a demonstration, not a calibration: on a real archive this
// value is chosen by cross-validation, and it moves when the archive does.
const C = 10;
/** Words of a ticket, lowercased and stripped of accents. */
function tokens(text) {
const folded = text.toLowerCase().normalize('NFKD').replace(/\p{M}/gu, '');
// Two characters or more, as scikit-learn's default token pattern.
const words = folded.match(/[\p{L}\p{N}_]{2,}/gu) ?? [];
// Word pairs as well as single words, because "mot de passe" and "en
// retard" carry more than the words they are made of.
return words.concat(words.slice(1).map((w, i) => `${words[i]} ${w}`));
}
/** Vocabulary and inverse document frequency, learnt from the archive alone. */
function fitVocabulary(tickets) {
const seen = new Map();
for (const ticket of tickets) {
for (const term of new Set(tokens(ticket))) seen.set(term, (seen.get(term) ?? 0) + 1);
}
const terms = new Map();
const idf = [];
for (const [term, documents] of seen) {
terms.set(term, idf.length);
idf.push(Math.log((1 + tickets.length) / (1 + documents)) + 1);
}
return { terms, idf };
}
/** One TF-IDF row, as [term, value] pairs, brought to length one. Unknown terms are dropped. */
function vector(vocabulary, ticket) {
const counts = new Map();
for (const term of tokens(ticket)) {
const j = vocabulary.terms.get(term);
if (j !== undefined) counts.set(j, (counts.get(j) ?? 0) + 1);
}
// Sublinear term frequency: a word repeated ten times is not ten times the
// signal, and an angry customer repeats words.
const row = [...counts].map(([j, count]) => [j, (1 + Math.log(count)) * vocabulary.idf[j]]);
const norm = Math.sqrt(row.reduce((sum, [, v]) => sum + v * v, 0));
return norm ? row.map(([j, v]) => [j, v / norm]) : row;
}
/** Softmax over the teams: one score per team, summing to one. */
function softmax(theta, width, classes, row) {
const raw = classes.map((_, c) => row.reduce((z, [j, v]) => z + theta[c * width + j] * v, theta[c * width + width - 1]));
const top = Math.max(...raw);
const exponentials = raw.map((z) => Math.exp(z - top));
const total = exponentials.reduce((a, b) => a + b, 0);
return exponentials.map((e) => e / total);
}
/**
* `teams` is the team that actually handled each past ticket.
*
* Each example is weighted by the rarity of its team: an archive is never
* balanced, and an unweighted model learns to answer the busiest team.
*/
export function train(tickets, teams) {
const classes = [...new Set(teams)].sort();
if (classes.length < 2 || tickets.length !== teams.length) {
throw new RangeError('the archive needs one team per ticket, and at least two teams');
}
const vocabulary = fitVocabulary(tickets);
const rows = tickets.map((t) => vector(vocabulary, t));
const width = vocabulary.idf.length + 1; // the weights of one team, then its bias
const labels = teams.map((t) => classes.indexOf(t));
const weight = labels.map((l) => tickets.length / (classes.length * labels.filter((m) => m === l).length));
// Minimise the weighted cross-entropy plus |W|² / 2C, scikit-learn's
// objective, by accelerated gradient descent. Each weight gets its own step,
// from a bound on its curvature, so no learning rate needs tuning.
const size = classes.length * width;
const bound = new Float64Array(width);
rows.forEach((row, i) => {
const spread = 1 + row.reduce((sum, [, v]) => sum + Math.abs(v), 0);
bound[width - 1] += 0.5 * weight[i] * spread;
for (const [j, v] of row) bound[j] += 0.5 * weight[i] * Math.abs(v) * spread;
});
for (let j = 0; j < width - 1; j += 1) bound[j] += 1 / C;
let theta = new Float64Array(size);
let previous = theta;
for (let step = 0, k = 0; step < 20000; step += 1, k += 1) {
const momentum = k / (k + 3);
const ahead = theta.map((t, n) => t + momentum * (t - previous[n]));
const gradient = ahead.map((t, n) => (n % width === width - 1 ? 0 : t / C));
rows.forEach((row, i) => {
softmax(ahead, width, classes, row).forEach((p, c) => {
const error = weight[i] * (p - (labels[i] === c ? 1 : 0));
gradient[c * width + width - 1] += error;
for (const [j, v] of row) gradient[c * width + j] += error * v;
});
});
const next = ahead.map((t, n) => t - gradient[n] / bound[n % width]);
// Restart the momentum when it points uphill.
let uphill = 0;
for (let n = 0; n < size; n += 1) uphill += gradient[n] * (next[n] - theta[n]);
if (uphill > 0) k = 0;
previous = theta;
theta = next;
if (gradient.every((g) => Math.abs(g) < 1e-6)) break;
}
return { vocabulary, classes, width, theta };
}
/**
* Every team with the probability the model gives it, best first.
*
* A support desk needs the runner-up: two teams at almost the same score is
* exactly the ambiguous ticket of N0, and here it is visible instead of being
* silently resolved by a priority order.
*/
export function rank(model, ticket) {
const probabilities = softmax(model.theta, model.width, model.classes, vector(model.vocabulary, ticket));
return model.classes
.map((team, c) => [team, probabilities[c]])
.sort((a, b) => b[1] - a[1]);
}
/**
* The team that gets the ticket, or the default queue below the floor.
*
* The floor is yours to set. Raise it and more tickets are read by a human
* before they move; lower it and more tickets are moved on a hunch. Nothing in
* the model can make that choice for you.
*/
export function route(model, ticket, { minConfidence = 0.5, defaultTeam = DEFAULT_TEAM } = {}) {
const [team, confidence] = rank(model, ticket)[0];
return confidence >= minConfidence ? team : defaultTeam;
}Essayer
interactifLe code ci-dessus tourne dans votre navigateur. Écrivez dans le champ : le résultat se refait sous vos yeux.
Le modèle est entraîné dans votre navigateur au chargement de la page, sur l’archive de tickets résolus écrite juste à côté. Aucun ticket ne part ailleurs.
Ou partez d’un exemple
La saisie libre et les autres exemples ont besoin de JavaScript. L’exemple affiché ci-dessous, lui, a été calculé à la construction du site.
Ce qu’il renvoie
Le ticket part chez Facturation
72 % de confiance, au-dessus du plancher de 50 %.
Le détail
| Équipe | Confiance du modèle |
|---|---|
| Facturation | 72 % |
| Technique | 15 % |
| Livraison | 13 % |
Modèle entraîné sur 21 tickets résolus, sept par équipe.
Là, ça casseVoir le niveau au-dessus
Ce que ça implique
- Vos données restent chez vous
- Résultat toujours identique
- Se mesure sur un échantillon, pas cas par cas
- Dépend d’une bibliothèque
- Empreinte faible
Ce que cela ajoute à votre périmètre réglementaire
- Constitution d’un corpus d’entraînement à partir de tickets clients réels, conservé à part des tickets dont il est tiré
- Le corpus entre dans votre registre des traitements si vous en tenez un
Point de rupture
Le modèle connaît l’archive, et rien d’autre. « Votre entrepôt accepte-t-il les visites scolaires le mercredi » ne partage avec les tickets déjà résolus que le mot « le » : les trois équipes ressortent presque à égalité, le seuil de confiance n’est pas atteint, et le ticket repart dans la file par défaut. N1 n’a pas supprimé cette file, il l’a rétrécie.
Quand passer au niveau suivant
Vos agents ressortent régulièrement de la file par défaut des tickets qui se révèlent être des problèmes connus, décrits avec des mots absents de l’archive.
Les quatre options
- N0Règles par mots-clés, avec priorité explicite et file par défautGratuit · Instantané
- RecommandéN1TF-IDF et classifieur linéaire entraîné sur l’archivenégligeable · Instantané
- N2Encodeur de phrases auto-hébergé, vote des tickets résolus voisinsPeu coûteux · Rapide
- N3Classement par appel à un modèle généraliste, contre une liste fermée d’équipesélevé · Une seconde
Les autres options
N0Règles par mots-clés, avec priorité explicite et file par défautGratuit · Instantané
Règles par mots-clés, avec priorité explicite et file par défaut
Règle et algorithme classiqueN0GratuitInstantané
Python
"""
Router un ticket de support avec des règles par mots-clés : ordonnées, avec
une équipe par défaut.
Niveau N0. Déterministe, bibliothèque standard seule, et chaque décision peut
être expliquée à celui qui demande pourquoi son ticket a atterri là.
Deux choses font d'une liste de mots-clés une règle qu'un service de support
peut vraiment appliquer.
D'abord, une priorité explicite. Un ticket parle de plusieurs sujets ; que
deux équipes soient déclenchées par le même ticket est donc le cas normal, pas
l'exception. L'ordre de RULES y répond une fois pour toutes et par écrit, au
lieu de laisser trancher la branche qui s'exécute la première.
Ensuite, une équipe par défaut. Tout ticket doit atterrir quelque part ; celui
qui ne déclenche rien doit partir dans une file qu'un humain surveille, et non
tomber par terre.
"""
import re
import unicodedata
# The queue that gets everything the rules cannot place. Naming it here, next
# to the rules, is what stops a ticket from silently going nowhere.
DEFAULT_TEAM = "general"
# Business knowledge, kept next to the code that applies it.
#
# The order is the priority, and it is a business decision, not a detail of
# implementation: a billing problem has a legal clock on it, an outage blocks
# the customer's work, a parcel is the one that can wait a day. Whoever
# disagrees can reorder this tuple and nothing else.
RULES = (
("billing", ("facture", "facturation", "remboursement", "prélèvement", "iban", "devis", "paiement")),
("technical", ("bug", "erreur", "panne", "connexion", "mot de passe", "identifiant")),
("shipping", ("livraison", "colis", "transporteur", "expédition", "suivi", "retard")),
)
def _fold(text: str) -> str:
"""Lowercase and drop accents, so "Prélèvement" matches "prelevement"."""
decomposed = unicodedata.normalize("NFD", text.lower())
return "".join(c for c in decomposed if not unicodedata.combining(c))
# A word boundary on the left only. "facture" then also matches "factures"
# and "facturé"; "facturation" does not start with it, and has its own entry.
# The price is that a keyword matches a longer word starting the same way:
# "panne" catches "panneau", "retard" catches "retardataire", "devis" catches
# "devise". The test names them.
_COMPILED = tuple(
(team, tuple((word, re.compile(rf"\b{re.escape(_fold(word))}")) for word in words))
for team, words in RULES
)
def matches(ticket: str) -> dict[str, list[str]]:
"""
Every team the ticket triggers, with the words that triggered it.
The routing decision needs only the first team, but the reviewer of a
misrouted ticket needs this: it shows what the rules saw, and what they
had to drop.
"""
folded = _fold(ticket)
found = {}
for team, patterns in _COMPILED:
hits = [word for word, pattern in patterns if pattern.search(folded)]
if hits:
found[team] = hits
return found
def route(ticket: str, default_team: str = DEFAULT_TEAM) -> str:
"""The team that gets the ticket. Always one, and always a real queue."""
found = matches(ticket)
for team, _ in RULES:
if team in found:
return team
return default_teamJavaScript
/**
* Router un ticket de support avec des règles par mots-clés : ordonnées, avec
* une équipe par défaut.
*
* Niveau N0. Déterministe, bibliothèque standard seule, et chaque décision peut
* être expliquée à celui qui demande pourquoi son ticket a atterri là.
*
* Deux choses font d'une liste de mots-clés une règle qu'un service de support
* peut vraiment appliquer.
*
* D'abord, une priorité explicite. Un ticket parle de plusieurs sujets ; que
* deux équipes soient déclenchées par le même ticket est donc le cas normal, pas
* l'exception. L'ordre de RULES y répond une fois pour toutes et par écrit, au
* lieu de laisser trancher la branche qui s'exécute la première.
*
* Ensuite, une équipe par défaut. Tout ticket doit atterrir quelque part ; celui
* qui ne déclenche rien doit partir dans une file qu'un humain surveille, et non
* tomber par terre.
*/
// The queue that gets everything the rules cannot place. Naming it here, next
// to the rules, is what stops a ticket from silently going nowhere.
export const DEFAULT_TEAM = 'general';
/**
* Business knowledge, kept next to the code that applies it.
*
* The order is the priority, and it is a business decision, not a detail of
* implementation: a billing problem has a legal clock on it, an outage blocks
* the customer's work, a parcel is the one that can wait a day. Whoever
* disagrees can reorder this list and nothing else.
*/
export const RULES = [
['billing', ['facture', 'facturation', 'remboursement', 'prélèvement', 'iban', 'devis', 'paiement']],
['technical', ['bug', 'erreur', 'panne', 'connexion', 'mot de passe', 'identifiant']],
['shipping', ['livraison', 'colis', 'transporteur', 'expédition', 'suivi', 'retard']],
];
/** Lowercase and drop accents, so "Prélèvement" matches "prelevement". */
function fold(text) {
return text.toLowerCase().normalize('NFD').replace(/\p{M}/gu, '');
}
// A word boundary on the left only, with Python's idea of a word character, so
// both languages cut "我的colis" the same way. "facture" then also matches
// "factures" and "facturé"; "facturation" does not start with it, and has its
// own entry. The price is that a keyword matches a longer word starting the
// same way: "panne" catches "panneau", "retard" catches "retardataire",
// "devis" catches "devise". The test names them.
const COMPILED = RULES.map(([team, words]) => [
team,
words.map((word) => [word, new RegExp(`(?<![\\p{L}\\p{N}_])${fold(word).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'u')]),
]);
/**
* Every team the ticket triggers, with the words that triggered it.
*
* The routing decision needs only the first team, but the reviewer of a
* misrouted ticket needs this: it shows what the rules saw, and what they had
* to drop.
*/
export function matches(ticket) {
const folded = fold(ticket);
const found = {};
for (const [team, patterns] of COMPILED) {
const hits = patterns.filter(([, pattern]) => pattern.test(folded)).map(([word]) => word);
if (hits.length) found[team] = hits;
}
return found;
}
/** The team that gets the ticket. Always one, and always a real queue. */
export function route(ticket, defaultTeam = DEFAULT_TEAM) {
const found = matches(ticket);
for (const [team] of RULES) {
if (team in found) return team;
}
return defaultTeam;
}Ce que ça implique
- Vos données ne sortent pas
- Résultat toujours identique
- Se teste unitairement, cas par cas
- Aucun fournisseur dans la boucle
- Empreinte négligeable
Ce que cela ajoute à votre périmètre réglementaire
- Aucun périmètre spécifique ajouté : le ticket est traité là où il est déjà stocké
Point de rupture
Le ticket qui appartient à deux équipes, et celui qui n’en déclenche aucune. « Le colis n’est jamais arrivé et le prélèvement est passé quand même » déclenche facturation et livraison ; la priorité tranche pour facturation, et la moitié colis disparaît sans trace dans la file qui reçoit le ticket. À l’autre bout, « je n’arrive plus à faire ce que je faisais avant » ne déclenche rien et part dans la file par défaut.
Quand passer au niveau suivant
La file par défaut devient la file la plus remplie.
N2Encodeur de phrases auto-hébergé, vote des tickets résolus voisinsPeu coûteux · Rapide
Encodeur de phrases auto-hébergé, vote des tickets résolus voisins
Petit modèle spécialisé auto-hébergéN2Peu coûteuxRapide
Python
"""
Router un ticket d'après les tickets résolus les plus proches, avec un
encodeur auto-hébergé.
Niveau N2. Le niveau N1 apprend les mots de l'archive ; celui-ci compare des
vecteurs. L'encodeur est un modèle entraîné pour la recherche sémantique et le
repérage de paraphrases : il associe un vecteur à chaque ticket, et un ticket
est routé d'après les tickets résolus dont les vecteurs sont les plus proches,
qu'ils partagent ses mots ou non.
Il n'y a pas d'étape d'entraînement ici, et c'est tout l'intérêt du niveau :
l'index est l'archive elle-même. Une équipe dont le périmètre change est un
ré-encodage, pas un réentraînement.
Ce qu'il coûte : un fichier de modèle à livrer et à tenir à jour, un processus
chaud pour le garder en mémoire, et un routage dont les réponses changent le
jour où vous changez de version de modèle. L'archive doit alors être
ré-encodée, et les anciens scores ne sont pas comparables aux nouveaux.
"""
from __future__ import annotations
MODEL_NAME = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
DEFAULT_TEAM = "general"
def load_encoder(name: str = MODEL_NAME):
"""The real encoder: fetched once, then held in memory and run locally."""
from sentence_transformers import SentenceTransformer # pragma: no cover
return SentenceTransformer(name)
def build_index(tickets: list[str], teams: list[str], encoder=None) -> dict:
"""
Encode the resolved archive once, and keep the encoder for the queries.
`encoder` is injected so this can be tested without loading a model. Left
alone, it is the real one above.
"""
encoder = load_encoder() if encoder is None else encoder
tickets = list(tickets)
return {"tickets": tickets, "teams": list(teams), "encoder": encoder,
"vectors": [_unit(v) for v in encoder.encode(tickets)]}
def neighbours(index: dict, ticket: str, k: int = 3) -> list[tuple[float, str]]:
"""The k nearest resolved tickets, best first, with their cosine score."""
vector = _unit(index["encoder"].encode([ticket])[0])
# Vectors from another model do not compare. A different length gives it
# away, where a dot product would still return a plausible number; a model
# of the same width goes unnoticed.
if index["vectors"] and len(vector) != len(index["vectors"][0]):
raise ValueError("the index was encoded by another model: re-encode the archive")
scored = [(_dot(known, vector), team)
for known, team in zip(index["vectors"], index["teams"])]
# A stable sort, so two equally close tickets always come back in archive
# order. A routing run has to be replayable.
scored.sort(key=lambda pair: -pair[0])
return scored[:k]
def route(index: dict, ticket: str, k: int = 3, min_similarity: float = 0.25,
default_team: str = DEFAULT_TEAM) -> str:
"""
The team of the nearest resolved tickets, each voting with its similarity.
A vote rather than the single best neighbour: one archived ticket that
happens to be phrased like this one is an accident, three of them are a
pattern.
The floor is what keeps the default queue of N0 alive. Below it, nothing
in the archive resembles this ticket, and the honest answer is that this
rung has never seen the problem.
"""
votes: dict[str, float] = {}
for similarity, team in neighbours(index, ticket, k):
if similarity >= min_similarity:
votes[team] = votes.get(team, 0.0) + similarity
if not votes:
return default_team
# Ties go to the team of the closest neighbour, which is the first key
# inserted above.
return max(votes, key=lambda team: votes[team])
def _unit(vector) -> list[float]:
"""Cosine similarity is a dot product once both sides have length one."""
values = [float(v) for v in vector]
norm = sum(v * v for v in values) ** 0.5
return [v / norm for v in values] if norm else values
def _dot(a: list[float], b: list[float]) -> float:
return sum(x * y for x, y in zip(a, b))JavaScript
/**
* Router un ticket d'après les tickets résolus les plus proches, avec un
* encodeur auto-hébergé.
*
* Niveau N2. Le niveau N1 apprend les mots de l'archive ; celui-ci compare des
* vecteurs. L'encodeur est un modèle entraîné pour la recherche sémantique et le
* repérage de paraphrases : il associe un vecteur à chaque ticket, et un ticket
* est routé d'après les tickets résolus dont les vecteurs sont les plus proches,
* qu'ils partagent ses mots ou non.
*
* Il n'y a pas d'étape d'entraînement ici, et c'est tout l'intérêt du niveau :
* l'index est l'archive elle-même. Une équipe dont le périmètre change est un
* ré-encodage, pas un réentraînement.
*
* Ce qu'il coûte : un fichier de modèle à livrer et à tenir à jour, un processus
* chaud pour le garder en mémoire, et un routage dont les réponses changent le
* jour où vous changez de version de modèle. L'archive doit alors être
* ré-encodée, et les anciens scores ne sont pas comparables aux nouveaux.
*/
export const MODEL_NAME = 'Xenova/paraphrase-multilingual-MiniLM-L12-v2';
export const DEFAULT_TEAM = 'general';
/** The real encoder: fetched once, then held in memory and run locally. */
export async function loadEncoder(name = MODEL_NAME) {
const { pipeline } = await import('@huggingface/transformers');
// Full precision, as the Python encoder: the transformers.js README gives a
// quantised model (q8) as the default on WASM.
const extract = await pipeline('feature-extraction', name, { dtype: 'fp32' });
return { encode: async (texts) => (await extract(texts, { pooling: 'mean' })).tolist() };
}
/**
* Encode the resolved archive once, and keep the encoder for the queries.
*
* `encoder` is injected so this can be tested without loading a model. Left
* alone, it is the real one above.
*/
export async function buildIndex(tickets, teams, encoder) {
const model = encoder ?? (await loadEncoder());
const kept = [...tickets];
return {
tickets: kept,
teams: [...teams],
encoder: model,
vectors: (await model.encode(kept)).map(unit),
};
}
/** The k nearest resolved tickets, best first, with their cosine score. */
export async function neighbours(index, ticket, k = 3) {
const vector = unit((await index.encoder.encode([ticket]))[0]);
// Vectors from another model do not compare. A different length gives it
// away, where a dot product would still return a plausible number; a model
// of the same width goes unnoticed.
if (index.vectors.length && vector.length !== index.vectors[0].length) {
throw new RangeError('the index was encoded by another model: re-encode the archive');
}
const scored = index.vectors.map((known, i) => [dot(known, vector), index.teams[i]]);
// A stable sort, so two equally close tickets always come back in archive
// order. A routing run has to be replayable.
return scored.sort((a, b) => b[0] - a[0]).slice(0, k);
}
/**
* The team of the nearest resolved tickets, each voting with its similarity.
*
* A vote rather than the single best neighbour: one archived ticket that
* happens to be phrased like this one is an accident, three of them are a
* pattern.
*
* The floor is what keeps the default queue of N0 alive. Below it, nothing in
* the archive resembles this ticket, and the honest answer is that this rung
* has never seen the problem.
*/
export async function route(index, ticket, { k = 3, minSimilarity = 0.25, defaultTeam = DEFAULT_TEAM } = {}) {
const votes = new Map();
for (const [similarity, team] of await neighbours(index, ticket, k)) {
if (similarity >= minSimilarity) votes.set(team, (votes.get(team) ?? 0) + similarity);
}
if (votes.size === 0) return defaultTeam;
// Ties go to the team of the closest neighbour, which is the first key
// inserted above.
let best = defaultTeam;
let bestVote = -Infinity;
for (const [team, vote] of votes) {
if (vote > bestVote) [best, bestVote] = [team, vote];
}
return best;
}
/** Cosine similarity is a dot product once both sides have length one. */
function unit(vector) {
let sum = 0;
for (const v of vector) sum += v * v;
const norm = Math.sqrt(sum);
return norm ? vector.map((v) => v / norm) : [...vector];
}
function dot(a, b) {
let total = 0;
for (let i = 0; i < a.length; i += 1) total += a[i] * b[i];
return total;
}Ce que ça implique
- Vos données restent chez vous
- Résultat toujours identique
- Se mesure sur un échantillon, pas cas par cas
- Dépend d’une bibliothèque
- Empreinte modérée
Ce que cela ajoute à votre périmètre réglementaire
- Copie encodée de l’archive conservée comme index sur votre infrastructure
- Une demande d’effacement portant sur un ticket archivé porte aussi sur l’index, qui en est une copie
- Modèle téléchargé auprès d’un tiers : sa licence et sa provenance sont à vérifier
Point de rupture
Il n’y a pas de modèle des équipes ici, seulement une archive, et le ticket le plus proche n’est pas toujours un ticket pertinent : « Bonjour, merci de me confirmer que vous avez bien reçu mon dossier », qui ne s’adresse à personne, part chez facturation parce qu’un ticket de facturation de l’archive emploie les mêmes formules de politesse. Retirez ce ticket de l’archive et le même message part chez technique : ce dont l’archive est faite est la politique de routage, y compris ce que personne n’y a choisi.
Quand passer au niveau suivant
Des tickets arrivent dans une langue absente de l’archive, ou la règle de routage existe sous forme de consigne écrite qu’aucun ticket résolu n’illustre.
N3Classement par appel à un modèle généraliste, contre une liste fermée d’équipesélevé · Une seconde
Classement par appel à un modèle généraliste, contre une liste fermée d’équipes
API de LLM généralisteN3élevéUne seconde
Python
"""
Router un ticket en interrogeant un modèle généraliste.
Niveau N3. Aucune règle à maintenir, aucune archive à étiqueter : les noms des
équipes vont dans l'invite, et le modèle répond par l'un d'eux.
Voyez ce que le code doit faire et que le niveau N0 n'avait pas à faire :
borner la taille de ce qui part — en tronquant, jamais en levant, parce qu'un
routeur qui lève laisse le ticket nulle part et que l'équipe se décide le plus
souvent au premier paragraphe —, réessayer quand le fournisseur échoue, analyser
une réponse qui n'est que probablement du JSON, et refuser une équipe que le
modèle a inventée. Cette plomberie est le vrai coût de ce niveau, et c'est la
part que vos tests doivent couvrir, puisque le modèle lui-même n'est pas
testable.
Lisez la fin de `route` de près. Un modèle répond des mots, et les mots ne
sont pas des files. La liste fermée est la seule chose qui sépare une réponse
assurée d'un ticket posé dans une file que personne ne surveille.
"""
from __future__ import annotations
import json
# The queues that exist. Business knowledge, and here also a safety rail.
TEAMS = ("billing", "technical", "shipping")
DEFAULT_TEAM = "general"
MAX_CHARACTERS = 4000
PROMPT = (
"You are routing a customer support ticket to one team.\n"
"Answer with JSON only: {{\"team\": \"...\"}} where team is one of: {teams}.\n"
"If the ticket does not clearly belong to one of them, answer {default!r}.\n\n"
"Ticket:\n{ticket}"
)
# The provider named here is an example, not a recommendation: the reasoning
# holds for any general-purpose model API, and the client is swappable. Pass
# any object with a `complete(prompt=..., temperature=...)` method.
MODEL = "gpt-4.1-mini" # an example id: check the parameters your model accepts
class ProviderClient:
"""The one call this snippet makes, on top of the provider's SDK."""
def __init__(self, sdk=None, model: str = MODEL):
if sdk is None: # pragma: no cover - needs a key and a network
from openai import OpenAI
sdk = OpenAI()
self.sdk, self.model = sdk, model
def complete(self, *, prompt: str, temperature: float) -> str:
response = self.sdk.chat.completions.create(
model=self.model,
messages=[{"role": "user", "content": prompt}],
temperature=temperature,
)
return response.choices[0].message.content
class RoutingUnavailable(Exception):
"""The provider could not be reached, or answered something unparseable."""
def route(ticket: str, client=None, *, attempts: int = 3) -> str:
"""
The team that gets the ticket.
`client` is injected so this function can be tested without a network
call. In production it defaults to a real provider client.
"""
client = client or ProviderClient()
# The provider bills every token of the prompt, and a ticket with a
# forwarded thread or a pasted log under it is long. The cap counts
# characters, not tokens, and it truncates rather than raising: a router
# that throws leaves the ticket nowhere, and the team is usually decided by
# the first paragraph anyway. What is cut is said to nobody, which is why
# the caller gets the ticket back only through its queue.
answer = _ask(client, ticket[:MAX_CHARACTERS], attempts)
team = answer.get("team") if isinstance(answer, dict) else None
team = str(team).strip().lower() if team is not None else ""
# Two failures, two treatments. A provider that cannot answer is an
# incident, and `_ask` above raises. A model that answers a team nobody
# created is a normal Tuesday, and the ticket goes to the default queue.
return team if team in TEAMS else DEFAULT_TEAM
def _ask(client, ticket: str, attempts: int) -> dict:
prompt = PROMPT.format(teams=", ".join(TEAMS), default=DEFAULT_TEAM, ticket=ticket)
last_error: Exception | None = None
for _ in range(attempts):
try:
# The lowest temperature: the SDK documents lower values as more
# focused and deterministic.
answer = client.complete(prompt=prompt, temperature=0)
if not isinstance(answer, str): # a refusal comes back as no content
last_error = ValueError("the model answered no text")
continue
return json.loads(answer)
except Exception as error: # noqa: BLE001 - any provider failure is retried
last_error = error
raise RoutingUnavailable(str(last_error))JavaScript
/**
* Router un ticket en interrogeant un modèle généraliste.
*
* Niveau N3. Aucune règle à maintenir, aucune archive à étiqueter : les noms des
* équipes vont dans l'invite, et le modèle répond par l'un d'eux.
*
* Voyez ce que le code doit faire et que le niveau N0 n'avait pas à faire :
* borner la taille de ce qui part — en tronquant, jamais en levant, parce qu'un
* routeur qui lève laisse le ticket nulle part et que l'équipe se décide le plus
* souvent au premier paragraphe —, réessayer quand le fournisseur échoue, analyser
* une réponse qui n'est que probablement du JSON, et refuser une équipe que le
* modèle a inventée. Cette plomberie est le vrai coût de ce niveau, et c'est la
* part que vos tests doivent couvrir, puisque le modèle lui-même n'est pas
* testable.
*
* Lisez la fin de `route` de près. Un modèle répond des mots, et les mots ne
* sont pas des files. La liste fermée est la seule chose qui sépare une réponse
* assurée d'un ticket posé dans une file que personne ne surveille.
*/
// The queues that exist. Business knowledge, and here also a safety rail.
export const TEAMS = ['billing', 'technical', 'shipping'];
export const DEFAULT_TEAM = 'general';
export const MAX_CHARACTERS = 4000;
const PROMPT = [
'You are routing a customer support ticket to one team.',
`Answer with JSON only: {"team": "..."} where team is one of: ${TEAMS.join(', ')}.`,
`If the ticket does not clearly belong to one of them, answer '${DEFAULT_TEAM}'.`,
'',
'Ticket:',
].join('\n');
// The provider named here is an example, not a recommendation: the reasoning
// holds for any general-purpose model API, and the client is swappable. Pass
// any object with a `complete({ prompt, temperature })` method.
export const MODEL = 'gpt-4.1-mini'; // an example id: check the parameters your model accepts
export async function providerClient(sdk, model = MODEL) {
if (!sdk) {
const { OpenAI } = await import('openai');
sdk = new OpenAI();
}
return {
async complete({ prompt, temperature }) {
const response = await sdk.chat.completions.create({
model,
messages: [{ role: 'user', content: prompt }],
temperature,
});
return response.choices[0].message.content;
},
};
}
export class RoutingUnavailable extends Error {}
/**
* The team that gets the ticket.
*
* @param {string} ticket
* @param {object} options
* @param {{complete: Function}} [options.client] injected so this can be
* tested without a network call; defaults to a real provider client
* @param {number} [options.attempts]
*/
export async function route(ticket, { client, attempts = 3 } = {}) {
client ??= await providerClient();
// The provider bills every token of the prompt, and a ticket with a
// forwarded thread or a pasted log under it is long. The cap counts
// characters, not tokens — code points, as in Python — and it truncates
// rather than throwing: a router that throws leaves the ticket nowhere, and
// the team is usually decided by the first paragraph anyway.
const sent = [...ticket].slice(0, MAX_CHARACTERS).join('');
const answer = await ask(client, sent, attempts);
const named = answer && typeof answer.team === 'string' ? answer.team.trim().toLowerCase() : '';
// Two failures, two treatments. A provider that cannot answer is an
// incident, and `ask` above throws. A model that answers a team nobody
// created is a normal Tuesday, and the ticket goes to the default queue.
return TEAMS.includes(named) ? named : DEFAULT_TEAM;
}
async function ask(client, ticket, attempts) {
let lastError;
for (let i = 0; i < attempts; i += 1) {
try {
// The lowest temperature: the SDK documents lower values as more focused
// and deterministic.
const answer = await client.complete({ prompt: `${PROMPT}\n${ticket}`, temperature: 0 });
if (typeof answer !== 'string') {
lastError = new Error('the model answered no text'); // a refusal comes back as no content
continue;
}
return JSON.parse(answer);
} catch (error) {
lastError = error;
}
}
throw new RoutingUnavailable(String(lastError));
}Ce que ça implique
- Vos données partent chez un tiers
- Résultat variable d’un appel à l’autre
- Difficile à tester
- Dépend d’un fournisseur externe
- Empreinte élevée
Ce que cela ajoute à votre périmètre réglementaire
- Transfert du contenu des tickets à un sous-traitant, avec l’encadrement contractuel que cela suppose
- Un ticket est du texte libre : le transfert porte aussi sur ce que le client y a écrit sans qu’on le lui demande
- Localisation du traitement à vérifier auprès du fournisseur
- Ne vous dispense pas de vos propres obligations d’information et de minimisation
Point de rupture
Le modèle répond des mots, et les mots ne sont pas des files. Rien dans la requête n’oblige la réponse à nommer une équipe de la liste : le test fait répondre au double local une quatrième équipe qui sonne juste et n’existe pas, « customer success », en JSON parfaitement formé. La liste fermée l’attrape et le ticket part dans la file par défaut ; ajoutez ce nom à la liste, et le même ticket est classé dans cette équipe — c’est la liste, et elle seule, qui l’arrête. Elle n’arrête d’ailleurs que l’invention : une réponse qui nomme une équipe existante passe, même dictée par un client qui a écrit dans son ticket de l’envoyer en facturation.
Quand passer au niveau suivant
Il n’y a pas de niveau au-dessus.
Pour aller plus loin
- scikit-learn — Extraction de caractéristiques sur du texte, TF-IDF
- scikit-learn — Classification de documents texte, exemple complet
- Introduction to Information Retrieval — la pondération tf-idf
- Sentence Transformers — encodeurs de phrases auto-hébergés
Source des chiffres
- scikit-learn — LogisticRegression : C, class_weight="balanced", objectif pénalisé
- Sentence Transformers — recherche sémantique, similarité, repérage de paraphrases
- paraphrase-multilingual-MiniLM-L12-v2 — vecteurs de 384 dimensions, cinquante langues, licence Apache 2.0
- Transformers.js — pipelines, option dtype
- OpenAI — tarifs de l’API, exprimés par million de jetons
Métadonnées
Information générale, tenue à jour au mieux. Ce n’est pas un avis juridique. Faites valider votre situation par un professionnel.