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,388 @@
1
+ """Spam detection for user behavior analysis.
2
+
3
+ Phase 4: Detect and prevent spam behavior with exponential backoff
4
+ (REQ-016 through REQ-022).
5
+ """
6
+
7
+ import logging
8
+ from collections import defaultdict, deque
9
+ from dataclasses import dataclass
10
+ from datetime import datetime, timedelta
11
+ from typing import Optional
12
+
13
+ from kryten_llm.models.config import SpamDetectionConfig
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ @dataclass
19
+ class SpamCheckResult:
20
+ """Result of spam check.
21
+
22
+ Implements PAT-003 from Phase 4 specification.
23
+ """
24
+
25
+ is_spam: bool
26
+ reason: str
27
+ penalty_duration: Optional[int] # Penalty duration in seconds
28
+ offense_count: int
29
+
30
+
31
+ class SpamDetector:
32
+ """Detects and prevents user spam behavior.
33
+
34
+ Phase 4 Implementation (REQ-016 through REQ-022):
35
+ - Message frequency tracking (REQ-016)
36
+ - Identical message detection (REQ-017)
37
+ - Rapid mention spam detection (REQ-018)
38
+ - Exponential backoff penalties (REQ-019)
39
+ - Admin exemptions (REQ-020)
40
+ - Clear logging (REQ-021)
41
+ - In-memory only state (REQ-022)
42
+ """
43
+
44
+ def __init__(self, config: SpamDetectionConfig):
45
+ """Initialize spam detector with configuration.
46
+
47
+ Args:
48
+ config: Spam detection configuration
49
+ """
50
+ self.config = config
51
+
52
+ # Track message timestamps per user (for rate limiting)
53
+ self._user_messages: dict[str, deque[datetime]] = defaultdict(lambda: deque(maxlen=100))
54
+
55
+ # Track mention timestamps per user (for mention spam)
56
+ self._user_mentions: dict[str, deque[datetime]] = defaultdict(lambda: deque(maxlen=50))
57
+
58
+ # Track penalties per user
59
+ self._user_penalties: dict[str, datetime] = {}
60
+
61
+ # Track offense counts for exponential backoff
62
+ self._offense_counts: dict[str, int] = defaultdict(int)
63
+
64
+ # Track last offense time for clean period
65
+ self._last_offense: dict[str, datetime] = {}
66
+
67
+ # Track last N messages per user (for identical detection)
68
+ self._last_messages: dict[str, deque[str]] = defaultdict(lambda: deque(maxlen=20))
69
+
70
+ logger.info(
71
+ f"SpamDetector initialized: enabled={config.enabled}, "
72
+ f"windows={len(config.message_windows)}, "
73
+ f"mention_window={config.mention_spam_window}s"
74
+ )
75
+
76
+ # Expose internal state for testing
77
+ @property
78
+ def user_messages(self):
79
+ return self._user_messages
80
+
81
+ @property
82
+ def user_penalties(self):
83
+ return self._user_penalties
84
+
85
+ @property
86
+ def offense_counts(self):
87
+ return self._offense_counts
88
+
89
+ @property
90
+ def last_offense(self):
91
+ return self._last_offense
92
+
93
+ def check_spam(
94
+ self, username: str, message: str, user_rank: int, mention_count: int = 0
95
+ ) -> SpamCheckResult:
96
+ """Check if user is spamming.
97
+
98
+ Implements spam detection pipeline with multiple checks.
99
+
100
+ Args:
101
+ username: User sending message
102
+ message: Message text
103
+ user_rank: User's rank (for admin exemption)
104
+ mention_count: Number of mentions in the message (for mention spam detection)
105
+
106
+ Returns:
107
+ SpamCheckResult indicating spam status and penalty
108
+ """
109
+ if not self.config.enabled:
110
+ return SpamCheckResult(
111
+ is_spam=False,
112
+ reason="Spam detection disabled",
113
+ penalty_duration=None,
114
+ offense_count=0,
115
+ )
116
+
117
+ # Check admin exemption (REQ-020)
118
+ if user_rank in self.config.admin_exempt_ranks:
119
+ return SpamCheckResult(
120
+ is_spam=False,
121
+ reason=f"User exempt from spam detection (admin rank {user_rank})",
122
+ penalty_duration=None,
123
+ offense_count=0,
124
+ )
125
+
126
+ # Check if user is currently under penalty
127
+ if self._is_under_penalty(username):
128
+ penalty_until = self._user_penalties[username]
129
+ remaining = int((penalty_until - datetime.now()).total_seconds())
130
+ return SpamCheckResult(
131
+ is_spam=True,
132
+ reason=f"User under spam penalty ({remaining}s remaining)",
133
+ penalty_duration=remaining,
134
+ offense_count=self._offense_counts[username],
135
+ )
136
+
137
+ # Check clean period for resetting offense count
138
+ self._check_clean_period(username)
139
+
140
+ # Check message rate limits (REQ-016)
141
+ rate_violation = self._check_rate_limits(username, mention_count)
142
+ if rate_violation:
143
+ penalty_duration = self._apply_penalty(username)
144
+ return SpamCheckResult(
145
+ is_spam=True,
146
+ reason=rate_violation,
147
+ penalty_duration=penalty_duration,
148
+ offense_count=self._offense_counts[username],
149
+ )
150
+
151
+ # Check identical message repetition (REQ-017)
152
+ if self._check_identical_messages(username, message):
153
+ penalty_duration = self._apply_penalty(username)
154
+ return SpamCheckResult(
155
+ is_spam=True,
156
+ reason=(
157
+ f"Identical message spam detected "
158
+ f"(threshold: {self.config.identical_message_threshold})"
159
+ ),
160
+ penalty_duration=penalty_duration,
161
+ offense_count=self._offense_counts[username],
162
+ )
163
+
164
+ # No spam detected
165
+ return SpamCheckResult(
166
+ is_spam=False,
167
+ reason="No spam detected",
168
+ penalty_duration=None,
169
+ offense_count=self._offense_counts.get(username, 0),
170
+ )
171
+
172
+ def record_message(self, username: str, message: str, user_rank: int, mention_count: int = 0):
173
+ """Record message for spam tracking.
174
+
175
+ Updates message history and timestamp tracking.
176
+
177
+ Args:
178
+ username: User who sent message
179
+ message: Message text
180
+ user_rank: User's rank (unused but kept for API compatibility)
181
+ mention_count: Number of mentions to track for mention spam
182
+ """
183
+ now = datetime.now()
184
+ self._user_messages[username].append(now)
185
+ self._last_messages[username].append(message.lower().strip())
186
+
187
+ # Track mentions separately for mention spam detection
188
+ if mention_count > 0:
189
+ for _ in range(mention_count):
190
+ self._user_mentions[username].append(now)
191
+
192
+ def _check_rate_limits(self, username: str, mention_count: int) -> Optional[str]:
193
+ """Check if user exceeds rate limits.
194
+
195
+ Implements REQ-016 (general) and REQ-018 (mention spam).
196
+
197
+ Args:
198
+ username: User to check
199
+ mention_count: Number of mentions in the message
200
+
201
+ Returns:
202
+ Violation reason if rate limit exceeded, None otherwise
203
+ """
204
+ now = datetime.now()
205
+ user_timestamps = self._user_messages[username]
206
+
207
+ # Check mention spam specifically (REQ-018)
208
+ if mention_count > 0:
209
+ # Handle both int (seconds) and MessageWindow types
210
+ window_seconds = (
211
+ self.config.mention_spam_window
212
+ if isinstance(self.config.mention_spam_window, int)
213
+ else self.config.mention_spam_window.seconds
214
+ )
215
+ mention_window = timedelta(seconds=window_seconds)
216
+ user_mention_timestamps = self._user_mentions[username]
217
+ recent_mentions = sum(1 for ts in user_mention_timestamps if now - ts <= mention_window)
218
+
219
+ # Spam if already sent max_messages mentions (current would exceed)
220
+ if recent_mentions >= self.config.mention_spam_threshold:
221
+ return (
222
+ f"Exceeded mention spam threshold: {recent_mentions + mention_count} mentions "
223
+ f"in {self.config.mention_spam_window}s "
224
+ f"(limit: {self.config.mention_spam_threshold})"
225
+ )
226
+
227
+ # Check general message windows (REQ-016)
228
+ for window in self.config.message_windows:
229
+ window_delta = timedelta(seconds=window.seconds)
230
+ messages_in_window = sum(1 for ts in user_timestamps if now - ts <= window_delta)
231
+
232
+ # Count current message
233
+ messages_in_window += 1
234
+
235
+ if messages_in_window > window.max_messages:
236
+ return (
237
+ f"Exceeded rate limit: {messages_in_window} messages "
238
+ f"in {window.seconds}s (limit: {window.max_messages})"
239
+ )
240
+
241
+ return None
242
+
243
+ def _check_identical_messages(self, username: str, message: str) -> bool:
244
+ """Check for identical message repetition.
245
+
246
+ Implements REQ-017: Detects if user sends same message multiple times.
247
+
248
+ Args:
249
+ username: User to check
250
+ message: Current message
251
+
252
+ Returns:
253
+ True if identical spam detected
254
+ """
255
+ message_lower = message.lower().strip()
256
+ recent_messages = self._last_messages[username]
257
+
258
+ if not recent_messages:
259
+ return False
260
+
261
+ # Count occurrences of this message in recent history
262
+ identical_count = sum(1 for msg in recent_messages if msg == message_lower)
263
+
264
+ # Spam if already sent max_messages identical messages (this would be max+1)
265
+ return identical_count >= self.config.identical_message_threshold
266
+
267
+ def _apply_penalty(self, username: str) -> int:
268
+ """Apply exponential backoff penalty.
269
+
270
+ Implements REQ-019: Penalty duration doubles on each violation.
271
+
272
+ Args:
273
+ username: User to penalize
274
+
275
+ Returns:
276
+ Penalty duration in seconds
277
+ """
278
+ # Increment offense count
279
+ self._offense_counts[username] += 1
280
+ offense_count = self._offense_counts[username]
281
+
282
+ # Calculate penalty duration with exponential backoff
283
+ # Penalty = initial_penalty * (penalty_multiplier ^ (offense_count - 1))
284
+ penalty_seconds = int(
285
+ self.config.initial_penalty * (self.config.penalty_multiplier ** (offense_count - 1))
286
+ )
287
+
288
+ # Cap at max_penalty
289
+ penalty_seconds = min(penalty_seconds, self.config.max_penalty)
290
+
291
+ penalty_until = datetime.now() + timedelta(seconds=penalty_seconds)
292
+ self._user_penalties[username] = penalty_until
293
+ self._last_offense[username] = datetime.now()
294
+
295
+ severity = "WARNING" if offense_count == 1 else "ERROR"
296
+ logger.log(
297
+ logging.WARNING if offense_count == 1 else logging.ERROR,
298
+ f"Spam penalty applied to {username}: {penalty_seconds:.0f}s "
299
+ f"(offense #{offense_count}, severity: {severity})",
300
+ )
301
+
302
+ return penalty_seconds
303
+
304
+ def _is_under_penalty(self, username: str) -> bool:
305
+ """Check if user currently under penalty.
306
+
307
+ Args:
308
+ username: User to check
309
+
310
+ Returns:
311
+ True if user is under active penalty
312
+ """
313
+ if username not in self._user_penalties:
314
+ return False
315
+
316
+ penalty_until = self._user_penalties[username]
317
+
318
+ if datetime.now() >= penalty_until:
319
+ # Penalty expired, clean up
320
+ del self._user_penalties[username]
321
+ return False
322
+
323
+ return True
324
+
325
+ def _check_clean_period(self, username: str):
326
+ """Check clean period to reset offense count.
327
+
328
+ Implements REQ-019: Reset to base penalty after clean period.
329
+
330
+ Args:
331
+ username: User to check
332
+ """
333
+ if username not in self._last_offense:
334
+ return
335
+
336
+ last_offense = self._last_offense[username]
337
+ clean_delta = timedelta(seconds=self.config.clean_period)
338
+
339
+ if datetime.now() - last_offense >= clean_delta:
340
+ # Clean period passed, reset offense count
341
+ old_count = self._offense_counts[username]
342
+ self._offense_counts[username] = 0
343
+ del self._last_offense[username]
344
+
345
+ logger.info(
346
+ f"Clean period passed for {username}: " f"offense count reset from {old_count} to 0"
347
+ )
348
+
349
+ def _cleanup_old_data(self):
350
+ """Clean up old tracking data (periodic maintenance).
351
+
352
+ Implements REQ-022: Keep state in memory with periodic cleanup.
353
+ Should be called periodically by service to prevent memory growth.
354
+ """
355
+ now = datetime.now()
356
+ cutoff = now - timedelta(hours=1)
357
+
358
+ # Clean up old penalties
359
+ expired_penalties = [user for user, until in self._user_penalties.items() if until < now]
360
+ for user in expired_penalties:
361
+ del self._user_penalties[user]
362
+
363
+ # Clean up old offense records
364
+ old_offenses = [user for user, ts in self._last_offense.items() if ts < cutoff]
365
+ for user in old_offenses:
366
+ if user in self._offense_counts:
367
+ del self._offense_counts[user]
368
+ del self._last_offense[user]
369
+
370
+ # Clean up empty message deques
371
+ empty_users = [
372
+ user
373
+ for user, msgs in self._user_messages.items()
374
+ if not msgs or (msgs and msgs[-1] < cutoff)
375
+ ]
376
+ for user in empty_users:
377
+ if user in self._user_messages:
378
+ del self._user_messages[user]
379
+ if user in self._last_messages:
380
+ del self._last_messages[user]
381
+
382
+ if expired_penalties or old_offenses or empty_users:
383
+ logger.debug(
384
+ f"Cleaned up spam tracking data: "
385
+ f"{len(expired_penalties)} penalties, "
386
+ f"{len(old_offenses)} offenses, "
387
+ f"{len(empty_users)} user histories"
388
+ )
@@ -0,0 +1,278 @@
1
+ """Trigger detection engine for chat messages."""
2
+
3
+ import logging
4
+ import random
5
+ import re
6
+ from typing import Optional
7
+
8
+ from kryten_llm.models.config import LLMConfig
9
+ from kryten_llm.models.events import TriggerResult
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class TriggerEngine:
15
+ """Detects trigger conditions in chat messages.
16
+
17
+ Implements REQ-004, REQ-005, REQ-006 from Phase 1 specification:
18
+ - Detect mentions using name variations (case-insensitive)
19
+ - Clean message by removing bot name
20
+ - Return TriggerResult with appropriate fields
21
+
22
+ Phase 1: Only mention detection
23
+ Phase 2: Add trigger word patterns with probabilities
24
+ """
25
+
26
+ def __init__(self, config: LLMConfig):
27
+ """Initialize with configuration.
28
+
29
+ Args:
30
+ config: LLM configuration containing personality name variations and triggers
31
+ """
32
+ self.config = config
33
+ # Sort name variations by length (longest first) to match longer names first
34
+ # This prevents "cynthia" from matching "cynthiarothbot"
35
+ self.name_variations = sorted(
36
+ [name.lower() for name in config.personality.name_variations], key=len, reverse=True
37
+ )
38
+
39
+ # Phase 2: Load enabled triggers and sort by priority (REQ-001, REQ-007)
40
+ self.triggers = [t for t in config.triggers if t.enabled]
41
+ # Sort by priority (highest first) for REQ-010
42
+ self.triggers.sort(key=lambda t: t.priority, reverse=True)
43
+
44
+ # Phase 6: Pre-compile regex patterns for efficiency
45
+ self._compiled_name_patterns: dict[str, re.Pattern] = {}
46
+ self._compiled_trigger_patterns: dict[str, re.Pattern] = {}
47
+ self._compile_patterns()
48
+
49
+ logger.info(
50
+ f"TriggerEngine initialized with {len(self.name_variations)} name variations "
51
+ f"and {len(self.triggers)} enabled triggers"
52
+ )
53
+
54
+ def _compile_patterns(self) -> None:
55
+ """Pre-compile regex patterns for name variations and trigger phrases.
56
+
57
+ Phase 6: Pattern compilation for performance optimization.
58
+ """
59
+ # Compile name variation patterns
60
+ for name in self.name_variations:
61
+ self._compiled_name_patterns[name] = re.compile(
62
+ r"\b" + re.escape(name) + r"\b[,.:;!?]?\s*", re.IGNORECASE
63
+ )
64
+
65
+ # Compile trigger phrase patterns
66
+ for trigger in self.triggers:
67
+ for pattern in trigger.patterns:
68
+ pattern_lower = pattern.lower()
69
+ if pattern_lower not in self._compiled_trigger_patterns:
70
+ self._compiled_trigger_patterns[pattern_lower] = re.compile(
71
+ r"\b" + re.escape(pattern) + r"\b[,.:;!?]?\s*", re.IGNORECASE
72
+ )
73
+
74
+ logger.debug(
75
+ f"Compiled {len(self._compiled_name_patterns)} name patterns "
76
+ f"and {len(self._compiled_trigger_patterns)} trigger patterns"
77
+ )
78
+
79
+ async def check_triggers(self, message: dict) -> TriggerResult:
80
+ """Check if message triggers a response.
81
+
82
+ Phase 2: Checks mentions first, then trigger word patterns with probabilities
83
+
84
+ Processing order (REQ-008):
85
+ 1. Check mentions (highest priority)
86
+ 2. Check trigger words (by configured priority)
87
+ 3. Apply probability check if trigger matches
88
+
89
+ Args:
90
+ message: Filtered message dict from MessageListener
91
+
92
+ Returns:
93
+ TriggerResult indicating if triggered and details
94
+ """
95
+ msg_text = message["msg"]
96
+
97
+ # REQ-008: Check mentions FIRST (priority over trigger words)
98
+ mention_result = self._check_mention(msg_text)
99
+ if mention_result:
100
+ logger.info(
101
+ f"Mention detected: '{mention_result.trigger_name}' from " f"{message['username']}"
102
+ )
103
+ return mention_result
104
+
105
+ # Phase 2: Check trigger words (REQ-002)
106
+ trigger_word_result = self._check_trigger_words(msg_text)
107
+ if trigger_word_result:
108
+ logger.info(
109
+ f"Trigger word activated: '{trigger_word_result.trigger_name}' "
110
+ f"(probability check passed) from {message['username']}"
111
+ )
112
+ return trigger_word_result
113
+
114
+ # No triggers detected
115
+ logger.debug(f"No triggers in message from {message['username']}")
116
+ return TriggerResult(
117
+ triggered=False,
118
+ trigger_type=None,
119
+ trigger_name=None,
120
+ cleaned_message=msg_text,
121
+ context=None,
122
+ priority=0,
123
+ )
124
+
125
+ def _check_mention(self, message_text: str) -> Optional[TriggerResult]:
126
+ """Check for bot name mentions.
127
+
128
+ Args:
129
+ message_text: Message text to check
130
+
131
+ Returns:
132
+ TriggerResult with trigger_type="mention" if found, else None
133
+ """
134
+ msg_lower = message_text.lower()
135
+
136
+ for name_variation in self.name_variations:
137
+ if name_variation in msg_lower:
138
+ cleaned_message = self._remove_bot_name(message_text, name_variation)
139
+
140
+ return TriggerResult(
141
+ triggered=True,
142
+ trigger_type="mention",
143
+ trigger_name=name_variation,
144
+ cleaned_message=cleaned_message,
145
+ context=None, # Mentions don't have context
146
+ priority=10, # High priority for mentions
147
+ )
148
+
149
+ return None
150
+
151
+ def _check_trigger_words(self, message_text: str) -> Optional[TriggerResult]:
152
+ """Check for trigger word patterns with probability.
153
+
154
+ Iterates through triggers by priority (highest first).
155
+ For each trigger, checks if any pattern matches.
156
+ If match found, applies probability check (REQ-004).
157
+
158
+ Args:
159
+ message_text: Message text to check
160
+
161
+ Returns:
162
+ TriggerResult with trigger_type="trigger_word" if triggered, else None
163
+ """
164
+ msg_lower = message_text.lower()
165
+
166
+ # REQ-010: Check triggers in priority order (highest first)
167
+ for trigger in self.triggers:
168
+ # Check if any pattern matches (REQ-003, REQ-009)
169
+ matched_pattern = None
170
+ for pattern in trigger.patterns:
171
+ if self._match_pattern(pattern, msg_lower):
172
+ matched_pattern = pattern
173
+ break
174
+
175
+ if matched_pattern:
176
+ # REQ-004: Apply probability check
177
+ roll = random.random()
178
+ logger.debug(
179
+ f"Trigger '{trigger.name}' pattern matched, "
180
+ f"probability roll: {roll:.3f} vs {trigger.probability}"
181
+ )
182
+
183
+ if roll < trigger.probability:
184
+ # Trigger activated!
185
+ cleaned_message = self._clean_message(message_text, matched_pattern)
186
+
187
+ # REQ-005, REQ-006: Return trigger context and priority
188
+ return TriggerResult(
189
+ triggered=True,
190
+ trigger_type="trigger_word",
191
+ trigger_name=trigger.name,
192
+ cleaned_message=cleaned_message,
193
+ context=trigger.context if trigger.context else None,
194
+ priority=trigger.priority,
195
+ )
196
+ else:
197
+ # Probability check failed, continue to next trigger
198
+ logger.debug(
199
+ f"Trigger '{trigger.name}' pattern matched but "
200
+ f"probability check failed ({roll:.3f} >= {trigger.probability})"
201
+ )
202
+
203
+ return None
204
+
205
+ def _match_pattern(self, pattern: str, text: str) -> bool:
206
+ """Check if pattern matches text (case-insensitive substring).
207
+
208
+ Phase 2: Simple substring matching (CON-001)
209
+ Phase 3+: Could add regex support
210
+
211
+ Args:
212
+ pattern: Pattern to match (will be lowercased)
213
+ text: Text to search in (already lowercased)
214
+
215
+ Returns:
216
+ True if pattern found in text
217
+ """
218
+ return pattern.lower() in text
219
+
220
+ def _clean_message(self, message: str, trigger_phrase: str) -> str:
221
+ """Remove trigger phrase from message for LLM processing.
222
+
223
+ Args:
224
+ message: Original message text
225
+ trigger_phrase: The phrase that was matched
226
+
227
+ Returns:
228
+ Cleaned message with trigger phrase removed
229
+ """
230
+ # Phase 6: Use cached compiled pattern if available
231
+ pattern_lower = trigger_phrase.lower()
232
+ if pattern_lower in self._compiled_trigger_patterns:
233
+ compiled = self._compiled_trigger_patterns[pattern_lower]
234
+ else:
235
+ # Fallback: compile on the fly for patterns not in cache
236
+ compiled = re.compile(
237
+ r"\b" + re.escape(trigger_phrase) + r"\b[,.:;!?]?\s*", re.IGNORECASE
238
+ )
239
+
240
+ # Remove the phrase
241
+ cleaned = compiled.sub("", message)
242
+
243
+ # Clean up extra whitespace
244
+ cleaned = " ".join(cleaned.split())
245
+
246
+ return cleaned.strip()
247
+
248
+ def _remove_bot_name(self, message: str, name_variation: str) -> str:
249
+ """Remove bot name from message (case-insensitive).
250
+
251
+ Removes the matched name variation and cleans up extra whitespace.
252
+
253
+ Args:
254
+ message: Original message text
255
+ name_variation: The name variation that was matched (lowercase)
256
+
257
+ Returns:
258
+ Cleaned message with bot name removed
259
+ """
260
+ # Phase 6: Use cached compiled pattern
261
+ if name_variation in self._compiled_name_patterns:
262
+ compiled = self._compiled_name_patterns[name_variation]
263
+ else:
264
+ # Fallback: compile on the fly
265
+ compiled = re.compile(
266
+ r"\b" + re.escape(name_variation) + r"\b[,.:;!?]?\s*", re.IGNORECASE
267
+ )
268
+
269
+ # Remove the name
270
+ cleaned = compiled.sub("", message)
271
+
272
+ # Clean up extra whitespace
273
+ cleaned = " ".join(cleaned.split())
274
+
275
+ # Remove leading/trailing whitespace and punctuation
276
+ cleaned = cleaned.strip(" ,.:;!?")
277
+
278
+ return cleaned