From 6857a3be7079c81d14f33a9d2015b5182d59d1d4 Mon Sep 17 00:00:00 2001 From: Bryan Mishkin <698306+bmish@users.noreply.github.com> Date: Sat, 18 Mar 2023 11:30:48 -0400 Subject: [PATCH] feat: support getEmojis() for multiple strings at once, switch to gpt-3.5-turbo --- README.md | 18 ++++--- bin/dummy.ts | 3 ++ lib/index.ts | 103 ++++++++++++++++++++++++++++++++++------- package.json | 6 +-- test/lib/index-test.ts | 94 +++++++++++++++++++++++++++++++------ tsconfig.json | 1 + 6 files changed, 183 insertions(+), 42 deletions(-) create mode 100644 bin/dummy.ts diff --git a/README.md b/README.md index 970e2f3..3147cf0 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Get the emoji(s) that best represent the given text/concept. -Uses [OpenAI's GPT-3.5](https://platform.openai.com/docs/models/gpt-3-5) `text-davinci-003` API. +Uses [OpenAI's GPT-3.5](https://platform.openai.com/docs/models/gpt-3-5) `gpt-3.5-turbo` API. ## Table of contents @@ -36,11 +36,11 @@ export OPENAI_API_KEY=sk-... | Parameter | Type | Description | | --- | --- | --- | -| `text` | `string` | The text to get emojis for. | +| `text` | `string` or `string[]` | The string(s) to get emojis for. | | `options` | `object` | Optional options for the function. | | `options.count` | `number` | The number of emojis to represent the text with. | -Returns a string array of emojis that best represent the given text. +Returns a string array of emojis that best represent the given text, or a nested array of strings if multiple texts are given. Choose how many emojis to use, or let the AI decide how many are needed. @@ -48,18 +48,22 @@ Note that this function is non-deterministic and could return different emojis e ## Usage -Specify that you want only one emoji: +A single string: ```js import { getEmojis } from 'gpt-emoji'; -getEmojis('japanese cherry blossom festival', { count: 1 }); // ['šŸŒø'] +getEmojis('japanese cherry blossom festival'); + +// ['šŸŒø', 'šŸ‡ÆšŸ‡µ, 'šŸŽŽ'] ``` -Allow the AI to decide how many emojis to use: +Multiple strings and a custom count: ```js import { getEmojis } from 'gpt-emoji'; -getEmojis('japanese cherry blossom festival'); // ['šŸŒø', 'šŸ‡ÆšŸ‡µ, 'šŸŽŽ'] +getEmojis(['atomic clock', 'machine learning'], { count: 2 }); + +// [ [ 'ā°', 'ā˜¢ļø' ], [ 'šŸ¤–', 'šŸ§ ' ] ] ``` diff --git a/bin/dummy.ts b/bin/dummy.ts new file mode 100644 index 0000000..be63095 --- /dev/null +++ b/bin/dummy.ts @@ -0,0 +1,3 @@ +/* eslint unicorn/no-empty-file:"off" */ +// Placeholder folder so that dist/ always maintains the same directory structure. +// If lib is the only folder, which can happen since the directories vary based on different tsconfig.json files, the directories can be collapsed. diff --git a/lib/index.ts b/lib/index.ts index 4f76dbf..bd76276 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -6,19 +6,58 @@ const configuration = new Configuration({ const openai = new OpenAIApi(configuration); +async function GPT35Turbo( + messages: { role: 'user' | 'system' | 'assistant'; content: string }[] +) { + const response = await openai.createChatCompletion({ + model: 'gpt-3.5-turbo', + messages, + }); + + if ( + !response.data.choices[0].message || + response.data.choices[0].message.content === '' + ) { + throw new Error('no message returned'); + } + + return response.data.choices[0].message.content; +} + +function stringOrArrayToArray( + stringOrArray: T +): readonly string[] { + return stringOrArray instanceof Array ? stringOrArray : [stringOrArray]; // eslint-disable-line unicorn/no-instanceof-array -- using Array.isArray() loses type information about the array. +} + +function messageQuestion(texts: readonly string[]) { + return `What are the best emojis to represent the strings: ${texts + .map((t) => `"${t}"`) + .join(', ')}?`; +} + +function messageCount(count?: number | undefined) { + return count + ? `Choose exactly ${count} emoji${ + count === 1 ? '' : 's' + } to represent each string.` + : 'Choose as many emojis as is needed to best represent each string.'; +} + /** - * Generates the emoji(s) that best represent a given string. + * Generates the emoji(s) that best represent the given string(s). * This is non-deterministic. - * @param text the text/concept to generate emoji(s) for + * @param text the text/concept(s) to generate emoji(s) for * @param options an object containing optional parameters * @param options.count the number of emoji(s) to represent the text with. If omitted, the AI will decide how many emojis are needed to best represent the string. - * @returns an array of emojis + * @returns an array of emojis, or an array of arrays of emojis if multiple strings were provided. */ -export async function getEmojis( - text: string, +export async function getEmojis( + text: T, options: { count?: number } = {} -): Promise { - if (typeof text !== 'string' || text.length === 0) { +): Promise { + const textArray = stringOrArrayToArray(text); + if (textArray.some((t) => typeof t !== 'string' || t.length === 0)) { throw new Error('text parameter must be a non-empty string'); } const count = options.count; @@ -26,17 +65,45 @@ export async function getEmojis( throw new Error('optional count parameter must be a positive number'); } - let prompt = `Please return the emoji or emojis that best describe the following text: "${text}".`; - prompt += count - ? ` Return ${count} emoji${count === 1 ? '' : 's'}.` - : ' Return as many emojis as is needed to represent the text.'; - prompt += 'Return only the emoji or emojis.'; - prompt += 'Separate the emojis with CSV formatting.'; + const messageIntro = [ + 'You answer questions about which emoji or emojis best represent the given strings.', + 'You respond in the specified computer-readable format.', + 'Reply with only the emoji or emojis.', + 'The results for each string should be on a separate line in the same order as the input.', + 'Use CSV formatting for each line.', + ].join(' '); - const completion = await openai.createCompletion({ - model: 'text-davinci-003', - prompt, - }); + const result = await GPT35Turbo([ + // Introduce the task. + { + role: 'system', + content: messageIntro, + }, + + // Demonstrate usage with sample. + { + role: 'user', + content: `${messageQuestion([ + 'machine learning', + 'rodeo', + ])} ${messageCount()}`, + }, + { role: 'assistant', content: 'šŸ¤–,šŸ“ˆ,šŸ’»,šŸ§ \nšŸ¤ ,šŸ“,šŸ‚' }, + + // Actual question. + { + role: 'user', + // The system message is repeated because: + // > gpt-3.5-turbo-0301 does not always pay strong attention to system messages. Future models will be trained to pay stronger attention to system messages. + // https://platform.openai.com/docs/guides/chat/introduction + content: `${messageQuestion(textArray)} ${messageCount( + textArray.length + )} ${messageIntro} `, + }, + ]); - return completion.data.choices[0].text?.trim().split(',') || []; + // @ts-expect-error -- TS template variable issue. + return Array.isArray(text) + ? result.split('\n').map((str) => (str === '' ? [] : str.split(','))) + : result.split(','); } diff --git a/package.json b/package.json index beaf4be..4b39ea2 100644 --- a/package.json +++ b/package.json @@ -18,9 +18,9 @@ "license": "ISC", "author": "Bryan Mishkin", "type": "module", - "exports": "./dist/index.js", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", + "exports": "./dist/lib/index.js", + "main": "./dist/lib/index.js", + "types": "./dist/lib/index.d.ts", "files": [ "dist/", "README.md" diff --git a/test/lib/index-test.ts b/test/lib/index-test.ts index 875f17a..8ac6c7f 100644 --- a/test/lib/index-test.ts +++ b/test/lib/index-test.ts @@ -1,8 +1,10 @@ import { getEmojis } from '../../lib/index.js'; -import { OpenAIApi } from 'openai'; +import { OpenAIApi, CreateChatCompletionResponse } from 'openai'; import * as sinon from 'sinon'; -function generateCreateCompletionResponse(text: string | undefined) { +function generateCreateChatCompletionResponse( + text: string +): import('axios').AxiosResponse { return { data: { id: 'abc', @@ -11,7 +13,10 @@ function generateCreateCompletionResponse(text: string | undefined) { model: 'text-davinci-003', choices: [ { - text, + message: { + content: text, + role: 'assistant', + }, }, ], }, @@ -32,8 +37,8 @@ function isEmoji(char: string): boolean { describe('getEmojis', () => { it('behaves correctly with no count specified', async () => { const stub = sinon - .stub(OpenAIApi.prototype, 'createCompletion') - .resolves(generateCreateCompletionResponse('ā—,šŸ˜ƒ,šŸ˜ˆ')); + .stub(OpenAIApi.prototype, 'createChatCompletion') + .resolves(generateCreateChatCompletionResponse('šŸŒø,šŸŽ‰,šŸŽŽ')); const result = await getEmojis('japanese cherry blossom festival'); @@ -44,10 +49,52 @@ describe('getEmojis', () => { stub.restore(); }); + it('behaves correctly with array input of multiple strings', async () => { + const stub = sinon + .stub(OpenAIApi.prototype, 'createChatCompletion') + .resolves( + generateCreateChatCompletionResponse( + ['šŸŒø,šŸŽ‰,šŸŽŽ', 'šŸ®,šŸŽ‘,šŸ„®,šŸŠ,šŸŽ‰,šŸ²'].join('\n') + ) + ); + + const result = await getEmojis([ + 'japanese cherry blossom festival', + 'chinese lantern festival', + ]); + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toStrictEqual(2); + expect(result[0].length).toStrictEqual(3); + expect(result[1].length).toStrictEqual(6); + expect(result.every((str) => str.every((char) => isEmoji(char)))).toBe( + true + ); + + stub.restore(); + }); + + it('behaves correctly with array input but a single string', async () => { + const stub = sinon + .stub(OpenAIApi.prototype, 'createChatCompletion') + .resolves(generateCreateChatCompletionResponse('šŸŒø,šŸŽ‰,šŸŽŽ')); + + const result = await getEmojis(['japanese cherry blossom festival']); + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toStrictEqual(1); + expect(result[0].length).toStrictEqual(3); + expect(result.every((str) => str.every((char) => isEmoji(char)))).toBe( + true + ); + + stub.restore(); + }); + it('behaves correctly with count = 1', async () => { const stub = sinon - .stub(OpenAIApi.prototype, 'createCompletion') - .resolves(generateCreateCompletionResponse('ā—')); + .stub(OpenAIApi.prototype, 'createChatCompletion') + .resolves(generateCreateChatCompletionResponse('šŸŒø')); const result = await getEmojis('japanese cherry blossom festival', { count: 1, @@ -62,8 +109,8 @@ describe('getEmojis', () => { it('behaves correctly with count = 2', async () => { const stub = sinon - .stub(OpenAIApi.prototype, 'createCompletion') - .resolves(generateCreateCompletionResponse('ā—,šŸ˜ˆ')); + .stub(OpenAIApi.prototype, 'createChatCompletion') + .resolves(generateCreateChatCompletionResponse('šŸŒø,šŸŽ‰')); const result = await getEmojis('japanese cherry blossom festival', { count: 2, @@ -76,15 +123,34 @@ describe('getEmojis', () => { stub.restore(); }); - it('returns empty array when response was empty', async () => { + it('returns empty array when response was empty with multiple strings', async () => { const stub = sinon - .stub(OpenAIApi.prototype, 'createCompletion') - .resolves(generateCreateCompletionResponse(undefined)); + .stub(OpenAIApi.prototype, 'createChatCompletion') + .resolves( + generateCreateChatCompletionResponse(['šŸŒø,šŸŽ‰,šŸŽŽ', ''].join('\n')) + ); - const result = await getEmojis('japanese cherry blossom festival'); + const result = await getEmojis([ + 'japanese cherry blossom festival', + 'something with no results', + ]); expect(Array.isArray(result)).toBe(true); - expect(result.length).toStrictEqual(0); + expect(result.length).toStrictEqual(2); + expect(result[0].length).toStrictEqual(3); + expect(result[1].length).toStrictEqual(0); + + stub.restore(); + }); + + it('throws if response was empty with single input string', async () => { + const stub = sinon + .stub(OpenAIApi.prototype, 'createChatCompletion') + .resolves(generateCreateChatCompletionResponse('')); + + await expect(() => + getEmojis('abc') + ).rejects.toThrowErrorMatchingInlineSnapshot('"no message returned"'); stub.restore(); }); diff --git a/tsconfig.json b/tsconfig.json index f0a7a77..d84c7df 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -33,6 +33,7 @@ ] }, "include": [ + "bin/**/*", "lib/**/*", "test/**/*" ]