kryten-llm 0.2.2__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.
kryten_llm/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """Kryten LLM Service - Chat moderation and filtering."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+
7
+ def _read_version() -> str:
8
+ """Read version from VERSION file."""
9
+ # Try package root first
10
+ version_file = Path(__file__).parent / "VERSION"
11
+ if version_file.exists():
12
+ return version_file.read_text().strip()
13
+
14
+ # Try repository root
15
+ version_file = Path(__file__).parent.parent / "VERSION"
16
+ if version_file.exists():
17
+ return version_file.read_text().strip()
18
+
19
+ return "0.0.0"
20
+
21
+
22
+ __version__ = _read_version()
kryten_llm/__main__.py ADDED
@@ -0,0 +1,148 @@
1
+ """Main entry point for kryten-llm service."""
2
+
3
+ import argparse
4
+ import asyncio
5
+ import logging
6
+ import platform
7
+ import signal
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Callable
11
+
12
+ from kryten_llm.components import ConfigReloader
13
+ from kryten_llm.config import load_config, validate_config_file
14
+ from kryten_llm.service import LLMService
15
+
16
+
17
+ def setup_logging(level: str = "INFO") -> None:
18
+ """Configure logging for the service."""
19
+ logging.basicConfig(
20
+ level=getattr(logging, level.upper()),
21
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
22
+ handlers=[logging.StreamHandler(sys.stdout)],
23
+ )
24
+
25
+
26
+ def parse_args() -> argparse.Namespace:
27
+ """Parse command line arguments."""
28
+ parser = argparse.ArgumentParser(
29
+ description="Kryten LLM Service - AI-powered chat bot for CyTube"
30
+ )
31
+ parser.add_argument(
32
+ "--config", type=Path, default=Path("config.json"), help="Path to configuration file"
33
+ )
34
+ parser.add_argument(
35
+ "--log-level",
36
+ choices=["DEBUG", "INFO", "WARNING", "ERROR"],
37
+ default="INFO",
38
+ help="Logging level",
39
+ )
40
+ parser.add_argument(
41
+ "--dry-run", action="store_true", help="Generate responses but don't send to chat"
42
+ )
43
+ parser.add_argument(
44
+ "--validate-config", action="store_true", help="Validate configuration file and exit"
45
+ )
46
+ return parser.parse_args()
47
+
48
+
49
+ async def main_async() -> None:
50
+ """Main async entry point."""
51
+ args = parse_args()
52
+ setup_logging(args.log_level)
53
+
54
+ logger = logging.getLogger(__name__)
55
+
56
+ # Validate config mode
57
+ if args.validate_config:
58
+ logger.info(f"Validating configuration: {args.config}")
59
+ is_valid, errors = validate_config_file(args.config)
60
+
61
+ if is_valid:
62
+ logger.info("✓ Configuration is valid")
63
+ sys.exit(0)
64
+ else:
65
+ logger.error("✗ Configuration validation failed:")
66
+ for error in errors:
67
+ logger.error(f" {error}")
68
+ sys.exit(1)
69
+
70
+ # Load configuration
71
+ try:
72
+ config = load_config(args.config)
73
+ except Exception as e:
74
+ logger.error(f"Failed to load configuration: {e}")
75
+ sys.exit(1)
76
+
77
+ # Override dry-run from CLI
78
+ if args.dry_run:
79
+ config.testing.dry_run = True
80
+ config.testing.send_to_chat = False
81
+ logger.info("Dry-run mode enabled via --dry-run flag")
82
+
83
+ logger.info("Starting Kryten LLM Service")
84
+
85
+ # Initialize service
86
+ service = LLMService(config=config)
87
+
88
+ # Phase 6: Setup config reloader for hot-reload support
89
+ config_reloader = ConfigReloader(
90
+ config_path=args.config, on_reload=service.reload_config, current_config=config
91
+ )
92
+
93
+ # Setup signal handlers
94
+ loop = asyncio.get_event_loop()
95
+
96
+ def signal_handler(sig: int) -> None:
97
+ logger.info(f"Received signal {sig}, shutting down...")
98
+ asyncio.create_task(service.stop())
99
+
100
+ # add_signal_handler is not supported on Windows, use signal.signal instead
101
+ if platform.system() != "Windows":
102
+ for sig in (signal.SIGTERM, signal.SIGINT):
103
+
104
+ def _make_handler(sig_num: int) -> Callable[[], None]:
105
+ return lambda: signal_handler(sig_num)
106
+
107
+ loop.add_signal_handler(sig, _make_handler(sig))
108
+
109
+ # Phase 6: Setup SIGHUP handler for config reload (POSIX only)
110
+ if hasattr(signal, "SIGHUP"):
111
+
112
+ def sighup_handler() -> None:
113
+ logger.info("Received SIGHUP, reloading configuration...")
114
+ asyncio.create_task(config_reloader.reload_config())
115
+
116
+ loop.add_signal_handler(signal.SIGHUP, sighup_handler)
117
+ logger.info("SIGHUP handler registered for config hot-reload")
118
+ else:
119
+ # Windows: Use signal.signal() for SIGINT/SIGTERM
120
+ def _signal_handler(sig_num: int, frame) -> None:
121
+ signal_handler(sig_num)
122
+
123
+ signal.signal(signal.SIGINT, _signal_handler)
124
+ signal.signal(signal.SIGTERM, _signal_handler)
125
+ logger.info("Signal handlers registered (Windows mode)")
126
+
127
+ try:
128
+ await service.start()
129
+ await service.wait_for_shutdown()
130
+ except KeyboardInterrupt:
131
+ logger.info("Keyboard interrupt received")
132
+ except Exception as e:
133
+ logger.error(f"Service error: {e}", exc_info=True)
134
+ sys.exit(1)
135
+ finally:
136
+ await service.stop()
137
+
138
+
139
+ def main() -> None:
140
+ """Main entry point."""
141
+ try:
142
+ asyncio.run(main_async())
143
+ except KeyboardInterrupt:
144
+ pass
145
+
146
+
147
+ if __name__ == "__main__":
148
+ main()
@@ -0,0 +1,24 @@
1
+ """Components for kryten-llm message processing pipeline."""
2
+
3
+ from kryten_llm.components.config_reloader import ConfigReloader
4
+ from kryten_llm.components.context_manager import ContextManager
5
+ from kryten_llm.components.formatter import ResponseFormatter
6
+ from kryten_llm.components.listener import MessageListener
7
+ from kryten_llm.components.llm_manager import LLMManager
8
+ from kryten_llm.components.prompt_builder import PromptBuilder
9
+ from kryten_llm.components.rate_limiter import RateLimitDecision, RateLimiter
10
+ from kryten_llm.components.response_logger import ResponseLogger
11
+ from kryten_llm.components.trigger_engine import TriggerEngine
12
+
13
+ __all__ = [
14
+ "ConfigReloader",
15
+ "ContextManager",
16
+ "MessageListener",
17
+ "TriggerEngine",
18
+ "LLMManager",
19
+ "PromptBuilder",
20
+ "ResponseFormatter",
21
+ "RateLimiter",
22
+ "RateLimitDecision",
23
+ "ResponseLogger",
24
+ ]
@@ -0,0 +1,286 @@
1
+ """Configuration hot-reload support for kryten-llm.
2
+
3
+ Phase 6: Implements SIGHUP-based configuration reload without service restart.
4
+
5
+ Example:
6
+ >>> reloader = ConfigReloader(config_path, on_reload=service.reload_config)
7
+ >>> reloader.setup_signal_handler()
8
+ >>> # Later: send SIGHUP to reload config
9
+ >>> # kill -HUP <pid>
10
+ """
11
+
12
+ import asyncio
13
+ import logging
14
+ import signal
15
+ from pathlib import Path
16
+ from typing import Awaitable, Callable, Optional
17
+
18
+ from kryten_llm.models.config import LLMConfig
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class ConfigReloader:
24
+ """Handles configuration reload on SIGHUP signal.
25
+
26
+ Implements hot-reload for configuration changes without restarting
27
+ the service. Only safe changes are applied; unsafe changes (like
28
+ NATS URL or channel changes) are logged as warnings.
29
+
30
+ Safe changes that can be hot-reloaded:
31
+ - Trigger configurations (probabilities, patterns, enabled status)
32
+ - Rate limits (global, per-user, per-trigger)
33
+ - Personality settings (prompts, response styles)
34
+ - Spam detection settings
35
+ - Logging configuration
36
+ - LLM provider settings (API keys, models, temperatures)
37
+
38
+ Unsafe changes (require restart):
39
+ - NATS connection settings
40
+ - Channel configuration
41
+ - Service name changes
42
+
43
+ Attributes:
44
+ config_path: Path to the configuration file
45
+ on_reload: Callback function called with new config after reload
46
+ current_config: Currently active configuration
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ config_path: Path | str,
52
+ on_reload: Optional[Callable[["LLMConfig"], Awaitable[None]]] = None,
53
+ current_config: Optional[LLMConfig] = None,
54
+ ):
55
+ """Initialize the config reloader.
56
+
57
+ Args:
58
+ config_path: Path to the configuration JSON file
59
+ on_reload: Async callback to invoke after successful reload
60
+ current_config: Current configuration for change detection
61
+ """
62
+ self.config_path = Path(config_path) if isinstance(config_path, str) else config_path
63
+ self.on_reload = on_reload
64
+ self.current_config = current_config
65
+ self._reload_lock = asyncio.Lock()
66
+ self._reload_in_progress = False
67
+
68
+ logger.debug(f"ConfigReloader initialized for {self.config_path}")
69
+
70
+ def setup_signal_handler(self) -> bool:
71
+ """Register SIGHUP handler for config reload (POSIX only).
72
+
73
+ On Windows, SIGHUP is not available, so this method logs a warning
74
+ and returns False.
75
+
76
+ Returns:
77
+ True if signal handler was registered, False otherwise
78
+ """
79
+ if not hasattr(signal, "SIGHUP"):
80
+ logger.warning(
81
+ "SIGHUP not available on this platform (Windows). "
82
+ "Hot-reload via signal is disabled. Use API endpoint instead."
83
+ )
84
+ return False
85
+
86
+ # Get the current event loop to schedule async reload
87
+ try:
88
+ loop = asyncio.get_running_loop()
89
+
90
+ def sighup_handler():
91
+ """Handle SIGHUP signal by scheduling config reload."""
92
+ logger.info("Received SIGHUP signal, initiating config reload...")
93
+ asyncio.create_task(self.reload_config())
94
+
95
+ loop.add_signal_handler(signal.SIGHUP, sighup_handler)
96
+ logger.info(f"SIGHUP handler registered for config reload from {self.config_path}")
97
+ return True
98
+
99
+ except RuntimeError:
100
+ # No running event loop
101
+ logger.warning("No running event loop, cannot register SIGHUP handler")
102
+ return False
103
+
104
+ async def reload_config(self) -> dict:
105
+ """Reload configuration from file.
106
+
107
+ Steps:
108
+ 1. Load new configuration file
109
+ 2. Validate the new configuration
110
+ 3. Detect safe vs unsafe changes
111
+ 4. Apply new configuration if valid
112
+ 5. Call on_reload callback with new config
113
+
114
+ Returns:
115
+ Dictionary with:
116
+ - success (bool): Whether reload succeeded
117
+ - message (str): Human-readable result
118
+ - changes (dict): What changed (field: "old -> new")
119
+ - warnings (list): Unsafe changes that require restart
120
+ - errors (list): Validation errors if failed
121
+ """
122
+ if self._reload_in_progress:
123
+ return {
124
+ "success": False,
125
+ "message": "Reload already in progress",
126
+ "changes": {},
127
+ "warnings": [],
128
+ "errors": ["Another reload operation is in progress"],
129
+ }
130
+
131
+ async with self._reload_lock:
132
+ self._reload_in_progress = True
133
+ try:
134
+ return await self._do_reload()
135
+ finally:
136
+ self._reload_in_progress = False
137
+
138
+ async def _do_reload(self) -> dict:
139
+ """Internal reload implementation."""
140
+ changes: dict[str, str] = {}
141
+ warnings: list[str] = []
142
+
143
+ # Step 1: Load new config file
144
+ try:
145
+ logger.info(f"Loading configuration from {self.config_path}")
146
+ new_config = LLMConfig.load(self.config_path)
147
+ except FileNotFoundError:
148
+ error_msg = f"Configuration file not found: {self.config_path}"
149
+ logger.error(error_msg)
150
+ return {
151
+ "success": False,
152
+ "message": "Configuration file not found",
153
+ "changes": {},
154
+ "warnings": [],
155
+ "errors": [error_msg],
156
+ }
157
+ except Exception as e:
158
+ error_msg = f"Failed to load configuration: {e}"
159
+ logger.error(error_msg, exc_info=True)
160
+ return {
161
+ "success": False,
162
+ "message": "Configuration validation failed",
163
+ "changes": {},
164
+ "warnings": [],
165
+ "errors": [error_msg],
166
+ }
167
+
168
+ # Step 2: Detect changes
169
+ if self.current_config:
170
+ changes, warnings = self._detect_changes(self.current_config, new_config)
171
+
172
+ # Step 3: Check for unsafe changes
173
+ if warnings:
174
+ logger.warning(f"Unsafe changes detected (require restart): {warnings}")
175
+
176
+ # Step 4: Apply new configuration
177
+ old_config = self.current_config
178
+ self.current_config = new_config
179
+
180
+ # Step 5: Call callback
181
+ if self.on_reload:
182
+ try:
183
+ if asyncio.iscoroutinefunction(self.on_reload):
184
+ await self.on_reload(new_config)
185
+ else:
186
+ self.on_reload(new_config)
187
+ except Exception as e:
188
+ # Rollback on callback error
189
+ self.current_config = old_config
190
+ error_msg = f"Reload callback failed: {e}"
191
+ logger.error(error_msg, exc_info=True)
192
+ return {
193
+ "success": False,
194
+ "message": "Reload callback failed, configuration rolled back",
195
+ "changes": {},
196
+ "warnings": warnings,
197
+ "errors": [error_msg],
198
+ }
199
+
200
+ # Log success
201
+ if changes:
202
+ logger.info(f"Configuration reloaded with changes: {changes}")
203
+ else:
204
+ logger.info("Configuration reloaded (no changes detected)")
205
+
206
+ return {
207
+ "success": True,
208
+ "message": "Configuration reloaded successfully",
209
+ "changes": changes,
210
+ "warnings": warnings,
211
+ "errors": [],
212
+ }
213
+
214
+ def _detect_changes(
215
+ self, old_config: LLMConfig, new_config: LLMConfig
216
+ ) -> tuple[dict[str, str], list[str]]:
217
+ """Detect changes between old and new configuration.
218
+
219
+ Args:
220
+ old_config: Previous configuration
221
+ new_config: New configuration
222
+
223
+ Returns:
224
+ Tuple of (changes dict, warnings list)
225
+ """
226
+ changes: dict[str, str] = {}
227
+ warnings: list[str] = []
228
+
229
+ # Check unsafe changes (require restart)
230
+ if old_config.nats.url != new_config.nats.url:
231
+ warnings.append(
232
+ f"nats.url changed ({old_config.nats.url} -> {new_config.nats.url}), "
233
+ "requires restart to apply"
234
+ )
235
+
236
+ old_channels = old_config.channels
237
+ new_channels = new_config.channels
238
+ if old_channels != new_channels:
239
+ warnings.append("channels configuration changed, requires restart to apply")
240
+
241
+ if old_config.service_metadata.service_name != new_config.service_metadata.service_name:
242
+ warnings.append("service_name changed, requires restart to apply")
243
+
244
+ # Check safe changes
245
+ if old_config.default_provider != new_config.default_provider:
246
+ changes["default_provider"] = (
247
+ f"{old_config.default_provider} -> {new_config.default_provider}"
248
+ )
249
+
250
+ # Trigger changes
251
+ old_trigger_names = {t.name for t in old_config.triggers}
252
+ new_trigger_names = {t.name for t in new_config.triggers}
253
+
254
+ added_triggers = new_trigger_names - old_trigger_names
255
+ removed_triggers = old_trigger_names - new_trigger_names
256
+
257
+ if added_triggers:
258
+ changes["triggers_added"] = ", ".join(sorted(added_triggers))
259
+ if removed_triggers:
260
+ changes["triggers_removed"] = ", ".join(sorted(removed_triggers))
261
+
262
+ # Rate limit changes
263
+ if old_config.rate_limits != new_config.rate_limits:
264
+ changes["rate_limits"] = "updated"
265
+
266
+ # Spam detection changes
267
+ if old_config.spam_detection != new_config.spam_detection:
268
+ changes["spam_detection"] = "updated"
269
+
270
+ # Personality changes
271
+ if old_config.personality != new_config.personality:
272
+ changes["personality"] = "updated"
273
+
274
+ # LLM provider changes
275
+ old_providers = set(old_config.llm_providers.keys())
276
+ new_providers = set(new_config.llm_providers.keys())
277
+
278
+ if old_providers != new_providers:
279
+ added = new_providers - old_providers
280
+ removed = old_providers - new_providers
281
+ if added:
282
+ changes["llm_providers_added"] = ", ".join(sorted(added))
283
+ if removed:
284
+ changes["llm_providers_removed"] = ", ".join(sorted(removed))
285
+
286
+ return changes, warnings
@@ -0,0 +1,186 @@
1
+ """Context manager for video and chat history tracking.
2
+
3
+ Phase 3: Implements REQ-008 through REQ-013 for context-aware responses.
4
+ """
5
+
6
+ import logging
7
+ from collections import deque
8
+ from datetime import datetime
9
+ from typing import Any, Dict, Optional
10
+
11
+ from kryten import ChangeMediaEvent # type: ignore[import-untyped]
12
+
13
+ from kryten_llm.models.config import LLMConfig
14
+ from kryten_llm.models.phase3 import ChatMessage, VideoMetadata
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class ContextManager:
20
+ """Manages video and chat context for LLM prompts.
21
+
22
+ Phase 3 component that:
23
+ - Subscribes to CyTube video change events (REQ-008)
24
+ - Maintains current video state (REQ-009)
25
+ - Maintains rolling chat history buffer (REQ-010)
26
+ - Provides context dict for prompt building (REQ-011)
27
+ - Handles edge cases and privacy constraints (REQ-012, REQ-013)
28
+ """
29
+
30
+ def __init__(self, config: LLMConfig):
31
+ """Initialize with configuration.
32
+
33
+ Args:
34
+ config: LLM configuration with context settings
35
+ """
36
+ self.config = config
37
+ self.current_video: Optional[VideoMetadata] = None
38
+
39
+ # REQ-010: Rolling buffer with configurable size
40
+ self.chat_history: deque[ChatMessage] = deque(maxlen=config.context.chat_history_size)
41
+
42
+ logger.info(
43
+ f"ContextManager initialized: chat_history_size={config.context.chat_history_size}, "
44
+ f"include_video={config.context.include_video_context}, "
45
+ f"include_chat={config.context.include_chat_history}"
46
+ )
47
+
48
+ async def start(self, kryten_client) -> None:
49
+ """Start subscribing to context events.
50
+
51
+ Args:
52
+ kryten_client: KrytenClient instance for subscriptions
53
+ """
54
+ # REQ-008: Subscribe to video change events
55
+ # Use first configured channel
56
+ channel_config = self.config.channels[0]
57
+ channel = (
58
+ channel_config.channel
59
+ if hasattr(channel_config, "channel")
60
+ else channel_config.get("channel", "unknown")
61
+ )
62
+ subject = f"kryten.events.cytube.{channel}.changemedia"
63
+
64
+ await kryten_client.subscribe(subject, self._handle_video_change)
65
+ logger.info(f"ContextManager subscribed to: {subject}")
66
+
67
+ async def _handle_video_change(self, event: ChangeMediaEvent) -> None:
68
+ """Handle video change event from CyTube.
69
+
70
+ REQ-009: Update current video state atomically.
71
+ REQ-012: Handle edge cases (long titles, missing fields, special chars).
72
+
73
+ Args:
74
+ event: ChangeMediaEvent from kryten-py with video metadata
75
+ """
76
+ try:
77
+ # Extract title from event
78
+ title = str(event.title or "Unknown")
79
+
80
+ # REQ-012: Truncate long titles
81
+ if len(title) > self.config.context.max_video_title_length:
82
+ title = title[: self.config.context.max_video_title_length]
83
+ logger.debug(
84
+ f"Truncated video title to {self.config.context.max_video_title_length} chars"
85
+ )
86
+
87
+ # REQ-009: Atomic update
88
+ self.current_video = VideoMetadata(
89
+ title=title,
90
+ duration=event.duration or 0,
91
+ type=event.media_type or "unknown",
92
+ queued_by="system", # ChangeMediaEvent doesn't have queued_by field
93
+ timestamp=datetime.now(),
94
+ )
95
+
96
+ logger.info(
97
+ f"Video changed: '{self.current_video.title}' "
98
+ f"({self.current_video.type}, {self.current_video.duration}s) "
99
+ f"queued by {self.current_video.queued_by}"
100
+ )
101
+
102
+ except Exception as e:
103
+ # REQ-033: Context errors should not block responses
104
+ logger.warning(f"Error handling video change: {e}", exc_info=True)
105
+
106
+ def add_chat_message(self, username: str, message: str) -> None:
107
+ """Add a message to chat history buffer.
108
+
109
+ REQ-010: Maintain rolling buffer excluding bot's own messages.
110
+ REQ-013: Only store configured number of messages.
111
+
112
+ Args:
113
+ username: User who sent the message
114
+ message: Message content
115
+ """
116
+ # REQ-010: Don't store bot's own messages
117
+ if username == self.config.personality.character_name:
118
+ return
119
+
120
+ # REQ-013: Deque automatically maintains size limit
121
+ self.chat_history.append(
122
+ ChatMessage(username=username, message=message, timestamp=datetime.now())
123
+ )
124
+
125
+ logger.debug(
126
+ f"Added message to history: {username}: {message[:50]}... "
127
+ f"(buffer size: {len(self.chat_history)})"
128
+ )
129
+
130
+ def get_context(self) -> Dict[str, Any]:
131
+ """Get current context for prompt building.
132
+
133
+ REQ-011: Provide context dict with current_video and recent_messages.
134
+ REQ-012: Handle None state for no video playing.
135
+
136
+ Returns:
137
+ Context dictionary with video and chat history
138
+ """
139
+ context: Dict[str, Any] = {}
140
+
141
+ # Include video context if enabled and available
142
+ if self.config.context.include_video_context and self.current_video:
143
+ context["current_video"] = {
144
+ "title": self.current_video.title,
145
+ "duration": self.current_video.duration,
146
+ "type": self.current_video.type,
147
+ "queued_by": self.current_video.queued_by,
148
+ }
149
+ else:
150
+ # REQ-012: No video playing
151
+ context["current_video"] = None
152
+
153
+ # Include chat history if enabled
154
+ if self.config.context.include_chat_history:
155
+ # REQ-016: Limit to most recent N messages for prompt
156
+ max_messages = self.config.context.max_chat_history_in_prompt
157
+ recent = list(self.chat_history)[-max_messages:] if self.chat_history else []
158
+
159
+ context["recent_messages"] = [
160
+ {"username": msg.username, "message": msg.message} for msg in recent
161
+ ]
162
+ else:
163
+ context["recent_messages"] = []
164
+
165
+ return context
166
+
167
+ def clear_chat_history(self) -> None:
168
+ """Clear chat history buffer.
169
+
170
+ REQ-013: Support clearing on service restart or for privacy.
171
+ """
172
+ self.chat_history.clear()
173
+ logger.info("Chat history buffer cleared")
174
+
175
+ def get_stats(self) -> Dict[str, Any]:
176
+ """Get context manager statistics.
177
+
178
+ Returns:
179
+ Statistics dict with buffer sizes and current state
180
+ """
181
+ return {
182
+ "chat_history_size": len(self.chat_history),
183
+ "chat_history_max": self.chat_history.maxlen,
184
+ "has_video": self.current_video is not None,
185
+ "current_video_title": self.current_video.title if self.current_video else None,
186
+ }