howl/tests/test_llm_client.py
lorentz 3bfda9e585 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>
2026-04-01 16:02:24 -04:00

133 lines
4.7 KiB
Python

from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from howl.llm.schemas import CLASSIFY_EMAIL_TOOL, EmailClassification
from howl.llm.prompts import build_prompt, SYSTEM_PROMPT
FIXTURES_DIR = Path(__file__).parent / "fixtures"
def test_email_classification_validates_valid_data(llm_responses):
cls = EmailClassification.model_validate(llm_responses["customer_inquiry"])
assert cls.classification == "customer_inquiry"
assert cls.action == "move_customer"
assert 0.0 <= cls.confidence <= 1.0
assert cls.reasoning
def test_email_classification_rejects_invalid_confidence():
with pytest.raises(Exception):
EmailClassification.model_validate({
"classification": "unknown",
"confidence": 1.5, # Out of range
"action": "inbox_keep",
"reasoning": "test",
"priority": "normal",
"requires_human_review": False,
})
def test_email_classification_requires_human_review_for_phishing(llm_responses):
cls = EmailClassification.model_validate(llm_responses["phishing"])
assert cls.requires_human_review is True
def test_email_classification_low_confidence(llm_responses):
cls = EmailClassification.model_validate(llm_responses["low_confidence"])
assert cls.confidence < 0.60
assert cls.requires_human_review is True
def test_build_prompt_includes_sender_info(customer_message, customer_match):
prompt = build_prompt(customer_message, "Hello, checking on my order.", customer_match)
assert customer_message.sender_address in prompt
assert "customer" in prompt
assert "Acme Corp" in prompt
assert "Key account" in prompt
def test_build_prompt_truncates_body(customer_message, unknown_match):
long_body = "x" * 10000
prompt = build_prompt(customer_message, long_body, unknown_match, max_body_chars=100)
assert "truncated" in prompt
# Body in prompt should be 100 chars, not 10000
assert "x" * 101 not in prompt
def test_build_prompt_unknown_sender(spam_message, unknown_match):
prompt = build_prompt(spam_message, "Claim your prize!", unknown_match)
assert "unknown" in prompt
# No entity name should appear
assert "Known as:" not in prompt
def test_classify_email_tool_schema_is_valid():
"""Tool definition should have all required fields for Anthropic tool use."""
assert CLASSIFY_EMAIL_TOOL["name"] == "classify_email"
schema = CLASSIFY_EMAIL_TOOL["input_schema"]
assert schema["type"] == "object"
required = schema["required"]
assert "classification" in required
assert "confidence" in required
assert "action" in required
assert "reasoning" in required
assert "requires_human_review" in required
def test_llm_client_calls_anthropic_and_parses_response(
settings, customer_message, customer_match
):
"""Test that LLMClient correctly calls the Anthropic SDK and parses tool use output."""
from howl.llm.client import LLMClient
mock_tool_use = MagicMock()
mock_tool_use.type = "tool_use"
mock_tool_use.input = {
"classification": "customer_inquiry",
"confidence": 0.92,
"action": "move_customer",
"reasoning": "Known customer inquiry.",
"priority": "normal",
"requires_human_review": False,
"tags": [],
}
mock_response = MagicMock()
mock_response.content = [mock_tool_use]
mock_response.model = "claude-sonnet-4-6"
mock_response.stop_reason = "tool_use"
mock_response.usage.input_tokens = 300
mock_response.usage.output_tokens = 75
with patch("howl.llm.client.Anthropic") as MockAnthropic:
MockAnthropic.return_value.messages.create.return_value = mock_response
client = LLMClient(settings)
classification, raw = client.classify(
customer_message, "Hello, checking my order status.", customer_match
)
assert classification.classification == "customer_inquiry"
assert classification.action == "move_customer"
assert raw["usage"]["input_tokens"] == 300
def test_llm_client_raises_if_no_tool_use(settings, customer_message, customer_match):
"""LLMClient should raise ValueError if Claude doesn't call the tool."""
from howl.llm.client import LLMClient
mock_text_block = MagicMock()
mock_text_block.type = "text"
mock_response = MagicMock()
mock_response.content = [mock_text_block]
with patch("howl.llm.client.Anthropic") as MockAnthropic:
MockAnthropic.return_value.messages.create.return_value = mock_response
client = LLMClient(settings)
with pytest.raises(ValueError, match="classify_email"):
client.classify(customer_message, "body", customer_match)