diff --git a/docs/00-introduction.ipynb b/docs/00-introduction.ipynb index 60c2a07f2ce5810a7e52a388676609b40e9b739a..9c68cd0c90b9790098f3510fee64222a8e52252a 100644 --- a/docs/00-introduction.ipynb +++ b/docs/00-introduction.ipynb @@ -37,11 +37,21 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 1, "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "\n", + "[notice] A new release of pip is available: 23.1.2 -> 24.0\n", + "[notice] To update, run: python.exe -m pip install --upgrade pip\n" + ] + } + ], "source": [ - "!pip install -qU semantic-router==0.0.34" + "!pip install -qU semantic-router==0.0.35" ] }, { @@ -53,7 +63,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": 3, "metadata": {}, "outputs": [], "source": [ @@ -81,7 +91,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 4, "metadata": {}, "outputs": [], "source": [ @@ -108,7 +118,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 5, "metadata": {}, "outputs": [], "source": [ @@ -136,14 +146,14 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 7, "metadata": {}, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ - "\u001b[32m2024-01-07 18:08:29 INFO semantic_router.utils.logger Initializing RouteLayer\u001b[0m\n" + "\u001b[32m2024-04-19 18:34:06 INFO semantic_router.utils.logger local\u001b[0m\n" ] } ], @@ -162,16 +172,16 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 8, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "RouteChoice(name='politics', function_call=None)" + "RouteChoice(name='politics', function_call=None, similarity_score=None)" ] }, - "execution_count": 5, + "execution_count": 8, "metadata": {}, "output_type": "execute_result" } @@ -182,16 +192,16 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 9, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "RouteChoice(name='chitchat', function_call=None)" + "RouteChoice(name='chitchat', function_call=None, similarity_score=None)" ] }, - "execution_count": 6, + "execution_count": 9, "metadata": {}, "output_type": "execute_result" } @@ -209,16 +219,16 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 10, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "RouteChoice(name=None, function_call=None)" + "RouteChoice(name=None, function_call=None, similarity_score=None)" ] }, - "execution_count": 7, + "execution_count": 10, "metadata": {}, "output_type": "execute_result" } @@ -231,8 +241,56 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "In this case, we return `None` because no matches were identified." + "We can also retrieve multiple routes with its associated score:" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[RouteChoice(name='politics', function_call=None, similarity_score=0.8596186767854487),\n", + " RouteChoice(name='chitchat', function_call=None, similarity_score=0.8356239688161808)]" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "rl.retrieve_multiple_routes(\"Hi! How are you doing in politics??\")" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[]" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "rl.retrieve_multiple_routes(\"I'm interested in learning about llama 2\")" ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { @@ -251,7 +309,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.5" + "version": "3.12.2" } }, "nbformat": 4, diff --git a/semantic_router/layer.py b/semantic_router/layer.py index 21c0da2766cd265a632d0e92e370c2a46a509bbd..a138893a88427fc7d7791f085d3508ee67bd6879 100644 --- a/semantic_router/layer.py +++ b/semantic_router/layer.py @@ -271,6 +271,32 @@ class RouteLayer: # if no route passes threshold, return empty route choice return RouteChoice() + def retrieve_multiple_routes( + self, + text: Optional[str] = None, + vector: Optional[List[float]] = None, + ) -> List[RouteChoice]: + if vector is None: + if text is None: + raise ValueError("Either text or vector must be provided") + vector_arr = self._encode(text=text) + else: + vector_arr = np.array(vector) + # get relevant utterances + results = self._retrieve(xq=vector_arr) + + # decide most relevant routes + categories_with_scores = self._semantic_classify_multiple_routes(results) + + route_choices = [] + for category, score in categories_with_scores: + route = self.check_for_matching_routes(category) + if route: + route_choice = RouteChoice(name=route.name, similarity_score=score) + route_choices.append(route_choice) + + return route_choices + def _retrieve_top_route( self, vector: List[float], route_filter: Optional[List[str]] = None ) -> Tuple[Optional[Route], List[float]]: @@ -423,14 +449,7 @@ class RouteLayer: ) def _semantic_classify(self, query_results: List[dict]) -> Tuple[str, List[float]]: - scores_by_class: Dict[str, List[float]] = {} - for result in query_results: - score = result["score"] - route = result["route"] - if route in scores_by_class: - scores_by_class[route].append(score) - else: - scores_by_class[route] = [score] + scores_by_class = self.group_scores_by_class(query_results) # Calculate total score for each class total_scores = { @@ -446,6 +465,49 @@ class RouteLayer: logger.warning("No classification found for semantic classifier.") return "", [] + def get(self, name: str) -> Optional[Route]: + for route in self.routes: + if route.name == name: + return route + logger.error(f"Route `{name}` not found") + return None + + def _semantic_classify_multiple_routes( + self, query_results: List[dict] + ) -> List[Tuple[str, float]]: + scores_by_class = self.group_scores_by_class(query_results) + + # Filter classes based on threshold and find max score for each + classes_above_threshold = [] + for route_name, scores in scores_by_class.items(): + # Use the get method to find the Route object by its name + route_obj = self.get(route_name) + if route_obj is not None: + # Use the Route object's threshold if it exists, otherwise use the provided threshold + _threshold = ( + route_obj.score_threshold + if route_obj.score_threshold is not None + else self.score_threshold + ) + if self._pass_threshold(scores, _threshold): + max_score = max(scores) + classes_above_threshold.append((route_name, max_score)) + + return classes_above_threshold + + def group_scores_by_class( + self, query_results: List[dict] + ) -> Dict[str, List[float]]: + scores_by_class: Dict[str, List[float]] = {} + for result in query_results: + score = result["score"] + route = result["route"] + if route in scores_by_class: + scores_by_class[route].append(score) + else: + scores_by_class[route] = [score] + return scores_by_class + def _pass_threshold(self, scores: List[float], threshold: float) -> bool: if scores: return max(scores) > threshold diff --git a/tests/unit/test_layer.py b/tests/unit/test_layer.py index 59830da1ac3e06060f944b5ad4e071503ebac90a..8f4833f0011225aac4f3337607c0f301083b46f9 100644 --- a/tests/unit/test_layer.py +++ b/tests/unit/test_layer.py @@ -97,6 +97,22 @@ def routes(): ] +@pytest.fixture +def routes_2(): + return [ + Route(name="Route 1", utterances=["Hello"]), + Route(name="Route 2", utterances=["Hello"]), + ] + + +@pytest.fixture +def routes_3(): + return [ + Route(name="Route 1", utterances=["Hello"]), + Route(name="Route 2", utterances=["Asparagus"]), + ] + + @pytest.fixture def dynamic_routes(): return [ @@ -503,6 +519,149 @@ class TestRouteLayer: ) assert route_layer.get_thresholds() == {"Route 1": 0.82, "Route 2": 0.82} + def test_with_multiple_routes_passing_threshold( + self, openai_encoder, routes, index_cls + ): + route_layer = RouteLayer( + encoder=openai_encoder, routes=routes, index=index_cls() + ) + route_layer.score_threshold = 0.5 # Set the score_threshold if needed + # Assuming route_layer is already set up with routes "Route 1" and "Route 2" + query_results = [ + {"route": "Route 1", "score": 0.6}, + {"route": "Route 2", "score": 0.7}, + {"route": "Route 1", "score": 0.8}, + ] + # Override _pass_threshold to always return True for this test + route_layer._pass_threshold = lambda scores, threshold: True + expected = [("Route 1", 0.8), ("Route 2", 0.7)] + results = route_layer._semantic_classify_multiple_routes(query_results) + assert sorted(results) == sorted( + expected + ), "Should classify and return routes above their thresholds" + + def test_with_no_routes_passing_threshold(self, openai_encoder, routes, index_cls): + route_layer = RouteLayer( + encoder=openai_encoder, routes=routes, index=index_cls() + ) + route_layer.score_threshold = 0.5 + # Override _pass_threshold to always return False for this test + route_layer._pass_threshold = lambda scores, threshold: False + query_results = [ + {"route": "Route 1", "score": 0.3}, + {"route": "Route 2", "score": 0.2}, + ] + expected = [] + results = route_layer._semantic_classify_multiple_routes(query_results) + assert ( + results == expected + ), "Should return an empty list when no routes pass their thresholds" + + def test_with_no_query_results(self, openai_encoder, routes, index_cls): + route_layer = RouteLayer( + encoder=openai_encoder, routes=routes, index=index_cls() + ) + route_layer.score_threshold = 0.5 + query_results = [] + expected = [] + results = route_layer._semantic_classify_multiple_routes(query_results) + assert ( + results == expected + ), "Should return an empty list when there are no query results" + + def test_with_unrecognized_route(self, openai_encoder, routes, index_cls): + route_layer = RouteLayer( + encoder=openai_encoder, routes=routes, index=index_cls() + ) + route_layer.score_threshold = 0.5 + # Test with a route name that does not exist in the route_layer's routes + query_results = [{"route": "UnrecognizedRoute", "score": 0.9}] + expected = [] + results = route_layer._semantic_classify_multiple_routes(query_results) + assert results == expected, "Should ignore and not return unrecognized routes" + + def test_retrieve_with_text(self, openai_encoder, routes, index_cls): + route_layer = RouteLayer( + encoder=openai_encoder, routes=routes, index=index_cls() + ) + text = "Hello" + results = route_layer.retrieve_multiple_routes(text=text) + assert len(results) >= 1, "Expected at least one result" + assert any( + result.name in ["Route 1", "Route 2"] for result in results + ), "Expected the result to be either 'Route 1' or 'Route 2'" + + def test_retrieve_with_vector(self, openai_encoder, routes, index_cls): + route_layer = RouteLayer( + encoder=openai_encoder, routes=routes, index=index_cls() + ) + vector = [0.1, 0.2, 0.3] + results = route_layer.retrieve_multiple_routes(vector=vector) + assert len(results) >= 1, "Expected at least one result" + assert any( + result.name in ["Route 1", "Route 2"] for result in results + ), "Expected the result to be either 'Route 1' or 'Route 2'" + + def test_retrieve_without_text_or_vector(self, openai_encoder, routes, index_cls): + route_layer = RouteLayer( + encoder=openai_encoder, routes=routes, index=index_cls() + ) + with pytest.raises(ValueError, match="Either text or vector must be provided"): + route_layer.retrieve_multiple_routes() + + def test_retrieve_no_matches(self, openai_encoder, routes, index_cls): + route_layer = RouteLayer( + encoder=openai_encoder, routes=routes, index=index_cls() + ) + text = "Asparagus" + results = route_layer.retrieve_multiple_routes(text=text) + assert len(results) == 0, f"Expected no results, but got {len(results)}" + + def test_retrieve_one_match(self, openai_encoder, routes_3, index_cls): + route_layer = RouteLayer( + encoder=openai_encoder, routes=routes_3, index=index_cls() + ) + text = "Hello" + results = route_layer.retrieve_multiple_routes(text=text) + assert len(results) == 1, f"Expected one result, and got {len(results)}" + matched_routes = [result.name for result in results] + assert "Route 1" in matched_routes, "Expected 'Route 1' to be a match" + + def test_retrieve_with_text_for_multiple_matches( + self, openai_encoder, routes_2, index_cls + ): + route_layer = RouteLayer( + encoder=openai_encoder, routes=routes_2, index=index_cls() + ) + text = "Hello" + results = route_layer.retrieve_multiple_routes(text=text) + assert len(results) == 2, "Expected two results" + matched_routes = [result.name for result in results] + assert "Route 1" in matched_routes, "Expected 'Route 1' to be a match" + assert "Route 2" in matched_routes, "Expected 'Route 2' to be a match" + + def test_set_aggregation_method_with_unsupported_value( + self, openai_encoder, routes, index_cls + ): + route_layer = RouteLayer( + encoder=openai_encoder, routes=routes, index=index_cls() + ) + unsupported_aggregation = "unsupported_aggregation_method" + with pytest.raises( + ValueError, + match=f"Unsupported aggregation method chosen: {unsupported_aggregation}. Choose either 'SUM', 'MEAN', or 'MAX'.", + ): + route_layer._set_aggregation_method(unsupported_aggregation) + + def test_refresh_routes_not_implemented(self, openai_encoder, routes, index_cls): + route_layer = RouteLayer( + encoder=openai_encoder, routes=routes, index=index_cls() + ) + with pytest.raises( + NotImplementedError, match="This method has not yet been implemented." + ): + route_layer._refresh_routes() + class TestLayerFit: def test_eval(self, openai_encoder, routes, test_data):