feat(ia): add Judite & Narrador personas, per-player AI selection, persistent memory, and event reactivity #3
@@ -437,6 +437,7 @@ final class Ai {
|
||||
return;
|
||||
}
|
||||
String clean = String.join(" ", segments);
|
||||
lastAnswer = new Answered(askerId, question, clean);
|
||||
// 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
|
||||
@@ -444,10 +445,13 @@ final class Ai {
|
||||
if (asker != null) {
|
||||
conversations.remember(askerId, question, clean);
|
||||
if (plugin.playerMemory() != null) {
|
||||
|
masi marked this conversation as resolved
|
||||
try {
|
||||
plugin.playerMemory().recordTurn(askerId, asker.getName(), question, clean);
|
||||
|
masi marked this conversation as resolved
pragent-bot
commented
[MEDIUM] recordTurn() -> PlayerMemory.save() performs a synchronous full ia-memoria.yml write on the main server thread for every /ia answer (deliver runs via runTask), blocking the tick loop; cost scales with total players in the file. Fix: Move the save off the main thread (async scheduler task, or a dirty-flag + periodic flush like Notes/DeathLog), or batch writes so a single answer doesn't rewrite every player's entry. 📎 ref: https://bukkit.fandom.com/wiki/Scheduler_Programming 🪙 ~4360 tok (32% · attributed output) **[MEDIUM]** recordTurn() -> PlayerMemory.save() performs a synchronous full ia-memoria.yml write on the main server thread for every /ia answer (deliver runs via runTask), blocking the tick loop; cost scales with total players in the file.
Fix: Move the save off the main thread (async scheduler task, or a dirty-flag + periodic flush like Notes/DeathLog), or batch writes so a single answer doesn't rewrite every player's entry.
📎 ref: https://bukkit.fandom.com/wiki/Scheduler_Programming
🪙 ~4360 tok (32% · attributed output)
|
||||
} catch (RuntimeException e) {
|
||||
plugin.getLogger().warning("Falha ao gravar memória da IA para " + asker.getName() + ": " + e);
|
||||
}
|
||||
}
|
||||
}
|
||||
lastAnswer = new Answered(askerId, question, clean);
|
||||
|
||||
if (isPrivate || !settings.aiPublic()) {
|
||||
if (asker != null) {
|
||||
|
||||
@@ -146,7 +146,20 @@ final class Tools {
|
||||
}
|
||||
String uuidStr = who.uuid();
|
||||
List<DeathLog.Entry> deaths = plugin.deathLog().forPlayer(uuidStr);
|
||||
List<Note> notes = plugin.notes().visibleTo(uuidStr, null, "");
|
||||
List<Note> notes = plugin.notes().visibleTo(uuidStr, Note.Scope.PUBLICA, "");
|
||||
|
masi marked this conversation as resolved
Outdated
pragent-bot
commented
[HIGH] lugares_jogador calls notes.visibleTo(targetUuid, null, ""), which returns the target player's PRIVATE notes (visibleTo is PUBLICA || authorId==viewer), and that text is returned to the model whose context leaves the server for MiniMax — a direct violation of the Notes.publicSummary invariant that private notes must never reach the third-party AI. Fix: Restrict to PUBLICA notes authored by the target player only, so no private text ever enters the tool response. 🪙 ~792 tok (40% · attributed output) **[HIGH]** lugares_jogador calls notes.visibleTo(targetUuid, null, ""), which returns the target player's PRIVATE notes (visibleTo is PUBLICA || authorId==viewer), and that text is returned to the model whose context leaves the server for MiniMax — a direct violation of the Notes.publicSummary invariant that private notes must never reach the third-party AI.
Fix: Restrict to PUBLICA notes authored by the target player only, so no private text ever enters the tool response.
```java
List<Note> notes = new java.util.ArrayList<>();
for (Note n : plugin.notes().visibleTo(uuidStr, null, "")) {
if (n.scope() == Note.Scope.PUBLICA
&& n.authorId() != null && n.authorId().equals(uuidStr)) {
notes.add(n);
}
}
```
🪙 ~792 tok (40% · attributed output)
|
||||
return formatPlayerPlaces(who, deaths, notes);
|
||||
}
|
||||
|
||||
String formatPlayerPlaces(OfflineStats.Known who, List<DeathLog.Entry> deaths, List<Note> visibleNotes) {
|
||||
String uuidStr = who.uuid();
|
||||
List<Note> notes = new java.util.ArrayList<>();
|
||||
if (visibleNotes != null) {
|
||||
for (Note n : visibleNotes) {
|
||||
if (n.scope() == Note.Scope.PUBLICA && n.authorId() != null && n.authorId().equals(uuidStr)) {
|
||||
notes.add(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("Lugares conhecidos de ").append(who.name()).append(":\n");
|
||||
@@ -164,7 +177,7 @@ final class Tools {
|
||||
sb.append("\n");
|
||||
}
|
||||
|
||||
if (deaths.isEmpty()) {
|
||||
if (deaths == null || deaths.isEmpty()) {
|
||||
sb.append("- Mortes recentes: nenhum registro de morte recente.");
|
||||
} else {
|
||||
sb.append("- Mortes recentes: ");
|
||||
|
||||
@@ -3,6 +3,8 @@ package dev.marcospaulo.canalhandia;
|
||||
import com.google.gson.JsonArray;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
@@ -42,4 +44,35 @@ class ToolsTest {
|
||||
String res = tools.run("wiki", "malformed json");
|
||||
assertTrue(res.contains("argumentos inválidos"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void formatPlayerPlacesFiltersOutPrivateNotesAndOtherAuthors() {
|
||||
Tools tools = new Tools(null, null, null, s -> {});
|
||||
OfflineStats.Known who = new OfflineStats.Known("uuid-ana", "ana");
|
||||
|
||||
Note privateNote = new Note(1, Note.Scope.PRIVADA, "ana", "uuid-ana", "segredo", "world", 10, 20, 30, 1000L);
|
||||
Note publicNote = new Note(2, Note.Scope.PUBLICA, "ana", "uuid-ana", "vila do spawn", "world", 100, 64, 200, 1000L);
|
||||
Note otherAuthorPublicNote = new Note(3, Note.Scope.PUBLICA, "bob", "uuid-bob", "base do bob", "world", 500, 64, 500, 1000L);
|
||||
|
||||
String formatted = tools.formatPlayerPlaces(who, List.of(), List.of(privateNote, publicNote, otherAuthorPublicNote));
|
||||
|
||||
assertTrue(formatted.contains("vila do spawn"));
|
||||
assertFalse(formatted.contains("segredo"), "Private notes must never be included in places output");
|
||||
assertFalse(formatted.contains("base do bob"), "Notes of other players must not be included");
|
||||
assertTrue(formatted.contains("nenhum registro de morte recente"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void formatPlayerPlacesIncludesDeathsAndFormatsPlaces() {
|
||||
Tools tools = new Tools(null, null, null, s -> {});
|
||||
OfflineStats.Known who = new OfflineStats.Known("uuid-ana", "ana");
|
||||
|
||||
DeathLog.Entry d = new DeathLog.Entry("uuid-ana", "abraçou um Creeper", "Mundo normal", 12, 64, -80, 1000L);
|
||||
String formatted = tools.formatPlayerPlaces(who, List.of(d), List.of());
|
||||
|
||||
assertTrue(formatted.contains("Lugares conhecidos de ana:"));
|
||||
assertTrue(formatted.contains("nenhuma base salva"));
|
||||
assertTrue(formatted.contains("12, 64, -80 (Mundo normal)"));
|
||||
assertTrue(formatted.contains("abraçou um Creeper"));
|
||||
}
|
||||
}
|
||||
|
||||
[MEDIUM] recordTurn (which does a synchronous YAML save to disk) runs before lastAnswer is set and before the answer is delivered; if save() throws IllegalStateException on a disk error, the already-paid-for answer is never sent to the player and /ia corrigir state is lost.
Fix: Move recordTurn after the answer is delivered and lastAnswer is set, or wrap it in a try/catch so memory persistence cannot swallow the chat reply.
🪙 ~792 tok (40% · attributed output)