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

feat: add assistant experiment #4

Merged
merged 3 commits into from
Apr 16, 2024
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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
OPENAI_API_KEY=REPLACE

# For Experiment 02-assistant
ASSISTANT_ID=REPLACE
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question (non-blocking): This is more of an identifier than a secret, yeah? Should we consider hard-coding a default here, or does it make more sense to leave this blank to allow devs to iterate in parallel without conflicts?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is more of an identifier than a secret, yeah?

Exactly. Just config. I wasn't sure if there is any way it could be abused, and didn't want to check into the repo without being more confident (I would imagine its likely fine and doesn't work without our API key).

or does it make more sense leave this blank to allow devs to iterate in parallel without conflicts?

Yeah, this was also part of the reasoning.

Happy to remove if we would like to keep .env purely for secrets. Not strongly opinionated here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense this way

261 changes: 261 additions & 0 deletions src/02-assistant/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
/*
* This example demonstrates how to use the OpenAI API to create and/or use an assistant that uses functions calls.
*
* The assistant will listen to the user's input, and if it requires a tool, it will call a function to get the data from Artsy's API.
*
* Usage examples:
*
* NOTE: special symbols like $, #, and others may need to be escaped with a backslash (\) in the terminal.
*
* yarn tsx src/02-assistant/index.ts "I want to purchase art with a budget of 5,000. I am especially interested in photography. Please provide some recommendations."
* yarn tsx src/02-assistant/index.ts "I want to purchase art with a budge of 100,000. Make some recomendations base on whats popular."
* yarn tsx src/02-assistant/index.ts "I want to purchase art with a budget of 1,000,000. I like abstract art."
*/

import dotenv from "dotenv"
import OpenAI from "openai"

dotenv.config()
const openai = new OpenAI() // uses `OPENAI_API_KEY` from .env

/*
* Get user input
*/

const input =
process.argv.slice(2).join(" ") ||
"I'm looking to purchase some art. Provide me some options around $50,000 that from trending artists."

/*
* Define the tools that the assistant can use:
*
* 1. get_artists: Get a list of artists on Artsy
* 2. get_curated_artists: Get a list of curated artists on Artsy
*/

const tools: OpenAI.Beta.FunctionTool[] = [
{
type: "function",
function: {
name: "get_artists",
description: `Get a list of artists on Artsy. Artists may be sorted chronologically by creation date, alphabetically by name, or in descending order of a popularity/trending score.`,
parameters: {
type: "object",
properties: {
size: {
type: "integer",
description: "The number of artists to return",
default: 5,
minimum: 1,
maximum: 20,
},
sort: {
type: "string",
description: "The sort order in which to return artists",
default: "SORTABLE_ID_ASC",
enum: [
"CREATED_AT_ASC",
"CREATED_AT_DESC",
"SORTABLE_ID_ASC",
"SORTABLE_ID_DESC",
"TRENDING_DESC",
],
},
},
},
},
},
{
type: "function",
function: {
name: "get_curated_artists",
description: `Get a list of curated artists on Artsy. These are artists whose works have been highlighted by Artsy curators, and may change from week to week.`,
parameters: {
type: "object",
properties: {
size: {
type: "integer",
description: "The number of artists to return",
default: 5,
minimum: 1,
maximum: 20,
},
},
},
},
},
]

async function main() {
/*
* Create an assistant. Use an existing model by setting ASSISTANT_ID in env or create a new one with the tools defined above.
*/

let assistant

if (process.env.ASSISTANT_ID) {
assistant = await openai.beta.assistants.retrieve(
process.env.ASSISTANT_ID as string
)
} else {
assistant = await openai.beta.assistants.create({
name: "Artsy Advisor",
instructions:
"You are an art advisor. Your job is to listen to your client and provide helpful recommendations of artworks and artists that they may like. You consider price range, medium, rarity, and other key attributes a client may want to consider when purchasing art. You ask clarifying questions where necessary to build an accurate profile on your client and provide more accurate recommendations.",
model: "gpt-3.5-turbo",
tools,
})
}

/*
* Create a new thread and send it a message with the user's input.
*/

const thread = await openai.beta.threads.create()

await openai.beta.threads.messages.create(thread.id, {
role: "user",
content: input,
})

/*
* Run the thread using the desired assistant and poll until it reaches a completed state.
* https://platform.openai.com/docs/assistants/how-it-works/runs-and-run-steps
*/

let run = await openai.beta.threads.runs.createAndPoll(thread.id, {
assistant_id: assistant.id,
instructions:
"Respond like you work at artsy.net. Always provide a list of artists and include the link to their profile. Always check artsy before making a recommendation.",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question (non-blocking): Does the "Always check artsy" bit force any kind of live web-browsing behavior? Does the assistant have that capability? (Wasn't clear to me that it did, from the create statement above.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is more an artifact of my testing different instructions and trying to see if I can force it to use one of the tools. There was no noticeable difference in the small number of test prompts, however, and probably can be removed.

})

/*
* If the assistant wants to use a tool to help its response, then call the function (locally defined, further down) to get the result from Artsy's API.
*/

if (
run.status === "requires_action" &&
run.required_action?.type === "submit_tool_outputs"
) {
const name =
run.required_action.submit_tool_outputs.tool_calls?.[0].function.name
const args = JSON.parse(
run.required_action.submit_tool_outputs.tool_calls?.[0].function
.arguments || "null"
)

console.log(`Calling function: ${name} with args: ${JSON.stringify(args)}`)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea 👍🏽


let artists

if (name === "get_artists") {
artists = await get_artists(args)
}

if (name === "get_curated_artists") {
artists = await get_curated_artists(args)
}

if (artists) {
await openai.beta.threads.runs.submitToolOutputs(thread.id, run.id, {
tool_outputs: [
{
tool_call_id:
run.required_action.submit_tool_outputs.tool_calls?.[0].id,
output: JSON.stringify(artists, null, 2),
},
],
})
}
}

/*
* Poll the run until it reaches a completed state and print the messages.
*/

run = await openai.beta.threads.runs.poll(thread.id, run.id)

if (run.status === "completed") {
const messages = await openai.beta.threads.messages.list(run.thread_id)
for (const message of messages.data.reverse()) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question (non-blocking): Why reverse the messages here?

Copy link
Contributor Author

@mc-jones mc-jones Apr 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This block came from their docs, so not 100% but would guess is that by default the messages come in reverse chronological order, which would be confusing when printed out.

if (message.content[0].type === "text") {
console.log(`${message.role} > ${message.content[0].text.value}`)
}
}
} else {
console.log(run.status)
}
}

/*
* Define the get_artists() and get_curated_artists() functions that can be called by the chat completion.
*/

async function get_artists(args: { size: number; sort: string }) {
const query = `query GetArtists($size: Int!, $sort: ArtistSorts) {
artists(size: $size, sort: $sort) {
slug
name
formattedNationalityAndBirthday
counts {
forSaleArtworks
}
}
}`

const variables = {
size: args.size,
sort: args.sort,
}

const response = await metaphysics({ query, variables })
return response
}

async function get_curated_artists(args: { size: number }) {
const query = `query GetCuratedArtists($size: Int!) {
curatedTrendingArtists(first: $size) {
edges {
node {
slug
name
formattedNationalityAndBirthday
counts {
forSaleArtworks
}
}
}
}
}`

const variables = {
size: args.size,
}

const response = await metaphysics({ query, variables })
return response
}

/*
* Define the API helpers the the function calls will make use of
*/

async function metaphysics(args: {
query: string
variables: Record<string, unknown>
}) {
const { query, variables } = args

const url = "https://metaphysics-production.artsy.net/v2"
const headers = {
"Content-Type": "application/json",
}
const body = JSON.stringify({ query, variables })
const options = { method: "POST", headers, body }

const response = await fetch(url, options)
const json = await response.json()
return json
}

main()