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