diff --git a/contentcuration/automation/utils/appnexus/base.py b/contentcuration/automation/utils/appnexus/base.py index efbc41f100..0998753bd8 100644 --- a/contentcuration/automation/utils/appnexus/base.py +++ b/contentcuration/automation/utils/appnexus/base.py @@ -1,9 +1,9 @@ -import time import logging -import requests +import time from abc import ABC from abc import abstractmethod -from builtins import NotImplementedError + +import requests from requests.adapters import HTTPAdapter from requests.packages.urllib3.util.retry import Retry @@ -15,7 +15,7 @@ class SessionWithMaxConnectionAge(requests.Session): Session with a maximum connection age. If the connection is older than the specified age, it will be closed and a new one will be created. The age is specified in seconds. """ - def __init__(self, age = 100): + def __init__(self, age=100): super().__init__() self.age = age self.last_used = time.time() @@ -56,7 +56,8 @@ def __init__( class BackendResponse(object): """ Class that should be inherited by specific backend for its responses""" - def __init__(self, **kwargs): + def __init__(self, error=None, **kwargs): + self.error = error for key, value in kwargs.items(): setattr(self, key, value) @@ -67,8 +68,8 @@ class Backend(ABC): session = None base_url = None connect_endpoint = None - max_retries=1 - backoff_factor=0.3 + max_retries = 1 + backoff_factor = 0.3 def __new__(cls, *args, **kwargs): if not isinstance(cls._instance, cls): @@ -156,14 +157,14 @@ def _make_request(self, request): ) as e: logging.exception(e) raise errors.InvalidResponse(f"Invalid response from {url}") - + def connect(self, **kwargs): """ Establishes a connection to the backend service. """ try: request = BackendRequest(method="GET", path=self.connect_endpoint, **kwargs) self._make_request(request) return True - except Exception as e: + except Exception: return False def make_request(self, request): diff --git a/contentcuration/automation/utils/appnexus/errors.py b/contentcuration/automation/utils/appnexus/errors.py index f9166678ab..34ef92f749 100644 --- a/contentcuration/automation/utils/appnexus/errors.py +++ b/contentcuration/automation/utils/appnexus/errors.py @@ -1,15 +1,18 @@ - class ConnectionError(Exception): - pass + pass + class TimeoutError(Exception): - pass + pass + class HttpError(Exception): - pass + pass + class InvalidRequest(Exception): - pass + pass + class InvalidResponse(Exception): - pass + pass diff --git a/contentcuration/contentcuration/tests/utils/test_recommendations.py b/contentcuration/contentcuration/tests/utils/test_recommendations.py index a4f711033a..3ef11e43e5 100644 --- a/contentcuration/contentcuration/tests/utils/test_recommendations.py +++ b/contentcuration/contentcuration/tests/utils/test_recommendations.py @@ -1,6 +1,12 @@ +from automation.utils.appnexus import errors from django.test import TestCase +from mock import MagicMock +from mock import patch +from contentcuration.models import ContentNode +from contentcuration.utils.recommendations import EmbeddingsResponse from contentcuration.utils.recommendations import Recommendations +from contentcuration.utils.recommendations import RecommendationsAdapter class RecommendationsTestCase(TestCase): @@ -8,3 +14,80 @@ def test_backend_initialization(self): recomendations = Recommendations() self.assertIsNotNone(recomendations) self.assertIsInstance(recomendations, Recommendations) + + +class RecommendationsAdapterTestCase(TestCase): + def setUp(self): + self.adapter = RecommendationsAdapter(MagicMock()) + self.topic = { + 'id': 'topic_id', + 'title': 'topic_title', + 'description': 'topic_description', + 'language': 'en', + 'ancestors': [ + { + 'id': 'ancestor_id', + 'title': 'ancestor_title', + 'description': 'ancestor_description', + } + ] + } + self.resources = [ + MagicMock(spec=ContentNode), + ] + + def test_adapter_initialization(self): + self.assertIsNotNone(self.adapter) + self.assertIsInstance(self.adapter, RecommendationsAdapter) + + @patch('contentcuration.utils.recommendations.EmbedTopicsRequest') + def test_embed_topics_backend_connect_success(self, embed_topics_request_mock): + self.adapter.backend.connect.return_value = True + self.adapter.backend.make_request.return_value = MagicMock(spec=EmbeddingsResponse) + response = self.adapter.embed_topics(self.topic) + self.adapter.backend.connect.assert_called_once() + self.adapter.backend.make_request.assert_called_once() + self.assertIsInstance(response, EmbeddingsResponse) + + def test_embed_topics_backend_connect_failure(self): + self.adapter.backend.connect.return_value = False + with self.assertRaises(errors.ConnectionError): + self.adapter.embed_topics(self.topic) + self.adapter.backend.connect.assert_called_once() + self.adapter.backend.make_request.assert_not_called() + + @patch('contentcuration.utils.recommendations.EmbedTopicsRequest') + def test_embed_topics_make_request_exception(self, embed_topics_request_mock): + self.adapter.backend.connect.return_value = True + self.adapter.backend.make_request.side_effect = Exception("Mocked exception") + response = self.adapter.embed_topics(self.topic) + self.adapter.backend.connect.assert_called_once() + self.adapter.backend.make_request.assert_called_once() + self.assertIsInstance(response, EmbeddingsResponse) + self.assertEqual(str(response.error), "Mocked exception") + + @patch('contentcuration.utils.recommendations.EmbedContentRequest') + def test_embed_content_backend_connect_success(self, embed_content_request_mock): + self.adapter.backend.connect.return_value = True + self.adapter.backend.make_request.return_value = MagicMock(spec=EmbeddingsResponse) + response = self.adapter.embed_content(self.resources) + self.adapter.backend.connect.assert_called_once() + self.adapter.backend.make_request.assert_called_once() + self.assertIsInstance(response, EmbeddingsResponse) + + def test_embed_content_backend_connect_failure(self): + self.adapter.backend.connect.return_value = False + with self.assertRaises(errors.ConnectionError): + self.adapter.embed_content(self.resources) + self.adapter.backend.connect.assert_called_once() + self.adapter.backend.make_request.assert_not_called() + + @patch('contentcuration.utils.recommendations.EmbedContentRequest') + def test_embed_content_make_request_exception(self, embed_content_request_mock): + self.adapter.backend.connect.return_value = True + self.adapter.backend.make_request.side_effect = Exception("Mocked exception") + response = self.adapter.embed_content(self.resources) + self.adapter.backend.connect.assert_called_once() + self.adapter.backend.make_request.assert_called_once() + self.assertIsInstance(response, EmbeddingsResponse) + self.assertEqual(str(response.error), "Mocked exception") diff --git a/contentcuration/contentcuration/utils/recommendations.py b/contentcuration/contentcuration/utils/recommendations.py index 8fd551da15..1c015d2050 100644 --- a/contentcuration/contentcuration/utils/recommendations.py +++ b/contentcuration/contentcuration/utils/recommendations.py @@ -1,38 +1,56 @@ +from typing import Any +from typing import Dict +from typing import List from typing import Union +from automation.utils.appnexus import errors from automation.utils.appnexus.base import Adapter from automation.utils.appnexus.base import Backend from automation.utils.appnexus.base import BackendFactory from automation.utils.appnexus.base import BackendRequest from automation.utils.appnexus.base import BackendResponse +from contentcuration.models import ContentNode + class RecommendationsBackendRequest(BackendRequest): - pass + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) -class RecommedationsRequest(RecommendationsBackendRequest): - def __init__(self) -> None: - super().__init__() +class RecommendationsRequest(RecommendationsBackendRequest): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) class EmbeddingsRequest(RecommendationsBackendRequest): - def __init__(self) -> None: - super().__init__() + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) class RecommendationsBackendResponse(BackendResponse): - pass + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) class RecommendationsResponse(RecommendationsBackendResponse): - def __init__(self) -> None: - pass + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + +class EmbedTopicsRequest(RecommendationsBackendRequest): + path = '/embed-topics' + method = 'POST' + + +class EmbedContentRequest(RecommendationsBackendRequest): + path = '/embed-content' + method = 'POST' class EmbeddingsResponse(RecommendationsBackendResponse): - def __init__(self) -> None: - pass + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) class RecommendationsBackendFactory(BackendFactory): @@ -67,9 +85,39 @@ def cache_embeddings(self, embeddings_list) -> bool: return True def get_recommendations(self, embedding) -> RecommendationsResponse: - request = RecommedationsRequest(embedding) + request = RecommendationsRequest(embedding) return self.backend.make_request(request) + def embed_topics(self, topics: Dict[str, Any]) -> EmbeddingsResponse: + + if not self.backend.connect(): + raise errors.ConnectionError("Connection to the backend failed") + + try: + embed_topics_request = EmbedTopicsRequest(json=topics) + return self.backend.make_request(embed_topics_request) + except Exception as e: + return EmbeddingsResponse(error=e) + + def embed_content(self, nodes: List[ContentNode]) -> EmbeddingsResponse: + + if not self.backend.connect(): + raise errors.ConnectionError("Connection to the backend failed") + + try: + resources = [self.extract_content(node) for node in nodes] + json = { + 'resources': resources, + 'metadata': {} + } + embed_content_request = EmbedContentRequest(json=json) + return self.backend.make_request(embed_content_request) + except Exception as e: + return EmbeddingsResponse(error=e) + + def extract_content(self, node: ContentNode) -> Dict[str, Any]: + return {} + class Recommendations(Backend):