Files
canalhandia/docs/plans/2026-08-05-ia-grounding.md
2026-08-05 14:49:08 +00:00

1635 lines
52 KiB
Markdown

# /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 `<dependencies>` block with:
```xml
<dependencies>
<dependency>
<groupId>io.papermc.paper</groupId>
<artifactId>paper-api</artifactId>
<version>26.2.build.92-stable</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.3</version>
<scope>test</scope>
</dependency>
</dependencies>
```
**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.
*
* <p>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<String> 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<String, String> byUrlFragment) {
return new Fetcher() {
@Override
public String get(String url) {
for (Map.Entry<String, String> 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.
*
* <p>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.
*
* <p>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<String, Article> 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.
*
* <p>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<String> lines = new ArrayList<>();
Iterator<Recipe> 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<String> 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.
*
* <p>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.
*
* <p>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<Msg> 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<Msg> 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.
*
* <p>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<UUID, Deque<Entry>> 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<Entry> 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<MiniMax.Msg> history(UUID player) {
Deque<Entry> entries = byPlayer.get(player);
List<MiniMax.Msg> 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.
*
* <p>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 <nome>.
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<Corrections.Entry> 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<Corrections.Entry> all = List.of(
new Corrections.Entry("como pegar um camelo", ""));
assertTrue(Corrections.matching(all, "onde acho diamante?").isEmpty());
}
@Test
void shortWordsDoNotCreateMatches() {
List<Corrections.Entry> 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.
*
* <p>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<Entry> 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<Entry> all() {
return List.copyOf(entries);
}
/** Corrections sharing at least two significant words with the question. */
static List<Entry> matching(List<Entry> all, String question) {
Set<String> asked = significantWords(question);
List<Entry> out = new ArrayList<>();
for (Entry entry : all) {
Set<String> known = significantWords(entry.question());
known.retainAll(asked);
if (known.size() >= 2) {
out.add(entry);
}
}
return out;
}
private static Set<String> significantWords(String text) {
Set<String> 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<MiniMax.Msg> compose(Player asker, String question,
Settings settings, StringBuilder sourceNote) {
java.util.List<MiniMax.Msg> 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<MiniMax.Msg> 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 <pergunta>
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 <economico|preciso>` — requires `canalhandia.ia.perfil`, calls
`settings.aiProfile(...)`, replies with the new profile.
- `corrigir <resposta correta>` — 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.