diff --git a/examples/question.js b/examples/question.js new file mode 100644 index 0000000..d3522ee --- /dev/null +++ b/examples/question.js @@ -0,0 +1,18 @@ +'use strict'; + +const { questionQuery } = require('../lib/embeddings-query'); +const util = require('util'); + +async function main() { + const question = 'Kui suur on mu eelmise kuu Amazoni arve?'; + + const info = await questionQuery(question, process.env.OPENAI_API_KEY, { + //gptModel: 'gpt-3.5-turbo', + //gptModel: 'gpt-4', + verbose: true + }); + + console.log(util.inspect(info, false, 22, true)); +} + +main(); diff --git a/index.js b/index.js index 26bac16..e771db9 100644 --- a/index.js +++ b/index.js @@ -3,13 +3,14 @@ const { generateSummary, DEFAULT_SYSTEM_PROMPT, DEFAULT_USER_PROMPT } = require('./lib/generate-summary'); const riskAnalysis = require('./lib/risk-analysis'); const { generateEmbeddings, getChunkEmbeddings } = require('./lib/generate-embeddings'); -const { embeddingsQuery } = require('./lib/embeddings-query'); +const { embeddingsQuery, questionQuery } = require('./lib/embeddings-query'); module.exports = { generateSummary, generateEmbeddings, getChunkEmbeddings, embeddingsQuery, + questionQuery, riskAnalysis, DEFAULT_SYSTEM_PROMPT, DEFAULT_USER_PROMPT diff --git a/lib/embeddings-query.js b/lib/embeddings-query.js index 55fbe97..69fcbfb 100644 --- a/lib/embeddings-query.js +++ b/lib/embeddings-query.js @@ -3,6 +3,7 @@ const packageData = require('../package.json'); const { default: GPT3Tokenizer } = require('gpt3-tokenizer'); const util = require('util'); +const crypto = require('crypto'); const { fetch: fetchCmd, Agent } = require('undici'); const fetchAgent = new Agent({ connect: { timeout: 90 * 1000 } }); @@ -14,26 +15,94 @@ const OPENAI_API_URL_CHAT = 'https://api.openai.com/v1/chat/completions'; const OPENAI_API_URL_INSTRUCT = 'https://api.openai.com/v1/completions'; const DEFAULT_SYSTEM_PROMPT = ` -You are a helpful executive assistant that looks for requested information from stored emails. +You are an automated system designed to extract and provide information based on stored emails. `.trim(); const SCHEMA_PROMPT = ` -Input facts: -- The question to process is encoded in the following JSON schema: - {"question":""} -- A list of emails is provided as the context -- Each context email consists of a header, and the content -- The header consists of comma-separated key-value pairs -- An empty line separates the header and content of an email - -Output facts: -- Select the best matching email from the context emails and compose an answer for the question based on that email -- If there is no matching email or confidence about the match is low, do not write a response -- Do not use an email that is not listed in the context emails list -- On the first line of the response, write a prefix "Message-ID": that follows with the Message-ID header value of the matching email -- On the second line of the response, write the answer to the question -- Do not mention the Message-ID value in the answer text -- Do not comment anything`.trim(); +**Input Information:** + +- **Question Format:** The query is presented using the JSON schema: \`{"question":"What was the date of our last meeting?"}\` + +- **Email Context:** We are provided with a series of emails to analyze. + + - **Email Structure:** Each email is divided into two sections: a header and its content. These sections are separated by an empty line. + + - **Email Content:** This pertains exclusively to the plain text of the email. No attachments or their contents are provided. + + - **Sample Header:** + \`\`\` + - EMAIL #1: + From: James + To: Andris + Subject: Hello + Message-ID: + Date: 1 Oct 2023 06:30:26 +0200 + Attachments: image.png, invoice.pdf + \`\`\` + + - Every header starts with the string \`- EMAIL #\` followed by the email sequence number + - **Mandatory Field:** Every email will contain a unique Message-ID. + - **Date Field:** Represents the timestamp when the email was sent. + - **Attachments:** This field, when present, lists the names of attachments included with the email, separated by commas. + +**Output Guidelines:** + +1. Your objective is to sift through the email context and pinpoint the answer that best addresses the given query. +2. If no email matches the query criteria, or if the match is ambiguous, refrain from providing an answer. +3. Limit your sources strictly to the provided email context. External references are not to be utilized. +4. Format your response as follows: + - Start with \`Answer:\` followed by the relevant information. + - On a new line, begin with \`Message-ID:\` and cite the unique Message-ID(s) of the emails you sourced your answer from. +5. Ensure that the Message-ID is never embedded within the main body of your response. +6. Avoid including any additional commentary or annotations. +`.trim(); + +const QUESTION_PROMPT = ` +Instructions: + +You are analyzing user questions regarding email retrieval from a database. From the user's query, determine: + +1. **Order Preference**: + - Retrieve older emails first ('older_first'). + - Retrieve newer emails first ('newer_first'). + - If no specific order is discernible from the query, identify the most relevant email ('best_match'), based on keywords or subjects that closely align with the user's question. + +2. **Time Constraints**: + - Identify the starting point for the query ('start_time'). + - Identify when to stop the query ('end_time'). + +**Context**: + +- The current time is '${new Date().toUTCString()}'. + +**Output Guidelines**: + +- For terms implying a near-future context (e.g., "next", "newest", "upcoming"), opt for the 'newer_first' ordering. +- For terms implying a distant past (e.g., "first", "oldest"), use the 'older_first' ordering. +- If the user's query does not provide a clear time frame, or if the system's confidence in deducing a timeframe is below 70%, exclude 'start_time' and 'end_time' from the output. +- If the deduced 'end_time' aligns with current time, omit the 'end_time'. +- For unspecified time zones, timestamps should follow the 'YYYY-MM-DD hh:mm:ss' format. +- If only the date is known, use the 'YYYY-MM-DD' format. +- Assume the week starts on Monday. +- Your response should be structured in JSON, strictly adhering to the schema: + \`\`\` + { + "ordering": "", + "start_time": "", + "end_time": "" + } + \`\`\` +- Example Queries and Responses: + - **Query**: "When is the next conference event?" + **Response**: \`{"ordering":"newer_first"}\` + - **Query**: "What did James write to me about last Friday?" (assuming that current time is "2023-10-02") + **Response**: \`{"ordering":"best_match", "start_time": "2023-09-29", "end_time": "2023-09-30"}\` + - **Query**: "When did I receive my first Amazon invoice?" + **Response**: \`{"ordering":"older_first"}\` + +**User's Query**: +Process the user question: +`.trim(); async function embeddingsQuery(apiToken, opts) { opts = opts || {}; @@ -104,6 +173,10 @@ ${promptText} model: gptModel }; + if (opts.user) { + payload.user = opts.user; + } + if (opts.temperature && !isNaN(opts.temperature)) { payload.temperature = Number(opts.temperature); } @@ -112,6 +185,8 @@ ${promptText} payload.top_p = Number(opts.topP); } + const requestId = crypto.randomBytes(8).toString('base64'); + let res; let data; let retries = 0; @@ -145,7 +220,7 @@ ${promptText} } if (opts.verbose) { - console.log(util.inspect(payload, false, 5, true)); + console.error(util.inspect({ requestId, payload }, false, 8, true)); } let run = async () => { @@ -189,7 +264,6 @@ ${promptText} await run(); const reqEndTime = Date.now(); - let values; let output = data && data.choices && @@ -200,27 +274,49 @@ ${promptText} .join('') .trim(); - let prefixMatch = output.match(/Message[-_]ID:?/i); - if (prefixMatch) { - output = output.substring(prefixMatch.index + prefixMatch[0].length).trim(); + if (opts.verbose) { + console.error(util.inspect({ requestId, output: data }, false, 8, true)); } - output = output + let responseValues = { answer: '', 'message-id': '' }; + let curKey; + output .trim() - .replace(/^(message[-_]?id|output|answer|response):?\s*/i, '') - .trim(); - let lineBreakMatch = output.match(/[\r\n]+/); - if (lineBreakMatch) { - values = { - messageId: output.substring(0, lineBreakMatch.index).trim(), - answer: output - .substring(lineBreakMatch.index + lineBreakMatch[0].length) - .trim() - .replace(/^answer:?\s*/i, '') - }; + .replace(/\r?\n/g, '\n') + .split(/(^|\n)(Answer:|Message-ID:)/gi) + .map(v => v.trim()) + .filter(v => v) + .forEach(val => { + if (/^(answer|message-id):$/i.test(val)) { + curKey = val.replace(/:$/, '').trim().toLowerCase(); + return; + } + + if (!curKey || !val) { + return; + } + + if (curKey === 'message-id') { + val = val + .split(/,/) + .map(v => v.trim()) + .filter(v => v) + .join('\n'); + } + + if (!responseValues[curKey]) { + responseValues[curKey] = val; + } else { + responseValues[curKey] += '\n' + val; + } + }); + + if (responseValues['message-id']) { + responseValues.messageId = Array.from(new Set(responseValues['message-id'].split(/\n/))); } + delete responseValues['message-id']; - const response = Object.assign({ id: null, tokens: null, model: null }, values, { + const response = Object.assign({ id: null, tokens: null, model: null }, responseValues, { id: data && data.id, tokens: data && data.usage && data.usage.total_tokens, model: gptModel @@ -229,9 +325,207 @@ ${promptText} if (opts.verbose) { response._time = reqEndTime - reqStartTime; response._cr = charactersRemoved; + + console.error(util.inspect({ requestId, response }, false, 8, true)); + } + + return response; +} + +async function questionQuery(question, apiToken, opts) { + opts = opts || {}; + + let systemPrompt = (opts.systemPrompt || DEFAULT_SYSTEM_PROMPT).toString().trim(); + question = (question || '').toString().trim(); + if (!question) { + let error = new Error('Question not provided'); + error.code = 'EmptyInput'; + throw error; + } + + let gptModel = opts.gptModel || 'gpt-3.5-turbo-instruct'; + + let prompt = `${QUESTION_PROMPT} +${question} +`; + + let headers = { + 'User-Agent': `${packageData.name}/${packageData.version}`, + Authorization: `Bearer ${apiToken}`, + 'Content-Type': 'application/json' + }; + + let payload = { + model: gptModel + }; + + if (opts.user) { + payload.user = opts.user; + } + + if (opts.temperature && !isNaN(opts.temperature)) { + payload.temperature = Number(opts.temperature); + } else { + payload.temperature = 0.2; + } + + if (opts.topP && !isNaN(opts.topP)) { + payload.top_p = Number(opts.topP); + } + + const requestId = crypto.randomBytes(8).toString('base64'); + + let res; + let data; + let retries = 0; + + let openAiAPIURL; + switch (gptModel) { + case 'gpt-3.5-turbo-instruct': + { + openAiAPIURL = OPENAI_API_URL_INSTRUCT; + payload.prompt = `${systemPrompt}\n${prompt}`; + let tokens = tokenizer.encode(payload.prompt); + payload.max_tokens = 4000 - tokens.bpe.length; + } + break; + + case 'gpt-3.5-turbo': + case 'gpt-4': + default: + openAiAPIURL = OPENAI_API_URL_CHAT; + payload.messages = [ + { + role: 'system', + content: `${systemPrompt}` + }, + { + role: 'user', + content: prompt + } + ]; + break; + } + + if (opts.verbose) { + console.error(util.inspect({ requestId, payload }, false, 8, true)); + } + + let run = async () => { + res = await fetchCmd(openAiAPIURL, { + method: 'post', + headers, + body: JSON.stringify(payload), + dispatcher: fetchAgent + }); + + data = await res.json(); + + if (!res.ok) { + if (res.status === 429 && ++retries < 5) { + // try again + await new Promise(r => setTimeout(r, 1000)); + return await run(); + } + + if (data && data.error) { + let error = new Error(data.error.message || data.error); + if (data.error.code) { + error.code = data.error.code; + } + + error.statusCode = res.status; + throw error; + } + + let error = new Error('Failed to run API request'); + error.statusCode = res.status; + throw error; + } + + if (!data) { + throw new Error(`Failed to POST API request`); + } + }; + + const reqStartTime = Date.now(); + await run(); + const reqEndTime = Date.now(); + + let values; + let output = + data && + data.choices && + data.choices + .filter(msg => msg && ((msg.message && msg.message.role === 'assistant' && msg.message.content) || msg.text)) + .sort((a, b) => ((a && a.index) || 0) - ((b && b.index) || 0)) + .map(msg => (msg.message && msg.message.content) || msg.text) + .join('') + .trim(); + + if (opts.verbose) { + console.error(util.inspect({ requestId, output: data }, false, 8, true)); + } + + try { + let objStart = output.indexOf('{'); + let objEnd = output.lastIndexOf('}'); + + if (objStart < 0 || objEnd < 0 || objEnd <= objStart) { + let error = new Error('Invalid JSON object'); + error.objStart = objStart; + error.objEnd = objEnd; + throw error; + } + + // remove potential comments before and after the JSON output + if (objEnd < output.length - 1) { + output = output.substring(0, objEnd + 1); + } + + if (objStart > 0) { + output = output.substring(objStart); + } + + values = JSON.parse(output); + + let walkAndRemoveNull = branch => { + if (typeof branch !== 'object' || !branch) { + return; + } + for (let key of Object.keys(branch)) { + let subBranch = branch[key]; + if (Array.isArray(subBranch)) { + for (let entry of subBranch) { + walkAndRemoveNull(entry); + } + } else if (subBranch && typeof subBranch === 'object') { + walkAndRemoveNull(subBranch); + } else if (!['boolean', 'string', 'number'].includes(typeof subBranch) || subBranch === '') { + delete branch[key]; + } + } + }; + + walkAndRemoveNull(values); + } catch (err) { + let error = new Error('Failed to parse output from OpenAI API', { cause: err }); + error.textContent = output; + error.code = 'OutputParseFailed'; + throw error; + } + + const response = Object.assign({ id: null, tokens: null, model: null }, values || {}, { + id: data && data.id, + tokens: data && data.usage && data.usage.total_tokens, + model: gptModel + }); + + if (opts.verbose) { + response._time = reqEndTime - reqStartTime; } return response; } -module.exports = { embeddingsQuery }; +module.exports = { embeddingsQuery, questionQuery }; diff --git a/lib/generate-embeddings.js b/lib/generate-embeddings.js index f78d820..ea78ea3 100644 --- a/lib/generate-embeddings.js +++ b/lib/generate-embeddings.js @@ -11,6 +11,7 @@ const fetchAgent = new Agent({ connect: { timeout: 90 * 1000 } }); const util = require('util'); const linkifyIt = require('linkify-it'); const tlds = require('tlds'); +const crypto = require('crypto'); const linkify = linkifyIt() .tlds(tlds) // Reload with full tlds list @@ -38,12 +39,18 @@ async function getChunkEmbeddings(chunk, apiToken, opts) { input: chunk }; + if (opts.user) { + payload.user = opts.user; + } + + let requestId = crypto.randomBytes(8).toString('base64'); + let res; let data; let retries = 0; if (verbose) { - console.log(util.inspect(payload, false, 5, true)); + console.error(util.inspect({ requestId, payload }, false, 8, true)); } let run = async () => { @@ -86,6 +93,10 @@ async function getChunkEmbeddings(chunk, apiToken, opts) { await run(); const reqEndTime = Date.now(); + if (opts.verbose) { + console.error(util.inspect({ requestId, output: data }, false, 8, true)); + } + let embedding = data?.data?.[0]?.embedding; return { chunk, diff --git a/lib/generate-summary.js b/lib/generate-summary.js index 4ba73ce..3469acd 100644 --- a/lib/generate-summary.js +++ b/lib/generate-summary.js @@ -4,6 +4,7 @@ const packageData = require('../package.json'); const { htmlToText } = require('@postalsys/email-text-tools'); const { default: GPT3Tokenizer } = require('gpt3-tokenizer'); const util = require('util'); +const crypto = require('crypto'); const { fetch: fetchCmd, Agent } = require('undici'); const fetchAgent = new Agent({ connect: { timeout: 90 * 1000 } }); @@ -218,6 +219,10 @@ ${JSON.stringify(content)}`; model: gptModel }; + if (opts.user) { + payload.user = opts.user; + } + if (opts.temperature && !isNaN(opts.temperature)) { payload.temperature = Number(opts.temperature); } @@ -226,6 +231,8 @@ ${JSON.stringify(content)}`; payload.top_p = Number(opts.topP); } + let requestId = crypto.randomBytes(8).toString('base64'); + let res; let data; let retries = 0; @@ -259,7 +266,7 @@ ${JSON.stringify(content)}`; } if (opts.verbose) { - console.log(util.inspect(payload, false, 5, true)); + console.error(util.inspect({ requestId, payload }, false, 8, true)); } let run = async () => { @@ -314,6 +321,10 @@ ${JSON.stringify(content)}`; .join('') .trim(); + if (opts.verbose) { + console.error(util.inspect({ output, data }, false, 8, true)); + } + try { let objStart = output.indexOf('{'); let objEnd = output.lastIndexOf('}'); diff --git a/lib/risk-analysis.js b/lib/risk-analysis.js index a02581e..f036d21 100644 --- a/lib/risk-analysis.js +++ b/lib/risk-analysis.js @@ -162,6 +162,10 @@ ${JSON.stringify(content)}`; ] }; + if (opts.user) { + payload.user = opts.user; + } + if (opts.temperature && !isNaN(opts.temperature)) { payload.temperature = Number(opts.temperature); } diff --git a/package-lock.json b/package-lock.json index 02c96d8..e43fbce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "nodemailer": "6.9.5", "punycode": "2.3.0", "tlds": "1.242.0", - "undici": "5.25.2" + "undici": "5.25.3" }, "devDependencies": { "eslint-config-nodemailer": "1.2.0", @@ -51,9 +51,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.9.0.tgz", - "integrity": "sha512-zJmuCWj2VLBt4c25CfBIbMZLGLyhkvs7LznyVX5HfpzeocThgIj5XQK4L+g3U36mMcx8bPMhGyPpwCATamC4jQ==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.9.1.tgz", + "integrity": "sha512-Y27x+MBLjXa+0JWDhykM3+JE+il3kHKAEqabfEWq3SDhZjLYb6/BHL/JKFnH3fe207JaXkyDo685Oc2Glt6ifA==", "dev": true, "peer": true, "engines": { @@ -94,6 +94,14 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@fastify/busboy": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.0.0.tgz", + "integrity": "sha512-JUFJad5lv7jxj926GPgymrWQxxjPYuJNiNjNMzqT+HiuP6Vl3dk5xzG+8sTX96np0ZAluvaMzPsjhHZ5rNuNQQ==", + "engines": { + "node": ">=14" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.11", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.11.tgz", @@ -343,17 +351,6 @@ "concat-map": "0.0.1" } }, - "node_modules/busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "dependencies": { - "streamsearch": "^1.1.0" - }, - "engines": { - "node": ">=10.16.0" - } - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2093,14 +2090,6 @@ "node": "*" } }, - "node_modules/streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -2217,11 +2206,11 @@ "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==" }, "node_modules/undici": { - "version": "5.25.2", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.25.2.tgz", - "integrity": "sha512-tch8RbCfn1UUH1PeVCXva4V8gDpGAud/w0WubD6sHC46vYQ3KDxL+xv1A2UxK0N6jrVedutuPHxe1XIoqerwMw==", + "version": "5.25.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.25.3.tgz", + "integrity": "sha512-7lmhlz3K1+IKB6IUjkdzV2l0jKY8/0KguEMdEpzzXCug5pEGIp3DxUg0DEN65DrVoxHiRKpPORC/qzX+UglSkQ==", "dependencies": { - "busboy": "^1.6.0" + "@fastify/busboy": "^2.0.0" }, "engines": { "node": ">=14.0" diff --git a/package.json b/package.json index 93deda7..d265e38 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,6 @@ "nodemailer": "6.9.5", "punycode": "2.3.0", "tlds": "1.242.0", - "undici": "5.25.2" + "undici": "5.25.3" } }