generated from nhcarrigan/template
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:
+73
-59
@@ -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
@@ -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}`,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
Reference in New Issue
Block a user