Skip to content

Commit

Permalink
validate code examples for threads (#155)
Browse files Browse the repository at this point in the history
  • Loading branch information
tim-smart authored Apr 19, 2024
1 parent 9a7e2af commit 52b9dc0
Show file tree
Hide file tree
Showing 5 changed files with 159 additions and 23 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"author": "",
"license": "ISC",
"devDependencies": {
"@effect/language-service": "^0.1.0",
"@octokit/types": "^13.4.1",
"@types/node": "^20.12.7",
"tsx": "^4.7.2",
Expand Down
7 changes: 7 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

75 changes: 56 additions & 19 deletions src/AutoThreads.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Schema, TreeFormatter } from "@effect/schema"
import { ChannelsCache } from "bot/ChannelsCache"
import { OpenAI, OpenAIError } from "bot/OpenAI"
import { OpenAI, OpenAIError, OpenAITool } from "bot/OpenAI"
import { LayerUtils } from "bot/_common"
import * as Str from "bot/utils/String"
import { Discord, DiscordREST, Ix, Perms, UI } from "dfx"
Expand All @@ -11,6 +11,7 @@ import {
} from "dfx/gateway"
import {
Cause,
Console,
Context,
Data,
Duration,
Expand All @@ -23,9 +24,6 @@ import {

const retryPolicy = pipe(
Schedule.fixed(Duration.millis(500)),
Schedule.whileInput(
(_: OpenAIError | Cause.NoSuchElementException) => _._tag === "OpenAIError",
),
Schedule.intersect(Schedule.recurs(2)),
)

Expand All @@ -40,6 +38,28 @@ export class PermissionsError extends Data.TaggedError("PermissionsError")<{
readonly subject: string
}> {}

class MessageInfo extends Schema.Class<MessageInfo>("MessageInfo")({
short_title: Schema.String.pipe(
Schema.description("A short title summarizing the message"),
),
has_code_examples: Schema.Boolean.pipe(
Schema.description("Are there code examples in the message?"),
),
has_code_fences: Schema.Boolean.pipe(
Schema.description("Does the message contain code fences, e.g. ```"),
),
}) {
get missingCodeFences() {
return this.has_code_examples && !this.has_code_fences
}
}

const messageInfo = new OpenAITool(
"message_info",
"Extract a short title and validate a message",
MessageInfo,
)

const make = ({ topicKeyword }: { readonly topicKeyword: string }) =>
Effect.gen(function* (_) {
const openai = yield* _(OpenAI)
Expand Down Expand Up @@ -70,33 +90,36 @@ const make = ({ topicKeyword }: { readonly topicKeyword: string }) =>
.get(message.guild_id!, message.channel_id)
.pipe(Effect.flatMap(EligibleChannel)),
}).pipe(
Effect.bind("title", () =>
pipe(
Str.nonEmpty(message.content),
Effect.flatMap(content =>
pipe(
openai.generateTitle(content),
Effect.retry(retryPolicy),
Effect.tapErrorCause(_ => Effect.log(_)),
),
),
Effect.bind("info", () =>
openai.fn(messageInfo, message.content).pipe(
Effect.retry({
schedule: retryPolicy,
while: err => err._tag === "OpenAIError",
}),
Effect.tapErrorCause(_ => Effect.log(_)),
Effect.orElseSucceed(() =>
pipe(
Option.fromNullable(message.member?.nick),
Option.getOrElse(() => message.author.username),
_ => `${_}'s thread`,
_ =>
new MessageInfo({
short_title: `${_}'s thread`,
has_code_examples: false,
has_code_fences: false,
}),
),
),
),
),
Effect.flatMap(
({ channel, title }) =>
Effect.bind(
"thread",
({ channel, info }) =>
rest.startThreadFromMessage(channel.id, message.id, {
name: Str.truncate(title, 100),
name: Str.truncate(info.short_title, 100),
auto_archive_duration: 1440,
}).json,
),
Effect.flatMap(thread =>
Effect.tap(({ thread }) =>
rest.createMessage(thread.id, {
components: UI.grid([
[
Expand All @@ -113,6 +136,20 @@ const make = ({ topicKeyword }: { readonly topicKeyword: string }) =>
]),
}),
),
Effect.tap(({ thread, info }) =>
rest
.createMessage(thread.id, {
content: `It looks like your code examples might be missing code fences.
You can wrap your code like this:
${"\\`\\`\\`"}ts
const a = 123
${"\\`\\`\\`"}
`,
})
.pipe(Effect.when(() => info.missingCodeFences)),
),
Effect.withSpan("AutoThreads.handleMessages"),
Effect.catchTags({
ParseError: error =>
Expand Down
96 changes: 93 additions & 3 deletions src/OpenAI.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import { JSONSchema, Schema } from "@effect/schema"
import { LayerUtils } from "bot/_common"
import * as Str from "bot/utils/String"
import {
Secret,
Context,
Data,
Effect,
Layer,
Metric,
Option,
pipe,
Predicate,
Metric,
Secret,
pipe,
} from "effect"
import * as Tokenizer from "gpt-tokenizer"
import * as OAI from "openai"
Expand Down Expand Up @@ -41,6 +42,68 @@ export interface Message {
readonly content: string
}

export interface ChoiceToolCall<A>
extends Schema.Struct<{
message: Schema.Struct<{
tool_calls: Schema.NonEmptyArray<
Schema.Struct<{
function: Schema.Struct<{
name: Schema.Literal<[string]>
arguments: Schema.Schema<A, string, never>
}>
}>
>
}>
}> {}

const ChoiceToolCall = <A, I>(
name: string,
schema: Schema.Schema<A, I>,
): ChoiceToolCall<A> =>
Schema.Struct({
message: Schema.Struct({
tool_calls: Schema.NonEmptyArray(
Schema.Struct({
function: Schema.Struct({
name: Schema.Literal(name),
arguments: Schema.parseJson(schema),
}),
}),
),
}),
})

export class OpenAITool<A> {
constructor(
readonly name: string,
readonly description: string,
readonly schema: Schema.Schema<A, any> & {
readonly fields: Record<string, Schema.Schema<any, any>>
},
) {
this.jsonSchema = JSONSchema.make(Schema.Struct(schema.fields)) as any
this.choiceSchema = ChoiceToolCall(name, schema)
}

readonly jsonSchema: Record<string, unknown>
readonly choiceSchema: ChoiceToolCall<A>

decodeChoice(value: OAI.OpenAI.ChatCompletion.Choice | undefined) {
return Schema.decodeUnknown(this.choiceSchema)(value)
}

get tool(): OAI.OpenAI.ChatCompletionTool {
return {
type: "function",
function: {
name: this.name,
description: this.description,
parameters: this.jsonSchema,
},
}
}
}

const make = (params: {
readonly apiKey: Secret.Secret
readonly organization: Option.Option<Secret.Secret>
Expand All @@ -62,6 +125,32 @@ const make = (params: {
Effect.withSpan("OpenAI.call"),
)

const fn = <A>(tool: OpenAITool<A>, prompt: string) =>
call((_, signal) =>
_.chat.completions.create(
{
model: "gpt-4-turbo-preview",
tools: [tool.tool],
tool_choice: {
type: "function",
function: {
name: tool.name,
},
},
messages: [
{
role: "user",
content: prompt,
},
],
},
{ signal },
),
).pipe(
Effect.andThen(_ => tool.decodeChoice(_.choices[0])),
Effect.map(_ => _.message.tool_calls[0].function.arguments),
)

const generateTitle = (prompt: string) =>
call((_, signal) =>
_.chat.completions.create(
Expand Down Expand Up @@ -185,6 +274,7 @@ The title of this chat is "${title}".`,
return {
client,
call,
fn,
generateTitle,
generateReply,
generateDocs,
Expand Down
3 changes: 2 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
"bot": ["./src/index.js"],
"bot/*": ["./src/*.js"]
},
"tsBuildInfoFile": "./tsconfig.tsbuildinfo"
"tsBuildInfoFile": "./tsconfig.tsbuildinfo",
"plugins": [{ "name": "@effect/langauge-service" }]
},
"include": ["src/**/*"]
}

0 comments on commit 52b9dc0

Please sign in to comment.