feat(ia): agentic tool-calling — web search, stats, ranking, wiki
Turn /ia from a fixed-prompt chatbot into an agent that pulls what it needs. Instead of one hardcoded wiki pre-fetch, the model is offered read-only tools and MiniMax's tool_choice=auto lets it decide which to call; results are fed back until it answers (MiniMax.answerWithTools), capped by ia.max-ferramentas. - Search: web search via the cluster's self-hosted SearXNG (JSON API, no external key); results boiled down to a few "título — trecho (url)" lines. - Tools: registry + dispatch for pesquisar_web, wiki, estatisticas_jogador, conquistas_jogador, ranking. All read-only and thread-safe off the main thread, so they run on the existing async answer worker. - Ai.ask: agentic path when ia.ferramentas is on (default), else the previous behaviour untouched. - Config: ia.ferramentas, ia.max-ferramentas, ia.searxng-url, ia.resultados-web, ia.trecho-web; getters in Settings. Reloadable via /canalhandia reload. Verified live against MiniMax-M2.7 + SearXNG: the model auto-calls the tool and answers from the result. SearchTest covers the pure result formatter. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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() : "";
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user