Route a support ticket to the right team

Send each incoming support ticket to the team that can answer it.

No generative AI needed

négligeableInstantYour data stays with you

The verdict

N1 wins because its training data already exists: the archive of resolved tickets, each labelled with the team that closed it, is one export away, and it routes the phrasings N0's keyword list never anticipated. It also puts the ambiguous ticket back on the table: `rank` returns the runner-up team with its probability, where N0 dropped it silently. N2 buys paraphrase matching at the price of a model file, a warm process and an index to re-encode on every upgrade: that price is worth paying the day your agents start fishing known problems out of the default queue, not before. One caveat about the archive, and it is the first trap in production: the team that closes a ticket is not always the one that should have received it — escalation, reassignment after a first exchange, teams merged since. Train on the tickets that were never reassigned, or on the first team that answered, and retrain when the org chart changes.

TF-IDF and a linear classifier trained on the archive

No generative AI needed

Lightweight classic modelN1négligeableInstant

Python

snippets/route-support-tickets/n1.pyTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
"""
Route a ticket with TF-IDF and a linear classifier trained on the archive.

Rung N1. The keyword rules of N0 know the words someone thought of. This
knows the words the desk actually received: it is trained on the tickets the
teams have already answered, so the vocabulary of the customers, not the
vocabulary of the rule writer, decides.

What it keeps from N0, deliberately: a default team. A classifier always
returns something, and its most likely class on a ticket it has no opinion
about is still a class. The confidence floor below is what turns that shrug
back into the default queue, instead of a wrong queue.

Training data is the exported archive: the resolved tickets and the team that
resolved each one.
"""

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_team

JavaScript

snippets/route-support-tickets/n1.jsTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
/**
 * Route a ticket with TF-IDF and a linear classifier trained on the archive.
 *
 * Rung N1. The keyword rules of N0 know the words someone thought of. This
 * knows the words the desk actually received: it is trained on the tickets the
 * teams have already answered, so the vocabulary of the customers, not the
 * vocabulary of the rule writer, decides.
 *
 * Written out rather than pulled from a library, and written to learn what the
 * Python snippet learns with scikit-learn: the same words and word pairs, the
 * same TF-IDF weighting, the same penalised objective. The model is a table of
 * weights, and every weight can be printed and argued about when someone asks
 * why their ticket moved.
 *
 * What it keeps from N0, deliberately: a default team. A classifier always
 * returns something, and its most likely class on a ticket it has no opinion
 * about is still a class. The confidence floor is what turns that shrug back
 * into the default queue, instead of a wrong queue.
 */

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;
}

Try it

interactive

The code above runs in your browser. Type in the box: the result is redone in front of you.

The model is trained in your browser as the page loads, on the archive of resolved tickets written right beside it. No ticket leaves the page.

yours to edit

Recomputed on every keystroke.

Or start from an example

Free typing and the other examples need JavaScript. The example shown below was computed when the site was built.

What the code makes of it

What it returns

The ticket goes to Billing

77% confidence, above the 50% floor.

In detail

TeamModel confidence
Billing77%
Shipping12%
Technical11%

Model trained on 21 resolved tickets, seven per team.

What this means for you

  • Your data stays on your own machines
  • Always the same result
  • Measured on a sample, not case by case
  • Depends on a library
  • Low footprint

What this adds to your regulatory scope

  • Creation of a training corpus from real customer tickets, kept apart from the tickets it was drawn from
  • The corpus belongs in your record of processing activities, where you keep one

Breaking point

The model knows the archive and nothing else. « Votre entrepôt accepte-t-il les visites scolaires le mercredi » shares a single word with the resolved tickets, « le »: the three teams come out nearly tied, the confidence floor is not cleared, and the ticket returns to the default queue. N1 did not remove that queue, it made it smaller.

When to move up a level

Agents keep pulling tickets out of the default queue that turn out to be ordinary known problems, described in words the archive does not contain.

The four options

  1. N0Keyword rules, with an explicit priority and a default queueFree · Instant
  2. RecommendedN1TF-IDF and a linear classifier trained on the archivenégligeable · Instant
  3. N2Self-hosted sentence encoder, vote of the nearest resolved ticketsInexpensive · Fast
  4. N3Classification through a general-purpose model, against a closed list of teamsélevé · About a second

The other options

N0Keyword rules, with an explicit priority and a default queueFree · Instant

Keyword rules, with an explicit priority and a default queue

Rule and classic algorithmN0FreeInstant

Python

snippets/route-support-tickets/n0.pyTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
"""
Route a support ticket with keyword rules: ordered, with a default team.

Rung N0. Deterministic, standard library only, and every decision can be
explained to the person who asks why their ticket landed where it did.

Two things turn a keyword list into a rule a support desk can actually run.

First, an explicit priority. Tickets mention several subjects, so two teams
matching the same ticket is the normal case, not the exception. The order of
RULES answers it once and in writing, rather than leaving it to whichever
branch happens to run first.

Second, a default team. Every ticket must land somewhere; a ticket that
matches nothing has to go to a queue a human watches, not to the floor.
"""

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_team

JavaScript

snippets/route-support-tickets/n0.jsTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
/**
 * Route a support ticket with keyword rules: ordered, with a default team.
 *
 * Rung N0. Deterministic, no dependency, and every decision can be explained
 * to the person who asks why their ticket landed where it did.
 *
 * Two things turn a keyword list into a rule a support desk can actually run.
 *
 * First, an explicit priority. Tickets mention several subjects, so two teams
 * matching the same ticket is the normal case, not the exception. The order of
 * RULES answers it once and in writing, rather than leaving it to whichever
 * branch happens to run first.
 *
 * Second, a default team. Every ticket must land somewhere; a ticket that
 * matches nothing has to go to a queue a human watches, not to the floor.
 */

// 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;
}

What this means for you

  • Your data stays put
  • Always the same result
  • Unit-testable, case by case
  • No provider in the loop
  • Negligible footprint

What this adds to your regulatory scope

  • No specific scope added: the ticket is processed where it is already stored

Breaking point

The ticket that belongs to two teams, and the one that triggers none. « Le colis n’est jamais arrivé et le prélèvement est passé quand même » matches both billing and shipping; priority settles on billing, and the parcel half vanishes without a trace in the queue that receives it. At the other end, « je n’arrive plus à faire ce que je faisais avant » matches nothing and goes to the default queue.

When to move up a level

The default queue becomes the busiest queue you have.

N2Self-hosted sentence encoder, vote of the nearest resolved ticketsInexpensive · Fast

Self-hosted sentence encoder, vote of the nearest resolved tickets

Small self-hosted specialised modelN2InexpensiveFast

Python

snippets/route-support-tickets/n2.pyTested, service simulated — This snippet runs on every build of the site, but its test replaces the external service with a local double: the request sent, the response decoded and the error paths are verified, the real provider’s answer is not.
"""
Route a ticket by its nearest resolved tickets, with a self-hosted encoder.

Rung N2. N1 learns the words of the archive; this compares vectors instead.
The encoder is a model trained for semantic search and paraphrase mining: it
maps each ticket to a vector, and a ticket is routed by the resolved tickets
whose vectors are closest, whether or not they share its words.

There is no training step here, and that is the point of the rung: the index
is the archive itself. A team that changes scope is a re-encoding, not a
retraining.

What it costs: a model file to ship and keep in sync, a warm process to hold
it, and a routing whose answers change the day you upgrade the model. The
archive also has to be re-encoded then, and the old scores are not comparable
to the new.
"""

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

snippets/route-support-tickets/n2.jsTested, service simulated — This snippet runs on every build of the site, but its test replaces the external service with a local double: the request sent, the response decoded and the error paths are verified, the real provider’s answer is not.
/**
 * Route a ticket by its nearest resolved tickets, with a self-hosted encoder.
 *
 * Rung N2. N1 learns the words of the archive; this compares vectors instead.
 * The encoder is a model trained for semantic search and paraphrase mining: it
 * maps each ticket to a vector, and a ticket is routed by the resolved tickets
 * whose vectors are closest, whether or not they share its words.
 *
 * There is no training step here, and that is the point of the rung: the index
 * is the archive itself. A team that changes scope is a re-encoding, not a
 * retraining.
 *
 * What it costs: a model file to ship and keep in sync, a warm process to hold
 * it, and a routing whose answers change the day you upgrade the model. The
 * archive also has to be re-encoded then, and the old scores are not
 * comparable to the new.
 */

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;
}

What this means for you

  • Your data stays on your own machines
  • Always the same result
  • Measured on a sample, not case by case
  • Depends on a library
  • Moderate footprint

What this adds to your regulatory scope

  • An encoded copy of the archive kept as an index on your own infrastructure
  • An erasure request on an archived ticket also reaches the index, which is a copy of it
  • Model downloaded from a third party: its licence and its provenance are yours to check

Breaking point

There is no model of the teams on this level, only an archive, and the nearest ticket is not always a relevant one: « Bonjour, merci de me confirmer que vous avez bien reçu mon dossier », addressed to nobody, lands on billing because one billing ticket in the archive uses the same politeness formulas. Take that ticket out of the archive and the same message goes to technical: whatever the archive is made of is the routing policy, including the parts nobody chose.

When to move up a level

Tickets arrive in a language the archive does not contain, or the routing rule exists as a written instruction that no resolved ticket illustrates.

N3Classification through a general-purpose model, against a closed list of teamsélevé · About a second

Classification through a general-purpose model, against a closed list of teams

General-purpose LLM APIN3élevéAbout a second

Python

snippets/route-support-tickets/n3.pyTested, service simulated — This snippet runs on every build of the site, but its test replaces the external service with a local double: the request sent, the response decoded and the error paths are verified, the real provider’s answer is not.
"""
Route a ticket by asking a general-purpose model.

Rung N3. No rules to maintain and no archive to label: the team names go in
the prompt, and the model answers with one of them.

Note what the code has to do that N0 did not: cap the input size, retry a
provider that fails, parse an answer that is only probably JSON, and refuse a
team the model made up. That plumbing is the real cost of this rung, and it is
the part your tests have to cover, because the model itself is not testable.

Read the end of `route` closely. A model answers with words, and
words are not queues. The closed list is the only thing standing between a
confident answer and a ticket sitting in a queue nobody watches.
"""

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

snippets/route-support-tickets/n3.jsTested, service simulated — This snippet runs on every build of the site, but its test replaces the external service with a local double: the request sent, the response decoded and the error paths are verified, the real provider’s answer is not.
/**
 * Route a ticket by asking a general-purpose model.
 *
 * Rung N3. No rules to maintain and no archive to label: the team names go in
 * the prompt, and the model answers with one of them.
 *
 * Note what the code has to do that N0 did not: cap the input size, retry a
 * provider that fails, parse an answer that is only probably JSON, and refuse
 * a team the model made up. That plumbing is the real cost of this rung, and
 * it is the part your tests have to cover, because the model itself is not
 * testable.
 *
 * Read the end of `route` closely. A model answers with words, and words are
 * not queues. The closed list is the only thing standing between a confident
 * answer and a ticket sitting in a queue nobody watches.
 */

// 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));
}

What this means for you

  • Your data goes to a third party
  • The result varies from one call to the next
  • Hard to test
  • Depends on an outside provider
  • High footprint

What this adds to your regulatory scope

  • Transfer of ticket content to a processor, with the contractual framing that implies
  • A ticket is free text: the transfer also covers whatever the customer wrote in it unprompted
  • Processing location to be confirmed with the provider
  • Does not excuse you from your own duties of transparency and minimisation

Breaking point

The model answers words, and words are not queues. Nothing in the request forces the answer to name a team from the list: the test has the local double answer a fourth team that sounds right and does not exist, « customer success », as well-formed JSON. The closed list catches it and the ticket goes to the default queue; add that name to the list and the same ticket is filed into that team — the list, and the list alone, stops it. And it only stops invention: an answer naming a team that exists passes, even one dictated by a customer who wrote in the ticket that it should go to billing.

When to move up a level

There is no level above this one.

Further reading

Where the figures come from

Metadata