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.
@@ -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
+ ]