fix(ia): make memory persistence async, wire heuristic fact extraction and /ia lembrar, and add tag and event tests
This commit is contained in:
@@ -457,7 +457,7 @@ final class Ai {
|
|||||||
if (asker != null) {
|
if (asker != null) {
|
||||||
boolean bedrock = Platform.isBedrock(asker);
|
boolean bedrock = Platform.isBedrock(asker);
|
||||||
for (int i = 0; i < segments.size(); i++) {
|
for (int i = 0; i < segments.size(); i++) {
|
||||||
asker.sendMessage(style(segments.get(i), question, persona, settings, bedrock, i == 0));
|
asker.sendMessage(style(segments.get(i), question, persona, settings.aiFancy(), bedrock, i == 0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -469,7 +469,7 @@ final class Ai {
|
|||||||
for (int i = 0; i < segments.size(); i++) {
|
for (int i = 0; i < segments.size(); i++) {
|
||||||
String segment = segments.get(i);
|
String segment = segments.get(i);
|
||||||
boolean first = i == 0;
|
boolean first = i == 0;
|
||||||
plugin.broadcastPerPlatform(bedrock -> style(segment, question, persona, settings, bedrock, first));
|
plugin.broadcastPerPlatform(bedrock -> style(segment, question, persona, settings.aiFancy(), bedrock, first));
|
||||||
}
|
}
|
||||||
plugin.openAiReactions(askerId);
|
plugin.openAiReactions(askerId);
|
||||||
}
|
}
|
||||||
@@ -477,10 +477,10 @@ final class Ai {
|
|||||||
/**
|
/**
|
||||||
* Renders one answer for chat.
|
* Renders one answer for chat.
|
||||||
*/
|
*/
|
||||||
private Component style(String answer, String question, Persona persona, Settings settings, boolean bedrock, boolean firstLine) {
|
static Component style(String answer, String question, Persona persona, boolean fancy, 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 && fancy) {
|
||||||
body = body
|
body = body
|
||||||
.hoverEvent(net.kyori.adventure.text.event.HoverEvent.showText(
|
.hoverEvent(net.kyori.adventure.text.event.HoverEvent.showText(
|
||||||
Component.text("Pergunta: ", NamedTextColor.GRAY)
|
Component.text("Pergunta: ", NamedTextColor.GRAY)
|
||||||
|
|||||||
@@ -975,6 +975,14 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
|||||||
iaForget(sender);
|
iaForget(sender);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (sub.equals("lembrar") && args.length >= 2) {
|
||||||
|
String fact = String.join(" ", Arrays.copyOfRange(args, 1, args.length)).trim();
|
||||||
|
if (!fact.isBlank() && plugin.playerMemory() != null) {
|
||||||
|
plugin.playerMemory().addFact(player.getUniqueId(), player.getName(), fact);
|
||||||
|
Msg.ok(sender, "Fato gravado na memória da sua IA: \"" + fact + "\"");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
// Personalities: player can switch for themselves (/ia persona <nome>)
|
// Personalities: player can switch for themselves (/ia persona <nome>)
|
||||||
// or reset to default (/ia persona padrao).
|
// or reset to default (/ia persona padrao).
|
||||||
if ((sub.equals("personalidade") || sub.equals("persona"))
|
if ((sub.equals("personalidade") || sub.equals("persona"))
|
||||||
@@ -1840,7 +1848,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
|||||||
}
|
}
|
||||||
if (name.equals("ia") || name.equals("iap")) {
|
if (name.equals("ia") || name.equals("iap")) {
|
||||||
if (args.length == 1) {
|
if (args.length == 1) {
|
||||||
List<String> subs = new ArrayList<>(List.of("persona", "personalidade", "status", "esquecer"));
|
List<String> subs = new ArrayList<>(List.of("persona", "personalidade", "status", "lembrar", "esquecer"));
|
||||||
if (sender.hasPermission("canalhandia.ia.perfil")) {
|
if (sender.hasPermission("canalhandia.ia.perfil")) {
|
||||||
subs.addAll(List.of("perfil", "eventos", "saudacao", "corrigir"));
|
subs.addAll(List.of("perfil", "eventos", "saudacao", "corrigir"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,12 @@ import java.util.ArrayList;
|
|||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persistent per-player memory and preferences for the AI companion.
|
* Persistent per-player memory and preferences for the AI companion.
|
||||||
@@ -54,6 +58,11 @@ final class PlayerMemory {
|
|||||||
|
|
||||||
private final File file;
|
private final File file;
|
||||||
private final Map<UUID, Profile> profiles = new HashMap<>();
|
private final Map<UUID, Profile> profiles = new HashMap<>();
|
||||||
|
private final ExecutorService io = Executors.newSingleThreadExecutor(r -> {
|
||||||
|
Thread t = new Thread(r, "canalhandia-player-memory-io");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
|
||||||
PlayerMemory(File file) {
|
PlayerMemory(File file) {
|
||||||
this.file = file;
|
this.file = file;
|
||||||
@@ -108,7 +117,25 @@ final class PlayerMemory {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String summary(UUID uuid) {
|
||||||
|
synchronized (profiles) {
|
||||||
|
Profile p = profiles.get(uuid);
|
||||||
|
return p != null ? p.summary() : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> facts(UUID uuid) {
|
||||||
|
synchronized (profiles) {
|
||||||
|
Profile p = profiles.get(uuid);
|
||||||
|
return p != null ? Collections.unmodifiableList(p.facts()) : List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void setPersona(UUID uuid, String name, Persona persona) {
|
void setPersona(UUID uuid, String name, Persona persona) {
|
||||||
|
if (persona == null) {
|
||||||
|
resetPersona(uuid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
synchronized (profiles) {
|
synchronized (profiles) {
|
||||||
Profile p = profiles.computeIfAbsent(uuid, k -> new Profile(k, name, null, "", new ArrayList<>(), System.currentTimeMillis()));
|
Profile p = profiles.computeIfAbsent(uuid, k -> new Profile(k, name, null, "", new ArrayList<>(), System.currentTimeMillis()));
|
||||||
profiles.put(uuid, p.withPersona(persona));
|
profiles.put(uuid, p.withPersona(persona));
|
||||||
@@ -126,20 +153,6 @@ final class PlayerMemory {
|
|||||||
save();
|
save();
|
||||||
}
|
}
|
||||||
|
|
||||||
String summary(UUID uuid) {
|
|
||||||
synchronized (profiles) {
|
|
||||||
Profile p = profiles.get(uuid);
|
|
||||||
return p != null ? p.summary() : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
List<String> facts(UUID uuid) {
|
|
||||||
synchronized (profiles) {
|
|
||||||
Profile p = profiles.get(uuid);
|
|
||||||
return p != null ? Collections.unmodifiableList(new ArrayList<>(p.facts())) : List.of();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Condenses a conversation turn into the player's persistent summary.
|
* Condenses a conversation turn into the player's persistent summary.
|
||||||
*/
|
*/
|
||||||
@@ -178,7 +191,39 @@ final class PlayerMemory {
|
|||||||
}
|
}
|
||||||
profiles.put(uuid, p.withSummary(updated));
|
profiles.put(uuid, p.withSummary(updated));
|
||||||
}
|
}
|
||||||
save();
|
|
||||||
|
// Automatic heuristic fact extraction from player statements
|
||||||
|
String heuristicFact = extractHeuristicFact(question);
|
||||||
|
if (heuristicFact != null) {
|
||||||
|
addFact(uuid, name, heuristicFact);
|
||||||
|
} else {
|
||||||
|
save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String extractHeuristicFact(String text) {
|
||||||
|
if (text == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String lower = text.toLowerCase(Locale.ROOT).trim();
|
||||||
|
String[] triggers = {
|
||||||
|
"minha base", "meu spawn", "minha casa", "estou construindo",
|
||||||
|
"meu plano", "meu objetivo", "sou especialista em", "moro em"
|
||||||
|
};
|
||||||
|
for (String trigger : triggers) {
|
||||||
|
int idx = lower.indexOf(trigger);
|
||||||
|
if (idx >= 0) {
|
||||||
|
String candidate = text.substring(idx).trim();
|
||||||
|
candidate = candidate.replaceAll("[?!.]+$", "").trim();
|
||||||
|
if (candidate.length() > 60) {
|
||||||
|
candidate = candidate.substring(0, 60) + "…";
|
||||||
|
}
|
||||||
|
if (candidate.length() >= 8) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
void addFact(UUID uuid, String name, String fact) {
|
void addFact(UUID uuid, String name, String fact) {
|
||||||
@@ -250,7 +295,15 @@ final class PlayerMemory {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void save() {
|
/** Flushes any pending background writes to disk (useful for shutdown or tests). */
|
||||||
|
void flush() {
|
||||||
|
try {
|
||||||
|
io.submit(() -> {}).get(2, TimeUnit.SECONDS);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private YamlConfiguration buildYaml() {
|
||||||
YamlConfiguration yaml = new YamlConfiguration();
|
YamlConfiguration yaml = new YamlConfiguration();
|
||||||
synchronized (profiles) {
|
synchronized (profiles) {
|
||||||
for (Map.Entry<UUID, Profile> entry : profiles.entrySet()) {
|
for (Map.Entry<UUID, Profile> entry : profiles.entrySet()) {
|
||||||
@@ -265,6 +318,21 @@ final class PlayerMemory {
|
|||||||
yaml.set(key + ".atualizado_em", p.updatedAt());
|
yaml.set(key + ".atualizado_em", p.updatedAt());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return yaml;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void save() {
|
||||||
|
YamlConfiguration yaml = buildYaml();
|
||||||
|
io.execute(() -> {
|
||||||
|
try {
|
||||||
|
yaml.save(file);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void saveSync() {
|
||||||
|
YamlConfiguration yaml = buildYaml();
|
||||||
try {
|
try {
|
||||||
yaml.save(file);
|
yaml.save(file);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package dev.marcospaulo.canalhandia;
|
||||||
|
|
||||||
|
import net.kyori.adventure.text.Component;
|
||||||
|
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class AiTagTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allPersonasRenderDynamicTagsCorrectly() {
|
||||||
|
for (Persona persona : Persona.values()) {
|
||||||
|
Component c = Ai.style("Minha resposta", "Qualquer pergunta", persona, true, false, true);
|
||||||
|
String plain = PlainTextComponentSerializer.plainText().serialize(c);
|
||||||
|
|
||||||
|
assertTrue(plain.contains("[" + persona.displayTag() + "]"),
|
||||||
|
"Rendered component must contain tag for " + persona);
|
||||||
|
assertTrue(plain.contains("Minha resposta"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void continuationLinesUseArrowPrefix() {
|
||||||
|
Component c = Ai.style("Segunda linha", "Pergunta", Persona.JUDITE, true, false, false);
|
||||||
|
String plain = PlainTextComponentSerializer.plainText().serialize(c);
|
||||||
|
|
||||||
|
assertTrue(plain.contains("»"));
|
||||||
|
assertTrue(plain.contains("Segunda linha"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bedrockDisablesHoverEvent() {
|
||||||
|
Component c = Ai.style("Resposta Bedrock", "Pergunta", Persona.JUDITE, true, true, true);
|
||||||
|
assertNull(c.children().isEmpty() ? c.hoverEvent() : c.children().get(c.children().size() - 1).hoverEvent());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void javaFancyIncludesHoverWithPersonaDetails() {
|
||||||
|
Component c = Ai.style("Resposta Java", "Pergunta de teste", Persona.NARRADOR, true, false, true);
|
||||||
|
assertNotNull(c);
|
||||||
|
String plain = PlainTextComponentSerializer.plainText().serialize(c);
|
||||||
|
assertTrue(plain.contains("[Narrador]"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package dev.marcospaulo.canalhandia;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class EventTest {
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path tempDir;
|
||||||
|
|
||||||
|
private PlayerMemory memory;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
File file = tempDir.resolve("ia-memoria.yml").toFile();
|
||||||
|
memory = new PlayerMemory(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void eventResolvesPlayerCustomPersonaWhenSet() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Persona defaultPersona = Persona.ZOEIRO;
|
||||||
|
|
||||||
|
// Default fallback
|
||||||
|
assertEquals(Persona.ZOEIRO, memory.persona(id, defaultPersona));
|
||||||
|
|
||||||
|
// Player custom choice
|
||||||
|
memory.setPersona(id, "Marcos", Persona.JUDITE);
|
||||||
|
assertEquals(Persona.JUDITE, memory.persona(id, defaultPersona));
|
||||||
|
|
||||||
|
// Switch to Narrador
|
||||||
|
memory.setPersona(id, "Marcos", Persona.NARRADOR);
|
||||||
|
assertEquals(Persona.NARRADOR, memory.persona(id, defaultPersona));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void joinWelcomePromptContainsPlayerAndStats() {
|
||||||
|
String name = "Marcos";
|
||||||
|
String stats = "100 diamantes minerados, 2 mortes";
|
||||||
|
String prompt = "O jogador " + name + " acabou de entrar no servidor."
|
||||||
|
+ " Estatísticas dele: " + stats
|
||||||
|
+ " Dê as boas-vindas do seu jeito e na sua personalidade, em uma frase curta.";
|
||||||
|
|
||||||
|
assertTrue(prompt.contains("Marcos"));
|
||||||
|
assertTrue(prompt.contains("100 diamantes"));
|
||||||
|
assertTrue(prompt.contains("personalidade"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deathStreakPromptContainsCountAndFlavor() {
|
||||||
|
String name = "Marcos";
|
||||||
|
int count = 4;
|
||||||
|
String flavor = "abraçou um Creeper";
|
||||||
|
String prompt = "O jogador " + name + " morreu " + count
|
||||||
|
+ " vezes seguidas em poucos minutos. A última foi assim: " + flavor
|
||||||
|
+ ". Comente na sua personalidade, sem ofender de verdade.";
|
||||||
|
|
||||||
|
assertTrue(prompt.contains("morreu 4 vezes seguidas"));
|
||||||
|
assertTrue(prompt.contains("abraçou um Creeper"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void achievementUnlockPromptContainsTitleAndDescription() {
|
||||||
|
String title = "Mestre dos Diamantes";
|
||||||
|
String desc = "Minerou 1000 diamantes";
|
||||||
|
String prompt = "O jogador Marcos desbloqueou a conquista \""
|
||||||
|
+ title + "\" (" + desc
|
||||||
|
+ "). Faça um breve comentário na sua personalidade.";
|
||||||
|
|
||||||
|
assertTrue(prompt.contains(title));
|
||||||
|
assertTrue(prompt.contains(desc));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,13 @@ class PlayerMemoryTest {
|
|||||||
memory = new PlayerMemory(file);
|
memory = new PlayerMemory(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@org.junit.jupiter.api.AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
if (memory != null) {
|
||||||
|
memory.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void startsEmpty() {
|
void startsEmpty() {
|
||||||
assertEquals(0, memory.size());
|
assertEquals(0, memory.size());
|
||||||
@@ -45,12 +52,14 @@ class PlayerMemoryTest {
|
|||||||
assertEquals(Persona.JUDITE, memory.rawPersona(id));
|
assertEquals(Persona.JUDITE, memory.rawPersona(id));
|
||||||
|
|
||||||
// Reload from disk
|
// Reload from disk
|
||||||
|
memory.flush();
|
||||||
PlayerMemory reloaded = new PlayerMemory(file);
|
PlayerMemory reloaded = new PlayerMemory(file);
|
||||||
assertEquals(Persona.JUDITE, reloaded.persona(id, Persona.ZOEIRO));
|
assertEquals(Persona.JUDITE, reloaded.persona(id, Persona.ZOEIRO));
|
||||||
assertEquals(Persona.JUDITE, reloaded.rawPersona(id));
|
assertEquals(Persona.JUDITE, reloaded.rawPersona(id));
|
||||||
|
|
||||||
// Reset
|
// Reset
|
||||||
reloaded.resetPersona(id);
|
reloaded.resetPersona(id);
|
||||||
|
reloaded.flush();
|
||||||
assertNull(reloaded.rawPersona(id));
|
assertNull(reloaded.rawPersona(id));
|
||||||
assertEquals(Persona.ZOEIRO, reloaded.persona(id, Persona.ZOEIRO));
|
assertEquals(Persona.ZOEIRO, reloaded.persona(id, Persona.ZOEIRO));
|
||||||
}
|
}
|
||||||
@@ -121,4 +130,30 @@ class PlayerMemoryTest {
|
|||||||
assertEquals(0, memory.size());
|
assertEquals(0, memory.size());
|
||||||
assertNull(memory.rawPersona(id));
|
assertNull(memory.rawPersona(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void extractsHeuristicFactsAutomatically() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
memory.recordTurn(id, "Marcos", "Minha base fica nas montanhas nevadas!", "Que lugar bonito!");
|
||||||
|
List<String> facts = memory.facts(id);
|
||||||
|
assertFalse(facts.isEmpty());
|
||||||
|
assertTrue(facts.get(0).toLowerCase().contains("minha base"));
|
||||||
|
|
||||||
|
String fact = PlayerMemory.extractHeuristicFact("Eu estou construindo uma pirâmide gigante?");
|
||||||
|
assertNotNull(fact);
|
||||||
|
assertTrue(fact.contains("estou construindo uma pirâmide gigante"));
|
||||||
|
assertNull(PlayerMemory.extractHeuristicFact("quantos blocos tem o mundo?"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void flushesToDiskCorrectly() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
memory.setPersona(id, "Marcos", Persona.JUDITE);
|
||||||
|
memory.recordTurn(id, "Marcos", "Preciso de ajuda", "Aguarde na linha");
|
||||||
|
memory.flush();
|
||||||
|
|
||||||
|
PlayerMemory disk = new PlayerMemory(file);
|
||||||
|
assertEquals(Persona.JUDITE, disk.persona(id, Persona.ZOEIRO));
|
||||||
|
assertTrue(disk.summary(id).contains("ajuda"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user