i18n: per-player EN/PT via Adventure GlobalTranslator #1
@@ -43,6 +43,7 @@ final class Ai {
|
|||||||
private final Canalhandia plugin;
|
private final Canalhandia plugin;
|
||||||
private final MiniMax api;
|
private final MiniMax api;
|
||||||
private final Wiki wiki;
|
private final Wiki wiki;
|
||||||
|
private final Tools tools;
|
||||||
private final Conversations conversations;
|
private final Conversations conversations;
|
||||||
private final Corrections corrections;
|
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. */
|
||||||
@@ -107,6 +108,10 @@ final class Ai {
|
|||||||
// 3-arg ctor + logger (carry-forward #1): the 2-arg ctor is silent in
|
// 3-arg ctor + logger (carry-forward #1): the 2-arg ctor is silent in
|
||||||
// production, so every wiki failure here is logged.
|
// production, so every wiki failure here is logged.
|
||||||
this.wiki = new Wiki(fetcher, settings.aiWikiChars(), plugin.getLogger()::warning);
|
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.conversations = new Conversations(settings.aiMemoryExchanges(), settings.aiMemoryMinutes());
|
||||||
this.corrections = new Corrections(new java.io.File(plugin.getDataFolder(), "correcoes.yml"));
|
this.corrections = new Corrections(new java.io.File(plugin.getDataFolder(), "correcoes.yml"));
|
||||||
// Note: aiUrl(), aiWikiChars(), aiMemoryExchanges() and aiMemoryMinutes()
|
// Note: aiUrl(), aiWikiChars(), aiMemoryExchanges() and aiMemoryMinutes()
|
||||||
@@ -263,32 +268,46 @@ final class Ai {
|
|||||||
java.util.List<MiniMax.Turn> messages =
|
java.util.List<MiniMax.Turn> messages =
|
||||||
compose(asker, prompt, settings, liveState, chatContext);
|
compose(asker, prompt, settings, liveState, chatContext);
|
||||||
|
|
||||||
if (settings.aiProfile() == AiProfile.PRECISO) {
|
if (settings.aiTools()) {
|
||||||
String term = api.searchTerm(key, settings.aiModel(), prompt);
|
// Agentic path: the model pulls what it needs (web search,
|
||||||
Wiki.Article article = term == null ? null : wiki.lookup(term);
|
// stats, ranking, wiki) via tools instead of a single fixed
|
||||||
if (article != null) {
|
// pre-fetch. The tools run on this same async worker.
|
||||||
messages.add(messages.size() - 1, new MiniMax.Turn("system",
|
answer = api.answerWithTools(key, settings.aiModel(), messages,
|
||||||
"Artigo da Minecraft Wiki pt-BR — '" + article.title() + "':\n"
|
tools.definitions(), tools::run,
|
||||||
+ article.text()));
|
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,
|
answer = api.answer(key, settings.aiModel(), messages,
|
||||||
settings.aiMaxTokens(), settings.aiTemperature());
|
settings.aiMaxTokens(), settings.aiTemperature());
|
||||||
|
|
||||||
// Hidden reasoning can swallow the budget, and the model
|
// Hidden reasoning can swallow the budget, and the model
|
||||||
// occasionally drops a foreign word mid-sentence. Both are
|
// occasionally drops a foreign word mid-sentence. Both are
|
||||||
// worth one retry before giving up (carry-forwards #3 and #7).
|
// worth one retry before giving up (carry-forwards #3 and #7).
|
||||||
if (answer == null || AiText.hasForeignScript(answer)) {
|
if (answer == null || AiText.hasForeignScript(answer)) {
|
||||||
// Cap before doubling: an absurd ia.max-tokens near
|
// Cap before doubling: an absurd ia.max-tokens near
|
||||||
// Integer.MAX_VALUE would overflow to a negative budget
|
// Integer.MAX_VALUE would overflow to a negative budget
|
||||||
// and be sent to the API. The default (1200) is unaffected.
|
// and be sent to the API. The default (1200) is unaffected.
|
||||||
int retryTokens = Math.min(settings.aiMaxTokens(), Integer.MAX_VALUE / 2) * 2;
|
int retryTokens = Math.min(settings.aiMaxTokens(), Integer.MAX_VALUE / 2) * 2;
|
||||||
answer = api.answer(key, settings.aiModel(), messages, retryTokens, 0.1);
|
answer = api.answer(key, settings.aiModel(), messages, retryTokens, 0.1);
|
||||||
}
|
}
|
||||||
if (answer != null && AiText.hasForeignScript(answer)) {
|
if (answer != null && AiText.hasForeignScript(answer)) {
|
||||||
plugin.getLogger().warning("Resposta descartada por idioma estrangeiro.");
|
plugin.getLogger().warning("Resposta descartada por idioma estrangeiro.");
|
||||||
answer = null;
|
answer = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
// Redacts the key: the JDK's header validator quotes the whole
|
// Redacts the key: the JDK's header validator quotes the whole
|
||||||
|
|||||||
@@ -171,6 +171,88 @@ final class MiniMax {
|
|||||||
return content.getAsString();
|
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) {
|
private JsonObject base(String model, List<Turn> messages, int maxTokens, double temperature) {
|
||||||
JsonArray array = new JsonArray();
|
JsonArray array = new JsonArray();
|
||||||
for (Turn msg : messages) {
|
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);
|
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
|
* 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
|
* 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() : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -171,9 +171,10 @@ ranking-tamanho: 5
|
|||||||
# lp user <nome> permission set canalhandia.ia true
|
# lp user <nome> permission set canalhandia.ia true
|
||||||
# lp group <grupo> 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
|
# A IA só produz texto de chat: a resposta NUNCA é executada como comando. As
|
||||||
# nenhuma ferramenta é oferecida ao modelo na requisição — ele não tem como
|
# ferramentas abaixo são todas de LEITURA (busca web, estatísticas, ranking,
|
||||||
# rodar nada no servidor, no terminal ou no jogo.
|
# wiki) — o modelo pode consultar informação, mas não muda nada no servidor,
|
||||||
|
# no mundo ou no terminal.
|
||||||
ia:
|
ia:
|
||||||
url: "https://api.minimax.io/v1/text/chatcompletion_v2"
|
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
|
# 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.
|
# cortada no meio da palavra, a não ser com um orçamento muito maior.
|
||||||
modelo: "MiniMax-M2.7"
|
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.
|
# 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
|
# 1200, não 300: o raciocínio oculto do M3 consome o orçamento e a resposta
|
||||||
# chega vazia quando o teto é baixo.
|
# 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user