Skip to content

Commit ebc2416

Browse files
Merge pull request #99 from raccoongang/feature/delegate-orchestrator-instantionation
feat: centralize orchestrator instantiation via BaseOrchestrator
2 parents be2cb0c + d4f0472 commit ebc2416

File tree

3 files changed

+234
-17
lines changed

3 files changed

+234
-17
lines changed

backend/openedx_ai_extensions/workflows/models.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from django.utils.functional import cached_property
1717
from opaque_keys.edx.django.models import CourseKeyField, UsageKeyField
1818

19+
from openedx_ai_extensions.workflows.orchestrators import BaseOrchestrator
1920
from openedx_ai_extensions.workflows.template_utils import (
2021
get_effective_config,
2122
parse_json5_string,
@@ -279,17 +280,17 @@ def execute(self, user_input, action, user, running_context) -> dict[str, str |
279280

280281
try:
281282
# Load the orchestrator for this workflow
282-
from openedx_ai_extensions.workflows import orchestrators # pylint: disable=import-outside-toplevel
283-
284-
orchestrator_name = self.profile.orchestrator_class # "DirectLLMResponse"
285-
orchestrator_class = getattr(orchestrators, orchestrator_name)
286-
orchestrator = orchestrator_class(workflow=self, user=user, context=running_context)
283+
orchestrator = BaseOrchestrator.get_orchestrator(
284+
workflow=self,
285+
user=user,
286+
context=running_context,
287+
)
287288

288289
self.action = action
289290

290291
if not hasattr(orchestrator, action):
291292
raise NotImplementedError(
292-
f"Orchestrator '{orchestrator_name}' does not implement action '{action}'"
293+
f"Orchestrator '{self.profile.orchestrator_class}' does not implement action '{action}'"
293294
)
294295
result = getattr(orchestrator, action)(user_input)
295296

backend/openedx_ai_extensions/workflows/orchestrators.py

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -66,18 +66,20 @@ def _execute_orchestrator_async(task_self, session_id, action, params=None):
6666
'location_id': str(session.location_id),
6767
}
6868

69-
# 3. Get orchestrator class and instantiate from current module's globals
69+
# 3. Resolve and instantiate orchestrator via centralized factory
7070
orchestrator_name = session.profile.orchestrator_class
71-
orchestrator_class = sys.modules[__name__].__dict__.get(orchestrator_name)
72-
if not orchestrator_class:
73-
error_msg = f"Orchestrator class '{orchestrator_name}' not found in module"
74-
logger.error(f"Task {task_id}: {error_msg}")
75-
raise AttributeError(error_msg)
76-
orchestrator = orchestrator_class(
77-
workflow=session.scope,
78-
user=session.user,
79-
context=context
80-
)
71+
try:
72+
orchestrator = BaseOrchestrator.get_orchestrator(
73+
workflow=session.scope,
74+
user=session.user,
75+
context=context,
76+
)
77+
except (AttributeError, TypeError) as exc:
78+
logger.error(
79+
f"Task {task_id}: Failed to resolve orchestrator: {exc}",
80+
exc_info=True,
81+
)
82+
raise
8183

8284
# 4. Validate action exists
8385
if not hasattr(orchestrator, action):
@@ -146,6 +148,49 @@ def _emit_workflow_event(self, event_name: str) -> None:
146148
def run(self, input_data):
147149
raise NotImplementedError("Subclasses must implement run method")
148150

151+
@classmethod
152+
def get_orchestrator(cls, *, workflow, user, context):
153+
"""
154+
Resolve and instantiate an orchestrator for the given workflow.
155+
156+
This factory method centralizes orchestrator lookup and validation.
157+
It ensures that the resolved class exists and is a subclass of
158+
BaseOrchestrator, providing a single, consistent entry point
159+
for orchestrator creation across the codebase.
160+
161+
Args:
162+
workflow: AIWorkflowScope instance that defines the workflow configuration.
163+
user: User for whom the workflow is being executed.
164+
context: Dictionary with runtime context (e.g. course_id, location_id).
165+
166+
Returns:
167+
BaseOrchestrator: An instantiated orchestrator for the given workflow.
168+
169+
Raises:
170+
AttributeError: If the configured orchestrator class cannot be found.
171+
TypeError: If the resolved class is not a subclass of BaseOrchestrator.
172+
"""
173+
orchestrator_name = workflow.profile.orchestrator_class
174+
175+
try:
176+
module = sys.modules[__name__]
177+
orchestrator_class = getattr(module, orchestrator_name)
178+
except AttributeError as exc:
179+
raise AttributeError(
180+
f"Orchestrator class '{orchestrator_name}' not found"
181+
) from exc
182+
183+
if not issubclass(orchestrator_class, BaseOrchestrator):
184+
raise TypeError(
185+
f"{orchestrator_name} is not a subclass of BaseOrchestrator"
186+
)
187+
188+
return orchestrator_class(
189+
workflow=workflow,
190+
user=user,
191+
context=context,
192+
)
193+
149194

150195
class MockResponse(BaseOrchestrator):
151196
"""
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
"""
2+
Tests for the BaseOrchestrator class in openedx-ai-extensions workflows module.
3+
"""
4+
5+
from unittest.mock import patch
6+
7+
import pytest
8+
from django.contrib.auth import get_user_model
9+
10+
from openedx_ai_extensions.workflows.orchestrators import BaseOrchestrator
11+
12+
User = get_user_model()
13+
14+
15+
# ============================================================================
16+
# Fixtures
17+
# ============================================================================
18+
19+
@pytest.fixture
20+
def mock_user(db): # pylint: disable=unused-argument
21+
"""
22+
Create a test user.
23+
"""
24+
return User.objects.create_user(
25+
username="testuser2", email="[email protected]", password="password123"
26+
)
27+
28+
29+
@pytest.fixture
30+
def mock_workflow_profile():
31+
"""
32+
Create a fake workflow profile object with orchestrator_class attribute.
33+
"""
34+
class Profile:
35+
slug = "mock-profile"
36+
orchestrator_class = "MockOrchestrator"
37+
38+
return Profile()
39+
40+
41+
@pytest.fixture
42+
def mock_workflow(mock_workflow_profile): # pylint: disable=redefined-outer-name
43+
"""
44+
Create a fake workflow object with profile and action attributes.
45+
"""
46+
class Workflow:
47+
id = 123
48+
profile = mock_workflow_profile
49+
action = "test_action"
50+
51+
return Workflow()
52+
53+
54+
# ============================================================================
55+
# BaseOrchestrator Initialization Tests
56+
# ============================================================================
57+
58+
@pytest.mark.django_db
59+
def test_base_orchestrator_init(mock_workflow, mock_user): # pylint: disable=redefined-outer-name
60+
"""
61+
Test that BaseOrchestrator initializes attributes correctly.
62+
"""
63+
context = {"location_id": "loc-1", "course_id": "course-1"}
64+
orchestrator = BaseOrchestrator(workflow=mock_workflow, user=mock_user, context=context)
65+
66+
assert orchestrator.workflow == mock_workflow
67+
assert orchestrator.user == mock_user
68+
assert orchestrator.profile == mock_workflow.profile
69+
assert orchestrator.location_id == "loc-1"
70+
assert orchestrator.course_id == "course-1"
71+
72+
73+
# ============================================================================
74+
# _emit_workflow_event Tests
75+
# ============================================================================
76+
77+
@pytest.mark.django_db
78+
@patch("openedx_ai_extensions.workflows.orchestrators.tracker")
79+
def test_emit_workflow_event(mock_tracker, mock_workflow, mock_user): # pylint: disable=redefined-outer-name
80+
"""
81+
Test that _emit_workflow_event calls tracker.emit with correct payload.
82+
"""
83+
context = {"location_id": "loc-1", "course_id": "course-1"}
84+
orchestrator = BaseOrchestrator(workflow=mock_workflow, user=mock_user, context=context)
85+
86+
orchestrator._emit_workflow_event("TEST_EVENT") # pylint: disable=protected-access
87+
88+
mock_tracker.emit.assert_called_once_with("TEST_EVENT", {
89+
"workflow_id": str(mock_workflow.id),
90+
"action": mock_workflow.action,
91+
"course_id": str("course-1"),
92+
"profile_name": mock_workflow.profile.slug,
93+
"location_id": str("loc-1"),
94+
})
95+
96+
97+
# ============================================================================
98+
# run Method Tests
99+
# ============================================================================
100+
101+
@pytest.mark.django_db
102+
def test_base_orchestrator_run_raises_not_implemented(mock_workflow, mock_user): # pylint: disable=redefined-outer-name
103+
"""
104+
Test that calling run on BaseOrchestrator raises NotImplementedError.
105+
"""
106+
orchestrator = BaseOrchestrator(workflow=mock_workflow, user=mock_user, context={})
107+
with pytest.raises(NotImplementedError):
108+
orchestrator.run({})
109+
110+
111+
# ============================================================================
112+
# get_orchestrator Classmethod Tests
113+
# ============================================================================
114+
115+
@pytest.mark.django_db
116+
def test_get_orchestrator_success(monkeypatch, mock_workflow, mock_user): # pylint: disable=redefined-outer-name
117+
"""
118+
Test get_orchestrator returns an instance of the resolved class.
119+
"""
120+
from openedx_ai_extensions.workflows import orchestrators # pylint: disable=import-outside-toplevel
121+
122+
class MockOrchestrator(BaseOrchestrator):
123+
def run(self, input_data):
124+
return {"status": "ok"}
125+
126+
monkeypatch.setitem(orchestrators.__dict__, "MockOrchestrator", MockOrchestrator)
127+
128+
context = {"location_id": "loc-1", "course_id": "course-1"}
129+
orchestrator = BaseOrchestrator.get_orchestrator(
130+
workflow=mock_workflow,
131+
user=mock_user,
132+
context=context
133+
)
134+
135+
assert isinstance(orchestrator, MockOrchestrator)
136+
assert orchestrator.workflow == mock_workflow
137+
assert orchestrator.user == mock_user
138+
139+
140+
@pytest.mark.django_db
141+
def test_get_orchestrator_attribute_error(mock_workflow, mock_user): # pylint: disable=redefined-outer-name
142+
"""
143+
Test get_orchestrator raises AttributeError when class does not exist.
144+
"""
145+
mock_workflow.profile.orchestrator_class = "NonExistingClass"
146+
context = {"location_id": None, "course_id": None}
147+
148+
with pytest.raises(AttributeError) as exc_info:
149+
BaseOrchestrator.get_orchestrator(workflow=mock_workflow, user=mock_user, context=context)
150+
151+
assert "NonExistingClass" in str(exc_info.value)
152+
153+
154+
@pytest.mark.django_db
155+
def test_get_orchestrator_type_error(monkeypatch, mock_workflow, mock_user): # pylint: disable=redefined-outer-name
156+
"""
157+
Test get_orchestrator raises TypeError when resolved class is not a subclass of BaseOrchestrator.
158+
"""
159+
from openedx_ai_extensions.workflows import orchestrators # pylint: disable=import-outside-toplevel
160+
161+
class NotAnOrchestrator:
162+
pass
163+
164+
monkeypatch.setitem(orchestrators.__dict__, "MockOrchestrator", NotAnOrchestrator)
165+
166+
context = {"location_id": None, "course_id": None}
167+
168+
with pytest.raises(TypeError) as exc_info:
169+
BaseOrchestrator.get_orchestrator(workflow=mock_workflow, user=mock_user, context=context)
170+
171+
assert "MockOrchestrator is not a subclass of BaseOrchestrator" in str(exc_info.value)

0 commit comments

Comments
 (0)