ci / lint-test-leaks (pull_request) Canceled after 0s
- IMAP poll (UNSEEN, stdlib imaplib) -> parse (bare address via email.utils) - threading: In-Reply-To -> outbound sent-draft message-id -> proposal - classify: deterministic rules; optional LLM behind provider interface; below-threshold confidence -> needs_review (never guessed) - unthreadable approvals flagged needs_review (suspicious by definition) - merchant_status recorded on proposal detail; proposal lifecycle untouched - reply drafts generated from templates (drafted), NEVER auto-sent - SMTP sends only draft_status=approved (human approve_draft, audited); sent drafts store their Message-ID for future threading - idempotent on Message-ID; DaemonRun wrapper for monitor tests: 9 new (classification rules, threshold->review, threading, idempotence, blocked send without approval, approve->send->audit chain, double approve) — 29 total passing
69 lines
2.8 KiB
Python
69 lines
2.8 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, re.compile(r"\b(declin|reject|not able to|unable to|unfortunately|we regret|won.t be able)\w*", re.I), 0.9),
|
|
(MailClassification.APPROVAL, re.compile(r"\b(approv|accept|welcome to the|you.re (in|approved)|live|activated|glad to)\w*", re.I), 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), 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
|