Skip to content

Commit 0528e6a

Browse files
committed
Implement intent classification and Turn message update tasks with error handling
1 parent 402594f commit 0528e6a

2 files changed

Lines changed: 270 additions & 30 deletions

File tree

src/tasks.py

Lines changed: 132 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,149 @@
11
"""Celery tasks for async processing."""
22

33
import logging
4+
from pathlib import Path
5+
6+
from celery import chain
47

58
from src.celery_app import celery_app
9+
from src.intent_classifier import IntentClassifier
10+
from src.turn_client import TurnAPIClient, TurnAPIServerError
611

712
logger = logging.getLogger(__name__)
813

14+
# Load classifier once at module level for efficiency
15+
# Model is large and we're classifying thousands of messages
16+
_DATA_DIR = Path(__file__).parent / "data"
17+
_EMBEDDINGS_PATH = _DATA_DIR / "intent_embeddings.json"
18+
_NLU_PATH = _DATA_DIR / "nlu.yaml"
19+
20+
try:
21+
classifier = IntentClassifier(
22+
embeddings_path=_EMBEDDINGS_PATH,
23+
nlu_path=_NLU_PATH,
24+
)
25+
logger.info("Intent classifier loaded successfully")
26+
except Exception as e:
27+
logger.error(f"Failed to load intent classifier: {e}")
28+
classifier = None
29+
30+
31+
@celery_app.task(
32+
acks_late=True, # Only ack after task completes (safer for worker crashes)
33+
)
34+
def classify_turn_message(message_id: str, message_text: str) -> dict:
35+
"""
36+
Classify incoming message text.
37+
38+
This task:
39+
1. Classifies the message text using the IntentClassifier
40+
2. Does NOT call the Turn API (no label update here)
41+
42+
Args:
43+
message_id: Turn message ID to classify
44+
message_text: Message text to classify
45+
46+
Returns:
47+
dict: Classification result with keys:
48+
- message_id: The Turn message ID
49+
- intent: Classified intent label
50+
- confidence: Classification confidence score
51+
- error: Optional error string
52+
53+
Raises:
54+
ValueError: Classifier not loaded or invalid input
55+
"""
56+
# Validate classifier is loaded
57+
if classifier is None:
58+
error_msg = "Intent classifier not loaded. Cannot classify message."
59+
logger.error(f"{error_msg} Message ID: {message_id}")
60+
raise ValueError(error_msg)
61+
62+
try:
63+
# Step 1: Classify the message
64+
logger.info(f"Classifying message {message_id}: {message_text[:100]}...")
65+
intent, confidence = classifier.classify(message_text)
66+
logger.info(
67+
f"Classified message {message_id} as '{intent}' with confidence {confidence:.4f}"
68+
)
69+
70+
return {
71+
"message_id": message_id,
72+
"intent": intent,
73+
"confidence": confidence,
74+
}
75+
except Exception as e:
76+
# Log other errors but don't retry (likely classification or client errors)
77+
logger.error(
78+
f"Error processing message {message_id}: {e}",
79+
exc_info=True,
80+
)
81+
raise
82+
983

10-
@celery_app.task(name="src.tasks.hello_world")
11-
def hello_world(name: str = "World") -> dict:
84+
@celery_app.task(
85+
acks_late=True, # Only ack after task completes (safer for worker crashes)
86+
autoretry_for=(TurnAPIServerError,), # Retry on 5xx server errors
87+
max_retries=3,
88+
retry_backoff=True, # Exponential backoff
89+
retry_backoff_max=600, # Max 10 minutes between retries
90+
retry_jitter=True, # Add randomness to prevent thundering herd
91+
)
92+
def update_turn_message_label(classification: dict) -> dict:
1293
"""
13-
Simple hello world task for testing Celery connection.
94+
Update Turn message label.
95+
96+
This task:
97+
1. Updates the message label in Turn via the API
98+
2. Automatically retries on server errors (5xx) with exponential backoff
99+
3. Does NOT retry on client errors (4xx) - bad data won't improve on retry
14100
15101
Args:
16-
name: Name to greet (default: "World")
102+
classification: dict with keys message_id and intent
17103
18104
Returns:
19-
dict: Status message with greeting
105+
dict: Update result with keys:
106+
- message_id: The Turn message ID
107+
- intent: Intent label sent to Turn
108+
- turn_response: Response from Turn API
109+
110+
Raises:
111+
TurnAPIServerError: Turn API server error (5xx) - will auto-retry
112+
TurnAPIClientError: Turn API client error (4xx) - will NOT retry
20113
"""
21-
message = f"Hello, {name}!"
22-
logger.info(f"Hello world task executed: {message}")
23-
return {"status": "success", "message": message}
114+
message_id = classification["message_id"]
115+
intent = classification["intent"]
116+
117+
try:
118+
turn_client = TurnAPIClient()
119+
turn_response = turn_client.update_message_label(message_id, intent)
120+
121+
logger.info(f"Successfully updated Turn message {message_id} with label: {intent}")
122+
123+
return {
124+
"message_id": message_id,
125+
"intent": intent,
126+
"turn_response": turn_response,
127+
}
128+
except TurnAPIServerError:
129+
# Re-raise server errors to trigger retry
130+
logger.warning(
131+
f"Turn API server error for message {message_id}. "
132+
f"Retry {update_turn_message_label.request.retries}/{update_turn_message_label.max_retries}"
133+
)
134+
raise
135+
except Exception as e:
136+
# Log other errors but don't retry (likely client errors)
137+
logger.error(
138+
f"Error updating Turn message {message_id}: {e}",
139+
exc_info=True,
140+
)
141+
raise
24142

25143

26-
# Future tasks will be added here:
27-
# - classify_and_update_turn: Main classification task that calls Turn API
144+
def build_classify_and_update_chain(message_id: str, message_text: str):
145+
"""Build a Celery chain that classifies then updates the Turn label."""
146+
return chain(
147+
classify_turn_message.s(message_id, message_text),
148+
update_turn_message_label.s(),
149+
)

tests/test_tasks.py

Lines changed: 138 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,20 @@
11
"""Tests for Celery tasks."""
22

3+
import pytest
4+
35
from src.celery_app import celery_app
4-
from src.tasks import hello_world
6+
from src.tasks import (
7+
build_classify_and_update_chain,
8+
classify_turn_message,
9+
update_turn_message_label,
10+
)
11+
from src.turn_client import TurnAPIClientError, TurnAPIServerError
512

613
# Enable eager mode for all tests in this module (run tasks synchronously)
714
celery_app.conf.task_always_eager = True
815
celery_app.conf.task_eager_propagates = True
916

1017

11-
class TestHelloWorldTask:
12-
"""Tests for the hello_world task."""
13-
14-
def test_hello_world_default(self):
15-
"""Test hello world task with default parameter."""
16-
result = hello_world()
17-
assert result == {"status": "success", "message": "Hello, World!"}
18-
19-
def test_hello_world_custom_name(self):
20-
"""Test hello world task with custom name."""
21-
result = hello_world("Celery")
22-
assert result == {"status": "success", "message": "Hello, Celery!"}
23-
24-
def test_hello_world_synchronous(self):
25-
"""Test hello world task called synchronously."""
26-
result = hello_world("Testing")
27-
assert result == {"status": "success", "message": "Hello, Testing!"}
28-
29-
3018
class TestCeleryConfiguration:
3119
"""Tests for Celery app configuration."""
3220

@@ -45,3 +33,133 @@ def test_task_serializer_is_json(self):
4533
"""Test that task serializer is set to JSON."""
4634
assert celery_app.conf.task_serializer == "json"
4735
assert "json" in celery_app.conf.accept_content
36+
37+
38+
class TestClassifyTurnMessageTask:
39+
"""Tests for the classify_turn_message task."""
40+
41+
def test_classify_success(self, mocker):
42+
"""Test successful classification."""
43+
# Mock classifier
44+
mock_classifier = mocker.Mock()
45+
mock_classifier.classify.return_value = ("compliment", 0.95)
46+
mocker.patch("src.tasks.classifier", mock_classifier)
47+
48+
# Execute task
49+
result = classify_turn_message("msg-123", "Thank you so much!")
50+
51+
# Verify classifier was called
52+
mock_classifier.classify.assert_called_once_with("Thank you so much!")
53+
54+
# Verify result
55+
assert result["message_id"] == "msg-123"
56+
assert result["intent"] == "compliment"
57+
assert result["confidence"] == 0.95
58+
59+
def test_classify_classifier_not_loaded(self, mocker):
60+
"""Test error when classifier is not loaded."""
61+
# Mock classifier as None (not loaded)
62+
mocker.patch("src.tasks.classifier", None)
63+
64+
# Execute task - should raise ValueError
65+
with pytest.raises(ValueError, match="Intent classifier not loaded"):
66+
classify_turn_message("msg-789", "Hello world")
67+
68+
def test_classify_unclassified_intent(self, mocker):
69+
"""Test handling of unclassified intent."""
70+
# Mock classifier to return Unclassified
71+
mock_classifier = mocker.Mock()
72+
mock_classifier.classify.return_value = ("Unclassified", 0.42)
73+
mocker.patch("src.tasks.classifier", mock_classifier)
74+
75+
# Execute task
76+
result = classify_turn_message("msg-unclass", "asdfghjkl")
77+
78+
# Verify result
79+
assert result["intent"] == "Unclassified"
80+
assert result["confidence"] == 0.42
81+
82+
83+
class TestUpdateTurnMessageLabelTask:
84+
"""Tests for the update_turn_message_label task."""
85+
86+
def test_update_turn_label_success(self, mocker, monkeypatch):
87+
"""Test successful Turn API update."""
88+
# Set up Turn API env vars
89+
monkeypatch.setenv("TURN_API_BASE_URL", "https://api.turn.io")
90+
monkeypatch.setenv("TURN_API_TOKEN", "test-token")
91+
92+
# Mock Turn API client
93+
mock_turn_client = mocker.Mock()
94+
mock_turn_client.update_message_label.return_value = {
95+
"status": "success",
96+
"message_id": "msg-123",
97+
}
98+
mocker.patch("src.tasks.TurnAPIClient", return_value=mock_turn_client)
99+
100+
# Execute task
101+
result = update_turn_message_label(
102+
{"message_id": "msg-123", "intent": "compliment"}
103+
)
104+
105+
# Verify Turn API was called
106+
mock_turn_client.update_message_label.assert_called_once_with(
107+
"msg-123", "compliment"
108+
)
109+
110+
# Verify result
111+
assert result["message_id"] == "msg-123"
112+
assert result["intent"] == "compliment"
113+
assert result["turn_response"]["status"] == "success"
114+
115+
def test_update_turn_label_client_error(self, mocker, monkeypatch):
116+
"""Test that 4xx client errors do NOT trigger retry."""
117+
# Set up Turn API env vars
118+
monkeypatch.setenv("TURN_API_BASE_URL", "https://api.turn.io")
119+
monkeypatch.setenv("TURN_API_TOKEN", "test-token")
120+
121+
# Mock Turn API client to raise client error (4xx)
122+
mock_turn_client = mocker.Mock()
123+
mock_turn_client.update_message_label.side_effect = TurnAPIClientError(
124+
"Turn API client error (HTTP 400): Invalid label"
125+
)
126+
mocker.patch("src.tasks.TurnAPIClient", return_value=mock_turn_client)
127+
128+
# Execute task - should raise TurnAPIClientError without retry
129+
with pytest.raises(TurnAPIClientError, match="Turn API client error"):
130+
update_turn_message_label({"message_id": "msg-400", "intent": "greeting"})
131+
132+
# Verify Turn API was called only once (no retry)
133+
mock_turn_client.update_message_label.assert_called_once()
134+
135+
def test_update_turn_label_server_error_retry(self, mocker, monkeypatch):
136+
"""Test that 5xx server errors trigger automatic retry."""
137+
# Set up Turn API env vars
138+
monkeypatch.setenv("TURN_API_BASE_URL", "https://api.turn.io")
139+
monkeypatch.setenv("TURN_API_TOKEN", "test-token")
140+
141+
# Mock Turn API client to raise server error (5xx)
142+
mock_turn_client = mocker.Mock()
143+
mock_turn_client.update_message_label.side_effect = TurnAPIServerError(
144+
"Turn API server error (HTTP 500): Internal server error"
145+
)
146+
mocker.patch("src.tasks.TurnAPIClient", return_value=mock_turn_client)
147+
148+
# Execute task - should raise TurnAPIServerError (retry will be handled by Celery)
149+
with pytest.raises(TurnAPIServerError, match="Turn API server error"):
150+
update_turn_message_label({"message_id": "msg-500", "intent": "question"})
151+
152+
# Verify Turn API was called
153+
mock_turn_client.update_message_label.assert_called_once()
154+
155+
156+
class TestClassifyAndUpdateChain:
157+
"""Tests for the classify and update chain helper."""
158+
159+
def test_build_chain_signatures(self):
160+
"""Ensure chain is composed of classify then update tasks."""
161+
chain_sig = build_classify_and_update_chain("msg-123", "Hello there")
162+
163+
assert len(chain_sig.tasks) == 2
164+
assert chain_sig.tasks[0].name == classify_turn_message.name
165+
assert chain_sig.tasks[1].name == update_turn_message_label.name

0 commit comments

Comments
 (0)