Skip to content

Commit

Permalink
use @effect/ai (#185)
Browse files Browse the repository at this point in the history
  • Loading branch information
tim-smart authored Sep 24, 2024
1 parent 768dc52 commit b968db8
Show file tree
Hide file tree
Showing 9 changed files with 270 additions and 770 deletions.
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@ COPY package.json ./
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules

CMD ["node", "--enable-source-maps", "dist/main.js"]
CMD ["node", "dist/main.js"]
20 changes: 10 additions & 10 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,28 +20,28 @@
"devDependencies": {
"@effect/language-service": "^0.1.0",
"@octokit/types": "^13.5.0",
"@types/node": "^22.5.5",
"@types/node": "^22.6.1",
"tsx": "^4.19.1",
"typescript": "5.6.2"
},
"dependencies": {
"@effect/experimental": "^0.26.0",
"@effect/opentelemetry": "^0.37.0",
"@effect/platform": "^0.65.0",
"@effect/platform-node": "^0.60.0",
"@effect/schema": "^0.73.0",
"@effect/ai": "https://pkg.pr.new/Effect-TS/effect/@effect/ai@31f882b",
"@effect/ai-openai": "https://pkg.pr.new/Effect-TS/effect/@effect/ai-openai@31f882b",
"@effect/experimental": "https://pkg.pr.new/Effect-TS/effect/@effect/experimental@31f882b",
"@effect/opentelemetry": "^0.37.3",
"@effect/platform": "^0.66.1",
"@effect/platform-node": "^0.61.1",
"@effect/schema": "^0.74.0",
"@opentelemetry/exporter-metrics-otlp-http": "^0.53.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.53.0",
"@opentelemetry/sdk-metrics": "^1.26.0",
"@opentelemetry/sdk-trace-base": "^1.26.0",
"@opentelemetry/sdk-trace-node": "^1.26.0",
"dfx": "^0.102.0",
"dfx": "^0.103.0",
"dotenv": "^16.4.5",
"effect": "^3.8.0",
"gpt-tokenizer": "^2.2.2",
"effect": "^3.8.3",
"html-entities": "^2.5.2",
"octokit": "^4.0.2",
"openai": "^4.61.0",
"prettier": "^3.3.3"
}
}
518 changes: 122 additions & 396 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

78 changes: 78 additions & 0 deletions src/Ai.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { OpenAiCompletions, OpenAiClient } from "@effect/ai-openai"
import { Chunk, Config, Context, Effect, Layer, pipe } from "effect"
import { AiInput, Completions } from "@effect/ai"
import * as Str from "./utils/String.js"
import { NodeHttpClient } from "@effect/platform-node"

export const OpenAiLive = OpenAiClient.layerConfig({
apiKey: Config.redacted("OPENAI_API_KEY"),
organizationId: Config.redacted("OPENAI_ORGANIZATION").pipe(
Config.withDefault(undefined),
),
}).pipe(Layer.provide(NodeHttpClient.layerUndici))

export const CompletionsLive = OpenAiCompletions.layer({
model: "gpt-4o",
}).pipe(Layer.provide(OpenAiLive))

const make = Effect.gen(function* () {
const completions = yield* Completions.Completions

const generateTitle = (prompt: string) =>
completions.create.pipe(
AiInput.provideSystem("Create a short title summarizing the message."),
AiInput.provide(Str.truncateWords(prompt, 500)),
Effect.provideService(OpenAiCompletions.OpenAiConfig, {
temperature: 0.25,
max_tokens: 64,
}),
Effect.map(_ => cleanTitle(_.text)),
Effect.withSpan("Ai.generateTitle", { attributes: { prompt } }),
)

const generateDocs = (
title: string,
messages: AiInput.AiInput.Type,
instruction = "Create a documentation article from the above chat messages. The article should be written in markdown and should contain code examples where appropiate.",
) =>
completions.create.pipe(
Effect.provideService(
AiInput.SystemInstruction,
`You are a helpful assistant for the Effect-TS ecosystem.
The title of this chat is "${title}".`,
),
AiInput.provideEffect(
Chunk.appendAll(messages, AiInput.make(instruction)).pipe(
AiInput.truncate(30_000),
Effect.provideService(Completions.Completions, completions),
),
),
Effect.map(_ => _.text),
)

const generateSummary = (title: string, messages: AiInput.AiInput.Type) =>
generateDocs(
title,
messages,
"Summarize the above messages. Also include some key takeaways.",
)

return {
generateTitle,
generateDocs,
generateSummary,
} as const
})

export class AiHelpers extends Context.Tag("app/AiHelpers")<
AiHelpers,
Effect.Effect.Success<typeof make>
>() {
static Live = Layer.effect(AiHelpers, make).pipe(
Layer.provide(CompletionsLive),
)
}

const cleanTitle = (_: string) =>
pipe(Str.firstParagraph(_), Str.removeQuotes, Str.removePeriod)
13 changes: 7 additions & 6 deletions src/AutoThreads.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Schema } from "@effect/schema"
import { ChannelsCache } from "bot/ChannelsCache"
import { DiscordLive } from "bot/Discord"
import { OpenAI } from "bot/OpenAI"
import * as Str from "bot/utils/String"
import { Discord, DiscordREST, Ix, Perms, UI } from "dfx"
import { DiscordGateway, InteractionsRegistry } from "dfx/gateway"
Expand All @@ -16,6 +15,7 @@ import {
Schedule,
pipe,
} from "effect"
import { AiHelpers } from "./Ai.js"

const retryPolicy = pipe(
Schedule.fixed(Duration.millis(500)),
Expand All @@ -37,7 +37,7 @@ const make = Effect.gen(function* () {
const topicKeyword = yield* Config.string("keyword").pipe(
Config.withDefault("[threads]"),
)
const openai = yield* OpenAI
const ai = yield* AiHelpers
const gateway = yield* DiscordGateway
const rest = yield* DiscordREST
const channels = yield* ChannelsCache
Expand Down Expand Up @@ -70,17 +70,18 @@ const make = Effect.gen(function* () {
.pipe(Effect.flatMap(EligibleChannel)),
}).pipe(
Effect.bind("title", () =>
openai.generateTitle(message.content).pipe(
ai.generateTitle(message.content).pipe(
Effect.tapErrorCause(Effect.log),
Effect.retry({
schedule: retryPolicy,
while: err => err._tag === "OpenAIError",
while: err => err._tag === "AiError",
}),
Effect.withSpan("AutoThreads.generateTitle"),
Effect.orElseSucceed(() =>
pipe(
Option.fromNullable(message.member?.nick),
Option.getOrElse(() => message.author.username),
_ => `${_}'s thread`,
name => `${name}'s thread`,
),
),
),
Expand Down Expand Up @@ -231,6 +232,6 @@ const make = Effect.gen(function* () {

export const AutoThreadsLive = Layer.scopedDiscard(make).pipe(
Layer.provide(ChannelsCache.Live),
Layer.provide(OpenAI.Live),
Layer.provide(AiHelpers.Live),
Layer.provide(DiscordLive),
)
24 changes: 10 additions & 14 deletions src/Issueifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { ChannelsCache } from "bot/ChannelsCache"
import { DiscordLive } from "bot/Discord"
import { Github } from "bot/Github"
import { Messages } from "bot/Messages"
import { Message, OpenAI } from "bot/OpenAI"
import { Discord, DiscordREST, Ix } from "dfx"
import { InteractionsRegistry } from "dfx/gateway"
import {
Expand All @@ -16,6 +15,8 @@ import {
Stream,
pipe,
} from "effect"
import { AiHelpers } from "./Ai.js"
import { AiInput, AiRole } from "@effect/ai"

export class NotInThreadError extends Data.TaggedError(
"NotInThreadError",
Expand All @@ -30,7 +31,7 @@ type GithubRepo = (typeof githubRepos)[number]
const make = Effect.gen(function* () {
const rest = yield* DiscordREST
const channels = yield* ChannelsCache
const openai = yield* OpenAI
const ai = yield* AiHelpers
const messages = yield* Messages
const registry = yield* InteractionsRegistry
const github = yield* Github
Expand All @@ -47,19 +48,14 @@ const make = Effect.gen(function* () {
Effect.map(chunk =>
Chunk.map(
Chunk.reverse(chunk),
(msg): Message => ({
bot: false,
name: msg.author.username,
content: msg.content,
}),
),
),
Effect.flatMap(openAiMessages =>
openai.generateSummary(
channel.name!,
Chunk.toReadonlyArray(openAiMessages),
(msg): AiInput.Message =>
AiInput.Message.fromInput(
msg.content,
AiRole.userWithName(msg.author.username),
),
),
),
Effect.flatMap(messages => ai.generateSummary(channel.name!, messages)),
Effect.flatMap(summary =>
createGithubIssue({
owner: repo.owner,
Expand Down Expand Up @@ -179,6 +175,6 @@ export const IssueifierLive = Layer.scopedDiscard(make).pipe(
Layer.provide(DiscordLive),
Layer.provide(ChannelsCache.Live),
Layer.provide(Messages.Live),
Layer.provide(OpenAI.Live),
Layer.provide(AiHelpers.Live),
Layer.provide(Github.Live),
)
72 changes: 40 additions & 32 deletions src/Mentions.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { AiInput, AiRole, Completions } from "@effect/ai"
import { ChannelsCache } from "bot/ChannelsCache"
import { DiscordLive } from "bot/Discord"
import { Message, OpenAI } from "bot/OpenAI"
import * as Str from "bot/utils/String"
import { Discord, DiscordREST } from "dfx"
import { DiscordGateway } from "dfx/DiscordGateway"
import { Effect, Data, Layer, pipe } from "effect"
import { CompletionsLive } from "./Ai.js"

class NonEligibleMessage extends Data.TaggedError("NonEligibleMessage")<{
readonly reason: "non-mentioned" | "not-in-thread" | "from-bot"
Expand All @@ -14,11 +15,11 @@ const make = Effect.gen(function* () {
const rest = yield* DiscordREST
const gateway = yield* DiscordGateway
const channels = yield* ChannelsCache
const openai = yield* OpenAI
const completions = yield* Completions.Completions

const botUser = yield* rest.getCurrentUser().json

const generateContext = (
const generateAiInput = (
thread: Discord.Channel,
message: Discord.MessageCreateEvent,
) =>
Expand All @@ -29,33 +30,48 @@ const make = Effect.gen(function* () {
.json,
messages: rest.getChannelMessages(message.channel_id, {
before: message.id,
limit: 4,
limit: 10,
}).json,
},
{ concurrency: "unbounded" },
),
Effect.map(({ openingMessage, messages }) =>
[message, ...messages, openingMessage]
.reverse()
.filter(
msg =>
msg.type === Discord.MessageType.DEFAULT ||
msg.type === Discord.MessageType.REPLY,
)
.filter(msg => msg.content.trim().length > 0)
.map(
(msg): Message => ({
content: msg.content,
name:
msg.author.id === botUser.id
? undefined
: `<@${msg.author.id}>`,
bot: msg.author.id === botUser.id,
}),
),
AiInput.make(
[message, ...messages, openingMessage]
.reverse()
.filter(
msg =>
msg.type === Discord.MessageType.DEFAULT ||
msg.type === Discord.MessageType.REPLY,
)
.filter(msg => msg.content.trim().length > 0)
.map(
(msg): AiInput.Message =>
AiInput.Message.fromInput(
msg.content,
msg.author.id === botUser.id
? AiRole.model
: AiRole.userWithName(msg.author.username),
),
),
),
),
)

const generateCompletion = (
thread: Discord.Channel,
message: Discord.MessageCreateEvent,
) =>
completions.create.pipe(
AiInput.provideEffect(generateAiInput(thread, message)),
AiInput.provideSystem(`You are Effect Bot, a funny, helpful assistant for the Effect Discord community.
Please keep replies under 2000 characters.
The title of this conversation is "${thread.name ?? "A thread"}".`),
Effect.map(r => r.text),
)

const run = gateway.handleDispatch("MESSAGE_CREATE", message =>
pipe(
Effect.succeed(message),
Expand All @@ -72,14 +88,7 @@ const make = Effect.gen(function* () {
_ => _.type === Discord.ChannelType.PUBLIC_THREAD,
() => new NonEligibleMessage({ reason: "not-in-thread" }),
),
Effect.flatMap(thread =>
pipe(
generateContext(thread, message),
Effect.flatMap(messages =>
openai.generateReply(thread.name ?? "A thread", messages),
),
),
),
Effect.flatMap(thread => generateCompletion(thread, message)),
Effect.tap(content =>
rest.createMessage(message.channel_id, {
message_reference: {
Expand All @@ -90,7 +99,6 @@ const make = Effect.gen(function* () {
),
Effect.catchTags({
NonEligibleMessage: _ => Effect.void,
NoSuchElementException: _ => Effect.void,
}),
Effect.catchAllCause(Effect.logError),
),
Expand All @@ -101,6 +109,6 @@ const make = Effect.gen(function* () {

export const MentionsLive = Layer.scopedDiscard(make).pipe(
Layer.provide(ChannelsCache.Live),
Layer.provide(OpenAI.Live),
Layer.provide(DiscordLive),
Layer.provide(CompletionsLive),
)
Loading

0 comments on commit b968db8

Please sign in to comment.