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 +22 -0
- kryten_llm/__main__.py +148 -0
- kryten_llm/components/__init__.py +24 -0
- kryten_llm/components/config_reloader.py +286 -0
- kryten_llm/components/context_manager.py +186 -0
- kryten_llm/components/formatter.py +383 -0
- kryten_llm/components/health_monitor.py +266 -0
- kryten_llm/components/heartbeat.py +122 -0
- kryten_llm/components/listener.py +79 -0
- kryten_llm/components/llm_manager.py +349 -0
- kryten_llm/components/prompt_builder.py +148 -0
- kryten_llm/components/rate_limiter.py +478 -0
- kryten_llm/components/response_logger.py +105 -0
- kryten_llm/components/spam_detector.py +388 -0
- kryten_llm/components/trigger_engine.py +278 -0
- kryten_llm/components/validator.py +269 -0
- kryten_llm/config.py +93 -0
- kryten_llm/models/__init__.py +25 -0
- kryten_llm/models/config.py +496 -0
- kryten_llm/models/events.py +16 -0
- kryten_llm/models/phase3.py +59 -0
- kryten_llm/service.py +572 -0
- kryten_llm/utils/__init__.py +0 -0
- kryten_llm-0.2.2.dist-info/METADATA +271 -0
- kryten_llm-0.2.2.dist-info/RECORD +28 -0
- kryten_llm-0.2.2.dist-info/WHEEL +4 -0
- kryten_llm-0.2.2.dist-info/entry_points.txt +3 -0
- kryten_llm-0.2.2.dist-info/licenses/LICENSE +21 -0
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
|
+
}
|