Repository: oppia/oppia

Issue:
Create classifier_services.py and move existing classification code from reader.py to classifier_services.
Currently, core/controllers/reader.py contains not only the ClassifyHandler but also the logic for running the classifier (`classify_string_classifier_rule` and classify `methods` precisely). We need to create a seperate classifier_services.py and move classification related logic from reader.py to classifier_servies.py.

This involves the following steps:
1. Creating classifier_servies.py under "core/domain".
2. Moving classify_string_classifier_rule and classify to classifier_servies.py.
3. Creating classifier_services_test.py and moving the existing ReaderClassifyTests from reader_test.py to classifier_services_test.py (along with renaming the test class).


Discussion:

Resolving patch:
diff --git a/core/controllers/reader.py b/core/controllers/reader.py
--- a/core/controllers/reader.py
+++ b/core/controllers/reader.py
@@ -21,7 +21,7 @@
 import jinja2
 
 from core.controllers import base
-from core.domain import classifier_registry
+from core.domain import classifier_services
 from core.domain import collection_services
 from core.domain import config_domain
 from core.domain import dependency_registry
@@ -79,56 +79,6 @@ def test_can_play(self, exploration_id, **kwargs):
     return test_can_play
 
 
-def classify_string_classifier_rule(state, normalized_answer):
-    """Run the classifier if no prediction has been made yet. Currently this
-    is behind a development flag.
-    """
-    best_matched_answer_group = None
-    best_matched_answer_group_index = len(state.interaction.answer_groups)
-    best_matched_rule_spec_index = None
-
-    sc = classifier_registry.ClassifierRegistry.get_classifier_by_id(
-        feconf.INTERACTION_CLASSIFIER_MAPPING['TextInput'])
-
-    training_examples = [
-        [doc, []] for doc in state.interaction.confirmed_unclassified_answers]
-    for (answer_group_index, answer_group) in enumerate(
-            state.interaction.answer_groups):
-        classifier_rule_spec_index = answer_group.get_classifier_rule_index()
-        if classifier_rule_spec_index is not None:
-            classifier_rule_spec = answer_group.rule_specs[
-                classifier_rule_spec_index]
-        else:
-            classifier_rule_spec = None
-        if classifier_rule_spec is not None:
-            training_examples.extend([
-                [doc, [str(answer_group_index)]]
-                for doc in classifier_rule_spec.inputs['training_data']])
-    if len(training_examples) > 0:
-        sc.train(training_examples)
-        labels = sc.predict([normalized_answer])
-        predicted_label = labels[0]
-        if predicted_label != feconf.DEFAULT_CLASSIFIER_LABEL:
-            predicted_answer_group_index = int(predicted_label)
-            predicted_answer_group = state.interaction.answer_groups[
-                predicted_answer_group_index]
-            for rule_spec in predicted_answer_group.rule_specs:
-                if rule_spec.rule_type == exp_domain.CLASSIFIER_RULESPEC_STR:
-                    best_matched_rule_spec_index = classifier_rule_spec_index
-                    break
-            best_matched_answer_group = predicted_answer_group
-            best_matched_answer_group_index = predicted_answer_group_index
-            return {
-                'outcome': best_matched_answer_group.outcome.to_dict(),
-                'answer_group_index': best_matched_answer_group_index,
-                'rule_spec_index': best_matched_rule_spec_index,
-            }
-        else:
-            return None
-
-    return None
-
-
 def _get_exploration_player_data(
         exploration_id, version, collection_id, can_edit):
     try:
@@ -191,56 +141,6 @@ def _get_exploration_player_data(
     }
 
 
-def classify(state, answer):
-    """Classify the answer using the string classifier.
-
-    This should only be called if the string classifier functionality is
-    enabled, and the interaction is trainable.
-
-    Normalize the answer and classifies the answer if the interaction has a
-    classifier associated with it. Otherwise, classifies the answer to the
-    default outcome.
-
-    Returns a dict with the following keys:
-        'outcome': A dict representing the outcome of the answer group matched.
-        'answer_group_index': An index into the answer groups list indicating
-            which one was selected as the group which this answer belongs to.
-            This is equal to the number of answer groups if the default outcome
-            was matched.
-        'rule_spec_index': An index into the rule specs list of the matched
-            answer group which was selected that indicates which rule spec was
-            matched. This is equal to 0 if the default outcome is selected.
-    When the default rule is matched, outcome is the default_outcome of the
-    state's interaction.
-    """
-    assert feconf.ENABLE_STRING_CLASSIFIER
-
-    interaction_instance = interaction_registry.Registry.get_interaction_by_id(
-        state.interaction.id)
-    normalized_answer = interaction_instance.normalize_answer(answer)
-    response = None
-
-    if interaction_instance.is_string_classifier_trainable:
-        response = classify_string_classifier_rule(state, normalized_answer)
-    else:
-        raise Exception('No classifier found for interaction.')
-
-    if response is not None:
-        return response
-    elif state.interaction.default_outcome is not None:
-        return {
-            'outcome': state.interaction.default_outcome.to_dict(),
-            'answer_group_index': len(state.interaction.answer_groups),
-            'classification_certainty': 0.0,
-            'rule_spec_index': 0
-        }
-
-    raise Exception(
-        'Something has seriously gone wrong with the exploration. Oppia does '
-        'not know what to do with this answer. Please contact the '
-        'exploration owner.')
-
-
 class ExplorationPageEmbed(base.BaseHandler):
     """Page describing a single embedded exploration."""
 
@@ -435,8 +335,8 @@ def post(self, unused_exploration_id):
         # The learner's parameter values.
         params = self.payload.get('params')
         params['answer'] = answer
-
-        self.render_json(classify(old_state, answer))
+        result = classifier_services.classify(old_state, answer)
+        self.render_json(result)
 
 
 class ReaderFeedbackHandler(base.BaseHandler):
diff --git a/core/domain/classifier_services.py b/core/domain/classifier_services.py
new file mode 100644
--- /dev/null
+++ b/core/domain/classifier_services.py
@@ -0,0 +1,120 @@
+# Copyright 2017 The Oppia Authors. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS-IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Services for classifier models"""
+
+from core.domain import classifier_registry
+from core.domain import exp_domain
+from core.domain import interaction_registry
+
+import feconf
+
+def classify(state, answer):
+    """Classify the answer using the string classifier.
+
+    This should only be called if the string classifier functionality is
+    enabled, and the interaction is trainable.
+
+    Normalize the answer and classifies the answer if the interaction has a
+    classifier associated with it. Otherwise, classifies the answer to the
+    default outcome.
+
+    Returns a dict with the following keys:
+        'outcome': A dict representing the outcome of the answer group matched.
+        'answer_group_index': An index into the answer groups list indicating
+            which one was selected as the group which this answer belongs to.
+            This is equal to the number of answer groups if the default outcome
+            was matched.
+        'rule_spec_index': An index into the rule specs list of the matched
+            answer group which was selected that indicates which rule spec was
+            matched. This is equal to 0 if the default outcome is selected.
+    When the default rule is matched, outcome is the default_outcome of the
+    state's interaction.
+    """
+    assert feconf.ENABLE_STRING_CLASSIFIER
+
+    interaction_instance = interaction_registry.Registry.get_interaction_by_id(
+        state.interaction.id)
+    normalized_answer = interaction_instance.normalize_answer(answer)
+    response = None
+
+    if interaction_instance.is_string_classifier_trainable:
+        response = classify_string_classifier_rule(state, normalized_answer)
+    else:
+        raise Exception('No classifier found for interaction.')
+
+    if response is not None:
+        return response
+    elif state.interaction.default_outcome is not None:
+        return {
+            'outcome': state.interaction.default_outcome.to_dict(),
+            'answer_group_index': len(state.interaction.answer_groups),
+            'classification_certainty': 0.0,
+            'rule_spec_index': 0
+        }
+
+    raise Exception(
+        'Something has seriously gone wrong with the exploration. Oppia does '
+        'not know what to do with this answer. Please contact the '
+        'exploration owner.')
+
+
+def classify_string_classifier_rule(state, normalized_answer):
+    """Run the classifier if no prediction has been made yet. Currently this
+    is behind a development flag.
+    """
+    best_matched_answer_group = None
+    best_matched_answer_group_index = len(state.interaction.answer_groups)
+    best_matched_rule_spec_index = None
+
+    sc = classifier_registry.ClassifierRegistry.get_classifier_by_id(
+        feconf.INTERACTION_CLASSIFIER_MAPPING['TextInput'])
+
+    training_examples = [
+        [doc, []] for doc in state.interaction.confirmed_unclassified_answers]
+    for (answer_group_index, answer_group) in enumerate(
+            state.interaction.answer_groups):
+        classifier_rule_spec_index = answer_group.get_classifier_rule_index()
+        if classifier_rule_spec_index is not None:
+            classifier_rule_spec = answer_group.rule_specs[
+                classifier_rule_spec_index]
+        else:
+            classifier_rule_spec = None
+        if classifier_rule_spec is not None:
+            training_examples.extend([
+                [doc, [str(answer_group_index)]]
+                for doc in classifier_rule_spec.inputs['training_data']])
+    if len(training_examples) > 0:
+        sc.train(training_examples)
+        labels = sc.predict([normalized_answer])
+        predicted_label = labels[0]
+        if predicted_label != feconf.DEFAULT_CLASSIFIER_LABEL:
+            predicted_answer_group_index = int(predicted_label)
+            predicted_answer_group = state.interaction.answer_groups[
+                predicted_answer_group_index]
+            for rule_spec in predicted_answer_group.rule_specs:
+                if rule_spec.rule_type == exp_domain.CLASSIFIER_RULESPEC_STR:
+                    best_matched_rule_spec_index = classifier_rule_spec_index
+                    break
+            best_matched_answer_group = predicted_answer_group
+            best_matched_answer_group_index = predicted_answer_group_index
+            return {
+                'outcome': best_matched_answer_group.outcome.to_dict(),
+                'answer_group_index': best_matched_answer_group_index,
+                'rule_spec_index': best_matched_rule_spec_index,
+            }
+        else:
+            return None
+
+    return None


Test patch:
diff --git a/core/controllers/reader_test.py b/core/controllers/reader_test.py
--- a/core/controllers/reader_test.py
+++ b/core/controllers/reader_test.py
@@ -14,17 +14,12 @@
 
 """Tests for the page that allows learners to play through an exploration."""
 
-import os
-
-from core.controllers import reader
-from core.domain import classifier_registry
 from core.domain import exp_domain
 from core.domain import exp_services
 from core.domain import param_domain
 from core.domain import rights_manager
 from core.tests import test_utils
 import feconf
-import utils
 
 
 class ReaderPermissionsTest(test_utils.GenericTestBase):
@@ -109,68 +104,6 @@ def test_published_explorations_are_visible_to_logged_in_users(self):
         self.assertEqual(response.status_int, 200)
 
 
-class ReaderClassifyTests(test_utils.GenericTestBase):
-    """Test reader.classify using the sample explorations.
-
-    Since the end to end tests cover correct classification, and frontend tests
-    test hard rules, ReaderClassifyTests is only checking that the string
-    classifier is actually called.
-    """
-
-    def setUp(self):
-        super(ReaderClassifyTests, self).setUp()
-        self._init_classify_inputs('16')
-
-    def _init_classify_inputs(self, exploration_id):
-        test_exp_filepath = os.path.join(
-            feconf.TESTS_DATA_DIR, 'string_classifier_test.yaml')
-        yaml_content = utils.get_file_contents(test_exp_filepath)
-        assets_list = []
-        exp_services.save_new_exploration_from_yaml_and_assets(
-            feconf.SYSTEM_COMMITTER_ID, yaml_content, exploration_id,
-            assets_list)
-
-        self.exp_id = exploration_id
-        self.exp_state = (
-            exp_services.get_exploration_by_id(exploration_id).states['Home'])
-
-    def _is_string_classifier_called(self, answer):
-        sc = classifier_registry.ClassifierRegistry.get_classifier_by_id(
-            feconf.INTERACTION_CLASSIFIER_MAPPING['TextInput'])
-        string_classifier_predict = (
-            sc.__class__.predict)
-        predict_counter = test_utils.CallCounter(
-            string_classifier_predict)
-
-        with self.swap(sc.__class__, 'predict', predict_counter):
-            response = reader.classify(self.exp_state, answer)
-
-        answer_group_index = response['answer_group_index']
-        rule_spec_index = response['rule_spec_index']
-        answer_groups = self.exp_state.interaction.answer_groups
-        if answer_group_index == len(answer_groups):
-            return 'default'
-
-        answer_group = answer_groups[answer_group_index]
-        return (answer_group.get_classifier_rule_index() == rule_spec_index and
-                predict_counter.times_called == 1)
-
-    def test_string_classifier_classification(self):
-        """All these responses trigger the string classifier."""
-        with self.swap(feconf, 'ENABLE_STRING_CLASSIFIER', True):
-            self.assertTrue(
-                self._is_string_classifier_called(
-                    'it\'s a permutation of 3 elements'))
-            self.assertTrue(
-                self._is_string_classifier_called(
-                    'There are 3 options for the first ball, and 2 for the '
-                    'remaining two. So 3*2=6.'))
-            self.assertTrue(
-                self._is_string_classifier_called('abc acb bac bca cbb cba'))
-            self.assertTrue(
-                self._is_string_classifier_called('dunno, just guessed'))
-
-
 class FeedbackIntegrationTest(test_utils.GenericTestBase):
     """Test the handler for giving feedback."""
 
diff --git a/core/domain/classifier_services_test.py b/core/domain/classifier_services_test.py
new file mode 100644
--- /dev/null
+++ b/core/domain/classifier_services_test.py
@@ -0,0 +1,85 @@
+# Copyright 2017 The Oppia Authors. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS-IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Tests for classifier services"""
+
+import os
+
+from core.domain import classifier_registry
+from core.domain import classifier_services
+from core.domain import exp_services
+from core.tests import test_utils
+import feconf
+import utils
+
+class ClassifierServicesTests(test_utils.GenericTestBase):
+    """Test reader.classify using the sample explorations.
+
+    Since the end to end tests cover correct classification, and frontend tests
+    test hard rules, ReaderClassifyTests is only checking that the string
+    classifier is actually called.
+    """
+
+    def setUp(self):
+        super(ClassifierServicesTests, self).setUp()
+        self._init_classify_inputs('16')
+
+    def _init_classify_inputs(self, exploration_id):
+        test_exp_filepath = os.path.join(
+            feconf.TESTS_DATA_DIR, 'string_classifier_test.yaml')
+        yaml_content = utils.get_file_contents(test_exp_filepath)
+        assets_list = []
+        exp_services.save_new_exploration_from_yaml_and_assets(
+            feconf.SYSTEM_COMMITTER_ID, yaml_content, exploration_id,
+            assets_list)
+
+        self.exp_id = exploration_id
+        self.exp_state = (
+            exp_services.get_exploration_by_id(exploration_id).states['Home'])
+
+    def _is_string_classifier_called(self, answer):
+        sc = classifier_registry.ClassifierRegistry.get_classifier_by_id(
+            feconf.INTERACTION_CLASSIFIER_MAPPING['TextInput'])
+        string_classifier_predict = (
+            sc.__class__.predict)
+        predict_counter = test_utils.CallCounter(
+            string_classifier_predict)
+
+        with self.swap(sc.__class__, 'predict', predict_counter):
+            response = classifier_services.classify(self.exp_state, answer)
+
+        answer_group_index = response['answer_group_index']
+        rule_spec_index = response['rule_spec_index']
+        answer_groups = self.exp_state.interaction.answer_groups
+        if answer_group_index == len(answer_groups):
+            return 'default'
+
+        answer_group = answer_groups[answer_group_index]
+        return (answer_group.get_classifier_rule_index() == rule_spec_index and
+                predict_counter.times_called == 1)
+
+    def test_string_classifier_classification(self):
+        """All these responses trigger the string classifier."""
+        with self.swap(feconf, 'ENABLE_STRING_CLASSIFIER', True):
+            self.assertTrue(
+                self._is_string_classifier_called(
+                    'it\'s a permutation of 3 elements'))
+            self.assertTrue(
+                self._is_string_classifier_called(
+                    'There are 3 options for the first ball, and 2 for the '
+                    'remaining two. So 3*2=6.'))
+            self.assertTrue(
+                self._is_string_classifier_called('abc acb bac bca cbb cba'))
+            self.assertTrue(
+                self._is_string_classifier_called('dunno, just guessed'))