]> vgcfreebox.myrthtech.pt Git - alentejosemlei.git/blobdiff - redis_bridge.py
agent puppeteer method and asl module
[alentejosemlei.git] / redis_bridge.py
index da40a5b90cd4a4371f8b0d87b0ff47f3a290e187..a45944e8d595bb62bad99de76ad30ad34a006e3a 100644 (file)
@@ -4,23 +4,89 @@ import aiohttp
 import redis.asyncio as redis
 import re
 import os
 import redis.asyncio as redis
 import re
 import os
+import time
+import chromadb
 
 # --- CONFIGURATION ---
 MAX_CONCURRENT_OLLAMA_REQUESTS = 3 
 ALLOW_TEXT_EMOTES = False
 
 
 # --- CONFIGURATION ---
 MAX_CONCURRENT_OLLAMA_REQUESTS = 3 
 ALLOW_TEXT_EMOTES = False
 
-# --- LOAD THE WORLD LORE ON STARTUP ---
-WORLD_LORE = ""
-if os.path.exists("asl_lore.txt"):
-    with open("asl_lore.txt", "r", encoding="utf-8") as f:
-        WORLD_LORE = f.read().strip()
+# =====================================================================
+# 1. INITIALIZE VECTOR DATABASES (Lore & Episodic Memory)
+# =====================================================================
+print("Initializing ChromaDB Vector Databases...")
+chroma_client = chromadb.PersistentClient(path="./asl_vectordb")
+
+# The Lore Database
+lore_collection = chroma_client.get_or_create_collection(name="world_lore")
+
+# --- NEW: The Episodic Memory Database ---
+memory_collection = chroma_client.get_or_create_collection(name="episodic_memories")
+memory_queue = asyncio.Queue()
+
+if os.path.exists("asl_lore.md"):
+    if lore_collection.count() == 0:
+        print("[VECTOR DB] Reading asl_lore.txt and vectorizing chunks...")
+        with open("asl_lore.txt", "r", encoding="utf-8") as f:
+            raw_lore = f.read()
+        
+        lore_chunks = [chunk.strip() for chunk in raw_lore.split('\n\n') if chunk.strip()]
+        
+        if lore_chunks:
+            chunk_ids = [f"lore_{i}" for i in range(len(lore_chunks))]
+            lore_collection.add(documents=lore_chunks, ids=chunk_ids)
+            print(f"[VECTOR DB] Successfully stored {len(lore_chunks)} lore chunks!")
 else:
 else:
-    print("[WARNING] asl_lore.txt/asl_lore.txt not found. Running without global lore.")
+    print("[WARNING] asl_lore.txt not found.")
 
 semaphore = asyncio.Semaphore(MAX_CONCURRENT_OLLAMA_REQUESTS)
 
 semaphore = asyncio.Semaphore(MAX_CONCURRENT_OLLAMA_REQUESTS)
-# Dictionary to keep individual conversations separate
 chat_memory = {}
 
 chat_memory = {}
 
+# =====================================================================
+# ---  BACKGROUND MEMORY SUMMARIZER (The "Dream State") ---
+# =====================================================================
+async def memory_summarizer_worker(session):
+    print("[BACKGROUND] Memory Summarizer Worker is active.")
+    while True:
+        job = await memory_queue.get()
+        session_id = job['session_id']
+        player_name = job['player_name']
+        npc_tag = job['npc_tag']
+        chat_log = job['chat_log']
+
+        prompt = f"Summarize the key events, facts, and the emotional tone of this conversation snippet between {player_name} and {npc_tag}. Keep it to 2 brief sentences in the past tense.\nConversation Log:\n{chat_log}"
+
+        try:
+            print(f"[MEMORY DB] Generating background memory for {player_name} and {npc_tag}...")
+            # We use /api/generate here because we just want a raw text summary, not a JSON macro
+            async with session.post('http://localhost:11434/api/generate', json={
+                "model": "gemma4",
+                "prompt": prompt,
+                "stream": False,
+                "options": {
+                    "temperature": 0.1
+                }
+            }) as response:
+                result = await response.json()
+                summary = result['response'].strip()
+                
+                if summary:
+                    doc_id = f"{session_id}_{int(time.time())}"
+                    # We store the session_id as metadata so NPCs only recall their OWN memories with this specific player
+                    memory_collection.add(
+                        documents=[summary],
+                        metadatas=[{"session_id": session_id}],
+                        ids=[doc_id]
+                    )
+                    print(f"[MEMORY DB] Memory Saved: {summary}")
+
+        except Exception as e:
+            print(f"[MEMORY ERROR] Failed to summarize memory: {e}")
+        
+        memory_queue.task_done()
+# =====================================================================
+
+
 async def process_message(r, session, message_data):
     try:
         data = json.loads(message_data)
 async def process_message(r, session, message_data):
     try:
         data = json.loads(message_data)
@@ -28,56 +94,61 @@ async def process_message(r, session, message_data):
         npc_tag = data.get('npc_tag', 'UnknownNPC')
         message = data.get('message', '')
         
         npc_tag = data.get('npc_tag', 'UnknownNPC')
         message = data.get('message', '')
         
-        # --- THE ASTERISK SCRUBBER ---
         if not ALLOW_TEXT_EMOTES:
             message = re.sub(r'\*.*?\*', '', message).strip()
 
         if not ALLOW_TEXT_EMOTES:
             message = re.sub(r'\*.*?\*', '', message).strip()
 
-        # Extract the dynamic variables sent from the Aurora Toolset/Engine
         player_race = data.get('player_race', 'Unknown')
         player_alignment = data.get('player_alignment', 'Unknown')
         nearby_players = data.get('nearby_players', '')
         
         player_race = data.get('player_race', 'Unknown')
         player_alignment = data.get('player_alignment', 'Unknown')
         nearby_players = data.get('nearby_players', '')
         
-        # --- Extract States and Health ---
-        player_state = data.get('player_state', 'Relaxed and unarmed.')
-        world_state = data.get('world_state', 'Nothing of note is happening.')
-        # --- Extract Geographic Awareness ---
-        location_context = data.get('location_context', 'You are in a generic area.')
-        # --- Extract NPC health ---
-        npc_health = data.get('npc_health', 'Healthy and uninjured.')
-        # --- Extract Relationship ---
-        relationship = data.get('relationship', 'Neutral or Friendly.')
-
-        # Extract the decoupled NPC attributes
         npc_persona = data.get('persona', 'You are a generic citizen.')
         npc_profession = data.get('profession', 'Commoner')
         npc_mood = data.get('mood', 'Neutral')
         npc_secret = data.get('secret', '')
         
         npc_persona = data.get('persona', 'You are a generic citizen.')
         npc_profession = data.get('profession', 'Commoner')
         npc_mood = data.get('mood', 'Neutral')
         npc_secret = data.get('secret', '')
         
-        # Extract Character Traits & Native Engine Data
         npc_alignment = data.get('npc_alignment', 'True Neutral')
         npc_gender = data.get('npc_gender', 'Unknown')
         npc_race = data.get('npc_race', 'Creature')
         npc_routine = data.get('npc_routine', '')
         
         npc_alignment = data.get('npc_alignment', 'True Neutral')
         npc_gender = data.get('npc_gender', 'Unknown')
         npc_race = data.get('npc_race', 'Creature')
         npc_routine = data.get('npc_routine', '')
         
-        # Build the context strings
-        group_context = ""
-        if nearby_players:
-            group_context = f"Be aware that these other players are listening nearby: {nearby_players}."
-            
-        secret_context = ""
-        if npc_secret:
-            secret_context = f"YOUR SECRET (Reveal only if players are persuasive): {npc_secret}"
-            
-        routine_context = ""
-        if npc_routine:
-            routine_context = f"YOUR REQUIRED ROUTINE: {npc_routine}"
+        player_state = data.get('player_state', 'Relaxed and unarmed.')
+        world_state = data.get('world_state', 'Nothing of note is happening.')
+        npc_health = data.get('npc_health', 'Healthy and uninjured.')
+        relationship = data.get('relationship', 'Neutral or Friendly.')
+        location_context = data.get('location_context', 'You are in a generic area.')
+
+        group_context = f"Be aware that these other players are listening nearby: {nearby_players}." if nearby_players else ""
+        secret_context = f"YOUR SECRET (Reveal only if players are persuasive): {npc_secret}" if npc_secret else ""
+        routine_context = f"YOUR REQUIRED ROUTINE: {npc_routine}" if npc_routine else ""
+
+        session_id = f"{player_name}_{npc_tag}"
 
         # =====================================================================
 
         # =====================================================================
-        # THE PROMPT COMPILER 
+        # 2. THE DUAL RAG QUERY (Lore + Memories)
+        # =====================================================================
+        search_query = f"{location_context} {message}"
+        retrieved_lore = "No specific local lore currently relevant."
+        past_memories = ""
+
+        # Fetch Lore
+        if lore_collection.count() > 0:
+            results = lore_collection.query(query_texts=[search_query], n_results=1)
+            if results['documents'] and results['documents'][0]:
+                retrieved_lore = f"- {results['documents'][0][0]}"
+
+        # Fetch Episodic Memories (ONLY memories between this specific NPC and this specific Player)
+        if memory_collection.count() > 0:
+            mem_results = memory_collection.query(
+                query_texts=[search_query],
+                n_results=2,
+                where={"session_id": session_id} 
+            )
+            if mem_results['documents'] and mem_results['documents'][0]:
+                formatted_mems = "\n- ".join(mem_results['documents'][0])
+                past_memories = f"\nPAST MEMORIES OF {player_name}:\n- {formatted_mems}"
         # =====================================================================
         # =====================================================================
-        dynamic_system_prompt = f"""
 
 
-        {WORLD_LORE}
+        dynamic_system_prompt = f"""
 
         {npc_persona}
         
 
         {npc_persona}
         
@@ -85,19 +156,22 @@ async def process_message(r, session, message_data):
         - Race & Gender: {npc_gender} {npc_race}
         - Profession: {npc_profession}
         - Alignment: {npc_alignment}
         - Race & Gender: {npc_gender} {npc_race}
         - Profession: {npc_profession}
         - Alignment: {npc_alignment}
-        - Conversational Charisma: Based on mood, profession and your character charisma.
+        - Conversational Charisma: Low/Gruff unless otherwise specified.
         - Current Mood: {npc_mood}
         - Current Physical State: {npc_health}
         {secret_context}
         {routine_context}
         
         CURRENT LOCATION: {location_context}
         - Current Mood: {npc_mood}
         - Current Physical State: {npc_health}
         {secret_context}
         {routine_context}
         
         CURRENT LOCATION: {location_context}
-
+        
+        RELEVANT WORLD KNOWLEDGE:
+        {retrieved_lore}
+        {past_memories}
+        
         CURRENT WORLD RUMORS/EVENTS:
         {world_state}
         
         CURRENT WORLD RUMORS/EVENTS:
         {world_state}
         
-        CURRENT TARGET: 
-        You are speaking to {player_name}, who is a {player_alignment} {player_race}. 
+        CURRENT TARGET: You are speaking to {player_name}, who is a {player_alignment} {player_race}. 
         Their physical state: {player_state}
         Relationship to you: {relationship}
         {group_context}
         Their physical state: {player_state}
         Relationship to you: {relationship}
         {group_context}
@@ -110,48 +184,49 @@ async def process_message(r, session, message_data):
         Your "action" key MUST be exactly one of the following words:
         [WANDER, PATROL, FOLLOW, GUARD, GO_TO, INTERACT, USE_OBJECT, RETURN_TO_POST, ATTACK, REST, STEALTH, SEARCH, UNSTEALTH, PEACE, COMMAND]
         
         Your "action" key MUST be exactly one of the following words:
         [WANDER, PATROL, FOLLOW, GUARD, GO_TO, INTERACT, USE_OBJECT, RETURN_TO_POST, ATTACK, REST, STEALTH, SEARCH, UNSTEALTH, PEACE, COMMAND]
         
-        - Use PEACE if you want to accept an apology, de-escalate a fight, surrender, or forgive someone.
-        - Use REST if you are severely injured, out of spells, or exhausted. This will heal you.
-        - Use STEALTH if you need to hide from enemies, sneak past someone, or if you are a rogue preparing an ambush.
-        - Use SEARCH if you suspect traps, are looking for clues, or are trying to find hidden enemies.
-        - Use UNSTEALTH to return to normal walking/visibility.
-        - Use COMMAND if you are a leader and want to order your minions.
-          For "action_target", you MUST use one of these specific tactical targets:
-          1. The name of a specific Player (to focus all minion attacks on them).
-          2. "RETREAT" (to order all minions to run away and regroup).
-          3. "DEFEND_ME" (to order all minions to surround you).
-
-        - If your action involves a specific person or object, set "action_target" to their name (e.g. "Geron Webber", "Wine Cup", "Shrine of Umberlee").
-        - If your action is general (like WANDER, REST, SEARCH, STEALTH, UNSTEALTH), leave "action_target" as an empty string.
-
-        You MUST respect your current mood and routine:
-        - Mood affects tone and willingness to help.
-        - Routine describes duties you should try to follow unless there is a strong reason not to.
-        
-        EMOTION RULE:
-        Your "emotion" key MUST be exactly one of the following words: 
-        [NEUTRAL, LAUGHING, ANGRY, PLEADING, BOW, TAUNT, CHEER]. Do not invent new emotions. Do not perform writen emotions in text with **.
+       ACTION RULE:
+        Your "action" key MUST be exactly one of the following words:
+        [WANDER, PATROL, FOLLOW, GUARD, GO_TO, INTERACT, USE_OBJECT, RETURN_TO_POST, ATTACK, REST, STEALTH, SEARCH, UNSTEALTH, PEACE, COMMAND, CONVERSE]
         
         
+        - Use CONVERSE if you want to initiate a back-and-forth dialogue with a standard, unintelligent NPC. You will write their response for them.
+
         YOUR RESPONSE MUST BE A SINGLE, VALID JSON OBJECT. YOU MUST USE THIS EXACT TEMPLATE:
         {{
             "thought": "Your internal reasoning here.",
         YOUR RESPONSE MUST BE A SINGLE, VALID JSON OBJECT. YOU MUST USE THIS EXACT TEMPLATE:
         {{
             "thought": "Your internal reasoning here.",
-            "speech": "You MUST say something out loud. If you don't want to talk, output something your character would do.",
+            "speech": "What YOU say out loud.",
             "emotion": "MACRO WORD",
             "action": "MACRO WORD",
             "emotion": "MACRO WORD",
             "action": "MACRO WORD",
-            "action_target": "Target name"
+            "action_target": "Target name",
+            "target_speech": "If action is CONVERSE, write what the target NPC replies back to you here. Otherwise, leave blank."
         }}
         """
         }}
         """
-
-        session_id = f"{player_name}_{npc_tag}"
         
         if session_id not in chat_memory:
             chat_memory[session_id] = [{"role": "system", "content": dynamic_system_prompt}]
         
         if session_id not in chat_memory:
             chat_memory[session_id] = [{"role": "system", "content": dynamic_system_prompt}]
+        else:
+            chat_memory[session_id][0] = {"role": "system", "content": dynamic_system_prompt}
 
         chat_memory[session_id].append({"role": "user", "content": f"{player_name} says: {message}"})
 
 
         chat_memory[session_id].append({"role": "user", "content": f"{player_name} says: {message}"})
 
-        # Sliding Window Fix
+        # =====================================================================
+        # --- THE MEMORY EXTRACTION TRIGGER ---
+        # =====================================================================
         if len(chat_memory[session_id]) > 10:
         if len(chat_memory[session_id]) > 10:
+            # Grab the 5 oldest conversation messages (skipping the system prompt at [0])
+            messages_to_summarize = chat_memory[session_id][1:6]
+            chat_log_str = "\n".join([m['content'] for m in messages_to_summarize])
+            
+            # Fire and forget: push it to the background queue!
+            await memory_queue.put({
+                'session_id': session_id,
+                'player_name': player_name,
+                'npc_tag': npc_tag,
+                'chat_log': chat_log_str
+            })
+
+            # Slide the window to keep live generation fast
             chat_memory[session_id] = [chat_memory[session_id][0]] + chat_memory[session_id][-5:]
             chat_memory[session_id] = [chat_memory[session_id][0]] + chat_memory[session_id][-5:]
+        # =====================================================================
 
         async with semaphore:
             print(f"[THINKING] Processing reply for {player_name}...")
 
         async with semaphore:
             print(f"[THINKING] Processing reply for {player_name}...")
@@ -169,21 +244,17 @@ async def process_message(r, session, message_data):
                 result = await response.json()
                 raw_reply_text = result['message']['content']
 
                 result = await response.json()
                 raw_reply_text = result['message']['content']
 
-        # =====================================================================
-        # THE PYTHON BOUNCER (Sanitization)
-        # =====================================================================
         try:
             agent_brain = json.loads(raw_reply_text)
             agent_brain = {k.lower(): v for k, v in agent_brain.items()}
             
         try:
             agent_brain = json.loads(raw_reply_text)
             agent_brain = {k.lower(): v for k, v in agent_brain.items()}
             
-            # Ensure all 5 keys exist
             if "thought" not in agent_brain: agent_brain["thought"] = ""
             if "speech" not in agent_brain: agent_brain["speech"] = ""
             if "emotion" not in agent_brain: agent_brain["emotion"] = "NEUTRAL" 
             if "action" not in agent_brain: agent_brain["action"] = "GUARD"
             if "action_target" not in agent_brain: agent_brain["action_target"] = ""
             if "thought" not in agent_brain: agent_brain["thought"] = ""
             if "speech" not in agent_brain: agent_brain["speech"] = ""
             if "emotion" not in agent_brain: agent_brain["emotion"] = "NEUTRAL" 
             if "action" not in agent_brain: agent_brain["action"] = "GUARD"
             if "action_target" not in agent_brain: agent_brain["action_target"] = ""
+            if "target_speech" not in agent_brain: agent_brain["target_speech"] = ""
             
             
-            # --- THE NEW ANTI-SILENCE & TARGET CLEANUP FIXES ---
             if not agent_brain["speech"].strip():
                 agent_brain["speech"] = "*grunts quietly*"
             
             if not agent_brain["speech"].strip():
                 agent_brain["speech"] = "*grunts quietly*"
             
@@ -228,6 +299,9 @@ async def main():
     print(f"Ready! Listening for game messages. Max GPU concurrency: {MAX_CONCURRENT_OLLAMA_REQUESTS}")
 
     async with aiohttp.ClientSession() as session:
     print(f"Ready! Listening for game messages. Max GPU concurrency: {MAX_CONCURRENT_OLLAMA_REQUESTS}")
 
     async with aiohttp.ClientSession() as session:
+        # --- NEW: Start the background memory worker ---
+        asyncio.create_task(memory_summarizer_worker(session))
+
         while True:
             try:
                 result = await r.blpop('nwn_to_llm')
         while True:
             try:
                 result = await r.blpop('nwn_to_llm')