├── .gitignore ├── LICENSE ├── README.md ├── examples ├── product_order.py ├── product_order_flow └── product_order_flow.png ├── product_order_flow ├── product_order_flow.png ├── pyproject.toml └── src └── goalchain ├── __init__.py └── goalchain.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | .DS_Store 162 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 adlumal 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GoalChain 2 | GoalChain is a simple but effective framework for enabling goal-orientated conversation flows for human-LLM and LLM-LLM interaction. 3 | 4 | ## Installation 5 | 6 | ```console 7 | pip install goalchain 8 | ``` 9 | 10 | ## Getting started 11 | 12 | Let's import the `Field`, `ValidationError`, `Goal` and `GoalChain` classes, which are the basis for the conversation flow. 13 | ```py 14 | from goalchain import Field, ValidationError, Goal, GoalChain 15 | ``` 16 | 17 | In this example we will create an AI assistant whose goal is to collect information from a customer about their desired product order. We define the information to be collected using `Field` objects within the `ProductOrderGoal`, which is a child of `Goal`: 18 | * the product name, 19 | * the customer's email, and 20 | * quantity 21 | 22 | We also define a validator for the quantity (after type casting to an int). `ValidationError` is used to pass error messages back to conversation. These messages should be human-readable. 23 | 24 | `format_hint` is a natural language type hint for the LLM's JSON mode output. 25 | 26 | ```py 27 | def quantity_validator(value): 28 | try: 29 | value = int(value) 30 | except (ValueError, TypeError): 31 | raise ValidationError("Quantity must be a valid number") 32 | if value <= 0: 33 | raise ValidationError("Quantity cannot be less than one") 34 | if value > 100: 35 | raise ValidationError("Quantity cannot be greater than 100") 36 | return value 37 | 38 | class ProductOrderGoal(Goal): 39 | product_name = Field("product to be ordered", format_hint="a string") 40 | customer_email = Field("customer email", format_hint="a string") 41 | quantity = Field("quantity of product", format_hint="an integer", validator=quantity_validator) 42 | ``` 43 | 44 | In case the customer changes their mind, let's create another `Goal` child class called `OrderCancelGoal`. 45 | We will request an optional reason for the customer's cancellation of the ongoing order. Through specifying that the field is "(optional)" in the description, the LLM will know it isn't necessary to achieve the goal. 46 | 47 | ```py 48 | class OrderCancelGoal(Goal): 49 | reason = Field("reason for order cancellation (optional)", format_hint="a string") 50 | ``` 51 | 52 | Note that the field object names, such as `product_name` are passed directly to the LLM prompt, and so they are part of the prompt-engineering task, as is every other string. 53 | 54 | Essentially the classes we defined are like forms to be filled out by the customer, but they lack instructions. Let's add those by instantiating the classes as objects. 55 | 56 | ```py 57 | product_order_goal = ProductOrderGoal( 58 | label="product_order", 59 | goal="to obtain information on an order to be made", 60 | opener="I see you are trying to order a product, how can I help you?", 61 | out_of_scope="Ask the user to contact sales team at sales@acme.com" 62 | ) 63 | 64 | order_cancel_goal = OrderCancelGoal( 65 | label="cancel_current_order", 66 | goal="to obtain the reason for the cancellation", 67 | opener="I see you are trying to cancel the current order, how can I help you?", 68 | out_of_scope="Ask the user to contact the support team at support@acme.com", 69 | confirm=False 70 | ) 71 | ``` 72 | 73 | We define 74 | * an internal label to be used (also part of our prompt-engineering task), 75 | * the goal, expressed as a "to ..." statement, 76 | * a default `opener` - something the AI assistant will use given no prior input, 77 | * and importantly, instructions for the AI assistant as to what they should do in case of an out of scope user query 78 | 79 | The `confirm` flag determines whether the AI assistant will ask for confirmation once it has all of the required information defined using the `Field` objects. It is `True` by default. We don't need a confirmation for the order cancellation goal, since it is in itself already a kind of confirmation. 80 | 81 | Next we need to connect the goals together. 82 | 83 | ```py 84 | product_order_goal.connect(goal=order_cancel_goal, 85 | user_goal="to cancel the current order", 86 | hand_over=True, 87 | keep_messages=True) 88 | 89 | ``` 90 | 91 | The `user_goal` is another "to ..." statement. Without `hand_over=True` the AI agent would reply with the canned `opener`. Setting it to `True` ensures the conversation flows smoothly. Sometimes you may want a canned response, other times not. 92 | 93 | `keep_messages=True` means the `order_cancel_goal` will receive the full history of the conversation with `product_order_goal`, otherwise it will be wiped. Again, sometimes a wipe of the conversation history may be desired, such as when simulating different AI personalities. 94 | 95 | Let's also consider the possibility of a really undecisive customer. We should also give them the option to "cancel the cancellation". 96 | 97 | ```py 98 | order_cancel_goal.connect(goal=product_order_goal, 99 | user_goal="to continue with the order anyway", 100 | hand_over=True, 101 | keep_messages=True) 102 | ``` 103 | 104 | At some point you may have wondered if you can make a goal without any `Field` objects. You can! Such a goal is a routing goal defined only be the connections it has. This is useful for example in a voice-mail menu system. 105 | 106 | You may also be curious whether you can connect a goal to itself. You can! This is useful for example when using `confirm=False` with the `Goal`-inheriting object, where you require sequential user input of some variety. 107 | 108 | You can also chain connects, e.g. `goal.connect(...).connect(...).connect(...)` 109 | 110 | Finally, let's use `GoalChain` to set the initial goal and test our AI sales assistant! 111 | 112 | ```py 113 | goal_chain = GoalChain(product_order_goal) 114 | ``` 115 | 116 | Note that each goal can use a separate LLM API as enabled by [LiteLLM](https://github.com/BerriAI/litellm), and if you have the required environment variables set, you can use any model from the supported [model providers](https://docs.litellm.ai/docs/providers). 117 | 118 | The default model is `"gpt-4-1106-preview"`, that is: 119 | 120 | ```py 121 | product_order_goal = ProductOrderGoal(... 122 | model="gpt-4-1106-preview", 123 | json_model="gpt-4-1106-preview" 124 | ) 125 | ``` 126 | 127 | You can also pass LiteLLM [common parameters](https://litellm.vercel.app/docs/completion/input) using `params`, for example: 128 | 129 | ```py 130 | product_order_goal = ProductOrderGoal(... 131 | model="gpt-4-1106-preview", 132 | json_model="gpt-4-1106-preview", 133 | params={"temperature": 1.5, "max_tokens": 10} 134 | ) 135 | ``` 136 | 137 | You can also use `params` to call local models [using VLLM](https://docs.litellm.ai/docs/providers/vllm). 138 | 139 | When using the default `"gpt-4-1106-preview"` model, remember to set the `OPENAI_API_KEY` environment variable. 140 | 141 | ```py 142 | import os 143 | os.environ["OPENAI_API_KEY"] = "sk-ABC..." 144 | ``` 145 | 146 | Note: The code so far is available as a [gist](https://gist.github.com/adlumal/d5d1138b57011b0b61a20e83b7484377). Paste it into a Jupyter notebook, preceded by `!pip install goalchain` to get started with the live example below. 147 | 148 | Usually it is the user who prompts the AI agent first, but if this is not the case, we call `get_response` without any arguments, or use `None` as the argument: 149 | 150 | ```py 151 | goal_chain.get_response() 152 | ``` 153 | 154 | ```txt 155 | {'type': 'message', 156 | 'content': 'Great choice! Could you please provide me with your email address to proceed with the order?', 157 | 'goal': <__main__.ProductOrderGoal at 0x7f8c8b687110>} 158 | ``` 159 | 160 | GoalChain returns a `dict` containing the type of response (either `message` or `data`), the content of the response (right now just our canned response) and the current `Goal`-inheriting object. 161 | 162 | Let's query our AI assistant with a potential purchase. 163 | 164 | ```py 165 | goal_chain.get_response("Hi, I'd like to buy a vacuum cleaner") 166 | ``` 167 | 168 | ```txt 169 | {'type': 'message', 170 | 'content': 'Great! Could you please provide your email address so we can send the confirmation of your order?', 171 | 'goal': <__main__.ProductOrderGoal at 0x7ff0fb283090>} 172 | ``` 173 | 174 | The AI assistant is working towards achieving its current goal, and gathering the required information for an order. 175 | 176 | ```py 177 | goal_chain.get_response("Sure, it is john@smith.com") 178 | ``` 179 | 180 | ```txt 181 | {'type': 'message', 182 | 'content': 'Thank you, John. Which model of vacuum cleaner would you like to order?', 183 | 'goal': <__main__.ProductOrderGoal at 0x7ff0fb283090>} 184 | ``` 185 | 186 | ```py 187 | goal_chain.get_response("The 2000XL model") 188 | ``` 189 | 190 | ```txt 191 | {'type': 'message', 192 | 'content': 'How many of the 2000XL model would you like to order?', 193 | 'goal': <__main__.ProductOrderGoal at 0x7ff0fb283090>} 194 | 195 | ``` 196 | 197 | Let's test whether our AI assistant can handle a cancellation of the current order. 198 | 199 | ```py 200 | goal_chain.get_response("Actually I changed my mind, cancel this order") 201 | ``` 202 | 203 | ```txt 204 | {'type': 'message', 205 | 'content': 'Of course, I can assist with that. Could you please tell me the reason for the cancellation?', 206 | 'goal': <__main__.OrderCancelGoal at 0x7ff0fb275650>} 207 | 208 | ``` 209 | 210 | It worked. Note that the returned goal is now of type `OrderCancelGoal`. We've switched goals. Let's also test whether we can switch back. 211 | 212 | ```py 213 | goal_chain.get_response("Actually, yeah, I would like to buy the vacuum cleaner") 214 | ``` 215 | 216 | ```txt 217 | {'type': 'message', 218 | 'content': 'Understood. How many of the 2000XL model would you like to order?', 219 | 'goal': <__main__.ProductOrderGoal at 0x7ff0fb283090>} 220 | ``` 221 | 222 | We're back to the `ProductOrderGoal`. 223 | 224 | ```py 225 | goal_chain.get_response("1 please") 226 | ``` 227 | 228 | ```txt 229 | {'type': 'message', 230 | 'content': 'To confirm, you would like to order one 2000XL vacuum cleaner and the order will be sent to john@smith.com, is that correct?', 231 | 'goal': <__main__.ProductOrderGoal at 0x7ff0fb283090>} 232 | ``` 233 | 234 | The AI assistant confirms our order. If we didn't like this behaviour we would use `confirm=False`. 235 | 236 | Let's see how the assistant responds to an out of scope query. 237 | 238 | ```py 239 | goal_chain.get_response("Is it a good vacuum cleaner? What do you think?") 240 | ``` 241 | 242 | ```txt 243 | {'type': 'message', 244 | 'content': "For product reviews and additional information, I recommend contacting our sales team at sales@acme.com. They can help with your inquiries. Meanwhile, can you please confirm if you'd like to proceed with the order for one 2000XL vacuum cleaner to john@smith.com?", 245 | 'goal': <__main__.ProductOrderGoal at 0x7ff0fb283090>} 246 | ``` 247 | 248 | The AI assistant redirects us to the sales team inbox as defined earlier, and re-iterates the confirmation. 249 | 250 | But let's throw a curve-ball... 251 | 252 | ```py 253 | goal_chain.get_response("Ok, I'd actually like to make that an order of 500") 254 | ``` 255 | 256 | ```txt 257 | {'type': 'message', 258 | 'content': "Just to clarify, you'd like to order 500 units of the 2000XL vacuum cleaner, with the order confirmation sent to john@smith.com. Is that correct?", 259 | 'goal': <__main__.ProductOrderGoal at 0x7ff0fb283090>} 260 | ``` 261 | 262 | ```py 263 | goal_chain.get_response("Yes") 264 | ``` 265 | 266 | ```txt 267 | {'type': 'message', 268 | 'content': 'I’m sorry, but I need to inform you that the quantity cannot be greater than 100 for an order. If you would like to proceed with an order within this limit, please let me know.', 269 | 'goal': <__main__.ProductOrderGoal at 0x7ff0fb283090>} 270 | ``` 271 | 272 | The validator we use has given enough information to the AI assistant to justify why it cannot process this quantity via the `ValidationError` message. 273 | 274 | Note that GoalChain only validates inputs once the `Goal` has been completed for token efficiency and performance reasons. If you'd like to validate inputs as you go, you have two options: 275 | 276 | 1. Use a `Goal` with only one `Field`, and `confirm=False`. Chain these goals instead of using multiple fields in a single `Goal`. 277 | 278 | 1. Use a soft-prompt, e.g. `quantity = Field("quantity of product (no more than 100)", format_hint="an integer")`. This approach is not foolproof, so it is still recommended to use a validator. The user will receive immediate feedback, however. 279 | 280 | Let's complete the order. 281 | 282 | ```py 283 | goal_chain.get_response("Alright, I'll guess I'll just go with 1") 284 | ``` 285 | 286 | ```txt 287 | {'type': 'message', 288 | 'content': 'To confirm, you would like to order one 2000XL vacuum cleaner and the order will be sent to john@smith.com, is that correct?', 289 | 'goal': <__main__.ProductOrderGoal at 0x7ff0fb283090>} 290 | ``` 291 | 292 | ```py 293 | goal_chain.get_response("That's right") 294 | ``` 295 | 296 | ```txt 297 | {'type': 'data', 298 | 'content': {'customer_email': 'john@smith.com', 299 | 'product_name': '2000XL', 300 | 'quantity': 1}, 301 | 'goal': <__main__.ProductOrderGoal at 0x7ff0fb283090>} 302 | ``` 303 | 304 | The content returned is a dictionary parsed from the output of the LLM's JSON mode. The keys are our field instance names. We can now use the data to perform some kind of action, such as processing the order of our hypothetical 2000XL vacuum cleaner. 305 | 306 | Note that in reality, if you were building such a system, you would need to make a dedicated product-lookup goal as not to allow arbitrary or meaningless product names. 307 | 308 | Let's send our confirmation the order has been processed via `simulate_response`. We will also use `rephrase = True` to rephrase the output, which will appear more natural in case the customer frequently interacts with the goal. 309 | 310 | ```py 311 | goal_chain.simulate_response(f"Thank you for ordering from Acme. Your order will be dispatched in the next 1-3 business days.", rephrase = True) 312 | ``` 313 | 314 | ```txt 315 | {'type': 'message', 316 | 'content': 'We appreciate your purchase with Acme! Rest assured, your order will be on its way within the next 1 to 3 business days.', 317 | 'goal': <__main__.ProductOrderGoal at 0x7ff0fb283090>} 318 | ``` 319 | 320 | At this point we may end the session or connect back to a menu or routing goal for further input. 321 | 322 | If you would like to customise or contribute to GoalChain, or report any issues, visit the [GitHub page](https://github.com/adlumal/GoalChain). 323 | 324 | -------------------------------------------------------------------------------- /examples/product_order.py: -------------------------------------------------------------------------------- 1 | from goalchain import Field, ValidationError, Goal, Action, GoalChain, plot_goal_chain, RESET, CLEAR 2 | import os, random 3 | from dotenv import load_dotenv 4 | 5 | # Load API keys from .env 6 | load_dotenv() 7 | # or set directly 8 | # os.environ["OPENAI_API_KEY"] = "sk-ABC..." # SPECIFY OPENAI API KEY 9 | 10 | def quantity_validator(value): 11 | try: 12 | value = int(value) 13 | except (ValueError, TypeError): 14 | raise ValidationError("Quantity must be a valid number") 15 | if value <= 0: 16 | raise ValidationError("Quantity cannot be less than one") 17 | if value > 100: 18 | raise ValidationError("Quantity cannot be greater than 100") 19 | return value 20 | 21 | class ProductOrderGoal(Goal): 22 | product_name = Field("product to be ordered", format_hint="a string") 23 | customer_email = Field("customer email", format_hint="a string") 24 | quantity = Field("quantity of product", format_hint="an integer", validator=quantity_validator) 25 | 26 | product_order_goal = ProductOrderGoal( 27 | label="Product Order", 28 | goal="to obtain information on an order to be made", 29 | opener="I see you are trying to order a product, how can I help you?", 30 | out_of_scope="Ask the user to contact sales team at sales@acme.com" 31 | ) 32 | 33 | class OrderCancelGoal(Goal): 34 | reason = Field("reason for order cancellation (optional)", format_hint="a string") 35 | 36 | order_cancel_goal = OrderCancelGoal( 37 | label="Cancel Current Order", 38 | goal="to obtain the reason for the cancellation", 39 | opener="I see you are trying to cancel the current order, how can I help you?", 40 | out_of_scope="Ask the user to contact the support team at support@acme.com", 41 | confirm=False 42 | ) 43 | 44 | # Define the processing function 45 | def process_order(data): 46 | # Simulate processing the order 47 | order_number = "ORD123456" 48 | data['order_number'] = order_number 49 | print(">> Order details:") 50 | print(data) 51 | return data 52 | 53 | # Create the Action with rephrase enabled 54 | process_order_action = ~Action( 55 | function=process_order, 56 | response_template="Your order has been processed successfully! Your order number is {{ order_number }}.", 57 | rephrase=True, 58 | ) 59 | 60 | # Define the processing function 61 | def cancel_order(data): 62 | # Simulate processing the order 63 | order_number = "ORD123456" 64 | data['order_number'] = order_number 65 | return data 66 | 67 | # Create the Action with rephrase enabled 68 | cancel_order_action = ~Action( 69 | function=cancel_order, 70 | response_template="Your order number {{ order_number }} has been cancelled successfully. I understand the reason you provided: {{ reason }}", 71 | rephrase=True, 72 | ) 73 | 74 | # Define condition functions 75 | def is_high_quantity(data): 76 | return data.get('quantity', 0) >= 50 77 | 78 | def is_normal_quantity(data): 79 | return data.get('quantity', 0) < 50 80 | 81 | class HighValueOrderGoal(Goal): 82 | verification_code = Field("verification code", format_hint="a 6-digit code") 83 | # Include quantity field to allow data extraction 84 | quantity = Field("quantity of product", format_hint="an integer", validator=quantity_validator) 85 | 86 | def on_start(self): 87 | # Send verification code 88 | verification_code = send_verification_code(self.data['customer_email']) 89 | self.expected_code = verification_code 90 | 91 | def on_complete(self, data): 92 | if data['verification_code'] == self.expected_code: 93 | # Verification successful, proceed to processing 94 | self.next_action = process_high_value_order_action 95 | return data 96 | else: 97 | # Verification failed, ask again 98 | response = self.simulate_response("Incorrect verification code. Please try again.", rephrase=True) 99 | return response # Return the response directly 100 | 101 | high_value_order_goal = HighValueOrderGoal( 102 | label="High-Value Order Verification", 103 | goal="to verify high-value orders", 104 | opener="Since you're ordering a large quantity, we need to verify your order by sending you a verification code over email.", 105 | out_of_scope="Please contact support for further assistance." 106 | ) 107 | 108 | # Chain the goals and actions 109 | product_order_goal >> process_order_action 110 | product_order_goal >> (order_cancel_goal / "to cancel the current order") 111 | order_cancel_goal >> cancel_order_action 112 | order_cancel_goal >> (product_order_goal / "to continue with the order anyway") 113 | 114 | # Process high-value order action 115 | def process_high_value_order(data): 116 | # Simulate processing the high-value order 117 | order_number = "ORD789012" 118 | data['order_number'] = order_number 119 | print(">> High-value order details: ") 120 | print(data) 121 | return data 122 | 123 | process_high_value_order_action = ~Action( 124 | function=process_high_value_order, 125 | response_template="Your high-value order has been verified and processed successfully! Your order number is {{ order_number }}.", 126 | rephrase=True, 127 | ) 128 | 129 | def send_verification_code(email): 130 | verification_code = str(random.randint(100000, 999999)) 131 | # Simulate sending the code via email 132 | print(f"[[[ Verification code sent to {email}: {verification_code} ]]]") 133 | return verification_code 134 | 135 | high_value_order_goal >> process_high_value_order_action 136 | high_value_order_goal >> (order_cancel_goal / "to cancel the current order") 137 | # high_value_order_goal >> (product_order_goal / "to make a non high-value order") 138 | 139 | # Add conditions using the new operator overloads 140 | product_order_goal >> (is_high_quantity, "Since this order is for a large quantity, we need to verify the order.") >> high_value_order_goal 141 | high_value_order_goal >> (is_normal_quantity, "The order is no longer a high-value order. Proceeding without verification.") >> product_order_goal 142 | 143 | goal_chain = GoalChain(product_order_goal) 144 | 145 | # Plot the GoalChain 146 | plot_goal_chain(filename='product_order_flow') 147 | 148 | # Start the conversation (optional) 149 | print(goal_chain.get_response()["content"]) 150 | while True: 151 | user_input = input("You: ") 152 | response = goal_chain.get_response(user_input) 153 | print("Assistant: " + response["content"]) 154 | if response["type"] == "end": 155 | # Optionally, print a final message 156 | print("Assistant: " + goal_chain.simulate_response("Thank you for choosing our service. Have a great day!", rephrase=True, closing=True)["content"]) 157 | break # Exit the loop to end the conversation -------------------------------------------------------------------------------- /examples/product_order_flow: -------------------------------------------------------------------------------- 1 | // GoalChain 2 | digraph { 3 | G0 [label="product_order 4 | Goal: to obtain information on an order to be made 5 | Opener: I see you are trying to order a product, how can I help you? 6 | Fields: 7 | - customer_email: customer email (a string) 8 | - product_name: product to be ordered (a string) 9 | - quantity: quantity of product (an integer) [Validator]"] 10 | G1 [label="cancel_current_order 11 | Goal: to obtain the reason for the cancellation 12 | Opener: I see you are trying to cancel the current order, how can I help you? 13 | Fields: 14 | - reason: reason for order cancellation (optional) (a string)"] 15 | G2 [label="high_value_order 16 | Goal: to verify high-value orders 17 | Opener: Since you're ordering a large quantity, we need to verify your order by sending you a verification code over email. 18 | Fields: 19 | - verification_code: verification code (a 6-digit code)"] 20 | A0 [label="Action: process_order 21 | Response Template 22 | [Rephrase] 23 | [End]"] 24 | A1 [label="Action: cancel_order 25 | Response Template 26 | [Rephrase] 27 | [End]"] 28 | A2 [label="Action: process_high_value_order 29 | Response Template 30 | [Rephrase] 31 | [End]"] 32 | G0 -> A0 [label=""] 33 | G0 -> G1 [label="to cancel the current order"] 34 | G0 -> G1 [label="to cancel the current order"] 35 | G1 -> A1 [label=""] 36 | G1 -> G0 [label="to continue with the order anyway"] 37 | G1 -> G0 [label="to continue with the order anyway"] 38 | G2 -> A2 [label=""] 39 | G2 -> G1 [label="to cancel the current order"] 40 | G2 -> G1 [label="to cancel the current order"] 41 | A0 -> G2 [label=is_high_quantity] 42 | A0 -> G2 [label=is_high_quantity] 43 | } 44 | -------------------------------------------------------------------------------- /examples/product_order_flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adlumal/GoalChain/2264ebe754e05ce0e889d67404641b6aff37bd09/examples/product_order_flow.png -------------------------------------------------------------------------------- /product_order_flow: -------------------------------------------------------------------------------- 1 | // GoalChain 2 | digraph { 3 | node [fillcolor="#FFFFFF" fontname=Helvetica shape=box style="rounded,filled"] 4 | edge [fontname=Helvetica] 5 | G0 [label=<
Product Order
Goal: to obtain information on an order to be made
Opener: I see you are trying to order a product, how can I help you?
Fields:
NameDescriptionFormatValidator
customer_emailcustomer emaila string-
product_nameproduct to be ordereda string-
quantityquantity of productan integerquantity_validator
> fillcolor="#AEDFF7" shape=box style="rounded,filled"] 6 | G1 [label=<
Cancel Current Order
Goal: to obtain the reason for the cancellation
Opener: I see you are trying to cancel the current order, how can I help you?
Fields:
NameDescriptionFormatValidator
reasonreason for order cancellation (optional)a string-
> fillcolor="#AEDFF7" shape=box style="rounded,filled"] 7 | G2 [label=<
High-Value Order Verification
Goal: to verify high-value orders
Opener: Since you're ordering a large quantity, we need to verify your order by sending you a verification code over email.
Fields:
NameDescriptionFormatValidator
quantityquantity of productan integerquantity_validator
verification_codeverification codea 6-digit code-
> fillcolor="#AEDFF7" shape=box style="rounded,filled"] 8 | A0 [label=<
Action: process_order
Response Template
[Rephrase]
[End]
> fillcolor="#FFD1DC" shape=box style="rounded,filled"] 9 | A1 [label=<
Action: cancel_order
Response Template
[Rephrase]
[End]
> fillcolor="#FFD1DC" shape=box style="rounded,filled"] 10 | A2 [label=<
Action: process_high_value_order
Response Template
[Rephrase]
[End]
> fillcolor="#FFD1DC" shape=box style="rounded,filled"] 11 | G0 -> A0 [label="" color=black fontcolor=black fontsize=10 style=solid] 12 | G0 -> G1 [label="to cancel the current order" color=black fontcolor=black fontsize=10 style=solid] 13 | G1 -> A1 [label="" color=black fontcolor=black fontsize=10 style=solid] 14 | G1 -> G0 [label="to continue with the order anyway" color=black fontcolor=black fontsize=10 style=solid] 15 | G2 -> A2 [label="" color=black fontcolor=black fontsize=10 style=solid] 16 | G2 -> G1 [label="to cancel the current order" color=black fontcolor=black fontsize=10 style=solid] 17 | G0 -> G2 [label="is_high_quantity 18 | \"Since this order is for a large quantity, we need to verify the order.\"" color=orange fontcolor=orange fontsize=10 style=dashed] 19 | G2 -> G0 [label="is_normal_quantity 20 | \"The order is no longer a high-value order. Proceeding without verification.\"" color=orange fontcolor=orange fontsize=10 style=dashed] 21 | } 22 | -------------------------------------------------------------------------------- /product_order_flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adlumal/GoalChain/2264ebe754e05ce0e889d67404641b6aff37bd09/product_order_flow.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "goalchain" 7 | version = "0.0.6" 8 | authors = [ 9 | {name="Adrian Lucas Malec", email="dr.adrian@gmail.com"}, 10 | ] 11 | description = "GoalChain is a simple but effective framework for enabling goal-orientated conversation flows for human-LLM and LLM-LLM interaction." 12 | readme = "README.md" 13 | requires-python = ">=3.9" 14 | license = {text="MIT"} 15 | keywords = [ 16 | "llm", 17 | "conversation", 18 | "chat", 19 | "agent", 20 | "goal", 21 | "flow", 22 | ] 23 | classifiers = [ 24 | "Intended Audience :: Developers", 25 | "Intended Audience :: Information Technology", 26 | "License :: OSI Approved :: MIT License", 27 | "Operating System :: OS Independent", 28 | "Programming Language :: Python :: 3.9", 29 | "Topic :: Scientific/Engineering :: Artificial Intelligence", 30 | "Topic :: Software Development :: Libraries :: Python Modules", 31 | ] 32 | dependencies = [ 33 | "Jinja2 >= 3.1.2", 34 | "graphviz" 35 | ] 36 | 37 | [project.urls] 38 | Homepage = "https://github.com/adlumal/GoalChain" 39 | Documentation = "https://github.com/adlumal/GoalChain/blob/main/README.md" 40 | Issues = "https://github.com/adlumal/GoalChain/issues" 41 | Source = "https://github.com/adlumal/GoalChain" -------------------------------------------------------------------------------- /src/goalchain/__init__.py: -------------------------------------------------------------------------------- 1 | """GoalChain is a simple but effective framework for enabling goal-orientated conversation flows for human-LLM and LLM-LLM interaction.""" 2 | 3 | from .goalchain import Prompt, Field, Goal, ValidationError, GoalConnection, Action, GoalChain, plot_goal_chain, RESET, CLEAR -------------------------------------------------------------------------------- /src/goalchain/goalchain.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import inspect 4 | from litellm import completion 5 | from jinja2 import Environment, select_autoescape 6 | from graphviz import Digraph # Import Graphviz for plotting 7 | 8 | # Define special constants 9 | RESET = object() 10 | CLEAR = object() 11 | 12 | # Prompt class based on banks by Massimiliano Pippi 13 | class Prompt: 14 | env = Environment( 15 | autoescape=select_autoescape( 16 | enabled_extensions=("html", "xml"), 17 | default_for_string=False, 18 | ), 19 | trim_blocks=True, 20 | lstrip_blocks=True, 21 | ) 22 | 23 | def __init__(self, text, filters = {}): 24 | for filter_label, filter_function in filters.items(): 25 | self.env.filters[filter_label] = filter_function 26 | self._template = self.env.from_string(text) 27 | 28 | def text(self, data): 29 | rendered: str = self._template.render(data) 30 | return rendered 31 | 32 | class Field: 33 | def __init__(self, description, format_hint=None, validator=None): 34 | self.description = description 35 | self.format_hint = format_hint 36 | self.validator = validator 37 | 38 | class ValidationError(Exception): 39 | def __init__(self, message): 40 | super().__init__(message) 41 | 42 | class Goal: 43 | _id_counter = 0 44 | _all_nodes = [] 45 | _all_edges = [] 46 | 47 | def _format_flag(self, flag): 48 | return f"<{flag}>" 49 | 50 | def __init__(self, 51 | label, 52 | goal, 53 | opener, 54 | out_of_scope=None, 55 | confirm=True, 56 | goal_prompt_template=None, 57 | completed_prompt_template=None, 58 | error_prompt_template=None, 59 | validation_prompt_template=None, 60 | rephrase_prompt_template=None, 61 | rephrase_prompt_closing_template=None, 62 | data_extraction_prompt_template=None, 63 | model="gpt-4-1106-preview", 64 | json_model="gpt-4-1106-preview", 65 | params = {}): 66 | self.id = 'G' + str(Goal._id_counter) 67 | Goal._id_counter += 1 68 | Goal._all_nodes.append(self) 69 | 70 | self.label = label 71 | self.goal = goal 72 | self.opener = opener 73 | self.confirm = confirm 74 | self.out_of_scope = out_of_scope 75 | self.model = model 76 | self.json_model = json_model 77 | self.messages = [] 78 | self.connected_goals = [] 79 | self.completed_string = "completed" 80 | self.hand_over = True 81 | self.params = params 82 | self.next_action = None 83 | self.started = False 84 | self.conditions = [] 85 | self.data = {} 86 | 87 | self.goal_prompt = goal_prompt_template if goal_prompt_template else Prompt("""Your role is to continue the conversation below as the Assistant. 88 | Goal: {{goal}} 89 | {% if information_list %} 90 | Information to be gathered: {{information_list|join(", ")}} 91 | This is all of the information you are to gather, do not ask for anything else. 92 | {% if confirmation %} 93 | Once you have the information ask for a confirmation. 94 | If you receive this confirmation reply only with: 95 | {{ completed_string | format_flag }} 96 | {% else %} 97 | Once you have the information reply only with: 98 | {{ completed_string | format_flag }} 99 | {% endif %} 100 | {% endif %} 101 | {% if out_of_scope %} 102 | {% for goal in connected_goals %} 103 | If the user wants {{ goal.user_goal }} reply only with this: 104 | {{ goal.goal.label | format_flag }} 105 | {% endfor %} 106 | For anything outside of the scope of the goal: 107 | {{ out_of_scope }} 108 | {% endif %} 109 | Respond naturally, and don't repeat yourself. 110 | Conversation so far: 111 | {% for message in messages %} 112 | {{ message.actor }}: {{ message.content }} 113 | {% endfor %} 114 | Assistant:""", filters={"format_flag": self._format_flag}) 115 | self.completed_prompt = completed_prompt_template if completed_prompt_template else Prompt("""Given the conversation below output JSON which includes only the following keys: 116 | {% for field in fields %} 117 | {{ field.name }}: {{ field.description }} {% if field.format_hint %}({{field.format_hint}}) 118 | {% endif %} 119 | {% endfor %} 120 | If any keys are not provided in the conversation set their values to null. 121 | Conversation: 122 | {% for message in messages %} 123 | {{ message.actor }}: {{ message.content }} 124 | {% endfor %}""") 125 | self.error_prompt = error_prompt_template if error_prompt_template else Prompt("""I'm sorry, but I'm having trouble processing that request right now.""") 126 | self.validation_prompt = validation_prompt_template if validation_prompt_template else Prompt("""Your role is to continue the conversation below as the Assistant. 127 | Unfortunately you had trouble processing the user's request because of the following problems: 128 | {% for error in validation_error_messages %} 129 | * {{ error }} 130 | {% endfor %} 131 | Continue the conversation naturally, and explain the problems. 132 | Do not be creative. Do not make suggestions as to how to fix the problems. 133 | Conversation so far: 134 | {% for message in messages %} 135 | {{ message.actor }}: {{ message.content }} 136 | {% endfor %} 137 | Assistant:""") 138 | self.rephrase_prompt = rephrase_prompt_template if rephrase_prompt_template else Prompt("""Your role is to continue the conversation below as the Assistant. 139 | Normally you respond with: {{ response }} 140 | {% if message_history %} 141 | Goal: {{goal}} 142 | But now you need to take into account the conversation so far and tailor your response accordingly. 143 | Continue the conversation naturally. Do not be creative. 144 | Conversation so far: 145 | {% for message in message_history %} 146 | {{ message.actor }}: {{ message.content }} 147 | {% endfor %} 148 | {% else %} 149 | Simply rephrase your response as the Assistant. 150 | {% endif %} 151 | Assistant:""") 152 | self.rephrase_prompt_closing = rephrase_prompt_closing_template if rephrase_prompt_closing_template else Prompt(""" 153 | Your role is to act as the Assistant. Rephrase the following response to make it more natural and engaging, taking into account the conversation so far. 154 | Do not add any new information or messages. Only rephrase the provided response. 155 | 156 | Response: 157 | {{ response }} 158 | 159 | Assistant:""") 160 | self.data_extraction_prompt = data_extraction_prompt_template if data_extraction_prompt_template else Prompt("""Given the conversation below, extract the following information: 161 | 162 | {% for field in fields %} 163 | - {{ field.name }}: {{ field.description }} {% if field.format_hint %}({{field.format_hint}}) 164 | {% endif %} 165 | {% endfor %} 166 | 167 | If any information is not provided in the conversation, set its value to null. 168 | 169 | Conversation: 170 | {% for message in messages %} 171 | {{ message.actor }}: {{ message.content }} 172 | {% endfor %} 173 | 174 | Provide the extracted information in JSON format.""") 175 | 176 | # Overloading the '/' operator to create GoalConnection 177 | def __truediv__(self, other): 178 | if isinstance(other, str): 179 | # Create a GoalConnection with user_goal 180 | return GoalConnection(goal=self, user_goal=other) 181 | else: 182 | raise TypeError("Use 'goal / \"user goal description\"'") 183 | 184 | def __rshift__(self, other): 185 | if isinstance(other, GoalConnection): 186 | self.connect( 187 | goal=other.goal, 188 | user_goal=other.user_goal, 189 | hand_over=other.hand_over, 190 | keep_messages=other.keep_messages, 191 | flags=other.flags 192 | ) 193 | return other.goal 194 | elif isinstance(other, Action): 195 | self.next_action = other 196 | # Record the edge 197 | edge = { 198 | 'from': self, 199 | 'to': other, 200 | 'label': '', 201 | 'style': 'solid' 202 | } 203 | if edge not in Goal._all_edges: 204 | Goal._all_edges.append(edge) 205 | return other 206 | elif callable(other): 207 | # 'other' is a condition function 208 | self._current_condition = {'condition_function': other} 209 | return self 210 | elif isinstance(other, tuple): 211 | # 'other' is a tuple, could be (condition_function, silent_text, *flags) 212 | condition_function = other[0] 213 | silent_text = other[1] if len(other) > 1 and isinstance(other[1], str) else None 214 | flags = other[2:] if len(other) > 2 else [] 215 | hand_over = RESET not in flags 216 | keep_messages = CLEAR not in flags 217 | self._current_condition = { 218 | 'condition_function': condition_function, 219 | 'silent_text': silent_text, 220 | 'hand_over': hand_over, 221 | 'keep_messages': keep_messages, 222 | } 223 | return self 224 | elif isinstance(other, Goal): 225 | # If we have a current condition, finalize it 226 | if hasattr(self, '_current_condition') and self._current_condition: 227 | condition = self._current_condition 228 | condition['next_goal'] = other 229 | self.add_condition(condition) 230 | del self._current_condition 231 | return other 232 | else: 233 | # No current condition, cannot chain directly to another goal 234 | raise TypeError("Use 'goal >> condition >> next_goal' or 'goal >> action'") 235 | else: 236 | raise TypeError("Invalid type for '>>' operator") 237 | 238 | def get_fields(self): 239 | fields = inspect.getmembers(self) 240 | field_dict = {} 241 | for field in fields: 242 | if isinstance(field[1], Field): 243 | field_dict[field[0]] = field[1] 244 | return field_dict 245 | 246 | def _get_goal_details(self): 247 | prompt_details = { 248 | "goal": self.goal, 249 | "confirmation": self.confirm, 250 | "messages": self.messages, 251 | "completed_string": self.completed_string, 252 | "out_of_scope": self.out_of_scope, 253 | "connected_goals": self.connected_goals, 254 | } 255 | 256 | fields = self.get_fields() 257 | information_list = [] 258 | for label, field in fields.items(): 259 | information_list.append(field.description) 260 | prompt_details["information_list"] = information_list 261 | return prompt_details 262 | 263 | def _get_completion_details(self): 264 | prompt_details = { 265 | "messages": self.messages, 266 | } 267 | 268 | fields = self.get_fields() 269 | field_list = [] 270 | for label, field in fields.items(): 271 | field_list.append( 272 | { 273 | "name": label, 274 | "description": field.description, 275 | "format_hint": field.format_hint, 276 | } 277 | ) 278 | prompt_details["fields"] = field_list 279 | return prompt_details 280 | 281 | def _inference(self, user_message, system_prompt="", json_mode=False): 282 | llm_messages = [ 283 | {"role": "system", "content": system_prompt}, 284 | {"role": "user", "content": user_message}, 285 | ] 286 | model = self.json_model if json_mode else self.model 287 | response_format = {"type": "json_object"} if json_mode else None 288 | 289 | llm_response = completion( 290 | messages=llm_messages, 291 | model=model, 292 | response_format=response_format, 293 | **self.params 294 | ) 295 | return llm_response["choices"][0]["message"]["content"] 296 | 297 | def on_complete(self, data): 298 | # Default behavior: proceed to the next action or return data 299 | return data 300 | 301 | def simulate_response(self, response, rephrase = False, closing = False, message_history = []): 302 | if rephrase: 303 | rephrase_details = { 304 | "response": response, 305 | "message_history": message_history or self.messages, 306 | "goal": self.goal, 307 | } 308 | rephrase_pre_prompt = self.rephrase_prompt.text(rephrase_details) if not closing else self.rephrase_prompt_closing.text(rephrase_details) 309 | response = self._inference( 310 | rephrase_pre_prompt 311 | ) 312 | self.messages.append( 313 | { 314 | "actor": "Assistant", 315 | "content": response, 316 | } 317 | ) 318 | return response 319 | 320 | def user_response(self, response): 321 | self.messages.append( 322 | { 323 | "actor": "User", 324 | "content": response, 325 | } 326 | ) 327 | return response 328 | 329 | def update_data(self): 330 | # Use data_extraction_prompt to extract data 331 | prompt_details = { 332 | "messages": self.messages, 333 | } 334 | 335 | fields = self.get_fields() 336 | field_list = [] 337 | for label, field in fields.items(): 338 | field_list.append( 339 | { 340 | "name": label, 341 | "description": field.description, 342 | "format_hint": field.format_hint, 343 | } 344 | ) 345 | prompt_details["fields"] = field_list 346 | 347 | data_extraction_prompt = self.data_extraction_prompt.text(prompt_details) 348 | json_response_text = self._inference( 349 | data_extraction_prompt, 350 | json_mode=True) 351 | 352 | try: 353 | response_object = json.loads(json_response_text) 354 | 355 | # Update data 356 | for label, value in response_object.items(): 357 | if value is not None: 358 | self.data[label] = value 359 | except json.JSONDecodeError: 360 | pass 361 | 362 | def add_condition(self, condition): 363 | # condition is a dict with keys: 364 | # 'condition_function', 'next_goal', 'silent_text', 'rephrase', 'hand_over', 'keep_messages' 365 | self.conditions.append(condition) 366 | # Record the edge for plotting 367 | edge = { 368 | 'from': self, 369 | 'to': condition['next_goal'], 370 | 'label': condition['condition_function'].__name__, 371 | 'style': 'dashed', 372 | 'color': 'orange', 373 | 'conditional': True, 374 | 'silent_text': condition.get('silent_text'), 375 | 'rephrase': condition.get('rephrase', False), 376 | 'flags': [] 377 | } 378 | if not condition.get('hand_over', True): 379 | edge['flags'].append('RESET') 380 | if not condition.get('keep_messages', True): 381 | edge['flags'].append('CLEAR') 382 | if edge not in Goal._all_edges: 383 | Goal._all_edges.append(edge) 384 | 385 | def check_conditions(self): 386 | for condition in self.conditions: 387 | if condition['condition_function'](self.data): 388 | if condition['silent_text']: 389 | self.messages.append( 390 | { 391 | "actor": "Assistant", 392 | "content": condition['silent_text'], 393 | } 394 | ) 395 | return condition['next_goal'].take_over( 396 | messages=self.messages if condition['keep_messages'] else [], 397 | data=self.data, 398 | hand_over=condition['hand_over'] 399 | ) 400 | return None 401 | 402 | def get_response(self, user_input): 403 | if not self.messages and not user_input and not self.hand_over: 404 | return self.simulate_response(self.opener) 405 | elif not self.messages and not user_input and self.hand_over: 406 | return self.simulate_response(self.opener, rephrase=True) 407 | else: 408 | if user_input: 409 | user_input = self.user_response(user_input) 410 | self.update_data() # Update data after user's message 411 | 412 | # Check for goal transition after user's input 413 | new_goal = self.check_conditions() 414 | if new_goal: 415 | return new_goal.get_response(user_input) 416 | 417 | response_text = self._inference( 418 | self.goal_prompt.text(self._get_goal_details()) 419 | ) 420 | 421 | # self.simulate_response(response_text) 422 | 423 | # Update data after assistant's response 424 | self.update_data() 425 | 426 | # Check for goal transition 427 | new_goal = self.check_conditions() 428 | if new_goal: 429 | return new_goal.get_response() 430 | 431 | # if HANDING OVER 432 | for connected_goal in self.connected_goals: 433 | if self._format_flag(connected_goal["goal"].label).lower() in response_text.lower(): 434 | if connected_goal["keep_messages"]: 435 | hand_over_messages = self.messages 436 | else: 437 | hand_over_messages = [] 438 | return connected_goal["goal"].take_over(messages=hand_over_messages, hand_over=connected_goal["hand_over"]) 439 | 440 | # if COMPLETED 441 | if self._format_flag(self.completed_string).lower() in response_text.lower(): 442 | 443 | json_response_text = self._inference( 444 | self.completed_prompt.text(self._get_completion_details()), 445 | json_mode=True) 446 | 447 | try: 448 | response_object = json.loads(json_response_text) 449 | 450 | validation_error_messages = [] 451 | fields = self.get_fields() 452 | 453 | for label, field in fields.items(): 454 | if label in response_object: 455 | if field.validator: 456 | try: 457 | response_object[label] = field.validator(response_object[label]) 458 | except ValidationError as e: 459 | validation_error_messages.append(str(e)) 460 | 461 | if not validation_error_messages: 462 | return self.on_complete(response_object) 463 | else: 464 | validation_details = { 465 | "validation_error_messages": validation_error_messages, 466 | "messages": self.messages 467 | } 468 | validation_pre_prompt = self.validation_prompt.text(validation_details) 469 | 470 | validation_response_text = self._inference( 471 | validation_pre_prompt 472 | ) 473 | 474 | return self.simulate_response(validation_response_text) 475 | 476 | except json.JSONDecodeError: 477 | error_response = self.error_prompt.text() 478 | return self.simulate_response(error_response) 479 | 480 | else: 481 | return self.simulate_response(response_text) 482 | 483 | def take_over(self, messages=[], hand_over=True, data=None): 484 | if messages is not None: 485 | self.messages = messages 486 | if hand_over: 487 | self.hand_over = True 488 | if data is not None: 489 | self.data = data 490 | else: 491 | self.data = {} 492 | if not self.started: 493 | if hasattr(self, 'on_start') and callable(self.on_start): 494 | self.on_start() 495 | self.started = True 496 | return self 497 | 498 | def connect(self, goal, user_goal, hand_over = True, keep_messages = True, flags=None): 499 | self.connected_goals.append( 500 | { 501 | "goal": goal, 502 | "user_goal": user_goal, 503 | "hand_over": hand_over, 504 | "keep_messages": keep_messages, 505 | } 506 | ) 507 | # Record the edge 508 | edge = { 509 | 'from': self, 510 | 'to': goal, 511 | 'label': user_goal, 512 | 'style': 'solid', 513 | 'hand_over': hand_over, 514 | 'keep_messages': keep_messages, 515 | 'flags': flags 516 | } 517 | if edge not in Goal._all_edges: 518 | Goal._all_edges.append(edge) 519 | return goal 520 | 521 | class GoalConnection: 522 | def __init__(self, goal, user_goal, hand_over=True, keep_messages=True): 523 | self.goal = goal 524 | self.user_goal = user_goal 525 | self.hand_over = hand_over 526 | self.keep_messages = keep_messages 527 | self.flags = [] 528 | 529 | def __or__(self, other): 530 | if other is RESET: 531 | self.hand_over = False 532 | self.flags.append('RESET') 533 | return self 534 | elif other is CLEAR: 535 | self.keep_messages = False 536 | self.flags.append('CLEAR') 537 | return self 538 | else: 539 | raise TypeError("Use '| RESET' or '| CLEAR' to set options") 540 | 541 | class Action: 542 | _id_counter = 0 543 | _all_nodes = [] 544 | _all_edges = [] 545 | 546 | def __init__(self, function, response_template=None, rephrase=False, rephrase_prompt_template=None, conversation_end=False): 547 | self.id = 'A' + str(Action._id_counter) 548 | Action._id_counter += 1 549 | Action._all_nodes.append(self) 550 | 551 | self.function = function 552 | self.response_template = response_template 553 | self.rephrase = rephrase 554 | self.conversation_end = conversation_end 555 | self.next_goal = None 556 | self.conditions = [] 557 | self.rephrase_prompt = rephrase_prompt_template if rephrase_prompt_template else Prompt(""" 558 | Your role is to continue the conversation below as the Assistant. 559 | Please rephrase the following response to make it more natural and engaging, taking into account the conversation so far. 560 | Continue the conversation naturally. Do not be creative. Do not include information, actions, suggestions, facts or details that are not in the response you are to rephrase. 561 | Response: 562 | {{ response }} 563 | Conversation so far: 564 | {% for message in message_history %} 565 | {{ message.actor }}: {{ message.content }} 566 | {% endfor %} 567 | Assistant: 568 | """) 569 | self.next_goal = None 570 | self.conditions = [] # List of tuples (condition_function, next_goal) 571 | 572 | # Overload the '~' operator to set conversation_end to True 573 | def __invert__(self): 574 | self.conversation_end = True 575 | return self 576 | 577 | # Overload '>>' to chain actions to goals or other actions 578 | def __rshift__(self, other): 579 | if isinstance(other, tuple) and len(other) == 2: 580 | condition_function, next_goal = other 581 | self.add_condition(condition_function, next_goal) 582 | return next_goal 583 | elif isinstance(other, Goal): 584 | self.next_goal = other 585 | # Record the edge 586 | edge = { 587 | 'from': self, 588 | 'to': other, 589 | 'label': '', 590 | 'style': 'solid' 591 | } 592 | if edge not in Action._all_edges: 593 | Action._all_edges.append(edge) 594 | return other 595 | elif isinstance(other, Action): 596 | self.next_action = other 597 | # Record the edge 598 | edge = { 599 | 'from': self, 600 | 'to': other, 601 | 'label': '', 602 | 'style': 'solid' 603 | } 604 | if edge not in Action._all_edges: 605 | Action._all_edges.append(edge) 606 | return other 607 | else: 608 | raise TypeError("Can only chain an Action to a Goal or another Action using '>>' operator") 609 | 610 | def add_condition(self, condition_function, next_goal): 611 | self.conditions.append((condition_function, next_goal)) 612 | # Record the edge 613 | edge = { 614 | 'from': self, 615 | 'to': next_goal, 616 | 'label': condition_function.__name__, 617 | 'style': 'dashed', 618 | 'color': 'red', 619 | 'conditional': True 620 | } 621 | if edge not in Action._all_edges: 622 | Action._all_edges.append(edge) 623 | 624 | def execute(self, data, assistant): 625 | result = self.function(data) 626 | if self.response_template: 627 | # Generate response using the response_template 628 | response_text = self.generate_response(result) 629 | if self.rephrase: 630 | # Rephrase the response using the assistant's LLM 631 | response_text = self.rephrase_response(response_text, assistant) 632 | # After processing, check conditions 633 | for condition_function, next_goal in self.conditions: 634 | if condition_function(result): 635 | self.next_goal = next_goal 636 | break 637 | return response_text 638 | else: 639 | # No response template, but still check conditions 640 | for condition_function, next_goal in self.conditions: 641 | if condition_function(result): 642 | self.next_goal = next_goal 643 | break 644 | return result 645 | 646 | def generate_response(self, result): 647 | # Generate response using the response_template 648 | prompt = Prompt(self.response_template) 649 | response_text = prompt.text(result) 650 | return response_text 651 | 652 | def rephrase_response(self, response_text, assistant): 653 | rephrase_details = { 654 | 'response': response_text, 655 | 'goal': assistant.goal, 656 | 'message_history': assistant.messages 657 | } 658 | rephrase_pre_prompt = self.rephrase_prompt.text(rephrase_details) 659 | # Use the assistant's inference method to get the rephrased response 660 | rephrased_response = assistant._inference( 661 | user_message=rephrase_pre_prompt, 662 | system_prompt="", 663 | json_mode=False 664 | ) 665 | return rephrased_response 666 | 667 | class GoalChain: 668 | def __init__(self, starting_goal): 669 | self.goal = starting_goal 670 | self.data = {} 671 | self.goal.take_over(data=self.data) 672 | 673 | def get_response(self, user_input=None): 674 | try: 675 | response = self.goal.get_response(user_input) 676 | if isinstance(response, str): 677 | return self._handle_message(response) 678 | elif isinstance(response, Goal): 679 | return self._handle_goal_transition(response) 680 | elif isinstance(response, dict): 681 | return self._handle_data_response(response) 682 | else: 683 | raise TypeError("Unexpected Goal response type") 684 | except Exception as e: 685 | return self.simulate_response("I'm sorry, something went wrong.") 686 | 687 | def _handle_message(self, response): 688 | return {"type": "message", "content": response, "goal": self.goal} 689 | 690 | def _handle_goal_transition(self, new_goal): 691 | # Check if transitioning to a different goal or re-entering the same goal 692 | if new_goal is not self.goal or new_goal.started: 693 | self.goal = new_goal 694 | self.goal.started = False # Reset started flag 695 | self.goal.take_over(data=self.data) 696 | return self.get_response() 697 | 698 | def _handle_data_response(self, data): 699 | self.data.update(data) 700 | if hasattr(self.goal, 'next_action') and self.goal.next_action: 701 | action = self.goal.next_action 702 | action_response = action.execute(self.data, assistant=self.goal) 703 | # Check for next_goal first 704 | if hasattr(action, 'next_goal') and action.next_goal: 705 | self.goal = action.next_goal 706 | self.goal.take_over(data=self.data) 707 | return self.get_response() 708 | elif action.conversation_end: 709 | return {"type": "end", "content": action_response, "goal": self.goal} 710 | else: 711 | return {"type": "message", "content": action_response, "goal": self.goal} 712 | else: 713 | return {"type": "data", "content": self.data, "goal": self.goal} 714 | 715 | def simulate_response(self, user_input, rephrase = False, closing = False): 716 | response = self.goal.simulate_response(user_input, rephrase = rephrase, closing = closing) 717 | return {"type": "message", "content": response, "goal": self.goal} 718 | 719 | # Function to plot the GoalChain 720 | def plot_goal_chain(filename='goalchain', format='png'): 721 | dot = Digraph(comment='GoalChain', format=format) 722 | dot.attr('node', shape='box', style='rounded,filled', fillcolor='#FFFFFF', fontname='Helvetica') 723 | dot.attr('edge', fontname='Helvetica') 724 | 725 | # Add nodes 726 | for node in Goal._all_nodes + Action._all_nodes: 727 | # Create label for node 728 | if isinstance(node, Goal): 729 | label = f"<" 730 | label += f"" 731 | label += f"" 732 | label += f"" 733 | # Include fields 734 | fields = node.get_fields() 735 | if fields: 736 | # Start a new table for fields 737 | field_table = "
{node.label}
Goal: {node.goal}
Opener: {node.opener}
" 738 | field_table += "" 739 | # Add header row 740 | field_table += "" 741 | field_table += "" 742 | field_table += "" 743 | field_table += "" 744 | field_table += "" 745 | field_table += "" 746 | # Add each field as a row 747 | for fname, field in fields.items(): 748 | field_table += "" 749 | field_table += f"" 750 | field_table += f"" 751 | field_table += f"" 752 | field_table += f"" 753 | field_table += "" 754 | field_table += "
Fields:
NameDescriptionFormatValidator
{fname}{field.description}{field.format_hint if field.format_hint else '-'}{field.validator.__name__ if field.validator else '-'}
" 755 | # Add the fields table to the main label 756 | label += f"{field_table}" 757 | label += ">" 758 | dot.node(node.id, label, shape='box', style='rounded,filled', fillcolor='#AEDFF7') 759 | elif isinstance(node, Action): 760 | label = f"<" 761 | label += f"" 762 | if node.response_template: 763 | label += f"" 764 | if node.rephrase: 765 | label += f"" 766 | if node.conversation_end: 767 | label += f"" 768 | label += "
Action: {node.function.__name__}
Response Template
[Rephrase]
[End]
>" 769 | dot.node(node.id, label, shape='box', style='rounded,filled', fillcolor='#FFD1DC') 770 | else: 771 | continue 772 | 773 | # Combine all edges from Goals and Actions 774 | all_edges = set() 775 | for edge in Goal._all_edges + Action._all_edges: 776 | from_id = edge['from'].id 777 | to_id = edge['to'].id 778 | label = edge.get('label', '') 779 | style = edge.get('style', 'solid') 780 | color = edge.get('color', 'black') 781 | # Check for flags and annotate edge 782 | flags = edge.get('flags', []) 783 | if flags: 784 | flag_text = ', '.join(flags) 785 | label = f"{label} [{flag_text}]" 786 | # Include response text and rephrase info for conditional edges 787 | if edge.get('conditional', False): 788 | silent_text = edge.get('silent_text', '') 789 | rephrase_flag = ' [Rephrase]' if edge.get('rephrase', False) else '' 790 | flag_text = ', '.join(edge.get('flags', [])) 791 | if flag_text: 792 | flag_text = f' [{flag_text}]' 793 | if silent_text: 794 | label = f"{label}\n\"{silent_text}\"{rephrase_flag}{flag_text}" 795 | else: 796 | label = f"{label}{rephrase_flag}{flag_text}" 797 | # Use a tuple to prevent duplicates 798 | edge_tuple = (from_id, to_id, label) 799 | if edge_tuple not in all_edges: 800 | all_edges.add(edge_tuple) 801 | edge_attrs = {'style': style, 'color': color, 'label': label, 'fontsize': '10', 'fontcolor': color} 802 | if edge.get('conditional', False): 803 | edge_attrs['style'] = 'dashed' 804 | edge_attrs['color'] = 'orange' 805 | dot.edge(from_id, to_id, **edge_attrs) 806 | 807 | # Save and render the graph 808 | dot.render(filename, view=True) --------------------------------------------------------------------------------