X-Git-Url: https://vgcfreebox.myrthtech.pt/gitweb/alentejosemlei.git/blobdiff_plain/edddb07cf6c42360b20c036bc9a30f0f5a48cb78..40fc26a5aafc53c1f325fc2491738afdc530f222:/redis_bridge.py?ds=sidebyside diff --git a/redis_bridge.py b/redis_bridge.py index 97f27e9..da40a5b 100644 --- a/redis_bridge.py +++ b/redis_bridge.py @@ -1,76 +1,221 @@ import asyncio import json import aiohttp -import redis.asyncio as redis # Note the new async redis import! +import redis.asyncio as redis +import re +import os # --- CONFIGURATION --- -# The "Bouncer": How many simultaneous thoughts the 5060 Ti is allowed to process at once. -# If 10 players talk at once, 3 will process immediately, 7 will wait in the async line. MAX_CONCURRENT_OLLAMA_REQUESTS = 3 -semaphore = asyncio.Semaphore(MAX_CONCURRENT_OLLAMA_REQUESTS) +ALLOW_TEXT_EMOTES = False -NPC_SYSTEM_PROMPT = """ -You are Elrendur Arna. You are currently residing in Alentejo Sem Lei. -Keep your responses under 3 sentences. -""" +# --- 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() +else: + print("[WARNING] asl_lore.txt/asl_lore.txt not found. Running without global lore.") -# Dictionary to keep individual player conversations separate -# Format: {"PlayerName_NpcTag": [{"role": "system", "content": "..."}, ...]} +semaphore = asyncio.Semaphore(MAX_CONCURRENT_OLLAMA_REQUESTS) +# Dictionary to keep individual conversations separate chat_memory = {} async def process_message(r, session, message_data): - """This function runs in parallel for every single message received.""" try: data = json.loads(message_data) - player_name = data.get('player', 'Unknown') + player_name = data.get('player', data.get('target_player', 'Unknown')) 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}" + + # ===================================================================== + # THE PROMPT COMPILER + # ===================================================================== + dynamic_system_prompt = f""" + + {WORLD_LORE} + + {npc_persona} + + CURRENT STATUS & TRAITS: + - Race & Gender: {npc_gender} {npc_race} + - Profession: {npc_profession} + - Alignment: {npc_alignment} + - Conversational Charisma: Based on mood, profession and your character charisma. + - Current Mood: {npc_mood} + - Current Physical State: {npc_health} + {secret_context} + {routine_context} + + CURRENT LOCATION: {location_context} + + CURRENT WORLD RUMORS/EVENTS: + {world_state} + + 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} + React appropriately based on your personality, alignment, and mood. + + CRITICAL ENGINE RULES: + Respond ONLY in valid JSON. You MUST use exactly these FIVE keys: "thought", "speech", "emotion", "action", and "action_target". + + 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] + + - 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 **. + + 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.", + "emotion": "MACRO WORD", + "action": "MACRO WORD", + "action_target": "Target name" + }} + """ - # 1. Create a unique session ID for this specific player and NPC interaction session_id = f"{player_name}_{npc_tag}" - # 2. If they haven't spoken before, initialize their memory with the system prompt if session_id not in chat_memory: - chat_memory[session_id] = [{"role": "system", "content": NPC_SYSTEM_PROMPT}] + chat_memory[session_id] = [{"role": "system", "content": dynamic_system_prompt}] - # 3. Add the player's new message to their specific history - print(f"\n[RECEIVED] {player_name} -> {npc_tag}: '{message}'") chat_memory[session_id].append({"role": "user", "content": f"{player_name} says: {message}"}) - # 4. The Semaphore Bouncer: Wait in line if the GPU is currently full + # Sliding Window Fix + if len(chat_memory[session_id]) > 10: + 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}...") - - # Use aiohttp to make a non-blocking network request to Ollama async with session.post('http://localhost:11434/api/chat', json={ "model": "llama3", "messages": chat_memory[session_id], - "stream": False + "format": "json", + "stream": False, + "options": { + "temperature": 0.2 + } }, timeout=45) as response: response.raise_for_status() result = await response.json() - reply_text = result['message']['content'] + 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"] = "" + + # --- THE NEW ANTI-SILENCE & TARGET CLEANUP FIXES --- + if not agent_brain["speech"].strip(): + agent_brain["speech"] = "*grunts quietly*" + + agent_brain["action_target"] = agent_brain["action_target"].replace("?", "").replace(".", "").strip() + + clean_reply_text = json.dumps(agent_brain) - # 5. Save the AI's reply to the memory and print it - print(f"[REPLY] Elrendur to {player_name}: {reply_text}") - chat_memory[session_id].append({"role": "assistant", "content": reply_text}) + except json.JSONDecodeError: + print(f"[WARNING] AI Hallucinated! Overriding with safe defaults.") + clean_reply_text = json.dumps({ + "thought": "I lost my train of thought.", + "speech": "*grunts quietly*", + "emotion": "NEUTRAL", + "action": "WANDER", + "action_target": "" + }) + + print(f"[REPLY] from {npc_tag} to {player_name}: {clean_reply_text}") + chat_memory[session_id].append({"role": "assistant", "content": clean_reply_text}) - # 6. Package and send back to the game (Awaiting the async Redis push) reply_payload = { "npc_tag": npc_tag, - "reply": reply_text + "target_player": player_name, + "reply": clean_reply_text } await r.rpush('llm_to_nwn', json.dumps(reply_payload)) except Exception as e: print(f"[ERROR] Failed to process message: {e}") - async def main(): print("Initializing Async Redis Bridge...") - - # Notice we use redis.asyncio.Redis now r = redis.Redis(host='127.0.0.1', port=6380, decode_responses=True) try: @@ -82,24 +227,16 @@ async def main(): print(f"Ready! Listening for game messages. Max GPU concurrency: {MAX_CONCURRENT_OLLAMA_REQUESTS}") - # Open a single persistent HTTP session for efficiency async with aiohttp.ClientSession() as session: while True: try: - # This line yields control back to the event loop while waiting for Redis result = await r.blpop('nwn_to_llm') - if result: queue_name, message_data = result - - # THE MAGIC: Spawn a background worker to handle this message - # and instantly go back to listening to the Redis queue! asyncio.create_task(process_message(r, session, message_data)) - except Exception as e: print(f"[LOOP ERROR] {e}") - await asyncio.sleep(1) # Prevent infinite crash loops + await asyncio.sleep(1) if __name__ == "__main__": - # This starts the Asyncio Event Loop asyncio.run(main()) \ No newline at end of file