diff --git a/application/src/main/java/org/togetherjava/tjbot/features/Features.java b/application/src/main/java/org/togetherjava/tjbot/features/Features.java index 893adbc00f..d184c4618e 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java @@ -73,6 +73,7 @@ import org.togetherjava.tjbot.features.tophelper.TopHelpersCommand; import org.togetherjava.tjbot.features.tophelper.TopHelpersMessageListener; import org.togetherjava.tjbot.features.tophelper.TopHelpersPurgeMessagesRoutine; +import org.togetherjava.tjbot.features.tophelper.ai.AITopHelperCommand; import java.util.ArrayList; import java.util.Collection; @@ -192,6 +193,7 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new BookmarksCommand(bookmarksSystem)); features.add(new ChatGptCommand(chatGptService, helpSystemHelper)); features.add(new JShellCommand(jshellEval)); + features.add(new AITopHelperCommand(chatGptService)); FeatureBlacklist> blacklist = blacklistConfig.normal(); return blacklist.filterStream(features.stream(), Object::getClass).toList(); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptService.java b/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptService.java index e8b02d04bb..0a0559d569 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptService.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptService.java @@ -12,7 +12,6 @@ import java.time.Duration; import java.util.List; -import java.util.Objects; import java.util.Optional; /** @@ -95,32 +94,32 @@ public Optional ask(String question, String context) { if (isDisabled) { return Optional.empty(); } + String instructions = "KEEP IT CONCISE, NOT MORE THAN 280 WORDS"; + String questionWithContext = "context: Category %s on a Java Q&A discord server. %s %s" + .formatted(context, instructions, question); + return ask(questionWithContext); + } + /** + * Prompt ChatGPT with a question and receive a response. + * + * @param question The question being asked of ChatGPT. Max is {@value MAX_TOKENS} tokens. + * @return response from ChatGPT as a String. + * @see ChatGPT + * Tokens. + */ + public Optional ask(String question) { try { - String instructions = "KEEP IT CONCISE, NOT MORE THAN 280 WORDS"; - String questionWithContext = "context: Category %s on a Java Q&A discord server. %s %s" - .formatted(context, instructions, question); - ChatMessage chatMessage = new ChatMessage(ChatMessageRole.USER.value(), - Objects.requireNonNull(questionWithContext)); - ChatCompletionRequest chatCompletionRequest = ChatCompletionRequest.builder() - .model(AI_MODEL) - .messages(List.of(chatMessage)) - .frequencyPenalty(FREQUENCY_PENALTY) - .temperature(TEMPERATURE) - .maxTokens(MAX_TOKENS) - .n(MAX_NUMBER_OF_RESPONSES) - .build(); - + ChatCompletionRequest chatCompletionRequest = + chatCompletionRequest(new ChatMessage(ChatMessageRole.USER.value(), question)); String response = openAiService.createChatCompletion(chatCompletionRequest) .getChoices() .getFirst() .getMessage() .getContent(); - if (response == null) { return Optional.empty(); } - return Optional.of(response); } catch (OpenAiHttpException openAiHttpException) { logger.warn( @@ -133,4 +132,15 @@ public Optional ask(String question, String context) { } return Optional.empty(); } + + private static ChatCompletionRequest chatCompletionRequest(ChatMessage chatMessage) { + return ChatCompletionRequest.builder() + .model(AI_MODEL) + .messages(List.of(chatMessage)) + .frequencyPenalty(FREQUENCY_PENALTY) + .temperature(TEMPERATURE) + .maxTokens(MAX_TOKENS) + .n(MAX_NUMBER_OF_RESPONSES) + .build(); + } } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/ai/AITopHelperCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/ai/AITopHelperCommand.java new file mode 100644 index 0000000000..8c876a4183 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/ai/AITopHelperCommand.java @@ -0,0 +1,159 @@ +package org.togetherjava.tjbot.features.tophelper.ai; + +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.MessageHistory; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.entities.channel.concrete.ForumChannel; +import net.dv8tion.jda.api.entities.channel.concrete.ThreadChannel; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.togetherjava.tjbot.features.CommandVisibility; +import org.togetherjava.tjbot.features.SlashCommandAdapter; +import org.togetherjava.tjbot.features.chatgpt.ChatGptService; + +import java.awt.*; +import java.time.OffsetDateTime; +import java.time.YearMonth; +import java.util.*; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; + +public class AITopHelperCommand extends SlashCommandAdapter { + private static final Logger logger = LoggerFactory.getLogger(AITopHelperCommand.class); + private static final String COMMAND_NAME = "top-helper-ai"; + private static final String QUESTIONS_CHANNEL_NAME = "questions"; + private static final String CHATGPT_PROMPT = + """ + The following contains user IDs and their message. Using the messages provided by each user, + which user ID was the most helpful/answered the question? If there are no meaningful messages, you must still choose somebody. + ONLY provide the user ID of that person and the reason. Do not reply with anything else. Reply in the format userID|reason + %s + """; + + private final ChatGptService chatGptService; + + public AITopHelperCommand(ChatGptService chatGptService) { + super(COMMAND_NAME, + "Uses AI to determine who the current top helper is from the beginning of the month", + CommandVisibility.GUILD); + this.chatGptService = chatGptService; + } + + @Override + public void onSlashCommand(SlashCommandInteractionEvent event) { + List channels = + event.getJDA().getForumChannelsByName(QUESTIONS_CHANNEL_NAME, true); + + if (channels.isEmpty()) { + event.reply("No forum " + QUESTIONS_CHANNEL_NAME + " found").queue(); + return; + } + + List questions = channels.getFirst().getThreadChannels(); + + if (questions.isEmpty()) { + event.reply("No thread channels found").queue(); + return; + } + + event.deferReply().queue(); + determineTopHelper(event, questions); + } + + private void determineTopHelper(SlashCommandInteractionEvent event, + List questions) { + List potentialTopHelpers = new ArrayList<>(); + + List> futures = questions.stream() + .filter(question -> !question.getTimeCreated().isBefore(getFirstDayOfMonth())) + .map(question -> processQuestionAsync(question, potentialTopHelpers)) + .toList(); + + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + + sendCommandResponse(event, potentialTopHelpers); + } + + private CompletableFuture processQuestionAsync(ThreadChannel question, + List potentialTopHelpers) { + return MessageHistory.getHistoryFromBeginning(question) + .submit() + .thenApply(MessageHistory::getRetrievedHistory) + .thenAccept(history -> { + StringBuilder allMessages = new StringBuilder(); + history.forEach(message -> { + User author = message.getAuthor(); + if (author.getIdLong() != question.getOwnerIdLong() && !author.isBot()) { + String content = message.getContentStripped(); + allMessages.append(author.getIdLong()) + .append(": ") + .append(content) + .append("\n\n"); + } + }); + + if (!allMessages.isEmpty()) { + Optional topHelper = + chatGptService.ask(CHATGPT_PROMPT.formatted(allMessages.toString())); + topHelper.ifPresent(potentialTopHelpers::add); + } else { + logger.trace("No messages found"); + } + }); + } + + private void sendCommandResponse(SlashCommandInteractionEvent event, + List potentialTopHelpers) { + Map topHelpers = calculateTopHelpers(potentialTopHelpers); + + String response = topHelpers.entrySet().stream().map(entry -> { + String result = entry.getKey(); + String[] parts = result.split("\\|"); + String userId = parts[0]; + int count = entry.getValue(); + try { + User user = event.getJDA().getUserById(userId); + if (user != null) { + return user.getAsMention() + " " + count; + } + return null; + } catch (NumberFormatException e) { + logger.debug("Invalid user ID encountered: {}", userId); + return null; + } + }).filter(Objects::nonNull).collect(Collectors.joining("\n")); + + EmbedBuilder eb = new EmbedBuilder().setColor(Color.CYAN) + .setTitle("Top Helpers") + .setDescription(response.isEmpty() ? "None at the moment" : response) + .setFooter("The higher the number next to their name, the better the helper they are"); + + event.getHook().editOriginalEmbeds(eb.build()).queue(); + } + + private static Map calculateTopHelpers(List potentialTopHelpers) { + Map frequencyMap = new HashMap<>(); + for (String helper : potentialTopHelpers) { + String userId = helper.split("\\|")[0]; + if (frequencyMap.containsKey(userId)) { + int count = frequencyMap.get(userId) + 1; + frequencyMap.put(userId, count); + } else { + frequencyMap.put(userId, 1); + } + } + return frequencyMap.entrySet() + .stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (e1, e2) -> e1, + LinkedHashMap::new)); + } + + private static OffsetDateTime getFirstDayOfMonth() { + OffsetDateTime now = OffsetDateTime.now(); + return YearMonth.from(now).atDay(1).atStartOfDay().atOffset(now.getOffset()); + } +}