GoalChain is a simple but effective framework for enabling goal-orientated conversation flows for human-LLM and LLM-LLM interaction.
pip install goalchain
Let's import the Field
, ValidationError
, Goal
and GoalChain
classes, which are the basis for the conversation flow.
from goalchain import Field, ValidationError, Goal, GoalChain
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
:
- the product name,
- the customer's email, and
- quantity
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.
format_hint
is a natural language type hint for the LLM's JSON mode output.
def quantity_validator(value):
try:
value = int(value)
except (ValueError, TypeError):
raise ValidationError("Quantity must be a valid number")
if value <= 0:
raise ValidationError("Quantity cannot be less than one")
if value > 100:
raise ValidationError("Quantity cannot be greater than 100")
return value
class ProductOrderGoal(Goal):
product_name = Field("product to be ordered", format_hint="a string")
customer_email = Field("customer email", format_hint="a string")
quantity = Field("quantity of product", format_hint="an integer", validator=quantity_validator)
In case the customer changes their mind, let's create another Goal
child class called OrderCancelGoal
.
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.
class OrderCancelGoal(Goal):
reason = Field("reason for order cancellation (optional)", format_hint="a string")
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.
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.
product_order_goal = ProductOrderGoal(
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?",
out_of_scope="Ask the user to contact sales team at [email protected]"
)
order_cancel_goal = OrderCancelGoal(
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?",
out_of_scope="Ask the user to contact the support team at [email protected]",
confirm=False
)
We define
- an internal label to be used (also part of our prompt-engineering task),
- the goal, expressed as a "to ..." statement,
- a default
opener
- something the AI assistant will use given no prior input, - and importantly, instructions for the AI assistant as to what they should do in case of an out of scope user query
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.
Next we need to connect the goals together.
product_order_goal.connect(goal=order_cancel_goal,
user_goal="to cancel the current order",
hand_over=True,
keep_messages=True)
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.
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.
Let's also consider the possibility of a really undecisive customer. We should also give them the option to "cancel the cancellation".
order_cancel_goal.connect(goal=product_order_goal,
user_goal="to continue with the order anyway",
hand_over=True,
keep_messages=True)
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.
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.
You can also chain connects, e.g. goal.connect(...).connect(...).connect(...)
Finally, let's use GoalChain
to set the initial goal and test our AI sales assistant!
goal_chain = GoalChain(product_order_goal)
Note that each goal can use a separate LLM API as enabled by LiteLLM, and if you have the required environment variables set, you can use any model from the supported model providers.
The default model is "gpt-4-1106-preview"
, that is:
product_order_goal = ProductOrderGoal(...
model="gpt-4-1106-preview",
json_model="gpt-4-1106-preview"
)
You can also pass LiteLLM common parameters using params
, for example:
product_order_goal = ProductOrderGoal(...
model="gpt-4-1106-preview",
json_model="gpt-4-1106-preview",
params={"temperature": 1.5, "max_tokens": 10}
)
You can also use params
to call local models using VLLM.
When using the default "gpt-4-1106-preview"
model, remember to set the OPENAI_API_KEY
environment variable.
import os
os.environ["OPENAI_API_KEY"] = "sk-ABC..."
Note: The code so far is available as a gist. Paste it into a Jupyter notebook, preceded by !pip install goalchain
to get started with the live example below.
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:
goal_chain.get_response()
{'type': 'message',
'content': 'Great choice! Could you please provide me with your email address to proceed with the order?',
'goal': <__main__.ProductOrderGoal at 0x7f8c8b687110>}
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.
Let's query our AI assistant with a potential purchase.
goal_chain.get_response("Hi, I'd like to buy a vacuum cleaner")
{'type': 'message',
'content': 'Great! Could you please provide your email address so we can send the confirmation of your order?',
'goal': <__main__.ProductOrderGoal at 0x7ff0fb283090>}
The AI assistant is working towards achieving its current goal, and gathering the required information for an order.
goal_chain.get_response("Sure, it is [email protected]")
{'type': 'message',
'content': 'Thank you, John. Which model of vacuum cleaner would you like to order?',
'goal': <__main__.ProductOrderGoal at 0x7ff0fb283090>}
goal_chain.get_response("The 2000XL model")
{'type': 'message',
'content': 'How many of the 2000XL model would you like to order?',
'goal': <__main__.ProductOrderGoal at 0x7ff0fb283090>}
Let's test whether our AI assistant can handle a cancellation of the current order.
goal_chain.get_response("Actually I changed my mind, cancel this order")
{'type': 'message',
'content': 'Of course, I can assist with that. Could you please tell me the reason for the cancellation?',
'goal': <__main__.OrderCancelGoal at 0x7ff0fb275650>}
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.
goal_chain.get_response("Actually, yeah, I would like to buy the vacuum cleaner")
{'type': 'message',
'content': 'Understood. How many of the 2000XL model would you like to order?',
'goal': <__main__.ProductOrderGoal at 0x7ff0fb283090>}
We're back to the ProductOrderGoal
.
goal_chain.get_response("1 please")
{'type': 'message',
'content': 'To confirm, you would like to order one 2000XL vacuum cleaner and the order will be sent to [email protected], is that correct?',
'goal': <__main__.ProductOrderGoal at 0x7ff0fb283090>}
The AI assistant confirms our order. If we didn't like this behaviour we would use confirm=False
.
Let's see how the assistant responds to an out of scope query.
goal_chain.get_response("Is it a good vacuum cleaner? What do you think?")
{'type': 'message',
'content': "For product reviews and additional information, I recommend contacting our sales team at [email protected]. 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 [email protected]?",
'goal': <__main__.ProductOrderGoal at 0x7ff0fb283090>}
The AI assistant redirects us to the sales team inbox as defined earlier, and re-iterates the confirmation.
But let's throw a curve-ball...
goal_chain.get_response("Ok, I'd actually like to make that an order of 500")
{'type': 'message',
'content': "Just to clarify, you'd like to order 500 units of the 2000XL vacuum cleaner, with the order confirmation sent to [email protected]. Is that correct?",
'goal': <__main__.ProductOrderGoal at 0x7ff0fb283090>}
goal_chain.get_response("Yes")
{'type': 'message',
'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.',
'goal': <__main__.ProductOrderGoal at 0x7ff0fb283090>}
The validator we use has given enough information to the AI assistant to justify why it cannot process this quantity via the ValidationError
message.
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:
-
Use a
Goal
with only oneField
, andconfirm=False
. Chain these goals instead of using multiple fields in a singleGoal
. -
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.
Let's complete the order.
goal_chain.get_response("Alright, I'll guess I'll just go with 1")
{'type': 'message',
'content': 'To confirm, you would like to order one 2000XL vacuum cleaner and the order will be sent to [email protected], is that correct?',
'goal': <__main__.ProductOrderGoal at 0x7ff0fb283090>}
goal_chain.get_response("That's right")
{'type': 'data',
'content': {'customer_email': '[email protected]',
'product_name': '2000XL',
'quantity': 1},
'goal': <__main__.ProductOrderGoal at 0x7ff0fb283090>}
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.
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.
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.
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)
{'type': 'message',
'content': 'We appreciate your purchase with Acme! Rest assured, your order will be on its way within the next 1 to 3 business days.',
'goal': <__main__.ProductOrderGoal at 0x7ff0fb283090>}
At this point we may end the session or connect back to a menu or routing goal for further input.
If you would like to customise or contribute to GoalChain, or report any issues, visit the GitHub page.