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
+7
View File
@@ -68,6 +68,13 @@
<groupId>org.apache.maven.plugins</groupId> <groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId> <artifactId>maven-surefire-plugin</artifactId>
<version>3.5.4</version> <version>3.5.4</version>
<configuration>
<!-- The pin above guards against a surefire version that quietly stops
discovering tests. It does not guard against a test class being
misplaced, misnamed or disabled, which ends the same way: a green
build that ran nothing. Failing on an empty suite closes that gap. -->
<failIfNoTests>true</failIfNoTests>
</configuration>
</plugin> </plugin>
</plugins> </plugins>
</build> </build>
@@ -32,7 +32,7 @@ import java.util.UUID;
* dispatcher, never written to disk, and no tool/function definitions are sent * 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 * 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 * 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. * mistaken for a command someone should paste.
* *
* <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
@@ -157,7 +157,7 @@ final class Ai {
return; return;
} }
Component message = Msg.tag("IA", NamedTextColor.LIGHT_PURPLE) 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)); .decoration(TextDecoration.BOLD, false));
if (settings.aiPublic()) { if (settings.aiPublic()) {
Bukkit.broadcast(message); Bukkit.broadcast(message);
@@ -190,7 +190,7 @@ final class Ai {
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString()); HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() / 100 != 2) { if (response.statusCode() / 100 != 2) {
plugin.getLogger().warning("IA respondeu HTTP " + response.statusCode() + ": " plugin.getLogger().warning("IA respondeu HTTP " + response.statusCode() + ": "
+ trim(response.body())); + AiText.forLog(response.body()));
return null; return null;
} }
return extract(response.body()); return extract(response.body());
@@ -263,39 +263,4 @@ final class Ai {
return askedToday; 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;
}
}
@@ -0,0 +1,47 @@
package dev.marcospaulo.canalhandia;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class AiTextTest {
@Test
void stripsMarkdownEmphasis() {
assertEquals("use magma cream",
AiText.sanitise("use **magma cream**", 500));
}
@Test
void stripsEmojiAndSectionSigns() {
assertEquals("boa sorte",
AiText.sanitise("boa sorte 😄 §c", 500));
}
@Test
void stripsLeadingSlashesSoRepliesCannotLookLikeCommands() {
assertEquals("give me diamonds",
AiText.sanitise("//give me diamonds", 500));
}
@Test
void keepsPortugueseAccents() {
assertEquals("poção de resistência ao fogo",
AiText.sanitise("poção de resistência ao fogo", 500));
}
@Test
void truncatesToLimit() {
assertEquals("abc…", AiText.sanitise("abcdefg", 3));
}
// The model leaked "搭档" and "contiennent" into Portuguese answers.
@Test
void detectsCjk() {
assertTrue(AiText.hasForeignScript("te aceite como搭档"));
}
@Test
void plainPortugueseIsNotForeign() {
assertFalse(AiText.hasForeignScript("camelos são pacíficos e mansos"));
}
}