feat(ia): split long/list answers into separate chat messages

Minecraft has no meaningful character cap on what the server sends —
the 256-char limit is only on what a player types. The real problem
was that AiText.sanitise flattened every \n to a space, so a
model-produced numbered list or multi-paragraph answer landed as one
wrapped wall of text instead of readable lines.

AiText.segments() keeps the model's own line breaks, re-wraps any
line still too long at a sentence boundary (falling back to a
word/char wrap for pathological input), and caps both the total
character budget (ia.max-caracteres, unchanged meaning) and the
number of chat messages (new ia.max-mensagens, default 4) so a
runaway list can't flood chat.

Ai.deliver sends one message per segment instead of one flattened
line; style() tags only the first with [IA], continuation lines get
a plain " » " marker so a 5-item list reads as one grouped answer,
not five separate replies.

295 -> 305 tests.
This commit is contained in:
marcos
2026-08-09 06:06:51 +00:00
parent 67a2cbb37d
commit c54c6b22b3
5 changed files with 320 additions and 6 deletions
@@ -398,7 +398,14 @@ final class Ai {
} }
return; return;
} }
String clean = AiText.sanitise(answer, settings.aiMaxAnswer()); java.util.List<String> segments = AiText.segments(answer, settings.aiMaxAnswer(), settings.aiMaxMessages());
if (segments.isEmpty()) {
if (asker != null) {
Msg.error(asker, "Não consegui resposta agora. Tente de novo em instantes.");
}
return;
}
String clean = String.join(" ", segments);
// Only remember if the asker is still online: a PlayerQuitEvent forgets // Only remember if the asker is still online: a PlayerQuitEvent forgets
// the player's history (carry-forward #6), and re-adding here after the // the player's history (carry-forward #6), and re-adding here after the
// quit would resurrect it. lastAnswer stays regardless, so /ia corrigir // quit would resurrect it. lastAnswer stays regardless, so /ia corrigir
@@ -410,13 +417,22 @@ final class Ai {
if (isPrivate || !settings.aiPublic()) { if (isPrivate || !settings.aiPublic()) {
if (asker != null) { if (asker != null) {
asker.sendMessage(style(clean, question, settings, Platform.isBedrock(asker))); boolean bedrock = Platform.isBedrock(asker);
for (int i = 0; i < segments.size(); i++) {
asker.sendMessage(style(segments.get(i), question, settings, bedrock, i == 0));
}
} }
return; return;
} }
// Built per platform: Bedrock renders neither hover nor click, so it // Built per platform: Bedrock renders neither hover nor click, so it
// gets the plain line instead of silently losing the interaction. // gets the plain line instead of silently losing the interaction. Each
plugin.broadcastPerPlatform(bedrock -> style(clean, question, settings, bedrock)); // segment is its own broadcast — a list sent as five one-line messages
// reads as a list; sent as one flattened line it reads as noise.
for (int i = 0; i < segments.size(); i++) {
String segment = segments.get(i);
boolean first = i == 0;
plugin.broadcastPerPlatform(bedrock -> style(segment, question, settings, bedrock, first));
}
plugin.openAiReactions(askerId); plugin.openAiReactions(askerId);
} }
@@ -431,8 +447,14 @@ final class Ai {
* <p>Bedrock gets the same text with no hover and no click, because it * <p>Bedrock gets the same text with no hover and no click, because it
* renders neither; the styling is decoration and its absence costs nothing. * renders neither; the styling is decoration and its absence costs nothing.
* {@code ia.estilo-rico: false} forces the plain form everywhere. * {@code ia.estilo-rico: false} forces the plain form everywhere.
*
* <p>A long or list-shaped answer arrives as several segments ({@link
* AiText#segments}); the first carries the full {@code [IA]} tag, the rest
* carry a plain grey continuation mark instead of repeating the tag on
* every line, so a five-item list reads as one grouped answer rather than
* five separate IA replies.
*/ */
private Component style(String answer, String question, Settings settings, boolean bedrock) { private Component style(String answer, String question, Settings settings, boolean bedrock, boolean firstLine) {
Component body = Component.text(answer, NamedTextColor.WHITE) Component body = Component.text(answer, NamedTextColor.WHITE)
.decoration(TextDecoration.BOLD, false); .decoration(TextDecoration.BOLD, false);
if (!bedrock && settings.aiFancy()) { if (!bedrock && settings.aiFancy()) {
@@ -449,7 +471,10 @@ final class Ai {
NamedTextColor.DARK_GRAY)))) NamedTextColor.DARK_GRAY))))
.clickEvent(net.kyori.adventure.text.event.ClickEvent.suggestCommand("/ia ")); .clickEvent(net.kyori.adventure.text.event.ClickEvent.suggestCommand("/ia "));
} }
return Msg.tag("IA", NamedTextColor.LIGHT_PURPLE).append(body); Component prefix = firstLine
? Msg.tag("IA", NamedTextColor.LIGHT_PURPLE)
: Component.text(" » ", NamedTextColor.DARK_GRAY);
return prefix.append(body);
} }
// --- spontaneous lines -------------------------------------------------- // --- spontaneous lines --------------------------------------------------
@@ -1,5 +1,9 @@
package dev.marcospaulo.canalhandia; package dev.marcospaulo.canalhandia;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
/** /**
@@ -83,6 +87,179 @@ final class AiText {
return text; return text;
} }
/**
* Line-wrap width used inside {@link #segments}, in characters.
*
* <p>Minecraft imposes no real limit here: the 256-character cap is on
* what a <em>player</em> types, not on chat components the server sends,
* and the underlying packet allows far more than any answer needs. This
* number instead picks how much text belongs in one visual chat line —
* a list with five items reads as five lines, not one paragraph, and a
* long explanation reads as a few short lines instead of one wall wrapped
* by the client at whatever width the player's window happens to be.
*/
private static final int LINE_WIDTH = 200;
private static final Pattern SENTENCE = Pattern.compile("[^.!?]+[.!?]*\\s*");
/**
* Splits a model answer into the separate chat messages it should be sent
* as, instead of one flattened line.
*
* <p>Unlike {@link #sanitise}, this keeps the model's own line breaks —
* that is what turns a numbered list or a set of short points back into
* one message per item. Each resulting line is then colour/markdown/emoji
* cleaned exactly like {@code sanitise} does, and re-wrapped at
* {@link #LINE_WIDTH} if it is still too long to read as one message.
*
* <p>{@code totalMax} caps the combined length exactly like
* {@code sanitise}'s {@code max} does today (protects the token/spam
* budget); {@code maxMessages} caps how many separate chat lines go out
* (protects against a runaway list flooding chat) — anything past that
* cap is folded into the last line and ellipsised.
*
* @return never null; empty list only for a null/blank/all-noise answer
*/
static List<String> segments(String raw, int totalMax, int maxMessages) {
if (raw == null || raw.isBlank()) {
return List.of();
}
String cleaned = raw
.replaceAll("§[0-9A-Za-z]", " ")
.replace('§', ' ')
.replace("\r\n", "\n")
.replace('\r', '\n')
.replaceAll("(?s)\\*{1,3}(?!\\s)(.+?)(?<!\\s)\\*{1,3}", "$1")
.replaceAll("(?s)`{1,3}(?!\\s)(.+?)(?<!\\s)`{1,3}", "$1")
.replaceAll("(?m)^#{1,6}\\s+", "")
.replaceAll("[\\x{1F000}-\\x{1FAFF}\\x{2190}-\\x{2BFF}\\x{FE0F}\\x{20E3}]", "")
.replaceAll("[ \\t]{2,}", " ")
.trim();
while (cleaned.startsWith("/")) {
cleaned = cleaned.substring(1).trim();
}
if (cleaned.length() > Math.max(totalMax, 1) * Math.max(maxMessages, 1)) {
cleaned = truncate(cleaned, Math.max(totalMax, 1) * Math.max(maxMessages, 1));
}
List<String> lines = new ArrayList<>();
for (String line : cleaned.split("\\n+")) {
String trimmed = line.trim();
if (!trimmed.isEmpty()) {
lines.add(trimmed);
}
}
if (lines.isEmpty()) {
return List.of();
}
List<String> wrapped = new ArrayList<>();
for (String line : lines) {
wrapped.addAll(wrap(line, LINE_WIDTH));
}
int cap = Math.max(1, maxMessages);
if (wrapped.size() <= cap) {
return wrapped;
}
List<String> capped = new ArrayList<>(wrapped.subList(0, cap - 1));
StringBuilder rest = new StringBuilder();
for (int i = cap - 1; i < wrapped.size(); i++) {
if (!rest.isEmpty()) {
rest.append(' ');
}
rest.append(wrapped.get(i));
}
capped.add(truncate(rest.toString(), Math.max(totalMax, LINE_WIDTH)));
return capped;
}
/** Breaks one line into sentence-sized chunks of at most {@code width} chars. */
private static List<String> wrap(String line, int width) {
if (line.length() <= width) {
return List.of(line);
}
List<String> out = new ArrayList<>();
StringBuilder current = new StringBuilder();
Matcher m = SENTENCE.matcher(line);
while (m.find()) {
String sentence = m.group().trim();
if (sentence.isEmpty()) {
continue;
}
if (sentence.length() > width) {
if (!current.isEmpty()) {
out.add(current.toString());
current.setLength(0);
}
out.addAll(wrapByWord(sentence, width));
continue;
}
if (!current.isEmpty() && current.length() + 1 + sentence.length() > width) {
out.add(current.toString());
current.setLength(0);
}
if (!current.isEmpty()) {
current.append(' ');
}
current.append(sentence);
}
if (!current.isEmpty()) {
out.add(current.toString());
}
return out.isEmpty() ? List.of(line) : out;
}
/** Last-resort wrap for a single sentence with no punctuation to break on. */
private static List<String> wrapByWord(String text, int width) {
List<String> out = new ArrayList<>();
StringBuilder current = new StringBuilder();
for (String word : text.split("\\s+")) {
// A "word" longer than the whole width (no spaces at all — never
// seen from the model, but not impossible from pasted junk) has
// nothing left to break on but the character boundary itself.
if (word.length() > width) {
if (!current.isEmpty()) {
out.add(current.toString());
current.setLength(0);
}
for (int i = 0; i < word.length(); i += width) {
out.add(word.substring(i, Math.min(i + width, word.length())));
}
continue;
}
if (!current.isEmpty() && current.length() + 1 + word.length() > width) {
out.add(current.toString());
current.setLength(0);
}
if (!current.isEmpty()) {
current.append(' ');
}
current.append(word);
}
if (!current.isEmpty()) {
out.add(current.toString());
}
return out;
}
/**
* Surrogate-safe truncation shared by {@link #sanitise} and
* {@link #segments}: backing off one char when the cut lands on a high
* surrogate avoids leaving an orphan half that renders as a replacement
* box.
*/
private static String truncate(String text, int max) {
if (text.length() <= max) {
return text;
}
int cut = max;
if (cut > 0 && Character.isHighSurrogate(text.charAt(cut - 1))) {
cut--;
}
return text.substring(0, cut).trim() + "";
}
/** Shortens text for a log line. */ /** Shortens text for a log line. */
static String forLog(String text) { static String forLog(String text) {
return text.length() > 300 ? text.substring(0, 300) + "" : text; return text.length() > 300 ? text.substring(0, 300) + "" : text;
@@ -297,6 +297,18 @@ final class Settings {
return Math.max(64, plugin.getConfig().getInt("ia.max-caracteres", 500)); return Math.max(64, plugin.getConfig().getInt("ia.max-caracteres", 500));
} }
/**
* How many separate chat messages a single answer may be split into.
* Minecraft has no meaningful per-message character limit for text the
* server sends (that 256-char cap is only on what a player can type), but
* a list or a long paragraph dumped into one chat line loses its
* structure. This bounds how many lines {@link Ai} will break an answer
* into instead — a hard cap so a runaway list can't flood chat.
*/
int aiMaxMessages() {
return Math.max(1, Math.min(8, plugin.getConfig().getInt("ia.max-mensagens", 4)));
}
/** Whether the question and answer go to everyone or only to the asker. */ /** Whether the question and answer go to everyone or only to the asker. */
boolean aiPublic() { boolean aiPublic() {
return plugin.getConfig().getBoolean("ia.publico", true); return plugin.getConfig().getBoolean("ia.publico", true);
+8
View File
@@ -187,6 +187,14 @@ ia:
max-tokens: 1200 max-tokens: 1200
max-caracteres: 500 max-caracteres: 500
# O Minecraft não limita o tamanho de uma mensagem que o SERVIDOR manda (o
# limite de 256 caracteres é só no que um JOGADOR digita). O problema de
# despejar uma lista inteira numa linha só é de leitura, não do jogo: vira
# um bloco de texto em vez de itens separados. Por isso a resposta é
# dividida em até N mensagens de chat — uma lista de 5 itens vira 5 linhas.
# max-caracteres continua sendo o teto total somado entre todas elas.
max-mensagens: 4
# Tamanho máximo da pergunta, em caracteres. # Tamanho máximo da pergunta, em caracteres.
max-pergunta: 300 max-pergunta: 300
@@ -0,0 +1,92 @@
package dev.marcospaulo.canalhandia;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import org.junit.jupiter.api.Test;
class AiTextSegmentsTest {
@Test
void shortAnswerIsOneSegment() {
assertEquals(List.of("Sim, dá para plantar cacto na areia."),
AiText.segments("Sim, dá para plantar cacto na areia.", 500, 4));
}
@Test
void nullOrBlankIsEmpty() {
assertEquals(List.of(), AiText.segments(null, 500, 4));
assertEquals(List.of(), AiText.segments(" ", 500, 4));
}
@Test
void newlinesBecomeSeparateSegmentsInsteadOfBeingFlattened() {
// AiText.sanitise collapses \n to a space; segments must not, because
// this is exactly what turns a model-produced list into one message
// per item instead of one wall of text.
List<String> out = AiText.segments("1. minere ferro\n2. faça uma picareta\n3. vá para a caverna",
500, 4);
assertEquals(List.of("1. minere ferro", "2. faça uma picareta", "3. vá para a caverna"), out);
}
@Test
void blankLinesBetweenParagraphsDoNotProduceEmptySegments() {
List<String> out = AiText.segments("primeira parte\n\n\nsegunda parte", 500, 4);
assertEquals(List.of("primeira parte", "segunda parte"), out);
}
@Test
void aLineLongerThanTheWidthIsWrappedBySentence() {
String longLine = "Esta é a primeira frase bem grande para forçar a quebra de linha no teste. "
+ "E esta é a segunda frase, também grande, para garantir que os limites funcionam direito. "
+ "E aqui vai uma terceira frase só para garantir que passamos dos duzentos caracteres.";
assertTrue(longLine.length() > 200, "fixture too short: " + longLine.length());
List<String> out = AiText.segments(longLine, 500, 4);
assertTrue(out.size() >= 2, "expected the long line to wrap into multiple segments, got: " + out);
for (String segment : out) {
assertTrue(segment.length() <= 200, "segment too long: " + segment);
}
}
@Test
void moreLinesThanMaxMessagesAreFoldedIntoTheLast() {
List<String> out = AiText.segments("um\ndois\ntrês\nquatro\ncinco\nseis", 500, 3);
assertEquals(3, out.size());
assertEquals("um", out.get(0));
assertEquals("dois", out.get(1));
assertTrue(out.get(2).contains("três") && out.get(2).contains("seis"),
"expected overflow lines merged into the last segment: " + out.get(2));
}
@Test
void eachSegmentIsCleanedLikeSanitise() {
List<String> out = AiText.segments("§cvermelho\n**negrito**\n`codigo`", 500, 4);
assertEquals(List.of("vermelho", "negrito", "codigo"), out);
}
@Test
void leadingSlashIsStrippedOnlyOnce() {
List<String> out = AiText.segments("/kill isso não é um comando de verdade", 500, 4);
assertEquals(List.of("kill isso não é um comando de verdade"), out);
}
@Test
void totalBudgetStillCapsAVeryLongAnswer() {
String huge = "palavra ".repeat(400); // way over any reasonable total budget
// totalMax * maxMessages (750) clears the 200-char line-wrap width, so
// the truncated text still wraps into more lines than fit, and the
// overflow gets folded into the last of the 3 allowed segments.
List<String> out = AiText.segments(huge, 250, 3);
assertEquals(3, out.size());
int total = out.stream().mapToInt(String::length).sum();
assertTrue(total <= 250 * 3 + 20, "segments should stay close to the total budget, got total=" + total);
}
@Test
void singleSegmentHardWrapsAWordSaladLineWithNoPunctuation() {
String noPunctuation = "palavra".repeat(60); // 420 chars, no spaces or sentence breaks
List<String> out = AiText.segments(noPunctuation, 1000, 4);
assertTrue(out.size() >= 2, "expected a hard wrap fallback, got: " + out);
}
}