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
+143 -104
View File
@@ -1,25 +1,17 @@
package dev.marcospaulo.canalhandia;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextDecoration;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@@ -38,6 +30,11 @@ import java.util.UUID;
* <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
* {@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 {
@@ -45,7 +42,10 @@ final class Ai {
private static final String KEY_ENV = "MINIMAX_API_KEY";
private final Canalhandia plugin;
private final HttpClient http;
private final MiniMax api;
private final Wiki wiki;
private final Conversations conversations;
private final Corrections corrections;
/** Per-player cooldown, so one person cannot spend the whole budget. */
private final Map<UUID, Long> lastAsk = new HashMap<>();
/** In-flight guard: one question per player at a time. */
@@ -54,11 +54,44 @@ final class Ai {
private LocalDate day = LocalDate.now();
private int askedToday;
/**
* The most recent answered question, so {@code /ia corrigir} (Task 11) can
* correct it without the operator retyping anything.
*/
private Answered lastAnswer;
record Answered(UUID asker, String question, String answer) {
}
/** Package-private so Task 11's {@code /ia corrigir} can read what to correct. */
Answered lastAnswer() {
return lastAnswer;
}
/** Package-private so Task 11 can wire quit-forget. */
Conversations conversations() {
return conversations;
}
/** Package-private so Task 11 can wire {@code /ia corrigir}. */
Corrections corrections() {
return corrections;
}
Ai(Canalhandia plugin) {
this.plugin = plugin;
this.http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
Settings settings = plugin.settings();
Fetcher fetcher = new HttpFetcher(settings.aiTimeoutSeconds());
this.api = new MiniMax(fetcher, settings.aiUrl(), plugin.getLogger()::warning);
// 3-arg ctor + logger (carry-forward #1): the 2-arg ctor is silent in
// production, so every wiki failure here is logged.
this.wiki = new Wiki(fetcher, settings.aiWikiChars(), plugin.getLogger()::warning);
this.conversations = new Conversations(settings.aiMemoryExchanges(), settings.aiMemoryMinutes());
this.corrections = new Corrections(new java.io.File(plugin.getDataFolder(), "correcoes.yml"));
// Note: aiUrl(), aiWikiChars(), aiMemoryExchanges() and aiMemoryMinutes()
// are baked here at construction. Only aiProfile() is read live in the
// async body, which is correct — operators switch profiles live without
// a restart, and the other settings are not meant to be hot-swapped.
}
/** True if a key is configured. Without one the module stays quiet. */
@@ -125,7 +158,7 @@ final class Ai {
if (Files.isReadable(file)) {
return cleanKey(Files.readString(file, StandardCharsets.UTF_8));
}
} catch (IOException e) {
} catch (java.io.IOException e) {
plugin.getLogger().warning("Não consegui ler " + KEY_FILE + ": " + e.getMessage());
}
return null;
@@ -138,6 +171,17 @@ final class Ai {
* reply is posted back on it.
*/
void ask(Player asker, String question) {
ask(asker, question, false);
}
/**
* Asks the model on behalf of a player and delivers the answer to chat.
*
* @param isPrivate true for {@code /iap} (Task 11): the question and answer
* go only to the asker even when the module is public. A private
* question never broadcasts the question either.
*/
void ask(Player asker, String question, boolean isPrivate) {
Settings settings = plugin.settings();
String key = apiKey();
@@ -169,7 +213,7 @@ final class Ai {
pending.put(asker.getUniqueId(), true);
askedToday++;
if (settings.aiPublic()) {
if (!isPrivate && settings.aiPublic()) {
Bukkit.broadcast(Msg.tag("IA", NamedTextColor.LIGHT_PURPLE)
.append(Component.text(asker.getName() + " perguntou: ", NamedTextColor.GRAY)
.decoration(TextDecoration.BOLD, false))
@@ -180,27 +224,91 @@ final class Ai {
String prompt = question;
UUID id = asker.getUniqueId();
final boolean isPriv = isPrivate;
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
String answer;
String answer = null;
try {
answer = call(key, prompt, settings);
java.util.List<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) {
// Redacted: this catches everything call() can throw, including
// the JDK's header validation, whose message quotes the whole
// Authorization value back. cleanKey should make that
// impossible; this is the backstop if it ever does not.
// Redacts the key: the JDK's header validator quotes the whole
// Authorization value back into the exception message, so a key
// with a stray newline would otherwise reach the log verbatim.
// cleanKey makes that impossible; this is the backstop.
warnWithout(key, "Falha na chamada à IA: " + e);
answer = null;
}
String finalAnswer = answer;
Bukkit.getScheduler().runTask(plugin, () -> {
pending.remove(id);
deliver(id, finalAnswer, settings);
deliver(id, prompt, finalAnswer, settings, isPriv);
});
});
}
private void deliver(UUID askerId, String answer, Settings settings) {
/**
* Builds the messages for one question.
*
* <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);
if (answer == null || answer.isBlank()) {
if (asker != null) {
@@ -208,90 +316,21 @@ final class Ai {
}
return;
}
String clean = AiText.sanitise(answer, settings.aiMaxAnswer());
conversations.remember(askerId, question, clean);
lastAnswer = new Answered(askerId, question, clean);
Component message = Msg.tag("IA", NamedTextColor.LIGHT_PURPLE)
.append(Component.text(AiText.sanitise(answer, settings.aiMaxAnswer()), NamedTextColor.WHITE)
.append(Component.text(clean, NamedTextColor.WHITE)
.decoration(TextDecoration.BOLD, false));
if (settings.aiPublic()) {
Bukkit.broadcast(message);
} else if (asker != null) {
asker.sendMessage(message);
}
}
// --- HTTP ---------------------------------------------------------------
private String call(String key, String question, Settings settings) throws Exception {
JsonArray messages = new JsonArray();
messages.add(message("system", settings.aiInstructions()));
messages.add(message("user", question));
JsonObject body = new JsonObject();
body.addProperty("model", settings.aiModel());
body.add("messages", messages);
body.addProperty("max_tokens", settings.aiMaxTokens());
body.addProperty("temperature", settings.aiTemperature());
// No "tools" and no "tool_choice": the model is given nothing it could call.
// Rejects a malformed key before the JDK's own validator can quote it
// back into an exception message. Task 10 retires this whole method in
// favour of MiniMax, which reaches the same check through HttpFetcher.
HttpFetcher.checkBearer(key);
HttpRequest request = HttpRequest.newBuilder(URI.create(settings.aiUrl()))
.timeout(Duration.ofSeconds(settings.aiTimeoutSeconds()))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + key)
.POST(HttpRequest.BodyPublishers.ofString(body.toString(), StandardCharsets.UTF_8))
.build();
HttpResponse<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 (isPrivate || !settings.aiPublic()) {
if (asker != null) {
asker.sendMessage(message);
}
return;
}
if (!root.has("choices")) {
return null;
}
JsonArray choices = root.getAsJsonArray("choices");
if (choices.isEmpty()) {
return null;
}
JsonObject first = choices.get(0).getAsJsonObject();
if (!first.has("message")) {
return null;
}
JsonObject message = first.getAsJsonObject("message");
// Reasoning models also return "reasoning_content"; only "content" is shown.
return message.has("content") && !message.get("content").isJsonNull()
? message.get("content").getAsString()
: null;
Bukkit.broadcast(message);
plugin.openAiReactions(askerId);
}
// --- limits and cleanup -------------------------------------------------
@@ -320,4 +359,4 @@ final class Ai {
return askedToday;
}
}
}
@@ -37,8 +37,18 @@ final class AiText {
* Makes a reply safe and readable in chat: no colour codes to forge server
* messages, no markdown or emoji (chat renders neither, and emoji are empty
* boxes on Bedrock), no leading slash that could read as a command.
*
* <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) {
if (raw == null) {
return "";
}
String text = raw
// A colour code is the section sign plus the code character, so both
// go. Dropping only the sign would leave the bare letter behind and
@@ -46,8 +56,13 @@ final class AiText {
.replaceAll("§[0-9A-Za-z]", " ")
.replace('§', ' ')
.replaceAll("[\\r\\n]+", " ")
.replaceAll("\\*{1,3}([^*]+)\\*{1,3}", "$1")
.replaceAll("`{1,3}([^`]+)`{1,3}", "$1")
// Markdown emphasis/inline-code: only strip the markers when they
// flank non-space text. A lone "*" surrounded by spaces is not
// emphasis (chat uses it as a bullet), so the regex requires a
// non-space right after the opening markers and before the
// closing ones. "{1,3}" keeps it handling *, ** and ***.
.replaceAll("(?s)\\*{1,3}(?!\\s)(.+?)(?<!\\s)\\*{1,3}", "$1")
.replaceAll("(?s)`{1,3}(?!\\s)(.+?)(?<!\\s)`{1,3}", "$1")
.replaceAll("^#{1,6}\\s+", "")
.replaceAll("[\\x{1F000}-\\x{1FAFF}\\x{2190}-\\x{2BFF}\\x{FE0F}\\x{20E3}]", "")
.replaceAll("\\s{2,}", " ")
@@ -56,7 +71,14 @@ final class AiText {
text = text.substring(1).trim();
}
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;
}
@@ -62,6 +62,11 @@ public final class Canalhandia extends JavaPlugin implements Listener {
offlineStats = new OfflineStats(this);
milestones = new Milestones(this);
ai = new Ai(this);
// Snapshot the server's recipes on the main thread; RecipeBook.describe
// reads from the async answer path and Bukkit.recipeIterator() is not
// safe off the main thread. Datapack reloads after this are not
// re-snapshotted.
RecipeBook.preload();
optOutKey = new NamespacedKey(this, "opt_out");
CanalhandiaCommand root = new CanalhandiaCommand(this);
@@ -299,6 +304,19 @@ public final class Canalhandia extends JavaPlugin implements Listener {
return reactions;
}
/**
* Opens the reaction row on a public AI answer. Minimal for now: reuses
* the shared {@link #openReactions()} machinery. Task 11 wires the
* asker-gated feedback path and the typed {@code /reagir} twin.
*/
void openAiReactions(UUID askerId) {
// TODO Task 11: asker-gated feedback and the typed /reagir twin use askerId.
if (!settings.reactionsEnabled()) {
return;
}
openReactions();
}
/**
* The newest reaction set still accepting clicks, for typed shortcuts like
* {@code /wow} where the player never sees an id.
@@ -475,6 +493,17 @@ public final class Canalhandia extends JavaPlugin implements Listener {
}, settings.reactionWindowSeconds() * 20L);
}
/**
* Drops a player's short-term AI memory on quit, so a rejoin does not
* answer a fresh question with an old one (carry-forward #6).
*/
@EventHandler
public void onQuit(org.bukkit.event.player.PlayerQuitEvent event) {
if (ai != null) {
ai.conversations().forget(event.getPlayer().getUniqueId());
}
}
// --- per-player opt out -------------------------------------------------
boolean isOptedOut(Player player) {
@@ -356,10 +356,30 @@ final class RecipeBook {
return craftingFor(material);
}
/** Reads the server's registry. Requires a running server. */
/**
* Snapshot of the server's recipes, taken once on the main thread at
* enable. {@link Bukkit#recipeIterator()} is documented main-thread-only
* and has been observed misbehaving off it, so {@link #describe} iterates
* this copy instead. Datapack reloads after enable are not re-snapshotted.
*/
private static volatile List<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) {
List<String> lines = new ArrayList<>();
Iterator<Recipe> it = Bukkit.recipeIterator();
Iterator<Recipe> it = recipes.iterator();
while (it.hasNext() && lines.size() < MAX_RECIPES) {
Recipe recipe = it.next();
if (recipe.getResult().getType() != material) {