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
This commit is contained in:
2026-03-03 11:53:59 -08:00
parent 94247c2a68
commit 5beebeff44
3 changed files with 163 additions and 96 deletions
+73 -59
View File
@@ -6,8 +6,8 @@
import { MessageFlags, type ChatInputCommandInteraction } from "discord.js"; import { MessageFlags, type ChatInputCommandInteraction } from "discord.js";
import { ids } from "../config/ids.js"; import { ids } from "../config/ids.js";
import { anthropic } from "../utils/anthropic.js";
import { logger } from "../utils/logger.js"; import { logger } from "../utils/logger.js";
import { makeAiRequest } from "../utils/makeAiRequest.js";
interface GiteaIssueResponse { interface GiteaIssueResponse {
// eslint-disable-next-line @typescript-eslint/naming-convention -- Gitea API field. // eslint-disable-next-line @typescript-eslint/naming-convention -- Gitea API field.
@@ -15,39 +15,64 @@ interface GiteaIssueResponse {
number: number; number: number;
} }
const issueSystemPrompt = "You are a helpful assistant that creates" interface GiteaIssueOptions {
+ " well-structured Gitea issue bodies in markdown. Include relevant" body: string;
+ " sections like 'Description' and 'Acceptance Criteria'." owner: string;
+ " Be clear and actionable. Return only the body text."; 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 description - Optional additional context for the issue.
* @param title - The subject of the Gitea 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( const generateIssueBody = async(
description: string, description: string,
title: string, title: string,
): Promise<string> => { ): Promise<string> => {
const aiResponse = await anthropic.messages.create({ const result = await makeAiRequest({
// eslint-disable-next-line @typescript-eslint/naming-convention -- Anthropic API field. maxTokens: 1000,
max_tokens: 1000, systemPrompt: issueSystemPrompt,
messages: [ userMessage: `Create a clear, detailed issue body for a software project.\n\nIssue title: ${title}${description === ""
{ ? ""
content: `Create a clear, detailed issue body for a software project.\n\nIssue title: ${title}${description === "" : `\nAdditional context: ${description}`}`,
? ""
: `\nAdditional context: ${description}`}`,
role: "user",
},
],
model: "claude-haiku-4-5-20251001",
system: issueSystemPrompt,
}); });
const [ firstContent ] = aiResponse.content; return result ?? description;
return firstContent?.type === "text" };
? firstContent.text
: 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<GiteaIssueResponse> => {
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 }); await interaction.deferReply({ ephemeral: true });
const augmentedBody = await generateIssueBody(description, title); try {
const augmentedBody = await generateIssueBody(description, title);
const response = await fetch( const data = await postGiteaIssue({
`https://git.nhcarrigan.com/api/v1/repos/${owner}/${repo}/issues`, body: augmentedBody,
{ owner: owner,
body: JSON.stringify({ repo: repo,
body: augmentedBody, title: title,
title: title, });
}), await logger.metric("created_issue", 1, {
headers: { repository: `${owner}/${repo}`,
// eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name. title: title,
"Authorization": `Bearer ${process.env.GITEA_KEY ?? ""}`, });
// eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name. await interaction.editReply({
"Content-Type": "application/json", content: `✅ Issue #${data.number.toString()} created: **${title}**\n${data.html_url}`,
}, });
method: "POST", } catch (error) {
}, if (error instanceof Error) {
); await logger.error("createIssue command", error);
}
if (!response.ok) { const errorMessage = error instanceof Error
const errorText = await response.text(); ? error.message
await interaction.editReply({ : "Unknown error";
content: `❌ Failed to create issue: ${errorText}`, 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}`,
});
}; };
+36 -37
View File
@@ -6,46 +6,36 @@
import { MessageFlags, type ChatInputCommandInteraction } from "discord.js"; import { MessageFlags, type ChatInputCommandInteraction } from "discord.js";
import { ids } from "../config/ids.js"; import { ids } from "../config/ids.js";
import { anthropic } from "../utils/anthropic.js";
import { logger } from "../utils/logger.js"; import { logger } from "../utils/logger.js";
import { makeAiRequest } from "../utils/makeAiRequest.js";
interface LeantimeResponse { interface LeantimeResponse {
error?: { message: string }; error?: { message: string };
result?: number; result?: number;
} }
const taskSystemPrompt = "You are a helpful assistant that creates" const taskSystemPrompt = "Create well-structured task descriptions."
+ " well-structured task descriptions. Be concise and actionable." + " Be concise and actionable."
+ " Return only the description text with no extra formatting or headers."; + " 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 description - Optional additional context for the task.
* @param title - The subject of the Leantime 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( const generateTaskDescription = async(
description: string, description: string,
title: string, title: string,
): Promise<string> => { ): Promise<string> => {
const aiResponse = await anthropic.messages.create({ const result = await makeAiRequest({
// eslint-disable-next-line @typescript-eslint/naming-convention -- Anthropic API field. maxTokens: 500,
max_tokens: 500, systemPrompt: taskSystemPrompt,
messages: [ userMessage: `Create a clear, concise task description for a personal productivity board.\n\nTask title: ${title}${description === ""
{ ? ""
content: `Create a clear, concise task description for a personal productivity board.\n\nTask title: ${title}${description === "" : `\nAdditional context: ${description}`}`,
? ""
: `\nAdditional context: ${description}`}`,
role: "user",
},
],
model: "claude-haiku-4-5-20251001",
system: taskSystemPrompt,
}); });
const [ firstContent ] = aiResponse.content; return result ?? description;
return firstContent?.type === "text"
? firstContent.text
: description;
}; };
/** /**
@@ -110,23 +100,32 @@ export const createTask = async(
await interaction.deferReply({ ephemeral: true }); await interaction.deferReply({ ephemeral: true });
const augmentedDesc = await generateTaskDescription(description, title); try {
const data = await postLeantimeTask(augmentedDesc, priority, title); 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({ 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}`,
});
}; };
+54
View File
@@ -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<string | null> => {
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 };