feat: ground answers in the wiki, recipes, corrections and memory
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,25 +1,17 @@
|
|||||||
package dev.marcospaulo.canalhandia;
|
package dev.marcospaulo.canalhandia;
|
||||||
|
|
||||||
import com.google.gson.JsonArray;
|
|
||||||
import com.google.gson.JsonObject;
|
|
||||||
import com.google.gson.JsonParser;
|
|
||||||
import net.kyori.adventure.text.Component;
|
import net.kyori.adventure.text.Component;
|
||||||
import net.kyori.adventure.text.format.NamedTextColor;
|
import net.kyori.adventure.text.format.NamedTextColor;
|
||||||
import net.kyori.adventure.text.format.TextDecoration;
|
import net.kyori.adventure.text.format.TextDecoration;
|
||||||
import org.bukkit.Bukkit;
|
import org.bukkit.Bukkit;
|
||||||
import org.bukkit.entity.Player;
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.net.URI;
|
|
||||||
import java.net.http.HttpClient;
|
|
||||||
import java.net.http.HttpRequest;
|
|
||||||
import java.net.http.HttpResponse;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.time.Duration;
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -38,6 +30,11 @@ import java.util.UUID;
|
|||||||
* <p>The API key never lives in config.yml, because config.yml is committed to
|
* <p>The API key never lives in config.yml, because config.yml is committed to
|
||||||
* git. It comes from the {@code MINIMAX_API_KEY} environment variable, or from
|
* git. It comes from the {@code MINIMAX_API_KEY} environment variable, or from
|
||||||
* {@code plugins/Canalhandia/minimax.key}, which is gitignored.
|
* {@code plugins/Canalhandia/minimax.key}, which is gitignored.
|
||||||
|
*
|
||||||
|
* <p>This class is an <em>orchestrator</em>: it assembles context (server facts,
|
||||||
|
* operator corrections, recipes, conversation memory, a wiki article) into a
|
||||||
|
* list of {@link MiniMax.Turn}s and delegates the HTTP to {@link MiniMax}. It
|
||||||
|
* holds no {@code HttpClient} of its own.
|
||||||
*/
|
*/
|
||||||
final class Ai {
|
final class Ai {
|
||||||
|
|
||||||
@@ -45,7 +42,10 @@ final class Ai {
|
|||||||
private static final String KEY_ENV = "MINIMAX_API_KEY";
|
private static final String KEY_ENV = "MINIMAX_API_KEY";
|
||||||
|
|
||||||
private final Canalhandia plugin;
|
private final Canalhandia plugin;
|
||||||
private final HttpClient http;
|
private final MiniMax api;
|
||||||
|
private final Wiki wiki;
|
||||||
|
private final Conversations conversations;
|
||||||
|
private final Corrections corrections;
|
||||||
/** Per-player cooldown, so one person cannot spend the whole budget. */
|
/** Per-player cooldown, so one person cannot spend the whole budget. */
|
||||||
private final Map<UUID, Long> lastAsk = new HashMap<>();
|
private final Map<UUID, Long> lastAsk = new HashMap<>();
|
||||||
/** In-flight guard: one question per player at a time. */
|
/** In-flight guard: one question per player at a time. */
|
||||||
@@ -54,11 +54,44 @@ final class Ai {
|
|||||||
private LocalDate day = LocalDate.now();
|
private LocalDate day = LocalDate.now();
|
||||||
private int askedToday;
|
private int askedToday;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The most recent answered question, so {@code /ia corrigir} (Task 11) can
|
||||||
|
* correct it without the operator retyping anything.
|
||||||
|
*/
|
||||||
|
private Answered lastAnswer;
|
||||||
|
|
||||||
|
record Answered(UUID asker, String question, String answer) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Package-private so Task 11's {@code /ia corrigir} can read what to correct. */
|
||||||
|
Answered lastAnswer() {
|
||||||
|
return lastAnswer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Package-private so Task 11 can wire quit-forget. */
|
||||||
|
Conversations conversations() {
|
||||||
|
return conversations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Package-private so Task 11 can wire {@code /ia corrigir}. */
|
||||||
|
Corrections corrections() {
|
||||||
|
return corrections;
|
||||||
|
}
|
||||||
|
|
||||||
Ai(Canalhandia plugin) {
|
Ai(Canalhandia plugin) {
|
||||||
this.plugin = plugin;
|
this.plugin = plugin;
|
||||||
this.http = HttpClient.newBuilder()
|
Settings settings = plugin.settings();
|
||||||
.connectTimeout(Duration.ofSeconds(10))
|
Fetcher fetcher = new HttpFetcher(settings.aiTimeoutSeconds());
|
||||||
.build();
|
this.api = new MiniMax(fetcher, settings.aiUrl(), plugin.getLogger()::warning);
|
||||||
|
// 3-arg ctor + logger (carry-forward #1): the 2-arg ctor is silent in
|
||||||
|
// production, so every wiki failure here is logged.
|
||||||
|
this.wiki = new Wiki(fetcher, settings.aiWikiChars(), plugin.getLogger()::warning);
|
||||||
|
this.conversations = new Conversations(settings.aiMemoryExchanges(), settings.aiMemoryMinutes());
|
||||||
|
this.corrections = new Corrections(new java.io.File(plugin.getDataFolder(), "correcoes.yml"));
|
||||||
|
// Note: aiUrl(), aiWikiChars(), aiMemoryExchanges() and aiMemoryMinutes()
|
||||||
|
// are baked here at construction. Only aiProfile() is read live in the
|
||||||
|
// async body, which is correct — operators switch profiles live without
|
||||||
|
// a restart, and the other settings are not meant to be hot-swapped.
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True if a key is configured. Without one the module stays quiet. */
|
/** True if a key is configured. Without one the module stays quiet. */
|
||||||
@@ -125,7 +158,7 @@ final class Ai {
|
|||||||
if (Files.isReadable(file)) {
|
if (Files.isReadable(file)) {
|
||||||
return cleanKey(Files.readString(file, StandardCharsets.UTF_8));
|
return cleanKey(Files.readString(file, StandardCharsets.UTF_8));
|
||||||
}
|
}
|
||||||
} catch (IOException e) {
|
} catch (java.io.IOException e) {
|
||||||
plugin.getLogger().warning("Não consegui ler " + KEY_FILE + ": " + e.getMessage());
|
plugin.getLogger().warning("Não consegui ler " + KEY_FILE + ": " + e.getMessage());
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -138,6 +171,17 @@ final class Ai {
|
|||||||
* reply is posted back on it.
|
* reply is posted back on it.
|
||||||
*/
|
*/
|
||||||
void ask(Player asker, String question) {
|
void ask(Player asker, String question) {
|
||||||
|
ask(asker, question, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks the model on behalf of a player and delivers the answer to chat.
|
||||||
|
*
|
||||||
|
* @param isPrivate true for {@code /iap} (Task 11): the question and answer
|
||||||
|
* go only to the asker even when the module is public. A private
|
||||||
|
* question never broadcasts the question either.
|
||||||
|
*/
|
||||||
|
void ask(Player asker, String question, boolean isPrivate) {
|
||||||
Settings settings = plugin.settings();
|
Settings settings = plugin.settings();
|
||||||
|
|
||||||
String key = apiKey();
|
String key = apiKey();
|
||||||
@@ -169,7 +213,7 @@ final class Ai {
|
|||||||
pending.put(asker.getUniqueId(), true);
|
pending.put(asker.getUniqueId(), true);
|
||||||
askedToday++;
|
askedToday++;
|
||||||
|
|
||||||
if (settings.aiPublic()) {
|
if (!isPrivate && settings.aiPublic()) {
|
||||||
Bukkit.broadcast(Msg.tag("IA", NamedTextColor.LIGHT_PURPLE)
|
Bukkit.broadcast(Msg.tag("IA", NamedTextColor.LIGHT_PURPLE)
|
||||||
.append(Component.text(asker.getName() + " perguntou: ", NamedTextColor.GRAY)
|
.append(Component.text(asker.getName() + " perguntou: ", NamedTextColor.GRAY)
|
||||||
.decoration(TextDecoration.BOLD, false))
|
.decoration(TextDecoration.BOLD, false))
|
||||||
@@ -180,27 +224,91 @@ final class Ai {
|
|||||||
|
|
||||||
String prompt = question;
|
String prompt = question;
|
||||||
UUID id = asker.getUniqueId();
|
UUID id = asker.getUniqueId();
|
||||||
|
final boolean isPriv = isPrivate;
|
||||||
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||||
String answer;
|
String answer = null;
|
||||||
try {
|
try {
|
||||||
answer = call(key, prompt, settings);
|
java.util.List<MiniMax.Turn> messages = compose(asker, prompt, settings);
|
||||||
|
|
||||||
|
if (settings.aiProfile() == AiProfile.PRECISO) {
|
||||||
|
String term = api.searchTerm(key, settings.aiModel(), prompt);
|
||||||
|
Wiki.Article article = term == null ? null : wiki.lookup(term);
|
||||||
|
if (article != null) {
|
||||||
|
messages.add(messages.size() - 1, new MiniMax.Turn("system",
|
||||||
|
"Artigo da Minecraft Wiki pt-BR — '" + article.title() + "':\n"
|
||||||
|
+ article.text()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
answer = api.answer(key, settings.aiModel(), messages,
|
||||||
|
settings.aiMaxTokens(), settings.aiTemperature());
|
||||||
|
|
||||||
|
// Hidden reasoning can swallow the budget, and the model
|
||||||
|
// occasionally drops a foreign word mid-sentence. Both are
|
||||||
|
// worth one retry before giving up (carry-forwards #3 and #7).
|
||||||
|
if (answer == null || AiText.hasForeignScript(answer)) {
|
||||||
|
answer = api.answer(key, settings.aiModel(), messages,
|
||||||
|
settings.aiMaxTokens() * 2, 0.1);
|
||||||
|
}
|
||||||
|
if (answer != null && AiText.hasForeignScript(answer)) {
|
||||||
|
plugin.getLogger().warning("Resposta descartada por idioma estrangeiro.");
|
||||||
|
answer = null;
|
||||||
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
// Redacted: this catches everything call() can throw, including
|
// Redacts the key: the JDK's header validator quotes the whole
|
||||||
// the JDK's header validation, whose message quotes the whole
|
// Authorization value back into the exception message, so a key
|
||||||
// Authorization value back. cleanKey should make that
|
// with a stray newline would otherwise reach the log verbatim.
|
||||||
// impossible; this is the backstop if it ever does not.
|
// cleanKey makes that impossible; this is the backstop.
|
||||||
warnWithout(key, "Falha na chamada à IA: " + e);
|
warnWithout(key, "Falha na chamada à IA: " + e);
|
||||||
answer = null;
|
answer = null;
|
||||||
}
|
}
|
||||||
String finalAnswer = answer;
|
String finalAnswer = answer;
|
||||||
Bukkit.getScheduler().runTask(plugin, () -> {
|
Bukkit.getScheduler().runTask(plugin, () -> {
|
||||||
pending.remove(id);
|
pending.remove(id);
|
||||||
deliver(id, finalAnswer, settings);
|
deliver(id, prompt, finalAnswer, settings, isPriv);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private void deliver(UUID askerId, String answer, Settings settings) {
|
/**
|
||||||
|
* Builds the messages for one question.
|
||||||
|
*
|
||||||
|
* <p>Order matters: system instructions, server context, operator
|
||||||
|
* corrections, recipes (which the wiki cannot supply — {@code explaintext}
|
||||||
|
* drops tables), the conversation history, then the question itself.
|
||||||
|
*/
|
||||||
|
private java.util.List<MiniMax.Turn> compose(Player asker, String question, Settings settings) {
|
||||||
|
java.util.List<MiniMax.Turn> messages = new java.util.ArrayList<>();
|
||||||
|
messages.add(new MiniMax.Turn("system", settings.aiInstructions()));
|
||||||
|
|
||||||
|
String serverContext = settings.aiServerContext();
|
||||||
|
if (!serverContext.isBlank()) {
|
||||||
|
messages.add(new MiniMax.Turn("system", "Sobre este servidor: " + serverContext));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (Corrections.Entry entry : Corrections.matching(corrections.all(), question)) {
|
||||||
|
messages.add(new MiniMax.Turn("system",
|
||||||
|
"Correção registrada por um operador. Pergunta parecida: \""
|
||||||
|
+ entry.question() + "\" Resposta correta: " + entry.answer()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recipes never appear in wiki text: explaintext drops tables. The wiki
|
||||||
|
// is consulted here only for the englishTitle translation; the recipe
|
||||||
|
// data comes from the Bukkit snapshot taken at enable (RecipeBook.preload).
|
||||||
|
if (RecipeBook.isRecipeQuestion(question)) {
|
||||||
|
String recipes = RecipeBook.describe(question, wiki);
|
||||||
|
if (recipes != null) {
|
||||||
|
messages.add(new MiniMax.Turn("system", recipes));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.addAll(conversations.history(asker.getUniqueId()));
|
||||||
|
messages.add(new MiniMax.Turn("user", question));
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void deliver(UUID askerId, String question, String answer,
|
||||||
|
Settings settings, boolean isPrivate) {
|
||||||
Player asker = Bukkit.getPlayer(askerId);
|
Player asker = Bukkit.getPlayer(askerId);
|
||||||
if (answer == null || answer.isBlank()) {
|
if (answer == null || answer.isBlank()) {
|
||||||
if (asker != null) {
|
if (asker != null) {
|
||||||
@@ -208,90 +316,21 @@ final class Ai {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
String clean = AiText.sanitise(answer, settings.aiMaxAnswer());
|
||||||
|
conversations.remember(askerId, question, clean);
|
||||||
|
lastAnswer = new Answered(askerId, question, clean);
|
||||||
|
|
||||||
Component message = Msg.tag("IA", NamedTextColor.LIGHT_PURPLE)
|
Component message = Msg.tag("IA", NamedTextColor.LIGHT_PURPLE)
|
||||||
.append(Component.text(AiText.sanitise(answer, settings.aiMaxAnswer()), NamedTextColor.WHITE)
|
.append(Component.text(clean, NamedTextColor.WHITE)
|
||||||
.decoration(TextDecoration.BOLD, false));
|
.decoration(TextDecoration.BOLD, false));
|
||||||
if (settings.aiPublic()) {
|
if (isPrivate || !settings.aiPublic()) {
|
||||||
Bukkit.broadcast(message);
|
if (asker != null) {
|
||||||
} else if (asker != null) {
|
asker.sendMessage(message);
|
||||||
asker.sendMessage(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- HTTP ---------------------------------------------------------------
|
|
||||||
|
|
||||||
private String call(String key, String question, Settings settings) throws Exception {
|
|
||||||
JsonArray messages = new JsonArray();
|
|
||||||
messages.add(message("system", settings.aiInstructions()));
|
|
||||||
messages.add(message("user", question));
|
|
||||||
|
|
||||||
JsonObject body = new JsonObject();
|
|
||||||
body.addProperty("model", settings.aiModel());
|
|
||||||
body.add("messages", messages);
|
|
||||||
body.addProperty("max_tokens", settings.aiMaxTokens());
|
|
||||||
body.addProperty("temperature", settings.aiTemperature());
|
|
||||||
// No "tools" and no "tool_choice": the model is given nothing it could call.
|
|
||||||
|
|
||||||
// Rejects a malformed key before the JDK's own validator can quote it
|
|
||||||
// back into an exception message. Task 10 retires this whole method in
|
|
||||||
// favour of MiniMax, which reaches the same check through HttpFetcher.
|
|
||||||
HttpFetcher.checkBearer(key);
|
|
||||||
|
|
||||||
HttpRequest request = HttpRequest.newBuilder(URI.create(settings.aiUrl()))
|
|
||||||
.timeout(Duration.ofSeconds(settings.aiTimeoutSeconds()))
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.header("Authorization", "Bearer " + key)
|
|
||||||
.POST(HttpRequest.BodyPublishers.ofString(body.toString(), StandardCharsets.UTF_8))
|
|
||||||
.build();
|
|
||||||
|
|
||||||
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
|
|
||||||
if (response.statusCode() / 100 != 2) {
|
|
||||||
plugin.getLogger().warning("IA respondeu HTTP " + response.statusCode() + ": "
|
|
||||||
+ AiText.forLog(response.body()));
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return extract(response.body());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static JsonObject message(String role, String content) {
|
|
||||||
JsonObject object = new JsonObject();
|
|
||||||
object.addProperty("role", role);
|
|
||||||
object.addProperty("content", content);
|
|
||||||
return object;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pulls the reply text out of an OpenAI-shaped response.
|
|
||||||
*
|
|
||||||
* <p>MiniMax returns HTTP 200 even for application-level failures, putting
|
|
||||||
* the real outcome in {@code base_resp.status_code}, so that is checked too.
|
|
||||||
*/
|
|
||||||
private String extract(String json) {
|
|
||||||
JsonObject root = JsonParser.parseString(json).getAsJsonObject();
|
|
||||||
if (root.has("base_resp")) {
|
|
||||||
JsonObject base = root.getAsJsonObject("base_resp");
|
|
||||||
int status = base.has("status_code") ? base.get("status_code").getAsInt() : 0;
|
|
||||||
if (status != 0) {
|
|
||||||
plugin.getLogger().warning("IA recusou: base_resp " + base);
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (!root.has("choices")) {
|
Bukkit.broadcast(message);
|
||||||
return null;
|
plugin.openAiReactions(askerId);
|
||||||
}
|
|
||||||
JsonArray choices = root.getAsJsonArray("choices");
|
|
||||||
if (choices.isEmpty()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
JsonObject first = choices.get(0).getAsJsonObject();
|
|
||||||
if (!first.has("message")) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
JsonObject message = first.getAsJsonObject("message");
|
|
||||||
// Reasoning models also return "reasoning_content"; only "content" is shown.
|
|
||||||
return message.has("content") && !message.get("content").isJsonNull()
|
|
||||||
? message.get("content").getAsString()
|
|
||||||
: null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- limits and cleanup -------------------------------------------------
|
// --- limits and cleanup -------------------------------------------------
|
||||||
|
|||||||
@@ -37,8 +37,18 @@ final class AiText {
|
|||||||
* Makes a reply safe and readable in chat: no colour codes to forge server
|
* Makes a reply safe and readable in chat: no colour codes to forge server
|
||||||
* messages, no markdown or emoji (chat renders neither, and emoji are empty
|
* messages, no markdown or emoji (chat renders neither, and emoji are empty
|
||||||
* boxes on Bedrock), no leading slash that could read as a command.
|
* boxes on Bedrock), no leading slash that could read as a command.
|
||||||
|
*
|
||||||
|
* <p>{@code null} returns {@code ""}: {@code Ai.deliver} never calls here
|
||||||
|
* with a null, but hardening is cheap and keeps the contract total.
|
||||||
|
*
|
||||||
|
* <p>Truncation backs off one char when it lands on a high surrogate, so the
|
||||||
|
* result is never left with an orphan surrogate that would render as a
|
||||||
|
* replacement box.
|
||||||
*/
|
*/
|
||||||
static String sanitise(String raw, int max) {
|
static String sanitise(String raw, int max) {
|
||||||
|
if (raw == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
String text = raw
|
String text = raw
|
||||||
// A colour code is the section sign plus the code character, so both
|
// A colour code is the section sign plus the code character, so both
|
||||||
// go. Dropping only the sign would leave the bare letter behind and
|
// go. Dropping only the sign would leave the bare letter behind and
|
||||||
@@ -46,8 +56,13 @@ final class AiText {
|
|||||||
.replaceAll("§[0-9A-Za-z]", " ")
|
.replaceAll("§[0-9A-Za-z]", " ")
|
||||||
.replace('§', ' ')
|
.replace('§', ' ')
|
||||||
.replaceAll("[\\r\\n]+", " ")
|
.replaceAll("[\\r\\n]+", " ")
|
||||||
.replaceAll("\\*{1,3}([^*]+)\\*{1,3}", "$1")
|
// Markdown emphasis/inline-code: only strip the markers when they
|
||||||
.replaceAll("`{1,3}([^`]+)`{1,3}", "$1")
|
// flank non-space text. A lone "*" surrounded by spaces is not
|
||||||
|
// emphasis (chat uses it as a bullet), so the regex requires a
|
||||||
|
// non-space right after the opening markers and before the
|
||||||
|
// closing ones. "{1,3}" keeps it handling *, ** and ***.
|
||||||
|
.replaceAll("(?s)\\*{1,3}(?!\\s)(.+?)(?<!\\s)\\*{1,3}", "$1")
|
||||||
|
.replaceAll("(?s)`{1,3}(?!\\s)(.+?)(?<!\\s)`{1,3}", "$1")
|
||||||
.replaceAll("^#{1,6}\\s+", "")
|
.replaceAll("^#{1,6}\\s+", "")
|
||||||
.replaceAll("[\\x{1F000}-\\x{1FAFF}\\x{2190}-\\x{2BFF}\\x{FE0F}\\x{20E3}]", "")
|
.replaceAll("[\\x{1F000}-\\x{1FAFF}\\x{2190}-\\x{2BFF}\\x{FE0F}\\x{20E3}]", "")
|
||||||
.replaceAll("\\s{2,}", " ")
|
.replaceAll("\\s{2,}", " ")
|
||||||
@@ -56,7 +71,14 @@ final class AiText {
|
|||||||
text = text.substring(1).trim();
|
text = text.substring(1).trim();
|
||||||
}
|
}
|
||||||
if (text.length() > max) {
|
if (text.length() > max) {
|
||||||
text = text.substring(0, max).trim() + "…";
|
int cut = max;
|
||||||
|
// Back off one char if we sliced a high surrogate off its low half,
|
||||||
|
// otherwise the String is left with an orphan surrogate that
|
||||||
|
// renders as a replacement character.
|
||||||
|
if (cut > 0 && Character.isHighSurrogate(text.charAt(cut - 1))) {
|
||||||
|
cut--;
|
||||||
|
}
|
||||||
|
text = text.substring(0, cut).trim() + "…";
|
||||||
}
|
}
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,11 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
|||||||
offlineStats = new OfflineStats(this);
|
offlineStats = new OfflineStats(this);
|
||||||
milestones = new Milestones(this);
|
milestones = new Milestones(this);
|
||||||
ai = new Ai(this);
|
ai = new Ai(this);
|
||||||
|
// Snapshot the server's recipes on the main thread; RecipeBook.describe
|
||||||
|
// reads from the async answer path and Bukkit.recipeIterator() is not
|
||||||
|
// safe off the main thread. Datapack reloads after this are not
|
||||||
|
// re-snapshotted.
|
||||||
|
RecipeBook.preload();
|
||||||
optOutKey = new NamespacedKey(this, "opt_out");
|
optOutKey = new NamespacedKey(this, "opt_out");
|
||||||
|
|
||||||
CanalhandiaCommand root = new CanalhandiaCommand(this);
|
CanalhandiaCommand root = new CanalhandiaCommand(this);
|
||||||
@@ -299,6 +304,19 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
|||||||
return reactions;
|
return reactions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the reaction row on a public AI answer. Minimal for now: reuses
|
||||||
|
* the shared {@link #openReactions()} machinery. Task 11 wires the
|
||||||
|
* asker-gated feedback path and the typed {@code /reagir} twin.
|
||||||
|
*/
|
||||||
|
void openAiReactions(UUID askerId) {
|
||||||
|
// TODO Task 11: asker-gated feedback and the typed /reagir twin use askerId.
|
||||||
|
if (!settings.reactionsEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openReactions();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The newest reaction set still accepting clicks, for typed shortcuts like
|
* The newest reaction set still accepting clicks, for typed shortcuts like
|
||||||
* {@code /wow} where the player never sees an id.
|
* {@code /wow} where the player never sees an id.
|
||||||
@@ -475,6 +493,17 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
|||||||
}, settings.reactionWindowSeconds() * 20L);
|
}, settings.reactionWindowSeconds() * 20L);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops a player's short-term AI memory on quit, so a rejoin does not
|
||||||
|
* answer a fresh question with an old one (carry-forward #6).
|
||||||
|
*/
|
||||||
|
@EventHandler
|
||||||
|
public void onQuit(org.bukkit.event.player.PlayerQuitEvent event) {
|
||||||
|
if (ai != null) {
|
||||||
|
ai.conversations().forget(event.getPlayer().getUniqueId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- per-player opt out -------------------------------------------------
|
// --- per-player opt out -------------------------------------------------
|
||||||
|
|
||||||
boolean isOptedOut(Player player) {
|
boolean isOptedOut(Player player) {
|
||||||
|
|||||||
@@ -356,10 +356,30 @@ final class RecipeBook {
|
|||||||
return craftingFor(material);
|
return craftingFor(material);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reads the server's registry. Requires a running server. */
|
/**
|
||||||
|
* Snapshot of the server's recipes, taken once on the main thread at
|
||||||
|
* enable. {@link Bukkit#recipeIterator()} is documented main-thread-only
|
||||||
|
* and has been observed misbehaving off it, so {@link #describe} iterates
|
||||||
|
* this copy instead. Datapack reloads after enable are not re-snapshotted.
|
||||||
|
*/
|
||||||
|
private static volatile List<Recipe> recipes = List.of();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshot the server's recipes on the main thread (at enable).
|
||||||
|
*/
|
||||||
|
static void preload() {
|
||||||
|
List<Recipe> snapshot = new ArrayList<>();
|
||||||
|
Iterator<Recipe> it = Bukkit.recipeIterator();
|
||||||
|
while (it.hasNext()) {
|
||||||
|
snapshot.add(it.next());
|
||||||
|
}
|
||||||
|
recipes = List.copyOf(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads the snapshot taken at enable. */
|
||||||
private static String craftingFor(Material material) {
|
private static String craftingFor(Material material) {
|
||||||
List<String> lines = new ArrayList<>();
|
List<String> lines = new ArrayList<>();
|
||||||
Iterator<Recipe> it = Bukkit.recipeIterator();
|
Iterator<Recipe> it = recipes.iterator();
|
||||||
while (it.hasNext() && lines.size() < MAX_RECIPES) {
|
while (it.hasNext() && lines.size() < MAX_RECIPES) {
|
||||||
Recipe recipe = it.next();
|
Recipe recipe = it.next();
|
||||||
if (recipe.getResult().getType() != material) {
|
if (recipe.getResult().getType() != material) {
|
||||||
|
|||||||
@@ -44,4 +44,30 @@ class AiTextTest {
|
|||||||
void plainPortugueseIsNotForeign() {
|
void plainPortugueseIsNotForeign() {
|
||||||
assertFalse(AiText.hasForeignScript("camelos são pacíficos e mansos"));
|
assertFalse(AiText.hasForeignScript("camelos são pacíficos e mansos"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// null must not reach the regex chain — it is total hardening for a method
|
||||||
|
// whose caller promises non-null but should not explode if it does not.
|
||||||
|
@Test
|
||||||
|
void sanitiseReturnsEmptyForNull() {
|
||||||
|
assertEquals("", AiText.sanitise(null, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncating a supplementary character in half leaves an orphan surrogate
|
||||||
|
// that renders as a replacement box. The cut backs off one char.
|
||||||
|
@Test
|
||||||
|
void truncationBacksOffAHighSurrogate() {
|
||||||
|
// 𝄞 (U+1D11E) is two UTF-16 chars; it is not in the emoji strip range,
|
||||||
|
// so it survives to the truncation step. Cutting at index 4 would slice
|
||||||
|
// the high surrogate (D834) off its low half (DD1E).
|
||||||
|
assertEquals("abc…", AiText.sanitise("abc𝄞def", 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A lone "*" surrounded by spaces is a bullet, not markdown emphasis.
|
||||||
|
// The strip must only remove markers that flank non-space text.
|
||||||
|
@Test
|
||||||
|
void keepsStandaloneAsterisk() {
|
||||||
|
assertEquals("foo * bar", AiText.sanitise("foo * bar", 500));
|
||||||
|
// And real emphasis still strips.
|
||||||
|
assertEquals("use magma cream", AiText.sanitise("use *magma cream*", 500));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user