File size: 8,188 Bytes
d60934b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
from app.models.session import Conversation
from app.core.logging import LoggerMixin
from pathlib import Path
import json
from typing import Optional, Dict
import os
from mistralai import Mistral
from app.utils.file_management import FileManager
from app.models.session import UserSession


# Get the Mistral API key from environment (injected by ECS)
mistral_api_key = os.getenv("MISTRAL_API_KEY")


class ChatService(LoggerMixin):
    def __init__(self, session: UserSession):
        self.logger.info("Initializing ChatService")
        # Load all available character in every wagon. 
        self.player_details: Dict = self._load_player_details(session)

        if len(self.player_details) == 0:
            self.logger.error("Failed to initialize player details - array is empty")
        else:
            self.logger.info(f"Loaded player details for wagons: {list(self.player_details)}")

        # Get the Mistral API key from environment (injected by ECS)
        mistral_api_key = os.getenv("MISTRAL_API_KEY")
        if not mistral_api_key:
            self.logger.error("MISTRAL_API_KEY not found in environment variables")
            raise ValueError("MISTRAL_API_KEY is required")

        self.client = Mistral(api_key=mistral_api_key)
        self.model = "mistral-large-latest"

        self.logger.info("Initialized Mistral AI client")

    @classmethod
    def _load_player_details(cls, session) -> Dict:
        """Load character details from JSON files"""
        try:
            # Use FileManager to load the default session data
            _, player_details, _ = FileManager.load_session_data(session.session_id, session.default_game)
            
            if len(player_details) == 0:
                cls.get_logger().error("Missing 'player_details' key in JSON data")
                return {}
            
            # success for loading player_details
            cls.get_logger().info(f"Successfully loaded player details.: {list(player_details)}")
            return player_details
        
        except FileNotFoundError as e:
            cls.get_logger().error(f"Failed to load default player details: {str(e)}")
            return {}
        except Exception as e:
            cls.get_logger().error(f"Failed to load player details: {str(e)}")
            return {}

    def _get_character_context(self, uid: str) -> Optional[Dict]:
        """Get the character's context for the conversation"""
        try:
            self.logger.debug(f"Getting character context for uid: {uid}")
            # "wagon-<i>-player-<k>"
            uid_splitted = uid.split("-")
            # try catch for wagon_index 
            try:
                wagon_index = int(uid_splitted[1])
            except ValueError:
                self.logger.error(f"Invalid wagon index | uid: {uid} | wagon_index: {uid_splitted[1]}")
                return None
            
            wagon_key, player_key = (
                f"wagon-{wagon_index}",
                f"player-{uid_splitted[3]}",
            )

            # check if the wagon key exists
            if len(self.player_details) == 0:
                self.logger.error("Wagon not found in player details")
                return None

            # find specific player details
            specific_player_detais = next((player for player in self.player_details[wagon_index]["players"] if player["playerId"] == player_key), None)

            self.logger.debug(
                f"Retrieved player context | uid: {uid} | wagon: {wagon_key} | player: {player_key} | profession: {specific_player_detais['profile']['profession']}"
            )
            return specific_player_detais
        except (KeyError, IndexError) as e:
            self.logger.error(f"Failed to get character context: {str(e)} | uid: {uid} | error: {str(e)} | player_details_keys: {list(self.player_details) if self.player_details else None}")
            return None

    def _create_character_prompt(self, theme: str, character: Dict) -> str:
        """Create a prompt that describes the character's personality and context"""
        occupation = character["profile"]["profession"]
        personality = character["profile"]["personality"]
        role = character["profile"]["role"]
        mystery = character["profile"]["mystery_intrigue"]
        name = character["profile"]["name"]

        prompt = f"""
        You are an NPC in a fictional world set in the theme of {theme}. You are part of this theme's story and lore.
        Your name is {name}, and you are a {occupation}.
        Your role in the story is {role}, and you have a mysterious secret tied to you: {mystery}. Your personality is {personality}, 
        which influences how you speak, act, and interact with others. Stay in character at all times, 
        and respond to the player based on your occupation, role, mystery, and personality.

        You may only reveal your mystery if the player explicitly asks about it or asks about something closely related to it. 
        For example, if your mystery involves a hidden treasure, and the player asks about rumors of gold in the area, you may
        hint at or reveal your secret. However, you should still respond in a way that feels natural to your character.
        Do not break character or reveal your mystery too easily—only share it if it makes sense in the context of the conversation 
        and your personality.

        Respond in maximum 3-4 sentences per message to keep the conversation flowing and engaing for the player.
        """

        return prompt

    def generate_response(self, uid: str, theme: str, conversation: Conversation) -> Optional[str]:
        """Generate a response using Mistral AI based on character profile"""
        self.logger.info(f"Generating response for uid: {uid}")
        character = self._get_character_context(uid)

        if not character:
            self.logger.error(
                f"Cannot generate response - character not found for uid: {uid}"
            )
            return None

        try:
            # Create the system prompt with character context
            system_prompt = self._create_character_prompt(theme, character)

            # Convert conversation history to Mistral AI format
            messages = [{"role": "system", "content": system_prompt}]

            # Add conversation history (limit to last 10 messages to stay within context window)
            for msg in conversation.messages[-10:]:
                # Convert 'agent' role to 'assistant' for Mistral compatibility
                role = "assistant" if msg.role == "agent" else msg.role
                messages.append({"role": role, "content": msg.content})

            # Get response from Mistral AI
            try:
                chat_response = self.client.chat.complete(
                    model=self.model, messages=messages, temperature=0.7, max_tokens=500
                )

                if not chat_response or not chat_response.choices:
                    raise ValueError("Empty response received from Mistral AI")

                response = chat_response.choices[0].message.content

                if not response or not isinstance(response, str):
                    raise ValueError(f"Invalid response format: {type(response)}")

                self.logger.info(
                    f"Generated Mistral AI response | uid: {uid} | response_length: {len(response)} | conversation_length: {len(conversation.messages)}"
                )

                return response

            except Exception as api_error:
                self.logger.error(
                    f"Mistral API error | uid: {uid} | error: {str(api_error)} | messages_count: {len(messages)}"
                )
                raise ValueError(f"Mistral API error: {str(api_error)}")

        except Exception as e:
            self.logger.error(
                f"Failed to generate Mistral AI response | uid: {uid} | error: {str(e)} | error_type: {type(e).__name__} | character_name: {character.get('profile', {}).get('name', 'unknown')}"
            )
            return f"I apologize, but I'm having trouble responding right now. Error: {str(e)}"