fix(ia): make memory persistence async, wire heuristic fact extraction and /ia lembrar, and add tag and event tests

This commit is contained in:
Marcos Paulo
2026-08-18 19:42:46 -03:00
parent 2220f11e64
commit 998757d610
6 changed files with 261 additions and 21 deletions
@@ -457,7 +457,7 @@ final class Ai {
if (asker != null) {
boolean bedrock = Platform.isBedrock(asker);
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;
@@ -469,7 +469,7 @@ final class Ai {
for (int i = 0; i < segments.size(); i++) {
String segment = segments.get(i);
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);
}
@@ -477,10 +477,10 @@ final class Ai {
/**
* 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)
.decoration(TextDecoration.BOLD, false);
if (!bedrock && settings.aiFancy()) {
if (!bedrock && fancy) {
body = body
.hoverEvent(net.kyori.adventure.text.event.HoverEvent.showText(
Component.text("Pergunta: ", NamedTextColor.GRAY)
@@ -975,6 +975,14 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
iaForget(sender);
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>)
// or reset to default (/ia persona padrao).
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 (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")) {
subs.addAll(List.of("perfil", "eventos", "saudacao", "corrigir"));
}
@@ -7,8 +7,12 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
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.
@@ -54,6 +58,11 @@ final class PlayerMemory {
private final File file;
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) {
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) {
if (persona == null) {
resetPersona(uuid);
return;
}
synchronized (profiles) {
Profile p = profiles.computeIfAbsent(uuid, k -> new Profile(k, name, null, "", new ArrayList<>(), System.currentTimeMillis()));
profiles.put(uuid, p.withPersona(persona));
@@ -126,20 +153,6 @@ final class PlayerMemory {
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.
*/
@@ -178,7 +191,39 @@ final class PlayerMemory {
}
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) {
@@ -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();
synchronized (profiles) {
for (Map.Entry<UUID, Profile> entry : profiles.entrySet()) {
@@ -265,6 +318,21 @@ final class PlayerMemory {
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 {
yaml.save(file);
} catch (Exception e) {