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)