diff --git a/pom.xml b/pom.xml
index 4489a01..380c5af 100644
--- a/pom.xml
+++ b/pom.xml
@@ -68,6 +68,13 @@
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 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"));
+ }
+}