├── .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: | Name | Description | Format | Validator | customer_email | customer email | a string | - | product_name | product to be ordered | a string | - | quantity | quantity of product | an integer | quantity_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: | Name | Description | Format | Validator | reason | reason 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: | Name | Description | Format | Validator | quantity | quantity of product | an integer | quantity_validator | verification_code | verification code | a 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"{node.label} |
"
731 | label += f"Goal: {node.goal} |
"
732 | label += f"Opener: {node.opener} |
"
733 | # Include fields
734 | fields = node.get_fields()
735 | if fields:
736 | # Start a new table for fields
737 | field_table = ""
738 | field_table += "Fields: |
"
739 | # Add header row
740 | field_table += ""
741 | field_table += "Name | "
742 | field_table += "Description | "
743 | field_table += "Format | "
744 | field_table += "Validator | "
745 | field_table += "
"
746 | # Add each field as a row
747 | for fname, field in fields.items():
748 | field_table += ""
749 | field_table += f"{fname} | "
750 | field_table += f"{field.description} | "
751 | field_table += f"{field.format_hint if field.format_hint else '-'} | "
752 | field_table += f"{field.validator.__name__ if field.validator else '-'} | "
753 | field_table += "
"
754 | field_table += "
"
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"Action: {node.function.__name__} |
"
762 | if node.response_template:
763 | label += f"Response Template |
"
764 | if node.rephrase:
765 | label += f"[Rephrase] |
"
766 | if node.conversation_end:
767 | label += f"[End] |
"
768 | label += "
>"
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)
--------------------------------------------------------------------------------