#!/usr/bin/env catnip
# Extraction de lignes de commande et de montants dans un courriel fournisseur.
# spaCy découpe en phrases (sentencizer) et trouve les motifs token par token
# (Matcher) ; Catnip structure les extractions et produit le bilan chiffré.
#
# Complément de spacy_support_tickets.cat : l'EntityRuler y nommait des entités
# fixes, ici le Matcher capture des structures (quantité + unité + produit)
# sans aucun modèle statistique.
#
# DEPS: spacy
# OFFLINE: aucun modèle spaCy externe requis
spacy = import('spacy')
nlp = spacy.blank('fr')
nlp.add_pipe('sentencizer')
# Motifs sur attributs de tokens uniquement : aucun tagger requis.
# ORDER_LINE : nombre, unité, "de"/"d'" optionnel, produit (mots, élisions comprises).
# PRICE : nombre (virgule admise) suivi d'une devise littérale.
# Le produit exclut les conjonctions et prépositions : sans tagger, c'est ce
# qui empêche « pommes et 12 cartons » d'être avalé comme un seul produit.
# Il est reconnu par expression régulière plutôt que par IS_ALPHA : le
# tokenizer français isole l'élision (« d' » dans « jus d'orange »), qui n'est
# pas alphabétique et couperait le produit à « jus ».
matcher = spacy.matcher.Matcher(nlp.vocab)
matcher.add('ORDER_LINE',
list(list(
dict(LIKE_NUM=True),
dict(LOWER=dict(IN=list('kg', 'g', 'l', 'cl', 'ml', 'pièce', 'pièces', 'carton', 'cartons'))),
dict(LOWER=dict(IN=list('de', "d'")), OP='?'),
dict(TEXT=dict(REGEX="^[A-Za-zÀ-ÖØ-öø-ÿ]+['’]?$"), LOWER=dict(NOT_IN=list('et', 'pour', 'au', 'à', 'avec')), OP='+'),
)))
matcher.add('PRICE',
list(list(
dict(TEXT=dict(REGEX='^[0-9]+([.,][0-9]+)?$')),
dict(LOWER=dict(IN=list('€', 'euros', 'eur'))),
)))
email = "Bonjour, merci de préparer la commande de la semaine. Nous souhaitons 25 kg de pommes et 12 cartons de jus d'orange. Ajoutez 3 pièces de fromage affiné, pour 145,50 euros au total. Livrez aussi 5 l d'huile d'olive. Le devis précédent s'élevait à 320 euros."
struct OrderLine { sentence: int; qty: str; unit: str; product: str }
struct Price { sentence: int; amount: float }
doc = nlp(email)
lines = list()
prices = list()
# Les OP gourmands produisent toutes les variantes préfixées d'un même match :
# pour chaque position de départ (renvoyées dans l'ordre), on garde la plus longue.
longest_only = (matches) => {
filtered = list()
pending = None
for m in matches {
if pending != None and m[1] == pending[1] {
pending = m
} else {
if pending != None { filtered.append(pending) }
pending = m
}
}
if pending != None { filtered.append(pending) }
filtered
}
i = 0
for sent in doc.sents {
for m in longest_only(matcher(sent)) {
label = nlp.vocab.strings[m[0]]
span = sent[m[1]:m[2]]
if label == 'ORDER_LINE' {
start = 2
if span[2].lower_ == 'de' or span[2].lower_ == "d'" { start = 3 }
# .text rend le texte d'origine du span, espacement compris :
# recoller les tokens à l'espace produirait « jus d' orange ».
lines.append(OrderLine(i, span[0].text, span[1].text, span[start:].text))
}
if label == 'PRICE' {
amount = float(span[0].text.replace(',', '.'))
prices.append(Price(i, amount))
}
}
i = i + 1
}
print("⇒ Lignes de commande extraites")
for l in lines {
print(f" phrase {l.sentence} {l.qty:>4} {l.unit:<7} {l.product}")
}
print()
print("⇒ Montants détectés")
total = 0.0
for p in prices {
total = total + p.amount
print(f" phrase {p.sentence} {p.amount} €")
}
# Somme de ce qui a été détecté, pas un total de commande : le courriel cite
# aussi un devis antérieur, que rien dans les motifs ne distingue du reste.
print(f" somme {round(total, 2)} €")
# Agrégation des quantités par unité.
print()
print("⇒ Quantités par unité")
units = list()
qty_by_unit = dict()
for l in lines {
if l.unit not in units { units.append(l.unit) }
qty_by_unit[l.unit] = qty_by_unit.get(l.unit, 0) + int(l.qty)
}
for unit in units {
print(f" {unit:<7}: {qty_by_unit[unit]}")
}
# Les produits entrent dans l'oracle : ce sont eux que l'élision tronquait,
# et un compte de lignes juste ne dit rien de ce qui est extrait.
products = list()
for l in lines { products.append(l.product) }
expected = list('pommes', "jus d'orange", 'fromage affiné', "huile d'olive")
oracle_ok = len(lines) == 4 and len(prices) == 2
oracle_ok = oracle_ok and products == expected
oracle_ok = oracle_ok and abs(total - 465.5) < 0.01
oracle_ok = oracle_ok and qty_by_unit.get('kg', 0) == 25 and qty_by_unit.get('cartons', 0) == 12
print()
print(f"⇒ Oracle (4 lignes et leurs produits, 2 montants, somme 465,50 €, quantités) : {oracle_ok}")