Repository: deepset-ai/haystack

Issue:
Add custom filter support to ConditionalRouter component
*Is your feature request related to a problem? Please describe.**
Currently, the `ConditionalRouter` component doesn't support custom filters in the 'condition' and 'output' fields of a route. This limitation restricts users from applying complex logic or data transformations within these fields, forcing them to pre-process data before it reaches the router or to create additional components for simple transformations. This can lead to less efficient pipelines and reduced flexibility in routing logic.

**Describe the solution you'd like**
We propose adding support for custom filters in the `ConditionalRouter`, similar to how they are implemented in the `OutputAdapter` component. Specifically:

1. Add a 'custom_filters' parameter to the ConditionalRouter's __init__ method, accepting a dictionary of custom filter functions.
2. Allow these custom filters to be used in both the 'condition' and 'output' fields of each route.
3. Implement the necessary logic to apply these filters during the evaluation of conditions and generation of outputs.

This would allow users to write more expressive and powerful routing logic directly within the ConditionalRouter, enhancing its capabilities and flexibility.

**Describe alternatives you've considered**
1. Pre-processing data: Users could transform data before it reaches the `ConditionalRouter`, but this can be cumbersome and less efficient for complex pipelines.
2. Additional components: Creating separate components for data transformation before routing, which can make pipelines more complex and harder to manage.

**Additional context**
This feature would align the `ConditionalRouter` more closely with the `OutputAdapter` in terms of functionality, providing a consistent experience across Haystack components. It would be particularly useful for scenarios involving complex data transformations or when integrating external libraries for specific computations within the routing logic.




Discussion:
Hi @vblagoje and team Haystack.  I just implemented a custom component inheriting from `ConditionalRouter` that does this in a recent project.  I'll submit a PR.

Resolving patch:
diff --git a/haystack/components/routers/conditional_router.py b/haystack/components/routers/conditional_router.py
--- a/haystack/components/routers/conditional_router.py
+++ b/haystack/components/routers/conditional_router.py
@@ -2,13 +2,13 @@
 #
 # SPDX-License-Identifier: Apache-2.0
 
-from typing import Any, Dict, List, Set
+from typing import Any, Callable, Dict, List, Optional, Set
 
 from jinja2 import Environment, TemplateSyntaxError, meta
 from jinja2.nativetypes import NativeEnvironment
 
 from haystack import component, default_from_dict, default_to_dict, logging
-from haystack.utils import deserialize_type, serialize_type
+from haystack.utils import deserialize_callable, deserialize_type, serialize_callable, serialize_type
 
 logger = logging.getLogger(__name__)
 
@@ -102,7 +102,7 @@ class ConditionalRouter:
     ```
     """
 
-    def __init__(self, routes: List[Dict]):
+    def __init__(self, routes: List[Dict], custom_filters: Optional[Dict[str, Callable]] = None):
         """
         Initializes the `ConditionalRouter` with a list of routes detailing the conditions for routing.
 
@@ -113,12 +113,20 @@ def __init__(self, routes: List[Dict]):
             - `output_type`: The type of the output data (e.g., str, List[int]).
             - `output_name`: The name under which the `output` value of the route is published. This name is used to
                 connect the router to other components in the pipeline.
+        :param custom_filters: A dictionary of custom Jinja2 filters to be used in the condition expressions.
+            For example, passing `{"my_filter": my_filter_fcn}` where:
+            - `my_filter` is the name of the custom filter.
+            - `my_filter_fcn` is a callable that takes `my_var:str` and returns `my_var[:3]`.
+              `{{ my_var|my_filter }}` can then be used inside a route condition expression like so:
+                `"condition": "{{ my_var|my_filter == 'foo' }}"`.
         """
         self._validate_routes(routes)
         self.routes: List[dict] = routes
+        self.custom_filters = custom_filters or {}
 
         # Create a Jinja native environment to inspect variables in the condition templates
         env = NativeEnvironment()
+        env.filters.update(self.custom_filters)
 
         # Inspect the routes to determine input and output types.
         input_types: Set[str] = set()  # let's just store the name, type will always be Any
@@ -145,8 +153,8 @@ def to_dict(self) -> Dict[str, Any]:
         for route in self.routes:
             # output_type needs to be serialized to a string
             route["output_type"] = serialize_type(route["output_type"])
-
-        return default_to_dict(self, routes=self.routes)
+        se_filters = {name: serialize_callable(filter_func) for name, filter_func in self.custom_filters.items()}
+        return default_to_dict(self, routes=self.routes, custom_filters=se_filters)
 
     @classmethod
     def from_dict(cls, data: Dict[str, Any]) -> "ConditionalRouter":
@@ -163,6 +171,8 @@ def from_dict(cls, data: Dict[str, Any]) -> "ConditionalRouter":
         for route in routes:
             # output_type needs to be deserialized from a string to a type
             route["output_type"] = deserialize_type(route["output_type"])
+        for name, filter_func in init_params.get("custom_filters", {}).items():
+            init_params["custom_filters"][name] = deserialize_callable(filter_func) if filter_func else None
         return default_from_dict(cls, data)
 
     def run(self, **kwargs):
@@ -185,6 +195,7 @@ def run(self, **kwargs):
         """
         # Create a Jinja native environment to evaluate the condition templates as Python expressions
         env = NativeEnvironment()
+        env.filters.update(self.custom_filters)
 
         for route in self.routes:
             try:


Test patch:
diff --git a/test/components/routers/test_conditional_router.py b/test/components/routers/test_conditional_router.py
--- a/test/components/routers/test_conditional_router.py
+++ b/test/components/routers/test_conditional_router.py
@@ -12,6 +12,11 @@
 from haystack.dataclasses import ChatMessage
 
 
+def custom_filter_to_sede(value):
+    """splits by hyphen and returns the first part"""
+    return int(value.split("-")[0])
+
+
 class TestRouter:
     @pytest.fixture
     def routes(self):
@@ -288,3 +293,47 @@ def test_router_serialization_idempotence(self):
         router_dict_first_invocation = copy.deepcopy(router.to_dict())
         router_dict_second_invocation = router.to_dict()
         assert router_dict_first_invocation == router_dict_second_invocation
+
+    def test_custom_filter(self):
+        routes = [
+            {
+                "condition": "{{phone_num|get_area_code == 123}}",
+                "output": "Phone number has a 123 area code",
+                "output_name": "good_phone_num",
+                "output_type": str,
+            },
+            {
+                "condition": "{{phone_num|get_area_code != 123}}",
+                "output": "Phone number does not have 123 area code",
+                "output_name": "bad_phone_num",
+                "output_type": str,
+            },
+        ]
+
+        router = ConditionalRouter(routes, custom_filters={"get_area_code": custom_filter_to_sede})
+        kwargs = {"phone_num": "123-456-7890"}
+        result = router.run(**kwargs)
+        assert result == {"good_phone_num": "Phone number has a 123 area code"}
+        kwargs = {"phone_num": "321-456-7890"}
+        result = router.run(**kwargs)
+        assert result == {"bad_phone_num": "Phone number does not have 123 area code"}
+
+    def test_sede_with_custom_filter(self):
+        routes = [
+            {
+                "condition": "{{ test|custom_filter_to_sede == 123 }}",
+                "output": "123",
+                "output_name": "test",
+                "output_type": int,
+            }
+        ]
+        custom_filters = {"custom_filter_to_sede": custom_filter_to_sede}
+        router = ConditionalRouter(routes, custom_filters=custom_filters)
+        kwargs = {"test": "123-456-789"}
+        result = router.run(**kwargs)
+        assert result == {"test": 123}
+        serialized_router = router.to_dict()
+        deserialized_router = ConditionalRouter.from_dict(serialized_router)
+        assert deserialized_router.custom_filters == router.custom_filters
+        assert deserialized_router.custom_filters["custom_filter_to_sede"]("123-456-789") == 123
+        assert result == deserialized_router.run(**kwargs)