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