diff --git a/src/main/java/dev/marcospaulo/canalhandia/Ai.java b/src/main/java/dev/marcospaulo/canalhandia/Ai.java
index b5b788e..67abae6 100644
--- a/src/main/java/dev/marcospaulo/canalhandia/Ai.java
+++ b/src/main/java/dev/marcospaulo/canalhandia/Ai.java
@@ -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;
*
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.
+ *
+ *
This class is an orchestrator: 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 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 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.
+ *
+ * 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 compose(Player asker, String question, Settings settings) {
+ java.util.List 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 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.
- *
- * 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;
}
-}
+}
\ No newline at end of file
diff --git a/src/main/java/dev/marcospaulo/canalhandia/AiText.java b/src/main/java/dev/marcospaulo/canalhandia/AiText.java
index f9ce84a..e286215 100644
--- a/src/main/java/dev/marcospaulo/canalhandia/AiText.java
+++ b/src/main/java/dev/marcospaulo/canalhandia/AiText.java
@@ -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.
+ *
+ *
{@code null} returns {@code ""}: {@code Ai.deliver} never calls here
+ * with a null, but hardening is cheap and keeps the contract total.
+ *
+ *
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)(.+?)(? 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;
}
diff --git a/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java b/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java
index ff6e985..a802a98 100644
--- a/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java
+++ b/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java
@@ -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) {
diff --git a/src/main/java/dev/marcospaulo/canalhandia/RecipeBook.java b/src/main/java/dev/marcospaulo/canalhandia/RecipeBook.java
index d1ae94e..b7563d4 100644
--- a/src/main/java/dev/marcospaulo/canalhandia/RecipeBook.java
+++ b/src/main/java/dev/marcospaulo/canalhandia/RecipeBook.java
@@ -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 recipes = List.of();
+
+ /**
+ * Snapshot the server's recipes on the main thread (at enable).
+ */
+ static void preload() {
+ List snapshot = new ArrayList<>();
+ Iterator 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 lines = new ArrayList<>();
- Iterator it = Bukkit.recipeIterator();
+ Iterator it = recipes.iterator();
while (it.hasNext() && lines.size() < MAX_RECIPES) {
Recipe recipe = it.next();
if (recipe.getResult().getType() != material) {
diff --git a/src/test/java/dev/marcospaulo/canalhandia/AiTextTest.java b/src/test/java/dev/marcospaulo/canalhandia/AiTextTest.java
index a6298de..280b561 100644
--- a/src/test/java/dev/marcospaulo/canalhandia/AiTextTest.java
+++ b/src/test/java/dev/marcospaulo/canalhandia/AiTextTest.java
@@ -44,4 +44,30 @@ class AiTextTest {
void plainPortugueseIsNotForeign() {
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));
+ }
}