ci / lint-test-leaks (pull_request) Canceled after 0s
Merchants write in Spanish; negations like nunca, lamentablemente or no podemos must veto approval exactly like their English counterparts. Previously an all-Spanish rejection mail could fall through to OTHER, and a mixed mail with the approval word in English approved despite a Spanish negation. - negation list adds: nunca, jamas, tampoco, ningun/ninguna, nadie, nada, no podemos, no es posible, no aceptamos, rechaz*, descartad*, lamentablemente, no ha sido aprobad*, no procede - new Spanish REJECTION rule (rechaz*, solicitud denegada, no cumple los requisitos) and Spanish APPROVAL rule (aprob*, aceptad*, bienvenid*, encantados de trabajar) - veto rule unchanged: any negation anywhere -> needs_review - tests: 15 new (12 veto cases incl. 2 mixed-language, pure-Spanish approval still approves, Spanish rejection classifies rejection) Gate: ruff 0 · 96 unit + 1 PG integration · gitleaks clean.
129 lines
5.0 KiB
Python
129 lines
5.0 KiB
Python
"""maild classification: rules + sentence-level negation, LLM optional.
|
|
|
|
Approval rule (audit round 2, point 2): a mail may only classify `approval`
|
|
when NO sentence contains a negation. Any negation token anywhere in the mail
|
|
(conservative: even outside the approval sentence) downgrades an approval to
|
|
`needs_review`. Rejections/questions/other are unaffected.
|
|
|
|
The LLM never decides alone: below-threshold confidence → `needs_review`.
|
|
With the `null` provider (default) only deterministic rules run.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
|
|
from cayceops.config import Config
|
|
from cayceops.models import MailClassification
|
|
|
|
_APPROVAL_WORDS = re.compile(r"\b(approv|accept|welcome)", re.I)
|
|
|
|
# any of these tokens anywhere in the mail vetoes an `approval` result.
|
|
# English and Spanish (merchants write in both; mixed mails are common).
|
|
_NEGATIONS = re.compile(
|
|
r"\b(not|no|never|cannot|can'?t|won'?t|hasn'?t|isn'?t|"
|
|
r"no\s+longer|unable|decline\w*|regret\w*|unfortunately|"
|
|
# spanish: standalone negation words
|
|
r"nunca|jam[aá]s|tampoco|ning[uú]n(?:a|o)?|nadie|nada|"
|
|
# spanish: multi-word negations and rejection stems
|
|
r"no\s+podemos|no\s+es\s+posible|no\s+acept(?:amos|ar[aá])|"
|
|
r"rechaz\w*|lamentablemente|descartad\w*|"
|
|
r"no\s+ha\s+sido\s+aprobad\w*|no\s+procede)\b",
|
|
re.I,
|
|
)
|
|
|
|
_RULES: list[tuple[MailClassification, re.Pattern[str], float]] = [
|
|
# (class, pattern, confidence when matched)
|
|
(
|
|
MailClassification.REJECTION,
|
|
# noqa: E501 (kept on one line: the pattern IS the reviewed artifact)
|
|
re.compile(r"\b(declin|reject|not able to|unable to|we regret|won.t be able)\w*", re.I), # noqa: E501
|
|
0.9,
|
|
),
|
|
(
|
|
MailClassification.REJECTION,
|
|
# spanish rejections (strong stems, not just negation words)
|
|
re.compile(r"\b(rechaz\w*|no\s+vamos\s+a\s+pod(?:er|emos)|su\s+solicitud\s+ha\s+sido\s+denegada|no\s+cumple\s+los\s+requisitos)\b", re.I), # noqa: E501
|
|
0.9,
|
|
),
|
|
(
|
|
MailClassification.APPROVAL,
|
|
re.compile(r"\b(approv|accept|welcome to the|you.re (in|approved)|live|activated|glad to)\w*", re.I), # noqa: E501
|
|
0.85,
|
|
),
|
|
(
|
|
MailClassification.APPROVAL,
|
|
# spanish approvals (standalone positive stems; the veto still
|
|
# downgrades if ANY negation appears elsewhere in the mail)
|
|
re.compile(r"\b(aprob\w*|aceptad\w*|bienvenid\w*|ha\s+sido\s+aceptada|encantados?\s+de\s+(trabajar|colaborar))\b", re.I), # noqa: E501
|
|
0.85,
|
|
),
|
|
(
|
|
MailClassification.QUESTION,
|
|
re.compile(r"\b(could you|can you|please (provide|clarify|confirm)|what (is|are)|how (do|does|can)|need more (info|details))\b", re.I), # noqa: E501
|
|
0.7,
|
|
),
|
|
]
|
|
|
|
_LLM_PROMPT = """Classify this merchant email into exactly one of:
|
|
approval, rejection, question, other.
|
|
Reply with strict JSON: {{"classification": "...", "confidence": 0.0-1.0}}
|
|
|
|
Subject and body:
|
|
{body}"""
|
|
|
|
|
|
def _rules(text: str) -> tuple[MailClassification, float]:
|
|
for kind, pattern, confidence in _RULES:
|
|
if pattern.search(text):
|
|
return kind, confidence
|
|
return MailClassification.OTHER, 0.3
|
|
|
|
|
|
def _has_negation(text: str) -> bool:
|
|
return bool(_NEGATIONS.search(text))
|
|
|
|
|
|
def _llm_classify(text: str, cfg: Config) -> tuple[MailClassification, float] | None:
|
|
"""Ask the configured LLM. Returns None on any failure (rules take over)."""
|
|
from cayceops.llm import get_provider
|
|
|
|
try:
|
|
provider = get_provider(cfg)
|
|
raw = provider.complete(
|
|
_LLM_PROMPT.format(body=text[:4000]),
|
|
system="You classify merchant emails. Reply with JSON only.",
|
|
)
|
|
data = json.loads(re.search(r"\{.*\}", raw, re.S).group(0))
|
|
kind = MailClassification(data["classification"].lower())
|
|
confidence = float(data["confidence"])
|
|
except Exception: # noqa: BLE001 — LLM is best-effort by design
|
|
return None
|
|
if kind not in MailClassification or kind is MailClassification.NEEDS_REVIEW:
|
|
return None
|
|
return kind, min(max(confidence, 0.0), 1.0)
|
|
|
|
|
|
def classify(text: str, cfg: Config) -> tuple[MailClassification, float]:
|
|
"""Classify merchant text. Degrades to rules when no LLM is configured."""
|
|
if cfg.llm_provider != "null":
|
|
llm_result = _llm_classify(text, cfg)
|
|
if llm_result is not None:
|
|
kind, confidence = llm_result
|
|
else: # LLM failed — fall through to rules
|
|
kind, confidence = _rules(text)
|
|
else:
|
|
kind, confidence = _rules(text)
|
|
|
|
# APPROVAL VETO (audit round 2): any negation anywhere in the mail
|
|
# downgrades an approval to needs_review. Approval requires a mail with
|
|
# zero negations — "cannot currently accept", "never accept",
|
|
# "hasn't been approved", "no longer accepting" all land here.
|
|
if kind is MailClassification.APPROVAL and _has_negation(text):
|
|
return MailClassification.NEEDS_REVIEW, min(confidence, 0.6)
|
|
|
|
if confidence < cfg.llm_review_threshold:
|
|
return MailClassification.NEEDS_REVIEW, confidence
|
|
return kind, confidence
|