#!/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}")