Skip to content

Commit

Permalink
Merge pull request #9 from ubq-testing/development
Browse files Browse the repository at this point in the history
refactor: proxy callbacks
  • Loading branch information
gentlementlegen authored Oct 30, 2024
2 parents ec32970 + c53734f commit 8ea7b0f
Show file tree
Hide file tree
Showing 16 changed files with 337 additions and 201 deletions.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"knip-ci": "knip --no-exit-code --reporter json --config .github/knip.ts",
"prepare": "husky install",
"test": "jest --setupFiles dotenv/config --coverage",
"worker": "wrangler dev --env dev --port 5000"
"worker": "wrangler dev --env dev --port 4000"
},
"keywords": [
"typescript",
Expand All @@ -32,7 +32,7 @@
"@octokit/webhooks": "13.2.7",
"@sinclair/typebox": "0.32.33",
"@supabase/supabase-js": "^2.45.4",
"@ubiquity-dao/ubiquibot-logger": "^1.3.0",
"@ubiquity-os/ubiquity-os-logger": "^1.3.2",
"dotenv": "^16.4.5",
"github-diff-tool": "^1.0.6",
"gpt-tokenizer": "^2.5.1",
Expand Down
70 changes: 34 additions & 36 deletions src/handlers/ask-llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { recursivelyFetchLinkedIssues } from "../helpers/issue-fetching";
import { formatChatHistory } from "../helpers/format-chat-history";
import { fetchRepoDependencies, fetchRepoLanguageStats } from "./ground-truths/chat-bot";
import { findGroundTruths } from "./ground-truths/find-ground-truths";
import { bubbleUpErrorComment } from "../helpers/errors";

/**
* Asks a question to GPT and returns the response
Expand Down Expand Up @@ -39,43 +40,40 @@ export async function askGpt(context: Context, question: string, formattedChat:
const {
env: { UBIQUITY_OS_APP_NAME },
config: { model, similarityThreshold, maxTokens },
adapters: {
supabase: { comment, issue },
voyage: { reranker },
openai: { completions },
},
logger,
} = context;
let similarComments: CommentSimilaritySearchResult[] = [];
let similarIssues: IssueSimilaritySearchResult[] = [];
try {
similarComments = (await context.adapters.supabase.comment.findSimilarComments(question, 1 - similarityThreshold, "")) || [];
} catch (error) {
context.logger.error(`Error fetching similar comments: ${(error as Error).message}`);
}
try {
similarIssues = (await context.adapters.supabase.issue.findSimilarIssues(question, 1 - similarityThreshold, "")) || [];
} catch (error) {
context.logger.error(`Error fetching similar issues: ${(error as Error).message}`);
}
let similarText = similarComments.map((comment: CommentSimilaritySearchResult) => comment.comment_plaintext);
similarText.push(...similarIssues.map((issue: IssueSimilaritySearchResult) => issue.issue_plaintext));
// Remove Null Results (Private Comments)
similarText = similarText.filter((text) => text !== null);
formattedChat = formattedChat.filter((text) => text !== null);
similarText = similarText.filter((text) => text !== "");
const rerankedText = similarText.length > 0 ? await context.adapters.voyage.reranker.reRankResults(similarText, question) : [];
const languages = await fetchRepoLanguageStats(context);
let dependencies = {};
let devDependencies = {};

try {
const deps = await fetchRepoDependencies(context);
dependencies = deps.dependencies;
devDependencies = deps.devDependencies;
const [similarComments, similarIssues] = await Promise.all([
comment.findSimilarComments(question, 1 - similarityThreshold, ""),
issue.findSimilarIssues(question, 1 - similarityThreshold, "")
]);

const similarText = [
...similarComments?.map((comment: CommentSimilaritySearchResult) => comment.comment_plaintext) || [],
...similarIssues?.map((issue: IssueSimilaritySearchResult) => issue.issue_plaintext) || []
];

formattedChat = formattedChat.filter(text => text);

const rerankedText = similarText.length > 0 ? await reranker.reRankResults(similarText, question) : [];
const [languages, { dependencies, devDependencies }] = await Promise.all([
fetchRepoLanguageStats(context),
fetchRepoDependencies(context)
]);

const groundTruths = await findGroundTruths(context, "chat-bot", { languages, dependencies, devDependencies });

const numTokens = await completions.findTokenLength(question, rerankedText, formattedChat, groundTruths);
logger.info(`Number of tokens: ${numTokens}`);

return completions.createCompletion(question, model, rerankedText, formattedChat, groundTruths, UBIQUITY_OS_APP_NAME, maxTokens);
} catch (error) {
context.logger.error(`Unable to Fetch Dependencies: ${(error as Error).message}`);
throw bubbleUpErrorComment(context, error, false);
}
const groundTruths = await findGroundTruths(context, "chat-bot", {
languages,
dependencies,
devDependencies,
});
//Calculate the current context size in tokens
const numTokens = await context.adapters.openai.completions.findTokenLength(question, rerankedText, formattedChat, groundTruths);
context.logger.info(`Number of tokens: ${numTokens}`);
return context.adapters.openai.completions.createCompletion(question, model, rerankedText, formattedChat, groundTruths, UBIQUITY_OS_APP_NAME, maxTokens);
}
}
79 changes: 79 additions & 0 deletions src/handlers/comment-created-callback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { Context, SupportedEvents } from "../types";
import { addCommentToIssue } from "./add-comment";
import { askQuestion } from "./ask-llm";
import { CallbackResult } from "../types/proxy";
import { bubbleUpErrorComment, sanitizeMetadata } from "../helpers/errors";
import { LogReturn } from "@ubiquity-os/ubiquity-os-logger";

export async function issueCommentCreatedCallback(
context: Context<"issue_comment.created", SupportedEvents["issue_comment.created"]>
): Promise<CallbackResult> {
const {
logger,
env: { UBIQUITY_OS_APP_NAME },
} = context;
const question = context.payload.comment.body.trim();
const slugRegex = new RegExp(`^@${UBIQUITY_OS_APP_NAME}`, "i");

if (!slugRegex.test(question)) {
return { status: 204, reason: logger.info("Comment does not mention the app. Skipping.").logMessage.raw };
}

if (!question.length || question.replace(slugRegex, "").trim().length === 0) {
return { status: 204, reason: logger.info("No question provided. Skipping.").logMessage.raw };
}

if (context.payload.comment.user?.type === "Bot") {
return { status: 204, reason: logger.info("Comment is from a bot. Skipping.").logMessage.raw };
}

logger.info(`Asking question: ${question}`);
let commentToPost;
try {
const response = await askQuestion(context, question);
const { answer, tokenUsage, groundTruths } = response;
if (!answer) {
throw logger.error(`No answer from OpenAI`);
}
logger.info(`Answer: ${answer}`, { tokenUsage });

const metadata = {
groundTruths,
tokenUsage,
};

const metadataString = createStructuredMetadata("LLM Ground Truths and Token Usage", logger.info(`Answer: ${answer}`, { metadata }));
commentToPost = answer + metadataString;
await addCommentToIssue(context, commentToPost);
return { status: 200, reason: logger.info("Comment posted successfully").logMessage.raw };
} catch (error) {
throw await bubbleUpErrorComment(context, error, false);
}
}

function createStructuredMetadata(header: string | undefined, logReturn: LogReturn) {
let logMessage, metadata;
if (logReturn) {
logMessage = logReturn.logMessage;
metadata = logReturn.metadata;
}

const jsonPretty = sanitizeMetadata(metadata);
const stackLine = new Error().stack?.split("\n")[2] ?? "";
const caller = stackLine.match(/at (\S+)/)?.[1] ?? "";
const ubiquityMetadataHeader = `\n\n<!-- Ubiquity - ${header} - ${caller} - ${metadata?.revision}`;

let metadataSerialized: string;
const metadataSerializedVisible = ["```json", jsonPretty, "```"].join("\n");
const metadataSerializedHidden = [ubiquityMetadataHeader, jsonPretty, "-->"].join("\n");

if (logMessage?.type === "fatal") {
// if the log message is fatal, then we want to show the metadata
metadataSerialized = [metadataSerializedVisible, metadataSerializedHidden].join("\n");
} else {
// otherwise we want to hide it
metadataSerialized = metadataSerializedHidden;
}

return metadataSerialized;
}
6 changes: 5 additions & 1 deletion src/handlers/comments.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { logger } from "../helpers/errors";
import { splitKey } from "../helpers/issue";
import { LinkedIssues, SimplifiedComment } from "../types/github-types";
import { StreamlinedComment } from "../types/llm";
Expand Down Expand Up @@ -53,7 +54,10 @@ export function createKey(issueUrl: string, issue?: number) {
}

if (!key) {
throw new Error("Invalid issue url");
throw logger.error("Invalid issue URL", {
issueUrl,
issueNumber: issue,
});
}

if (key.includes("#")) {
Expand Down
55 changes: 32 additions & 23 deletions src/handlers/ground-truths/chat-bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,23 @@ export async function fetchRepoDependencies(context: Context) {
},
} = context;

const { data: packageJson } = await octokit.repos.getContent({
owner,
repo,
path: "package.json",
});
try {
const { data: packageJson } = await octokit.repos.getContent({
owner,
repo,
path: "package.json",
});

if ("content" in packageJson) {
return extractDependencies(JSON.parse(Buffer.from(packageJson.content, "base64").toString()));
} else {
throw logger.error(`No package.json found in ${owner}/${repo}`);
if ("content" in packageJson) {
return extractDependencies(JSON.parse(Buffer.from(packageJson.content, "base64").toString()));
}
} catch (err) {
logger.error(`Error fetching package.json for ${owner}/${repo}`, { err });
}
return {
dependencies: {},
devDependencies: {},
};
}

export function extractDependencies(packageJson: Record<string, Record<string, string>>) {
Expand All @@ -44,21 +50,24 @@ export async function fetchRepoLanguageStats(context: Context) {
},
},
} = context;
try {
const { data: languages } = await octokit.repos.listLanguages({
owner,
repo,
});

const { data: languages } = await octokit.repos.listLanguages({
owner,
repo,
});

const totalBytes = Object.values(languages).reduce((acc, bytes) => acc + bytes, 0);
const totalBytes = Object.values(languages).reduce((acc, bytes) => acc + bytes, 0);

const stats = Object.entries(languages).reduce(
(acc, [language, bytes]) => {
acc[language] = bytes / totalBytes;
return acc;
},
{} as Record<string, number>
);
const stats = Object.entries(languages).reduce(
(acc, [language, bytes]) => {
acc[language] = bytes / totalBytes;
return acc;
},
{} as Record<string, number>
);

return Array.from(Object.entries(stats)).sort((a, b) => b[1] - a[1]);
return Array.from(Object.entries(stats)).sort((a, b) => b[1] - a[1]);
} catch (err) {
throw logger.error(`Error fetching language stats for ${owner}/${repo}`, { err });
}
}
65 changes: 65 additions & 0 deletions src/helpers/callback-proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { issueCommentCreatedCallback } from "../handlers/comment-created-callback";
import { Context, SupportedEventsU } from "../types";
import { ProxyCallbacks } from "../types/proxy";
import { bubbleUpErrorComment } from "./errors";

/**
* The `callbacks` object defines an array of callback functions for each supported event type.
*
* Since multiple callbacks might need to be executed for a single event, we store each
* callback in an array. This design allows for extensibility and flexibility, enabling
* us to add more callbacks for a particular event without modifying the core logic.
*/
const callbacks = {
"issue_comment.created": [issueCommentCreatedCallback],
} as ProxyCallbacks;

/**
* The `proxyCallbacks` function returns a Proxy object that intercepts access to the
* `callbacks` object. This Proxy enables dynamic handling of event callbacks, including:
*
* - **Event Handling:** When an event occurs, the Proxy looks up the corresponding
* callbacks in the `callbacks` object. If no callbacks are found for the event,
* it returns a `skipped` status.
*
* - **Error Handling:** If an error occurs while processing a callback, the Proxy
* logs the error and returns a `failed` status.
*
* The Proxy uses the `get` trap to intercept attempts to access properties on the
* `callbacks` object. This trap allows us to asynchronously execute the appropriate
* callbacks based on the event type, ensuring that the correct context is passed to
* each callback.
*/
export function proxyCallbacks(context: Context): ProxyCallbacks {
return new Proxy(callbacks, {
get(target, prop: SupportedEventsU) {
if (!target[prop]) {
context.logger.info(`No callbacks found for event ${prop}`);
return { status: 204, reason: "skipped" };
}
return (async () => {
try {
return await Promise.all(target[prop].map((callback) => handleCallback(callback, context)));
} catch (er) {
return { status: 500, reason: (await bubbleUpErrorComment(context, er)).logMessage.raw };
}
})();
},
});
}

/**
* Why do we need this wrapper function?
*
* By using a generic `Function` type for the callback parameter, we bypass strict type
* checking temporarily. This allows us to pass a standard `Context` object, which we know
* contains the correct event and payload types, to the callback safely.
*
* We can trust that the `ProxyCallbacks` type has already ensured that each callback function
* matches the expected event and payload types, so this function provides a safe and
* flexible way to handle callbacks without introducing type or logic errors.
*/
// eslint-disable-next-line @typescript-eslint/ban-types
export function handleCallback(callback: Function, context: Context) {
return callback(context);
}
32 changes: 30 additions & 2 deletions src/helpers/errors.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,31 @@
import { Logs } from "@ubiquity-dao/ubiquibot-logger"; // import is fixed in #13

import { LogReturn, Logs } from "@ubiquity-os/ubiquity-os-logger";
import { Context } from "../types";
import { addCommentToIssue } from "../handlers/add-comment";
export const logger = new Logs("debug");

export function handleUncaughtError(error: unknown) {
logger.error("An uncaught error occurred", { err: error });
const status = 500;
return new Response(JSON.stringify({ error }), { status: status, headers: { "content-type": "application/json" } });
}

export function sanitizeMetadata(obj: LogReturn["metadata"]): string {
return JSON.stringify(obj, null, 2).replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/--/g, "&#45;&#45;");
}

export async function bubbleUpErrorComment(context: Context, err: unknown, post = true): Promise<LogReturn> {
let errorMessage;
if (err instanceof LogReturn) {
errorMessage = err;
} else if (err instanceof Error) {
errorMessage = context.logger.error(err.message, { stack: err.stack });
} else {
errorMessage = context.logger.error("An error occurred", { err });
}

if (post) {
await addCommentToIssue(context, `${errorMessage?.logMessage.diff}\n<!--\n${sanitizeMetadata(errorMessage?.metadata)}\n-->`);
}

return errorMessage;
}
Loading

0 comments on commit 8ea7b0f

Please sign in to comment.