All 32 findings resolved: regex literals kept single-line with noqa (pattern IS the reviewed artifact), HTML rows string-concatenated for line length, walrus tuple in BIP-340 verify split into named generator, unused pytest import dropped. No behavior changes; 44/44 tests pass. Full-gate discipline restored: ruff exit 0.
85 lines
3.0 KiB
Python
85 lines
3.0 KiB
Python
"""maild classification: rules first, LLM optional, conservative threshold.
|
|
|
|
The LLM never decides alone: any result below ``CAYCE_OPS_LLM_REVIEW_THRESHOLD``
|
|
confidence becomes ``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
|
|
|
|
_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|unfortunately|we regret|won.t be able)\w*", 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.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 _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)
|
|
|
|
if confidence < cfg.llm_review_threshold:
|
|
return MailClassification.NEEDS_REVIEW, confidence
|
|
return kind, confidence
|