i18n: per-player EN/PT via Adventure GlobalTranslator #1

Merged
masi merged 20 commits from feat/ia-grounding into main 2026-08-12 15:53:28 +00:00
7 changed files with 466 additions and 26 deletions
Showing only changes of commit f1d210ddc4 - Show all commits
@@ -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,6 +268,19 @@ final class Ai {
java.util.List<MiniMax.Turn> messages =
compose(asker, prompt, settings, liveState, chatContext);
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);
@@ -290,6 +308,7 @@ final class Ai {
plugin.getLogger().warning("Resposta descartada por idioma estrangeiro.");
answer = null;
}
}
} catch (Exception e) {
// Redacts the key: the JDK's header validator quotes the whole
// Authorization value back into the exception message, so a key
@@ -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.
*
* <p>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<Turn> 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<Turn> messages, int maxTokens, double temperature) {
JsonArray array = new JsonArray();
for (Turn msg : messages) {
@@ -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.
*
* <p>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<String> warn;
Search(Fetcher fetcher, String baseUrl, int maxResults, int snippetChars, Consumer<String> 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() + "";
}
}
@@ -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
@@ -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.
*
* <p>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.
*
* <p>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<String> log;
Tools(Canalhandia plugin, Wiki wiki, Search search, Consumer<String> 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<Achievement> 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<OfflineStats.Row> 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() : "";
}
}
+16 -3
View File
@@ -171,9 +171,10 @@ ranking-tamanho: 5
# lp user <nome> permission set canalhandia.ia true
# lp group <grupo> 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.
@@ -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");
}
}