X-Git-Url: https://vgcfreebox.myrthtech.pt/gitweb/alentejosemlei.git/blobdiff_plain/40fc26a5aafc53c1f325fc2491738afdc530f222..6bbe17d84dbe471b53705d9276637ac98a44c376:/redis_bridge.py?ds=inline diff --git a/redis_bridge.py b/redis_bridge.py index da40a5b..a45944e 100644 --- a/redis_bridge.py +++ b/redis_bridge.py @@ -4,23 +4,89 @@ import aiohttp import redis.asyncio as redis import re import os +import time +import chromadb # --- 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: - 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) -# Dictionary to keep individual conversations separate 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) @@ -28,56 +94,61 @@ async def process_message(r, session, message_data): 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() - # 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', '') - # --- 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', '') - # 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', '') - # 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} @@ -85,19 +156,22 @@ async def process_message(r, session, message_data): - 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} - + + RELEVANT WORLD KNOWLEDGE: + {retrieved_lore} + {past_memories} + 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} @@ -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] - - 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.", - "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", - "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}] + 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}"}) - # Sliding Window Fix + # ===================================================================== + # --- THE MEMORY EXTRACTION TRIGGER --- + # ===================================================================== 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:] + # ===================================================================== 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'] - # ===================================================================== - # THE PYTHON BOUNCER (Sanitization) - # ===================================================================== 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 "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*" @@ -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: + # --- NEW: Start the background memory worker --- + asyncio.create_task(memory_summarizer_worker(session)) + while True: try: result = await r.blpop('nwn_to_llm')