import csv import unittest from pathlib import Path from unittest.mock import patch import json def classify(text, mode): from src.llm_service import classify as run return run(text, mode) class ClassificationTests(unittest.TestCase): def test_demo_sample_cases(self): path = Path(__file__).resolve().parents[1] / "samples/sample_leads.csv" with path.open(encoding="utf-8-sig", newline="") as sample: for row in csv.DictReader(sample): with self.subTest(text=row["text"]): if row["expected_category"] == "invalid": with self.assertRaises(ValueError): classify(row["text"], "DEMO") continue result = classify(row["text"], "DEMO") self.assertEqual(result.category, row["expected_category"]) self.assertEqual(result.needs_review, row["needs_review"] == "true") self.assertTrue(result.reason.strip()) if result.category != "spam": self.assertTrue(result.draft_reply.strip()) else: self.assertEqual(result.draft_reply, "") self.assertEqual((result.input_tokens, result.output_tokens, result.cached_tokens), (0, 0, 0)) self.assertEqual(result, classify(row["text"], "DEMO")) def test_invalid_inputs(self): for text in (None, " ", "я" * 4001, "a\x00b", "a\uffff", "\udc00"): with self.subTest(text=repr(text)[:20]), self.assertRaises(ValueError): classify(text, "DEMO") with self.assertRaises(ValueError): classify("Здравствуйте", "typo") self.assertTrue(classify("я" * 4000, "DEMO").category) def test_real_requires_key(self): with patch.dict("os.environ", {"OPENAI_API_KEY": ""}), self.assertRaisesRegex(ValueError, "OPENAI_API_KEY"): classify("Нужен счёт", "REAL AI") def run_with_response(self, content, status=200): """Run the installed OpenAI parser with a local HTTP transport.""" import httpx from openai import OpenAI def serve(request): body = json.loads(request.content) self.assertEqual(body["input"], [{"role": "user", "content": "Сломалась плита"}]) self.assertTrue(body["text"]["format"]["strict"]) payload = { "id": "resp_test", "created_at": 1, "object": "response", "status": "completed", "model": "provider-model", "output": [{"id": "msg_test", "type": "message", "status": "completed", "role": "assistant", "content": [content]}], "usage": {"input_tokens": 120, "output_tokens": 31, "total_tokens": 151, "input_tokens_details": {"cached_tokens": 64}, "output_tokens_details": {"reasoning_tokens": 0}}, } if status != 200: payload = {"error": {"message": "private-request-detail", "type": "invalid_request_error"}} return httpx.Response(status, json=payload) def client(**settings): return OpenAI(**settings, http_client=httpx.Client(transport=httpx.MockTransport(serve))) with patch.dict("os.environ", {"OPENAI_API_KEY": "unit-test"}), patch("src.llm_service.OpenAI", client): return classify("Сломалась плита", "REAL AI") def test_real_parses_result_and_provider_usage(self): data = dict(category="normal", reason="Нужен ремонт плиты.", draft_reply="Уточните модель, пожалуйста.", needs_review=False) content = {"type": "output_text", "text": json.dumps(data), "annotations": []} output = self.run_with_response(content) self.assertEqual((output.model, output.input_tokens, output.output_tokens, output.cached_tokens), ("provider-model", 120, 31, 64)) self.assertEqual(output.reason, "Нужен ремонт плиты.") self.assertTrue(output.needs_review) def test_real_rejects_refusal_and_invalid_schema(self): responses = [ {"type": "refusal", "refusal": "Отклонено"}, {"type": "output_text", "text": '{"category": "unknown"}', "annotations": []}, ] for content in responses: with self.subTest(content=content), self.assertRaisesRegex(ValueError, "ответ"): self.run_with_response(content) def test_real_hides_provider_error_detail(self): with self.assertRaisesRegex(ValueError, "401") as caught: self.run_with_response(None, status=401) self.assertNotIn("private-request-detail", str(caught.exception)) def test_unavailable_service_stops_after_one_retry(self): with patch("openai._base_client.time.sleep") as delay: with self.assertRaisesRegex(ValueError, "503"): self.run_with_response(None, status=503) delay.assert_called_once()