diff --git a/pom.xml b/pom.xml index 4489a01..380c5af 100644 --- a/pom.xml +++ b/pom.xml @@ -68,6 +68,13 @@ org.apache.maven.plugins maven-surefire-plugin 3.5.4 + + + true + diff --git a/src/main/java/dev/marcospaulo/canalhandia/Ai.java b/src/main/java/dev/marcospaulo/canalhandia/Ai.java index 76e2325..032431d 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Ai.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Ai.java @@ -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. * *

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 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. - * - *

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. - * - *

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; - } } diff --git a/src/main/java/dev/marcospaulo/canalhandia/AiText.java b/src/main/java/dev/marcospaulo/canalhandia/AiText.java new file mode 100644 index 0000000..d6c53bb --- /dev/null +++ b/src/main/java/dev/marcospaulo/canalhandia/AiText.java @@ -0,0 +1,59 @@ +package dev.marcospaulo.canalhandia; + +import java.util.regex.Pattern; + +/** + * Text guards for model replies. + * + *

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; + } +} diff --git a/src/test/java/dev/marcospaulo/canalhandia/AiTextTest.java b/src/test/java/dev/marcospaulo/canalhandia/AiTextTest.java new file mode 100644 index 0000000..a6298de --- /dev/null +++ b/src/test/java/dev/marcospaulo/canalhandia/AiTextTest.java @@ -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")); + } +}