Skip to content

Commit 522780f

Browse files
authored
Merge pull request #36 from praekeltfoundation/updates-for-mcxp
Implement intent classification and Turn message update tasks with er…
2 parents 31a74ef + 3ff0732 commit 522780f

2 files changed

Lines changed: 288 additions & 30 deletions

File tree

src/tasks.py

Lines changed: 133 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,150 @@
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:
27+
logger.exception("Failed to load intent classifier")
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+
52+
Raises:
53+
ValueError: Classifier not loaded or invalid input
54+
"""
55+
# Validate classifier is loaded
56+
if classifier is None:
57+
error_msg = "Intent classifier not loaded. Cannot classify message."
58+
logger.error(f"{error_msg} Message ID: {message_id}")
59+
raise ValueError(error_msg)
60+
61+
try:
62+
# Step 1: Classify the message
63+
logger.info(f"Classifying message {message_id}: {message_text[:100]}...")
64+
intent, confidence = classifier.classify(message_text)
65+
logger.info(
66+
f"Classified message {message_id} as '{intent}' with confidence {confidence:.4f}"
67+
)
68+
69+
return {
70+
"message_id": message_id,
71+
"intent": intent,
72+
"confidence": confidence,
73+
}
74+
except Exception as e:
75+
# Log other errors but don't retry (likely classification or client errors)
76+
logger.error(
77+
f"Error processing message {message_id}: {e}",
78+
exc_info=True,
79+
)
80+
raise
81+
982

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

25144

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

tests/test_tasks.py

Lines changed: 155 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,150 @@ 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+
89+
# Mock Turn API client
90+
mock_turn_client = mocker.Mock()
91+
mock_turn_client.update_message_label.return_value = {
92+
"status": "success",
93+
"message_id": "msg-123",
94+
}
95+
mocker.patch("src.tasks.TurnAPIClient", return_value=mock_turn_client)
96+
97+
# Execute task
98+
result = update_turn_message_label(
99+
{"message_id": "msg-123", "intent": "compliment"}
100+
)
101+
102+
# Verify Turn API was called
103+
mock_turn_client.update_message_label.assert_called_once_with(
104+
"msg-123", "compliment"
105+
)
106+
107+
# Verify result
108+
assert result["message_id"] == "msg-123"
109+
assert result["intent"] == "compliment"
110+
assert result["turn_response"]["status"] == "success"
111+
112+
def test_update_turn_label_client_error(self, mocker, monkeypatch):
113+
"""Test that 4xx client errors do NOT trigger retry."""
114+
# Set up Turn API env vars
115+
monkeypatch.setenv("TURN_API_BASE_URL", "https://api.turn.io")
116+
monkeypatch.setenv("TURN_API_TOKEN", "test-token")
117+
118+
# Mock Turn API client to raise client error (4xx)
119+
mock_turn_client = mocker.Mock()
120+
mock_turn_client.update_message_label.side_effect = TurnAPIClientError(
121+
"Turn API client error (HTTP 400): Invalid label"
122+
)
123+
mocker.patch("src.tasks.TurnAPIClient", return_value=mock_turn_client)
124+
125+
# Execute task - should raise TurnAPIClientError without retry
126+
with pytest.raises(TurnAPIClientError, match="Turn API client error"):
127+
update_turn_message_label({"message_id": "msg-400", "intent": "greeting"})
128+
129+
# Verify Turn API was called only once (no retry)
130+
mock_turn_client.update_message_label.assert_called_once()
131+
132+
def test_update_turn_label_server_error_retry(self, mocker, monkeypatch):
133+
"""Test that 5xx server errors trigger automatic retry."""
134+
# Set up Turn API env vars
135+
monkeypatch.setenv("TURN_API_BASE_URL", "https://api.turn.io")
136+
monkeypatch.setenv("TURN_API_TOKEN", "test-token")
137+
138+
# Mock Turn API client to raise server error (5xx)
139+
mock_turn_client = mocker.Mock()
140+
mock_turn_client.update_message_label.side_effect = TurnAPIServerError(
141+
"Turn API server error (HTTP 500): Internal server error"
142+
)
143+
mocker.patch("src.tasks.TurnAPIClient", return_value=mock_turn_client)
144+
145+
# Execute task - should raise TurnAPIServerError (retry will be handled by Celery)
146+
with pytest.raises(TurnAPIServerError, match="Turn API server error"):
147+
update_turn_message_label({"message_id": "msg-500", "intent": "question"})
148+
149+
# Verify Turn API was called
150+
mock_turn_client.update_message_label.assert_called_once()
151+
152+
153+
class TestClassifyAndUpdateChain:
154+
"""Tests for the classify and update chain helper."""
155+
156+
def test_build_chain_signatures(self):
157+
"""Ensure chain is composed of classify then update tasks."""
158+
chain_sig = build_classify_and_update_chain("msg-123", "Hello there")
159+
160+
assert len(chain_sig.tasks) == 2
161+
assert chain_sig.tasks[0].name == classify_turn_message.name
162+
assert chain_sig.tasks[1].name == update_turn_message_label.name
163+
assert chain_sig.tasks[0].args == ("msg-123", "Hello there")
164+
assert chain_sig.tasks[1].args == ()
165+
166+
def test_chain_passes_result_to_update(self, mocker):
167+
"""Ensure classifier result is passed to update_turn_message_label."""
168+
mock_classifier = mocker.Mock()
169+
mock_classifier.classify.return_value = ("compliment", 0.95)
170+
mocker.patch("src.tasks.classifier", mock_classifier)
171+
172+
mock_update = mocker.patch(
173+
"src.tasks.update_turn_message_label.run",
174+
return_value={"status": "success"},
175+
)
176+
177+
chain_sig = build_classify_and_update_chain("msg-123", "Thank you!")
178+
chain_sig.apply()
179+
180+
mock_update.assert_called_once_with(
181+
{"message_id": "msg-123", "intent": "compliment", "confidence": 0.95}
182+
)

0 commit comments

Comments
 (0)