refactor: extract AiText and detect foreign-script leakage

Moves sanitise out of the Bukkit-bound Ai class so it can be unit
tested, and adds hasForeignScript to catch the CJK words the model
intermittently drops into Portuguese answers.

Colour-code stripping now removes the code character too: replacing
only the section sign left "§c" reading as a stray "c" in chat.

Surefire now fails on an empty suite, so a misplaced or disabled test
class cannot pass as a green build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3jSvSTrG4qsniLSr6TpC6
This commit is contained in:
marcos
2026-08-05 15:03:00 +00:00
parent be1f31778a
commit 4f427659bb
4 changed files with 116 additions and 38 deletions
@@ -32,7 +32,7 @@ import java.util.UUID;
* dispatcher, never written to disk, and no tool/function definitions are sent
* in the request, so there is nothing for the model to call. A player asking it
* to "run /op me" gets a string back, not an executed command. As a second
* layer, {@link #sanitise} strips leading slashes so a reply cannot even be
* layer, {@link AiText#sanitise} strips leading slashes so a reply cannot even be
* mistaken for a command someone should paste.
*
* <p>The API key never lives in config.yml, because config.yml is committed to
@@ -157,7 +157,7 @@ final class Ai {
return;
}
Component message = Msg.tag("IA", NamedTextColor.LIGHT_PURPLE)
.append(Component.text(sanitise(answer, settings.aiMaxAnswer()), NamedTextColor.WHITE)
.append(Component.text(AiText.sanitise(answer, settings.aiMaxAnswer()), NamedTextColor.WHITE)
.decoration(TextDecoration.BOLD, false));
if (settings.aiPublic()) {
Bukkit.broadcast(message);
@@ -190,7 +190,7 @@ final class Ai {
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() / 100 != 2) {
plugin.getLogger().warning("IA respondeu HTTP " + response.statusCode() + ": "
+ trim(response.body()));
+ AiText.forLog(response.body()));
return null;
}
return extract(response.body());
@@ -263,39 +263,4 @@ final class Ai {
return askedToday;
}
/**
* Makes a model reply safe and readable in chat.
*
* <p>Strips colour codes so the reply cannot forge server messages, folds
* newlines so one answer stays one chat entry, and removes leading slashes
* so nothing that comes back reads as a command to run.
*
* <p>It also strips markdown and emoji, which the models emit freely.
* Minecraft chat renders neither: {@code **negrito**} arrives as literal
* asterisks, and emoji show up as empty boxes on Bedrock.
*/
static String sanitise(String raw, int max) {
String text = raw.replace('§', ' ')
.replaceAll("[\\r\\n]+", " ")
// Markdown emphasis and code fences: chat shows the characters, not the effect.
.replaceAll("\\*{1,3}([^*]+)\\*{1,3}", "$1")
.replaceAll("`{1,3}([^`]+)`{1,3}", "$1")
.replaceAll("^#{1,6}\\s+", "")
// Emoji live outside the BMP, plus the symbol blocks and the
// variation selector. Accented pt-BR letters are far below this.
.replaceAll("[\\x{1F000}-\\x{1FAFF}\\x{2190}-\\x{2BFF}\\x{FE0F}\\x{20E3}]", "")
.replaceAll("\\s{2,}", " ")
.trim();
while (text.startsWith("/")) {
text = text.substring(1).trim();
}
if (text.length() > max) {
text = text.substring(0, max).trim() + "";
}
return text;
}
private static String trim(String text) {
return text.length() > 300 ? text.substring(0, 300) + "" : text;
}
}
@@ -0,0 +1,59 @@
package dev.marcospaulo.canalhandia;
import java.util.regex.Pattern;
/**
* Text guards for model replies.
*
* <p>Pure functions, deliberately free of Bukkit, so they can be tested without
* a server.
*/
final class AiText {
/**
* Scripts that should never appear in a Portuguese answer. The model has
* been observed dropping single Chinese words mid-sentence.
*/
private static final Pattern FOREIGN = Pattern.compile(
"[\\p{IsHan}\\p{IsHiragana}\\p{IsKatakana}\\p{IsHangul}\\p{IsCyrillic}\\p{IsArabic}]");
private AiText() {
}
static boolean hasForeignScript(String text) {
return text != null && FOREIGN.matcher(text).find();
}
/**
* 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
* boxes on Bedrock), no leading slash that could read as a command.
*/
static String sanitise(String raw, int max) {
String text = raw
// 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
// "§c" would read as a stray "c" in the middle of the sentence.
.replaceAll("§[0-9A-Za-z]", " ")
.replace('§', ' ')
.replaceAll("[\\r\\n]+", " ")
.replaceAll("\\*{1,3}([^*]+)\\*{1,3}", "$1")
.replaceAll("`{1,3}([^`]+)`{1,3}", "$1")
.replaceAll("^#{1,6}\\s+", "")
.replaceAll("[\\x{1F000}-\\x{1FAFF}\\x{2190}-\\x{2BFF}\\x{FE0F}\\x{20E3}]", "")
.replaceAll("\\s{2,}", " ")
.trim();
while (text.startsWith("/")) {
text = text.substring(1).trim();
}
if (text.length() > max) {
text = text.substring(0, max).trim() + "";
}
return text;
}
/** Shortens text for a log line. */
static String forLog(String text) {
return text.length() > 300 ? text.substring(0, 300) + "" : text;
}
}