1635 lines
52 KiB
Markdown
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.
|