From 0a73f35f1e036596904d330bf42887147c47f052 Mon Sep 17 00:00:00 2001 From: Earnest Angel <57413115+earnestangel@users.noreply.github.com> Date: Sun, 9 Jul 2023 19:27:35 +0800 Subject: [PATCH] Refactor RirikoAI-NLP.js, remove duplicated response, implement retries, better prompt trimming (#170) * Refactor RirikoAI-NLP.js * Fix NLPCloudProvider.test.js test --- src/app/Providers/AI/NLPCloudProvider.js | 9 +- src/app/Providers/AI/NLPCloudProvider.test.js | 2 +- src/app/Providers/AI/OpenAIProvider.js | 11 +- src/app/RirikoAI-NLP.js | 584 ++++++++++++------ src/ririkoBot.js | 4 + 5 files changed, 404 insertions(+), 206 deletions(-) diff --git a/src/app/Providers/AI/NLPCloudProvider.js b/src/app/Providers/AI/NLPCloudProvider.js index d753c754..bcc7e9ea 100644 --- a/src/app/Providers/AI/NLPCloudProvider.js +++ b/src/app/Providers/AI/NLPCloudProvider.js @@ -24,12 +24,19 @@ class NLPCloudProvider extends AIProviderBase { return this.nlpCloudClient; } + /** + * Send chat to NLP Cloud + * @param {String} messageText + * @param {String} context + * @param {Array} history + * @returns {Promise<*>} + */ async sendChat(messageText, context, history) { try { // Send request to NLP Cloud. const response = await this.nlpCloudClient.chatbot( messageText, - context + history + context + history.join("\n") ); if (typeof response.data["response"] !== "undefined") { diff --git a/src/app/Providers/AI/NLPCloudProvider.test.js b/src/app/Providers/AI/NLPCloudProvider.test.js index 7c91324a..98c34e56 100644 --- a/src/app/Providers/AI/NLPCloudProvider.test.js +++ b/src/app/Providers/AI/NLPCloudProvider.test.js @@ -26,7 +26,7 @@ describe("NLPCloudProvider", () => { const response = await nlpCloudProvider.sendChat( "hello", "This is a chat", - {} + [] ); expect(response).toBe("example response"); }); diff --git a/src/app/Providers/AI/OpenAIProvider.js b/src/app/Providers/AI/OpenAIProvider.js index 5753b3c2..98cff119 100644 --- a/src/app/Providers/AI/OpenAIProvider.js +++ b/src/app/Providers/AI/OpenAIProvider.js @@ -25,10 +25,17 @@ class OpenAIProvider extends AIProviderBase { return this.openAiClient; } + /** + * Send chat to OpenAI + * @param {String} messageText + * @param {String} context + * @param {Array} history + * @returns {Promise<*>} + */ async sendChat(messageText, context, history) { try { const model = config.AI.GPTModel; // davinci or gpt35 - const prompt = context + history.join("\n") + "\n"; + const prompt = `${context}${history.join("\n")}\nHuman: ${messageText}\n`; if (model === "gpt35") { const convertedChatHistory = history.map((message) => { @@ -61,7 +68,7 @@ class OpenAIProvider extends AIProviderBase { model: "text-davinci-003", prompt, temperature: 1, - max_tokens: 1900, + max_tokens: 2000, top_p: 0.2, frequency_penalty: 0.9, presence_penalty: 1.9, diff --git a/src/app/RirikoAI-NLP.js b/src/app/RirikoAI-NLP.js index 676fea81..5c413c14 100644 --- a/src/app/RirikoAI-NLP.js +++ b/src/app/RirikoAI-NLP.js @@ -26,8 +26,6 @@ const { AI } = require("config"); * @author earnestangel https://github.com/RirikoAI/RirikoBot */ class RirikoAINLP { - // Begin Static Class implementations ------------------------------------------------------------------------------- - static instance = null; static getInstance() { @@ -37,14 +35,19 @@ class RirikoAINLP { return this.instance; } - // Begin Class implementations -------------------------------------------------------------------------------------- - + /** + * Initialize the AI + */ constructor() { try { this.prefix = getconfig.AIPrefix(); // Prefix for the AI this.chatHistory = []; // This is where we store the chat history + this.currentUserPrompt = []; // This is where we store the current user prompt this.costPerToken = 0.00003; // Cost per token in USD - this.minRepetition = 6; // Minimum number of times a phrase must occur to be considered a repetition + this.maxChatTokens = 1900; // Maximum number of prompt tokens per chat + this.retries = []; + this.maxRetries = 3; + this.retryDelay = 1000; // Initialize the AI provider if (AIProvider() === "NLPCloudProvider") { @@ -69,89 +72,17 @@ class RirikoAINLP { } } - // Business Logic implementations ------------------------------------------------------------------------------------ - /** - * Get Personality and Abilities - * @returns {string} - */ - getPersonalitiesAndAbilities() { - try { - // Get the current time - let currentTime = new Date(); - - // Use regular expressions and the replace() method to replace the string - let personality = AIPersonality().join("\n"); - - // Return the personality and abilities - return ( - // Replace the %CURRENT_TIME% placeholder with the current time - personality.replace("%CURRENT_TIME%", currentTime) + - "\n" + - AIPrompts().join("\n") + // Join the prompts with a new line - "\n" - ); - } catch (e) { - console.error("Error in RIRIKO AI:", e); - throw ( - "[ERROR] Something when wrong trying to read the AI Personality and Prompts. " + - "Check the config file (and config.example files), see if there are missing configs" - ); - } - } - - getCurrentTime() { - return new Date(); - } - - calculateToken(historyString) { - return parseInt(historyString.length / 4); // we are making simple assumption that 4 chars = 1 token - } - - getChatHistory(discordMessage) { - if (this.chatHistory[discordMessage.author.id]) { - return this.chatHistory[discordMessage.author.id]; - } else { - // This user has not chatted with Ririko recently, returns empty string - return ""; - } - } - - removeDuplicates = (reply, chatHistory) => { - // Remove duplicate sentences from the reply, based on the last 2 replies - const previousReplies = chatHistory - .filter((entry) => entry.startsWith("Friend:")) - .slice(-2); - - const replyWithoutDuplicates = reply - .split(". ") - .map((sentence) => { - const normalizedSentence = sentence.trim(); - if ( - normalizedSentence && - previousReplies.some((prevReply) => - prevReply.includes(normalizedSentence) - ) - ) { - return "."; - } - return sentence; - }) - .join(". "); - - return replyWithoutDuplicates; - }; - - // Async methods ---------------------------------------------------------------------------------------------------- - - /** - * + * The first entry point for the AI, from Discord event handler * @param message * @returns {Promise} */ async handleMessage(message) { + // Check if the message contains the prefix if (message.content.substring(0, 1) === this.prefix) { + // Check if Daily limit is enabled if (AI.DailyLimit !== false) + // If Daily limit is enabled, check if the user has exceeded the limit try { const usageCount = await getAndIncrementUsageCount( message.member.user.id, @@ -164,9 +95,13 @@ class RirikoAINLP { await message.channel.sendTyping(); - const prompt = message.content.substring(1); //remove the prefix from the message + // remove the prefix from the message so to make it a prompt + const prompt = message.content.substring(1); + + // Ask the AI const answer = await this.ask(prompt, message); + // If the answer is empty, return if (!answer) { return; } @@ -174,13 +109,16 @@ class RirikoAINLP { await message.channel.sendTyping(); // Send response to Discord bot. + // Discord has a limit of 2000 characters per message, so we need to split the answer into multiple messages for (let i = 0; i < answer.length; i += 2000) { const toSend = answer.substring(i, Math.min(answer.length, i + 2000)); message.reply(toSend); } + // Process the answer and see if we should take any action const pa = this.processAnswer(answer); + // The answer is a music command, will now play the music if (pa.isMusic) { if (message.member.voice.channelId !== null) { await message.client.player.play( @@ -194,171 +132,352 @@ class RirikoAINLP { ); } } - return; } } - /** - * Check if we have anything else to do when we receive the answer. - * @param answer - */ - processAnswer(answer) { - const matches = answer.match(/(?<=\🎵).+?(?=\🎵)/g); - if (matches !== null) { - console.info("Playing " + matches[0] + "now. "); - return { - isMusic: true, - name: matches[0], - }; - } else { - return { - isMusic: false, - }; - } - } - - retries = 0; - maxRetries = 3; - retryDelay = 1000; - async ask(messageText, discordMessage) { - if (messageText === "clear") + if (messageText === "clear") { return await this.clearChatHistory(discordMessage); - - await this.setPromptAndChatHistory(messageText, discordMessage); - const currentToken = this.calculateToken( - this.getPersonalitiesAndAbilities() + this.getChatHistory(discordMessage) - ); + } try { - const chatHistory = await this.getChatHistory(discordMessage); - - // Send request to NLP Cloud. - let answer = await this.provider.sendChat( + const chatHistory = await this.setPromptAndChatHistory( messageText, - this.getPersonalitiesAndAbilities(), - chatHistory + discordMessage ); - // if the answer is empty, retry - if (answer === undefined || answer === null) { - if (this.retries < this.maxRetries) { - this.retries++; - console.log("retrying..."); - await this.sleep(this.retryDelay); - return await this.ask(messageText, discordMessage); - } else { - console.log("Max retries reached, aborting."); - this.retries = 0; - throw "Max retries reached, aborting. The answer is always empty."; - } + if (!chatHistory) { + return "Your current prompt is too long / is empty. Please try again."; } - let originalAnswer = answer; + const currentToken = this.calculateTokenWithEverything(discordMessage); + + let answer = await this.sendChatRequest(messageText, chatHistory); + + if (!answer) { + return await this.retryAsk(messageText, discordMessage); + } answer = this.removeDuplicates(answer, chatHistory); - await this.saveAnswer(answer, discordMessage); + await this.saveAnswer(answer, messageText, discordMessage); - const totalToken = currentToken + this.calculateToken(originalAnswer); + const totalToken = this.calculateTotalToken(currentToken, answer); - console.info( - "[RirikoAI-NLP] Request complete, costs ".blue + - totalToken + - ` tokens, that's about `.blue + - `$${(this.costPerToken * totalToken).toFixed(5)}` - ); + this.logTokenCost(totalToken); return answer; } catch (e) { - console.error( - "Something went wrong when trying to send the request to the AI provider:" + - " Check if your API key is still valid, or if your prompts are not corrupted / too long." - ); - console.error( - "Also try to clear your chat history with Ririko by entering .clear in Discord.", - e + return this.handleRequestError(e); + } + } + + /** + * Send the chat request to the AI provider + * @param {String} messageText + * @param {Array} chatHistory + * @returns {Promise<*>} + */ + async sendChatRequest(messageText, chatHistory) { + return await this.provider.sendChat( + messageText, + this.getPersonalitiesAndAbilities(), + chatHistory + ); + } + + async retryAsk(messageText, discordMessage) { + let retries = this.retries[discordMessage.author.id] || 0; + + if (retries < this.maxRetries) { + retries++; + this.retries[discordMessage.author.id] = retries; + + console.info("retrying..."); + await this.sleep(this.retryDelay); + return await this.ask(messageText, discordMessage); + } else { + console.error("Max retries reached, aborting."); + this.retries[discordMessage.author.id] = 0; + throw new Error( + "Max retries reached, aborting. The answer is always empty." ); } } + calculateTotalToken(currentToken, answer) { + return currentToken + this.calculateToken(answer); + } + + logTokenCost(totalToken) { + console.info( + "[RirikoAI-NLP] Request complete, costs ".blue + + totalToken + + ` tokens, that's about `.blue + + `$${(this.costPerToken * totalToken).toFixed(5)}` + ); + } + + handleRequestError(e) { + console.error( + "Something went wrong when trying to send the request to the AI provider: " + + "Check if your API key is still valid, or if your prompts are not corrupted / too long. Also try to clear your " + + "chat history with Ririko by entering .clear in Discord." + ); + + if (e.response) { + console.error(e.response.data.error.message); + return "Your current prompt is too long, please try again with a shorter prompt. Alternatively, you can clear your chat with `.clear` and try again."; + } else { + console.error("", e); + return "Something went wrong when trying to reply to you. Please try with a different/shorter prompt or clear your chat with `.clear` and try again."; + } + } + + // ========================================== All about current prompt ============================================== + + /** + * Get the current prompt + * @param discordMessage + * @returns {*} + */ + getCurrentPrompt(discordMessage) { + return this.currentUserPrompt[discordMessage.author.id]; + } + + /** + * Set the current prompt + * @param message + * @param discordMessage + * @returns {*} + */ + setCurrentPrompt(message, discordMessage) { + this.currentUserPrompt[discordMessage.author.id] = "Human: " + message; + return this.currentUserPrompt[discordMessage.author.id]; + } + + /** + * Set and check the current prompt with chat history + * @param message + * @param discordMessage + * @returns {Promise<*|boolean>} + */ async setPromptAndChatHistory(message, discordMessage) { - try { - const currentPrompt = "Human: " + message; + // Set the current prompt into array + this.setCurrentPrompt(message, discordMessage); + + // check upfront if the current message does not exceed the maxChatToken + const tokens = this.calculateToken( + this.getPersonalitiesAndAbilities() + + this.getCurrentPrompt(discordMessage) + ); - let perUserChatHistory = await findChatHistory( - discordMessage.guildId, - discordMessage.author.id + if (tokens > this.maxChatTokens - 1000) { + console.error( + `Your current prompt is too long, please try again with a shorter prompt` ); + return false; + } - // this user never chatted with Ririko before, create a new chathistory - if (perUserChatHistory === null) { - this.chatHistory[discordMessage.author.id] = []; - perUserChatHistory = await addChatHistory(discordMessage, []); - } + try { + await this.initializeChatHistory(discordMessage); - // Check if perUserChatHistory is an array or a string. If it's a string, convert it to an array. - // For compatibility with older versions of Ririko AI < 0.9.0 - if (typeof perUserChatHistory.chat_history === "string") { - perUserChatHistory.chat_history = [perUserChatHistory.chat_history]; + const lengthIsValid = this.validateTokenLength(discordMessage); + + if (!lengthIsValid) { + await this.truncateChatHistory(discordMessage); } - // Set the chat history to the one stored in the database - this.chatHistory[discordMessage.author.id] = - perUserChatHistory.chat_history; + return this.chatHistory[discordMessage.author.id]; + } catch (e) { + throw e; + } + } - // Add the current prompt to the chat history - this.chatHistory[discordMessage.author.id].push(currentPrompt); + /** + * Get Personality and Abilities + * @returns {string} + */ + getPersonalitiesAndAbilities() { + try { + // Get the current time + let currentTime = new Date(); - // Join the chat history array into a single string - const prompt = this.chatHistory[discordMessage.author.id].join("\n"); + // Use regular expressions and the replace() method to replace the string + let personality = AIPersonality().join("\n"), + ability = AIPrompts().join("\n"); - // Calculate the number of tokens the prompt costs approximately - const chatTokens = this.calculateToken( - this.getPersonalitiesAndAbilities() + prompt + // Return the personality and abilities + return ( + // Replace the %CURRENT_TIME% placeholder with the current time + personality.replace("%CURRENT_TIME%", currentTime) + + "\n\n" + // Space between the personality and abilities + ability + // Joins the abilities with a new line + "\n" // Ends with a new line ); - - console.info( - "[RirikoAI-NLP] A new request with ".blue + - chatTokens + - " tokens is being prepared.".blue + } catch (e) { + console.error("Error in RIRIKO AI:", e); + throw ( + "[ERROR] Something when wrong trying to read the AI Personality and Prompts. " + + "Check the config file (and config.example files), see if there are missing configs" ); + } + } + + // ========================================= All about token calculations =========================================== + + /** + * Calculate the number of token + * @param historyString + * @returns {number} + */ + calculateToken(historyString) { + // Calculate the number of tokens, we're making the assumption that length of the string divided by 4 is the number of tokens + // This is not 100% accurate, but it's good enough for now + return parseInt(historyString.length / 4); + } + + /** + * Calculate the number of token with everything (personalities, abilities, chat history, and current prompt) + * @param discordMessage + * @returns {number} + */ + calculateTokenWithEverything(discordMessage) { + return this.calculateToken( + this.getPersonalitiesAndAbilities() + + this.getChatHistory(discordMessage).toString() + + this.getCurrentPrompt(discordMessage) + ); + } - // If the prompt is too long, trim it - if (chatTokens > 1900) { - /** - * The actual maximum number of tokens is around 2048 (new models support 4096). - * But I do not plan to hit it but put the ceiling a bit much lower then remove - * old messages after it is reached to continue chatting. - */ - - console.info( - "[RirikoAI-NLP] The prompt has reached the maximum of ".blue + - chatTokens + - ". Trimming now.".blue - ); - - /** - * This code takes the chatHistory array, removes the first 20 elements, - * and keeps the remaining elements as the most recent chat history. - */ - this.chatHistory[discordMessage.author.id] = - this.chatHistory[discordMessage.author.id].slice(-20); + /** + * Check if the current token length is valid + * @param discordMessage + * @returns {boolean} + */ + validateTokenLength(discordMessage) { + // Calculate the current token with everything + const chatTokens = this.calculateTokenWithEverything(discordMessage); + + console.info( + "[RirikoAI-NLP] A new request with ".blue + + chatTokens + + " tokens is being prepared.".blue + ); + + return chatTokens < this.maxChatTokens; + } + + // ============================================ All about chat history ============================================== + + /** + * + * @param discordMessage + * @returns {Array} - Array of strings, or empty array if no chat history found + */ + getChatHistory(discordMessage) { + if (this.chatHistory[discordMessage.author.id]) { + return this.chatHistory[discordMessage.author.id]; + } else { + // This user has not chatted with Ririko recently, returns empty array + return []; + } + } + + /** + * Initialize chat history. If the user has never chatted with Ririko before, create a new chat history + * If the user has chatted with Ririko before, get the chat history from the database. + * If the chat history in the database is a string, convert it to an array + * + * @param discordMessage + * @returns {Promise} + */ + async initializeChatHistory(discordMessage) { + let perUserChatHistory = await findChatHistory( + discordMessage.guildId, + discordMessage.author.id + ); + + // this user never chatted with Ririko before, create a new chathistory + if (perUserChatHistory === null) { + this.chatHistory[discordMessage.author.id] = []; + perUserChatHistory = await addChatHistory(discordMessage, []); + } + + // Check if perUserChatHistory is an array or a string. If it's a string, convert it to an array. + // For compatibility with older versions of Ririko AI < 0.9.0 + if (typeof perUserChatHistory.chat_history === "string") { + perUserChatHistory.chat_history = [perUserChatHistory.chat_history]; + } + + // Set the chat history to the one stored in the database + this.chatHistory[discordMessage.author.id] = + perUserChatHistory.chat_history; + } + + /** + * Truncate the chat history if the current token length is too long. + * Maximum retries is 100, to prevent infinite loop. + * + * @throws {string} - If truncating the chat history takes more than 100 retries, throw an error. + * @param discordMessage + * @returns {Promise} + */ + async truncateChatHistory(discordMessage) { + let totalToken = this.calculateTokenWithEverything(discordMessage); + + // Just in case, set a maxRetries to prevent infinite loop + const maxRetries = 100; + let retries = 0; + + // Truncate the chat history until the total token is less than the maxChatTokens + while (totalToken > this.maxChatTokens) { + if (retries > maxRetries) { + throw "Your current prompt is too long, please try again with a shorter prompt"; } - } catch (e) { - console.log("Something went wrong:", e); + + this.chatHistory[discordMessage.author.id] = + this.chatHistory[discordMessage.author.id].slice(1); + + totalToken = this.calculateTokenWithEverything(discordMessage); } + + console.info( + `[RirikoAI-NLP] Truncated chat history to ${totalToken} tokens.`.blue + ); } + /** + * Clear the chat history. This is useful if the user wants to start a new conversation or have any issues with the AI. + * The user can clear the chat history by entering the command .clear + * + * @param discordMessage + * @returns {Promise} + */ + async clearChatHistory(discordMessage) { + await deleteChatHistory(discordMessage.guildId, discordMessage.author.id); + return "Your chat history with Ririko has been cleared."; + } + + // =========================================== All about post AI answer ============================================= + /** * Save the answer into the chat history db * @param answer + * @param messageText * @param discordMessage * @returns {Promise} */ - async saveAnswer(answer, discordMessage) { + async saveAnswer(answer, messageText, discordMessage) { + // Save current user prompt and answer into chat history + this.chatHistory[discordMessage.author.id].push( + this.getCurrentPrompt(discordMessage) + ); + + // Save the answer into the chat history this.chatHistory[discordMessage.author.id].push("Friend: " + answer); - // save chat history into mongodb + + // Push the chat history containing personalities and abilities + past conversations + the current prompt + the answer into the database await updateChatHistory( discordMessage.guildId, discordMessage.author.id, @@ -366,14 +485,75 @@ class RirikoAINLP { ); } - async clearChatHistory(discordMessage) { - await deleteChatHistory(discordMessage.guildId, discordMessage.author.id); - return "Your chat history with Ririko has been cleared."; + /** + * Remove duplicate sentences from the reply, based on the last 2 replies + * @param reply + * @param chatHistory + * @returns {*} + */ + removeDuplicates = (reply, chatHistory) => { + // Get the last 2 replies from the AI + const previousReplies = chatHistory + .filter((entry) => entry.startsWith("Friend:")) + .slice(-2); + + // Returns the reply with duplicate sentences removed + return reply + .split(". ") + .map((sentence) => { + const normalizedSentence = sentence.trim(); + if ( + normalizedSentence && + previousReplies.some((prevReply) => + prevReply.includes(normalizedSentence) + ) + ) { + return "."; + } + return sentence; + }) + .join(". "); + }; + + /** + * Check if we have anything else to do when we receive the answer. + * @param answer + */ + processAnswer(answer) { + // Check if the answer is a music command + const matches = answer.match(/(?<=\🎵).+?(?=\🎵)/g); + + // If the answer is a music command, return the name of the song, and set isMusic to true. + // Otherwise, return isMusic as false. + if (matches !== null) { + console.info("Playing " + matches[0] + "now. "); + return { + isMusic: true, + name: matches[0], + }; + } else { + return { + isMusic: false, + }; + } } + /** + * Sleep for a certain amount of time + * @param ms + * @returns {Promise} + */ sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } + + /** + * Get the current time + * @returns {Date} + */ + getCurrentTime() { + return new Date(); + } } module.exports = { RirikoAINLP }; diff --git a/src/ririkoBot.js b/src/ririkoBot.js index e5a609b1..4c96138b 100644 --- a/src/ririkoBot.js +++ b/src/ririkoBot.js @@ -19,6 +19,10 @@ const { RirikoMusic } = require("app/RirikoMusic"); const { getLang } = require("./helpers/language"); const { RirikoAVC } = require("./app/RirikoAVC"); +process.on("uncaughtException", function (err) { + console.log("Caught exception: " + err); +}); + console.info("0------------------| Ririko AI (Bot):".brightCyan); /**