Skip to content
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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,20 @@ If you never set a time, the default of `17:00` is used. (`-t` is shorthand for
Days are case-insensitive and accept `Monday` through `Sunday`. The `on`/`off`
commands accept one or more days at a time.

#### `conversation-style` — choose how reminder DMs sound

Pick the tone the bot uses when it privately reminds you to post your handover.
This setting is per user and only affects reminder DMs.

| Command | Description |
| ------------------------------------- | --------------------------------------------- |
| `@handover conversation-style` | Show your current style and available styles |
| `@handover conversation-style basic` | Use a simple reminder without calling the LLM |
| `@handover conversation-style kiwi` | Use casual Kiwi slang in reminder DMs |

Available styles are `standard`, `basic`, `humorous`, `british`, `kiwi`, and
`unhinged`. Style names are case-insensitive.

#### `history` — review past handovers

Fetch a private copy of your previous handover posts.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "ConversationStyle" AS ENUM ('standard', 'basic', 'humorous', 'british', 'kiwi', 'unhinged');

-- AlterTable
ALTER TABLE "User" ADD COLUMN "conversationStyle" "ConversationStyle" NOT NULL DEFAULT 'standard';
18 changes: 14 additions & 4 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,24 @@ datasource db {
url = env("DATABASE_URL")
}

enum ConversationStyle {
standard
basic
humorous
british
kiwi
unhinged
}

model User {
id String @id
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
name String
timeZone String
dailyReminderTime String?
workdays Int[] @default([1, 2, 3, 4, 5])
workdays Int[] @default([1, 2, 3, 4, 5])
conversationStyle ConversationStyle @default(standard)

posts Post[]
Reminder Reminder[]
Expand Down
79 changes: 79 additions & 0 deletions src/ai.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, expect, test, vi } from 'vitest'
import {
createReminderPrompt,
generateReminder,
getConversationStyleInstruction,
type CreateReminderCompletion,
} from './ai.js'
import type { ConversationStyle } from './conversation-style.js'

describe('generateReminder', () => {
test('basic style returns fixed text without hitting the LLM', async () => {
const createReminderCompletion = vi.fn<CreateReminderCompletion>()

const reminder = await generateReminder(
{
name: 'Sam',
daysSinceLastPost: 1,
conversationStyle: 'basic',
},
{
createReminderCompletion,
},
)

expect(reminder).toBe('What did you work on today?')
expect(createReminderCompletion).not.toHaveBeenCalled()
})

test('standard style preserves the current generated reminder path', async () => {
const createReminderCompletion = vi.fn<CreateReminderCompletion>(
async () => ({
choices: [
{
message: {
content: 'Generated reminder',
},
},
],
}),
)

const reminder = await generateReminder(
{
name: 'Sam',
daysSinceLastPost: 2,
conversationStyle: 'standard',
},
{
createReminderCompletion,
},
)

expect(reminder).toBe('Generated reminder')
expect(createReminderCompletion).toHaveBeenCalledTimes(1)
})
})

describe('createReminderPrompt', () => {
test.each([
['humorous', 'light slapstick-style humor'],
['british', 'old-timey British colloquial tone'],
['kiwi', 'casual New Zealand slang'],
['unhinged', 'chaotic, absurd energy'],
] satisfies Array<[ConversationStyle, string]>)(
'adds the %s style instruction',
(conversationStyle, expectedInstruction) => {
const { userPrompt } = createReminderPrompt({
name: 'Sam',
daysSinceLastPost: 2,
conversationStyle,
})

expect(userPrompt).toContain(expectedInstruction)
expect(getConversationStyleInstruction(conversationStyle)).toContain(
expectedInstruction,
)
},
)
})
148 changes: 135 additions & 13 deletions src/ai.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import OpenAI from 'openai'
import { OPENAI_API_KEY } from './constants.js'

const openai = new OpenAI({
apiKey: OPENAI_API_KEY,
})
import {
defaultConversationStyle,
type ConversationStyle,
} from './conversation-style.js'

const pick = (array: string[]): string => {
if (array.length === 0) {
Expand All @@ -16,12 +15,102 @@ const pick = (array: string[]): string => {
type GenerateReminderOptions = {
name: string
daysSinceLastPost: number
conversationStyle?: ConversationStyle
}

const generateReminder = async (
options: GenerateReminderOptions,
): Promise<string> => {
const { name, daysSinceLastPost } = options
type ReminderMessage = {
role: 'system' | 'user'
content: string
}

type ReminderCompletionRequest = {
model: string
messages: ReminderMessage[]
}

type ReminderCompletionResponse = {
choices?: Array<{
message: {
content?: string
}
}>
}

type CreateReminderCompletion = (
request: ReminderCompletionRequest,
) => Promise<ReminderCompletionResponse>

type GenerateReminderDependencies = {
createReminderCompletion?: CreateReminderCompletion
}

type ReminderPrompt = {
systemPrompt: string
userPrompt: string
}

let openaiClient:
| {
chat: {
completions: {
create: CreateReminderCompletion
}
}
}
| undefined

const createOpenAIReminderCompletion: CreateReminderCompletion = async (
request,
) => {
const { default: OpenAI } = await import('openai')

openaiClient ??= new OpenAI({
apiKey: OPENAI_API_KEY,
}) as {
chat: {
completions: {
create: CreateReminderCompletion
}
}
}

return openaiClient.chat.completions.create(request)
}

const getConversationStyleInstruction = (
conversationStyle: ConversationStyle,
): string | undefined => {
switch (conversationStyle) {
case 'standard': {
return undefined
}

case 'humorous': {
return 'Use light slapstick-style humor, as if the workday briefly tripped over its own shoelaces.'
}

case 'british': {
return 'Use an old-timey British colloquial tone, warm and quaint without being hard to understand.'
}

case 'kiwi': {
return 'Use casual New Zealand slang and phrasing, friendly and relaxed without overdoing it.'
}

case 'unhinged': {
return 'Use chaotic, absurd energy that feels completely bananas, while staying workplace-safe and kind.'
}

case 'basic': {
return undefined
}
}
}

const createReminderPrompt = (
options: Required<GenerateReminderOptions>,
): ReminderPrompt => {
const { name, daysSinceLastPost, conversationStyle } = options

const systemPrompt = `You are a helpful assistant.`

Expand Down Expand Up @@ -60,15 +149,43 @@ const generateReminder = async (
'what they would like to share with the team',
])

const task =
let task =
'Could you please write a very short message (2 sentences max) that I can ask them? Please be informal and casual, but also funny.'

const defaultPrompt = `Hey ${name}, what have you been working today?`
const styleInstruction = getConversationStyleInstruction(conversationStyle)
if (styleInstruction) {
task += ` ${styleInstruction}`
}

const userPrompt = `${concern} ${relation}, ${name}, ${role}. ${action} ${question}. ${task}`

return {
systemPrompt,
userPrompt,
}
}

const generateReminder = async (
options: GenerateReminderOptions,
dependencies: GenerateReminderDependencies = {},
): Promise<string> => {
const conversationStyle =
options.conversationStyle ?? defaultConversationStyle

const defaultPrompt = `Hey ${options.name}, what have you been working today?`
if (conversationStyle === 'basic') {
return 'What did you work on today?'
}

const { systemPrompt, userPrompt } = createReminderPrompt({
...options,
conversationStyle,
})
const createReminderCompletion =
dependencies.createReminderCompletion ?? createOpenAIReminderCompletion

try {
const response = await openai.chat.completions.create({
const response = await createReminderCompletion({
model: 'gpt-3.5-turbo',
messages: [
{
Expand Down Expand Up @@ -98,4 +215,9 @@ const generateReminder = async (
}
}

export { generateReminder }
export {
createReminderPrompt,
generateReminder,
getConversationStyleInstruction,
}
export type { CreateReminderCompletion }
10 changes: 9 additions & 1 deletion src/command/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { createHistoryCmd } from './history/index.js'
import type { CreateCmdFunction } from './_utils/types.js'
import { createHelpHandler } from './_utils/create-help-handler.js'
import { createWorkdaysCmd } from './workdays/index.js'
import { createConversationStyleCmd } from './conversation-style/index.js'

const createHandoverCommand: CreateCmdFunction = (context) => {
const { web, userId } = context
Expand All @@ -13,9 +14,16 @@ const createHandoverCommand: CreateCmdFunction = (context) => {
const remindCmd = createRemindCmd(context)
const historyCmd = createHistoryCmd(context)
const workdaysCmn = createWorkdaysCmd(context)
const conversationStyleCmd = createConversationStyleCmd(context)

const handoverCmd = new CliCommand('handover')
.withSubCommands(formatCmd, remindCmd, historyCmd, workdaysCmn)
.withSubCommands(
formatCmd,
remindCmd,
historyCmd,
workdaysCmn,
conversationStyleCmd,
)
.withHelpHandler(createHelpHandler({ web, userId }))
.withHandler(() => {
handoverCmd.help()
Expand Down
Loading
Loading