feat: ground answers in the wiki, recipes, corrections and memory

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
marcos
2026-08-06 03:19:54 +00:00
parent 7940a8fc66
commit 5b13ec7713
5 changed files with 245 additions and 109 deletions
+141 -102
View File
@@ -1,25 +1,17 @@
package dev.marcospaulo.canalhandia; package dev.marcospaulo.canalhandia;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import net.kyori.adventure.text.Component; import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextDecoration; import net.kyori.adventure.text.format.TextDecoration;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.time.Duration;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
@@ -38,6 +30,11 @@ import java.util.UUID;
* <p>The API key never lives in config.yml, because config.yml is committed to * <p>The API key never lives in config.yml, because config.yml is committed to
* git. It comes from the {@code MINIMAX_API_KEY} environment variable, or from * git. It comes from the {@code MINIMAX_API_KEY} environment variable, or from
* {@code plugins/Canalhandia/minimax.key}, which is gitignored. * {@code plugins/Canalhandia/minimax.key}, which is gitignored.
*
* <p>This class is an <em>orchestrator</em>: it assembles context (server facts,
* operator corrections, recipes, conversation memory, a wiki article) into a
* list of {@link MiniMax.Turn}s and delegates the HTTP to {@link MiniMax}. It
* holds no {@code HttpClient} of its own.
*/ */
final class Ai { final class Ai {
@@ -45,7 +42,10 @@ final class Ai {
private static final String KEY_ENV = "MINIMAX_API_KEY"; private static final String KEY_ENV = "MINIMAX_API_KEY";
private final Canalhandia plugin; private final Canalhandia plugin;
private final HttpClient http; private final MiniMax api;
private final Wiki wiki;
private final Conversations conversations;
private final Corrections corrections;
/** Per-player cooldown, so one person cannot spend the whole budget. */ /** Per-player cooldown, so one person cannot spend the whole budget. */
private final Map<UUID, Long> lastAsk = new HashMap<>(); private final Map<UUID, Long> lastAsk = new HashMap<>();
/** In-flight guard: one question per player at a time. */ /** In-flight guard: one question per player at a time. */
@@ -54,11 +54,44 @@ final class Ai {
private LocalDate day = LocalDate.now(); private LocalDate day = LocalDate.now();
private int askedToday; private int askedToday;
/**
* The most recent answered question, so {@code /ia corrigir} (Task 11) can
* correct it without the operator retyping anything.
*/
private Answered lastAnswer;
record Answered(UUID asker, String question, String answer) {
}
/** Package-private so Task 11's {@code /ia corrigir} can read what to correct. */
Answered lastAnswer() {
return lastAnswer;
}
/** Package-private so Task 11 can wire quit-forget. */
Conversations conversations() {
return conversations;
}
/** Package-private so Task 11 can wire {@code /ia corrigir}. */
Corrections corrections() {
return corrections;
}
Ai(Canalhandia plugin) { Ai(Canalhandia plugin) {
this.plugin = plugin; this.plugin = plugin;
this.http = HttpClient.newBuilder() Settings settings = plugin.settings();
.connectTimeout(Duration.ofSeconds(10)) Fetcher fetcher = new HttpFetcher(settings.aiTimeoutSeconds());
.build(); this.api = new MiniMax(fetcher, settings.aiUrl(), plugin.getLogger()::warning);
// 3-arg ctor + logger (carry-forward #1): the 2-arg ctor is silent in
// production, so every wiki failure here is logged.
this.wiki = new Wiki(fetcher, settings.aiWikiChars(), plugin.getLogger()::warning);
this.conversations = new Conversations(settings.aiMemoryExchanges(), settings.aiMemoryMinutes());
this.corrections = new Corrections(new java.io.File(plugin.getDataFolder(), "correcoes.yml"));
// Note: aiUrl(), aiWikiChars(), aiMemoryExchanges() and aiMemoryMinutes()
// are baked here at construction. Only aiProfile() is read live in the
// async body, which is correct — operators switch profiles live without
// a restart, and the other settings are not meant to be hot-swapped.
} }
/** True if a key is configured. Without one the module stays quiet. */ /** True if a key is configured. Without one the module stays quiet. */
@@ -125,7 +158,7 @@ final class Ai {
if (Files.isReadable(file)) { if (Files.isReadable(file)) {
return cleanKey(Files.readString(file, StandardCharsets.UTF_8)); return cleanKey(Files.readString(file, StandardCharsets.UTF_8));
} }
} catch (IOException e) { } catch (java.io.IOException e) {
plugin.getLogger().warning("Não consegui ler " + KEY_FILE + ": " + e.getMessage()); plugin.getLogger().warning("Não consegui ler " + KEY_FILE + ": " + e.getMessage());
} }
return null; return null;
@@ -138,6 +171,17 @@ final class Ai {
* reply is posted back on it. * reply is posted back on it.
*/ */
void ask(Player asker, String question) { void ask(Player asker, String question) {
ask(asker, question, false);
}
/**
* Asks the model on behalf of a player and delivers the answer to chat.
*
* @param isPrivate true for {@code /iap} (Task 11): the question and answer
* go only to the asker even when the module is public. A private
* question never broadcasts the question either.
*/
void ask(Player asker, String question, boolean isPrivate) {
Settings settings = plugin.settings(); Settings settings = plugin.settings();
String key = apiKey(); String key = apiKey();
@@ -169,7 +213,7 @@ final class Ai {
pending.put(asker.getUniqueId(), true); pending.put(asker.getUniqueId(), true);
askedToday++; askedToday++;
if (settings.aiPublic()) { if (!isPrivate && settings.aiPublic()) {
Bukkit.broadcast(Msg.tag("IA", NamedTextColor.LIGHT_PURPLE) Bukkit.broadcast(Msg.tag("IA", NamedTextColor.LIGHT_PURPLE)
.append(Component.text(asker.getName() + " perguntou: ", NamedTextColor.GRAY) .append(Component.text(asker.getName() + " perguntou: ", NamedTextColor.GRAY)
.decoration(TextDecoration.BOLD, false)) .decoration(TextDecoration.BOLD, false))
@@ -180,27 +224,91 @@ final class Ai {
String prompt = question; String prompt = question;
UUID id = asker.getUniqueId(); UUID id = asker.getUniqueId();
final boolean isPriv = isPrivate;
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
String answer; String answer = null;
try { try {
answer = call(key, prompt, settings); java.util.List<MiniMax.Turn> messages = compose(asker, prompt, settings);
if (settings.aiProfile() == AiProfile.PRECISO) {
String term = api.searchTerm(key, settings.aiModel(), prompt);
Wiki.Article article = term == null ? null : wiki.lookup(term);
if (article != null) {
messages.add(messages.size() - 1, new MiniMax.Turn("system",
"Artigo da Minecraft Wiki pt-BR — '" + article.title() + "':\n"
+ article.text()));
}
}
answer = api.answer(key, settings.aiModel(), messages,
settings.aiMaxTokens(), settings.aiTemperature());
// Hidden reasoning can swallow the budget, and the model
// occasionally drops a foreign word mid-sentence. Both are
// worth one retry before giving up (carry-forwards #3 and #7).
if (answer == null || AiText.hasForeignScript(answer)) {
answer = api.answer(key, settings.aiModel(), messages,
settings.aiMaxTokens() * 2, 0.1);
}
if (answer != null && AiText.hasForeignScript(answer)) {
plugin.getLogger().warning("Resposta descartada por idioma estrangeiro.");
answer = null;
}
} catch (Exception e) { } catch (Exception e) {
// Redacted: this catches everything call() can throw, including // Redacts the key: the JDK's header validator quotes the whole
// the JDK's header validation, whose message quotes the whole // Authorization value back into the exception message, so a key
// Authorization value back. cleanKey should make that // with a stray newline would otherwise reach the log verbatim.
// impossible; this is the backstop if it ever does not. // cleanKey makes that impossible; this is the backstop.
warnWithout(key, "Falha na chamada à IA: " + e); warnWithout(key, "Falha na chamada à IA: " + e);
answer = null; answer = null;
} }
String finalAnswer = answer; String finalAnswer = answer;
Bukkit.getScheduler().runTask(plugin, () -> { Bukkit.getScheduler().runTask(plugin, () -> {
pending.remove(id); pending.remove(id);
deliver(id, finalAnswer, settings); deliver(id, prompt, finalAnswer, settings, isPriv);
}); });
}); });
} }
private void deliver(UUID askerId, String answer, Settings settings) { /**
* Builds the messages for one question.
*
* <p>Order matters: system instructions, server context, operator
* corrections, recipes (which the wiki cannot supply — {@code explaintext}
* drops tables), the conversation history, then the question itself.
*/
private java.util.List<MiniMax.Turn> compose(Player asker, String question, Settings settings) {
java.util.List<MiniMax.Turn> messages = new java.util.ArrayList<>();
messages.add(new MiniMax.Turn("system", settings.aiInstructions()));
String serverContext = settings.aiServerContext();
if (!serverContext.isBlank()) {
messages.add(new MiniMax.Turn("system", "Sobre este servidor: " + serverContext));
}
for (Corrections.Entry entry : Corrections.matching(corrections.all(), question)) {
messages.add(new MiniMax.Turn("system",
"Correção registrada por um operador. Pergunta parecida: \""
+ entry.question() + "\" Resposta correta: " + entry.answer()));
}
// Recipes never appear in wiki text: explaintext drops tables. The wiki
// is consulted here only for the englishTitle translation; the recipe
// data comes from the Bukkit snapshot taken at enable (RecipeBook.preload).
if (RecipeBook.isRecipeQuestion(question)) {
String recipes = RecipeBook.describe(question, wiki);
if (recipes != null) {
messages.add(new MiniMax.Turn("system", recipes));
}
}
messages.addAll(conversations.history(asker.getUniqueId()));
messages.add(new MiniMax.Turn("user", question));
return messages;
}
private void deliver(UUID askerId, String question, String answer,
Settings settings, boolean isPrivate) {
Player asker = Bukkit.getPlayer(askerId); Player asker = Bukkit.getPlayer(askerId);
if (answer == null || answer.isBlank()) { if (answer == null || answer.isBlank()) {
if (asker != null) { if (asker != null) {
@@ -208,90 +316,21 @@ final class Ai {
} }
return; 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) Component message = Msg.tag("IA", NamedTextColor.LIGHT_PURPLE)
.append(Component.text(AiText.sanitise(answer, settings.aiMaxAnswer()), NamedTextColor.WHITE) .append(Component.text(clean, NamedTextColor.WHITE)
.decoration(TextDecoration.BOLD, false)); .decoration(TextDecoration.BOLD, false));
if (settings.aiPublic()) { if (isPrivate || !settings.aiPublic()) {
Bukkit.broadcast(message); if (asker != null) {
} else if (asker != null) {
asker.sendMessage(message); asker.sendMessage(message);
} }
return;
} }
Bukkit.broadcast(message);
// --- HTTP --------------------------------------------------------------- plugin.openAiReactions(askerId);
private String call(String key, String question, Settings settings) throws Exception {
JsonArray messages = new JsonArray();
messages.add(message("system", settings.aiInstructions()));
messages.add(message("user", question));
JsonObject body = new JsonObject();
body.addProperty("model", settings.aiModel());
body.add("messages", messages);
body.addProperty("max_tokens", settings.aiMaxTokens());
body.addProperty("temperature", settings.aiTemperature());
// No "tools" and no "tool_choice": the model is given nothing it could call.
// Rejects a malformed key before the JDK's own validator can quote it
// back into an exception message. Task 10 retires this whole method in
// favour of MiniMax, which reaches the same check through HttpFetcher.
HttpFetcher.checkBearer(key);
HttpRequest request = HttpRequest.newBuilder(URI.create(settings.aiUrl()))
.timeout(Duration.ofSeconds(settings.aiTimeoutSeconds()))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + key)
.POST(HttpRequest.BodyPublishers.ofString(body.toString(), StandardCharsets.UTF_8))
.build();
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() / 100 != 2) {
plugin.getLogger().warning("IA respondeu HTTP " + response.statusCode() + ": "
+ AiText.forLog(response.body()));
return null;
}
return extract(response.body());
}
private static JsonObject message(String role, String content) {
JsonObject object = new JsonObject();
object.addProperty("role", role);
object.addProperty("content", content);
return object;
}
/**
* Pulls the reply text out of an OpenAI-shaped response.
*
* <p>MiniMax returns HTTP 200 even for application-level failures, putting
* the real outcome in {@code base_resp.status_code}, so that is checked too.
*/
private String extract(String json) {
JsonObject root = JsonParser.parseString(json).getAsJsonObject();
if (root.has("base_resp")) {
JsonObject base = root.getAsJsonObject("base_resp");
int status = base.has("status_code") ? base.get("status_code").getAsInt() : 0;
if (status != 0) {
plugin.getLogger().warning("IA recusou: base_resp " + base);
return null;
}
}
if (!root.has("choices")) {
return null;
}
JsonArray choices = root.getAsJsonArray("choices");
if (choices.isEmpty()) {
return null;
}
JsonObject first = choices.get(0).getAsJsonObject();
if (!first.has("message")) {
return null;
}
JsonObject message = first.getAsJsonObject("message");
// Reasoning models also return "reasoning_content"; only "content" is shown.
return message.has("content") && !message.get("content").isJsonNull()
? message.get("content").getAsString()
: null;
} }
// --- limits and cleanup ------------------------------------------------- // --- limits and cleanup -------------------------------------------------
@@ -37,8 +37,18 @@ final class AiText {
* Makes a reply safe and readable in chat: no colour codes to forge server * 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 * messages, no markdown or emoji (chat renders neither, and emoji are empty
* boxes on Bedrock), no leading slash that could read as a command. * boxes on Bedrock), no leading slash that could read as a command.
*
* <p>{@code null} returns {@code ""}: {@code Ai.deliver} never calls here
* with a null, but hardening is cheap and keeps the contract total.
*
* <p>Truncation backs off one char when it lands on a high surrogate, so the
* result is never left with an orphan surrogate that would render as a
* replacement box.
*/ */
static String sanitise(String raw, int max) { static String sanitise(String raw, int max) {
if (raw == null) {
return "";
}
String text = raw String text = raw
// A colour code is the section sign plus the code character, so both // A colour code is the section sign plus the code character, so both
// go. Dropping only the sign would leave the bare letter behind and // go. Dropping only the sign would leave the bare letter behind and
@@ -46,8 +56,13 @@ final class AiText {
.replaceAll("§[0-9A-Za-z]", " ") .replaceAll("§[0-9A-Za-z]", " ")
.replace('§', ' ') .replace('§', ' ')
.replaceAll("[\\r\\n]+", " ") .replaceAll("[\\r\\n]+", " ")
.replaceAll("\\*{1,3}([^*]+)\\*{1,3}", "$1") // Markdown emphasis/inline-code: only strip the markers when they
.replaceAll("`{1,3}([^`]+)`{1,3}", "$1") // flank non-space text. A lone "*" surrounded by spaces is not
// emphasis (chat uses it as a bullet), so the regex requires a
// non-space right after the opening markers and before the
// closing ones. "{1,3}" keeps it handling *, ** and ***.
.replaceAll("(?s)\\*{1,3}(?!\\s)(.+?)(?<!\\s)\\*{1,3}", "$1")
.replaceAll("(?s)`{1,3}(?!\\s)(.+?)(?<!\\s)`{1,3}", "$1")
.replaceAll("^#{1,6}\\s+", "") .replaceAll("^#{1,6}\\s+", "")
.replaceAll("[\\x{1F000}-\\x{1FAFF}\\x{2190}-\\x{2BFF}\\x{FE0F}\\x{20E3}]", "") .replaceAll("[\\x{1F000}-\\x{1FAFF}\\x{2190}-\\x{2BFF}\\x{FE0F}\\x{20E3}]", "")
.replaceAll("\\s{2,}", " ") .replaceAll("\\s{2,}", " ")
@@ -56,7 +71,14 @@ final class AiText {
text = text.substring(1).trim(); text = text.substring(1).trim();
} }
if (text.length() > max) { if (text.length() > max) {
text = text.substring(0, max).trim() + ""; int cut = max;
// Back off one char if we sliced a high surrogate off its low half,
// otherwise the String is left with an orphan surrogate that
// renders as a replacement character.
if (cut > 0 && Character.isHighSurrogate(text.charAt(cut - 1))) {
cut--;
}
text = text.substring(0, cut).trim() + "";
} }
return text; return text;
} }
@@ -62,6 +62,11 @@ public final class Canalhandia extends JavaPlugin implements Listener {
offlineStats = new OfflineStats(this); offlineStats = new OfflineStats(this);
milestones = new Milestones(this); milestones = new Milestones(this);
ai = new Ai(this); ai = new Ai(this);
// Snapshot the server's recipes on the main thread; RecipeBook.describe
// reads from the async answer path and Bukkit.recipeIterator() is not
// safe off the main thread. Datapack reloads after this are not
// re-snapshotted.
RecipeBook.preload();
optOutKey = new NamespacedKey(this, "opt_out"); optOutKey = new NamespacedKey(this, "opt_out");
CanalhandiaCommand root = new CanalhandiaCommand(this); CanalhandiaCommand root = new CanalhandiaCommand(this);
@@ -299,6 +304,19 @@ public final class Canalhandia extends JavaPlugin implements Listener {
return reactions; return reactions;
} }
/**
* Opens the reaction row on a public AI answer. Minimal for now: reuses
* the shared {@link #openReactions()} machinery. Task 11 wires the
* asker-gated feedback path and the typed {@code /reagir} twin.
*/
void openAiReactions(UUID askerId) {
// TODO Task 11: asker-gated feedback and the typed /reagir twin use askerId.
if (!settings.reactionsEnabled()) {
return;
}
openReactions();
}
/** /**
* The newest reaction set still accepting clicks, for typed shortcuts like * The newest reaction set still accepting clicks, for typed shortcuts like
* {@code /wow} where the player never sees an id. * {@code /wow} where the player never sees an id.
@@ -475,6 +493,17 @@ public final class Canalhandia extends JavaPlugin implements Listener {
}, settings.reactionWindowSeconds() * 20L); }, settings.reactionWindowSeconds() * 20L);
} }
/**
* Drops a player's short-term AI memory on quit, so a rejoin does not
* answer a fresh question with an old one (carry-forward #6).
*/
@EventHandler
public void onQuit(org.bukkit.event.player.PlayerQuitEvent event) {
if (ai != null) {
ai.conversations().forget(event.getPlayer().getUniqueId());
}
}
// --- per-player opt out ------------------------------------------------- // --- per-player opt out -------------------------------------------------
boolean isOptedOut(Player player) { boolean isOptedOut(Player player) {
@@ -356,10 +356,30 @@ final class RecipeBook {
return craftingFor(material); return craftingFor(material);
} }
/** Reads the server's registry. Requires a running server. */ /**
* Snapshot of the server's recipes, taken once on the main thread at
* enable. {@link Bukkit#recipeIterator()} is documented main-thread-only
* and has been observed misbehaving off it, so {@link #describe} iterates
* this copy instead. Datapack reloads after enable are not re-snapshotted.
*/
private static volatile List<Recipe> recipes = List.of();
/**
* Snapshot the server's recipes on the main thread (at enable).
*/
static void preload() {
List<Recipe> snapshot = new ArrayList<>();
Iterator<Recipe> it = Bukkit.recipeIterator();
while (it.hasNext()) {
snapshot.add(it.next());
}
recipes = List.copyOf(snapshot);
}
/** Reads the snapshot taken at enable. */
private static String craftingFor(Material material) { private static String craftingFor(Material material) {
List<String> lines = new ArrayList<>(); List<String> lines = new ArrayList<>();
Iterator<Recipe> it = Bukkit.recipeIterator(); Iterator<Recipe> it = recipes.iterator();
while (it.hasNext() && lines.size() < MAX_RECIPES) { while (it.hasNext() && lines.size() < MAX_RECIPES) {
Recipe recipe = it.next(); Recipe recipe = it.next();
if (recipe.getResult().getType() != material) { if (recipe.getResult().getType() != material) {
@@ -44,4 +44,30 @@ class AiTextTest {
void plainPortugueseIsNotForeign() { void plainPortugueseIsNotForeign() {
assertFalse(AiText.hasForeignScript("camelos são pacíficos e mansos")); assertFalse(AiText.hasForeignScript("camelos são pacíficos e mansos"));
} }
// null must not reach the regex chain — it is total hardening for a method
// whose caller promises non-null but should not explode if it does not.
@Test
void sanitiseReturnsEmptyForNull() {
assertEquals("", AiText.sanitise(null, 500));
}
// Truncating a supplementary character in half leaves an orphan surrogate
// that renders as a replacement box. The cut backs off one char.
@Test
void truncationBacksOffAHighSurrogate() {
// 𝄞 (U+1D11E) is two UTF-16 chars; it is not in the emoji strip range,
// so it survives to the truncation step. Cutting at index 4 would slice
// the high surrogate (D834) off its low half (DD1E).
assertEquals("abc…", AiText.sanitise("abc𝄞def", 4));
}
// A lone "*" surrounded by spaces is a bullet, not markdown emphasis.
// The strip must only remove markers that flank non-space text.
@Test
void keepsStandaloneAsterisk() {
assertEquals("foo * bar", AiText.sanitise("foo * bar", 500));
// And real emphasis still strips.
assertEquals("use magma cream", AiText.sanitise("use *magma cream*", 500));
}
} }