# /ia Grounding and Feedback Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Stop `/ia` inventing Minecraft mechanics by grounding every answer in the Portuguese Minecraft Wiki and the server's own recipe data, and add reactions, feedback and a correction loop around it. **Architecture:** `Ai` becomes an orchestrator. The pieces it calls — text sanitising, wiki retrieval, MiniMax transport, context assembly, per-player memory, recipe lookup — move into their own classes so each can be unit tested without a running server. Network access hides behind a `Fetcher` interface that tests replace with a stub. **Tech Stack:** Java 25, Paper 26.2 API, Gson (bundled with Paper), `java.net.http.HttpClient`, JUnit 5, Maven in Docker. **Read first:** `docs/plans/2026-08-05-ia-improvements-design.md` — it records what was measured and why each choice was made. **Hard constraint:** build and deploy the jar, but **do NOT restart the Minecraft server**. The new jar stays dormant until the next restart. --- ## Background the executing engineer needs Facts established by measurement. Do not re-litigate them; do not "simplify" them away. 1. **`pt.minecraft.wiki` returns HTTP 403 to a default user agent.** Every request must send an identifying `User-Agent`. This is MediaWiki policy, not a bug. 2. **MiniMax returns HTTP 200 on application-level errors.** The real outcome is in `base_resp.status_code`, where non-zero means failure. 3. **MiniMax models emit hidden reasoning that counts against `max_tokens`.** Too small a budget yields *empty* `content`, not a short answer. This is why answers must use 1200 and why free-text term extraction failed. 4. **`tool_choice` must force the call.** With `auto`, the model skipped the search on exactly the question it had already answered wrong twice. 5. **`explaintext` strips tables**, so wiki text never contains crafting or brewing recipes. Recipes come from `Bukkit.recipeIterator()` instead. 6. **Replies sometimes contain foreign words** mid-sentence (`搭档`, `contiennent`). They must be caught and retried. --- ### Task 1: Add a test harness Nothing in this repo is tested yet. Everything after this task depends on it. **Files:** - Modify: `pom.xml:25-32` - Create: `src/test/java/dev/marcospaulo/canalhandia/SanityTest.java` **Step 1: Add JUnit 5 to the pom** In `pom.xml`, replace the `` block with: ```xml io.papermc.paper paper-api 26.2.build.92-stable provided org.junit.jupiter junit-jupiter 5.11.3 test ``` **Step 2: Write a test that proves the harness runs** ```java package dev.marcospaulo.canalhandia; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; class SanityTest { @Test void harnessRuns() { assertEquals(2, 1 + 1); } } ``` **Step 3: Run it** ```bash docker run --rm -v "$PWD":/work -v "$HOME/.m2":/root/.m2 -w /work \ maven:3.9-eclipse-temurin-25 mvn -B test ``` Expected: `Tests run: 1, Failures: 0`. If surefire cannot find the test, the JUnit dependency is wrong — fix that before continuing. **Step 4: Commit** ```bash git add pom.xml src/test git commit -m "test: add JUnit 5 harness" ``` --- ### Task 2: Move sanitising into its own class and guard foreign scripts `Ai.sanitise` already strips markdown, emoji, `§` and leading slashes. It does **not** catch the foreign-word leakage, and it lives inside a Bukkit-dependent class. **Files:** - Create: `src/main/java/dev/marcospaulo/canalhandia/AiText.java` - Create: `src/test/java/dev/marcospaulo/canalhandia/AiTextTest.java` - Modify: `src/main/java/dev/marcospaulo/canalhandia/Ai.java` — delete `sanitise` and `trim`, call `AiText` instead **Step 1: Write the failing tests** ```java package dev.marcospaulo.canalhandia; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; class AiTextTest { @Test void stripsMarkdownEmphasis() { assertEquals("use magma cream", AiText.sanitise("use **magma cream**", 500)); } @Test void stripsEmojiAndSectionSigns() { assertEquals("boa sorte", AiText.sanitise("boa sorte \uD83D\uDE04 \u00a7c", 500)); } @Test void stripsLeadingSlashesSoRepliesCannotLookLikeCommands() { assertEquals("give me diamonds", AiText.sanitise("//give me diamonds", 500)); } @Test void keepsPortugueseAccents() { assertEquals("poção de resistência ao fogo", AiText.sanitise("poção de resistência ao fogo", 500)); } @Test void truncatesToLimit() { assertEquals("abc…", AiText.sanitise("abcdefg", 3)); } // The model leaked "搭档" and "contiennent" into Portuguese answers. @Test void detectsCjk() { assertTrue(AiText.hasForeignScript("te aceite como搭档")); } @Test void plainPortugueseIsNotForeign() { assertFalse(AiText.hasForeignScript("camelos são pacíficos e mansos")); } } ``` **Step 2: Run and watch it fail** ```bash docker run --rm -v "$PWD":/work -v "$HOME/.m2":/root/.m2 -w /work \ maven:3.9-eclipse-temurin-25 mvn -B test ``` Expected: compilation failure, `cannot find symbol: class AiText`. **Step 3: Write `AiText`** ```java package dev.marcospaulo.canalhandia; import java.util.regex.Pattern; /** * Text guards for model replies. * *

Pure functions, deliberately free of Bukkit, so they can be tested without * a server. */ final class AiText { /** * Scripts that should never appear in a Portuguese answer. The model has * been observed dropping single Chinese words mid-sentence. */ private static final Pattern FOREIGN = Pattern.compile( "[\\p{IsHan}\\p{IsHiragana}\\p{IsKatakana}\\p{IsHangul}\\p{IsCyrillic}\\p{IsArabic}]"); private AiText() { } static boolean hasForeignScript(String text) { return text != null && FOREIGN.matcher(text).find(); } /** * 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. */ static String sanitise(String raw, int max) { String text = raw.replace('§', ' ') .replaceAll("[\\r\\n]+", " ") .replaceAll("\\*{1,3}([^*]+)\\*{1,3}", "$1") .replaceAll("`{1,3}([^`]+)`{1,3}", "$1") .replaceAll("^#{1,6}\\s+", "") .replaceAll("[\\x{1F000}-\\x{1FAFF}\\x{2190}-\\x{2BFF}\\x{FE0F}\\x{20E3}]", "") .replaceAll("\\s{2,}", " ") .trim(); while (text.startsWith("/")) { text = text.substring(1).trim(); } if (text.length() > max) { text = text.substring(0, max).trim() + "…"; } return text; } /** Shortens text for a log line. */ static String forLog(String text) { return text.length() > 300 ? text.substring(0, 300) + "…" : text; } } ``` **Step 4: Run the tests** Expected: `Tests run: 8, Failures: 0`. **Step 5: Point `Ai` at it** In `Ai.java` delete the `sanitise` and `trim` methods and the now-unused `java.util.regex` imports, then replace the two call sites with `AiText.sanitise(...)` and `AiText.forLog(...)`. **Step 6: Build and commit** ```bash docker run --rm -v "$PWD":/work -v "$HOME/.m2":/root/.m2 -w /work \ maven:3.9-eclipse-temurin-25 mvn -B package git add -A git commit -m "refactor: extract AiText and detect foreign-script leakage" ``` --- ### Task 3: A `Fetcher` seam so HTTP can be tested Every later task needs network calls that tests must not make. **Files:** - Create: `src/main/java/dev/marcospaulo/canalhandia/Fetcher.java` - Create: `src/main/java/dev/marcospaulo/canalhandia/HttpFetcher.java` **Step 1: Write the interface** ```java package dev.marcospaulo.canalhandia; import java.io.IOException; /** The one place the plugin talks to the network, so tests can replace it. */ interface Fetcher { /** GET a URL, returning the body. */ String get(String url) throws IOException, InterruptedException; /** POST JSON with a bearer token, returning the body. */ String postJson(String url, String json, String bearer) throws IOException, InterruptedException; } ``` **Step 2: Write the real implementation** ```java package dev.marcospaulo.canalhandia; 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.time.Duration; final class HttpFetcher implements Fetcher { /** * pt.minecraft.wiki answers 403 to a default user agent — MediaWiki policy * requires callers to identify themselves. */ private static final String AGENT = "Canalhandia-Minecraft-Bot/1.0 (https://marcospaulo.dev.br)"; private final HttpClient http = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .build(); private final int timeoutSeconds; HttpFetcher(int timeoutSeconds) { this.timeoutSeconds = timeoutSeconds; } @Override public String get(String url) throws IOException, InterruptedException { HttpRequest request = HttpRequest.newBuilder(URI.create(url)) .timeout(Duration.ofSeconds(timeoutSeconds)) .header("User-Agent", AGENT) .GET() .build(); return body(http.send(request, HttpResponse.BodyHandlers.ofString())); } @Override public String postJson(String url, String json, String bearer) throws IOException, InterruptedException { HttpRequest request = HttpRequest.newBuilder(URI.create(url)) .timeout(Duration.ofSeconds(timeoutSeconds)) .header("Content-Type", "application/json") .header("User-Agent", AGENT) .header("Authorization", "Bearer " + bearer) .POST(HttpRequest.BodyPublishers.ofString(json, StandardCharsets.UTF_8)) .build(); return body(http.send(request, HttpResponse.BodyHandlers.ofString())); } private String body(HttpResponse response) throws IOException { if (response.statusCode() / 100 != 2) { throw new IOException("HTTP " + response.statusCode() + ": " + AiText.forLog(response.body())); } return response.body(); } } ``` **Step 3: Build and commit** ```bash docker run --rm -v "$PWD":/work -v "$HOME/.m2":/root/.m2 -w /work \ maven:3.9-eclipse-temurin-25 mvn -B package git add -A git commit -m "feat: add Fetcher seam with an identifying user agent" ``` --- ### Task 4: Wiki retrieval Search for a term, then pull the **full** article text. Not `exintro` — the lead paragraph alone made the model answer "não tenho certeza" to questions it could otherwise answer. **Files:** - Create: `src/main/java/dev/marcospaulo/canalhandia/Wiki.java` - Create: `src/test/java/dev/marcospaulo/canalhandia/WikiTest.java` **Step 1: Write the failing tests** ```java package dev.marcospaulo.canalhandia; import org.junit.jupiter.api.Test; import java.util.Map; import static org.junit.jupiter.api.Assertions.*; class WikiTest { /** Returns canned bodies keyed by a substring of the requested URL. */ private static Fetcher stub(Map byUrlFragment) { return new Fetcher() { @Override public String get(String url) { for (Map.Entry e : byUrlFragment.entrySet()) { if (url.contains(e.getKey())) { return e.getValue(); } } throw new AssertionError("unexpected url: " + url); } @Override public String postJson(String url, String json, String bearer) { throw new UnsupportedOperationException(); } }; } @Test void findsArticleAndReturnsFullText() { Wiki wiki = new Wiki(stub(Map.of( "list=search", "{\"query\":{\"search\":[{\"title\":\"Camelo\"}]}}", "prop=extracts", "{\"query\":{\"pages\":{\"1\":{\"title\":\"Camelo\"," + "\"extract\":\"Um camelo pode ser equipado com uma sela.\"}}}}")), 7000); Wiki.Article article = wiki.lookup("Camelo"); assertNotNull(article); assertEquals("Camelo", article.title()); assertTrue(article.text().contains("sela")); } @Test void returnsNullWhenNothingMatches() { Wiki wiki = new Wiki(stub(Map.of( "list=search", "{\"query\":{\"search\":[]}}")), 7000); assertNull(wiki.lookup("asdfghjkl")); } @Test void truncatesLongArticles() { String longText = "x".repeat(9000); Wiki wiki = new Wiki(stub(Map.of( "list=search", "{\"query\":{\"search\":[{\"title\":\"T\"}]}}", "prop=extracts", "{\"query\":{\"pages\":{\"1\":{\"title\":\"T\"," + "\"extract\":\"" + longText + "\"}}}}")), 100); assertEquals(100, wiki.lookup("T").text().length()); } @Test void cachesByTitleSoRepeatQuestionsCostOneFetch() { int[] calls = {0}; Fetcher counting = new Fetcher() { @Override public String get(String url) { calls[0]++; return url.contains("list=search") ? "{\"query\":{\"search\":[{\"title\":\"Creeper\"}]}}" : "{\"query\":{\"pages\":{\"1\":{\"title\":\"Creeper\",\"extract\":\"polvora\"}}}}"; } @Override public String postJson(String url, String json, String bearer) { throw new UnsupportedOperationException(); } }; Wiki wiki = new Wiki(counting, 7000); wiki.lookup("Creeper"); int afterFirst = calls[0]; wiki.lookup("Creeper"); assertEquals(afterFirst, calls[0], "second lookup should be served from cache"); } @Test void networkFailureYieldsNullRatherThanThrowing() { Wiki wiki = new Wiki(new Fetcher() { @Override public String get(String url) throws java.io.IOException { throw new java.io.IOException("boom"); } @Override public String postJson(String url, String json, String bearer) { throw new UnsupportedOperationException(); } }, 7000); assertNull(wiki.lookup("Camelo")); } } ``` **Step 2: Run and watch it fail** — `cannot find symbol: class Wiki`. **Step 3: Implement `Wiki`** ```java package dev.marcospaulo.canalhandia; import com.google.gson.JsonArray; import com.google.gson.JsonObject; import com.google.gson.JsonParser; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.LinkedHashMap; import java.util.Map; /** * Reads articles from the Portuguese Minecraft Wiki. * *

Full article text, not {@code exintro}: grounded on lead paragraphs alone * the model answered "não tenho certeza" to questions it could otherwise get * right, because the specifics live further down the page. * *

Note that {@code explaintext} drops tables, so crafting and brewing * recipes never appear here. Those come from {@link RecipeBook} instead. */ final class Wiki { private static final String API = "https://pt.minecraft.wiki/api.php"; record Article(String title, String text) { } private final Fetcher fetcher; private final int maxChars; /** Newest-last, evicted at 200 entries. Lost on restart, which is fine. */ private final Map cache = new LinkedHashMap<>(); Wiki(Fetcher fetcher, int maxChars) { this.fetcher = fetcher; this.maxChars = maxChars; } /** The best article for a search term, or null if there is none. */ Article lookup(String term) { Article cached = cache.get(term.toLowerCase()); if (cached != null) { return cached; } try { String title = search(term); if (title == null) { return null; } String text = extract(title); if (text == null || text.isBlank()) { return null; } Article article = new Article(title, trim(text)); remember(term, article); return article; } catch (Exception e) { // A wiki outage must not break the answer; the caller falls back // to answering without a source and says so. return null; } } private String search(String term) throws Exception { String url = API + "?action=query&list=search&format=json&srlimit=1&srsearch=" + URLEncoder.encode(term, StandardCharsets.UTF_8); JsonObject root = JsonParser.parseString(fetcher.get(url)).getAsJsonObject(); if (!root.has("query")) { return null; } JsonArray hits = root.getAsJsonObject("query").getAsJsonArray("search"); return hits.isEmpty() ? null : hits.get(0).getAsJsonObject().get("title").getAsString(); } private String extract(String title) throws Exception { String url = API + "?action=query&prop=extracts&explaintext=1&format=json&titles=" + URLEncoder.encode(title, StandardCharsets.UTF_8); JsonObject root = JsonParser.parseString(fetcher.get(url)).getAsJsonObject(); if (!root.has("query")) { return null; } JsonObject pages = root.getAsJsonObject("query").getAsJsonObject("pages"); for (String key : pages.keySet()) { JsonObject page = pages.getAsJsonObject(key); if (page.has("extract")) { return page.get("extract").getAsString(); } } return null; } private String trim(String text) { String collapsed = text.replaceAll("\n{2,}", "\n").trim(); return collapsed.length() > maxChars ? collapsed.substring(0, maxChars) : collapsed; } private void remember(String term, Article article) { cache.put(term.toLowerCase(), article); while (cache.size() > 200) { cache.remove(cache.keySet().iterator().next()); } } } ``` **Step 4: Run the tests** — expected `Tests run: 5, Failures: 0` for `WikiTest`. **Step 5: Commit** ```bash git add -A git commit -m "feat: fetch full articles from the Portuguese Minecraft Wiki" ``` --- ### Task 5: Recipes from the running server Wiki text never contains recipes. The server does. **Files:** - Create: `src/main/java/dev/marcospaulo/canalhandia/RecipeBook.java` There is no unit test here: `Bukkit.recipeIterator()` needs a running server. Verify it in Task 13 instead. **Step 1: Implement** ```java package dev.marcospaulo.canalhandia; import org.bukkit.Bukkit; import org.bukkit.Material; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.Recipe; import org.bukkit.inventory.ShapedRecipe; import org.bukkit.inventory.ShapelessRecipe; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import java.util.Locale; /** * Crafting recipes read from the running server. * *

The wiki cannot supply these: MediaWiki's {@code explaintext} strips * tables, and every recipe lives in one. The server's own data is also * authoritative for this exact version, which the wiki may not be. */ final class RecipeBook { private RecipeBook() { } /** True if the question looks like it is asking how to make something. */ static boolean isRecipeQuestion(String question) { String q = question.toLowerCase(Msg.PT_BR); return q.contains("receita") || q.contains("como faz") || q.contains("como faço") || q.contains("como fazer") || q.contains("como criar") || q.contains("como craft") || q.contains("crafta"); } /** * A plain-text description of how to make whatever the question names, or * null if no material in the question has a recipe. */ static String describe(String question) { Material material = guessMaterial(question); if (material == null) { return null; } List lines = new ArrayList<>(); Iterator it = Bukkit.recipeIterator(); while (it.hasNext() && lines.size() < 3) { Recipe recipe = it.next(); if (recipe.getResult().getType() != material) { continue; } if (recipe instanceof ShapedRecipe shaped) { lines.add(describeShaped(shaped)); } else if (recipe instanceof ShapelessRecipe shapeless) { lines.add(describeShapeless(shapeless)); } } return lines.isEmpty() ? null : "Receitas do servidor para " + material.name().toLowerCase(Locale.ROOT) + ":\n" + String.join("\n", lines); } private static String describeShaped(ShapedRecipe recipe) { StringBuilder out = new StringBuilder("Bancada, formato "); for (String row : recipe.getShape()) { out.append('[').append(row).append(']'); } out.append(" onde "); recipe.getIngredientMap().forEach((symbol, stack) -> { if (stack != null) { out.append(symbol).append('=') .append(stack.getType().name().toLowerCase(Locale.ROOT)).append(' '); } }); return out.toString().trim(); } private static String describeShapeless(ShapelessRecipe recipe) { List parts = new ArrayList<>(); for (ItemStack stack : recipe.getIngredientList()) { parts.add(stack.getType().name().toLowerCase(Locale.ROOT)); } return "Sem formato: " + String.join(" + ", parts); } /** Finds a material whose name appears in the question. Longest match wins. */ private static Material guessMaterial(String question) { String q = question.toLowerCase(Locale.ROOT).replace('ç', 'c'); Material best = null; for (Material material : Material.values()) { if (!material.isItem()) { continue; } String name = material.name().toLowerCase(Locale.ROOT).replace('_', ' '); if (name.length() > 3 && q.contains(name) && (best == null || name.length() > best.name().length())) { best = material; } } return best; } } ``` **Step 2: Build and commit** ```bash docker run --rm -v "$PWD":/work -v "$HOME/.m2":/root/.m2 -w /work \ maven:3.9-eclipse-temurin-25 mvn -B package git add -A git commit -m "feat: read crafting recipes from the running server" ``` --- ### Task 6: MiniMax client with a forced tool call **Files:** - Create: `src/main/java/dev/marcospaulo/canalhandia/MiniMax.java` - Create: `src/test/java/dev/marcospaulo/canalhandia/MiniMaxTest.java` **Step 1: Write the failing tests** ```java package dev.marcospaulo.canalhandia; import org.junit.jupiter.api.Test; import java.util.List; import static org.junit.jupiter.api.Assertions.*; class MiniMaxTest { private static Fetcher replying(String body) { return new Fetcher() { @Override public String get(String url) { throw new UnsupportedOperationException(); } @Override public String postJson(String url, String json, String bearer) { return body; } }; } @Test void readsAnswerContent() { MiniMax api = new MiniMax(replying( "{\"choices\":[{\"message\":{\"content\":\"pólvora\"}}]}")); assertEquals("pólvora", api.answer("k", "m", List.of(), 1200, 0.3)); } // MiniMax reports application errors with HTTP 200 and a non-zero base_resp. @Test void treatsNonZeroBaseRespAsFailure() { MiniMax api = new MiniMax(replying( "{\"base_resp\":{\"status_code\":1004,\"status_msg\":\"bad key\"},\"choices\":[]}")); assertNull(api.answer("k", "m", List.of(), 1200, 0.3)); } // Hidden reasoning can consume the whole budget, leaving content empty. @Test void emptyContentIsNullNotBlank() { MiniMax api = new MiniMax(replying( "{\"choices\":[{\"message\":{\"content\":\"\"}}]}")); assertNull(api.answer("k", "m", List.of(), 1200, 0.3)); } @Test void readsForcedToolArgument() { MiniMax api = new MiniMax(replying( "{\"choices\":[{\"message\":{\"tool_calls\":[{\"id\":\"1\",\"function\":" + "{\"name\":\"buscar_wiki\",\"arguments\":\"{\\\"termo\\\":\\\"Camelo\\\"}\"}}]}}]}")); assertEquals("Camelo", api.searchTerm("k", "m", "como pego um camelo?")); } @Test void missingToolCallYieldsNull() { MiniMax api = new MiniMax(replying( "{\"choices\":[{\"message\":{\"content\":\"sei lá\"}}]}")); assertNull(api.searchTerm("k", "m", "oi")); } } ``` **Step 2: Run and watch it fail.** **Step 3: Implement `MiniMax`** ```java package dev.marcospaulo.canalhandia; import com.google.gson.JsonArray; import com.google.gson.JsonObject; import com.google.gson.JsonParser; import java.util.List; /** * MiniMax chat-completions client. * *

Two calls per grounded question: {@link #searchTerm} makes the model name * a wiki article, then {@link #answer} answers with that article in context. */ final class MiniMax { static final String URL = "https://api.minimax.io/v1/text/chatcompletion_v2"; /** One message in the request. */ record Msg(String role, String content) { } private final Fetcher fetcher; MiniMax(Fetcher fetcher) { this.fetcher = fetcher; } /** * Asks the model which wiki article to read. * *

The call is forced with {@code tool_choice} rather than left to * {@code auto}. Given the choice the model skipped the search on exactly * the questions it was most likely to get wrong. A tool argument is also * structured output, so unlike a free-text reply it survives the model's * hidden reasoning eating the token budget. */ String searchTerm(String key, String model, String question) { JsonObject parameters = new JsonObject(); parameters.addProperty("type", "object"); JsonObject termo = new JsonObject(); termo.addProperty("type", "string"); termo.addProperty("description", "Termo curto do jogo, ex: Camelo, Creeper"); JsonObject properties = new JsonObject(); properties.add("termo", termo); parameters.add("properties", properties); JsonArray required = new JsonArray(); required.add("termo"); parameters.add("required", required); JsonObject function = new JsonObject(); function.addProperty("name", "buscar_wiki"); function.addProperty("description", "Busca um artigo na Minecraft Wiki em português."); function.add("parameters", parameters); JsonObject tool = new JsonObject(); tool.addProperty("type", "function"); tool.add("function", function); JsonArray tools = new JsonArray(); tools.add(tool); JsonObject chosenName = new JsonObject(); chosenName.addProperty("name", "buscar_wiki"); JsonObject choice = new JsonObject(); choice.addProperty("type", "function"); choice.add("function", chosenName); JsonObject body = base(model, List.of( new Msg("system", "Você escolhe qual artigo da Minecraft Wiki consultar."), new Msg("user", question)), 500, 0.0); body.add("tools", tools); body.add("tool_choice", choice); JsonObject message = message(post(key, body)); if (message == null || !message.has("tool_calls")) { return null; } JsonArray calls = message.getAsJsonArray("tool_calls"); if (calls.isEmpty()) { return null; } String arguments = calls.get(0).getAsJsonObject() .getAsJsonObject("function").get("arguments").getAsString(); JsonObject parsed = JsonParser.parseString(arguments).getAsJsonObject(); if (!parsed.has("termo")) { return null; } String term = parsed.get("termo").getAsString().trim(); return term.isEmpty() ? null : term; } /** Answers a question. Returns null on any failure, including empty content. */ String answer(String key, String model, List messages, int maxTokens, double temperature) { JsonObject message = message(post(key, base(model, messages, maxTokens, temperature))); if (message == null || !message.has("content") || message.get("content").isJsonNull()) { return null; } String content = message.get("content").getAsString(); return content.isBlank() ? null : content; } private JsonObject base(String model, List messages, int maxTokens, double temperature) { JsonArray array = new JsonArray(); for (Msg msg : messages) { JsonObject object = new JsonObject(); object.addProperty("role", msg.role()); object.addProperty("content", msg.content()); array.add(object); } JsonObject body = new JsonObject(); body.addProperty("model", model); body.add("messages", array); body.addProperty("max_tokens", maxTokens); body.addProperty("temperature", temperature); return body; } private JsonObject post(String key, JsonObject body) { try { return JsonParser.parseString(fetcher.postJson(URL, body.toString(), key)) .getAsJsonObject(); } catch (Exception e) { return null; } } /** The first choice's message, or null if the response reported a failure. */ private JsonObject message(JsonObject root) { if (root == null) { return null; } // HTTP 200 does not mean success here. if (root.has("base_resp")) { JsonObject base = root.getAsJsonObject("base_resp"); if (base.has("status_code") && base.get("status_code").getAsInt() != 0) { return null; } } if (!root.has("choices")) { return null; } JsonArray choices = root.getAsJsonArray("choices"); if (choices.isEmpty()) { return null; } JsonObject first = choices.get(0).getAsJsonObject(); return first.has("message") ? first.getAsJsonObject("message") : null; } } ``` **Step 4: Run the tests** — expected `Tests run: 5, Failures: 0` for `MiniMaxTest`. **Step 5: Commit** ```bash git add -A git commit -m "feat: MiniMax client with forced tool call for wiki term selection" ``` --- ### Task 7: Per-player conversation memory **Files:** - Create: `src/main/java/dev/marcospaulo/canalhandia/Conversations.java` - Create: `src/test/java/dev/marcospaulo/canalhandia/ConversationsTest.java` **Step 1: Write the failing tests** ```java package dev.marcospaulo.canalhandia; import org.junit.jupiter.api.Test; import java.util.UUID; import static org.junit.jupiter.api.Assertions.*; class ConversationsTest { @Test void keepsRecentExchangesForFollowUps() { Conversations c = new Conversations(3, 10); UUID id = UUID.randomUUID(); c.remember(id, "onde acho diamante?", "abaixo do Y 16"); assertEquals(2, c.history(id).size()); assertEquals("onde acho diamante?", c.history(id).get(0).content()); } @Test void dropsOldestBeyondLimit() { Conversations c = new Conversations(2, 10); UUID id = UUID.randomUUID(); c.remember(id, "q1", "a1"); c.remember(id, "q2", "a2"); c.remember(id, "q3", "a3"); assertEquals(4, c.history(id).size()); assertEquals("q2", c.history(id).get(0).content()); } @Test void expiresAfterTheWindow() { Conversations c = new Conversations(3, 0); UUID id = UUID.randomUUID(); c.remember(id, "q", "a"); assertTrue(c.history(id).isEmpty()); } @Test void playersDoNotShareHistory() { Conversations c = new Conversations(3, 10); UUID a = UUID.randomUUID(); UUID b = UUID.randomUUID(); c.remember(a, "q", "resposta de A"); assertTrue(c.history(b).isEmpty()); } } ``` **Step 2: Run and watch it fail.** **Step 3: Implement** ```java package dev.marcospaulo.canalhandia; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Deque; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; /** * Short per-player memory, so a follow-up like "e no nether?" makes sense. * *

Deliberately small and forgetful: the point is continuity within one * exchange, not a transcript. */ final class Conversations { private record Entry(MiniMax.Msg msg, long at) { } private final int maxExchanges; private final long windowMillis; private final Map> byPlayer = new HashMap<>(); Conversations(int maxExchanges, int windowMinutes) { this.maxExchanges = Math.max(0, maxExchanges); this.windowMillis = windowMinutes * 60_000L; } void remember(UUID player, String question, String answer) { Deque entries = byPlayer.computeIfAbsent(player, key -> new ArrayDeque<>()); long now = System.currentTimeMillis(); entries.addLast(new Entry(new MiniMax.Msg("user", question), now)); entries.addLast(new Entry(new MiniMax.Msg("assistant", answer), now)); while (entries.size() > maxExchanges * 2) { entries.removeFirst(); } } /** Recent messages still inside the window, oldest first. */ List history(UUID player) { Deque entries = byPlayer.get(player); List out = new ArrayList<>(); if (entries == null) { return out; } long cutoff = System.currentTimeMillis() - windowMillis; entries.removeIf(entry -> entry.at() < cutoff); for (Entry entry : entries) { out.add(entry.msg()); } return out; } void forget(UUID player) { byPlayer.remove(player); } } ``` **Step 4: Run the tests** — expected `Tests run: 4, Failures: 0`. **Step 5: Commit** ```bash git add -A git commit -m "feat: short per-player conversation memory" ``` --- ### Task 8: Settings, profiles and context files **Files:** - Modify: `src/main/java/dev/marcospaulo/canalhandia/Settings.java` — the `--- IA ---` block - Modify: `src/main/resources/config.yml` — the `ia:` section - Create: `src/main/resources/contexto.yml` - Create: `src/main/java/dev/marcospaulo/canalhandia/AiProfile.java` **Step 1: Add the profile enum** ```java package dev.marcospaulo.canalhandia; /** * How much work to do per question. * *

This trades latency, not money: the plan's token allowance is far beyond * what a small server can spend, but every added context token makes chat feel * slower. */ enum AiProfile { /** Skip the wiki round trip. Fast, ungrounded. */ ECONOMICO, /** Consult the wiki. Slower, accurate. */ PRECISO; static AiProfile byKey(String key) { for (AiProfile profile : values()) { if (profile.name().equalsIgnoreCase(key)) { return profile; } } return PRECISO; } } ``` **Step 2: Extend `Settings`** Add to the `--- IA ---` section, and change `aiMaxTokens`'s default from 300 to 1200 — at 300 the model's hidden reasoning consumed the budget and players got empty answers: ```java AiProfile aiProfile() { return AiProfile.byKey(plugin.getConfig().getString("ia.perfil", "PRECISO")); } void aiProfile(AiProfile profile) { set("ia.perfil", profile.name()); } /** How much article text to send. Lead paragraphs alone were not enough. */ int aiWikiChars() { return Math.max(500, plugin.getConfig().getInt("ia.wiki-caracteres", 7000)); } int aiMemoryExchanges() { return Math.max(0, plugin.getConfig().getInt("ia.memoria-perguntas", 3)); } int aiMemoryMinutes() { return Math.max(1, plugin.getConfig().getInt("ia.memoria-minutos", 10)); } String aiServerContext() { return String.join(" ", plugin.getConfig().getStringList("ia.contexto")); } ``` **Step 3: Extend `config.yml`** Append inside the existing `ia:` block: ```yaml # ECONOMICO pula a consulta à wiki (resposta rápida, sem fonte). # PRECISO consulta a wiki (mais lento, mais correto). Troque em jogo com # /ia perfil . perfil: PRECISO # Quantos caracteres do artigo da wiki enviar. Só a introdução não basta: # a receita e os detalhes ficam mais abaixo na página. wiki-caracteres: 7000 # Memória curta por jogador, para perguntas de seguimento ("e no nether?"). memoria-perguntas: 3 memoria-minutos: 10 # Fatos do servidor que a IA nunca teria como saber. Uma linha por fato. contexto: - "O servidor se chama Canalhandia e roda Minecraft 26.2 (Paper)." - "Jogadores de Bedrock entram pelo Geyser e o nome deles começa com ponto." - "O servidor tem BlueMap, voice chat e Distant Horizons." ``` **Step 4: Build and commit** ```bash docker run --rm -v "$PWD":/work -v "$HOME/.m2":/root/.m2 -w /work \ maven:3.9-eclipse-temurin-25 mvn -B package git add -A git commit -m "feat: IA profiles, server context and a workable token ceiling" ``` --- ### Task 9: Corrections store **Files:** - Create: `src/main/java/dev/marcospaulo/canalhandia/Corrections.java` - Create: `src/test/java/dev/marcospaulo/canalhandia/CorrectionsTest.java` Keep the matching logic pure and testable; file I/O stays in a thin wrapper. **Step 1: Write the failing test** ```java package dev.marcospaulo.canalhandia; import org.junit.jupiter.api.Test; import java.util.List; import static org.junit.jupiter.api.Assertions.*; class CorrectionsTest { @Test void matchesOnSharedSignificantWords() { List all = List.of( new Corrections.Entry("como pegar um camelo", "camelos são mansos, basta pôr uma sela")); assertEquals(1, Corrections.matching(all, "como eu pego camelo?").size()); } @Test void ignoresUnrelatedCorrections() { List all = List.of( new Corrections.Entry("como pegar um camelo", "…")); assertTrue(Corrections.matching(all, "onde acho diamante?").isEmpty()); } @Test void shortWordsDoNotCreateMatches() { List all = List.of( new Corrections.Entry("o que e um creeper", "explode")); assertTrue(Corrections.matching(all, "o que e um zumbi").isEmpty()); } } ``` **Step 2: Run and watch it fail.** **Step 3: Implement** ```java package dev.marcospaulo.canalhandia; import org.bukkit.configuration.file.YamlConfiguration; import java.io.File; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Set; /** * Operator corrections, injected when a new question resembles one that was * answered wrongly before. * *

This is the cheap alternative to fine-tuning: a wrong answer becomes * context, so the same mistake stops recurring. */ final class Corrections { record Entry(String question, String answer) { } private final File file; private final List entries = new ArrayList<>(); Corrections(File file) { this.file = file; load(); } void load() { entries.clear(); if (!file.exists()) { return; } YamlConfiguration yaml = YamlConfiguration.loadConfiguration(file); for (String key : yaml.getKeys(false)) { String question = yaml.getString(key + ".pergunta"); String answer = yaml.getString(key + ".resposta"); if (question != null && answer != null) { entries.add(new Entry(question, answer)); } } } void add(String question, String answer) { entries.add(new Entry(question, answer)); YamlConfiguration yaml = new YamlConfiguration(); for (int i = 0; i < entries.size(); i++) { yaml.set("c" + i + ".pergunta", entries.get(i).question()); yaml.set("c" + i + ".resposta", entries.get(i).answer()); } try { yaml.save(file); } catch (Exception e) { throw new IllegalStateException("não consegui gravar " + file, e); } } List all() { return List.copyOf(entries); } /** Corrections sharing at least two significant words with the question. */ static List matching(List all, String question) { Set asked = significantWords(question); List out = new ArrayList<>(); for (Entry entry : all) { Set known = significantWords(entry.question()); known.retainAll(asked); if (known.size() >= 2) { out.add(entry); } } return out; } private static Set significantWords(String text) { Set words = new HashSet<>(); for (String word : text.toLowerCase(Locale.ROOT).split("[^\\p{L}0-9]+")) { // Short words are almost all articles and prepositions in Portuguese. if (word.length() > 4) { words.add(word); } } return words; } } ``` **Step 4: Run the tests** — expected `Tests run: 3, Failures: 0`. **Step 5: Commit** ```bash git add -A git commit -m "feat: operator corrections injected into similar questions" ``` --- ### Task 10: Rewrite `Ai` as the orchestrator **Files:** - Modify: `src/main/java/dev/marcospaulo/canalhandia/Ai.java` (substantial rewrite) **Step 1: Replace the body of `ask`'s async section** The pipeline, in order. Keep the existing permission, cooldown, daily-cap and pending-guard checks untouched at the top. ```java /** Builds the messages for one question. */ private java.util.List compose(Player asker, String question, Settings settings, StringBuilder sourceNote) { java.util.List messages = new java.util.ArrayList<>(); messages.add(new MiniMax.Msg("system", settings.aiInstructions())); String serverContext = settings.aiServerContext(); if (!serverContext.isBlank()) { messages.add(new MiniMax.Msg("system", "Sobre este servidor: " + serverContext)); } for (Corrections.Entry entry : Corrections.matching(corrections.all(), question)) { messages.add(new MiniMax.Msg("system", "Correção registrada por um operador. Pergunta parecida: \"" + entry.question() + "\" Resposta correta: " + entry.answer())); } // Recipes never appear in wiki text: explaintext drops tables. if (RecipeBook.isRecipeQuestion(question)) { String recipes = RecipeBook.describe(question); if (recipes != null) { messages.add(new MiniMax.Msg("system", recipes)); } } messages.addAll(conversations.history(asker.getUniqueId())); messages.add(new MiniMax.Msg("user", question)); return messages; } ``` and the async body: ```java Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { String answer = null; try { StringBuilder note = new StringBuilder(); java.util.List messages = compose(asker, prompt, settings, note); 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.Msg("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. 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) { plugin.getLogger().warning("Falha na chamada à IA: " + e); } String finalAnswer = answer; Bukkit.getScheduler().runTask(plugin, () -> { pending.remove(id); deliver(id, prompt, finalAnswer, settings, isPrivate); }); }); ``` **Step 2: Have `deliver` record memory, honour privacy and attach reactions** ```java 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) { Msg.error(asker, "Não consegui resposta agora. Tente de novo em instantes."); } 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(clean, NamedTextColor.WHITE) .decoration(TextDecoration.BOLD, false)); if (isPrivate || !settings.aiPublic()) { if (asker != null) { asker.sendMessage(message); } return; } Bukkit.broadcast(message); plugin.openAiReactions(askerId); } ``` Add the field `private Answered lastAnswer;` and `record Answered(UUID asker, String question, String answer) {}` so `/ia corrigir` has something to correct. **Step 3: Build** ```bash docker run --rm -v "$PWD":/work -v "$HOME/.m2":/root/.m2 -w /work \ maven:3.9-eclipse-temurin-25 mvn -B package ``` **Step 4: Commit** ```bash git add -A git commit -m "feat: ground answers in the wiki, recipes, corrections and memory" ``` --- ### Task 11: Commands, reactions and feedback **Files:** - Modify: `src/main/resources/plugin.yml` - Modify: `src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java` - Modify: `src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java` **Step 1: Register the commands and permissions** In `plugin.yml`, under `commands:` ```yaml iap: description: Pergunta para a IA, resposta só para você. usage: /iap aliases: [iaprivado] ``` and under `permissions:` ```yaml canalhandia.ia.privado: description: Permite perguntar em privado com /iap. default: true canalhandia.ia.corrigir: description: Permite registrar correções para as respostas da IA. default: op canalhandia.ia.perfil: description: Permite trocar o perfil da IA entre economico e preciso. default: op ``` Register `iap` in `Canalhandia.onEnable`'s command list. **Step 2: Route the subcommands** In `CanalhandiaCommand.onCommand` add `case "iap" -> ia(sender, args, true);` and give `ia(...)` a `boolean isPrivate` parameter. Inside `ia(...)`, before treating arguments as a question, handle: - `perfil ` — requires `canalhandia.ia.perfil`, calls `settings.aiProfile(...)`, replies with the new profile. - `corrigir ` — requires `canalhandia.ia.corrigir`, takes the last answered question and stores the operator's text via `Corrections.add`. - `feedback ruim` — any player; flags the last answer so operators see it in `/canalhandia status`. **Step 3: Attach reactions to answers** Add to `Canalhandia`: ```java /** Opens a reaction set for the answer just broadcast, reusing the chat buttons. */ void openAiReactions(UUID asker) { if (!settings.reactionsEnabled()) { return; } Reactions reactions = new Reactions(nextId++, List.of( new ReactionDef("util", "[👍]", "[UTIL]", "legal"), new ReactionDef("errado", "[❌]", "[ERRADO]", "errado"))); liveReactions = reactions; remember(reactions); reactions.show(); broadcastPerPlatform(bedrock -> Component.text(" ") .append(reactions.buttons(bedrock))); } ``` Add `errado` to the config's `reacoes` block so the typed `/errado` shortcut resolves, and register `errado` in `plugin.yml`. **Step 4: Show IA state in `/canalhandia status`** Extend the existing `ia` line with the profile and the count of stored corrections. **Step 5: Build and commit** ```bash docker run --rm -v "$PWD":/work -v "$HOME/.m2":/root/.m2 -w /work \ maven:3.9-eclipse-temurin-25 mvn -B package git add -A git commit -m "feat: /iap, reactions on answers, feedback and /ia corrigir" ``` --- ### Task 12: Full test run and README **Files:** - Modify: `README.md` **Step 1: Run everything** ```bash docker run --rm -v "$PWD":/work -v "$HOME/.m2":/root/.m2 -w /work \ maven:3.9-eclipse-temurin-25 mvn -B test ``` Expected: all tests pass. Do not proceed with failures. **Step 2: Document the module in `README.md`** — commands, permissions, the profile switch, where the key lives, and the note that `contexto.yml` is the place to put server facts. **Step 3: Commit** ```bash git add -A git commit -m "docs: document the IA module" ``` --- ### Task 13: Deploy dormant, verify nothing restarts **The server must NOT be restarted.** The new jar sits beside the running one and is picked up whenever the server next restarts on its own. **Step 1: Build the final jar** ```bash docker run --rm -v "$PWD":/work -v "$HOME/.m2":/root/.m2 -w /work \ maven:3.9-eclipse-temurin-25 mvn -B package ``` **Step 2: Copy it in, with the ownership the JVM needs** The server runs as `crafty` (uid 1000, gid 0). A root-owned file is unreadable to it. ```bash P=crafty-controller-788486f84-9825t S=6e39a8b2-300b-42d6-8139-f397c23e461b microk8s kubectl cp target/Canalhandia-1.0.0.jar \ minecraft/$P:/crafty/servers/$S/plugins/Canalhandia-1.0.0.jar microk8s kubectl exec -n minecraft $P -- \ chown 1000:0 /crafty/servers/$S/plugins/Canalhandia-1.0.0.jar ``` **Step 3: Merge the new config keys into the live config** Append the new `ia:` keys (`perfil`, `wiki-caracteres`, `memoria-*`, `contexto`) to `plugins/Canalhandia/config.yml` and raise `max-tokens` to 1200, keeping a `.bak` copy first. Chown the result to `1000:0`. **Step 4: Confirm the server was not restarted** ```bash microk8s kubectl exec -n minecraft $P -- \ bash -lc "grep -c 'Done (' /crafty/servers/$S/logs/latest.log" ``` Expected: the same count as before the deploy. A higher number means the server restarted, which this plan forbids. **Step 5: Push** ```bash T=$(cat ~/.claude/.gitea-skills-token) git push "http://gitea_admin:$T@100.74.17.70:30000/gitea_admin/canalhandia.git" HEAD:main ``` **Step 6: Verify after the next natural restart** — not now. Then: `/canalhandia status` shows the profile; ask the four questions from the design doc and check the camel, mob farm and creeper answers contain the facts they previously got wrong.