Add LLM long-term memory system

- Implement LLMMemory module with SQLite + FTS5
- Add conversation tracking with context awareness
- Track NPC reputation and interactions
- Integrate memory with LLMBrain for contextual replies
- Add comprehensive test suite (13 tests)
- Update settings with memory_db_path config
This commit is contained in:
truetype committed 2026-02-08 21:06:01 +01:00
1 parent bd211b6036
commit 69ba824bc8
11 files changed
+979 -13

No files matched your search

BIN
View File
Binary file not shown.
@@ -0,0 +1,128 @@
<img src="https://r2cdn.perplexity.ai/pplx-full-logo-primary-dark%402x.png" style="height:64px;margin-right:32px"/>
# conosci i dale mud ? vorrei fare un brainstorming per vedere come andrebbe cvreato un app che simula interagendo con un dale mud nella simulazione di un personaggio giocante ai dale mud dalla scelta del personaggio e delle caratteristiche , poi al gioco vero oe proprio, leggendo le istruzioni a schermo e le descrizioni delle varie arre e interagendo con i personaggi del dale mud e giocando e facendo il automapping, fino a far crescere il personaggio facendo exp, e quest, e quindi una simulazione in toto del giocateore di da dalemud.
Sì, conosco bene i **MUD** (Multi-User Dungeon) - quei giochi di ruolo testuali multiplayer via Telnet che hanno ispirato gli MMORPG moderni. L'idea di creare un'app che simuli un "giocatore AI" di MUD è affascinante, un'intersezione tra **agente AI autonomo** e **retro-gaming**. Ti propongo un brainstorming strutturato per l'architettura di questa app.
## Architettura generale
L'app dovrebbe essere composta da diversi moduli specializzati che comunicano tra loro, simulando il "cervello" di un giocatore umano:
### Modulo 1: Parser \& Interpret (l'occhio sullo schermo)
Questo modulo deve interpretare il testo grezzo che arriva dal MUD e trasformarlo in una struttura dati comprensibile per l'AI.
- **Telnet Client**: Connessione al server MUD (handshake, gestione colori ANSI, MCCP per compressione)[^1][^2]
- **NLP Layer**: Estrazione di informazioni chiave dalle descrizioni (stanza corrente, NPC presenti, oggetti, direzioni disponibili, stato del personaggio HP/MP/EXP)
- **Stato di gioco**: Modello dati che rappresenta la "memoria a breve termine" del giocatore (dove sono, cosa vedo, che mob c'è)
### Modulo 2: Character Creator (la creazione del personaggio)
Simulation della fase di rolling iniziale.
- **Roller automatico**: Simulare il "roll" delle statistiche (STR, INT, WIS, DEX, CON, CHA) ottimizzando per la classe scelta[^3]
- **Scelta classe/razza**: Logica di decisione basata su preferenze (meele vs caster, tank vs dps)
- **Allocazione skill**: Decisione su come spendere i punti iniziali
### Modulo 3: Auto-Mapper (la memoria spaziale)
Una componente critica per la navigazione autonoma.
- **Grafo delle stanze**: Ogni stanza è un nodo, le direzioni (n/s/e/w/u/d) sono gli archi
- **Room ID detection**: Identificare univocamente ogni stanza (anche se la descrizione è simile, usando esiti dei comandi LOOK + coordinate relative)[^2]
- **Mapping esplorativo**: Esplorare sistematicamente aree sconosciute, marcare zone già visitate, rilevare loop
- **Pathfinding**: Calcolo del percorso più breve tra punto A e punto B (utile per tornare da un healer o raggiungere un quest-giver)
### Modulo 4: Decision Engine (il cervello strategico)
Il core dell'intelligenza artificiale del giocatore.
- **Goal Manager**: Gestione degli obiettivi a breve e lungo termine (leveling, quest, grinding, looting)
- **Combat AI**: Decisioni in combattimento (quando attaccare, quando fuggire, quando curarsi, quale mob scegliere in base al livello)
- **Social AI**: Interazione con altri giocatori (commercio, gruppi, comunicazione)
- **Economy AI**: Gestione dell'inventario, vendita loot, acquisto equipaggiamento, bilancio del gold
### Modulo 5: Quest System (il diary delle quest)
Gestione delle missioni in stile MUD classico.
- **Quest tracker**: Parsing delle descrizioni quest ("trova l'anello nel cimitero", "uccidi 5 goblin")
- **Obiettivi**: Scomporre la quest in task atomici (naviga verso zona X → trova mob Y → combatti → loota → ritorna)
- **Prioritizzazione**: Valutare EXP/time ratio delle quest disponibili
### Modulo 6: Progression Optimizer (il min-maxer)
La logica di crescita del personaggio.
- **Grinding spot finder**: Identificare zone dove il rapporto EXP/ora è ottimale per il livello corrente
- **Equip optimizer**: Confrontare gear basato su stat bonus
- **Skill rotation**: Scegliere quali abilità usare in combattimento in base a mana cost / damage / cooldown
## Flusso di gioco simulato
```
1. CONNESSIONE → Telnet al server, autenticazione
2. CREAZIONE → Roll stats, scegli classe, setup iniziale
3. EXPLORING → Inizia mappando la città di partenza, memorizza shop/inn/quest-giver
4. COMBATTIMENTO → Trova mob appropriato al livello, combatte, accumula EXP
5. QUESTING → Accetta quest, pianifica esecuzione, completa, riscatta reward
6. LOOP → Torna a 4-5 fino al level up, poi espandi aree esplorabili
```
## Stack tecnico suggerito
- **Backend**: Python con `asyncio` per la gestione Telnet non-bloccante
- **Parser**: Regex + NLP (spaCy/LLM) per interpretare le descrizioni
- **Grafo**: NetworkX per l'auto-mapping
- **AI Decision**: Può essere una serie di regole (expert system) oppure integrare un LLM locale (Llama via Ollama) per decisioni più "intuitive"
- **Storage**: SQLite per mappare le stanze e SQLite/JSON per il progresso del personaggio
## Sfide interessanti
- **Ambiguità testuali**: I MUD hanno descrizioni in prosa, non dati strutturati. Estrarre "questo è un mob ostile" vs "questo è un NPC friendly" richiede parsing intelligente.
- **Anti-cheat**: Molti MUD hanno sistemi di rilevamento bot. Il tuo "giocatore simulato" deve mimare comportamenti umani (tempi di reazione variabili, pause, errori di battitura occasionali).
- **Memoria a lungo termine**: Dopo settimane di gioco, il grafo del mondo diventa enorme. Serve una buona strategia di storage/indexing.
Vuoi approfondire un modulo specifico? Ad esempio, il sistema di auto-mapping è probabilmente il più complesso da implementare in modo robusto. Oppure preferisci discutere l'architettura di decisione (rule-based vs LLM-based)?
<span style="display:none">[^10][^11][^12][^13][^14][^15][^4][^5][^6][^7][^8][^9]</span>
<div align="center">⁂</div>
[^1]: https://www.reddit.com/r/MUD/comments/ke91kh/can_you_play_a_mud_over_just_sshtelnet/
[^2]: https://www.reddit.com/r/MUD/comments/15mt7pu/telnet_protocol/
[^3]: http://www.aardmud.org/creation.html
[^4]: https://en.wikipedia.org/wiki/Multi-user_dungeon
[^5]: https://www.livinginternet.com/d/di_major.htm
[^6]: https://www.landley.net/history/mirror/games/mud-history.html
[^7]: https://www.oreateai.com/blog/the-origin-and-cultural-impact-of-the-mud-game-the-ancestor-of-mmorpgs/dcbad95e5ff1728227e53e52772ade9a
[^8]: https://if50.substack.com/p/1980-mud
[^9]: https://www.reddit.com/r/MUD/comments/1jktmhd/creating_a_mud/
[^10]: https://www.gamedev.net/forums/topic/401912-making-a-telnet-based-game/
[^11]: https://www.reddit.com/r/MUD/comments/ew4gfe/history_of_muds_question/
[^12]: https://www.reddit.com/r/rpg/comments/1s0yxg/best_character_creation_layout_mechanics/
[^13]: https://www.linnaean.org/~lpb/muddex/mudline.html
[^14]: https://www.linnaean.org/~lpb/muddex/essay/
[^15]: https://akurosia.github.io/DevFFXIVPocketGuide/dalamud/
+117
View File
@@ -0,0 +1,117 @@
in questo vorrei proporre:
Interessante! Questo approccio esiste già ed è chiamato Memvid - una libreria Python open-source che implementa esattamente il flusso che descrivi. Vediamo se ha senso per il tuo automapper MUD.
C'è già: Memvid
Memvid è un progetto GitHub (8.1k stars) che codifica chunk di testo in QR code, li salva come frame in un file MP4 e usa un indice FAISS per la ricerca semantica. Il flusso è identico al tuo:
text
Text → chunk → embed → QR code → Frame MP4 (H.264/H.265)
Query → embed → FAISS cosine similarity → frame seek → decode QR → return text
Benefici confermati
Vantaggio Dettaglio Fonte
Compressione 50-100x I codec video moderni comprimono pattern ripetitivi (i QR) meglio di qualsiasi algoritmo custom
​
Sub-100ms retrieval Seek diretto al frame tramite indice, O(1), poi decodifica QR
Zero infrastruttura Solo Python + MP4, niente cluster DB, niente Docker
​
Portabilità totale Copia memoria.mp4 su un altro sistema, funziona ovunque
​
Offline-first Dopo l'encoding, tutto funziona senza internet
​
Benchmark reali
100MB di testo → 1-2MB video
​
Ricerca su 1M chunk in <100ms (MacBook Pro 2021)
​
RAM costante: ~500MB indipendentemente dalla dimensione
​
Il problema per il tuo use case
L'automapper di un MUD ha una caratteristica critica: aggiornamenti continui mentre esplori. E qui Memvid mostra i limiti:
​
Write Amplification
I file MP4 sono append-only. Se stai esplorando una nuova stanza nel MUD e vuoi aggiungerla alla mappa, non puoi semplicemente "inserire" un frame nel mezzo. Devi:
Decodificare tutto il video esistente
Aggiungere il nuovo chunk
Re-encodare l'intero MP4
Questo è killer per un'esperienza real-time dove ogni nuova stanza dovrebbe essere memorizzata immediatamente.
Sync tra indice e video
L'indice FAISS è un JSON separato che mappa embedding → numero frame. Se il video viene rigenerato, l'indice deve essere rigenerato. In uno scenario MUD con esplorazione continua, staresti re-encodando ogni volta che muovi il personaggio in una stanza nuova.
Soluzione ibrida suggerita
Per l'automapper MUD, ti propongo un archivio a due livelli:
Layer 1: Memoria attiva (SQLite/grafico in-memory)
Durante l'esplorazione: usa SQLite o un grafo NetworkX in RAM
Ogni nuova stanza viene inserita immediatamente
Navigazione, pathfinding, ricerca in tempo reale
Layer 2: Memoria storica (Memvid)
Periodicamente (alla fine sessione, o ogni X stanze): "congela" lo stato esplorato
Dump del grafo → Memvid MP4
Questo diventa l'archivio storico della mappa del mondo MUD
Per ricerche semantiche ("dove era quell'NPC goblin?"), query su Memvid
Vantaggio di questo ibrido
Real-time: SQLite per l'esplorazione attiva, nessun lag
Archivio: Memvid per storage storico compresso e portatile
Ricerca semantica: "Trova tutte le stanze con descrizione 'foresta oscura'" usando l'embedding dei testi delle stanze salvate in Memvid
Altra alternativa: "Delta encoding" in arrivo
Memvid v2 promette delta encoding per aggiornamenti incrementali senza re-encoding totale, ma per ora è in sviluppo. Nel frattempo, il pattern "SQLite attivo + Memvid storico" è il più pragmatico.
​
Conclusione
Il metodo QR-in-MP4 è valido e funziona (Memvid lo dimostra), ma per un automapper MUD serve un ibrido: grafo attivo in SQLite per l'esplorazione real-time, con periodico "snapshot" in Memvid per archiviazione compressa e ricerca semantica storica. Vuoi che approfondiamo l'architettura di questo ibrido?
### Modulo 6: Progression Optimizer (il min-maxer)
La logica di crescita del personaggio.
- **Grinding spot finder**: Identificare zone dove il rapporto EXP/ora è ottimale per il livello corrente
- **Equip optimizer**: Confrontare gear basato su stat bonus
- **Skill rotation**: Scegliere quali abilità usare in combattimento in base a mana cost / damage / cooldown
## Flusso di gioco simulato
```
1. CONNESSIONE → Telnet al server, autenticazione
2. CREAZIONE → Roll stats, scegli classe, setup iniziale
3. EXPLORING → Inizia mappando la città di partenza, memorizza shop/inn/quest-giver
4. COMBATTIMENTO → Trova mob appropriato al livello, combatte, accumula EXP
5. QUESTING → Accetta quest, pianifica esecuzione, completa, riscatta reward
6. LOOP → Torna a 4-5 fino al level up, poi espandi aree esplorabili
```
## Stack tecnico suggerito
- **Backend**: Python con `asyncio` per la gestione Telnet non-bloccante
- **Parser**: Regex + NLP (spaCy/LLM) per interpretare le descrizioni
- **Grafo**: NetworkX per l'auto-mapping
- **AI Decision**: Può essere una serie di regole (expert system) oppure integrare un LLM locale (Llama via Ollama) per decisioni più "intuitive"
- **Storage**: SQLite per mappare le stanze e SQLite/JSON per il progresso del personaggio
## Sfide interessanti
- **Ambiguità testuali**: I MUD hanno descrizioni in prosa, non dati strutturati. Estrarre "questo è un mob ostile" vs "questo è un NPC friendly" richiede parsing intelligente.
- **Anti-cheat**: Molti MUD hanno sistemi di rilevamento bot. Il tuo "giocatore simulato" deve mimare comportamenti umani (tempi di reazione variabili, pause, errori di battitura occasionali).
- **Memoria a lungo termine**: Dopo settimane di gioco, il grafo del mondo diventa enorme. Serve una buona strategia di storage/indexing.
Vuoi approfondire un modulo specifico? Ad esempio, il sistema di auto-mapping è probabilmente il più complesso da implementare in modo robusto. Oppure preferisci discutere l'architettura di decisione (rule-based vs LLM-based)?
+2
View File
@@ -38,6 +38,8 @@ class LLMProviderConfig(BaseModel):
"You must respond ONLY with the requested format. "
"Be concise and precise. No explanations unless asked."
)
memory_db_path: str = "data/llm_memory.db"
"""Path to SQLite database for LLM long-term memory."""
class AIConfig(BaseModel):
+22 -6
View File
@@ -76,7 +76,7 @@ async def main_loop(config):
max_retries=config.ai.llm.max_retries,
system_prompt=config.ai.llm.system_prompt,
)
llm_brain = LLMBrain(llm_config)
llm_brain = LLMBrain(llm_config, memory_db_path=config.ai.llm.memory_db_path)
social_ai = SocialAI(
client=llm_brain.client,
character_name=config.character.name,
@@ -207,14 +207,30 @@ async def main_loop(config):
if llm_brain.is_available:
social_event = social_ai.detect_social_event(block)
if social_event:
speaker = social_event["speaker"]
message = social_event["message"]
event_type = social_event["type"]
logger.info(
"Social: %s from %s: %s",
social_event["type"],
social_event["speaker"],
social_event["message"][:100],
event_type,
speaker,
message[:100],
)
reply_cmd = await social_ai.respond(social_event)
if reply_cmd:
# Use LLMBrain with memory for contextual replies
reply_text = await llm_brain.social_reply(
message=message,
sender=speaker,
character_class=config.character.preferred_class,
character_name=config.character.name,
)
if reply_text:
# Format as MUD command
if event_type == "tell":
reply_cmd = f"tell {speaker} {reply_text}"
elif event_type == "shout":
reply_cmd = f"shout {reply_text}"
else:
reply_cmd = f"say {reply_text}"
await human_behavior.delay()
await telnet.send(reply_cmd)
logger.info("Social reply: %s", reply_cmd)
+3
View File
@@ -12,6 +12,7 @@ All LLM features gracefully degrade when Ollama is unavailable.
from .client import LLMClient, LLMConfig, LLMResponse, render_template, PROMPT_TEMPLATES
from .brain import LLMBrain
from .social_ai import SocialAI
from .memory import LLMMemory, MemoryEntry
__all__ = [
"LLMClient",
@@ -19,6 +20,8 @@ __all__ = [
"LLMResponse",
"LLMBrain",
"SocialAI",
"LLMMemory",
"MemoryEntry",
"render_template",
"PROMPT_TEMPLATES",
]
+26 -5
View File
@@ -20,6 +20,7 @@ from dataclasses import dataclass
from typing import Dict, List, Optional, TYPE_CHECKING
from .client import LLMClient, LLMConfig, LLMResponse, render_template
from .memory import LLMMemory
if TYPE_CHECKING:
from ...core.game_state import GameState
@@ -48,19 +49,25 @@ class LLMBrain:
parsed = await brain.parse_ambiguous("Something strange happens...")
"""
def __init__(self, config: Optional[LLMConfig] = None) -> None:
def __init__(
self,
config: Optional[LLMConfig] = None,
memory_db_path: str = "data/llm_memory.db",
) -> None:
self.client = LLMClient(config)
self.memory = LLMMemory(memory_db_path)
self._available = False
self._query_count = 0
self._total_tokens = 0
self._total_latency_ms = 0.0
async def initialize(self) -> bool:
"""Check LLM availability. Returns ``True`` if the server is reachable."""
"""Check LLM availability and initialize memory. Returns ``True`` if server reachable."""
await self.memory.initialize()
self._available = await self.client.is_available()
if self._available:
logger.info(
"LLMBrain online: model=%s, url=%s",
"LLMBrain online: model=%s, url=%s, memory_initialized",
self.client.config.model,
self.client.config.base_url,
)
@@ -111,22 +118,32 @@ class LLMBrain:
async def social_reply(
self,
message: str,
sender: str = "unknown",
character_class: str = "adventurer",
character_name: str = "Player",
) -> Optional[str]:
"""Generate an in-character reply to another player's message.
Uses conversation history to maintain context.
Returns the reply string, or ``None`` on failure.
"""
if not self._available:
return None
resp = await self.client.ask(
"social_reply",
# Get context from memory
context = await self.memory.build_context_prompt(sender, max_tokens=200)
# Enhanced prompt template with context
prompt = render_template(
"social_reply_with_context",
context=context,
sender=sender,
message=message,
character_class=character_class,
character_name=character_name,
)
resp = await self.client.generate(prompt)
self._record(resp)
if not resp.success or not resp.text:
@@ -137,6 +154,10 @@ class LLMBrain:
# Limit length for MUD output
if len(reply) > 200:
reply = reply[:197] + "..."
# Record the conversation
await self.memory.add_conversation(sender, message, reply, importance=0.6)
return reply
# ------------------------------------------------------------------
+9
View File
@@ -134,6 +134,15 @@ PROMPT_TEMPLATES: Dict[str, str] = {
"Reply with JSON: {{\"hostile\": true/false, \"confidence\": 0.0-1.0, \"reason\": \"...\"}}\n"
"JSON:"
),
"social_reply_with_context": (
"{context}"
"You are {character_name}, a {character_class} in a MUD game. "
"Another player ({sender}) said: \"{message}\"\n"
"Write a short in-character reply (max 1 sentence). "
"Consider your previous interactions shown above. "
"Reply:"
),
}
+471
View File
@@ -0,0 +1,471 @@
"""Long-term memory system for LLM context persistence.
Stores conversation history, important events, and player relationships
across sessions. Integrates with both active (SQLite) and historical
(Memvid) storage for persistence.
Usage::
from ..config.settings import get_settings
from .memory import LLMMemory
memory = LLMMemory(settings.ai.llm.memory_db_path)
await memory.initialize()
# Record a conversation
await memory.add_conversation("Alice", "Hey, want to group up?", "Sure!")
# Recall recent context for a prompt
context = await memory.get_conversation_context("Alice", limit=5)
# Record an important event
await memory.add_event("gained_level", {"level": 10, "class": "warrior"})
# Search memories
memories = await memory.search_memories("dragon", limit=10)
"""
from __future__ import annotations
import json
import logging
import sqlite3
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional
logger = logging.getLogger(__name__)
@dataclass
class MemoryEntry:
"""A single memory entry."""
id: int
timestamp: datetime
category: str # "conversation", "event", "npc_interaction", "quest"
key: str # player name, npc name, event type, etc.
data: Dict
importance: float # 0.0-1.0, for prioritization
class LLMMemory:
"""Long-term memory manager for LLM context.
Maintains:
- Conversation history with other players
- NPC interactions and reputations
- Important game events
- Quest progress memories
"""
def __init__(self, db_path: str = "data/llm_memory.db") -> None:
self.db_path = Path(db_path)
self._conn: Optional[sqlite3.Connection] = None
async def initialize(self) -> None:
"""Initialize the memory database."""
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._conn = sqlite3.connect(str(self.db_path), check_same_thread=False)
self._conn.row_factory = sqlite3.Row
await self._create_tables()
logger.info("LLMMemory initialized: %s", self.db_path)
async def _create_tables(self) -> None:
"""Create the memory tables."""
self._conn.executescript("""
CREATE TABLE IF NOT EXISTS memories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
category TEXT NOT NULL,
key TEXT NOT NULL,
data TEXT NOT NULL,
importance REAL DEFAULT 0.5,
summary TEXT
);
CREATE INDEX IF NOT EXISTS idx_memories_category
ON memories(category);
CREATE INDEX IF NOT EXISTS idx_memories_key
ON memories(key);
CREATE INDEX IF NOT EXISTS idx_memories_timestamp
ON memories(timestamp);
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts
USING fts5(summary, data, content=memories, content_rowid=id);
CREATE TABLE IF NOT EXISTS npc_reputations (
npc_name TEXT PRIMARY KEY,
reputation_score REAL DEFAULT 0.0,
interactions_count INTEGER DEFAULT 0,
last_interaction TEXT,
notes TEXT
);
CREATE TABLE IF NOT EXISTS player_relationships (
player_name TEXT PRIMARY KEY,
relationship_score REAL DEFAULT 0.0,
first_met TEXT,
last_interaction TEXT,
notes TEXT,
conversation_count INTEGER DEFAULT 0
);
""")
self._conn.commit()
async def add_conversation(
self,
player_name: str,
message: str,
reply: str,
importance: float = 0.5,
) -> None:
"""Record a conversation with another player."""
if not self._conn:
return
now = datetime.utcnow().isoformat()
summary = f"Conversation with {player_name}: {message[:50]}..."
data = {
"player": player_name,
"message": message,
"reply": reply,
"direction": "received" if message else "sent",
}
cursor = self._conn.execute(
"""INSERT INTO memories (timestamp, category, key, data, importance, summary)
VALUES (?, ?, ?, ?, ?, ?)""",
(now, "conversation", player_name, json.dumps(data), importance, summary)
)
# Update FTS index
self._conn.execute(
"INSERT INTO memories_fts (rowid, summary, data) VALUES (?, ?, ?)",
(cursor.lastrowid, summary, json.dumps(data))
)
# Update player relationship
self._conn.execute(
"""INSERT INTO player_relationships (player_name, last_interaction, conversation_count)
VALUES (?, ?, 1)
ON CONFLICT(player_name) DO UPDATE SET
last_interaction = excluded.last_interaction,
conversation_count = conversation_count + 1,
first_met = COALESCE(first_met, excluded.last_interaction)""",
(player_name, now)
)
self._conn.commit()
async def get_conversation_context(
self,
player_name: str,
limit: int = 5,
) -> List[Dict]:
"""Get recent conversation history with a player."""
if not self._conn:
return []
cursor = self._conn.execute(
"""SELECT timestamp, data, importance FROM memories
WHERE category = 'conversation' AND key = ?
ORDER BY timestamp DESC
LIMIT ?""",
(player_name, limit)
)
results = []
for row in cursor:
data = json.loads(row["data"])
results.append({
"timestamp": row["timestamp"],
"message": data.get("message", ""),
"reply": data.get("reply", ""),
"importance": row["importance"],
})
return list(reversed(results)) # Oldest first
async def add_npc_interaction(
self,
npc_name: str,
interaction_type: str, # "talk", "combat", "trade", "quest"
details: Dict,
reputation_delta: float = 0.0,
) -> None:
"""Record an interaction with an NPC."""
if not self._conn:
return
now = datetime.utcnow().isoformat()
summary = f"{interaction_type} with {npc_name}: {str(details)[:50]}"
data = {
"npc": npc_name,
"type": interaction_type,
"details": details,
}
cursor = self._conn.execute(
"""INSERT INTO memories (timestamp, category, key, data, importance, summary)
VALUES (?, ?, ?, ?, ?, ?)""",
(now, "npc_interaction", npc_name, json.dumps(data), 0.6, summary)
)
self._conn.execute(
"INSERT INTO memories_fts (rowid, summary, data) VALUES (?, ?, ?)",
(cursor.lastrowid, summary, json.dumps(data))
)
# Update NPC reputation
self._conn.execute(
"""INSERT INTO npc_reputations (npc_name, reputation_score, interactions_count, last_interaction)
VALUES (?, ?, 1, ?)
ON CONFLICT(npc_name) DO UPDATE SET
reputation_score = reputation_score + ?,
interactions_count = interactions_count + 1,
last_interaction = excluded.last_interaction""",
(npc_name, reputation_delta, now, reputation_delta)
)
self._conn.commit()
async def get_npc_reputation(self, npc_name: str) -> Optional[Dict]:
"""Get reputation info for an NPC."""
if not self._conn:
return None
cursor = self._conn.execute(
"""SELECT * FROM npc_reputations WHERE npc_name = ?""",
(npc_name,)
)
row = cursor.fetchone()
if row:
return {
"npc_name": row["npc_name"],
"reputation_score": row["reputation_score"],
"interactions_count": row["interactions_count"],
"last_interaction": row["last_interaction"],
"notes": row["notes"],
}
return None
async def add_event(
self,
event_type: str,
details: Dict,
importance: float = 0.7,
) -> None:
"""Record an important game event."""
if not self._conn:
return
now = datetime.utcnow().isoformat()
summary = f"{event_type}: {str(details)[:50]}"
data = {
"type": event_type,
"details": details,
}
cursor = self._conn.execute(
"""INSERT INTO memories (timestamp, category, key, data, importance, summary)
VALUES (?, ?, ?, ?, ?, ?)""",
(now, "event", event_type, json.dumps(data), importance, summary)
)
self._conn.execute(
"INSERT INTO memories_fts (rowid, summary, data) VALUES (?, ?, ?)",
(cursor.lastrowid, summary, json.dumps(data))
)
self._conn.commit()
async def search_memories(
self,
query: str,
category: Optional[str] = None,
limit: int = 10,
) -> List[MemoryEntry]:
"""Search memories using full-text search."""
if not self._conn:
return []
if category:
cursor = self._conn.execute(
"""SELECT m.* FROM memories m
JOIN memories_fts fts ON m.id = fts.rowid
WHERE memories_fts MATCH ? AND m.category = ?
ORDER BY rank, m.importance DESC
LIMIT ?""",
(query, category, limit)
)
else:
cursor = self._conn.execute(
"""SELECT m.* FROM memories m
JOIN memories_fts fts ON m.id = fts.rowid
WHERE memories_fts MATCH ?
ORDER BY rank, m.importance DESC
LIMIT ?""",
(query, limit)
)
results = []
for row in cursor:
results.append(MemoryEntry(
id=row["id"],
timestamp=datetime.fromisoformat(row["timestamp"]),
category=row["category"],
key=row["key"],
data=json.loads(row["data"]),
importance=row["importance"],
))
return results
async def get_important_memories(
self,
min_importance: float = 0.7,
limit: int = 20,
) -> List[MemoryEntry]:
"""Get the most important recent memories."""
if not self._conn:
return []
cursor = self._conn.execute(
"""SELECT * FROM memories
WHERE importance >= ?
ORDER BY timestamp DESC
LIMIT ?""",
(min_importance, limit)
)
results = []
for row in cursor:
results.append(MemoryEntry(
id=row["id"],
timestamp=datetime.fromisoformat(row["timestamp"]),
category=row["category"],
key=row["key"],
data=json.loads(row["data"]),
importance=row["importance"],
))
return results
async def build_context_prompt(
self,
current_player: Optional[str] = None,
max_tokens: int = 500,
) -> str:
"""Build a context prompt from recent important memories.
This creates a summary of relevant memories to prepend to LLM prompts.
"""
memories = await self.get_important_memories(limit=10)
if current_player:
# Add conversation context with current player
conversations = await self.get_conversation_context(current_player, limit=3)
for conv in conversations:
memories.append(MemoryEntry(
id=0,
timestamp=datetime.fromisoformat(conv["timestamp"]),
category="conversation",
key=current_player,
data=conv,
importance=conv.get("importance", 0.5),
))
# Sort by importance and recency
memories.sort(key=lambda m: (m.importance, m.timestamp), reverse=True)
context_parts = []
token_count = 0
for mem in memories[:10]: # Max 10 memories
if mem.category == "conversation":
part = f"- Talked with {mem.key}: {mem.data.get('message', '')[:50]}\n"
elif mem.category == "event":
part = f"- {mem.data.get('type', 'Event')}: {str(mem.data.get('details', {}))[:50]}\n"
elif mem.category == "npc_interaction":
part = f"- Met {mem.key} ({mem.data.get('type', 'unknown')})\n"
else:
part = f"- {mem.category}: {mem.key}\n"
# Rough token estimate: 4 chars ≈ 1 token
estimated_tokens = len(part) // 4
if token_count + estimated_tokens > max_tokens:
break
context_parts.append(part)
token_count += estimated_tokens
if not context_parts:
return ""
return "Previous memories:\n" + "".join(context_parts) + "\n"
async def export_to_json(self, filepath: str) -> None:
"""Export all memories to JSON for backup/synchronization."""
if not self._conn:
return
cursor = self._conn.execute("SELECT * FROM memories ORDER BY timestamp")
memories = []
for row in cursor:
memories.append({
"id": row["id"],
"timestamp": row["timestamp"],
"category": row["category"],
"key": row["key"],
"data": json.loads(row["data"]),
"importance": row["importance"],
"summary": row["summary"],
})
Path(filepath).parent.mkdir(parents=True, exist_ok=True)
with open(filepath, 'w') as f:
json.dump(memories, f, indent=2)
logger.info("Exported %d memories to %s", len(memories), filepath)
async def import_from_json(self, filepath: str) -> int:
"""Import memories from JSON backup."""
if not self._conn or not Path(filepath).exists():
return 0
with open(filepath) as f:
memories = json.load(f)
count = 0
for mem in memories:
try:
self._conn.execute(
"""INSERT OR IGNORE INTO memories
(timestamp, category, key, data, importance, summary)
VALUES (?, ?, ?, ?, ?, ?)""",
(
mem["timestamp"],
mem["category"],
mem["key"],
json.dumps(mem["data"]),
mem["importance"],
mem.get("summary", ""),
)
)
count += 1
except Exception as e:
logger.warning("Failed to import memory %s: %s", mem.get("id"), e)
self._conn.commit()
logger.info("Imported %d memories from %s", count, filepath)
return count
async def close(self) -> None:
"""Close the database connection."""
if self._conn:
self._conn.close()
self._conn = None
+198
View File
@@ -0,0 +1,198 @@
"""Tests for LLMMemory system"""
import pytest
import asyncio
from pathlib import Path
from datetime import datetime
from src.modules.llm.memory import LLMMemory, MemoryEntry
@pytest.fixture
async def memory(tmp_path):
"""Create a temporary memory instance for testing."""
db_path = tmp_path / "test_memory.db"
mem = LLMMemory(str(db_path))
await mem.initialize()
yield mem
await mem.close()
class TestLLMMemory:
"""Test suite for LLMMemory."""
@pytest.mark.asyncio
async def test_initialization(self, tmp_path):
"""Test that memory initializes correctly."""
db_path = tmp_path / "test_init.db"
mem = LLMMemory(str(db_path))
await mem.initialize()
assert db_path.exists()
await mem.close()
@pytest.mark.asyncio
async def test_add_conversation(self, memory):
"""Test adding conversations."""
await memory.add_conversation(
"Alice", "Hey there!", "Hello! How are you?",
importance=0.7
)
context = await memory.get_conversation_context("Alice", limit=5)
assert len(context) == 1
assert context[0]["message"] == "Hey there!"
assert context[0]["reply"] == "Hello! How are you?"
assert context[0]["importance"] == 0.7
@pytest.mark.asyncio
async def test_multiple_conversations(self, memory):
"""Test multiple conversations with same player."""
await memory.add_conversation("Bob", "Msg 1", "Reply 1")
await memory.add_conversation("Bob", "Msg 2", "Reply 2")
await memory.add_conversation("Bob", "Msg 3", "Reply 3")
context = await memory.get_conversation_context("Bob", limit=2)
assert len(context) == 2
# Should be in chronological order
assert context[0]["message"] == "Msg 2"
assert context[1]["message"] == "Msg 3"
@pytest.mark.asyncio
async def test_npc_interaction(self, memory):
"""Test recording NPC interactions."""
await memory.add_npc_interaction(
"Guard", "talk", {"topic": "directions"}, reputation_delta=0.2
)
rep = await memory.get_npc_reputation("Guard")
assert rep is not None
assert rep["reputation_score"] == 0.2
assert rep["interactions_count"] == 1
@pytest.mark.asyncio
async def test_npc_reputation_accumulation(self, memory):
"""Test that NPC reputation accumulates."""
await memory.add_npc_interaction("Dragon", "combat", {}, reputation_delta=-0.5)
await memory.add_npc_interaction("Dragon", "combat", {}, reputation_delta=-0.3)
rep = await memory.get_npc_reputation("Dragon")
assert rep["reputation_score"] == -0.8
assert rep["interactions_count"] == 2
@pytest.mark.asyncio
async def test_add_event(self, memory):
"""Test recording important events."""
await memory.add_event(
"level_up", {"level": 10, "class": "warrior"}, importance=0.9
)
memories = await memory.get_important_memories(min_importance=0.8)
assert len(memories) == 1
assert memories[0].key == "level_up"
assert memories[0].data["details"]["level"] == 10
@pytest.mark.asyncio
async def test_search_memories(self, memory):
"""Test full-text search."""
await memory.add_conversation("Charlie", "Want to fight the dragon?", "Sure!")
await memory.add_conversation("Dave", "Let's explore the forest", "Okay")
await memory.add_event("found_item", {"item": "dragon scale"})
results = await memory.search_memories("dragon")
assert len(results) >= 2 # Should find conversation and event
dragon_conversation = [r for r in results if r.category == "conversation"]
assert len(dragon_conversation) == 1
@pytest.mark.asyncio
async def test_search_with_category(self, memory):
"""Test searching with category filter."""
await memory.add_conversation("Eve", "Hello", "Hi")
await memory.add_event("combat", {"enemy": "goblin"})
# Search only events
results = await memory.search_memories("goblin", category="event")
assert len(results) == 1
assert results[0].category == "event"
@pytest.mark.asyncio
async def test_build_context_prompt(self, memory):
"""Test building context prompt."""
await memory.add_conversation("Frank", "Hello!", "Hey!", importance=0.8)
await memory.add_event("quest_complete", {"quest": "tutorial"}, importance=0.9)
context = await memory.build_context_prompt("Frank", max_tokens=500)
assert "Previous memories:" in context
assert "Frank" in context
@pytest.mark.asyncio
async def test_export_import_json(self, memory, tmp_path):
"""Test exporting and importing memories."""
# Add some data
await memory.add_conversation("Grace", "Test", "Reply")
await memory.add_event("test_event", {"test": True})
# Export
export_path = tmp_path / "memories_export.json"
await memory.export_to_json(str(export_path))
assert export_path.exists()
# Create new memory and import
new_mem = LLMMemory(str(tmp_path / "new_memory.db"))
await new_mem.initialize()
count = await new_mem.import_from_json(str(export_path))
assert count == 2
# Verify data
context = await new_mem.get_conversation_context("Grace")
assert len(context) == 1
await new_mem.close()
@pytest.mark.asyncio
async def test_player_relationship_tracking(self, memory):
"""Test that player relationships are tracked."""
await memory.add_conversation("Henry", "Hey!", "Hello!")
await memory.add_conversation("Henry", "How are you?", "Good!")
# Check that relationship was created
# Note: We don't have a direct getter, but we can infer from conversation count
context = await memory.get_conversation_context("Henry")
assert len(context) == 2
@pytest.mark.asyncio
async def test_empty_memory_operations(self, memory):
"""Test operations on empty memory."""
context = await memory.get_conversation_context("Nobody", limit=5)
assert context == []
rep = await memory.get_npc_reputation("UnknownNPC")
assert rep is None
results = await memory.search_memories("nonexistent")
assert results == []
prompt = await memory.build_context_prompt("Nobody")
assert prompt == ""
class TestMemoryEntry:
"""Test MemoryEntry dataclass."""
def test_memory_entry_creation(self):
"""Test creating a MemoryEntry."""
entry = MemoryEntry(
id=1,
timestamp=datetime.now(),
category="test",
key="test_key",
data={"value": 123},
importance=0.5
)
assert entry.id == 1
assert entry.category == "test"
assert entry.key == "test_key"
assert entry.data["value"] == 123
assert entry.importance == 0.5
+3 -2
View File
@@ -172,10 +172,11 @@ class TestLLMBrain:
async def test_social_reply_with_mock(self):
brain = LLMBrain()
brain._available = True
brain.client.ask = AsyncMock(
# Mock generate since social_reply now uses it with context
brain.client.generate = AsyncMock(
return_value=_make_mock_response("Greetings, fellow adventurer!")
)
result = await brain.social_reply("Hello!")
result = await brain.social_reply("Hello!", sender="TestPlayer")
assert result is not None
assert "adventurer" in result.lower()