Initial commit: Howl M365 email management daemon
Full implementation of the Howl email triage system: - Microsoft Graph API integration with MSAL auth (client-credentials and delegated modes) - Claude LLM classification via tool use for structured output - PostgreSQL database with customers, vendors, whitelist, and email_log tables - Alembic migration for full schema - APScheduler daemon with graceful shutdown - Typer CLI (run, dry-run, status commands) - Business rule classifier with overrides (whitelist protection, low-confidence fallback) - Action executor (move to folders, flag, escalate with webhook) - 35 passing unit tests - README, SETUP, and NEXT_STEPS documentation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
3bfda9e585
39 changed files with 3656 additions and 0 deletions
91
tests/test_classifier.py
Normal file
91
tests/test_classifier.py
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from howl.db.queries import SenderMatch
|
||||
from howl.llm.schemas import EmailClassification
|
||||
from howl.pipeline import classifier
|
||||
|
||||
|
||||
def make_classification(**kwargs) -> EmailClassification:
|
||||
defaults = {
|
||||
"classification": "customer_inquiry",
|
||||
"confidence": 0.90,
|
||||
"action": "move_customer",
|
||||
"reasoning": "Test classification",
|
||||
"priority": "normal",
|
||||
"requires_human_review": False,
|
||||
"tags": [],
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return EmailClassification.model_validate(defaults)
|
||||
|
||||
|
||||
def test_whitelist_sender_not_moved_to_spam(settings, whitelist_match):
|
||||
classification = make_classification(action="move_spam", confidence=0.85)
|
||||
final_action, overridden = classifier.decide(whitelist_match, classification, settings)
|
||||
assert final_action == "inbox_keep"
|
||||
assert overridden is True
|
||||
|
||||
|
||||
def test_customer_sender_not_moved_to_spam(settings, customer_match):
|
||||
classification = make_classification(action="move_spam", confidence=0.85)
|
||||
final_action, overridden = classifier.decide(customer_match, classification, settings)
|
||||
assert final_action == "move_review"
|
||||
assert overridden is True
|
||||
|
||||
|
||||
def test_requires_human_review_forces_move_review(settings, unknown_match):
|
||||
classification = make_classification(
|
||||
action="inbox_keep",
|
||||
confidence=0.75,
|
||||
requires_human_review=True,
|
||||
)
|
||||
final_action, overridden = classifier.decide(unknown_match, classification, settings)
|
||||
assert final_action == "move_review"
|
||||
assert overridden is True
|
||||
|
||||
|
||||
def test_low_confidence_falls_back_to_inbox_keep(settings, unknown_match):
|
||||
# Settings has llm_confidence_threshold=0.60, this is 0.45
|
||||
classification = make_classification(
|
||||
action="move_spam",
|
||||
confidence=0.45,
|
||||
requires_human_review=False,
|
||||
)
|
||||
final_action, overridden = classifier.decide(unknown_match, classification, settings)
|
||||
assert final_action == "inbox_keep"
|
||||
assert overridden is True
|
||||
|
||||
|
||||
def test_high_confidence_customer_passes_through(settings, customer_match):
|
||||
classification = make_classification(
|
||||
action="move_customer",
|
||||
confidence=0.95,
|
||||
requires_human_review=False,
|
||||
)
|
||||
final_action, overridden = classifier.decide(customer_match, classification, settings)
|
||||
assert final_action == "move_customer"
|
||||
assert overridden is False
|
||||
|
||||
|
||||
def test_escalate_not_overridden_by_low_confidence(settings, customer_match):
|
||||
"""Escalate action should survive even low confidence — it's already going to human review."""
|
||||
classification = make_classification(
|
||||
action="escalate",
|
||||
confidence=0.40,
|
||||
requires_human_review=True,
|
||||
)
|
||||
final_action, overridden = classifier.decide(customer_match, classification, settings)
|
||||
assert final_action == "escalate"
|
||||
|
||||
|
||||
def test_flag_follow_up_survives_requires_human_review(settings, unknown_match):
|
||||
"""flag_follow_up is an acceptable requires_human_review action — should not be overridden."""
|
||||
classification = make_classification(
|
||||
action="flag_follow_up",
|
||||
confidence=0.80,
|
||||
requires_human_review=True,
|
||||
)
|
||||
final_action, overridden = classifier.decide(unknown_match, classification, settings)
|
||||
assert final_action == "flag_follow_up"
|
||||
Loading…
Add table
Add a link
Reference in a new issue