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,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
|