Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions backend/openedx_ai_extensions/xblock_service/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""
XBlock runtime service exposing AI workflow capabilities to XBlocks.

Registered under the ``xblock.service.v1`` entry-point group in setup.py, as
proposed in docs/decisions/0011-xblock-service-entry-points.rst.
"""

from openedx_ai_extensions.xblock_service.service import AIExtensionsService

__all__ = ["AIExtensionsService"]
77 changes: 77 additions & 0 deletions backend/openedx_ai_extensions/xblock_service/service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""
The ``ai_extensions`` XBlock runtime service.

Proof of concept for entry-point based service registration (ADR-0011). The
XBlock runtime instantiates this class as
``AIExtensionsService(runtime=runtime, xblock=block)`` when a block that
declared ``@XBlock.needs("ai_extensions")`` / ``@XBlock.wants("ai_extensions")``
calls ``self.runtime.service(self, "ai_extensions")``.

This module must not import ``xblock``: the provider contract is only "a
class instantiable with ``runtime=`` and ``xblock=`` keyword arguments", so
the plugin stays decoupled from the XBlock library version shipped by the
platform.
"""

import logging

logger = logging.getLogger(__name__)


class AIExtensionsService:
"""
Facade giving XBlocks access to AI workflow profiles.

``run_profile`` is currently a stub: it validates the piping from an
XBlock through the runtime into this plugin and returns a canned payload.
The real implementation will dispatch to the workflows engine
(``openedx_ai_extensions.workflows``) using the same signature.
"""

def __init__(self, **kwargs):
self.runtime = kwargs.get("runtime")
self.xblock = kwargs.get("xblock")

@property
def usage_key(self):
"""The usage key of the calling block, or None if unavailable."""
scope_ids = getattr(self.xblock, "scope_ids", None)
return getattr(scope_ids, "usage_id", None)

@property
def course_key(self):
"""The learning context (course) key of the calling block, or None."""
return getattr(self.usage_key, "context_key", None)

@property
def user_id(self):
"""The runtime user id the block is bound to, or None."""
scope_ids = getattr(self.xblock, "scope_ids", None)
return getattr(scope_ids, "user_id", None)

def run_profile(self, profile_id, user_input):
"""
Run the AI workflow profile ``profile_id`` with ``user_input``.

STUB: returns a bogus response without calling any workflow or LLM.
"""
logger.info(
"ai_extensions service stub called: profile=%s usage_key=%s user_id=%s",
profile_id,
self.usage_key,
self.user_id,
)
return {
"status": "ok",
"stub": True,
"profile_id": profile_id,
"response": (
f"[stubbed ai_extensions response for profile {profile_id!r}]"
),
"context": {
"usage_key": str(self.usage_key) if self.usage_key else None,
"course_key": str(self.course_key) if self.course_key else None,
"user_id": self.user_id,
},
"echo": user_input,
}
3 changes: 3 additions & 0 deletions backend/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,5 +183,8 @@ def is_requirement(line):
"cms.djangoapp": [
"openedx_ai_extensions = openedx_ai_extensions.apps:OpenedxAIExtensionsConfig",
],
"xblock.service.v1": [
"ai_extensions = openedx_ai_extensions.xblock_service:AIExtensionsService",
],
},
)
64 changes: 64 additions & 0 deletions backend/tests/test_xblock_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""
Tests for the ``ai_extensions`` XBlock runtime service PoC (ADR-0011).

The service is exercised exactly as the proposed XBlock runtime fallback
would: instantiated with ``runtime=`` and ``xblock=`` keyword arguments and
asked to run a profile. No Django or XBlock machinery is required.
"""

from unittest.mock import MagicMock, Mock

from openedx_ai_extensions.xblock_service import AIExtensionsService


def make_block(usage_key="block-v1:org+course+run+type@problem+block@1",
context_key="course-v1:org+course+run",
user_id=42):
"""Build a mock XBlock exposing the scope_ids attributes the service reads."""
usage_id = MagicMock()
usage_id.context_key = context_key
usage_id.__str__.return_value = usage_key
block = Mock()
block.scope_ids.usage_id = usage_id
block.scope_ids.user_id = user_id
return block


def test_service_instantiates_with_runtime_contract():
runtime = Mock()
block = make_block()
service = AIExtensionsService(runtime=runtime, xblock=block)
assert service.runtime is runtime
assert service.xblock is block


def test_run_profile_returns_stub_with_context():
service = AIExtensionsService(runtime=Mock(), xblock=make_block())
result = service.run_profile("summarize-v1", {"text": "hello"})

assert result["status"] == "ok"
assert result["stub"] is True
assert result["profile_id"] == "summarize-v1"
assert result["echo"] == {"text": "hello"}
assert result["context"]["course_key"] == "course-v1:org+course+run"
assert result["context"]["user_id"] == 42
assert "block-v1:" in result["context"]["usage_key"]


def test_run_profile_survives_minimal_context():
# Some runtimes/tests may hand in blocks without full scope_ids; the
# service should degrade to None context values, not raise.
service = AIExtensionsService()
result = service.run_profile("p1", "input")
assert result["status"] == "ok"
assert result["context"] == {
"usage_key": None,
"course_key": None,
"user_id": None,
}


def test_entry_point_target_is_importable():
# Guards the setup.py entry point target string.
from openedx_ai_extensions.xblock_service import service as service_module
assert service_module.AIExtensionsService is AIExtensionsService
Loading
Loading