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:
@@ -398,7 +398,14 @@ final class Ai {
|
||||
}
|
||||
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
|
||||
// the player's history (carry-forward #6), and re-adding here after the
|
||||
// quit would resurrect it. lastAnswer stays regardless, so /ia corrigir
|
||||
@@ -410,13 +417,22 @@ final class Ai {
|
||||
|
||||
if (isPrivate || !settings.aiPublic()) {
|
||||
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;
|
||||
}
|
||||
// Built per platform: Bedrock renders neither hover nor click, so it
|
||||
// gets the plain line instead of silently losing the interaction.
|
||||
plugin.broadcastPerPlatform(bedrock -> style(clean, question, settings, bedrock));
|
||||
// gets the plain line instead of silently losing the interaction. Each
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -431,8 +447,14 @@ final class Ai {
|
||||
* <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.
|
||||
* {@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)
|
||||
.decoration(TextDecoration.BOLD, false);
|
||||
if (!bedrock && settings.aiFancy()) {
|
||||
@@ -449,7 +471,10 @@ final class Ai {
|
||||
NamedTextColor.DARK_GRAY))))
|
||||
.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 --------------------------------------------------
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
@@ -83,6 +87,179 @@ final class AiText {
|
||||
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. */
|
||||
static String forLog(String 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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
boolean aiPublic() {
|
||||
return plugin.getConfig().getBoolean("ia.publico", true);
|
||||
|
||||
@@ -187,6 +187,14 @@ ia:
|
||||
max-tokens: 1200
|
||||
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.
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user