#!/usr/bin/env catnip
# Analyse et routage de tickets support avec un pipeline spaCy local.
# spaCy assure la tokenisation et l'extraction par motifs ; Catnip orchestre,
# classifie et agrège les résultats.
#
# DEPS: spacy
# OFFLINE: aucun modèle spaCy externe requis
spacy = import('spacy')
# Pipeline français vide : seul le tokenizer fourni par spaCy est chargé.
nlp = spacy.blank('fr')
ruler = nlp.add_pipe('entity_ruler')
ruler.add_patterns(list(
dict(label='ORDER_ID', pattern=list(dict(TEXT=dict(REGEX='CMD-[0-9]{4}')))),
dict(label='PRODUCT', pattern='application mobile'),
dict(label='PRODUCT', pattern='routeur fibre'),
dict(label='LOCATION', pattern='Lyon'),
dict(label='LOCATION', pattern='Paris'),
dict(label='LOCATION', pattern='Nantes'),
dict(label='INCIDENT', pattern='débité deux fois'),
dict(label='INCIDENT', pattern='en panne'),
))
struct Ticket {
id: str; text: str
}
struct Entity {
text: str; label: str
display(self): str => { f"{self.text}<{self.label}>" }
}
union Route {
billing; delivery; technical; account; general
label(self): str => {
match self {
Route.billing => { "facturation" }
Route.delivery => { "livraison" }
Route.technical => { "technique" }
Route.account => { "compte" }
Route.general => { "général" }
}
}
}
union Priority {
high; medium; low
label(self): str => {
match self {
Priority.high => { "haute" }
Priority.medium => { "moyenne" }
Priority.low => { "basse" }
}
}
}
struct Analysis {
ticket: Ticket; entities: list[Entity]; route: Route; priority: Priority
display(self): None => {
tags = if len(self.entities) > 0 {
", ".join(self.entities.[(entity) => { entity.display() }])
} else {
"aucune entité"
}
print(f" {self.ticket.id} {self.priority.label():<7} → {self.route.label():<11} | {tags}")
}
}
contains_any = (text: str, terms: list[str]): bool => {
found = False
for term in terms {
if term in text { found = True }
}
found
}
route_for = (text: str): Route => {
match True {
_ if contains_any(text, list('paiement', 'débité', 'facture', 'remboursement')) => { Route.billing }
_ if contains_any(text, list('livraison', 'colis', 'arrivé', 'adresse')) => { Route.delivery }
_ if contains_any(text, list('application', 'routeur', 'erreur', 'panne')) => { Route.technical }
_ if contains_any(text, list('compte', 'mot de passe', 'réinitialiser')) => { Route.account }
_ => { Route.general }
}
}
priority_for = (text: str, entities: list[Entity]): Priority => {
urgent = contains_any(text, list('urgent', 'impossible', 'bloqué', 'panne', 'deux fois'))
incident = False
for entity in entities {
if entity.label == 'INCIDENT' { incident = True }
}
match True {
_ if urgent or incident => { Priority.high }
_ if len(entities) > 0 => { Priority.medium }
_ => { Priority.low }
}
}
analyze = (ticket: Ticket): Analysis => {
doc = nlp(ticket.text)
entities = list()
for span in doc.ents {
entities.append(Entity(span.text, span.label_))
}
normalized = ticket.text.lower()
Analysis(ticket, entities, route_for(normalized), priority_for(normalized, entities))
}
tickets = list(
Ticket('T-001', "Urgent : le paiement de la commande CMD-1042 a été débité deux fois."),
Ticket('T-002', "Le colis CMD-2087 n'est toujours pas arrivé à Lyon."),
Ticket('T-003', "L'application mobile affiche une erreur au démarrage."),
Ticket('T-004', "Je souhaite changer l'adresse de livraison de CMD-3105 pour Nantes."),
Ticket('T-005', "Le routeur fibre est en panne depuis ce matin à Paris."),
Ticket('T-006', "Merci, tout fonctionne maintenant."),
Ticket('T-007', "Impossible de réinitialiser le mot de passe de mon compte."),
Ticket('T-008', "Où trouver la facture de la commande CMD-9912 ?"),
)
print(f"⇒ Analyse de {len(tickets)} tickets avec spaCy")
analyses = tickets.[analyze]
analyses.[(analysis) => { analysis.display() }]
route_counts = dict()
priority_counts = dict()
entity_counts = dict()
for analysis in analyses {
route = analysis.route.label()
priority = analysis.priority.label()
route_counts[route] = route_counts.get(route, 0) + 1
priority_counts[priority] = priority_counts.get(priority, 0) + 1
for entity in analysis.entities {
entity_counts[entity.label] = entity_counts.get(entity.label, 0) + 1
}
}
print()
print("⇒ Répartition par file")
for route in list("facturation", "livraison", "technique", "compte", "général") {
print(f" {route:<11}: {route_counts.get(route, 0)}")
}
print()
print("⇒ Répartition par priorité")
for priority in list("haute", "moyenne", "basse") {
print(f" {priority:<7}: {priority_counts.get(priority, 0)}")
}
print()
print("⇒ Entités extraites")
for label in list('ORDER_ID', 'PRODUCT', 'LOCATION', 'INCIDENT') {
print(f" {label:<9}: {entity_counts.get(label, 0)}")
}