Skip to content

Commit

Permalink
feat: support getEmojis() for multiple strings at once, switch to gpt…
Browse files Browse the repository at this point in the history
…-3.5-turbo
  • Loading branch information
bmish committed Mar 19, 2023
1 parent bb40717 commit b70401c
Show file tree
Hide file tree
Showing 6 changed files with 186 additions and 42 deletions.
18 changes: 11 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<!-- omit from toc -->

Expand Down Expand Up @@ -36,30 +36,34 @@ 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.

Note that this function is non-deterministic and could return different emojis each time.

## 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 });

// [ [ '⏰', '☢️' ], [ '🤖', '🧠' ] ]
```
3 changes: 3 additions & 0 deletions bin/dummy.ts
Original file line number Diff line number Diff line change
@@ -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.
102 changes: 84 additions & 18 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,37 +6,103 @@ 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<T extends string | readonly string[]>(
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<T extends string | readonly string[]>(
text: T,
options: { count?: number } = {}
): Promise<string[]> {
if (typeof text !== 'string' || text.length === 0) {
): Promise<readonly T[]> {
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;
if (count && (typeof count !== 'number' || count <= 0)) {
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(',');
}
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
98 changes: 84 additions & 14 deletions test/lib/index-test.ts
Original file line number Diff line number Diff line change
@@ -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<CreateChatCompletionResponse, unknown> {
return {
data: {
id: 'abc',
Expand All @@ -11,7 +13,10 @@ function generateCreateCompletionResponse(text: string | undefined) {
model: 'text-davinci-003',
choices: [
{
text,
message: {
content: text,
role: 'assistant',
},
},
],
},
Expand All @@ -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');

Expand All @@ -44,10 +49,56 @@ 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,
Expand All @@ -62,8 +113,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,
Expand All @@ -76,15 +127,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();
});
Expand Down
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
]
},
"include": [
"bin/**/*",
"lib/**/*",
"test/**/*"
]
Expand Down

0 comments on commit b70401c

Please sign in to comment.