Building an AI-Powered Language Learning App: Learning From Two AI Chatting | by Shuai Guo | Jun, 2023


Now that we have a clear understanding of what we want to build and the tools to build it, it’s time to roll up our sleeves and dive into the code! In this section, we’re going to focus on the nuts and bolts of creating our dual-chatbot interaction. First, we’ll explore the class definition for a single chatbot and then expand on this to create a dual-chatbot class, enabling our two chatbots to interact. We’ll save the design of the app interface using Streamlit for Section 4.

3.1 Developing a single chatbot

In this subsection, we will develop a single chatbot together, which will later be integrated into the dual-chatbot system. Let’s start with the overall class design, then shift our attention to prompt engineering.

🏗️ Class Design

Our chatbot class should enable the management of an individual chatbot. This involves instantiating a chatbot with a user-specified LLM as its backbone, providing instructions based on the user’s intent, and facilitating interactive multi-round conversations. With that in mind, let’s start coding.

First, import the necessary libraries:

import os
import openai
from langchain.prompts import (
ChatPromptTemplate,
MessagesPlaceholder,
SystemMessagePromptTemplate,
HumanMessagePromptTemplate
)
from langchain.prompts import PromptTemplate
from langchain.chains import LLMChain
from langchain.chains import ConversationChain
from langchain.chat_models import ChatOpenAI
from langchain.memory import ConversationBufferMemory

Next, we define the class constructor:

class Chatbot:
"""Class definition for a single chatbot with memory, created with LangChain."""

def __init__(self, engine):
"""Select backbone large language model, as well as instantiate
the memory for creating language chain in LangChain.
"""

# Instantiate llm
if engine == 'OpenAI':
# Reminder: need to set up openAI API key
# (e.g., via environment variable OPENAI_API_KEY)
self.llm = ChatOpenAI(
model_name="gpt-3.5-turbo",
temperature=0.7
)

else:
raise KeyError("Currently unsupported chat model type!")

# Instantiate memory
self.memory = ConversationBufferMemory(return_messages=True)

Currently, you can only choose to use the native OpenAI API. Nevertheless, adding more backend LLMs is straightforward since LangChain supports various types (e.g., Azure OpenAI endpoint, Anthropic chat models, PaLM API on Google Vertex AI, etc.).

Besides LLM, another important component we need to instantiate is memory, which tracks the conversation history. Here, we use ConversationBufferMemory for this purpose, which simply prepends the last few inputs/outputs to the current input of the chatbot. This is the simplest memory type offered in LangChain and it’s sufficient for our current purpose.

For a complete overview of other types of memory, please refer to the official docs.

Moving on, we need to have a class method that allows us to give instructions to the chatbot and make conversations with it. This is what self.instruct() for:

def instruct(self, role, oppo_role, language, scenario, 
session_length, proficiency_level,
learning_mode, starter=False):
"""Determine the context of chatbot interaction.
"""

# Define language settings
self.role = role
self.oppo_role = oppo_role
self.language = language
self.scenario = scenario
self.session_length = session_length
self.proficiency_level = proficiency_level
self.learning_mode = learning_mode
self.starter = starter

# Define prompt template
prompt = ChatPromptTemplate.from_messages([
SystemMessagePromptTemplate.from_template(self._specify_system_message()),
MessagesPlaceholder(variable_name="history"),
HumanMessagePromptTemplate.from_template("{input}")
])

# Create conversation chain
self.conversation = ConversationChain(memory=self.memory, prompt=prompt,
llm=self.llm, verbose=False)

  • We define a couple of settings to allow users to customize their learning experience.

In addition to what has been mentioned in “Section 1 Project Overview”, we have four new attributes:

self.role/self.oppo_role: this attribute takes the form of a dictionary that records the role name and corresponding actions. For instance:

self.role = {'name': 'Customer', 'action': 'ordering food'}

self.oppo_role represents the role taken by the other chatbot engaged in the conversation with the current chatbot. It’s essential because the current chatbot needs to understand who it is communicating with, providing necessary contextual information.

self.scenario sets the stage for the conversation. For “conversation” learning mode, self.scenariorepresents the place where the conversation
is happening; for “debate” mode, self.scenariorepresents the debating topic.

Finally, self.starter is just a boolean flag to indicate if the current chatbot will initiate the conversation.

  • We structure the prompt for the chatbot.

In OpenAI, a chat model generally takes a list of messages as input and returns a model-generated message as output. LangChain supports SystemMessage,AIMessage, HumanMessage: SystemMessage helps set the behavior of the chatbot, AIMessage stores previous chatbot responses, and HumanMessage provides requests or comments for the chatbot to respond to.

LangChain conveniently offers PromptTemplate to streamline prompt generation and ingestion. For a chatbot application, we need to specify the PromptTemplate for all three message types. The most critical piece is setting the SystemMessage, which controls the chatbot’s behavior. We have a separate method, self._specify_system_message(), to handle this, which we’ll discuss in detail later.

  • Finally, we bring all the pieces together and construct a ConversationChain.

🖋️ Prompt Design

Our focus now turns to guiding the chatbot in participating in the conversation as desired by the user. To this end, we have the self._specify_system_message() method. The signature of this method is shown below:

def _specify_system_message(self):
"""Specify the behavior of the chatbot, which consists of the following
aspects:

- general context: conducting conversation/debate under given scenario
- the language spoken
- purpose of the simulated conversation/debate
- language complexity requirement
- exchange length requirement
- other nuance constraints

Outputs:
--------
prompt: instructions for the chatbot.
"""

Essentially, this method compiles a string, which will then be fed into the SystemMessagePromptTemplate.from_template() to instruct the chatbot, as demonstrated in the definition of the self.instruct() method above. We’ll dissect this “long string” in the following to understand how each language learning requirement is incorporated into the prompt.

1️⃣ Session length

The session length is controlled by directly specifying the maximum number of exchanges that can happen within one session. Those numbers are hard-coded for the time being.

# Determine the number of exchanges between two bots
exchange_counts_dict = {
'Short': {'Conversation': 8, 'Debate': 4},
'Long': {'Conversation': 16, 'Debate': 8}
}
exchange_counts = exchange_counts_dict[self.session_length][self.learning_mode]

2️⃣ Number of sentences the chatbot can say in one exchange

Apart from limiting the total number of allowed exchanges, it’s also beneficial to restrict how much a chatbot can say within one exchange, or equivalently, the number of sentences.

In my experiments, there is usually no need to constrain this in “conversation” mode, as the chatbot mimics a real-life dialogue and tends to speak at a reasonable length. However, in “debate” mode, it’s necessary to impose a limit. Otherwise, the chatbot may continue speaking, eventually generating an “essay” 😆.

Similar to limiting the session length, the numbers that restrict the speech length are also hard-coded and correspond with the user’s proficiency level in the target language:

# Determine number of sentences in one debate round
argument_num_dict = {
'Beginner': 4,
'Intermediate': 6,
'Advanced': 8
}

3️⃣ Determine speech complexity

Here, we regulate the complexity level of the language the chatbot can use:

if self.proficiency_level == 'Beginner':
lang_requirement = """use as basic and simple vocabulary and
sentence structures as possible. Must avoid idioms, slang,
and complex grammatical constructs."""

elif self.proficiency_level == 'Intermediate':
lang_requirement = """use a wider range of vocabulary and a variety of sentence structures.
You can include some idioms and colloquial expressions,
but avoid highly technical language or complex literary expressions."""

elif self.proficiency_level == 'Advanced':
lang_requirement = """use sophisticated vocabulary, complex sentence structures, idioms,
colloquial expressions, and technical language where appropriate."""

else:
raise KeyError('Currently unsupported proficiency level!')

4️⃣ Put everything together!

Here’s what the instruction looks like for different learning modes:

# Compile bot instructions 
if self.learning_mode == 'Conversation':
prompt = f"""You are an AI that is good at role-playing.
You are simulating a typical conversation happened {self.scenario}.
In this scenario, you are playing as a {self.role['name']} {self.role['action']}, speaking to a
{self.oppo_role['name']} {self.oppo_role['action']}.
Your conversation should only be conducted in {self.language}. Do not translate.
This simulated {self.learning_mode} is designed for {self.language} language learners to learn real-life
conversations in {self.language}. You should assume the learners' proficiency level in
{self.language} is {self.proficiency_level}. Therefore, you should {lang_requirement}.
You should finish the conversation within {exchange_counts} exchanges with the {self.oppo_role['name']}.
Make your conversation with {self.oppo_role['name']} natural and typical in the considered scenario in
{self.language} cultural."""

elif self.learning_mode == 'Debate':
prompt = f"""You are an AI that is good at debating.
You are now engaged in a debate with the following topic: {self.scenario}.
In this debate, you are taking on the role of a {self.role['name']}.
Always remember your stances in the debate.
Your debate should only be conducted in {self.language}. Do not translate.
This simulated debate is designed for {self.language} language learners to
learn {self.language}. You should assume the learners' proficiency level in {self.language}
is {self.proficiency_level}. Therefore, you should {lang_requirement}.
You will exchange opinions with another AI (who plays the {self.oppo_role['name']} role)
{exchange_counts} times.
Everytime you speak, you can only speak no more than
{argument_num_dict[self.proficiency_level]} sentences."""

else:
raise KeyError('Currently unsupported learning mode!')

5️⃣ Who speaks first?

Finally, we instruct the chatbot whether it should speak first or wait for the response from the opponent AI:

# Give bot instructions
if self.starter:
# In case the current bot is the first one to speak
prompt += f"You are leading the {self.learning_mode}. n"

else:
# In case the current bot is the second one to speak
prompt += f"Wait for the {self.oppo_role['name']}'s statement."

Now we have completed the prompt design 🎉 As a quick summary, this is what we have developed so far:

The developed single chatbot class.
The single chatbot class. (Image by author)

3.2 Developing a dual-chatbot system

Now we arrive at the exciting part! In this subsection, we will develop a dual-chatbot class to let two chatbots interact with each other 💬💬

🏗️ Class Design

Thanks to the previously developed single Chatbot class, we can effortlessly instantiate two chatbots in the class constructor:

class DualChatbot:
"""Class definition for dual-chatbots interaction system,
created with LangChain."""

def __init__(self, engine, role_dict, language, scenario, proficiency_level,
learning_mode, session_length):

# Instantiate two chatbots
self.engine = engine
self.proficiency_level = proficiency_level
self.language = language
self.chatbots = role_dict
for k in role_dict.keys():
self.chatbots[k].update({'chatbot': Chatbot(engine)})

# Assigning roles for two chatbots
self.chatbots['role1']['chatbot'].instruct(role=self.chatbots['role1'],
oppo_role=self.chatbots['role2'],
language=language, scenario=scenario,
session_length=session_length,
proficiency_level=proficiency_level,
learning_mode=learning_mode, starter=True)

self.chatbots['role2']['chatbot'].instruct(role=self.chatbots['role2'],
oppo_role=self.chatbots['role1'],
language=language, scenario=scenario,
session_length=session_length,
proficiency_level=proficiency_level,
learning_mode=learning_mode, starter=False)

# Add session length
self.session_length = session_length

# Prepare conversation
self._reset_conversation_history()

The self.chatbots is a dictionary designed to store information related to both bots:

# For "conversation" mode
self.chatbots= {
'role1': {'name': 'Customer',
'action': 'ordering food',
'chatbot': Chatbot()},
'role2': {'name': 'Waitstaff',
'action': 'taking the order',
'chatbot': Chatbot()}
}

# For "debate" mode
self.chatbots= {
'role1': {'name': 'Proponent',
'chatbot': Chatbot()},
'role2': {'name': 'Opponent',
'chatbot': Chatbot()}
}

The self._reset_conversation_history serves to initiate a fresh conversation history and provide the initial instructions to the chatbots:

def _reset_conversation_history(self):
"""Reset the conversation history.
"""
# Placeholder for conversation history
self.conversation_history = []

# Inputs for two chatbots
self.input1 = "Start the conversation."
self.input2 = ""

To facilitate interaction between the two chatbots, we employ self.step() method. This method allows for one round of interaction between the two bots:

def step(self):
"""Make one exchange round between two chatbots.
"""

# Chatbot1 speaks
output1 = self.chatbots['role1']['chatbot'].conversation.predict(input=self.input1)
self.conversation_history.append({"bot": self.chatbots['role1']['name'], "text": output1})

# Pass output of chatbot1 as input to chatbot2
self.input2 = output1

# Chatbot2 speaks
output2 = self.chatbots['role2']['chatbot'].conversation.predict(input=self.input2)
self.conversation_history.append({"bot": self.chatbots['role2']['name'], "text": output2})

# Pass output of chatbot2 as input to chatbot1
self.input1 = output2

# Translate responses
translate1 = self.translate(output1)
translate2 = self.translate(output2)

return output1, output2, translate1, translate2

Notice that we have embedded a method called self.translate(). The purpose of this method is to translate the script into English. This functionality could be useful for language learners as they can understand the meaning of the conversation generated in the target language.

To achieve the translation functionality, we can employ the basic LLMChain, which requires a backend LLM model and a prompt for instruction:

  def translate(self, message):
"""Translate the generated script into English.
"""

if self.language == 'English':
# No translation performed
translation = 'Translation: ' + message

else:
# Instantiate translator
if self.engine == 'OpenAI':
# Reminder: need to set up openAI API key
# (e.g., via environment variable OPENAI_API_KEY)
self.translator = ChatOpenAI(
model_name="gpt-3.5-turbo",
temperature=0.7
)

else:
raise KeyError("Currently unsupported translation model type!")

# Specify instruction
instruction = """Translate the following sentence from {src_lang}
(source language) to {trg_lang} (target language).
Here is the sentence in source language: n
{src_input}."""

prompt = PromptTemplate(
input_variables=["src_lang", "trg_lang", "src_input"],
template=instruction,
)

# Create a language chain
translator_chain = LLMChain(llm=self.translator, prompt=prompt)
translation = translator_chain.predict(src_lang=self.language,
trg_lang="English",
src_input=message)

return translation

Finally, it could be beneficial for language learners to have a summary of the key language learning points of the generated conversation script, be it key vocabulary, grammar points, or function phrases. For that, we can include a self.summary() method:

def summary(self, script):
"""Distill key language learning points from the generated scripts.
"""

# Instantiate summary bot
if self.engine == 'OpenAI':
# Reminder: need to set up openAI API key
# (e.g., via environment variable OPENAI_API_KEY)
self.summary_bot = ChatOpenAI(
model_name="gpt-3.5-turbo",
temperature=0.7
)

else:
raise KeyError("Currently unsupported summary model type!")

# Specify instruction
instruction = """The following text is a simulated conversation in
{src_lang}. The goal of this text is to aid {src_lang} learners to learn
real-life usage of {src_lang}. Therefore, your task is to summarize the key
learning points based on the given text. Specifically, you should summarize
the key vocabulary, grammar points, and function phrases that could be important
for students learning {src_lang}. Your summary should be conducted in English, but
use examples from the text in the original language where appropriate.
Remember your target students have a proficiency level of
{proficiency} in {src_lang}. You summarization must match with their
proficiency level.

The conversation is: n
{script}."""

prompt = PromptTemplate(
input_variables=["src_lang", "proficiency", "script"],
template=instruction,
)

# Create a language chain
summary_chain = LLMChain(llm=self.summary_bot, prompt=prompt)
summary = summary_chain.predict(src_lang=self.language,
proficiency=self.proficiency_level,
script=script)

return summary

Similar to the self.translate() method, we employed a basic LLMChain to perform the desired task. Note that we explicitly ask the language model to summarize key language learning points based on the user’s proficiency level.

With that, we have completed the development of the dual-chatbot class 🥂 As a quick summary, this is what we have developed so far:

Chatbot class based on LangChain
The single chatbot & Dual-chatbot class. (Image by author)



Source link

Leave a Comment