From 48684fd350b22bcefad355da7130b8e4bfeee49f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Filip=C3=B3w?= Date: Tue, 9 Apr 2024 17:04:19 +0200 Subject: [PATCH] File chatbot initial concept --- package-lock.json | 46 ++++++++++++++ package.json | 2 + src/calculations/loanPrincipal.ts | 13 ---- src/calculations/loanTime.ts | 12 ---- src/calculations/monthlyInterestRate.ts | 4 -- src/chat/get-init.ts | 48 +-------------- src/chat/index.ts | 2 + src/chat/post-new-message.ts | 52 ++-------------- src/chat/tools.ts | 80 ------------------------- src/chat/upload-file.ts | 59 ++++++++++++++++++ src/index.ts | 2 +- src/util/fileReader.ts | 5 +- 12 files changed, 120 insertions(+), 205 deletions(-) delete mode 100644 src/calculations/loanPrincipal.ts delete mode 100644 src/calculations/loanTime.ts delete mode 100644 src/calculations/monthlyInterestRate.ts delete mode 100644 src/chat/tools.ts create mode 100644 src/chat/upload-file.ts diff --git a/package-lock.json b/package-lock.json index b48cde8..a5b4a8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.18.3", + "formidable": "^3.5.1", "officeparser": "^4.0.8", "openai": "^4.29.2", "zod": "^3.22.4" @@ -20,6 +21,7 @@ "@tsconfig/strictest": "^2.0.3", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", + "@types/formidable": "^3.4.5", "@types/node": "^20.11.30", "nodemon": "^3.1.0", "rimraf": "^5.0.5", @@ -178,6 +180,15 @@ "@types/send": "*" } }, + "node_modules/@types/formidable": { + "version": "3.4.5", + "resolved": "https://registry.npmjs.org/@types/formidable/-/formidable-3.4.5.tgz", + "integrity": "sha512-s7YPsNVfnsng5L8sKnG/Gbb2tiwwJTY1conOkJzTMRvJAlLFW1nEua+ADsJQu8N1c0oTHx9+d5nqg10WuT9gHQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/http-errors": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", @@ -357,6 +368,11 @@ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -861,6 +877,15 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/diff": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", @@ -1111,6 +1136,19 @@ "node": ">= 14" } }, + "node_modules/formidable": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.1.tgz", + "integrity": "sha512-WJWKelbRHN41m5dumb0/k8TeAx7Id/y3a+Z7QfhxP/htI9Js5zYaEDtG8uMgG0vM0lOlqnmjE99/kfpOYi/0Og==", + "dependencies": { + "dezalgo": "^1.0.4", + "hexoid": "^1.0.0", + "once": "^1.4.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1316,6 +1354,14 @@ "node": ">= 0.4" } }, + "node_modules/hexoid": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz", + "integrity": "sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==", + "engines": { + "node": ">=8" + } + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", diff --git a/package.json b/package.json index 7e40036..cf290e4 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.18.3", + "formidable": "^3.5.1", "officeparser": "^4.0.8", "openai": "^4.29.2", "zod": "^3.22.4" @@ -31,6 +32,7 @@ "@tsconfig/strictest": "^2.0.3", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", + "@types/formidable": "^3.4.5", "@types/node": "^20.11.30", "nodemon": "^3.1.0", "rimraf": "^5.0.5", diff --git a/src/calculations/loanPrincipal.ts b/src/calculations/loanPrincipal.ts deleted file mode 100644 index b1b0076..0000000 --- a/src/calculations/loanPrincipal.ts +++ /dev/null @@ -1,13 +0,0 @@ -import calcMonthlyInterestRate from "./monthlyInterestRate.js" -interface CalculateLoanAmountArgs { - monthlyInstallment: number; - interestRate: number; - time: number; -} - -const calculateLoanAmount = (args: CalculateLoanAmountArgs) => { - const monthlyInterestRate = calcMonthlyInterestRate(args.interestRate); - const monthlyInterestRateTransitTime = (1 + monthlyInterestRate) ** (args.time * 12); - return (args.monthlyInstallment * (monthlyInterestRateTransitTime - 1)) / (monthlyInterestRateTransitTime * monthlyInterestRate); -} -export default calculateLoanAmount; \ No newline at end of file diff --git a/src/calculations/loanTime.ts b/src/calculations/loanTime.ts deleted file mode 100644 index 6457f25..0000000 --- a/src/calculations/loanTime.ts +++ /dev/null @@ -1,12 +0,0 @@ -import calcMonthlyInterestRate from "./monthlyInterestRate.js"; -interface CalculateLoanTermInYearsArguments { - principal: number; - monthlyInstallment: number; - interestRate: number; -} - -const calculateLoanTimeInYears = (args: CalculateLoanTermInYearsArguments) => { - const percentagePerMonth = calcMonthlyInterestRate(args.interestRate); - return (-1 * Math.log(1 - (args.principal / args.monthlyInstallment) * percentagePerMonth) / Math.log(percentagePerMonth + 1)) / 12 -} -export default calculateLoanTimeInYears; \ No newline at end of file diff --git a/src/calculations/monthlyInterestRate.ts b/src/calculations/monthlyInterestRate.ts deleted file mode 100644 index 438d18b..0000000 --- a/src/calculations/monthlyInterestRate.ts +++ /dev/null @@ -1,4 +0,0 @@ -const calcMonthlyInterestRate = (rate: number) => { - return (rate / 100) / 12; -} -export default calcMonthlyInterestRate; \ No newline at end of file diff --git a/src/chat/get-init.ts b/src/chat/get-init.ts index 4d8ecb3..268192b 100644 --- a/src/chat/get-init.ts +++ b/src/chat/get-init.ts @@ -1,63 +1,19 @@ import { z } from "zod"; import { makeGetEndpoint } from "../middleware/validation/makeGetEndpoint.js"; -import { fileReader, parseFileReaderResponse } from "../util/fileReader.js"; -import { OPENAI_MODEL, PROMPT_FILE_NAME, RESPONSE_FORMAT, openai } from "./index.js"; //TODO: Rework type inference of fileReader export const init = makeGetEndpoint(z.any(), async (_request, response) => { - let messages: string[] = [] - const promptFile = await fileReader(PROMPT_FILE_NAME); - let jsonPrompt; - if(RESPONSE_FORMAT.type === "json_object"){ - - jsonPrompt = await fileReader('json-format-prompt.docx'); - if(parseFileReaderResponse(jsonPrompt)){ - messages.push(jsonPrompt.content); - } - else{ - return response.status(500).send({ - status: 500, - message: jsonPrompt.error - }) - } - } - - if(parseFileReaderResponse(promptFile)){ - messages.push(promptFile.content); - } - else{ - return response.status(500).send({ - status: 500, - message: promptFile.error - }) - } - - - const completion = await openai.chat.completions.create({ - messages: messages.map(message => ({role: "system", content: message})), - model: OPENAI_MODEL, - response_format: RESPONSE_FORMAT - }); - console.log(completion.choices[0]?.message); return response .status(200) .send([ - { - role: "system", - content: promptFile.content - }, - { - role: "system", - content: jsonPrompt?.content !== undefined ? jsonPrompt.content : "" - }, { role: 'system', - content: "Don't make assumptions about what values to plug into functions. Ask for clarification if a user request is ambiguous. Do not call functions until all arguments are provided by the user. Include all arguments in the response." + content: "Ask the user to provide the content of the file they want to chat about. Talk only about the content of the provided file." }, { role: "assistant", - content: "Hallo! Als virtueller Assistent beantworte ich gerne Ihre Fragen rund um die Baufinanzierung und helfe Ihnen bei der Berechnung der Kreditsumme anhand des Zeitpunkts und der Höhe der monatlichen Rate bzw. der Kreditlaufzeit anhand der Höhe und der Höhe der monatlichen Rate." + content: "Hallo! Bitte laden Sie die Datei hoch, über die Sie chatten möchten." } ]); }); \ No newline at end of file diff --git a/src/chat/index.ts b/src/chat/index.ts index 104ee35..3ddef1a 100644 --- a/src/chat/index.ts +++ b/src/chat/index.ts @@ -4,6 +4,7 @@ import OpenAI from "openai"; import { ChatCompletionCreateParams, ChatCompletionCreateParamsBase } from "openai/resources/chat/completions.js"; import { init } from "./get-init.js"; import { newMessage } from "./post-new-message.js"; +import fileUpload from "./upload-file.js"; /** * Change the prompt file, the model or the response format here @@ -21,6 +22,7 @@ chatRouter.post('/newMessage', newMessage); chatRouter.get("/test") chatRouter.get('/init', init); +chatRouter.post('/fileUpload', fileUpload) export { diff --git a/src/chat/post-new-message.ts b/src/chat/post-new-message.ts index 197c6ad..027e314 100644 --- a/src/chat/post-new-message.ts +++ b/src/chat/post-new-message.ts @@ -1,8 +1,7 @@ import { z } from "zod"; import { makePostEndpoint } from "../middleware/validation/makePostEndpoint.js"; import { OPENAI_MODEL, RESPONSE_FORMAT, openai } from "./index.js"; -import tools from "./tools.js"; -import {ChatCompletionMessageParam, ChatCompletionTool, ChatCompletionToolMessageParam } from "openai/resources/index.js"; +import { ChatCompletionMessageParam } from "openai/resources/index.js"; const ChatCompletionRole = z.union([z.literal('user'), z.literal('system'), z.literal('assistant')]); export type ChatCompletionRole = z.infer; @@ -22,58 +21,17 @@ const makeApiRequest = async (messages: ChatCompletionMessageParam[]) => { messages, model: OPENAI_MODEL, response_format: RESPONSE_FORMAT, - tools: Object.values(tools).map(val => val.definition), }); } -//check if all required arguments provided by openai api are present and are numbers -const validateArguments = (args: any, definition?: ChatCompletionTool) => { - return undefined === Object.keys(definition?.function?.parameters?.['properties'] as any) - ?.map(property => args[property]) - ?.find(value => value == null || typeof value !== 'number'); -} - const processMessages = async (messages: ChatCompletionMessageParam[], response: any) => { const completion = await makeApiRequest(messages); const chatResponse = completion.choices[0]; - if (chatResponse?.finish_reason === 'tool_calls') { - const toolCalls: ChatCompletionToolMessageParam[] = chatResponse.message.tool_calls?.map(call => { - let content: string; - try { - const choosenFunction = tools[call.function.name]?.fn; - if (!choosenFunction) { - content = 'Failed to calculate - unknown function' - } else { - const args = JSON.parse(call.function.arguments); - if (validateArguments(args, tools[call.function.name]?.definition)) { - console.log(`Running function ${call.function.name} with arguments ${JSON.stringify(args)}.`); - const result = choosenFunction(args); - content = isNaN(result) ? 'Unable to calculate for given arguments, change arguments and try again.' : result?.toString(); - } else { - content = 'Failed to calculate due to incorrect arguments' - } - - } - } catch (error) { - console.error(error); - content = 'Failed to calculate due to an error'; - } - console.log(`Executing ${call.function.name}. Result: ${content}`); - return ({ - role: 'tool', - content: content, - tool_call_id: call.id, - }) - }) ?? []; - const msgs = messages.concat([chatResponse.message, ...toolCalls]); - processMessages(msgs, response) - } else { - console.log(chatResponse); - if(!chatResponse){ - return response.status(500).send("Got no response from the bot"); - } - return response.status(200).send(chatResponse.message); + console.log(chatResponse); + if(!chatResponse){ + return response.status(500).send("Got no response from the bot"); } + return response.status(200).send(chatResponse.message); } export const newMessage = makePostEndpoint(MessageHistory, async (request, response) => { diff --git a/src/chat/tools.ts b/src/chat/tools.ts deleted file mode 100644 index 4fe827e..0000000 --- a/src/chat/tools.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { ChatCompletionTool } from "openai/resources"; -import calculateLoanTimeInYears from "../calculations/loanTime.js"; -import calculateLoanPrincipal from "../calculations/loanPrincipal.js"; - -interface ChatFunction { - [name: string]: { - definition: ChatCompletionTool; - fn: (args: any) => number; - } -} - -const tools: ChatFunction = { - calculateLoanTerm: { - fn: calculateLoanTimeInYears, - definition: { - type: 'function', - function: { - name: 'calculateLoanTerm', - description: 'Calculates loan term (in years) based on the given loan amount, interest rate and monthly installment.', - parameters: { - type: 'object', - properties: { - principal: { - type: 'number', - description: 'Principal of the loan in euro (€)', - }, - monthlyInstallment: { - type: 'number', - description: 'Monthly installment in euro (€)', - }, - interestRate: { - type: 'number', - description: 'Interest rate estimated by the bank, default value is 3,2', - } - }, - required: [ - 'principal', - 'monthlyInstallment', - 'interestRate' - ] - - } - }, - } - }, - calculateLoanPrincipal: { - fn: calculateLoanPrincipal, - definition: { - type: 'function', - function: { - name: 'calculateLoanPrincipal', - description: 'Calculate loan principal based on the given monthly installment, time and interest rate.', - parameters: { - type: 'object', - properties: { - time: { - type: 'number', - description: 'Time of the loan in years', - }, - monthlyInstallment: { - type: 'number', - description: 'Monthly installment in euro (€)', - }, - interestRate: { - type: 'number', - description: 'Interest rate estimated by the bank, default value is 3,2', - } - }, - required: [ - 'time', - 'monthlyInstallment', - 'interestRate', - ] - } - } - } - } -} - -export default tools; \ No newline at end of file diff --git a/src/chat/upload-file.ts b/src/chat/upload-file.ts new file mode 100644 index 0000000..cfda475 --- /dev/null +++ b/src/chat/upload-file.ts @@ -0,0 +1,59 @@ +import formidable from 'formidable'; +import { IncomingMessage } from 'http'; +import fs from 'node:fs/promises'; +import { fileReader } from '../util/fileReader'; + +const fileUpload = async (request: IncomingMessage, response: any) => { + let file: formidable.File | null = null; + let fileContent: string | null = null; + try { + const form = formidable({ + maxFileSize: 5 * 1024 * 1024, //5MB + maxFiles: 1, + keepExtensions: true, + + }); + file = (await form.parse(request))[1]?.['file']?.[0] ?? null; + if (!file) { + return response.status(400).send('File not present'); + } + console.log(`File created ${file.filepath}`); + switch(file.mimetype) { + case 'text/plain': + fileContent = await fs.readFile(file.filepath, {encoding: 'utf-8'}); + break; + case 'application/msword': + case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': + const fileConversionResult = await fileReader(file.filepath, true); + fileContent = fileConversionResult.content ?? null; + if (fileConversionResult.error) { + console.error(fileConversionResult.error); + } + console.log(fileConversionResult); + break; + default: + throw (`Unsupported file type: ${file?.mimetype}`); + } + if (fileContent) { + return response.status(200).send({ + content: fileContent, + name: file.originalFilename, + }); + } else { + return response.status(400).send('Empty file or failed to extract content'); + } + } catch (error) { + console.log(error); + return response.status(500).send('Failed to process file.'); + } finally { + try { + if (file) { + fs.rm(file.filepath).then(() => console.log(`File removed: ${file?.filepath}`)); + } + } catch (error) { + console.log(error); + } + } + +} +export default fileUpload; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 6cce307..a07e24b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ import cors from "cors"; const corsOptions = { - origin: ["http://localhost:4200", "https://loan-chatbot-frontend-csf37hag2a-ey.a.run.app"], + origin: ["http://localhost:4200", "https://file-chatbot-frontend-csf37hag2a-ey.a.run.app"], optionsSuccessStatus: 204 } diff --git a/src/util/fileReader.ts b/src/util/fileReader.ts index ae63358..964d6b2 100644 --- a/src/util/fileReader.ts +++ b/src/util/fileReader.ts @@ -17,9 +17,9 @@ export type FileReaderError = z.infer; const stringParser = z.string(); -export const fileReader = async (fileName:string): Promise => { +export const fileReader = async (fileName:string, isPath?: boolean): Promise => { try{ - const data = await officeParser.parseOfficeAsync(`${process.cwd()}/prompts/${fileName}`); + const data = await officeParser.parseOfficeAsync(isPath ? fileName : `${process.cwd()}/prompts/${fileName}`); const validatedData = stringParser.safeParse(data); if(!validatedData.success){ return { @@ -33,6 +33,7 @@ export const fileReader = async (fileName:string): Promise