rakam-systems-agent 0.1.1rc7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,446 @@
1
+ """SQL-Based Chat History Manager.
2
+
3
+ This module provides a ChatHistoryComponent implementation that stores
4
+ chat history in a SQLite database. Suitable for production deployments
5
+ requiring persistent, structured storage.
6
+ """
7
+
8
+ from __future__ import annotations
9
+ import json
10
+ import os
11
+ import sqlite3
12
+ from typing import Any, Dict, List, Optional
13
+
14
+ from rakam_systems_core.ai_core.interfaces.chat_history import ChatHistoryComponent
15
+
16
+ # Optional pydantic-ai integration
17
+ try:
18
+ from pydantic_ai.messages import ModelMessagesTypeAdapter, ModelMessage
19
+ from pydantic_core import to_jsonable_python
20
+ PYDANTIC_AI_AVAILABLE = True
21
+ except ImportError:
22
+ PYDANTIC_AI_AVAILABLE = False
23
+ ModelMessagesTypeAdapter = None # type: ignore
24
+ ModelMessage = None # type: ignore
25
+ to_jsonable_python = None # type: ignore
26
+
27
+
28
+ class SQLChatHistory(ChatHistoryComponent):
29
+ """Chat history manager using SQLite database storage.
30
+
31
+ This implementation stores all chat histories in a SQLite database.
32
+ It's suitable for:
33
+ - Production deployments
34
+ - Multi-instance applications (with proper connection handling)
35
+ - Applications requiring structured queries
36
+ - Medium to large scale applications
37
+
38
+ Config options:
39
+ db_path: Path to the SQLite database file (default: "./chat_history.db")
40
+
41
+ Example:
42
+ >>> history = SQLChatHistory(config={"db_path": "./data/chats.db"})
43
+ >>> history.add_message("chat123", {"role": "user", "content": "Hello"})
44
+ >>> history.add_message("chat123", {"role": "assistant", "content": "Hi there!"})
45
+ >>> messages = history.get_chat_history("chat123")
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ name: str = "sql_chat_history",
51
+ config: Optional[Dict[str, Any]] = None,
52
+ db_path: Optional[str] = None,
53
+ ) -> None:
54
+ """Initialize the SQL chat history manager.
55
+
56
+ Args:
57
+ name: Component name for identification.
58
+ config: Configuration dictionary. Supports:
59
+ - db_path: Path to SQLite database file
60
+ db_path: Direct path override (takes precedence over config).
61
+ """
62
+ super().__init__(name, config)
63
+
64
+ # Get db path from argument, config, or default
65
+ self.db_path = db_path or self.config.get(
66
+ "db_path", "./chat_history.db"
67
+ )
68
+
69
+ def setup(self) -> None:
70
+ """Initialize database and create tables."""
71
+ self._initialize_database()
72
+ super().setup()
73
+
74
+ def shutdown(self) -> None:
75
+ """Cleanup resources."""
76
+ super().shutdown()
77
+
78
+ def _get_connection(self) -> sqlite3.Connection:
79
+ """Get a database connection with foreign keys enabled.
80
+
81
+ Returns:
82
+ SQLite connection object.
83
+ """
84
+ conn = sqlite3.connect(self.db_path)
85
+ conn.execute('PRAGMA foreign_keys = ON;')
86
+ return conn
87
+
88
+ def _initialize_database(self) -> None:
89
+ """Initialize SQLite database and create necessary tables.
90
+
91
+ Creates the chats and messages tables if they don't exist.
92
+
93
+ Raises:
94
+ Exception: If database initialization fails.
95
+ """
96
+ # Ensure directory exists
97
+ db_dir = os.path.dirname(self.db_path)
98
+ if db_dir:
99
+ os.makedirs(db_dir, exist_ok=True)
100
+
101
+ with self._get_connection() as conn:
102
+ cursor = conn.cursor()
103
+
104
+ # Create tables
105
+ cursor.execute('''
106
+ CREATE TABLE IF NOT EXISTS chats (
107
+ chat_id TEXT PRIMARY KEY
108
+ )
109
+ ''')
110
+
111
+ cursor.execute('''
112
+ CREATE TABLE IF NOT EXISTS messages (
113
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
114
+ chat_id TEXT NOT NULL,
115
+ message_order INTEGER NOT NULL,
116
+ message_data TEXT NOT NULL,
117
+ FOREIGN KEY (chat_id) REFERENCES chats (chat_id) ON DELETE CASCADE
118
+ )
119
+ ''')
120
+
121
+ # Create index for faster lookups
122
+ cursor.execute('''
123
+ CREATE INDEX IF NOT EXISTS idx_messages_chat_id
124
+ ON messages (chat_id, message_order)
125
+ ''')
126
+
127
+ conn.commit()
128
+
129
+ def _ensure_initialized(self) -> None:
130
+ """Ensure the component is initialized before operations."""
131
+ if not self.initialized:
132
+ self.setup()
133
+
134
+ def add_message(self, chat_id: str, message: Dict[str, Any]) -> None:
135
+ """Add a single message to a chat session.
136
+
137
+ Args:
138
+ chat_id: Unique identifier for the chat session.
139
+ message: Message object (dict with role, content, timestamp, etc.).
140
+ """
141
+ self._ensure_initialized()
142
+
143
+ with self._get_connection() as conn:
144
+ cursor = conn.cursor()
145
+
146
+ # Ensure chat exists
147
+ cursor.execute(
148
+ 'INSERT OR IGNORE INTO chats (chat_id) VALUES (?)',
149
+ (chat_id,)
150
+ )
151
+
152
+ # Get next message order
153
+ cursor.execute(
154
+ 'SELECT COALESCE(MAX(message_order), -1) + 1 FROM messages WHERE chat_id = ?',
155
+ (chat_id,)
156
+ )
157
+ next_order = cursor.fetchone()[0]
158
+
159
+ # Insert message
160
+ message_json = json.dumps(message, ensure_ascii=False)
161
+ cursor.execute(
162
+ '''
163
+ INSERT INTO messages (chat_id, message_order, message_data)
164
+ VALUES (?, ?, ?)
165
+ ''',
166
+ (chat_id, next_order, message_json)
167
+ )
168
+
169
+ conn.commit()
170
+
171
+ def set_messages(self, chat_id: str, messages: List[Dict[str, Any]]) -> None:
172
+ """Set/replace all messages for a chat session.
173
+
174
+ Args:
175
+ chat_id: Unique identifier for the chat session.
176
+ messages: List of message objects to store.
177
+ """
178
+ self._ensure_initialized()
179
+
180
+ with self._get_connection() as conn:
181
+ cursor = conn.cursor()
182
+
183
+ # Ensure chat exists
184
+ cursor.execute(
185
+ 'INSERT OR IGNORE INTO chats (chat_id) VALUES (?)',
186
+ (chat_id,)
187
+ )
188
+
189
+ # Delete existing messages
190
+ cursor.execute(
191
+ 'DELETE FROM messages WHERE chat_id = ?', (chat_id,))
192
+
193
+ # Insert new messages with order
194
+ for order, message in enumerate(messages):
195
+ message_json = json.dumps(message, ensure_ascii=False)
196
+ cursor.execute(
197
+ '''
198
+ INSERT INTO messages (chat_id, message_order, message_data)
199
+ VALUES (?, ?, ?)
200
+ ''',
201
+ (chat_id, order, message_json)
202
+ )
203
+
204
+ conn.commit()
205
+
206
+ def get_chat_history(self, chat_id: str) -> List[Dict[str, Any]]:
207
+ """Retrieve all messages for a chat session.
208
+
209
+ Args:
210
+ chat_id: Unique identifier for the chat session.
211
+
212
+ Returns:
213
+ List of message objects, or empty list if chat doesn't exist.
214
+ """
215
+ self._ensure_initialized()
216
+
217
+ with self._get_connection() as conn:
218
+ cursor = conn.cursor()
219
+ cursor.execute(
220
+ '''
221
+ SELECT message_data
222
+ FROM messages
223
+ WHERE chat_id = ?
224
+ ORDER BY message_order ASC
225
+ ''',
226
+ (chat_id,)
227
+ )
228
+ rows = cursor.fetchall()
229
+
230
+ return [json.loads(row[0]) for row in rows]
231
+
232
+ def get_all_chat_ids(self) -> List[str]:
233
+ """Get all chat IDs currently stored.
234
+
235
+ Returns:
236
+ List of all chat session identifiers.
237
+ """
238
+ self._ensure_initialized()
239
+
240
+ with self._get_connection() as conn:
241
+ cursor = conn.cursor()
242
+ cursor.execute('SELECT chat_id FROM chats')
243
+ return [row[0] for row in cursor.fetchall()]
244
+
245
+ def delete_chat_history(self, chat_id: str) -> bool:
246
+ """Delete all messages for a chat session.
247
+
248
+ Args:
249
+ chat_id: Unique identifier for the chat session to delete.
250
+
251
+ Returns:
252
+ True if deletion was successful, False if chat_id didn't exist.
253
+ """
254
+ self._ensure_initialized()
255
+
256
+ with self._get_connection() as conn:
257
+ cursor = conn.cursor()
258
+
259
+ # Check if chat exists
260
+ cursor.execute('SELECT 1 FROM chats WHERE chat_id = ?', (chat_id,))
261
+ exists = cursor.fetchone() is not None
262
+
263
+ if not exists:
264
+ return False
265
+
266
+ # Delete messages (cascades from foreign key, but explicit is safer)
267
+ cursor.execute(
268
+ 'DELETE FROM messages WHERE chat_id = ?', (chat_id,))
269
+ cursor.execute('DELETE FROM chats WHERE chat_id = ?', (chat_id,))
270
+
271
+ conn.commit()
272
+ return True
273
+
274
+ def clear_all(self) -> None:
275
+ """Delete all chat histories."""
276
+ self._ensure_initialized()
277
+
278
+ with self._get_connection() as conn:
279
+ cursor = conn.cursor()
280
+ cursor.execute('DELETE FROM messages')
281
+ cursor.execute('DELETE FROM chats')
282
+ conn.commit()
283
+
284
+ def get_readable_chat_history(
285
+ self,
286
+ chat_id: str,
287
+ user_role: str = "user",
288
+ assistant_role: str = "assistant",
289
+ ) -> List[Dict[str, Any]]:
290
+ """Get chat history in a human-readable format.
291
+
292
+ This method transforms the raw message format into a display-friendly
293
+ format with 'from', 'message', and optional 'timestamp' keys.
294
+
295
+ Args:
296
+ chat_id: Unique identifier for the chat session.
297
+ user_role: The role name for user messages (default: "user").
298
+ assistant_role: The role name for assistant messages (default: "assistant").
299
+
300
+ Returns:
301
+ List of formatted message dictionaries with:
302
+ - 'from': "user" or "assistant"
303
+ - 'message': The message content
304
+ - 'timestamp': Message timestamp (if available)
305
+ """
306
+ self._ensure_initialized()
307
+
308
+ messages = self.get_chat_history(chat_id)
309
+ readable_messages = []
310
+
311
+ for msg in messages:
312
+ role = msg.get("role", "")
313
+ content = msg.get("content", "")
314
+ timestamp = msg.get("timestamp")
315
+
316
+ # Determine the 'from' field based on role
317
+ if role == user_role:
318
+ from_field = "user"
319
+ elif role == assistant_role:
320
+ from_field = "assistant"
321
+ else:
322
+ # Skip system messages or unknown roles
323
+ continue
324
+
325
+ formatted = {
326
+ "from": from_field,
327
+ "message": content,
328
+ }
329
+
330
+ if timestamp:
331
+ formatted["timestamp"] = timestamp
332
+
333
+ readable_messages.append(formatted)
334
+
335
+ return readable_messages
336
+
337
+ # ==================== Pydantic-AI Integration ====================
338
+
339
+ def get_message_history(self, chat_id: str) -> Optional[List[Any]]:
340
+ """Get chat history in pydantic-ai compatible format.
341
+
342
+ This method converts the stored database history to pydantic-ai's
343
+ ModelMessage format, ready to be passed to agent.run() or
344
+ agent.run_stream() as message_history.
345
+
346
+ Args:
347
+ chat_id: Unique identifier for the chat session.
348
+
349
+ Returns:
350
+ List of ModelMessage objects for pydantic-ai, or None if:
351
+ - Chat doesn't exist or is empty
352
+ - pydantic-ai is not installed
353
+
354
+ Example:
355
+ >>> history = SQLChatHistory()
356
+ >>> message_history = history.get_message_history("chat123")
357
+ >>> result = await agent.run("Hello", message_history=message_history)
358
+ """
359
+ if not PYDANTIC_AI_AVAILABLE:
360
+ raise ImportError(
361
+ "pydantic-ai is not installed. Install it with: pip install pydantic-ai"
362
+ )
363
+
364
+ self._ensure_initialized()
365
+ raw_history = self.get_chat_history(chat_id)
366
+
367
+ if not raw_history:
368
+ return None
369
+
370
+ return ModelMessagesTypeAdapter.validate_python(raw_history)
371
+
372
+ def save_messages(self, chat_id: str, messages: List[Any]) -> None:
373
+ """Save pydantic-ai messages to history.
374
+
375
+ This method converts pydantic-ai's ModelMessage objects to JSON
376
+ and stores them. Typically called with result.all_messages() after
377
+ an agent run.
378
+
379
+ Args:
380
+ chat_id: Unique identifier for the chat session.
381
+ messages: List of pydantic-ai ModelMessage objects
382
+ (e.g., from result.all_messages()).
383
+
384
+ Example:
385
+ >>> result = await agent.run("Hello", message_history=history.get_message_history("chat123"))
386
+ >>> history.save_messages("chat123", result.all_messages())
387
+ """
388
+ if not PYDANTIC_AI_AVAILABLE:
389
+ raise ImportError(
390
+ "pydantic-ai is not installed. Install it with: pip install pydantic-ai"
391
+ )
392
+
393
+ self._ensure_initialized()
394
+
395
+ # Convert pydantic-ai messages to JSON-serializable format
396
+ json_messages = to_jsonable_python(messages)
397
+ self.set_messages(chat_id, json_messages)
398
+
399
+
400
+ if __name__ == "__main__":
401
+ import tempfile
402
+
403
+ # Create a temporary database for testing
404
+ with tempfile.TemporaryDirectory() as tmpdir:
405
+ db_path = os.path.join(tmpdir, "test_chat_history.db")
406
+
407
+ # Example usage
408
+ history = SQLChatHistory(
409
+ config={"db_path": db_path}
410
+ )
411
+
412
+ # Add messages
413
+ history.add_message("chat123", {
414
+ "role": "user",
415
+ "content": "Hello!",
416
+ "timestamp": "2025-03-18 10:00:00"
417
+ })
418
+ history.add_message("chat123", {
419
+ "role": "assistant",
420
+ "content": "Hi there! How can I help?",
421
+ "timestamp": "2025-03-18 10:00:05"
422
+ })
423
+
424
+ # Retrieve history
425
+ print("Chat history:", history.get_chat_history("chat123"))
426
+ print("All chat IDs:", history.get_all_chat_ids())
427
+ print("Readable format:", history.get_readable_chat_history("chat123"))
428
+
429
+ # Test set_messages
430
+ history.set_messages("chat456", [
431
+ {"role": "user", "content": "Test message",
432
+ "timestamp": "2025-03-18 10:02:00"}
433
+ ])
434
+ print("Chat history for chat456:", history.get_chat_history("chat456"))
435
+ print("All chat IDs after adding chat456:", history.get_all_chat_ids())
436
+
437
+ # Clean up
438
+ deleted = history.delete_chat_history("chat123")
439
+ print(f"Deleted chat123: {deleted}")
440
+ print("After deletion:", history.get_all_chat_ids())
441
+
442
+ # Clear all
443
+ history.clear_all()
444
+ print("After clear_all:", history.get_all_chat_ids())
445
+
446
+ print("\nAll tests passed!")