diff --git a/src/bpmn_assistant/prompts/create_bpmn.txt b/src/bpmn_assistant/prompts/create_bpmn.txt index 9dadbf3..485c472 100644 --- a/src/bpmn_assistant/prompts/create_bpmn.txt +++ b/src/bpmn_assistant/prompts/create_bpmn.txt @@ -1,3 +1,5 @@ +The BPMN JSON representation uses a sequence of elements to describe the process. Each element is executed in order based on its position in the "process" array unless gateways (exclusive or parallel) specify branching paths. + # Representation of various BPMN elements ## Tasks @@ -25,11 +27,15 @@ Specify the event type in the 'type' field. Only "startEvent" and "endEvent" opt ## Gateways +Gateways determine process flow based on conditions or parallel tasks. + ### Exclusive gateway -Each branch has a condition and a path of elements that are executed if the condition is met. +Each branch must include a condition and an array of elements that are executed if the condition is met. +If a branch has an empty "path", it leads to the first element after the exclusive gateway. If the branch does not lead to the next element in the process (for example, it goes back to a previous element), specify the next element id. If the branch leads to the next element in the process, do not specify the next element id. +If the process needs to end under a specific condition, you must explicitly include an end event in that branch's "path". If no end event is provided, the process will automatically continue to the next task in the sequence. ```json { @@ -55,6 +61,8 @@ If the branch leads to the next element in the process, do not specify the next ### Parallel gateway +Specify "branches" as an array of arrays, where each sub-array lists elements executed in parallel. + ```json { "type": String = "parallelGateway", diff --git a/src/bpmn_assistant/services/bpmn_modeling_service.py b/src/bpmn_assistant/services/bpmn_modeling_service.py index c3fb17a..df9bb60 100644 --- a/src/bpmn_assistant/services/bpmn_modeling_service.py +++ b/src/bpmn_assistant/services/bpmn_modeling_service.py @@ -1,3 +1,4 @@ +import json import traceback from importlib import resources @@ -8,7 +9,7 @@ BpmnEditorService, define_change_request, ) -from bpmn_assistant.utils import prepare_prompt, message_history_to_string +from bpmn_assistant.utils import message_history_to_string, prepare_prompt class BpmnModelingService: @@ -46,8 +47,12 @@ def create_bpmn( response = llm_facade.call(prompt) try: - self._validate_bpmn(response["process"]) - return response["process"] # Return the process if it's valid + process = response["process"] + self._validate_bpmn(process) + logger.debug( + f"Generated BPMN process:\n{json.dumps(process, indent=2)}" + ) + return process # Return the process if it's valid except Exception as e: error_type = ( "LLM call failed" if response is None else "Invalid process" diff --git a/src/bpmn_assistant/services/bpmn_process_transformer.py b/src/bpmn_assistant/services/bpmn_process_transformer.py index e1d13af..c2ad759 100644 --- a/src/bpmn_assistant/services/bpmn_process_transformer.py +++ b/src/bpmn_assistant/services/bpmn_process_transformer.py @@ -54,6 +54,20 @@ def handle_exclusive_gateway( ) for branch in element["branches"]: + + if not branch.get("path"): + # Connect the exclusive gateway to the next element in the process + if next_element_id: + flows.append( + { + "id": f"{element['id']}-{next_element_id}", + "sourceRef": element["id"], + "targetRef": next_element_id, + "condition": branch.get("condition", None), + } + ) + continue # Skip further processing for empty branches + branch_structure = self.transform( branch["path"], join_gateway_id or next_element_id ) @@ -175,7 +189,7 @@ def handle_parallel_gateway(element: dict) -> str: "condition": None, } ) - elif next_element_id: + elif next_element_id and element["type"] != "endEvent": # Add the flow between the current element and the next element in the process flows.append( { diff --git a/tests/conftest.py b/tests/conftest.py index a0dcae7..e964888 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,7 +4,7 @@ from dotenv import load_dotenv from bpmn_assistant.core import LLMFacade, MessageItem -from bpmn_assistant.core.enums import Provider, AnthropicModels, OpenAIModels +from bpmn_assistant.core.enums import AnthropicModels, OpenAIModels, Provider from tests.fixtures.bpmn_loader import load_bpmn @@ -12,7 +12,7 @@ def anthropic_facade(): load_dotenv(override=True) api_key = os.getenv("ANTHROPIC_API_KEY") - return LLMFacade(Provider.ANTHROPIC, api_key, AnthropicModels.HAIKU.value) + return LLMFacade(Provider.ANTHROPIC, api_key, AnthropicModels.HAIKU_3_5.value) @pytest.fixture @@ -47,13 +47,52 @@ def empty_gateway_path_process(): } ], }, - {"condition": "Condition B", "path": [], "next": "end"}, + {"condition": "Condition B", "path": []}, ], }, {"type": "endEvent", "id": "end"}, ] +@pytest.fixture +def eg_end_event_in_path_process(): + """ + Description: A process that contains an exclusive gateway with an end event in one of the paths. + """ + return [ + {"type": "startEvent", "id": "start"}, + { + "type": "exclusiveGateway", + "id": "exclusive1", + "label": "Decision Point", + "has_join": False, + "branches": [ + { + "condition": "Condition A", + "path": [ + { + "type": "task", + "id": "task1", + "label": "Perform the first task", + } + ], + }, + { + "condition": "Condition B", + "path": [ + { + "type": "endEvent", + "id": "end1", + } + ], + }, + ], + }, + {"type": "task", "id": "task2", "label": "Perform the second task"}, + {"type": "endEvent", "id": "end2"}, + ] + + @pytest.fixture def order_process(): """ diff --git a/tests/services/test_bpmn_process_transformer.py b/tests/services/test_bpmn_process_transformer.py new file mode 100644 index 0000000..c9dca5e --- /dev/null +++ b/tests/services/test_bpmn_process_transformer.py @@ -0,0 +1,188 @@ +from bpmn_assistant.services.bpmn_process_transformer import BpmnProcessTransformer + + +class TestBpmnProcessTransformer: + + def test_transform_exclusive_gateway_with_empty_path( + self, empty_gateway_path_process + ): + + self.transformer = BpmnProcessTransformer() + + result = self.transformer.transform(empty_gateway_path_process) + + expected = { + "elements": [ + { + "id": "start", + "type": "startEvent", + "label": None, + "incoming": [], + "outgoing": ["start-task1"], + }, + { + "id": "task1", + "type": "task", + "label": "Perform a simple task", + "incoming": ["start-task1"], + "outgoing": ["task1-task2"], + }, + { + "id": "task2", + "type": "task", + "label": "Perform a second task", + "incoming": ["task1-task2"], + "outgoing": ["task2-exclusive1"], + }, + { + "id": "exclusive1", + "type": "exclusiveGateway", + "label": "Decision Point", + "incoming": ["task2-exclusive1"], + "outgoing": ["exclusive1-task3", "exclusive1-end"], + }, + { + "id": "task3", + "type": "task", + "label": "Perform a third task", + "incoming": ["exclusive1-task3"], + "outgoing": ["task3-end"], + }, + { + "id": "end", + "type": "endEvent", + "label": None, + "incoming": ["task3-end", "exclusive1-end"], + "outgoing": [], + }, + ], + "flows": [ + { + "id": "start-task1", + "sourceRef": "start", + "targetRef": "task1", + "condition": None, + }, + { + "id": "task1-task2", + "sourceRef": "task1", + "targetRef": "task2", + "condition": None, + }, + { + "id": "task2-exclusive1", + "sourceRef": "task2", + "targetRef": "exclusive1", + "condition": None, + }, + { + "id": "task3-end", + "sourceRef": "task3", + "targetRef": "end", + "condition": None, + }, + { + "id": "exclusive1-task3", + "sourceRef": "exclusive1", + "targetRef": "task3", + "condition": "Condition A", + }, + { + "id": "exclusive1-end", + "sourceRef": "exclusive1", + "targetRef": "end", + "condition": "Condition B", + }, + ], + } + + assert result == expected + + def test_transform_exclusive_gateway_with_end_event_in_path( + self, eg_end_event_in_path_process + ): + + self.transformer = BpmnProcessTransformer() + + result = self.transformer.transform(eg_end_event_in_path_process) + + expected = { + "elements": [ + { + "id": "start", + "type": "startEvent", + "label": None, + "incoming": [], + "outgoing": ["start-exclusive1"], + }, + { + "id": "exclusive1", + "type": "exclusiveGateway", + "label": "Decision Point", + "incoming": ["start-exclusive1"], + "outgoing": ["exclusive1-task1", "exclusive1-end1"], + }, + { + "id": "task1", + "type": "task", + "label": "Perform the first task", + "incoming": ["exclusive1-task1"], + "outgoing": ["task1-task2"], + }, + { + "id": "end1", + "type": "endEvent", + "label": None, + "incoming": ["exclusive1-end1"], + "outgoing": [], + }, + { + "id": "task2", + "type": "task", + "label": "Perform the second task", + "incoming": ["task1-task2"], + "outgoing": ["task2-end2"], + }, + { + "id": "end2", + "type": "endEvent", + "label": None, + "incoming": ["task2-end2"], + "outgoing": [], + }, + ], + "flows": [ + { + "id": "start-exclusive1", + "sourceRef": "start", + "targetRef": "exclusive1", + "condition": None, + }, + { + "id": "task1-task2", + "sourceRef": "task1", + "targetRef": "task2", + "condition": None, + }, + { + "id": "exclusive1-task1", + "sourceRef": "exclusive1", + "targetRef": "task1", + "condition": "Condition A", + }, + { + "id": "exclusive1-end1", + "sourceRef": "exclusive1", + "targetRef": "end1", + "condition": "Condition B", + }, + { + "id": "task2-end2", + "sourceRef": "task2", + "targetRef": "end2", + "condition": None, + }, + ], + } + + assert result == expected