From 19c9514d54d62b1eb3918a27efd49feca009756f Mon Sep 17 00:00:00 2001 From: Yuqi Tang Date: Wed, 2 Jul 2025 15:56:57 -0700 Subject: [PATCH] feat: update if-else component (#8756) * add ifelse component * [autofix.ci] apply automated fixes * fix test * fix test * fix test * fix tests * [autofix.ci] apply automated fixes * fix tests * fix test --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Lucas Democh --- .../components/logic/conditional_router.py | 63 +++++++++++---- .../starter_projects/Youtube Analysis.json | 77 +++++++++++++------ .../tests/unit/graph/graph/test_cycles.py | 36 +++++---- 3 files changed, 119 insertions(+), 57 deletions(-) diff --git a/src/backend/base/langflow/components/logic/conditional_router.py b/src/backend/base/langflow/components/logic/conditional_router.py index 9caf46ebe..7afc7d406 100644 --- a/src/backend/base/langflow/components/logic/conditional_router.py +++ b/src/backend/base/langflow/components/logic/conditional_router.py @@ -22,20 +22,31 @@ class ConditionalRouterComponent(Component): info="The primary text input for the operation.", required=True, ), + DropdownInput( + name="operator", + display_name="Operator", + options=[ + "equals", + "not equals", + "contains", + "starts with", + "ends with", + "regex", + "less than", + "less than or equal", + "greater than", + "greater than or equal", + ], + info="The operator to apply for comparing the texts.", + value="equals", + real_time_refresh=True, + ), MessageTextInput( name="match_text", display_name="Match Text", info="The text input to compare against.", required=True, ), - DropdownInput( - name="operator", - display_name="Operator", - options=["equals", "not equals", "contains", "starts with", "ends with", "regex"], - info="The operator to apply for comparing the texts.", - value="equals", - real_time_refresh=True, - ), BoolInput( name="case_sensitive", display_name="Case Sensitive", @@ -44,9 +55,15 @@ class ConditionalRouterComponent(Component): advanced=True, ), MessageInput( - name="message", - display_name="Alternative Output", - info="The message to pass through either route.", + name="true_case_message", + display_name="Case True", + info="The message to pass if the condition is True.", + advanced=True, + ), + MessageInput( + name="false_case_message", + display_name="Case False", + info="The message to pass if the condition is False.", advanced=True, ), IntInput( @@ -94,6 +111,20 @@ class ConditionalRouterComponent(Component): return bool(re.match(match_text, input_text)) except re.error: return False # Return False if the regex is invalid + if operator in ["less than", "less than or equal", "greater than", "greater than or equal"]: + try: + input_num = float(input_text) + match_num = float(match_text) + if operator == "less than": + return input_num < match_num + if operator == "less than or equal": + return input_num <= match_num + if operator == "greater than": + return input_num > match_num + if operator == "greater than or equal": + return input_num >= match_num + except ValueError: + return False # Invalid number format for comparison return False def iterate_and_stop_once(self, route_to_stop: str): @@ -109,9 +140,9 @@ class ConditionalRouterComponent(Component): self.input_text, self.match_text, self.operator, case_sensitive=self.case_sensitive ) if result: - self.status = self.message + self.status = self.true_case_message self.iterate_and_stop_once("false_result") - return self.message + return self.true_case_message self.iterate_and_stop_once("true_result") return Message(content="") @@ -120,9 +151,9 @@ class ConditionalRouterComponent(Component): self.input_text, self.match_text, self.operator, case_sensitive=self.case_sensitive ) if not result: - self.status = self.message + self.status = self.false_case_message self.iterate_and_stop_once("true_result") - return self.message + return self.false_case_message self.iterate_and_stop_once("false_result") return Message(content="") @@ -130,8 +161,6 @@ class ConditionalRouterComponent(Component): if field_name == "operator": if field_value == "regex": build_config.pop("case_sensitive", None) - - # Ensure case_sensitive is present for all other operators elif "case_sensitive" not in build_config: case_sensitive_input = next( (input_field for input_field in self.inputs if input_field.name == "case_sensitive"), None diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Youtube Analysis.json b/src/backend/base/langflow/initial_setup/starter_projects/Youtube Analysis.json index 36f00dd18..c9d1f36a8 100644 --- a/src/backend/base/langflow/initial_setup/starter_projects/Youtube Analysis.json +++ b/src/backend/base/langflow/initial_setup/starter_projects/Youtube Analysis.json @@ -2449,7 +2449,7 @@ "show": true, "title_case": false, "type": "code", - "value": "import re\n\nfrom langflow.custom.custom_component.component import Component\nfrom langflow.io import BoolInput, DropdownInput, IntInput, MessageInput, MessageTextInput, Output\nfrom langflow.schema.message import Message\n\n\nclass ConditionalRouterComponent(Component):\n display_name = \"If-Else\"\n description = \"Routes an input message to a corresponding output based on text comparison.\"\n icon = \"split\"\n name = \"ConditionalRouter\"\n\n def __init__(self, *args, **kwargs):\n super().__init__(*args, **kwargs)\n self.__iteration_updated = False\n\n inputs = [\n MessageTextInput(\n name=\"input_text\",\n display_name=\"Text Input\",\n info=\"The primary text input for the operation.\",\n required=True,\n ),\n MessageTextInput(\n name=\"match_text\",\n display_name=\"Match Text\",\n info=\"The text input to compare against.\",\n required=True,\n ),\n DropdownInput(\n name=\"operator\",\n display_name=\"Operator\",\n options=[\"equals\", \"not equals\", \"contains\", \"starts with\", \"ends with\", \"regex\"],\n info=\"The operator to apply for comparing the texts.\",\n value=\"equals\",\n real_time_refresh=True,\n ),\n BoolInput(\n name=\"case_sensitive\",\n display_name=\"Case Sensitive\",\n info=\"If true, the comparison will be case sensitive.\",\n value=True,\n advanced=True,\n ),\n MessageInput(\n name=\"message\",\n display_name=\"Alternative Output\",\n info=\"The message to pass through either route.\",\n advanced=True,\n ),\n IntInput(\n name=\"max_iterations\",\n display_name=\"Max Iterations\",\n info=\"The maximum number of iterations for the conditional router.\",\n value=10,\n advanced=True,\n ),\n DropdownInput(\n name=\"default_route\",\n display_name=\"Default Route\",\n options=[\"true_result\", \"false_result\"],\n info=\"The default route to take when max iterations are reached.\",\n value=\"false_result\",\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"True\", name=\"true_result\", method=\"true_response\", group_outputs=True),\n Output(display_name=\"False\", name=\"false_result\", method=\"false_response\", group_outputs=True),\n ]\n\n def _pre_run_setup(self):\n self.__iteration_updated = False\n\n def evaluate_condition(self, input_text: str, match_text: str, operator: str, *, case_sensitive: bool) -> bool:\n if not case_sensitive and operator != \"regex\":\n input_text = input_text.lower()\n match_text = match_text.lower()\n\n if operator == \"equals\":\n return input_text == match_text\n if operator == \"not equals\":\n return input_text != match_text\n if operator == \"contains\":\n return match_text in input_text\n if operator == \"starts with\":\n return input_text.startswith(match_text)\n if operator == \"ends with\":\n return input_text.endswith(match_text)\n if operator == \"regex\":\n try:\n return bool(re.match(match_text, input_text))\n except re.error:\n return False # Return False if the regex is invalid\n return False\n\n def iterate_and_stop_once(self, route_to_stop: str):\n if not self.__iteration_updated:\n self.update_ctx({f\"{self._id}_iteration\": self.ctx.get(f\"{self._id}_iteration\", 0) + 1})\n self.__iteration_updated = True\n if self.ctx.get(f\"{self._id}_iteration\", 0) >= self.max_iterations and route_to_stop == self.default_route:\n route_to_stop = \"true_result\" if route_to_stop == \"false_result\" else \"false_result\"\n self.stop(route_to_stop)\n\n def true_response(self) -> Message:\n result = self.evaluate_condition(\n self.input_text, self.match_text, self.operator, case_sensitive=self.case_sensitive\n )\n if result:\n self.status = self.message\n self.iterate_and_stop_once(\"false_result\")\n return self.message\n self.iterate_and_stop_once(\"true_result\")\n return Message(content=\"\")\n\n def false_response(self) -> Message:\n result = self.evaluate_condition(\n self.input_text, self.match_text, self.operator, case_sensitive=self.case_sensitive\n )\n if not result:\n self.status = self.message\n self.iterate_and_stop_once(\"true_result\")\n return self.message\n self.iterate_and_stop_once(\"false_result\")\n return Message(content=\"\")\n\n def update_build_config(self, build_config: dict, field_value: str, field_name: str | None = None) -> dict:\n if field_name == \"operator\":\n if field_value == \"regex\":\n build_config.pop(\"case_sensitive\", None)\n\n # Ensure case_sensitive is present for all other operators\n elif \"case_sensitive\" not in build_config:\n case_sensitive_input = next(\n (input_field for input_field in self.inputs if input_field.name == \"case_sensitive\"), None\n )\n if case_sensitive_input:\n build_config[\"case_sensitive\"] = case_sensitive_input.to_dict()\n return build_config\n" + "value": "import re\n\nfrom langflow.custom.custom_component.component import Component\nfrom langflow.io import BoolInput, DropdownInput, IntInput, MessageInput, MessageTextInput, Output\nfrom langflow.schema.message import Message\n\n\nclass ConditionalRouterComponent(Component):\n display_name = \"If-Else\"\n description = \"Routes an input message to a corresponding output based on text comparison.\"\n icon = \"split\"\n name = \"ConditionalRouter\"\n\n def __init__(self, *args, **kwargs):\n super().__init__(*args, **kwargs)\n self.__iteration_updated = False\n\n inputs = [\n MessageTextInput(\n name=\"input_text\",\n display_name=\"Text Input\",\n info=\"The primary text input for the operation.\",\n required=True,\n ),\n DropdownInput(\n name=\"operator\",\n display_name=\"Operator\",\n options=[\n \"equals\",\n \"not equals\",\n \"contains\",\n \"starts with\",\n \"ends with\",\n \"regex\",\n \"less than\",\n \"less than or equal\",\n \"greater than\",\n \"greater than or equal\",\n ],\n info=\"The operator to apply for comparing the texts.\",\n value=\"equals\",\n real_time_refresh=True,\n ),\n MessageTextInput(\n name=\"match_text\",\n display_name=\"Match Text\",\n info=\"The text input to compare against.\",\n required=True,\n ),\n BoolInput(\n name=\"case_sensitive\",\n display_name=\"Case Sensitive\",\n info=\"If true, the comparison will be case sensitive.\",\n value=True,\n advanced=True,\n ),\n MessageInput(\n name=\"true_case_message\",\n display_name=\"Case True\",\n info=\"The message to pass if the condition is True.\",\n advanced=True,\n ),\n MessageInput(\n name=\"false_case_message\",\n display_name=\"Case False\",\n info=\"The message to pass if the condition is False.\",\n advanced=True,\n ),\n IntInput(\n name=\"max_iterations\",\n display_name=\"Max Iterations\",\n info=\"The maximum number of iterations for the conditional router.\",\n value=10,\n advanced=True,\n ),\n DropdownInput(\n name=\"default_route\",\n display_name=\"Default Route\",\n options=[\"true_result\", \"false_result\"],\n info=\"The default route to take when max iterations are reached.\",\n value=\"false_result\",\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"True\", name=\"true_result\", method=\"true_response\", group_outputs=True),\n Output(display_name=\"False\", name=\"false_result\", method=\"false_response\", group_outputs=True),\n ]\n\n def _pre_run_setup(self):\n self.__iteration_updated = False\n\n def evaluate_condition(self, input_text: str, match_text: str, operator: str, *, case_sensitive: bool) -> bool:\n if not case_sensitive and operator != \"regex\":\n input_text = input_text.lower()\n match_text = match_text.lower()\n\n if operator == \"equals\":\n return input_text == match_text\n if operator == \"not equals\":\n return input_text != match_text\n if operator == \"contains\":\n return match_text in input_text\n if operator == \"starts with\":\n return input_text.startswith(match_text)\n if operator == \"ends with\":\n return input_text.endswith(match_text)\n if operator == \"regex\":\n try:\n return bool(re.match(match_text, input_text))\n except re.error:\n return False # Return False if the regex is invalid\n if operator in [\"less than\", \"less than or equal\", \"greater than\", \"greater than or equal\"]:\n try:\n input_num = float(input_text)\n match_num = float(match_text)\n if operator == \"less than\":\n return input_num < match_num\n if operator == \"less than or equal\":\n return input_num <= match_num\n if operator == \"greater than\":\n return input_num > match_num\n if operator == \"greater than or equal\":\n return input_num >= match_num\n except ValueError:\n return False # Invalid number format for comparison\n return False\n\n def iterate_and_stop_once(self, route_to_stop: str):\n if not self.__iteration_updated:\n self.update_ctx({f\"{self._id}_iteration\": self.ctx.get(f\"{self._id}_iteration\", 0) + 1})\n self.__iteration_updated = True\n if self.ctx.get(f\"{self._id}_iteration\", 0) >= self.max_iterations and route_to_stop == self.default_route:\n route_to_stop = \"true_result\" if route_to_stop == \"false_result\" else \"false_result\"\n self.stop(route_to_stop)\n\n def true_response(self) -> Message:\n result = self.evaluate_condition(\n self.input_text, self.match_text, self.operator, case_sensitive=self.case_sensitive\n )\n if result:\n self.status = self.true_case_message\n self.iterate_and_stop_once(\"false_result\")\n return self.true_case_message\n self.iterate_and_stop_once(\"true_result\")\n return Message(content=\"\")\n\n def false_response(self) -> Message:\n result = self.evaluate_condition(\n self.input_text, self.match_text, self.operator, case_sensitive=self.case_sensitive\n )\n if not result:\n self.status = self.false_case_message\n self.iterate_and_stop_once(\"true_result\")\n return self.false_case_message\n self.iterate_and_stop_once(\"false_result\")\n return Message(content=\"\")\n\n def update_build_config(self, build_config: dict, field_value: str, field_name: str | None = None) -> dict:\n if field_name == \"operator\":\n if field_value == \"regex\":\n build_config.pop(\"case_sensitive\", None)\n elif \"case_sensitive\" not in build_config:\n case_sensitive_input = next(\n (input_field for input_field in self.inputs if input_field.name == \"case_sensitive\"), None\n )\n if case_sensitive_input:\n build_config[\"case_sensitive\"] = case_sensitive_input.to_dict()\n return build_config\n" }, "default_route": { "_input_type": "DropdownInput", @@ -2474,6 +2474,29 @@ "type": "str", "value": "false_result" }, + "false_case_message": { + "_input_type": "MessageInput", + "advanced": true, + "display_name": "Case False", + "dynamic": false, + "info": "The message to pass if the condition is False.", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "false_case_message", + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "type": "str", + "value": "" + }, "input_text": { "_input_type": "MessageTextInput", "advanced": false, @@ -2538,29 +2561,6 @@ "type": "int", "value": 10 }, - "message": { - "_input_type": "MessageInput", - "advanced": true, - "display_name": "Alternative Output", - "dynamic": false, - "info": "The message to pass through either route.", - "input_types": [ - "Message" - ], - "list": false, - "list_add_label": "Add More", - "load_from_db": false, - "name": "message", - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "type": "str", - "value": "" - }, "operator": { "_input_type": "DropdownInput", "advanced": false, @@ -2576,7 +2576,11 @@ "contains", "starts with", "ends with", - "regex" + "regex", + "less than", + "less than or equal", + "greater than", + "greater than or equal" ], "options_metadata": [], "placeholder": "", @@ -2588,6 +2592,29 @@ "trace_as_metadata": true, "type": "str", "value": "regex" + }, + "true_case_message": { + "_input_type": "MessageInput", + "advanced": true, + "display_name": "Case True", + "dynamic": false, + "info": "The message to pass if the condition is True.", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "true_case_message", + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "type": "str", + "value": "" } }, "tool_mode": false diff --git a/src/backend/tests/unit/graph/graph/test_cycles.py b/src/backend/tests/unit/graph/graph/test_cycles.py index 744e08d15..d1b8e7786 100644 --- a/src/backend/tests/unit/graph/graph/test_cycles.py +++ b/src/backend/tests/unit/graph/graph/test_cycles.py @@ -32,15 +32,16 @@ class Concatenate(Component): def test_cycle_in_graph(): chat_input = ChatInput(_id="chat_input") router = ConditionalRouterComponent(_id="router", default_route="true_result") - # Use router's message output instead of false_response - chat_input.set(input_value=router.message) + # Use router's true case message output instead of message + chat_input.set(input_value=router.true_case_message) concat_component = Concatenate(_id="concatenate") concat_component.set(text=chat_input.message_response) router.set( input_text=chat_input.message_response, match_text="testtesttesttest", operator="equals", - message=concat_component.concatenate, + true_case_message=concat_component.concatenate, + false_case_message=concat_component.concatenate, ) text_output = TextOutputComponent(_id="text_output") text_output.set(input_value=router.true_response) @@ -83,14 +84,16 @@ def test_cycle_in_graph(): def test_cycle_in_graph_max_iterations(): text_input = TextInputComponent(_id="text_input") router = ConditionalRouterComponent(_id="router") + # Connect text_input to router's input text_input.set(input_value=router.false_response) concat_component = Concatenate(_id="concatenate") concat_component.set(text=text_input.text_response) + # Connect concatenate output back to router's input to create cycle router.set( input_text=text_input.text_response, match_text="testtesttesttest", operator="equals", - message=concat_component.concatenate, + false_case_message=concat_component.concatenate, ) text_output = TextOutputComponent(_id="text_output") text_output.set(input_value=router.true_response) @@ -100,7 +103,7 @@ def test_cycle_in_graph_max_iterations(): graph = Graph(text_input, chat_output) assert graph.is_cyclic is True - # Run queue should contain chat_input and not router + # Run queue should contain text_input and not router assert "text_input" in graph._run_queue assert "router" not in graph._run_queue @@ -109,24 +112,24 @@ def test_cycle_in_graph_max_iterations(): def test_that_outputs_cache_is_set_to_false_in_cycle(): - chat_input = ChatInput(_id="chat_input") + text_input = TextInputComponent(_id="text_input") router = ConditionalRouterComponent(_id="router") - # Use router's message output instead of false_response - chat_input.set(input_value=router.message) concat_component = Concatenate(_id="concatenate") - concat_component.set(text=chat_input.message_response) + text_input.set(input_value=router.false_response) + concat_component.set(text=text_input.text_response) router.set( - input_text=chat_input.message_response, + input_text=text_input.text_response, match_text="testtesttesttest", operator="equals", - message=concat_component.concatenate, + true_case_message=concat_component.concatenate, + false_case_message=concat_component.concatenate, ) text_output = TextOutputComponent(_id="text_output") text_output.set(input_value=router.true_response) chat_output = ChatOutput(_id="chat_output") chat_output.set(input_value=text_output.text_response) - graph = Graph(chat_input, chat_output) + graph = Graph(text_input, chat_output) cycle_vertices = find_cycle_vertices(graph._get_edges_as_list_of_tuples()) cycle_outputs_lists = [ graph.vertex_map[vertex_id].custom_component._outputs_map.values() for vertex_id in cycle_vertices @@ -167,7 +170,8 @@ def test_updated_graph_with_prompts(): input_text=openai_component_1.text_response, match_text=chat_input.message_response, operator="contains", - message=openai_component_1.text_response, + true_case_message=openai_component_1.text_response, + false_case_message=openai_component_1.text_response, ) # Second prompt: After the last try, provide a new hint @@ -236,7 +240,8 @@ def test_updated_graph_with_max_iterations(): input_text=openai_component_1.text_response, match_text=chat_input.message_response, operator="contains", - message=openai_component_1.text_response, + true_case_message=openai_component_1.text_response, + false_case_message=openai_component_1.text_response, ) # Second prompt: After the last try, provide a new hint @@ -290,7 +295,8 @@ def test_conditional_router_max_iterations(): input_text=text_input.text_response, match_text="bacon", operator="equals", - message="This message should not be routed to true_result", + true_case_message="This message should not be routed to true_result", + false_case_message="This message should not be routed to false_result", max_iterations=5, default_route="true_result", )