Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support getEmojis() with multiple input strings, switch to gpt-3.5-turbo #5

Merged
merged 1 commit into from
Mar 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
103 changes: 85 additions & 18 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,37 +6,104 @@ 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
94 changes: 80 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,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,
Expand All @@ -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,
Expand All @@ -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();
});
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