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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user