mortylen
Posted on October 23, 2023
I am not a native English speaker, but I am eager to improve my language skills. I have considered enrolling in English courses, hiring tutors, or seeking help from my English-speaking friends. However, it occurred to me that I am a programmer, so why not explore the realm of artificial intelligence? That's when I decided to use OpenAI's API. The API is well-documented and easy to use. For my project, I will be using Rust to create a simple console application that allows me to practice English conversations. The application will not only correct my English responses but also engage in extended conversations.
Of course, this application is not limited to being just an English language tutor. OpenAI supports over 80 different languages, providing the flexibility to switch to any supported language and enhance proficiency in that language. By the way, this project is not meant for serious foreign language learning; it's more of a fun endeavor during my free time to explore the OpenAI API and enhance my programming skills. :)
Disclaimer: Before I start describing the details, I need to point out that the application utilizes the OpenAI API. OpenAI is a paid service, and I believe that for the first three months after registration, you can enjoy a $5 credit for free. The app requires your API key for authentication. You can see your API keys in your OpanAI account in the 'View API Keys' tab. You can find more information about OpenAI accounts, APIs, and pricing on their official website at OpenAI.
OpenAI API
First and foremost, I would like to provide some information about the OpenAI API. Each request sent to the API must be authorized using the Bearer OpenAI API Key in the HTTP header.
Authorization: Bearer OPENAI_API_KEY
For more details, please refer to: OpenAI API Authentication.
The API offers a range of models, each with distinct capabilities and pricing. For my foreign language learning requirements, I selected the GPT-3.5-turbo model. It is primarily tailored for conversations, which aligns perfectly with my needs. For further information about the available models, please refer to the documentation: OpenAI API Models.
For the GPT-3.5-turbo model, the API endpoint is located at the URL address https://api.openai.com/v1/chat/completions. All requests are sent to this endpoint.
Now that it's clear how to authorize and where to send the request, let's take a closer look at what needs to be sent and in what format. I will be sending the request in JSON format, which should also be included in the HTTP header of the request.
Content-Type: application/json
And now, let's examine the content of the message. First, set the model with the following parameters:
"model": "gpt-3.5-turbo"
"temperature": 0.8
"max_tokens": 1024
As the documentation states, the model
is the ID of the model to use. temperature
- higher values like 0.8 will make the output more random, while lower values like 0.2 make it more focused and deterministic. max_tokens
is the maximum number of tokens to generate in the chat completion.
My conversation content is included in the messages
parameter, which is a list of messages that make up the conversation. Messages are sent as message fields, each containing two parameters:
role: The role of the message author. One of
system
,user
,assistant
.content: The content of the message.
Example of request:
{
"model": "gpt-3.5-turbo",
"temperature": 0.8,
"max_tokens": 1024,
"messages": [
{
"role": "user",
"content": "Say this is a test!"
}
]
}
The response to the request is also in JSON format. The most important parameter for my needs is the content
of the message
. It is also worth noting the usage
parameter, which contains usage statistics for the completion request.
Example of response:
{
"id": "chatcmpl-abc123",
"object": "chat.completion",
"created": 1677858242,
"model": "gpt-3.5-turbo-0613",
"usage": {
"prompt_tokens": 13,
"completion_tokens": 7,
"total_tokens": 20
},
"choices": [
{
"message": {
"role": "assistant",
"content": "\n\nThis is a test!"
},
"finish_reason": "stop",
"index": 0
}
]
}
For more details, please refer to: OpenAI API Chat Completion.
Application design
The concept of the app is straightforward. It sends a basic HTTP request to OpenAI and waits for a response. All requests and responses follow a simple JSON format, as described above.
The architecture of the application is not perfect; it's a bit ad-hoc but functional. :D I decided to split the code into several files:
main.rs: takes care of initialization, configuration, and selecting the learning model.
openai_executor.rs: contains structures for requests and responses and handles the sending and receiving of messages.
model_conversation.rs: operates as a loop, initially configuring the Chat GPT for the conversation and then responding cyclically to the user.
model_words.rs: functions similarly to model_conversation.rs but configures the GPT for generating words and evaluating them.
Adding new learning models in the future will be straightforward. Just create a new file and include a reference to it in main.rs
.
Let's take a closer look at the code. I will be using several packages in the code, which need to be added to the Cargo.toml
file as dependencies:
[dependencies]
reqwest = { version = "0.11", features = ["json"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
openssl = { version = "0.10", features = ["vendored"] }
OpenSSL is not necessary; I had to add it to the Replit compiler.
File openai_executor
The file contains two basic structures, GPTRequest
and GPTResponse
, serving as data wrappers for communication with OpenAI. GPTRequest
implements several functions:
new: a constructor that creates a structure and sets the model, temperature, and the maximum number of tokens.
add_message: checks the number of messages in the buffer, and if it's exceeded, it removes the last two messages and adds a new message to the buffer.
remove_system_message: clears system messages that configure the Chat GPT behavior.
#[derive(Serialize, Deserialize, Debug)]
pub struct Message {
pub role: String,
pub content: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct GPTRequest {
pub model: String,
pub temperature: f32,
pub max_tokens: i32,
pub messages: Vec<Message>,
}
#[derive(Default, Serialize, Deserialize, Debug)]
pub struct GPTResponse {
pub id: String,
pub object: String,
pub created: i64,
pub model: String,
pub choices: Vec<Choice>,
pub usage: Usage,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct Choice {
pub index: i32,
pub message: Message,
pub finish_reason: String,
}
#[derive(Default, Serialize, Deserialize, Debug)]
pub struct Usage {
pub prompt_tokens: i32,
pub completion_tokens: i32,
pub total_tokens: i32,
}
impl GPTRequest {
pub fn new(model: String, temperature: f32, max_tokens: i32) -> Self {
GPTRequest {
model,
temperature,
max_tokens,
messages: Vec::new(),
}
}
pub fn add_message(&mut self, msg: Message) {
if self.messages.len() >= MAX_MESSAGE_BUFFER {
self.messages.drain(0..2);
}
self.messages.push(msg);
}
pub fn remove_system_message(&mut self) {
if self.messages.len() >= 1 {
match self.messages.iter().position(|i| i.role == "system") {
Some(msg_index) => {
self.messages.remove(msg_index);
},
None => {},
}
}
}
}
Next, openai_executor
contains a couple of functions. The most important one is send_message
, which takes the GPTRequest
structure and ywt_api_key
as parameters and returns a response in the form of a GPTResponse
structure. It is accompanied by helper functions such as:
send_request: responsible for sending messages.
create_response_error: handles error messages.
parse_response: parses the response into the GPTResponse structure.
Another important function is get_user_input
, which only takes input from the user.
pub async fn send_request(request: &GPTRequest, ywt_api_key: &str) -> Result<reqwest::Response, reqwest::Error> {
let client = reqwest::Client::new();
client
.post(OPENAI_MODEL_URL)
.header(CONTENT_TYPE, "application/json")
.header(AUTHORIZATION, format!("Bearer {}", ywt_api_key.trim()))
.json(request)
.send()
.await
}
pub fn create_response_error<T>(error: T) -> GPTResponse where T: std::fmt::Display, {
GPTResponse {
choices: vec![Choice {
index: 0,
message: Message {
role: "system".to_string(),
content: error.to_string(),
},
finish_reason: "error".to_string(),
}],
..Default::default()
}
}
pub async fn parse_response(response: reqwest::Response) -> Result<GPTResponse, reqwest::Error> {
match response.status() {
StatusCode::OK => {
let response_body = response.text().await?;
let parse_result: Result<GPTResponse, _> = serde_json::from_str(&response_body);
match parse_result {
Ok(parsed_data) => {
Ok(parsed_data)
},
Err(error) => {
Ok(create_response_error(&error))
},
}
}
_ => {
Ok(create_response_error(&response.text().await?))
}
}
}
pub async fn send_message(request: &GPTRequest, ywt_api_key: &str) -> Result<GPTResponse, reqwest::Error> {
let response = send_request(request, ywt_api_key).await?;
let parsed_data = parse_response(response).await?;
Ok(parsed_data)
}
pub fn get_user_input() -> String {
let mut user_input = String::new();
io::stdin().read_line(&mut user_input).unwrap();
user_input.trim().to_string()
}
And finally, let's not forget what was supposed to be at the beginning of the file. We need to add several uses and constants:
use std::io;
use serde::{Deserialize, Serialize};
use reqwest::header::{CONTENT_TYPE, AUTHORIZATION};
use reqwest::{StatusCode};
const MAX_MESSAGE_BUFFER: usize = 11;
const OPENAI_MODEL_URL: &'static str = "https://api.openai.com/v1/chat/completions";
The MAX_MESSAGE_BUFFER
constant limits the maximum number of messages a message list can contain. In the future, it would be better to use a token-sensing algorithm instead of a fixed maximum number of messages.
The constant OPENAI_MODEL_URL
contains the path to the endpoint for the selected OpenAI model.
File: main
Let's examine the main.rs
file. However, before we do that, there are a couple of use
statements and constants at the beginning:
use std::env;
use reqwest::{Error};
mod openai_executor;
mod model_conversation;
mod model_words;
const OPENAI_MODEL: &'static str = "gpt-3.5-turbo";
const OPENAI_TEMPERATURE: f32 = 0.8;
const OPENAI_MAXTOKENS: i32 = 1024;
OPENAI_MODEL - represents the name of the model.
OPENAI_TEMPERATURE - denotes the sampling temperature.
OPENAI_MAXTOKENS - sets the maximum number of tokens used for the response.
This is how the main
function looks. First, it asks the user for the OpenAI API key, then the language they are interested in learning and their native language. Next, it provides the user with a choice of some learning models.
#[tokio::main]
async fn main() -> Result<(), Error> {
let ywt_api_key = wait_for_api_ywt();
let target_language = set_target_language();
let native_language = set_native_language();
let ai_chat = set_openai_chat();
println!("\n");
println!("Choose your education model: \n1 - Conversation \n2 - Learning words \n\nEnter the number of model:");
match openai_executor::get_user_input().trim() {
"1" => model_conversation::model_conversation(&ywt_api_key, ai_chat, &target_language, &native_language).await?,
"2" => model_words::model_words(&ywt_api_key, ai_chat, &target_language, &native_language).await?,
_ =>model_conversation::model_conversation(&ywt_api_key, ai_chat, &target_language, &native_language).await?,
}
Ok(())
}
Here are the helper functions that the main
uses. They don't do anything else, they just ask for input from the user.
fn wait_for_api_ywt() -> String {
let args: Vec<String> = env::args().collect();
let api_ywt: String = if args.len() > 1 {
args[1].to_string()
} else {
println!("\n");
println!("Please enter your OpenAI API key (do not share your API key with others): ");
openai_executor::get_user_input()
};
api_ywt
}
fn set_target_language() -> String {
println!("\n");
println!("Please specify the language you want to learn (e.g., 'English'): ");
openai_executor::get_user_input()
}
fn set_native_language() -> String {
println!("\n");
println!("Please indicate your native language (e.g., 'German'): ");
openai_executor::get_user_input()
}
fn set_openai_chat() -> openai_executor::GPTRequest {
let ai_chat = openai_executor::GPTRequest::new(OPENAI_MODEL.to_string(), OPENAI_TEMPERATURE, OPENAI_MAXTOKENS);
ai_chat
}
File: model_conversation
This is a model_conversation.rs
; it is the core of a learning model that sets OpenAI ChatGPT for conversations and corrects user mistakes in their sentences.
use reqwest::{Error};
use crate::openai_executor::GPTRequest;
use crate::openai_executor::Message;
use crate::openai_executor::send_message;
use crate::openai_executor::get_user_input;
pub async fn model_conversation(ywt_api_key: &String, mut ai_chat: GPTRequest, target_language: &String, native_language: &String) -> Result<(), Error> {
let system_message: String = format!("You are my {0} teacher. I would like you to split each of your replies into three part. \nIn the first part called as 'Correction:', write the correct version of my sentence in {0} language. \nIn second part called as 'Note', describle and explain of where I made grammatical mistakes in my {0} language, this part you must described in {1} language. \nIn the third part called as 'Conversation:', feel free to respond to my statement and continue the conversation in {0} language.", &target_language, &native_language);
println!("\n");
println!("Please start the conversation.");
let mut user_message: String;
loop {
ai_chat.remove_system_message();
ai_chat.add_message(Message{role: "system".to_string(), content: system_message.clone()});
println!("You: ");
user_message = format!("Fix my {0}. Correct my mistakes in this paragraph to standard {0} and write the explanation in {1}. \n'{2}'", &target_language, &native_language, get_user_input());
ai_chat.add_message(Message{role: "user".to_string(), content: user_message});
println!("\n");
match send_message(&ai_chat, &ywt_api_key).await {
Ok(response) => {
let message_content = Message {
role: response.choices[0].message.role.clone(),
content: response.choices[0].message.content.clone(),
};
ai_chat.add_message(message_content);
println!("Lector: \n{0}", response.choices[0].message.content);
}
Err(error) => {
eprintln!("Error sending message: {:?}", error);
}
}
println!("\n");
}
}
It's an infinite loop that keeps the conversation going. First, it removes all system messages if they exist, using the ai_chat.remove_system_message
function. Then, function ai_chat.add_message
sets a new system message, ensuring the correction of the discussion and its continuation. This system message must always be before the last user message; otherwise, GPT Chat does not work properly. I tried putting this message only at the beginning of the conversation, but GPT Chat changed its settings according to the content of the conversation.
Next, it asks for input from the user and sends these messages to the OpenAI endpoint, using the send_message
function. Finally, It waits for the response, which it prints out.
Finished application
And what is the result? A simple console application.
First, the application asks for your OpenAI API key.
Next, enter the language you want to learn.
Then enter your native language.
And finally, choose a language learning model.
Now you just proceed according to the chosen model.
For the Conversation model, you just start a conversation with your tutor.
For the Learning words model, you will translate the words that your tutor generates for you.
Few notes at the end
Tokens
GPT-3.5 has a maximum token count of 4097, which is shared between the prompt and completion. This means that the combined tokens in the messages sent and the response cannot exceed this limit. To ensure efficient use of this token limit, I have constrained the response to 1024 tokens, which is typically adequate for about 700 words. Additionally, I've set a limit of 11 messages for the entire conversation. In the future, it would be beneficial to implement token sensing and dynamically trim the messages as needed rather than relying on a fixed limit. However, in case the token count exceeds the limit, the program will issue an error, as shown in the following JSON example:
{
"error": {
"message": "This model's maximum context length is 4097 tokens. However, you requested 4634 tokens (3619 in the messages, 1024 in the completion). Please reduce the length of the messages or completion.",
"type": "invalid_request_error",
"param": "messages",
"code": "context_length_exceeded"
}
}
System role
According to the OpenAI documentation, the system message is intended to be the initial message sent to set the assistant's behavior and personality. It provides instructions for how the assistant should engage in the conversation. However, through several tests, I discovered that it doesn't always function as expected. When I send a system message at the beginning of a conversation, the assistant's initial responses align with the prompts within the system message. Nevertheless, as the conversation progresses, the assistant autonomously adapts its behavior based on the discussion's content. To address this, I experimented with sending the system message not at the outset but toward the end of the conversation, and this approach produced the desired results.
GPT model
Of course, you aren't limited to using the GPT-3.5 model; you can also experiment with GPT-4. To switch to GPT-4, simply update the OPENAI_MODEL
constant within the main.rs
subfile as follows:
const OPENAI_MODEL: &'static str = "gpt-4";
However, it's worth noting that GPT-4 tends to be slower and comes with higher computational costs.
Conclusion
Of course, I am aware that developing console applications of this kind may seem impractical nowadays. This project serves as a small demonstration of the OpenAI API and an opportunity for me to enhance my Rust development skills. Nonetheless, in the future, if I can allocate enough free time, I intend to transform this project into a web application. I am eager to revisit Rust and create a straightforward web application using web frameworks like Rocket or Actix. If you're interested in giving the app a try, you can find it on my GitHub account. I hope you enjoy exploring it and find it useful. Feel free to provide feedback or suggestions if you have any. May your code bring you joy!
Source code and release you can find on my GitHub.
Cover photo by Kiwihug
Posted on October 23, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.