Подготовить AI Lead Processor и проверить обработку заявок
This commit is contained in:
commit
634490174f
22 files changed
+1812
No files matched your search
@@ -0,0 +1,25 @@
|
||||
from pathlib import Path
|
||||
|
||||
from streamlit.testing.v1 import AppTest
|
||||
|
||||
|
||||
def test_manager_reviews_and_saves(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("LEADS_FILE", str(tmp_path / "leads.xlsx"))
|
||||
app = AppTest.from_file(str(Path(__file__).resolve().parents[1] / "app.py"), default_timeout=30).run()
|
||||
app.radio(key="mode").set_value("DEMO").run()
|
||||
app.button(key="process").click().run()
|
||||
assert app.error
|
||||
app.text_area(key="lead").set_value("Течёт стиральная машина, вода на полу").run()
|
||||
app.button(key="process").click().run()
|
||||
assert app.selectbox[0].value == "urgent"
|
||||
app.selectbox[0].set_value("normal").run()
|
||||
app.text_area[1].set_value("Уточните модель машины.").run()
|
||||
app.button(key="save").click().run()
|
||||
assert not app.exception
|
||||
from src.storage import snapshot
|
||||
rows, content = snapshot(tmp_path / "leads.xlsx")
|
||||
assert rows[0]["Категория"] == "Не срочно"
|
||||
assert rows[0]["Черновик ответа"] == "Уточните модель машины."
|
||||
assert rows[0]["Режим обработки"] == "DEMO"
|
||||
assert content.startswith(b"PK")
|
||||
assert not any(button.key == "save" for button in app.button)
|
||||
@@ -0,0 +1,103 @@
|
||||
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()
|
||||
@@ -0,0 +1,41 @@
|
||||
import unittest
|
||||
|
||||
from src.economics import calculate, estimate_request_usd
|
||||
|
||||
|
||||
class EconomicsTests(unittest.TestCase):
|
||||
def test_weekly_and_monthly_capacity(self):
|
||||
result = calculate()
|
||||
self.assertEqual((result.before_hours, result.after_hours), (8, 3))
|
||||
self.assertEqual(result.saved_hours, 5)
|
||||
self.assertAlmostEqual(result.monthly_saved_hours, 21.65)
|
||||
self.assertEqual(result.weekly_value_rub, 2500)
|
||||
self.assertAlmostEqual(result.monthly_value_rub, 10825)
|
||||
|
||||
def test_costs_and_net_capacity(self):
|
||||
result = calculate()
|
||||
self.assertAlmostEqual(result.request_usd, 0.00065)
|
||||
self.assertAlmostEqual(result.weekly_api_usd, 0.078)
|
||||
self.assertAlmostEqual(result.monthly_api_usd, 0.33774)
|
||||
self.assertAlmostEqual(result.weekly_net_rub, 2492.98)
|
||||
self.assertAlmostEqual(result.monthly_net_rub, 10794.6034)
|
||||
|
||||
def test_discount_only_for_cached_input(self):
|
||||
price = estimate_request_usd("gpt-5-mini-2025-08-07", 1200, 100, 1024)
|
||||
self.assertAlmostEqual(price, (176 * 0.25 + 1024 * 0.025 + 100 * 2) / 1e6)
|
||||
|
||||
def test_unknown_tariff_or_missing_usage(self):
|
||||
self.assertIsNone(estimate_request_usd("another-model", 100, 200))
|
||||
self.assertIsNone(estimate_request_usd("gpt-5-mini", None, 200))
|
||||
|
||||
def test_invalid_usage(self):
|
||||
with self.assertRaises(ValueError):
|
||||
estimate_request_usd("gpt-5-mini", 100, 200, 101)
|
||||
|
||||
def test_slow_review_can_give_negative_effect(self):
|
||||
self.assertLess(calculate(review_minutes=5).weekly_net_rub, 0)
|
||||
self.assertEqual(calculate(leads=0).weekly_net_rub, 0)
|
||||
|
||||
def test_invalid_assumption(self):
|
||||
with self.assertRaises(ValueError):
|
||||
calculate(manual_minutes=-1)
|
||||
@@ -0,0 +1,72 @@
|
||||
import unittest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from src import storage
|
||||
|
||||
|
||||
class StorageTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
directory = TemporaryDirectory()
|
||||
self.addCleanup(directory.cleanup)
|
||||
self.path = Path(directory.name) / "входящие" / "leads.xlsx"
|
||||
|
||||
def test_empty_snapshot_creates_downloadable_workbook(self):
|
||||
rows, data = storage.snapshot(self.path)
|
||||
self.assertEqual(rows, [])
|
||||
self.assertEqual(data, self.path.read_bytes())
|
||||
sheet = load_workbook(BytesIO(data)).active
|
||||
self.assertEqual(sheet["C1"].value, "Текст заявки")
|
||||
self.assertEqual(sheet["L1"].value, "Оценочная стоимость API-запроса, USD")
|
||||
|
||||
def test_retry_keeps_one_row_with_literal_text(self):
|
||||
row = {"ID": str(uuid4()), "Текст заявки": "=1+2\nПривет!", "Входные токены": 0}
|
||||
self.assertTrue(storage.append(self.path, row))
|
||||
self.assertFalse(storage.append(self.path, row))
|
||||
rows, data = storage.snapshot(self.path)
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual({key: rows[0][key] for key in row}, row)
|
||||
self.assertEqual(load_workbook(BytesIO(data)).active["C2"].data_type, "s")
|
||||
|
||||
def test_parallel_writers_preserve_all_rows(self):
|
||||
ids = [str(uuid4()) for _ in range(6)]
|
||||
with ThreadPoolExecutor(max_workers=4) as executor:
|
||||
results = executor.map(lambda key: storage.append(
|
||||
self.path, {"ID": key, "Текст заявки": "Здравствуйте!"}
|
||||
), ids)
|
||||
self.assertTrue(all(results))
|
||||
rows, _ = storage.snapshot(self.path)
|
||||
self.assertCountEqual([row["ID"] for row in rows], ids)
|
||||
|
||||
def test_bad_input_preserves_original_bytes(self):
|
||||
_, original = storage.snapshot(self.path)
|
||||
for value in [" ", "x\x00y", "ю" * 32768]:
|
||||
with self.subTest(length=len(value)), self.assertRaises(ValueError):
|
||||
storage.append(self.path, {"ID": str(uuid4()), "Текст заявки": value})
|
||||
self.assertEqual(original, self.path.read_bytes())
|
||||
|
||||
def test_corrupt_file_is_preserved(self):
|
||||
self.path.parent.mkdir()
|
||||
self.path.write_bytes(b"corrupt")
|
||||
with self.assertRaises(ValueError):
|
||||
storage.append(self.path, {"ID": str(uuid4()), "Текст заявки": "тест"})
|
||||
self.assertEqual(self.path.read_bytes(), b"corrupt")
|
||||
|
||||
def test_locked_excel_file_can_be_retried_without_losing_rows(self):
|
||||
first = {"ID": str(uuid4()), "Текст заявки": "Первая заявка"}
|
||||
second = {"ID": str(uuid4()), "Текст заявки": "Вторая заявка"}
|
||||
storage.append(self.path, first)
|
||||
original = self.path.read_bytes()
|
||||
with patch("src.storage.os.replace", side_effect=PermissionError):
|
||||
with self.assertRaises(PermissionError):
|
||||
storage.append(self.path, second)
|
||||
self.assertEqual(self.path.read_bytes(), original)
|
||||
self.assertTrue(storage.append(self.path, second))
|
||||
self.assertEqual([row["ID"] for row in storage.snapshot(self.path)[0]],
|
||||
[first["ID"], second["ID"]])
|
||||
Reference in new issue
Block a user