Skip to content

Commit 3dce58d

Browse files
Add test coverage for dialogue manager (#179)
1 parent f9d8a35 commit 3dce58d

File tree

7 files changed

+262
-18
lines changed

7 files changed

+262
-18
lines changed

app/admin/bots/schemas.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from pydantic import BaseModel, Field
1+
from pydantic import BaseModel, Field, ConfigDict
22
from typing import Dict, Any
33
from app.database import ObjectIdField
44

@@ -10,5 +10,4 @@ class Bot(BaseModel):
1010
name: str
1111
config: Dict[str, Any] = {}
1212

13-
class Config:
14-
arbitrary_types_allowed = True
13+
model_config = ConfigDict(arbitrary_types_allowed=True)

app/admin/entities/schemas.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from pydantic import BaseModel, Field
1+
from pydantic import BaseModel, Field, ConfigDict
22
from typing import List
33
from app.database import ObjectIdField
44

@@ -17,5 +17,4 @@ class Entity(BaseModel):
1717
name: str
1818
entity_values: List[EntityValue] = []
1919

20-
class Config:
21-
arbitrary_types_allowed = True
20+
model_config = ConfigDict(arbitrary_types_allowed=True)

app/admin/intents/schemas.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from app.database import ObjectIdField
2-
from pydantic import BaseModel, Field
2+
from pydantic import BaseModel, Field, ConfigDict
33
from typing import List, Optional, Dict, Any
44
from bson import ObjectId
55

@@ -14,8 +14,7 @@ class LabeledSentences(BaseModel):
1414
id: ObjectIdField = Field(default_factory=generate_object_id)
1515
data: List[str] = []
1616

17-
class Config:
18-
arbitrary_types_allowed = True
17+
model_config = ConfigDict(arbitrary_types_allowed=True)
1918

2019

2120
class Parameter(BaseModel):
@@ -27,8 +26,7 @@ class Parameter(BaseModel):
2726
type: Optional[str] = None
2827
prompt: Optional[str] = None
2928

30-
class Config:
31-
arbitrary_types_allowed = True
29+
model_config = ConfigDict(arbitrary_types_allowed=True)
3230

3331

3432
class ApiDetails(BaseModel):
@@ -61,5 +59,4 @@ class Intent(BaseModel):
6159
labeledSentences: List[LabeledSentences] = []
6260
trainingData: List[Dict[str, Any]] = []
6361

64-
class Config:
65-
arbitrary_types_allowed = True
62+
model_config = ConfigDict(arbitrary_types_allowed=True)

app/bot/dialogue_manager/models.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from typing import Optional, Dict, List, Any
2-
from datetime import datetime
2+
from datetime import datetime, UTC
33
from copy import deepcopy
44
from dataclasses import dataclass
55
from app.admin.intents.schemas import Intent
@@ -104,7 +104,7 @@ def __init__(
104104
self.current_node = current_node
105105
self.parameters = parameters or []
106106
self.owner = owner
107-
self.date = date or datetime.utcnow().isoformat()
107+
self.date = date or datetime.now(UTC).isoformat()
108108

109109
@classmethod
110110
def from_json(cls, request_json: Dict):

requirements.txt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ click==8.1.8
1313
cloudpathlib==0.20.0
1414
cloudpickle==3.1.0
1515
confection==0.1.5
16+
coverage==7.6.10
1617
cymem==2.0.10
1718
distlib==0.3.9
1819
dnspython==2.7.0
@@ -56,7 +57,10 @@ pydantic_core==2.27.2
5657
pyflakes==3.2.0
5758
Pygments==2.19.1
5859
pymongo==4.9.2
59-
pytest==8.3.4
60+
pytest==8.0.0
61+
pytest-asyncio==0.23.5
62+
pytest-cov==4.1.0
63+
pytest-mock==3.12.0
6064
python-crfsuite==0.9.11
6165
python-dateutil==2.9.0.post0
6266
python-dotenv==1.0.1

tests/test_dialogue_manager.py

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
import pytest
2+
from unittest.mock import Mock, patch, AsyncMock
3+
from app.bot.dialogue_manager.dialogue_manager import DialogueManager
4+
from app.bot.dialogue_manager.models import (
5+
ChatModel,
6+
IntentModel,
7+
ParameterModel,
8+
ApiDetailsModel,
9+
)
10+
from app.bot.nlu.pipeline import NLUPipeline
11+
12+
13+
@pytest.fixture
14+
def mock_nlu_pipeline():
15+
pipeline = Mock(spec=NLUPipeline)
16+
pipeline.process.return_value = {
17+
"intent": {"intent": "greet", "confidence": 0.95},
18+
"entities": {},
19+
}
20+
return pipeline
21+
22+
23+
@pytest.fixture
24+
def sample_intents():
25+
greet_intent = IntentModel(
26+
name="Greeting",
27+
intent_id="greet",
28+
parameters=[],
29+
speech_response="Hello!",
30+
api_trigger=False,
31+
api_details=None,
32+
)
33+
34+
order_pizza_intent = IntentModel(
35+
name="Order Pizza",
36+
intent_id="order_pizza",
37+
parameters=[
38+
ParameterModel(
39+
name="size",
40+
type="pizza_size",
41+
required=True,
42+
prompt="What size pizza would you like?",
43+
),
44+
ParameterModel(
45+
name="toppings",
46+
type="pizza_topping",
47+
required=True,
48+
prompt="What toppings would you like?",
49+
),
50+
],
51+
speech_response="Your {{parameters.size}} pizza with {{parameters.toppings}} will be ready soon!",
52+
api_trigger=True,
53+
api_details=ApiDetailsModel(
54+
url="http://pizza-api/order",
55+
request_type="POST",
56+
headers=[{"headerKey": "Content-Type", "headerValue": "application/json"}],
57+
is_json=True,
58+
json_data='{"size": "{{parameters.size}}", "toppings": "{{parameters.toppings}}"}',
59+
),
60+
)
61+
62+
fallback_intent = IntentModel(
63+
name="Fallback",
64+
intent_id="fallback",
65+
parameters=[],
66+
speech_response="I'm not sure I understand.",
67+
api_trigger=False,
68+
api_details=None,
69+
)
70+
71+
cancel_intent = IntentModel(
72+
name="Cancel",
73+
intent_id="cancel",
74+
parameters=[],
75+
speech_response="Operation cancelled.",
76+
api_trigger=False,
77+
api_details=None,
78+
)
79+
80+
return [greet_intent, order_pizza_intent, fallback_intent, cancel_intent]
81+
82+
83+
@pytest.fixture
84+
def dialogue_manager(mock_nlu_pipeline, sample_intents):
85+
return DialogueManager(
86+
intents=sample_intents,
87+
nlu_pipeline=mock_nlu_pipeline,
88+
fallback_intent_id="fallback",
89+
intent_confidence_threshold=0.90,
90+
)
91+
92+
93+
class TestDialogueManager:
94+
@pytest.mark.asyncio
95+
async def test_process_simple_intent(self, dialogue_manager):
96+
chat_model = ChatModel(
97+
input_text="hello", context={}, complete=False, owner="user1"
98+
)
99+
100+
result = await dialogue_manager.process(chat_model)
101+
102+
assert result.complete is True
103+
assert result.speech_response == ["Hello!"]
104+
assert result.intent["id"] == "greet"
105+
106+
@pytest.mark.asyncio
107+
async def test_process_intent_with_parameters(
108+
self, dialogue_manager, mock_nlu_pipeline
109+
):
110+
mock_nlu_pipeline.process.return_value = {
111+
"intent": {"intent": "order_pizza", "confidence": 0.95},
112+
"entities": {"pizza_size": "large"},
113+
}
114+
115+
chat_model = ChatModel(
116+
input_text="I want a large pizza", context={}, complete=False, owner="user1"
117+
)
118+
119+
result = await dialogue_manager.process(chat_model)
120+
121+
assert result.complete is False
122+
assert result.current_node == "toppings"
123+
assert "size" in result.extracted_parameters
124+
assert result.extracted_parameters["size"] == "large"
125+
assert "toppings" in result.missing_parameters
126+
127+
@pytest.mark.asyncio
128+
async def test_fallback_intent_low_confidence(
129+
self, dialogue_manager, mock_nlu_pipeline
130+
):
131+
mock_nlu_pipeline.process.return_value = {
132+
"intent": {"intent": "greet", "confidence": 0.85},
133+
"entities": {},
134+
}
135+
136+
chat_model = ChatModel(
137+
input_text="gibberish text", context={}, complete=False, owner="user1"
138+
)
139+
140+
result = await dialogue_manager.process(chat_model)
141+
142+
assert result.complete is True
143+
assert result.intent["id"] == "fallback"
144+
assert result.speech_response == ["I'm not sure I understand."]
145+
146+
@pytest.mark.asyncio
147+
async def test_cancel_active_intent(self, dialogue_manager, mock_nlu_pipeline):
148+
# First start an intent with parameters
149+
mock_nlu_pipeline.process.return_value = {
150+
"intent": {"intent": "order_pizza", "confidence": 0.95},
151+
"entities": {"pizza_size": "large"},
152+
}
153+
154+
chat_model = ChatModel(
155+
input_text="I want a large pizza", context={}, complete=False, owner="user1"
156+
)
157+
158+
result = await dialogue_manager.process(chat_model)
159+
assert not result.complete
160+
161+
# Then cancel it
162+
mock_nlu_pipeline.process.return_value = {
163+
"intent": {"intent": "cancel", "confidence": 1.0},
164+
"entities": {},
165+
}
166+
167+
result.input_text = "/cancel"
168+
result = await dialogue_manager.process(result)
169+
170+
assert result.complete is True
171+
assert result.intent["id"] == "cancel"
172+
assert len(result.parameters) == 0
173+
assert result.current_node is None
174+
175+
@pytest.mark.asyncio
176+
async def test_free_text_parameter(self, dialogue_manager, mock_nlu_pipeline):
177+
# Setup an intent with a free text parameter
178+
chat_model = ChatModel(
179+
input_text="some text",
180+
context={},
181+
complete=False,
182+
current_node="free_text_param",
183+
owner="user1",
184+
)
185+
186+
# Mock the intent to have a free text parameter
187+
dialogue_manager.intents["test_intent"] = IntentModel(
188+
name="Test Intent",
189+
intent_id="test_intent",
190+
parameters=[
191+
ParameterModel(
192+
name="free_text_param",
193+
type="free_text",
194+
required=True,
195+
prompt="Enter some text",
196+
)
197+
],
198+
speech_response="You entered: {{parameters.free_text_param}}",
199+
api_trigger=False,
200+
api_details=None,
201+
)
202+
203+
chat_model.intent = {"id": "test_intent"}
204+
205+
result = await dialogue_manager.process(chat_model)
206+
207+
assert "free_text_param" in result.extracted_parameters
208+
assert result.extracted_parameters["free_text_param"] == "some text"
209+
210+
@pytest.mark.asyncio
211+
async def test_api_trigger(self, dialogue_manager, mock_nlu_pipeline):
212+
# Mock API call
213+
with patch(
214+
"app.bot.dialogue_manager.dialogue_manager.call_api", new_callable=AsyncMock
215+
) as mock_call_api:
216+
mock_call_api.return_value = {"status": "success"}
217+
218+
# Setup chat model with all required parameters
219+
chat_model = ChatModel(
220+
input_text="pepperoni",
221+
context={},
222+
complete=False,
223+
current_node="toppings",
224+
intent={"id": "order_pizza"},
225+
extracted_parameters={"size": "large"},
226+
owner="user1",
227+
)
228+
229+
mock_nlu_pipeline.process.return_value = {
230+
"intent": {"intent": "order_pizza", "confidence": 0.95},
231+
"entities": {"pizza_topping": "pepperoni"},
232+
}
233+
234+
result = await dialogue_manager.process(chat_model)
235+
236+
assert result.complete is True
237+
assert mock_call_api.called
238+
assert (
239+
"Your large pizza with pepperoni will be ready soon!"
240+
in result.speech_response
241+
)
242+
243+
@pytest.mark.asyncio
244+
async def test_update_model(self, dialogue_manager):
245+
models_dir = "path/to/models"
246+
dialogue_manager.update_model(models_dir)
247+
dialogue_manager.nlu_pipeline.load.assert_called_once_with(models_dir)

tests/test_sample.py

Lines changed: 0 additions & 2 deletions
This file was deleted.

0 commit comments

Comments
 (0)