diff --git a/src/main/java/dev/marcospaulo/canalhandia/Ai.java b/src/main/java/dev/marcospaulo/canalhandia/Ai.java index 4857860..126c667 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Ai.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Ai.java @@ -43,6 +43,7 @@ final class Ai { private final Canalhandia plugin; private final MiniMax api; private final Wiki wiki; + private final Tools tools; private final Conversations conversations; private final Corrections corrections; /** Per-player cooldown, so one person cannot spend the whole budget. */ @@ -107,6 +108,10 @@ final class Ai { // 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.tools = new Tools(plugin, wiki, + new Search(fetcher, settings.aiSearxngUrl(), settings.aiSearchResults(), + settings.aiSearchSnippet(), plugin.getLogger()::warning), + plugin.getLogger()::info); 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() @@ -263,32 +268,46 @@ final class Ai { java.util.List messages = compose(asker, prompt, settings, liveState, chatContext); - 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())); + if (settings.aiTools()) { + // Agentic path: the model pulls what it needs (web search, + // stats, ranking, wiki) via tools instead of a single fixed + // pre-fetch. The tools run on this same async worker. + answer = api.answerWithTools(key, settings.aiModel(), messages, + tools.definitions(), tools::run, + settings.aiMaxTokens(), settings.aiTemperature(), + settings.aiMaxToolCalls()); + if (answer != null && AiText.hasForeignScript(answer)) { + plugin.getLogger().warning("Resposta descartada por idioma estrangeiro."); + answer = null; + } + } else { + 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()); + 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)) { - // Cap before doubling: an absurd ia.max-tokens near - // Integer.MAX_VALUE would overflow to a negative budget - // and be sent to the API. The default (1200) is unaffected. - int retryTokens = Math.min(settings.aiMaxTokens(), Integer.MAX_VALUE / 2) * 2; - answer = api.answer(key, settings.aiModel(), messages, retryTokens, 0.1); - } - if (answer != null && AiText.hasForeignScript(answer)) { - plugin.getLogger().warning("Resposta descartada por idioma estrangeiro."); - answer = null; + // 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)) { + // Cap before doubling: an absurd ia.max-tokens near + // Integer.MAX_VALUE would overflow to a negative budget + // and be sent to the API. The default (1200) is unaffected. + int retryTokens = Math.min(settings.aiMaxTokens(), Integer.MAX_VALUE / 2) * 2; + answer = api.answer(key, settings.aiModel(), messages, retryTokens, 0.1); + } + if (answer != null && AiText.hasForeignScript(answer)) { + plugin.getLogger().warning("Resposta descartada por idioma estrangeiro."); + answer = null; + } } } catch (Exception e) { // Redacts the key: the JDK's header validator quotes the whole diff --git a/src/main/java/dev/marcospaulo/canalhandia/MiniMax.java b/src/main/java/dev/marcospaulo/canalhandia/MiniMax.java index cffb24f..d7f1de2 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/MiniMax.java +++ b/src/main/java/dev/marcospaulo/canalhandia/MiniMax.java @@ -171,6 +171,88 @@ final class MiniMax { return content.getAsString(); } + /** Runs a tool the model asked for and returns its result text. */ + @FunctionalInterface + interface ToolExecutor { + String run(String name, String argumentsJson); + } + + /** + * Answers with tools: the model may call the given tools, whose results are + * fed back until it produces a final answer or {@code maxCalls} rounds pass. + * + *

The last round is deliberately sent tool-free, so a model that keeps + * asking for tools instead of answering is still forced to produce prose + * rather than looping forever. Every failure returns null, like {@link + * #answer}, so the caller cannot tell a broken loop from "no answer". + */ + String answerWithTools(String key, String model, List initial, JsonArray tools, + ToolExecutor executor, int maxTokens, double temperature, int maxCalls) { + JsonArray messages = new JsonArray(); + for (Turn turn : initial) { + JsonObject object = new JsonObject(); + object.addProperty("role", turn.role()); + object.addProperty("content", turn.content()); + messages.add(object); + } + for (int round = 0; round <= maxCalls; round++) { + boolean lastRound = round == maxCalls; + JsonObject body = new JsonObject(); + body.addProperty("model", model); + body.add("messages", messages); + body.addProperty("max_tokens", maxTokens); + body.addProperty("temperature", temperature); + if (!lastRound) { + body.add("tools", tools); + body.addProperty("tool_choice", "auto"); + } + JsonObject message = message(post(key, body)); + if (message == null) { + return null; + } + JsonElement calls = message.get("tool_calls"); + boolean hasCalls = calls != null && calls.isJsonArray() && !calls.getAsJsonArray().isEmpty(); + if (lastRound || !hasCalls) { + JsonElement content = message.get("content"); + if (content != null && content.isJsonPrimitive() && !content.getAsString().isBlank()) { + return content.getAsString(); + } + if (lastRound) { + warn.accept("IA: sem resposta após " + maxCalls + " rodadas de ferramenta."); + } + return null; + } + // Append the assistant turn (carrying its tool_calls) verbatim, then + // one tool result per call. Some servers reject a null content on an + // assistant turn, so an empty string stands in. + JsonObject assistant = message.deepCopy(); + if (!assistant.has("content") || assistant.get("content").isJsonNull()) { + assistant.addProperty("content", ""); + } + messages.add(assistant); + for (JsonElement element : calls.getAsJsonArray()) { + JsonObject call = element.getAsJsonObject(); + String id = call.has("id") ? call.get("id").getAsString() : ""; + JsonObject function = call.getAsJsonObject("function"); + String name = function.get("name").getAsString(); + String arguments = function.has("arguments") + ? function.get("arguments").getAsString() : "{}"; + String result; + try { + result = executor.run(name, arguments); + } catch (RuntimeException e) { + result = "erro ao executar " + name + ": " + e.getMessage(); + } + JsonObject toolMessage = new JsonObject(); + toolMessage.addProperty("role", "tool"); + toolMessage.addProperty("tool_call_id", id); + toolMessage.addProperty("content", result == null ? "sem resultado." : result); + messages.add(toolMessage); + } + } + return null; + } + private JsonObject base(String model, List messages, int maxTokens, double temperature) { JsonArray array = new JsonArray(); for (Turn msg : messages) { diff --git a/src/main/java/dev/marcospaulo/canalhandia/Search.java b/src/main/java/dev/marcospaulo/canalhandia/Search.java new file mode 100644 index 0000000..ded9958 --- /dev/null +++ b/src/main/java/dev/marcospaulo/canalhandia/Search.java @@ -0,0 +1,101 @@ +package dev.marcospaulo.canalhandia; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.function.Consumer; + +/** + * Web search through a self-hosted SearXNG instance. + * + *

SearXNG returns JSON when asked ({@code &format=json}), so no scraping and + * no third-party API key: the metasearch runs on the cluster and this only reads + * it. The result is boiled down to a few "título — trecho (url)" lines, small + * enough to hand back to the model as a tool result without blowing the context. + */ +final class Search { + + private final Fetcher fetcher; + private final String baseUrl; + private final int maxResults; + private final int snippetChars; + private final Consumer warn; + + Search(Fetcher fetcher, String baseUrl, int maxResults, int snippetChars, Consumer warn) { + this.fetcher = fetcher; + this.baseUrl = baseUrl == null ? "" : baseUrl.replaceAll("/+$", ""); + this.maxResults = Math.max(1, maxResults); + this.snippetChars = Math.max(80, snippetChars); + this.warn = warn; + } + + /** Runs a web search and returns a compact text digest, or a plain reason it failed. */ + String web(String query) { + if (query == null || query.isBlank()) { + return "consulta vazia."; + } + if (baseUrl.isBlank()) { + return "busca web não configurada (ia.searxng-url)."; + } + try { + String url = baseUrl + "/search?format=json&q=" + + URLEncoder.encode(query.trim(), StandardCharsets.UTF_8); + return format(fetcher.get(url), maxResults, snippetChars); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return "busca interrompida."; + } catch (Exception e) { + warn.accept("IA: busca web falhou: " + e); + return "a busca web falhou."; + } + } + + /** Turns SearXNG JSON into up to {@code max} lines. Pure, so it is testable. */ + static String format(String json, int max, int snippetChars) { + JsonElement root = JsonParser.parseString(json); + JsonArray results = root.isJsonObject() && root.getAsJsonObject().get("results") != null + && root.getAsJsonObject().get("results").isJsonArray() + ? root.getAsJsonObject().getAsJsonArray("results") + : new JsonArray(); + StringBuilder out = new StringBuilder(); + int shown = 0; + for (JsonElement element : results) { + if (shown >= max) { + break; + } + if (!element.isJsonObject()) { + continue; + } + JsonObject result = element.getAsJsonObject(); + String title = string(result, "title"); + String content = string(result, "content"); + String url = string(result, "url"); + if (title.isBlank() && content.isBlank()) { + continue; + } + out.append(++shown).append(". ").append(title); + if (!content.isBlank()) { + out.append(" — ").append(clip(content, snippetChars)); + } + if (!url.isBlank()) { + out.append(" (").append(url).append(')'); + } + out.append('\n'); + } + return shown == 0 ? "nenhum resultado." : out.toString().trim(); + } + + private static String string(JsonObject object, String key) { + JsonElement value = object.get(key); + return value != null && value.isJsonPrimitive() ? value.getAsString().trim() : ""; + } + + private static String clip(String text, int max) { + String flat = text.replaceAll("\\s+", " ").trim(); + return flat.length() <= max ? flat : flat.substring(0, max).trim() + "…"; + } +} diff --git a/src/main/java/dev/marcospaulo/canalhandia/Settings.java b/src/main/java/dev/marcospaulo/canalhandia/Settings.java index 0661cf2..4f80e9c 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Settings.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Settings.java @@ -241,6 +241,31 @@ final class Settings { set("ia.modelo", model); } + /** Whether the AI may call tools (web search, stats, ranking, wiki) on demand. */ + boolean aiTools() { + return plugin.getConfig().getBoolean("ia.ferramentas", true); + } + + /** Max tool rounds per question, so a runaway loop cannot burn the budget. */ + int aiMaxToolCalls() { + return Math.max(1, plugin.getConfig().getInt("ia.max-ferramentas", 4)); + } + + /** The SearXNG base URL for web search, or blank to disable it. */ + String aiSearxngUrl() { + return plugin.getConfig().getString("ia.searxng-url", "http://192.168.1.80:30888"); + } + + /** How many web results to feed back per search. */ + int aiSearchResults() { + return Math.max(1, plugin.getConfig().getInt("ia.resultados-web", 5)); + } + + /** Characters kept from each web result's snippet. */ + int aiSearchSnippet() { + return Math.max(80, plugin.getConfig().getInt("ia.trecho-web", 300)); + } + /** * The system prompt. Keeps answers short enough for chat and in pt-BR, and * tells the model it has no way to act on the server — it cannot run diff --git a/src/main/java/dev/marcospaulo/canalhandia/Tools.java b/src/main/java/dev/marcospaulo/canalhandia/Tools.java new file mode 100644 index 0000000..87fb9cf --- /dev/null +++ b/src/main/java/dev/marcospaulo/canalhandia/Tools.java @@ -0,0 +1,159 @@ +package dev.marcospaulo.canalhandia; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.util.List; +import java.util.UUID; +import java.util.function.Consumer; + +/** + * The tools the AI can call, and the code behind each. + * + *

This is what makes {@code /ia} more than a chatbot: instead of everything + * being pre-stuffed into the prompt, the model decides what it needs and asks + * for it — a web search, a player's stats, a ranking, a wiki article. Every tool + * here is safe to run off the main thread (file reads and HTTP only; no world or + * online-player access), because {@link MiniMax#answerWithTools} drives them from + * the async worker that {@link Ai} already answers on. + * + *

The definitions are written as JSON so they read against the API docs, and + * so adding a tool is one entry here plus one {@code case} in {@link #run}. + */ +final class Tools { + + private static final String DEFINITIONS = """ + [ + {"type":"function","function":{ + "name":"pesquisar_web", + "description":"Pesquisa na web (SearXNG) para fatos atuais ou fora do jogo. Use para notícias, datas, coisas do mundo real.", + "parameters":{"type":"object","properties":{ + "consulta":{"type":"string","description":"O que pesquisar, em poucas palavras."}}, + "required":["consulta"]}}}, + {"type":"function","function":{ + "name":"wiki", + "description":"Lê um artigo da Minecraft Wiki em português. Use para mecânicas, mobs, itens e blocos do jogo.", + "parameters":{"type":"object","properties":{ + "termo":{"type":"string","description":"Termo curto do jogo, ex: Creeper, Netherita."}}, + "required":["termo"]}}}, + {"type":"function","function":{ + "name":"estatisticas_jogador", + "description":"Estatísticas de um jogador do servidor (minérios, tempo, distância, mortes, kills).", + "parameters":{"type":"object","properties":{ + "jogador":{"type":"string","description":"Nome do jogador."}}, + "required":["jogador"]}}}, + {"type":"function","function":{ + "name":"conquistas_jogador", + "description":"Os títulos/conquistas que um jogador já desbloqueou no servidor.", + "parameters":{"type":"object","properties":{ + "jogador":{"type":"string","description":"Nome do jogador."}}, + "required":["jogador"]}}}, + {"type":"function","function":{ + "name":"ranking", + "description":"O placar do servidor para uma métrica. Métricas: mineracao, tempo, distancia, mortes, combate, pesca, pulos.", + "parameters":{"type":"object","properties":{ + "metrica":{"type":"string","description":"Uma das métricas listadas."}}, + "required":["metrica"]}}} + ] + """; + + private static final int RANKING_ROWS = 5; + + private final Canalhandia plugin; + private final Wiki wiki; + private final Search search; + private final Consumer log; + + Tools(Canalhandia plugin, Wiki wiki, Search search, Consumer log) { + this.plugin = plugin; + this.wiki = wiki; + this.search = search; + this.log = log; + } + + /** The tool schema to send in the request. */ + JsonArray definitions() { + return JsonParser.parseString(DEFINITIONS).getAsJsonArray(); + } + + /** Runs a tool the model asked for. Never throws: a failure comes back as text. */ + String run(String name, String argumentsJson) { + JsonObject args; + try { + args = JsonParser.parseString(argumentsJson == null ? "{}" : argumentsJson).getAsJsonObject(); + } catch (RuntimeException malformed) { + return "argumentos inválidos."; + } + log.accept("IA ferramenta: " + name + " " + AiText.forLog(argumentsJson)); + return switch (name) { + case "pesquisar_web" -> search.web(string(args, "consulta")); + case "wiki" -> wikiArticle(string(args, "termo")); + case "estatisticas_jogador" -> playerStats(string(args, "jogador")); + case "conquistas_jogador" -> playerAchievements(string(args, "jogador")); + case "ranking" -> ranking(string(args, "metrica")); + default -> "ferramenta desconhecida: " + name; + }; + } + + private String wikiArticle(String term) { + if (term.isBlank()) { + return "termo vazio."; + } + Wiki.Article article = wiki.lookup(term); + return article == null ? "não achei artigo para '" + term + "'." + : article.title() + ":\n" + article.text(); + } + + private String playerStats(String name) { + OfflineStats.Known who = plugin.offlineStats().resolve(name); + if (who == null) { + return "não conheço nenhum jogador chamado '" + name + "'."; + } + String summary = plugin.offlineStats().summary(UUID.fromString(who.uuid())); + return summary == null ? "ainda não tenho estatísticas de " + who.name() + "." : summary; + } + + private String playerAchievements(String name) { + OfflineStats.Known who = plugin.offlineStats().resolve(name); + if (who == null) { + return "não conheço nenhum jogador chamado '" + name + "'."; + } + var stats = plugin.offlineStats().achievementStats(UUID.fromString(who.uuid())); + List earned = stats == null ? List.of() : Achievement.earned(stats); + if (earned.isEmpty()) { + return who.name() + " ainda não desbloqueou nenhum título."; + } + StringBuilder out = new StringBuilder(who.name() + " (" + earned.size() + "/" + + Achievement.values().length + "): "); + for (int i = 0; i < earned.size(); i++) { + out.append(i == 0 ? "" : ", ").append(earned.get(i).title()); + } + return out.toString(); + } + + private String ranking(String metricKey) { + RankingMetric metric = RankingMetric.byKey(metricKey); + if (metric == null) { + return "métrica desconhecida: '" + metricKey + "'."; + } + List rows = plugin.offlineStats().ranking(metric, RANKING_ROWS); + if (rows.isEmpty()) { + return "sem dados para " + metric.label() + "."; + } + StringBuilder out = new StringBuilder(metric.label() + ": "); + for (int i = 0; i < rows.size(); i++) { + OfflineStats.Row row = rows.get(i); + out.append(i + 1).append(". ").append(row.name()).append(" (") + .append(metric.format(row.value())).append(")"); + if (i < rows.size() - 1) { + out.append(", "); + } + } + return out.toString(); + } + + private static String string(JsonObject args, String key) { + return args.has(key) && args.get(key).isJsonPrimitive() ? args.get(key).getAsString().trim() : ""; + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 52a2983..0f54fab 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -171,9 +171,10 @@ ranking-tamanho: 5 # lp user permission set canalhandia.ia true # lp group permission set canalhandia.ia true # -# A IA só produz texto de chat. A resposta nunca é executada como comando, e -# nenhuma ferramenta é oferecida ao modelo na requisição — ele não tem como -# rodar nada no servidor, no terminal ou no jogo. +# A IA só produz texto de chat: a resposta NUNCA é executada como comando. As +# ferramentas abaixo são todas de LEITURA (busca web, estatísticas, ranking, +# wiki) — o modelo pode consultar informação, mas não muda nada no servidor, +# no mundo ou no terminal. ia: url: "https://api.minimax.io/v1/text/chatcompletion_v2" # M2.7 mediu 2,7-5,2s com respostas corretas nos testes. O M3 é um modelo de @@ -181,6 +182,18 @@ ia: # cortada no meio da palavra, a não ser com um orçamento muito maior. modelo: "MiniMax-M2.7" + # IA agêntica: o modelo decide sozinho quando usar ferramentas (busca web, + # estatísticas de jogador, ranking, Minecraft Wiki) em vez de receber tudo + # pronto no prompt. Deixa as respostas bem mais espertas. + ferramentas: true + # Máximo de rodadas de ferramenta por pergunta (trava anti-loop). + max-ferramentas: 4 + # Busca web via SearXNG (self-hosted, sem chave de API externa). URL do serviço. + searxng-url: "http://192.168.1.80:30888" + # Quantos resultados de busca web devolver, e quanto de cada trecho manter. + resultados-web: 5 + trecho-web: 300 + # Tamanho da resposta pedida ao modelo, e o corte final no chat. # 1200, não 300: o raciocínio oculto do M3 consome o orçamento e a resposta # chega vazia quando o teto é baixo. diff --git a/src/test/java/dev/marcospaulo/canalhandia/SearchTest.java b/src/test/java/dev/marcospaulo/canalhandia/SearchTest.java new file mode 100644 index 0000000..e03d147 --- /dev/null +++ b/src/test/java/dev/marcospaulo/canalhandia/SearchTest.java @@ -0,0 +1,41 @@ +package dev.marcospaulo.canalhandia; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** The pure SearXNG JSON → text digest. */ +class SearchTest { + + @Test + void formatsAndCapsResults() { + String json = """ + {"query":"x","results":[ + {"title":"Netherite - Wiki","content":"Material do Nether para melhorar equipamento de diamante.","url":"https://a"}, + {"title":"B","content":"segundo","url":"https://b"}, + {"title":"C","content":"terceiro","url":"https://c"} + ]}"""; + String out = Search.format(json, 2, 300); + assertTrue(out.contains("1. Netherite - Wiki")); + assertTrue(out.contains("https://a")); + assertTrue(out.contains("2. B")); + assertFalse(out.contains("3. C"), "should cap at max results"); + } + + @Test + void handlesEmptyResults() { + assertEquals("nenhum resultado.", Search.format("{\"results\":[]}", 5, 300)); + assertEquals("nenhum resultado.", Search.format("{}", 5, 300)); + } + + @Test + void clipsLongSnippets() { + String longContent = "a".repeat(500); + String json = "{\"results\":[{\"title\":\"T\",\"content\":\"" + longContent + "\",\"url\":\"u\"}]}"; + String out = Search.format(json, 5, 100); + assertTrue(out.contains("…"), "a long snippet should be clipped"); + assertTrue(out.length() < 200, "clip should bound the line length"); + } +}