Critical 1 (approval forgery): audit_log is now hash-chained (prev_hash/entry_hash, verify_audit_chain); executor approve REQUIRES a BIP-340 signature over sha256(cayce-ops:approve:<id>) when CAYCE_OPS_OPERATOR_PUBKEY is set; deploy/roles.sql ships least-privilege per-daemon DB roles. Critical 2 (threading dead): sends create real OUTBOUND rows (own Message-ID, In-Reply-To -> inbound); inbound message_id never overwritten; maild compose adds the first-contact path; _match_proposal falls back to inbound chains. Critical 3 (duplicate sends): per-message transactions in send path — SMTP failure on message N no longer resurrects 1..N-1; failures audited (send_failed) and the row stays queued. Critical 4 (classifier): negated approvals (cannot accept / not approved / not accepting) classify REJECTION at 0.95 before the bare-keyword rule; LLM prompt braces escaped (.format no longer KeyError into silent fallback). Critical 5 (systemd): every daemon has a run subcommand (maild run = poll+send, publisher run = publish-due, monitor run = build); deploy README documents the separate daily publisher plan timer. Mediums: approved_hash binds drafts/captions to the approved text (edited text refuses to send/publish until re-approved); risk_score renamed match_score (migration 0002) and auto-approve orders HIGHEST relevance first; compose binds postgres to 127.0.0.1; IMAP marks \\Seen only after commit (fetch returns (num, raw), mark post-commit); disabled channels are terminal (ContentStatus.SKIPPED) and publish failures burn a bounded attempt budget before abandonment; make hooks installs the pre-push gate. tests: 12 new — 56 total, all green; ruff 0 findings; gitleaks clean.
106 lines
3.8 KiB
Python
106 lines
3.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
|
|
|
|
# NEGATED approval forms are rejections and must be checked FIRST:
|
|
# "We cannot accept your application", "not accepting new partners",
|
|
# "Your application was not approved" used to match the bare approval
|
|
# keywords (accept/approv) and read as approvals — audit finding 4.
|
|
_NEGATED_APPROVAL = (
|
|
MailClassification.REJECTION,
|
|
re.compile(
|
|
r"\b(cannot|can.?t|will not|won.?t|do not|don.?t|does not|doesn.?t)\s+"
|
|
r"(be\s+able\s+to\s+)?(accept|approve|proceed|offer|welcome)"
|
|
r"|\bnot\s+(been\s+)?approved\b"
|
|
r"|\bnot\s+accepting\b"
|
|
r"|\bunfortunately\b",
|
|
re.I,
|
|
),
|
|
0.95,
|
|
)
|
|
|
|
_RULES: list[tuple[MailClassification, re.Pattern[str], float]] = [
|
|
_NEGATED_APPROVAL,
|
|
# (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.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,
|
|
),
|
|
]
|
|
|
|
# braces are DOUBLED so .format() does not die on the literal JSON sample
|
|
# (the old prompt raised KeyError on every call and the generic except
|
|
# silently degraded to rules — audit finding 4)
|
|
_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
|