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
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Response validator for quality checking.
|
|
2
|
+
|
|
3
|
+
Phase 4: Response validation to ensure quality and relevance
|
|
4
|
+
(REQ-009 through REQ-015).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
9
|
+
from collections import deque
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from difflib import SequenceMatcher
|
|
12
|
+
from typing import Any, Literal
|
|
13
|
+
|
|
14
|
+
from kryten_llm.models.config import ValidationConfig
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ValidationResult:
|
|
21
|
+
"""Result of response validation.
|
|
22
|
+
|
|
23
|
+
Implements PAT-002 from Phase 4 specification.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
valid: bool
|
|
27
|
+
reason: str
|
|
28
|
+
severity: Literal["INFO", "WARNING", "ERROR"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ResponseValidator:
|
|
32
|
+
"""Validates LLM responses before sending.
|
|
33
|
+
|
|
34
|
+
Phase 4 Implementation (REQ-009 through REQ-015):
|
|
35
|
+
- Length checking (min/max) (REQ-009, REQ-010)
|
|
36
|
+
- Repetition detection (REQ-011)
|
|
37
|
+
- Inappropriate content filtering (REQ-012)
|
|
38
|
+
- Relevance checking (REQ-013)
|
|
39
|
+
- Detailed rejection reasons (REQ-014)
|
|
40
|
+
- Personality-aware validation (REQ-015)
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, config: ValidationConfig):
|
|
44
|
+
"""Initialize validator with configuration.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
config: Validation configuration
|
|
48
|
+
"""
|
|
49
|
+
self.config = config
|
|
50
|
+
self._recent_responses: deque[str] = deque(maxlen=config.repetition_history_size)
|
|
51
|
+
|
|
52
|
+
# Compile inappropriate content patterns if enabled
|
|
53
|
+
self.inappropriate_patterns = []
|
|
54
|
+
if config.check_inappropriate and config.inappropriate_patterns:
|
|
55
|
+
self.inappropriate_patterns = [
|
|
56
|
+
re.compile(pattern, re.IGNORECASE) for pattern in config.inappropriate_patterns
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
logger.info(
|
|
60
|
+
f"ResponseValidator initialized: "
|
|
61
|
+
f"min_length={config.min_length}, max_length={config.max_length}, "
|
|
62
|
+
f"check_repetition={config.check_repetition}, "
|
|
63
|
+
f"check_relevance={config.check_relevance}, "
|
|
64
|
+
f"check_inappropriate={config.check_inappropriate}"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def validate(
|
|
68
|
+
self, response: str, user_message: str, context: dict[str, Any]
|
|
69
|
+
) -> ValidationResult:
|
|
70
|
+
"""Validate response against quality criteria.
|
|
71
|
+
|
|
72
|
+
Runs all enabled validation checks and returns result.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
response: Formatted response to validate
|
|
76
|
+
user_message: Original user message
|
|
77
|
+
context: Context dict from ContextManager
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
ValidationResult with valid flag and reason
|
|
81
|
+
"""
|
|
82
|
+
# Check length (REQ-009, REQ-010)
|
|
83
|
+
result = self._check_length(response)
|
|
84
|
+
if not result.valid:
|
|
85
|
+
return result
|
|
86
|
+
|
|
87
|
+
# Check repetition (REQ-011)
|
|
88
|
+
if self.config.check_repetition:
|
|
89
|
+
result = self._check_repetition(response)
|
|
90
|
+
if not result.valid:
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
# Check inappropriate content (REQ-012)
|
|
94
|
+
if self.config.check_inappropriate:
|
|
95
|
+
result = self._check_inappropriate(response)
|
|
96
|
+
if not result.valid:
|
|
97
|
+
return result
|
|
98
|
+
|
|
99
|
+
# Check relevance (REQ-013)
|
|
100
|
+
if self.config.check_relevance:
|
|
101
|
+
result = self._check_relevance(response, user_message, context)
|
|
102
|
+
if not result.valid:
|
|
103
|
+
return result
|
|
104
|
+
|
|
105
|
+
# All checks passed - add to history for repetition checking
|
|
106
|
+
self._recent_responses.append(response.lower())
|
|
107
|
+
|
|
108
|
+
return ValidationResult(valid=True, reason="All validation checks passed", severity="INFO")
|
|
109
|
+
|
|
110
|
+
def _check_length(self, response: str) -> ValidationResult:
|
|
111
|
+
"""Check if response length is acceptable.
|
|
112
|
+
|
|
113
|
+
Implements REQ-009 (min length) and REQ-010 (max length).
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
response: Response to check
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
ValidationResult
|
|
120
|
+
"""
|
|
121
|
+
length = len(response)
|
|
122
|
+
|
|
123
|
+
if length < self.config.min_length:
|
|
124
|
+
return ValidationResult(
|
|
125
|
+
valid=False,
|
|
126
|
+
reason=f"Response too short ({length} chars, minimum {self.config.min_length})",
|
|
127
|
+
severity="WARNING",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if length > self.config.max_length:
|
|
131
|
+
return ValidationResult(
|
|
132
|
+
valid=False,
|
|
133
|
+
reason=f"Response too long ({length} chars, maximum {self.config.max_length})",
|
|
134
|
+
severity="ERROR",
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
return ValidationResult(valid=True, reason="Length acceptable", severity="INFO")
|
|
138
|
+
|
|
139
|
+
def _check_repetition(self, response: str) -> ValidationResult:
|
|
140
|
+
"""Check if response is repetitive.
|
|
141
|
+
|
|
142
|
+
Implements REQ-011: Detects if response is identical or highly similar
|
|
143
|
+
to recent responses using similarity threshold.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
response: Response to check
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
ValidationResult
|
|
150
|
+
"""
|
|
151
|
+
if not self._recent_responses:
|
|
152
|
+
return ValidationResult(valid=True, reason="No history to compare", severity="INFO")
|
|
153
|
+
|
|
154
|
+
response_lower = response.lower()
|
|
155
|
+
|
|
156
|
+
# Check for exact match first (fastest)
|
|
157
|
+
if response_lower in self._recent_responses:
|
|
158
|
+
return ValidationResult(
|
|
159
|
+
valid=False,
|
|
160
|
+
reason="Response is identical to recent response (exact match)",
|
|
161
|
+
severity="WARNING",
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# Check for high similarity
|
|
165
|
+
for past_response in self._recent_responses:
|
|
166
|
+
similarity = self._calculate_similarity(response_lower, past_response)
|
|
167
|
+
if similarity >= self.config.repetition_threshold:
|
|
168
|
+
return ValidationResult(
|
|
169
|
+
valid=False,
|
|
170
|
+
reason=(
|
|
171
|
+
f"Response is too similar to recent response "
|
|
172
|
+
f"(similarity: {similarity:.2f})"
|
|
173
|
+
),
|
|
174
|
+
severity="WARNING",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
return ValidationResult(valid=True, reason="Response is unique", severity="INFO")
|
|
178
|
+
|
|
179
|
+
def _check_inappropriate(self, response: str) -> ValidationResult:
|
|
180
|
+
"""Check for inappropriate content patterns.
|
|
181
|
+
|
|
182
|
+
Implements REQ-012: Checks response against configured inappropriate
|
|
183
|
+
content patterns (profanity, personal info, etc.).
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
response: Response to check
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
ValidationResult
|
|
190
|
+
"""
|
|
191
|
+
for pattern in self.inappropriate_patterns:
|
|
192
|
+
match = pattern.search(response)
|
|
193
|
+
if match:
|
|
194
|
+
return ValidationResult(
|
|
195
|
+
valid=False,
|
|
196
|
+
reason=f"Response contains inappropriate content: {match.group()}",
|
|
197
|
+
severity="ERROR",
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
return ValidationResult(
|
|
201
|
+
valid=True, reason="No inappropriate content detected", severity="INFO"
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
def _check_relevance(self, response: str, user_message: str, context: dict) -> ValidationResult:
|
|
205
|
+
"""Check if response is relevant to input.
|
|
206
|
+
|
|
207
|
+
Implements REQ-013: Optional relevance checking.
|
|
208
|
+
Checks if response contains keywords from user message or current video context.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
response: Response to check
|
|
212
|
+
user_message: Original user message
|
|
213
|
+
context: Context dict
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
ValidationResult
|
|
217
|
+
"""
|
|
218
|
+
# Simple keyword-based relevance check
|
|
219
|
+
# Extract significant words from user message (>3 chars)
|
|
220
|
+
user_words = {word.lower() for word in re.findall(r"\b\w{4,}\b", user_message)}
|
|
221
|
+
|
|
222
|
+
if not user_words:
|
|
223
|
+
# No significant words to check
|
|
224
|
+
return ValidationResult(
|
|
225
|
+
valid=True, reason="No keywords to check relevance", severity="INFO"
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
response_lower = response.lower()
|
|
229
|
+
|
|
230
|
+
# Check if any user words appear in response
|
|
231
|
+
matching_words = sum(1 for word in user_words if word in response_lower)
|
|
232
|
+
relevance_score = matching_words / len(user_words) if user_words else 0.0
|
|
233
|
+
|
|
234
|
+
# Also check video context if available
|
|
235
|
+
if context.get("current_video"):
|
|
236
|
+
video_title = context["current_video"].get("title", "").lower()
|
|
237
|
+
video_words = {word.lower() for word in re.findall(r"\b\w{4,}\b", video_title)}
|
|
238
|
+
if video_words:
|
|
239
|
+
video_matches = sum(1 for word in video_words if word in response_lower)
|
|
240
|
+
# Boost relevance if video context mentioned
|
|
241
|
+
relevance_score = max(relevance_score, video_matches / len(video_words))
|
|
242
|
+
|
|
243
|
+
if relevance_score < self.config.relevance_threshold:
|
|
244
|
+
return ValidationResult(
|
|
245
|
+
valid=False,
|
|
246
|
+
reason=f"Response not relevant to input (score: {relevance_score:.2f})",
|
|
247
|
+
severity="WARNING",
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
return ValidationResult(
|
|
251
|
+
valid=True,
|
|
252
|
+
reason=f"Response is relevant (score: {relevance_score:.2f})",
|
|
253
|
+
severity="INFO",
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
def _calculate_similarity(self, text1: str, text2: str) -> float:
|
|
257
|
+
"""Calculate similarity score between texts.
|
|
258
|
+
|
|
259
|
+
Uses SequenceMatcher from difflib for fuzzy matching.
|
|
260
|
+
Returns similarity score between 0.0 and 1.0.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
text1: First text
|
|
264
|
+
text2: Second text
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
Similarity score (0.0 = completely different, 1.0 = identical)
|
|
268
|
+
"""
|
|
269
|
+
return SequenceMatcher(None, text1, text2).ratio()
|
kryten_llm/config.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Configuration management for kryten-llm."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from pydantic import ValidationError
|
|
7
|
+
|
|
8
|
+
from kryten_llm.models.config import LLMConfig
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def load_config(config_path: Path) -> LLMConfig:
|
|
14
|
+
"""Load and validate configuration from file.
|
|
15
|
+
|
|
16
|
+
Uses kryten-py's built-in JSON loader with environment variable expansion.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
config_path: Path to configuration JSON file
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
Validated LLMConfig object
|
|
23
|
+
|
|
24
|
+
Raises:
|
|
25
|
+
FileNotFoundError: If config file doesn't exist
|
|
26
|
+
ValidationError: If config is invalid
|
|
27
|
+
ValueError: If config validation fails
|
|
28
|
+
"""
|
|
29
|
+
if not config_path.exists():
|
|
30
|
+
raise FileNotFoundError(f"Configuration file not found: {config_path}")
|
|
31
|
+
|
|
32
|
+
# Use kryten-py's from_json() - already handles ${VAR_NAME} expansion
|
|
33
|
+
try:
|
|
34
|
+
config: LLMConfig = LLMConfig.from_json(str(config_path)) # type: ignore[no-any-return]
|
|
35
|
+
except ValidationError as e:
|
|
36
|
+
logger.error("Configuration validation failed:")
|
|
37
|
+
for err in e.errors():
|
|
38
|
+
loc = " -> ".join(str(x) for x in err["loc"])
|
|
39
|
+
logger.error(f" {loc}: {err['msg']}")
|
|
40
|
+
raise
|
|
41
|
+
|
|
42
|
+
# Apply dry-run override
|
|
43
|
+
if config.testing.dry_run:
|
|
44
|
+
config.testing.send_to_chat = False
|
|
45
|
+
logger.info("Dry-run mode enabled - responses will not be sent to chat")
|
|
46
|
+
|
|
47
|
+
# Custom validation
|
|
48
|
+
is_valid, errors = config.validate_config()
|
|
49
|
+
if not is_valid:
|
|
50
|
+
logger.error("Configuration validation failed:")
|
|
51
|
+
for error in errors:
|
|
52
|
+
logger.error(f" {error}")
|
|
53
|
+
# Create exception with all error messages
|
|
54
|
+
error_msg = "Configuration validation failed:\n" + "\n".join(f" {e}" for e in errors)
|
|
55
|
+
raise ValueError(error_msg)
|
|
56
|
+
|
|
57
|
+
return config
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def validate_config_file(config_path: Path) -> tuple[bool, list[str]]:
|
|
61
|
+
"""Validate configuration file without loading.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
config_path: Path to configuration file
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Tuple of (is_valid, error_messages)
|
|
68
|
+
"""
|
|
69
|
+
errors = []
|
|
70
|
+
|
|
71
|
+
if not config_path.exists():
|
|
72
|
+
return False, [f"Configuration file not found: {config_path}"]
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
_config = load_config(config_path)
|
|
76
|
+
return True, []
|
|
77
|
+
except FileNotFoundError as e:
|
|
78
|
+
return False, [str(e)]
|
|
79
|
+
except ValidationError as e:
|
|
80
|
+
for error in e.errors():
|
|
81
|
+
loc = " -> ".join(str(x) for x in error["loc"])
|
|
82
|
+
errors.append(f"{loc}: {error['msg']}")
|
|
83
|
+
return False, errors
|
|
84
|
+
except ValueError as e:
|
|
85
|
+
# Parse the error message to extract individual errors
|
|
86
|
+
error_str = str(e)
|
|
87
|
+
if "Configuration validation failed:" in error_str:
|
|
88
|
+
# Split by newlines and filter out the header
|
|
89
|
+
error_lines = error_str.split("\n")[1:] # Skip first line
|
|
90
|
+
return False, [line.strip() for line in error_lines if line.strip()]
|
|
91
|
+
return False, [error_str]
|
|
92
|
+
except Exception as e:
|
|
93
|
+
return False, [f"Unexpected error: {e}"]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Data models for kryten-llm."""
|
|
2
|
+
|
|
3
|
+
from kryten_llm.models.config import (
|
|
4
|
+
ContextConfig,
|
|
5
|
+
LLMConfig,
|
|
6
|
+
LLMProvider,
|
|
7
|
+
MessageProcessing,
|
|
8
|
+
PersonalityConfig,
|
|
9
|
+
RateLimits,
|
|
10
|
+
TestingConfig,
|
|
11
|
+
Trigger,
|
|
12
|
+
)
|
|
13
|
+
from kryten_llm.models.events import TriggerResult
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"LLMConfig",
|
|
17
|
+
"PersonalityConfig",
|
|
18
|
+
"LLMProvider",
|
|
19
|
+
"Trigger",
|
|
20
|
+
"RateLimits",
|
|
21
|
+
"MessageProcessing",
|
|
22
|
+
"TestingConfig",
|
|
23
|
+
"ContextConfig",
|
|
24
|
+
"TriggerResult",
|
|
25
|
+
]
|