From 5beebeff4405dec8fab348da5784206df4c71d87 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 3 Mar 2026 11:53:59 -0800 Subject: [PATCH] feat: add makeAiRequest utility with Amari personality prompt - Centralise all Claude API calls through a shared makeAiRequest module - Apply a consistent Amari personality to all AI-generated content - Add try/catch with logger.error to all AI and external API call sites - Extract postGiteaIssue helper in createIssue for better structure --- src/commands/createIssue.ts | 132 ++++++++++++++++++++---------------- src/commands/createTask.ts | 73 ++++++++++---------- src/utils/makeAiRequest.ts | 54 +++++++++++++++ 3 files changed, 163 insertions(+), 96 deletions(-) create mode 100644 src/utils/makeAiRequest.ts diff --git a/src/commands/createIssue.ts b/src/commands/createIssue.ts index 8609ec1..be258ff 100644 --- a/src/commands/createIssue.ts +++ b/src/commands/createIssue.ts @@ -6,8 +6,8 @@ import { MessageFlags, type ChatInputCommandInteraction } from "discord.js"; import { ids } from "../config/ids.js"; -import { anthropic } from "../utils/anthropic.js"; import { logger } from "../utils/logger.js"; +import { makeAiRequest } from "../utils/makeAiRequest.js"; interface GiteaIssueResponse { // eslint-disable-next-line @typescript-eslint/naming-convention -- Gitea API field. @@ -15,39 +15,64 @@ interface GiteaIssueResponse { number: number; } -const issueSystemPrompt = "You are a helpful assistant that creates" - + " well-structured Gitea issue bodies in markdown. Include relevant" - + " sections like 'Description' and 'Acceptance Criteria'." - + " Be clear and actionable. Return only the body text."; +interface GiteaIssueOptions { + body: string; + owner: string; + repo: string; + title: string; +} + +const issueSystemPrompt = "Create well-structured Gitea issue bodies in" + + " markdown. Include relevant sections like 'Description' and" + + " 'Acceptance Criteria'. Be clear and actionable." + + " Return only the body text."; /** - * Generates an AI-augmented Gitea issue body using Claude. + * Generates an AI-augmented Gitea issue body. * @param description - Optional additional context for the issue. * @param title - The subject of the Gitea issue. - * @returns The generated issue body text. + * @returns The generated issue body text, or the original description as fallback. */ const generateIssueBody = async( description: string, title: string, ): Promise => { - const aiResponse = await anthropic.messages.create({ - // eslint-disable-next-line @typescript-eslint/naming-convention -- Anthropic API field. - max_tokens: 1000, - messages: [ - { - content: `Create a clear, detailed issue body for a software project.\n\nIssue title: ${title}${description === "" - ? "" - : `\nAdditional context: ${description}`}`, - role: "user", - }, - ], - model: "claude-haiku-4-5-20251001", - system: issueSystemPrompt, + const result = await makeAiRequest({ + maxTokens: 1000, + systemPrompt: issueSystemPrompt, + userMessage: `Create a clear, detailed issue body for a software project.\n\nIssue title: ${title}${description === "" + ? "" + : `\nAdditional context: ${description}`}`, }); - const [ firstContent ] = aiResponse.content; - return firstContent?.type === "text" - ? firstContent.text - : description; + return result ?? description; +}; + +/** + * Creates a Gitea issue via the API. + * @param options -- The issue fields to submit. + * @returns The created issue data. + */ +const postGiteaIssue = async( + options: GiteaIssueOptions, +): Promise => { + const response = await fetch( + `https://git.nhcarrigan.com/api/v1/repos/${options.owner}/${options.repo}/issues`, + { + body: JSON.stringify({ body: options.body, title: options.title }), + headers: { + // eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name. + "Authorization": `Bearer ${process.env.GITEA_KEY ?? ""}`, + // eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name. + "Content-Type": "application/json", + }, + method: "POST", + }, + ); + if (!response.ok) { + throw new Error(await response.text()); + } + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Required to type Response.json() output. + return await response.json() as GiteaIssueResponse; }; /** @@ -72,41 +97,30 @@ export const createIssue = async( await interaction.deferReply({ ephemeral: true }); - const augmentedBody = await generateIssueBody(description, title); - - const response = await fetch( - `https://git.nhcarrigan.com/api/v1/repos/${owner}/${repo}/issues`, - { - body: JSON.stringify({ - body: augmentedBody, - title: title, - }), - headers: { - // eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name. - "Authorization": `Bearer ${process.env.GITEA_KEY ?? ""}`, - // eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name. - "Content-Type": "application/json", - }, - method: "POST", - }, - ); - - if (!response.ok) { - const errorText = await response.text(); - await interaction.editReply({ - content: `❌ Failed to create issue: ${errorText}`, + try { + const augmentedBody = await generateIssueBody(description, title); + const data = await postGiteaIssue({ + body: augmentedBody, + owner: owner, + repo: repo, + title: title, + }); + await logger.metric("created_issue", 1, { + repository: `${owner}/${repo}`, + title: title, + }); + await interaction.editReply({ + content: `✅ Issue #${data.number.toString()} created: **${title}**\n${data.html_url}`, + }); + } catch (error) { + if (error instanceof Error) { + await logger.error("createIssue command", error); + } + const errorMessage = error instanceof Error + ? error.message + : "Unknown error"; + await interaction.editReply({ + content: `❌ Failed to create issue: ${errorMessage}`, }); - return; } - - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Required to type Response.json() output. - const data = await response.json() as GiteaIssueResponse; - - await logger.metric("created_issue", 1, { - repository: `${owner}/${repo}`, - title: title, - }); - await interaction.editReply({ - content: `✅ Issue #${data.number.toString()} created: **${title}**\n${data.html_url}`, - }); }; diff --git a/src/commands/createTask.ts b/src/commands/createTask.ts index ba5d528..3f5c53e 100644 --- a/src/commands/createTask.ts +++ b/src/commands/createTask.ts @@ -6,46 +6,36 @@ import { MessageFlags, type ChatInputCommandInteraction } from "discord.js"; import { ids } from "../config/ids.js"; -import { anthropic } from "../utils/anthropic.js"; import { logger } from "../utils/logger.js"; +import { makeAiRequest } from "../utils/makeAiRequest.js"; interface LeantimeResponse { error?: { message: string }; result?: number; } -const taskSystemPrompt = "You are a helpful assistant that creates" - + " well-structured task descriptions. Be concise and actionable." +const taskSystemPrompt = "Create well-structured task descriptions." + + " Be concise and actionable." + " Return only the description text with no extra formatting or headers."; /** - * Generates an AI-augmented task description using Claude. + * Generates an AI-augmented task description. * @param description - Optional additional context for the task. * @param title - The subject of the Leantime task. - * @returns The generated task description text. + * @returns The generated task description text, or the original description as fallback. */ const generateTaskDescription = async( description: string, title: string, ): Promise => { - const aiResponse = await anthropic.messages.create({ - // eslint-disable-next-line @typescript-eslint/naming-convention -- Anthropic API field. - max_tokens: 500, - messages: [ - { - content: `Create a clear, concise task description for a personal productivity board.\n\nTask title: ${title}${description === "" - ? "" - : `\nAdditional context: ${description}`}`, - role: "user", - }, - ], - model: "claude-haiku-4-5-20251001", - system: taskSystemPrompt, + const result = await makeAiRequest({ + maxTokens: 500, + systemPrompt: taskSystemPrompt, + userMessage: `Create a clear, concise task description for a personal productivity board.\n\nTask title: ${title}${description === "" + ? "" + : `\nAdditional context: ${description}`}`, }); - const [ firstContent ] = aiResponse.content; - return firstContent?.type === "text" - ? firstContent.text - : description; + return result ?? description; }; /** @@ -110,23 +100,32 @@ export const createTask = async( await interaction.deferReply({ ephemeral: true }); - const augmentedDesc = await generateTaskDescription(description, title); - const data = await postLeantimeTask(augmentedDesc, priority, title); + try { + const augmentedDesc = await generateTaskDescription(description, title); + const data = await postLeantimeTask(augmentedDesc, priority, title); - if (data.error !== undefined) { + if (data.error !== undefined) { + await interaction.editReply({ + content: `❌ Failed to create task: ${data.error.message}`, + }); + return; + } + + const taskId = data.result; + const taskUrl = taskId === undefined + ? "https://board.nhcarrigan.com" + : `https://board.nhcarrigan.com/dashboard/home#/tickets/showTicket/${taskId.toString()}`; + + await logger.metric("created_task", 1, { title }); await interaction.editReply({ - content: `❌ Failed to create task: ${data.error.message}`, + content: `✅ Task created: **${title}**\n${taskUrl}`, + }); + } catch (error) { + if (error instanceof Error) { + await logger.error("createTask command", error); + } + await interaction.editReply({ + content: "❌ An unexpected error occurred while creating the task.", }); - return; } - - const taskId = data.result; - const taskUrl = taskId === undefined - ? "https://board.nhcarrigan.com" - : `https://board.nhcarrigan.com/dashboard/home#/tickets/showTicket/${taskId.toString()}`; - - await logger.metric("created_task", 1, { title }); - await interaction.editReply({ - content: `✅ Task created: **${title}**\n${taskUrl}`, - }); }; diff --git a/src/utils/makeAiRequest.ts b/src/utils/makeAiRequest.ts new file mode 100644 index 0000000..c5b56e3 --- /dev/null +++ b/src/utils/makeAiRequest.ts @@ -0,0 +1,54 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { anthropic } from "./anthropic.js"; +import { logger } from "./logger.js"; + +const amariPersonality = "You are Amari, Naomi Carrigan's personal" + + " assistant bot at NHCarrigan. You are organised, professional, and" + + " precise. You help manage software projects by producing clear," + + " structured, and immediately actionable content."; + +interface AiRequestOptions { + maxTokens: number; + systemPrompt: string; + userMessage: string; +} + +/** + * Makes a request to the Claude API with Amari's personality applied. + * @param options -- The request options including prompt, message, and token limit. + * @returns The generated text, or null if the request fails. + */ +const makeAiRequest = async( + options: AiRequestOptions, +): Promise => { + try { + const response = await anthropic.messages.create({ + // eslint-disable-next-line @typescript-eslint/naming-convention -- Anthropic API field. + max_tokens: options.maxTokens, + messages: [ + { + content: options.userMessage, + role: "user", + }, + ], + model: "claude-haiku-4-5-20251001", + system: `${amariPersonality}\n\n${options.systemPrompt}`, + }); + const [ firstContent ] = response.content; + return firstContent?.type === "text" + ? firstContent.text + : null; + } catch (error) { + if (error instanceof Error) { + await logger.error("makeAiRequest", error); + } + return null; + } +}; + +export { amariPersonality, makeAiRequest };