mail-negotiator: classify merchant replies, draft gated, audited sends
ci / lint-test-leaks (pull_request) Canceled after 0s
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
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
"""maild package."""
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
"""maild — classify merchant replies, draft responses, gated SMTP.
|
||||||
|
|
||||||
|
Flow
|
||||||
|
----
|
||||||
|
1. ``poll``: fetch unread inbound mail (IMAP), parse, thread to the proposal
|
||||||
|
via ``In-Reply-To`` of a previously-sent draft, classify (rules + optional
|
||||||
|
LLM), store, and create a REPLY DRAFT (never sent automatically).
|
||||||
|
2. ``approve_draft``: a human approves a specific draft (audited).
|
||||||
|
3. ``send_approved``: SMTP sends *only* drafts in state ``approved``.
|
||||||
|
|
||||||
|
Classification is conservative: anything below the confidence threshold becomes
|
||||||
|
``needs_review`` and no draft is generated.
|
||||||
|
|
||||||
|
Public signatures match the CLI: ``poll()``, ``approve_draft(id, actor)``,
|
||||||
|
``send_approved()``. The ``_*`` variants take an explicit session/fetcher for
|
||||||
|
tests and composition.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import email
|
||||||
|
import email.policy
|
||||||
|
import imaplib
|
||||||
|
import smtplib
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from email.message import EmailMessage
|
||||||
|
|
||||||
|
from cayceops.components.maild.classify import classify as _classify_text
|
||||||
|
from cayceops.config import Config
|
||||||
|
from cayceops.db import session_scope
|
||||||
|
from cayceops.models import (
|
||||||
|
DaemonRun,
|
||||||
|
DraftStatus,
|
||||||
|
MailClassification,
|
||||||
|
MailDirection,
|
||||||
|
MailMessage,
|
||||||
|
Proposal,
|
||||||
|
audit,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- IMAP ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def connect_imap(cfg: Config) -> imaplib.IMAP4_SSL:
|
||||||
|
client = imaplib.IMAP4_SSL(cfg.imap_host, cfg.imap_port)
|
||||||
|
client.login(cfg.imap_user, cfg.imap_password)
|
||||||
|
client.select(cfg.imap_mailbox)
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_unread(cfg: Config) -> list[bytes]:
|
||||||
|
"""Fetch raw unread messages. Returns list of raw RFC822 bytes."""
|
||||||
|
client = connect_imap(cfg)
|
||||||
|
try:
|
||||||
|
_, data = client.search(None, "UNSEEN")
|
||||||
|
raw_messages: list[bytes] = []
|
||||||
|
for num in data[0].split():
|
||||||
|
_, msg_data = client.fetch(num, "(RFC822)")
|
||||||
|
raw_messages.append(msg_data[0][1])
|
||||||
|
client.store(num, "+FLAGS", "\\Seen")
|
||||||
|
return raw_messages
|
||||||
|
finally:
|
||||||
|
client.logout()
|
||||||
|
|
||||||
|
|
||||||
|
# --- parsing ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def parse_raw(raw: bytes) -> dict:
|
||||||
|
"""Parse raw RFC822 bytes into our flat dict shape."""
|
||||||
|
msg = email.message_from_bytes(raw, policy=email.policy.default)
|
||||||
|
body = ""
|
||||||
|
if msg.is_multipart():
|
||||||
|
for part in msg.walk():
|
||||||
|
if part.get_content_type() == "text/plain" and not part.get_filename():
|
||||||
|
body = part.get_content()
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
body = msg.get_content()
|
||||||
|
reply_to = msg.get("In-Reply-To") or msg.get("References") or ""
|
||||||
|
message_id = (msg.get("Message-ID") or "").strip()
|
||||||
|
if not message_id:
|
||||||
|
message_id = f"<generated-{datetime.now(UTC).timestamp()}@localhost>"
|
||||||
|
# store the bare address (envelope-safe); display names stay out of the DB
|
||||||
|
from_addr = email.utils.parseaddr(msg.get("From") or "")[1].strip()[:320]
|
||||||
|
return {
|
||||||
|
"message_id": message_id[:998],
|
||||||
|
"in_reply_to": reply_to.split()[-1].strip() if reply_to.strip() else None,
|
||||||
|
"from_addr": from_addr,
|
||||||
|
"subject": (msg.get("Subject") or "").strip()[:998],
|
||||||
|
"body": body[:20000],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- proposal threading -------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _match_proposal(parsed: dict, sess) -> Proposal | None:
|
||||||
|
"""Thread an inbound mail to its proposal via In-Reply-To of sent drafts."""
|
||||||
|
irt = parsed.get("in_reply_to")
|
||||||
|
if not irt:
|
||||||
|
return None
|
||||||
|
sent = (
|
||||||
|
sess.query(MailMessage)
|
||||||
|
.filter(
|
||||||
|
MailMessage.direction == MailDirection.OUTBOUND,
|
||||||
|
MailMessage.message_id == irt,
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if sent is None or sent.proposal_id is None:
|
||||||
|
return None
|
||||||
|
return sess.get(Proposal, sent.proposal_id)
|
||||||
|
|
||||||
|
|
||||||
|
# --- drafts -------------------------------------------------------------------
|
||||||
|
|
||||||
|
_DRAFT_TEMPLATES = {
|
||||||
|
MailClassification.APPROVAL: (
|
||||||
|
"Hello,\n\nThank you for the approval regarding \"{title}\". "
|
||||||
|
"We will proceed on our side and follow up with the next steps.\n\nBest regards,\nCayce"
|
||||||
|
),
|
||||||
|
MailClassification.REJECTION: (
|
||||||
|
"Hello,\n\nThank you for considering \"{title}\". "
|
||||||
|
"We appreciate the reply and would welcome the chance to revisit in the future.\n\n"
|
||||||
|
"Best regards,\nCayce"
|
||||||
|
),
|
||||||
|
MailClassification.QUESTION: (
|
||||||
|
"Hello,\n\nRegarding \"{title}\": {question}\n\n"
|
||||||
|
"Could you help us with the details so we can move forward?\n\nBest regards,\nCayce"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_draft(parsed: dict, kind: MailClassification, proposal: Proposal | None) -> str:
|
||||||
|
title = proposal.title if proposal else (parsed.get("subject") or "your message")
|
||||||
|
template = _DRAFT_TEMPLATES.get(kind)
|
||||||
|
if template is None:
|
||||||
|
return ""
|
||||||
|
question = (parsed.get("body") or "")[:200].strip() or "we had a question"
|
||||||
|
return template.format(title=title[:120], question=question)
|
||||||
|
|
||||||
|
|
||||||
|
# --- inbound pass ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _poll(cfg: Config, sess, fetcher=None) -> dict:
|
||||||
|
"""One inbound pass: parse, classify, thread, draft. Returns stats."""
|
||||||
|
stats = {"processed": 0, "drafted": 0, "needs_review": 0, "skipped": 0}
|
||||||
|
for raw in (fetcher or _fetch_unread)(cfg):
|
||||||
|
parsed = parse_raw(raw)
|
||||||
|
if (
|
||||||
|
sess.query(MailMessage)
|
||||||
|
.filter(MailMessage.message_id == parsed["message_id"])
|
||||||
|
.one_or_none()
|
||||||
|
):
|
||||||
|
stats["skipped"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
proposal = _match_proposal(parsed, sess)
|
||||||
|
kind, confidence = _classify_text(f"{parsed['subject']}\n{parsed['body']}", cfg)
|
||||||
|
if proposal is None and kind == MailClassification.APPROVAL:
|
||||||
|
# an approval we cannot thread is suspicious — human must look
|
||||||
|
kind = MailClassification.NEEDS_REVIEW
|
||||||
|
confidence = min(confidence, 0.4)
|
||||||
|
|
||||||
|
record = MailMessage(
|
||||||
|
direction=MailDirection.INBOUND,
|
||||||
|
message_id=parsed["message_id"],
|
||||||
|
in_reply_to=parsed["in_reply_to"],
|
||||||
|
from_addr=parsed["from_addr"] or None,
|
||||||
|
subject=parsed["subject"] or None,
|
||||||
|
body=parsed["body"],
|
||||||
|
classification=kind,
|
||||||
|
confidence=confidence,
|
||||||
|
proposal_id=proposal.id if proposal else None,
|
||||||
|
processed_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
sess.add(record)
|
||||||
|
sess.flush()
|
||||||
|
audit(
|
||||||
|
sess,
|
||||||
|
actor="daemon:maild",
|
||||||
|
action="classify",
|
||||||
|
proposal_id=proposal.id if proposal else None,
|
||||||
|
detail={"classification": kind.value, "confidence": confidence},
|
||||||
|
)
|
||||||
|
stats["processed"] += 1
|
||||||
|
|
||||||
|
if kind == MailClassification.NEEDS_REVIEW:
|
||||||
|
stats["needs_review"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if proposal is not None:
|
||||||
|
proposal.detail = {
|
||||||
|
**(proposal.detail or {}),
|
||||||
|
"merchant_status": kind.value,
|
||||||
|
"merchant_reply_at": datetime.now(UTC).isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
draft = _build_draft(parsed, kind, proposal)
|
||||||
|
if draft:
|
||||||
|
record.draft_reply = draft
|
||||||
|
record.draft_status = DraftStatus.DRAFTED
|
||||||
|
audit(
|
||||||
|
sess,
|
||||||
|
actor="daemon:maild",
|
||||||
|
action="draft_reply",
|
||||||
|
proposal_id=proposal.id if proposal else None,
|
||||||
|
detail={"mail_id": record.id},
|
||||||
|
)
|
||||||
|
stats["drafted"] += 1
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
# --- human gate: approve draft -------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _approve_draft(mail_id: int, actor: str, sess) -> int:
|
||||||
|
"""Human approves a drafted reply. Only then may SMTP send it."""
|
||||||
|
record = sess.get(MailMessage, mail_id)
|
||||||
|
if record is None or record.direction != MailDirection.INBOUND:
|
||||||
|
return 1
|
||||||
|
if record.draft_status != DraftStatus.DRAFTED:
|
||||||
|
audit(
|
||||||
|
sess,
|
||||||
|
actor=actor,
|
||||||
|
action="approve_draft",
|
||||||
|
proposal_id=record.proposal_id,
|
||||||
|
result="error",
|
||||||
|
detail={"reason": f"invalid draft state: {record.draft_status.value}"},
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
record.draft_status = DraftStatus.APPROVED
|
||||||
|
audit(
|
||||||
|
sess,
|
||||||
|
actor=actor,
|
||||||
|
action="approve_draft",
|
||||||
|
proposal_id=record.proposal_id,
|
||||||
|
detail={"mail_id": record.id},
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# --- SMTP: only approved drafts ------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _smtp_send(cfg: Config, to_addr: str, subject: str, body: str, message_id: str) -> None:
|
||||||
|
msg = EmailMessage()
|
||||||
|
msg["From"] = cfg.mail_from
|
||||||
|
msg["To"] = to_addr
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg["Message-ID"] = message_id
|
||||||
|
msg.set_content(body)
|
||||||
|
with smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30) as server:
|
||||||
|
server.starttls()
|
||||||
|
server.login(cfg.smtp_user, cfg.smtp_password)
|
||||||
|
server.send_message(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def _approved_by(sess, mail_id: int) -> str:
|
||||||
|
from cayceops.models import AuditEntry
|
||||||
|
|
||||||
|
for entry in sess.query(AuditEntry).all():
|
||||||
|
if entry.action == "approve_draft" and entry.detail.get("mail_id") == mail_id:
|
||||||
|
return entry.actor
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _send_approved(cfg: Config, sess, sender=None) -> dict:
|
||||||
|
"""Send every APPROVED draft. Anything else stays untouched."""
|
||||||
|
stats = {"sent": 0, "blocked": 0}
|
||||||
|
pending = (
|
||||||
|
sess.query(MailMessage)
|
||||||
|
.filter(
|
||||||
|
MailMessage.direction == MailDirection.INBOUND,
|
||||||
|
MailMessage.draft_status == DraftStatus.APPROVED,
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for record in pending:
|
||||||
|
to_addr = record.from_addr or ""
|
||||||
|
if not to_addr:
|
||||||
|
record.draft_status = DraftStatus.SKIPPED
|
||||||
|
stats["blocked"] += 1
|
||||||
|
continue
|
||||||
|
message_id = (
|
||||||
|
f"<cayce-draft-{record.id}-{int(datetime.now(UTC).timestamp())}@cayce-co.xyz>"
|
||||||
|
)
|
||||||
|
(sender or _smtp_send)(
|
||||||
|
cfg,
|
||||||
|
to_addr,
|
||||||
|
f"Re: {record.subject or 'your message'}",
|
||||||
|
record.draft_reply or "",
|
||||||
|
message_id,
|
||||||
|
)
|
||||||
|
approved_by = _approved_by(sess, record.id)
|
||||||
|
record.draft_status = DraftStatus.SENT
|
||||||
|
# store the Message-ID so future replies thread to this draft
|
||||||
|
record.message_id = message_id
|
||||||
|
audit(
|
||||||
|
sess,
|
||||||
|
actor="daemon:maild",
|
||||||
|
action="send_draft",
|
||||||
|
proposal_id=record.proposal_id,
|
||||||
|
detail={
|
||||||
|
"mail_id": record.id,
|
||||||
|
"message_id": message_id,
|
||||||
|
"approved_by": approved_by,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
stats["sent"] += 1
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
# --- CLI wrappers (session scope lives here) ------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def poll(cfg: Config | None = None) -> int:
|
||||||
|
from cayceops.config import load
|
||||||
|
from cayceops.db import engine
|
||||||
|
|
||||||
|
cfg = cfg or load()
|
||||||
|
try:
|
||||||
|
with session_scope() as sess:
|
||||||
|
run_row = DaemonRun(daemon="maild")
|
||||||
|
sess.add(run_row)
|
||||||
|
sess.flush()
|
||||||
|
try:
|
||||||
|
stats = _poll(cfg, sess)
|
||||||
|
except Exception as exc: # noqa: BLE001 — daemon boundary
|
||||||
|
run_row.status = "error"
|
||||||
|
run_row.detail = {"error": str(exc)[:300]}
|
||||||
|
run_row.finished_at = datetime.now(UTC)
|
||||||
|
return 1
|
||||||
|
run_row.status = "ok"
|
||||||
|
run_row.detail = stats
|
||||||
|
run_row.finished_at = datetime.now(UTC)
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
engine().dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def approve_draft(mail_id: int, actor: str) -> int:
|
||||||
|
from cayceops.db import engine
|
||||||
|
|
||||||
|
try:
|
||||||
|
with session_scope() as sess:
|
||||||
|
return _approve_draft(mail_id, actor, sess)
|
||||||
|
finally:
|
||||||
|
engine().dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def send_approved(cfg: Config | None = None) -> int:
|
||||||
|
from cayceops.config import load
|
||||||
|
from cayceops.db import engine
|
||||||
|
|
||||||
|
cfg = cfg or load()
|
||||||
|
try:
|
||||||
|
with session_scope() as sess:
|
||||||
|
_send_approved(cfg, sess)
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
engine().dispose()
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""maild tests — parsing, classification, threading, gated send. Invented data only."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from cayceops.components.maild import classify as clf
|
||||||
|
from cayceops.components.maild import maild
|
||||||
|
from cayceops.models import (
|
||||||
|
AuditEntry,
|
||||||
|
DraftStatus,
|
||||||
|
MailClassification,
|
||||||
|
MailDirection,
|
||||||
|
MailMessage,
|
||||||
|
Proposal,
|
||||||
|
ProposalStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
OUTBOUND_MSGID = "<cayce-draft-1-1000@cayce-co.xyz>"
|
||||||
|
|
||||||
|
|
||||||
|
def _eml(subject: str, body: str, in_reply_to: str | None = None, msgid: str | None = None) -> bytes:
|
||||||
|
lines = [
|
||||||
|
"From: Partner Desk <partners@acme.example.org>",
|
||||||
|
"To: agent@cayce-co.xyz",
|
||||||
|
f"Subject: {subject}",
|
||||||
|
f"Message-ID: {msgid or '<acme-reply-1@acme.example.org>'}",
|
||||||
|
]
|
||||||
|
if in_reply_to:
|
||||||
|
lines.append(f"In-Reply-To: {in_reply_to}")
|
||||||
|
lines += ["MIME-Version: 1.0", "Content-Type: text/plain; charset=utf-8", "", body]
|
||||||
|
return "\r\n".join(lines).encode()
|
||||||
|
|
||||||
|
|
||||||
|
def _proposal_with_sent_draft(sess) -> Proposal:
|
||||||
|
p = Proposal(kind="partnership", title="Acme hotel affiliate program", status=ProposalStatus.APPROVED)
|
||||||
|
sess.add(p)
|
||||||
|
sess.flush()
|
||||||
|
sess.add(
|
||||||
|
MailMessage(
|
||||||
|
direction=MailDirection.OUTBOUND,
|
||||||
|
message_id=OUTBOUND_MSGID,
|
||||||
|
to_addr="partners@acme.example.org",
|
||||||
|
subject="[cayce] partnership inquiry",
|
||||||
|
proposal_id=p.id,
|
||||||
|
draft_status=DraftStatus.SENT,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
sess.flush()
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
# --- classifier ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_rules_classification(cfg):
|
||||||
|
config = cfg()
|
||||||
|
assert clf.classify("Your application was approved, welcome to the program", config)[0] is MailClassification.APPROVAL
|
||||||
|
assert clf.classify("Unfortunately we cannot accept new partners", config)[0] is MailClassification.REJECTION
|
||||||
|
assert clf.classify("Could you provide your traffic numbers?", config)[0] is MailClassification.QUESTION
|
||||||
|
kind, confidence = clf.classify("grocery list", config)
|
||||||
|
assert kind is MailClassification.OTHER or kind is MailClassification.NEEDS_REVIEW
|
||||||
|
|
||||||
|
|
||||||
|
def test_low_confidence_becomes_needs_review(cfg):
|
||||||
|
config = cfg(CAYCE_OPS_LLM_REVIEW_THRESHOLD="0.95")
|
||||||
|
kind, confidence = clf.classify("Your application was approved, welcome", config)
|
||||||
|
assert kind is MailClassification.NEEDS_REVIEW
|
||||||
|
assert confidence < 0.95
|
||||||
|
|
||||||
|
|
||||||
|
# --- inbound pass ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_threads_approval_and_drafts_reply(cfg, sess):
|
||||||
|
p = _proposal_with_sent_draft(sess)
|
||||||
|
raw = _eml("Re: [cayce] partnership inquiry", "Approved! Welcome to the Acme partner program.", in_reply_to=OUTBOUND_MSGID)
|
||||||
|
|
||||||
|
stats = maild._poll(cfg(), sess, fetcher=lambda cfg: [raw])
|
||||||
|
assert stats["processed"] == 1 and stats["drafted"] == 1
|
||||||
|
|
||||||
|
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
|
||||||
|
assert inbound.classification is MailClassification.APPROVAL
|
||||||
|
assert inbound.proposal_id == p.id
|
||||||
|
assert inbound.draft_status is DraftStatus.DRAFTED
|
||||||
|
assert inbound.draft_reply and "approval" in inbound.draft_reply.lower()
|
||||||
|
|
||||||
|
assert p.detail["merchant_status"] == "approval"
|
||||||
|
assert p.status is ProposalStatus.APPROVED # lifecycle untouched by maild
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_idempotent_on_message_id(cfg, sess):
|
||||||
|
_proposal_with_sent_draft(sess)
|
||||||
|
raw = _eml("Re: hi", "Approved, welcome to the program.", in_reply_to=OUTBOUND_MSGID)
|
||||||
|
fetcher = lambda cfg: [raw]
|
||||||
|
assert maild._poll(cfg(), sess, fetcher=fetcher)["processed"] == 1
|
||||||
|
assert maild._poll(cfg(), sess, fetcher=fetcher)["skipped"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_unthreaded_approval_needs_review(cfg, sess):
|
||||||
|
raw = _eml("Great news", "Approved! Welcome aboard.") # no In-Reply-To
|
||||||
|
stats = maild._poll(cfg(), sess, fetcher=lambda cfg: [raw])
|
||||||
|
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
|
||||||
|
assert inbound.classification is MailClassification.NEEDS_REVIEW
|
||||||
|
assert stats["needs_review"] == 1
|
||||||
|
assert inbound.draft_status is DraftStatus.NONE # no draft for review items
|
||||||
|
|
||||||
|
|
||||||
|
def test_question_gets_clarification_draft(cfg, sess):
|
||||||
|
_proposal_with_sent_draft(sess)
|
||||||
|
raw = _eml("Re: inquiry", "Could you provide your monthly traffic?", in_reply_to=OUTBOUND_MSGID)
|
||||||
|
maild._poll(cfg(), sess, fetcher=lambda cfg: [raw])
|
||||||
|
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
|
||||||
|
assert inbound.classification is MailClassification.QUESTION
|
||||||
|
assert inbound.draft_status is DraftStatus.DRAFTED
|
||||||
|
assert "traffic" in inbound.draft_reply
|
||||||
|
|
||||||
|
|
||||||
|
# --- human gate -------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_blocked_without_approval(cfg, sess, monkeypatch):
|
||||||
|
_proposal_with_sent_draft(sess)
|
||||||
|
raw = _eml("Re: inquiry", "Approved, welcome to the program.", in_reply_to=OUTBOUND_MSGID)
|
||||||
|
maild._poll(cfg(), sess, fetcher=lambda cfg: [raw])
|
||||||
|
|
||||||
|
sent = []
|
||||||
|
config = cfg(CAYCE_OPS_MAIL_FROM="agent@cayce-co.xyz")
|
||||||
|
stats = maild._send_approved(config, sess, sender=lambda *a, **k: sent.append(a))
|
||||||
|
assert stats["sent"] == 0 and sent == []
|
||||||
|
assert sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one().draft_status is DraftStatus.DRAFTED
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_requires_explicit_human_approval(cfg, sess):
|
||||||
|
_proposal_with_sent_draft(sess)
|
||||||
|
raw = _eml("Re: inquiry", "Approved, welcome to the program.", in_reply_to=OUTBOUND_MSGID)
|
||||||
|
maild._poll(cfg(), sess, fetcher=lambda cfg: [raw])
|
||||||
|
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
|
||||||
|
|
||||||
|
assert maild._approve_draft(inbound.id, "gustavo", sess) == 0
|
||||||
|
|
||||||
|
sent = []
|
||||||
|
config = cfg(CAYCE_OPS_MAIL_FROM="agent@cayce-co.xyz")
|
||||||
|
stats = maild._send_approved(config, sess, sender=lambda *a, **k: sent.append(a))
|
||||||
|
assert stats["sent"] == 1
|
||||||
|
assert inbound.draft_status is DraftStatus.SENT
|
||||||
|
assert sent[0][1] == "partners@acme.example.org"
|
||||||
|
|
||||||
|
row = sess.query(AuditEntry).filter_by(action="send_draft").one()
|
||||||
|
assert "gustavo" in str(row.detail)
|
||||||
|
|
||||||
|
|
||||||
|
def test_double_approve_is_rejected(cfg, sess):
|
||||||
|
_proposal_with_sent_draft(sess)
|
||||||
|
raw = _eml("Re: inquiry", "Approved, welcome to the program.", in_reply_to=OUTBOUND_MSGID)
|
||||||
|
maild._poll(cfg(), sess, fetcher=lambda cfg: [raw])
|
||||||
|
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
|
||||||
|
|
||||||
|
assert maild._approve_draft(inbound.id, "gustavo", sess) == 0
|
||||||
|
assert maild._approve_draft(inbound.id, "gustavo", sess) == 1
|
||||||
Reference in New Issue
Block a user