134 lines
4.7 KiB
Python
134 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)
|