diff --git a/src/main/java/dev/marcospaulo/canalhandia/Ai.java b/src/main/java/dev/marcospaulo/canalhandia/Ai.java index b5b788e..67abae6 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Ai.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Ai.java @@ -1,25 +1,17 @@ package dev.marcospaulo.canalhandia; -import com.google.gson.JsonArray; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.TextDecoration; import org.bukkit.Bukkit; import org.bukkit.entity.Player; -import java.io.IOException; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.time.Duration; import java.time.LocalDate; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.UUID; @@ -38,6 +30,11 @@ import java.util.UUID; *

The API key never lives in config.yml, because config.yml is committed to * git. It comes from the {@code MINIMAX_API_KEY} environment variable, or from * {@code plugins/Canalhandia/minimax.key}, which is gitignored. + * + *

This class is an orchestrator: it assembles context (server facts, + * operator corrections, recipes, conversation memory, a wiki article) into a + * list of {@link MiniMax.Turn}s and delegates the HTTP to {@link MiniMax}. It + * holds no {@code HttpClient} of its own. */ final class Ai { @@ -45,7 +42,10 @@ final class Ai { private static final String KEY_ENV = "MINIMAX_API_KEY"; private final Canalhandia plugin; - private final HttpClient http; + private final MiniMax api; + private final Wiki wiki; + private final Conversations conversations; + private final Corrections corrections; /** Per-player cooldown, so one person cannot spend the whole budget. */ private final Map lastAsk = new HashMap<>(); /** In-flight guard: one question per player at a time. */ @@ -54,11 +54,44 @@ final class Ai { private LocalDate day = LocalDate.now(); private int askedToday; + /** + * The most recent answered question, so {@code /ia corrigir} (Task 11) can + * correct it without the operator retyping anything. + */ + private Answered lastAnswer; + + record Answered(UUID asker, String question, String answer) { + } + + /** Package-private so Task 11's {@code /ia corrigir} can read what to correct. */ + Answered lastAnswer() { + return lastAnswer; + } + + /** Package-private so Task 11 can wire quit-forget. */ + Conversations conversations() { + return conversations; + } + + /** Package-private so Task 11 can wire {@code /ia corrigir}. */ + Corrections corrections() { + return corrections; + } + Ai(Canalhandia plugin) { this.plugin = plugin; - this.http = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(10)) - .build(); + Settings settings = plugin.settings(); + Fetcher fetcher = new HttpFetcher(settings.aiTimeoutSeconds()); + this.api = new MiniMax(fetcher, settings.aiUrl(), plugin.getLogger()::warning); + // 3-arg ctor + logger (carry-forward #1): the 2-arg ctor is silent in + // production, so every wiki failure here is logged. + this.wiki = new Wiki(fetcher, settings.aiWikiChars(), plugin.getLogger()::warning); + this.conversations = new Conversations(settings.aiMemoryExchanges(), settings.aiMemoryMinutes()); + this.corrections = new Corrections(new java.io.File(plugin.getDataFolder(), "correcoes.yml")); + // Note: aiUrl(), aiWikiChars(), aiMemoryExchanges() and aiMemoryMinutes() + // are baked here at construction. Only aiProfile() is read live in the + // async body, which is correct — operators switch profiles live without + // a restart, and the other settings are not meant to be hot-swapped. } /** True if a key is configured. Without one the module stays quiet. */ @@ -125,7 +158,7 @@ final class Ai { if (Files.isReadable(file)) { return cleanKey(Files.readString(file, StandardCharsets.UTF_8)); } - } catch (IOException e) { + } catch (java.io.IOException e) { plugin.getLogger().warning("Não consegui ler " + KEY_FILE + ": " + e.getMessage()); } return null; @@ -138,6 +171,17 @@ final class Ai { * reply is posted back on it. */ void ask(Player asker, String question) { + ask(asker, question, false); + } + + /** + * Asks the model on behalf of a player and delivers the answer to chat. + * + * @param isPrivate true for {@code /iap} (Task 11): the question and answer + * go only to the asker even when the module is public. A private + * question never broadcasts the question either. + */ + void ask(Player asker, String question, boolean isPrivate) { Settings settings = plugin.settings(); String key = apiKey(); @@ -169,7 +213,7 @@ final class Ai { pending.put(asker.getUniqueId(), true); askedToday++; - if (settings.aiPublic()) { + if (!isPrivate && settings.aiPublic()) { Bukkit.broadcast(Msg.tag("IA", NamedTextColor.LIGHT_PURPLE) .append(Component.text(asker.getName() + " perguntou: ", NamedTextColor.GRAY) .decoration(TextDecoration.BOLD, false)) @@ -180,27 +224,91 @@ final class Ai { String prompt = question; UUID id = asker.getUniqueId(); + final boolean isPriv = isPrivate; Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { - String answer; + String answer = null; try { - answer = call(key, prompt, settings); + java.util.List messages = compose(asker, prompt, settings); + + if (settings.aiProfile() == AiProfile.PRECISO) { + String term = api.searchTerm(key, settings.aiModel(), prompt); + Wiki.Article article = term == null ? null : wiki.lookup(term); + if (article != null) { + messages.add(messages.size() - 1, new MiniMax.Turn("system", + "Artigo da Minecraft Wiki pt-BR — '" + article.title() + "':\n" + + article.text())); + } + } + + answer = api.answer(key, settings.aiModel(), messages, + settings.aiMaxTokens(), settings.aiTemperature()); + + // Hidden reasoning can swallow the budget, and the model + // occasionally drops a foreign word mid-sentence. Both are + // worth one retry before giving up (carry-forwards #3 and #7). + if (answer == null || AiText.hasForeignScript(answer)) { + answer = api.answer(key, settings.aiModel(), messages, + settings.aiMaxTokens() * 2, 0.1); + } + if (answer != null && AiText.hasForeignScript(answer)) { + plugin.getLogger().warning("Resposta descartada por idioma estrangeiro."); + answer = null; + } } catch (Exception e) { - // Redacted: this catches everything call() can throw, including - // the JDK's header validation, whose message quotes the whole - // Authorization value back. cleanKey should make that - // impossible; this is the backstop if it ever does not. + // Redacts the key: the JDK's header validator quotes the whole + // Authorization value back into the exception message, so a key + // with a stray newline would otherwise reach the log verbatim. + // cleanKey makes that impossible; this is the backstop. warnWithout(key, "Falha na chamada à IA: " + e); answer = null; } String finalAnswer = answer; Bukkit.getScheduler().runTask(plugin, () -> { pending.remove(id); - deliver(id, finalAnswer, settings); + deliver(id, prompt, finalAnswer, settings, isPriv); }); }); } - private void deliver(UUID askerId, String answer, Settings settings) { + /** + * Builds the messages for one question. + * + *

Order matters: system instructions, server context, operator + * corrections, recipes (which the wiki cannot supply — {@code explaintext} + * drops tables), the conversation history, then the question itself. + */ + private java.util.List compose(Player asker, String question, Settings settings) { + java.util.List messages = new java.util.ArrayList<>(); + messages.add(new MiniMax.Turn("system", settings.aiInstructions())); + + String serverContext = settings.aiServerContext(); + if (!serverContext.isBlank()) { + messages.add(new MiniMax.Turn("system", "Sobre este servidor: " + serverContext)); + } + + for (Corrections.Entry entry : Corrections.matching(corrections.all(), question)) { + messages.add(new MiniMax.Turn("system", + "Correção registrada por um operador. Pergunta parecida: \"" + + entry.question() + "\" Resposta correta: " + entry.answer())); + } + + // Recipes never appear in wiki text: explaintext drops tables. The wiki + // is consulted here only for the englishTitle translation; the recipe + // data comes from the Bukkit snapshot taken at enable (RecipeBook.preload). + if (RecipeBook.isRecipeQuestion(question)) { + String recipes = RecipeBook.describe(question, wiki); + if (recipes != null) { + messages.add(new MiniMax.Turn("system", recipes)); + } + } + + messages.addAll(conversations.history(asker.getUniqueId())); + messages.add(new MiniMax.Turn("user", question)); + return messages; + } + + private void deliver(UUID askerId, String question, String answer, + Settings settings, boolean isPrivate) { Player asker = Bukkit.getPlayer(askerId); if (answer == null || answer.isBlank()) { if (asker != null) { @@ -208,90 +316,21 @@ final class Ai { } return; } + String clean = AiText.sanitise(answer, settings.aiMaxAnswer()); + conversations.remember(askerId, question, clean); + lastAnswer = new Answered(askerId, question, clean); + Component message = Msg.tag("IA", NamedTextColor.LIGHT_PURPLE) - .append(Component.text(AiText.sanitise(answer, settings.aiMaxAnswer()), NamedTextColor.WHITE) + .append(Component.text(clean, NamedTextColor.WHITE) .decoration(TextDecoration.BOLD, false)); - if (settings.aiPublic()) { - Bukkit.broadcast(message); - } else if (asker != null) { - asker.sendMessage(message); - } - } - - // --- HTTP --------------------------------------------------------------- - - private String call(String key, String question, Settings settings) throws Exception { - JsonArray messages = new JsonArray(); - messages.add(message("system", settings.aiInstructions())); - messages.add(message("user", question)); - - JsonObject body = new JsonObject(); - body.addProperty("model", settings.aiModel()); - body.add("messages", messages); - body.addProperty("max_tokens", settings.aiMaxTokens()); - body.addProperty("temperature", settings.aiTemperature()); - // No "tools" and no "tool_choice": the model is given nothing it could call. - - // Rejects a malformed key before the JDK's own validator can quote it - // back into an exception message. Task 10 retires this whole method in - // favour of MiniMax, which reaches the same check through HttpFetcher. - HttpFetcher.checkBearer(key); - - HttpRequest request = HttpRequest.newBuilder(URI.create(settings.aiUrl())) - .timeout(Duration.ofSeconds(settings.aiTimeoutSeconds())) - .header("Content-Type", "application/json") - .header("Authorization", "Bearer " + key) - .POST(HttpRequest.BodyPublishers.ofString(body.toString(), StandardCharsets.UTF_8)) - .build(); - - HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString()); - if (response.statusCode() / 100 != 2) { - plugin.getLogger().warning("IA respondeu HTTP " + response.statusCode() + ": " - + AiText.forLog(response.body())); - return null; - } - return extract(response.body()); - } - - private static JsonObject message(String role, String content) { - JsonObject object = new JsonObject(); - object.addProperty("role", role); - object.addProperty("content", content); - return object; - } - - /** - * Pulls the reply text out of an OpenAI-shaped response. - * - *

MiniMax returns HTTP 200 even for application-level failures, putting - * the real outcome in {@code base_resp.status_code}, so that is checked too. - */ - private String extract(String json) { - JsonObject root = JsonParser.parseString(json).getAsJsonObject(); - if (root.has("base_resp")) { - JsonObject base = root.getAsJsonObject("base_resp"); - int status = base.has("status_code") ? base.get("status_code").getAsInt() : 0; - if (status != 0) { - plugin.getLogger().warning("IA recusou: base_resp " + base); - return null; + if (isPrivate || !settings.aiPublic()) { + if (asker != null) { + asker.sendMessage(message); } + return; } - if (!root.has("choices")) { - return null; - } - JsonArray choices = root.getAsJsonArray("choices"); - if (choices.isEmpty()) { - return null; - } - JsonObject first = choices.get(0).getAsJsonObject(); - if (!first.has("message")) { - return null; - } - JsonObject message = first.getAsJsonObject("message"); - // Reasoning models also return "reasoning_content"; only "content" is shown. - return message.has("content") && !message.get("content").isJsonNull() - ? message.get("content").getAsString() - : null; + Bukkit.broadcast(message); + plugin.openAiReactions(askerId); } // --- limits and cleanup ------------------------------------------------- @@ -320,4 +359,4 @@ final class Ai { return askedToday; } -} +} \ No newline at end of file diff --git a/src/main/java/dev/marcospaulo/canalhandia/AiText.java b/src/main/java/dev/marcospaulo/canalhandia/AiText.java index f9ce84a..e286215 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/AiText.java +++ b/src/main/java/dev/marcospaulo/canalhandia/AiText.java @@ -37,8 +37,18 @@ final class AiText { * Makes a reply safe and readable in chat: no colour codes to forge server * messages, no markdown or emoji (chat renders neither, and emoji are empty * boxes on Bedrock), no leading slash that could read as a command. + * + *

{@code null} returns {@code ""}: {@code Ai.deliver} never calls here + * with a null, but hardening is cheap and keeps the contract total. + * + *

Truncation backs off one char when it lands on a high surrogate, so the + * result is never left with an orphan surrogate that would render as a + * replacement box. */ static String sanitise(String raw, int max) { + if (raw == null) { + return ""; + } String text = raw // A colour code is the section sign plus the code character, so both // go. Dropping only the sign would leave the bare letter behind and @@ -46,8 +56,13 @@ final class AiText { .replaceAll("§[0-9A-Za-z]", " ") .replace('§', ' ') .replaceAll("[\\r\\n]+", " ") - .replaceAll("\\*{1,3}([^*]+)\\*{1,3}", "$1") - .replaceAll("`{1,3}([^`]+)`{1,3}", "$1") + // Markdown emphasis/inline-code: only strip the markers when they + // flank non-space text. A lone "*" surrounded by spaces is not + // emphasis (chat uses it as a bullet), so the regex requires a + // non-space right after the opening markers and before the + // closing ones. "{1,3}" keeps it handling *, ** and ***. + .replaceAll("(?s)\\*{1,3}(?!\\s)(.+?)(? max) { - text = text.substring(0, max).trim() + "…"; + int cut = max; + // Back off one char if we sliced a high surrogate off its low half, + // otherwise the String is left with an orphan surrogate that + // renders as a replacement character. + if (cut > 0 && Character.isHighSurrogate(text.charAt(cut - 1))) { + cut--; + } + text = text.substring(0, cut).trim() + "…"; } return text; } diff --git a/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java b/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java index ff6e985..a802a98 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java @@ -62,6 +62,11 @@ public final class Canalhandia extends JavaPlugin implements Listener { offlineStats = new OfflineStats(this); milestones = new Milestones(this); ai = new Ai(this); + // Snapshot the server's recipes on the main thread; RecipeBook.describe + // reads from the async answer path and Bukkit.recipeIterator() is not + // safe off the main thread. Datapack reloads after this are not + // re-snapshotted. + RecipeBook.preload(); optOutKey = new NamespacedKey(this, "opt_out"); CanalhandiaCommand root = new CanalhandiaCommand(this); @@ -299,6 +304,19 @@ public final class Canalhandia extends JavaPlugin implements Listener { return reactions; } + /** + * Opens the reaction row on a public AI answer. Minimal for now: reuses + * the shared {@link #openReactions()} machinery. Task 11 wires the + * asker-gated feedback path and the typed {@code /reagir} twin. + */ + void openAiReactions(UUID askerId) { + // TODO Task 11: asker-gated feedback and the typed /reagir twin use askerId. + if (!settings.reactionsEnabled()) { + return; + } + openReactions(); + } + /** * The newest reaction set still accepting clicks, for typed shortcuts like * {@code /wow} where the player never sees an id. @@ -475,6 +493,17 @@ public final class Canalhandia extends JavaPlugin implements Listener { }, settings.reactionWindowSeconds() * 20L); } + /** + * Drops a player's short-term AI memory on quit, so a rejoin does not + * answer a fresh question with an old one (carry-forward #6). + */ + @EventHandler + public void onQuit(org.bukkit.event.player.PlayerQuitEvent event) { + if (ai != null) { + ai.conversations().forget(event.getPlayer().getUniqueId()); + } + } + // --- per-player opt out ------------------------------------------------- boolean isOptedOut(Player player) { diff --git a/src/main/java/dev/marcospaulo/canalhandia/RecipeBook.java b/src/main/java/dev/marcospaulo/canalhandia/RecipeBook.java index d1ae94e..b7563d4 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/RecipeBook.java +++ b/src/main/java/dev/marcospaulo/canalhandia/RecipeBook.java @@ -356,10 +356,30 @@ final class RecipeBook { return craftingFor(material); } - /** Reads the server's registry. Requires a running server. */ + /** + * Snapshot of the server's recipes, taken once on the main thread at + * enable. {@link Bukkit#recipeIterator()} is documented main-thread-only + * and has been observed misbehaving off it, so {@link #describe} iterates + * this copy instead. Datapack reloads after enable are not re-snapshotted. + */ + private static volatile List recipes = List.of(); + + /** + * Snapshot the server's recipes on the main thread (at enable). + */ + static void preload() { + List snapshot = new ArrayList<>(); + Iterator it = Bukkit.recipeIterator(); + while (it.hasNext()) { + snapshot.add(it.next()); + } + recipes = List.copyOf(snapshot); + } + + /** Reads the snapshot taken at enable. */ private static String craftingFor(Material material) { List lines = new ArrayList<>(); - Iterator it = Bukkit.recipeIterator(); + Iterator it = recipes.iterator(); while (it.hasNext() && lines.size() < MAX_RECIPES) { Recipe recipe = it.next(); if (recipe.getResult().getType() != material) { diff --git a/src/test/java/dev/marcospaulo/canalhandia/AiTextTest.java b/src/test/java/dev/marcospaulo/canalhandia/AiTextTest.java index a6298de..280b561 100644 --- a/src/test/java/dev/marcospaulo/canalhandia/AiTextTest.java +++ b/src/test/java/dev/marcospaulo/canalhandia/AiTextTest.java @@ -44,4 +44,30 @@ class AiTextTest { void plainPortugueseIsNotForeign() { assertFalse(AiText.hasForeignScript("camelos são pacíficos e mansos")); } + + // null must not reach the regex chain — it is total hardening for a method + // whose caller promises non-null but should not explode if it does not. + @Test + void sanitiseReturnsEmptyForNull() { + assertEquals("", AiText.sanitise(null, 500)); + } + + // Truncating a supplementary character in half leaves an orphan surrogate + // that renders as a replacement box. The cut backs off one char. + @Test + void truncationBacksOffAHighSurrogate() { + // 𝄞 (U+1D11E) is two UTF-16 chars; it is not in the emoji strip range, + // so it survives to the truncation step. Cutting at index 4 would slice + // the high surrogate (D834) off its low half (DD1E). + assertEquals("abc…", AiText.sanitise("abc𝄞def", 4)); + } + + // A lone "*" surrounded by spaces is a bullet, not markdown emphasis. + // The strip must only remove markers that flank non-space text. + @Test + void keepsStandaloneAsterisk() { + assertEquals("foo * bar", AiText.sanitise("foo * bar", 500)); + // And real emphasis still strips. + assertEquals("use magma cream", AiText.sanitise("use *magma cream*", 500)); + } }