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,383 @@
|
|
|
1
|
+
"""Response formatter for chat output.
|
|
2
|
+
|
|
3
|
+
Phase 4: Intelligent formatting with sentence-aware splitting,
|
|
4
|
+
artifact removal, and code block stripping (REQ-001 through REQ-008).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
import emoji
|
|
11
|
+
|
|
12
|
+
from kryten_llm.models.config import FormattingConfig, LLMConfig, PersonalityConfig
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ResponseFormatter:
|
|
18
|
+
"""Formats LLM responses for chat output.
|
|
19
|
+
|
|
20
|
+
Phase 4 Implementation (REQ-001 through REQ-008):
|
|
21
|
+
- Intelligent sentence boundary splitting (REQ-001, REQ-002)
|
|
22
|
+
- Self-reference removal (REQ-003)
|
|
23
|
+
- LLM artifact removal (REQ-004)
|
|
24
|
+
- Emoji limiting (REQ-005)
|
|
25
|
+
- Whitespace normalization (REQ-006)
|
|
26
|
+
- Code block removal (REQ-007)
|
|
27
|
+
- Returns list of formatted strings (REQ-008)
|
|
28
|
+
|
|
29
|
+
Follows pipeline pattern (PAT-001):
|
|
30
|
+
1. Remove code blocks
|
|
31
|
+
2. Remove artifacts
|
|
32
|
+
3. Remove self-references
|
|
33
|
+
4. Normalize whitespace
|
|
34
|
+
5. Split on sentences
|
|
35
|
+
6. Add continuation indicators
|
|
36
|
+
7. Return list of strings
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, config: LLMConfig):
|
|
40
|
+
"""Initialize with configuration.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
config: LLM configuration containing formatting and personality settings
|
|
44
|
+
"""
|
|
45
|
+
self.formatting_config: FormattingConfig = config.formatting
|
|
46
|
+
self.personality_config: PersonalityConfig = config.personality
|
|
47
|
+
self.max_length = self.formatting_config.max_message_length
|
|
48
|
+
self.continuation = self.formatting_config.continuation_indicator
|
|
49
|
+
|
|
50
|
+
# Compile regex patterns for performance
|
|
51
|
+
self._compile_patterns()
|
|
52
|
+
|
|
53
|
+
logger.info(
|
|
54
|
+
f"ResponseFormatter initialized: max_length={self.max_length}, "
|
|
55
|
+
f"character={self.personality_config.character_name}, "
|
|
56
|
+
f"remove_artifacts={self.formatting_config.remove_llm_artifacts}, "
|
|
57
|
+
f"remove_self_refs={self.formatting_config.remove_self_references}"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def _compile_patterns(self):
|
|
61
|
+
"""Compile regex patterns for hot path performance."""
|
|
62
|
+
# Code block pattern (triple backticks)
|
|
63
|
+
self.code_block_pattern = re.compile(r"```[a-z]*\n.*?```", re.DOTALL | re.IGNORECASE)
|
|
64
|
+
|
|
65
|
+
# Artifact patterns from config
|
|
66
|
+
self.artifact_patterns = [
|
|
67
|
+
re.compile(pattern, re.IGNORECASE)
|
|
68
|
+
for pattern in self.formatting_config.artifact_patterns
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
# Self-reference patterns
|
|
72
|
+
bot_name = re.escape(self.personality_config.character_name)
|
|
73
|
+
self.self_ref_patterns = [
|
|
74
|
+
re.compile(rf"^(As |I am |I\'m )?{bot_name}[,:]?\s*", re.IGNORECASE),
|
|
75
|
+
re.compile(rf"\b(speaking as|in the role of|playing)\s+{bot_name}\b", re.IGNORECASE),
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
# Sentence boundary pattern - handles . ! ? followed by space or end
|
|
79
|
+
self.sentence_boundary = re.compile(r"([.!?])\s+")
|
|
80
|
+
|
|
81
|
+
# Multiple whitespace pattern
|
|
82
|
+
self.multi_space = re.compile(r"\s+")
|
|
83
|
+
|
|
84
|
+
# Empty lines pattern
|
|
85
|
+
self.empty_lines = re.compile(r"\n\s*\n")
|
|
86
|
+
|
|
87
|
+
def format_response(self, response: str) -> list[str]:
|
|
88
|
+
"""Format LLM response for chat following Phase 4 pipeline.
|
|
89
|
+
|
|
90
|
+
Pipeline (PAT-001):
|
|
91
|
+
1. Remove code blocks (REQ-007)
|
|
92
|
+
2. Remove artifacts (REQ-004)
|
|
93
|
+
3. Remove self-references (REQ-003)
|
|
94
|
+
4. Normalize whitespace (REQ-006)
|
|
95
|
+
5. Split on sentences (REQ-001)
|
|
96
|
+
6. Add continuation indicators (REQ-002)
|
|
97
|
+
7. Limit emoji if enabled (REQ-005)
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
response: Raw LLM response text
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
List of formatted message strings, each ≤ max_message_length
|
|
104
|
+
Empty list if response is invalid/empty
|
|
105
|
+
"""
|
|
106
|
+
if not response or not response.strip():
|
|
107
|
+
logger.warning("Empty response received")
|
|
108
|
+
return []
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
# Step 1: Remove code blocks (REQ-007)
|
|
112
|
+
formatted = self._remove_code_blocks(response)
|
|
113
|
+
if not formatted.strip():
|
|
114
|
+
logger.warning("Response empty after removing code blocks")
|
|
115
|
+
return []
|
|
116
|
+
|
|
117
|
+
# Step 2: Remove artifacts (REQ-004)
|
|
118
|
+
if self.formatting_config.remove_llm_artifacts:
|
|
119
|
+
formatted = self._remove_artifacts(formatted)
|
|
120
|
+
if not formatted.strip():
|
|
121
|
+
logger.warning("Response empty after removing artifacts")
|
|
122
|
+
return []
|
|
123
|
+
|
|
124
|
+
# Step 3: Remove self-references (REQ-003)
|
|
125
|
+
if self.formatting_config.remove_self_references:
|
|
126
|
+
formatted = self._remove_self_references(formatted)
|
|
127
|
+
if not formatted.strip():
|
|
128
|
+
logger.warning("Response empty after removing self-references")
|
|
129
|
+
return []
|
|
130
|
+
|
|
131
|
+
# Step 4: Normalize whitespace (REQ-006)
|
|
132
|
+
formatted = self._normalize_whitespace(formatted)
|
|
133
|
+
if not formatted:
|
|
134
|
+
logger.warning("Response empty after normalizing whitespace")
|
|
135
|
+
return []
|
|
136
|
+
|
|
137
|
+
# Step 5 & 6: Split on sentences and add continuation (REQ-001, REQ-002)
|
|
138
|
+
parts = self._split_on_sentences(formatted, self.max_length)
|
|
139
|
+
parts = self._add_continuation_indicators(parts)
|
|
140
|
+
|
|
141
|
+
# Step 7: Limit emoji if enabled (REQ-005)
|
|
142
|
+
if (
|
|
143
|
+
self.formatting_config.enable_emoji_limiting
|
|
144
|
+
and self.formatting_config.max_emoji_per_message
|
|
145
|
+
):
|
|
146
|
+
parts = [
|
|
147
|
+
self._limit_emoji(part, self.formatting_config.max_emoji_per_message)
|
|
148
|
+
for part in parts
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
if not parts:
|
|
152
|
+
logger.warning("No parts generated from response")
|
|
153
|
+
return []
|
|
154
|
+
|
|
155
|
+
logger.debug(f"Formatted response into {len(parts)} part(s)")
|
|
156
|
+
return parts
|
|
157
|
+
|
|
158
|
+
except Exception as e:
|
|
159
|
+
logger.error(f"Error formatting response: {e}", exc_info=True)
|
|
160
|
+
# Return empty list on error (graceful degradation)
|
|
161
|
+
return []
|
|
162
|
+
|
|
163
|
+
def _remove_code_blocks(self, text: str) -> str:
|
|
164
|
+
"""Remove triple-backtick code blocks.
|
|
165
|
+
|
|
166
|
+
Implements REQ-007: Code blocks are not suitable for chat display.
|
|
167
|
+
Removes entire code block including backticks and content.
|
|
168
|
+
Preserves text before and after code blocks.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
text: Response text
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
Text with code blocks removed
|
|
175
|
+
"""
|
|
176
|
+
# Remove all ```language...``` blocks
|
|
177
|
+
text = self.code_block_pattern.sub("", text)
|
|
178
|
+
return text.strip()
|
|
179
|
+
|
|
180
|
+
def _remove_artifacts(self, text: str) -> str:
|
|
181
|
+
"""Remove common LLM artifacts and preambles.
|
|
182
|
+
|
|
183
|
+
Implements REQ-004: Remove introductory phrases, meta-commentary,
|
|
184
|
+
and hedging language using configured patterns.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
text: Response text
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
Text with artifacts removed
|
|
191
|
+
"""
|
|
192
|
+
for pattern in self.artifact_patterns:
|
|
193
|
+
text = pattern.sub("", text)
|
|
194
|
+
|
|
195
|
+
return text.strip()
|
|
196
|
+
|
|
197
|
+
def _remove_self_references(self, text: str) -> str:
|
|
198
|
+
"""Remove self-referential phrases.
|
|
199
|
+
|
|
200
|
+
Implements REQ-003: Remove patterns where bot refers to itself
|
|
201
|
+
in third person or explains its role.
|
|
202
|
+
|
|
203
|
+
Examples removed:
|
|
204
|
+
- "As CynthiaRothbot, I think..."
|
|
205
|
+
- "I am CynthiaRothbot and..."
|
|
206
|
+
- "Speaking as CynthiaRothbot..."
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
text: Response text
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
Text with self-references removed
|
|
213
|
+
"""
|
|
214
|
+
for pattern in self.self_ref_patterns:
|
|
215
|
+
text = pattern.sub("", text)
|
|
216
|
+
|
|
217
|
+
return text.strip()
|
|
218
|
+
|
|
219
|
+
def _normalize_whitespace(self, text: str) -> str:
|
|
220
|
+
"""Normalize whitespace and line breaks.
|
|
221
|
+
|
|
222
|
+
Implements REQ-006:
|
|
223
|
+
- Remove leading/trailing whitespace
|
|
224
|
+
- Replace multiple spaces with single space
|
|
225
|
+
- Remove empty lines
|
|
226
|
+
- Preserve single line breaks where intentional
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
text: Response text
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
Text with normalized whitespace
|
|
233
|
+
"""
|
|
234
|
+
# Remove empty lines (multiple newlines)
|
|
235
|
+
text = self.empty_lines.sub("\n", text)
|
|
236
|
+
|
|
237
|
+
# Replace multiple spaces with single space
|
|
238
|
+
text = self.multi_space.sub(" ", text)
|
|
239
|
+
|
|
240
|
+
# Strip leading/trailing whitespace
|
|
241
|
+
text = text.strip()
|
|
242
|
+
|
|
243
|
+
return text
|
|
244
|
+
|
|
245
|
+
def _split_on_sentences(self, text: str, max_length: int) -> list[str]:
|
|
246
|
+
"""Split text at sentence boundaries.
|
|
247
|
+
|
|
248
|
+
Implements REQ-001: Split messages at sentence boundaries (. ! ?)
|
|
249
|
+
rather than mid-word or mid-sentence. Respects max_length constraint.
|
|
250
|
+
|
|
251
|
+
If a sentence exceeds max_length, falls back to word boundary splitting.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
text: Text to split
|
|
255
|
+
max_length: Maximum length per part
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
List of text parts split at sentence boundaries
|
|
259
|
+
"""
|
|
260
|
+
if len(text) <= max_length:
|
|
261
|
+
return [text]
|
|
262
|
+
|
|
263
|
+
parts = []
|
|
264
|
+
current_part = ""
|
|
265
|
+
|
|
266
|
+
# Split into sentences
|
|
267
|
+
sentences = self.sentence_boundary.split(text)
|
|
268
|
+
|
|
269
|
+
# Reconstruct sentences with punctuation
|
|
270
|
+
i = 0
|
|
271
|
+
while i < len(sentences):
|
|
272
|
+
if i + 1 < len(sentences) and sentences[i + 1] in ".!?":
|
|
273
|
+
sentence = sentences[i] + sentences[i + 1]
|
|
274
|
+
i += 2
|
|
275
|
+
else:
|
|
276
|
+
sentence = sentences[i]
|
|
277
|
+
i += 1
|
|
278
|
+
|
|
279
|
+
# Skip empty sentences
|
|
280
|
+
if not sentence.strip():
|
|
281
|
+
continue
|
|
282
|
+
|
|
283
|
+
# If sentence alone exceeds max_length, split on words
|
|
284
|
+
if len(sentence) > max_length:
|
|
285
|
+
# Flush current part if any
|
|
286
|
+
if current_part:
|
|
287
|
+
parts.append(current_part.strip())
|
|
288
|
+
current_part = ""
|
|
289
|
+
|
|
290
|
+
# Split long sentence on word boundaries
|
|
291
|
+
words = sentence.split()
|
|
292
|
+
word_part = ""
|
|
293
|
+
for word in words:
|
|
294
|
+
if len(word_part) + len(word) + 1 <= max_length:
|
|
295
|
+
word_part += (" " if word_part else "") + word
|
|
296
|
+
else:
|
|
297
|
+
if word_part:
|
|
298
|
+
parts.append(word_part.strip())
|
|
299
|
+
word_part = word
|
|
300
|
+
|
|
301
|
+
if word_part:
|
|
302
|
+
current_part = word_part
|
|
303
|
+
continue
|
|
304
|
+
|
|
305
|
+
# Check if adding sentence exceeds limit
|
|
306
|
+
test_part = (current_part + " " + sentence).strip() if current_part else sentence
|
|
307
|
+
|
|
308
|
+
if len(test_part) <= max_length:
|
|
309
|
+
current_part = test_part
|
|
310
|
+
else:
|
|
311
|
+
# Current part is full, start new part
|
|
312
|
+
if current_part:
|
|
313
|
+
parts.append(current_part.strip())
|
|
314
|
+
current_part = sentence.strip()
|
|
315
|
+
|
|
316
|
+
# Add final part
|
|
317
|
+
if current_part:
|
|
318
|
+
parts.append(current_part.strip())
|
|
319
|
+
|
|
320
|
+
return parts if parts else [text]
|
|
321
|
+
|
|
322
|
+
def _add_continuation_indicators(self, parts: list[str]) -> list[str]:
|
|
323
|
+
"""Add continuation indicators to multi-part messages.
|
|
324
|
+
|
|
325
|
+
Implements REQ-002: Append continuation indicator (default " ...")
|
|
326
|
+
to all parts except the last one.
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
parts: List of message parts
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
List of parts with continuation indicators added
|
|
333
|
+
"""
|
|
334
|
+
if len(parts) <= 1:
|
|
335
|
+
return parts
|
|
336
|
+
|
|
337
|
+
# Add continuation to all but last part
|
|
338
|
+
result = []
|
|
339
|
+
for i, part in enumerate(parts):
|
|
340
|
+
if i < len(parts) - 1:
|
|
341
|
+
# Ensure continuation fits
|
|
342
|
+
max_content = self.max_length - len(self.continuation)
|
|
343
|
+
if len(part) > max_content:
|
|
344
|
+
part = part[:max_content].rstrip()
|
|
345
|
+
result.append(part + self.continuation)
|
|
346
|
+
else:
|
|
347
|
+
result.append(part)
|
|
348
|
+
|
|
349
|
+
return result
|
|
350
|
+
|
|
351
|
+
def _limit_emoji(self, text: str, max_emoji: int) -> str:
|
|
352
|
+
"""Limit emoji count in text.
|
|
353
|
+
|
|
354
|
+
Implements REQ-005: Optional emoji limiting.
|
|
355
|
+
Counts emoji and truncates if exceeds max.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
text: Text to limit
|
|
359
|
+
max_emoji: Maximum emoji allowed
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
Text with emoji limited
|
|
363
|
+
"""
|
|
364
|
+
# Count emoji in text
|
|
365
|
+
emoji_count = emoji.emoji_count(text)
|
|
366
|
+
|
|
367
|
+
if emoji_count <= max_emoji:
|
|
368
|
+
return text
|
|
369
|
+
|
|
370
|
+
# Remove excess emoji
|
|
371
|
+
# Extract all emoji
|
|
372
|
+
emojis = emoji.emoji_list(text)
|
|
373
|
+
|
|
374
|
+
# Mark emoji to remove (those beyond max_emoji)
|
|
375
|
+
if len(emojis) > max_emoji:
|
|
376
|
+
to_remove = emojis[max_emoji:]
|
|
377
|
+
# Remove from end to start to preserve indices
|
|
378
|
+
for em in reversed(to_remove):
|
|
379
|
+
start = em["match_start"]
|
|
380
|
+
end = em["match_end"]
|
|
381
|
+
text = text[:start] + text[end:]
|
|
382
|
+
|
|
383
|
+
return text
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Service health monitoring for Phase 5.
|
|
2
|
+
|
|
3
|
+
Tracks health of individual components and determines overall service health.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
import socket
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import datetime, timedelta
|
|
10
|
+
from enum import Enum
|
|
11
|
+
|
|
12
|
+
from kryten_llm.models.config import ServiceMetadata
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class HealthState(Enum):
|
|
16
|
+
"""Overall service health states."""
|
|
17
|
+
|
|
18
|
+
HEALTHY = "healthy"
|
|
19
|
+
DEGRADED = "degraded"
|
|
20
|
+
FAILING = "failing"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class ComponentHealth:
|
|
25
|
+
"""Health status of a single component."""
|
|
26
|
+
|
|
27
|
+
name: str
|
|
28
|
+
healthy: bool
|
|
29
|
+
message: str
|
|
30
|
+
last_check: datetime
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class ServiceHealth:
|
|
35
|
+
"""Overall service health status."""
|
|
36
|
+
|
|
37
|
+
state: HealthState
|
|
38
|
+
message: str
|
|
39
|
+
components: dict[str, ComponentHealth]
|
|
40
|
+
metrics: dict[str, int | float]
|
|
41
|
+
timestamp: datetime
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ServiceHealthMonitor:
|
|
45
|
+
"""Monitor service health and component status.
|
|
46
|
+
|
|
47
|
+
Tracks health of:
|
|
48
|
+
- NATS connection
|
|
49
|
+
- LLM providers (stateless API calls - ok/failed/unknown)
|
|
50
|
+
- Phase 4 components (formatter, validator, spam detector)
|
|
51
|
+
- Overall system health
|
|
52
|
+
|
|
53
|
+
Phase 5 Implementation (REQ-003, REQ-010).
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, config: ServiceMetadata, logger: logging.Logger):
|
|
57
|
+
"""Initialize health monitor.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
config: Service metadata configuration
|
|
61
|
+
logger: Logger instance
|
|
62
|
+
"""
|
|
63
|
+
self.config = config
|
|
64
|
+
self.logger = logger
|
|
65
|
+
|
|
66
|
+
# Component health tracking
|
|
67
|
+
self._component_health: dict[str, ComponentHealth] = {}
|
|
68
|
+
|
|
69
|
+
# LLM provider status tracking (stateless API calls)
|
|
70
|
+
# Status: "ok" = last call succeeded, "failed" = last call failed, "unknown" = no calls yet
|
|
71
|
+
self._provider_status: dict[str, str] = {} # provider_name -> status
|
|
72
|
+
|
|
73
|
+
# Metrics tracking
|
|
74
|
+
self._messages_processed = 0
|
|
75
|
+
self._responses_sent = 0
|
|
76
|
+
self._errors_count = 0
|
|
77
|
+
self._errors_window: list[datetime] = [] # Last 5 minutes
|
|
78
|
+
|
|
79
|
+
# Health state
|
|
80
|
+
self._current_state = HealthState.HEALTHY
|
|
81
|
+
self._state_changed_at = datetime.now()
|
|
82
|
+
|
|
83
|
+
def record_message_processed(self) -> None:
|
|
84
|
+
"""Record a message was processed."""
|
|
85
|
+
self._messages_processed += 1
|
|
86
|
+
|
|
87
|
+
def record_response_sent(self) -> None:
|
|
88
|
+
"""Record a response was sent."""
|
|
89
|
+
self._responses_sent += 1
|
|
90
|
+
|
|
91
|
+
def record_error(self) -> None:
|
|
92
|
+
"""Record an error occurred."""
|
|
93
|
+
self._errors_count += 1
|
|
94
|
+
self._errors_window.append(datetime.now())
|
|
95
|
+
|
|
96
|
+
# Clean old errors (>5 minutes)
|
|
97
|
+
cutoff = datetime.now() - timedelta(minutes=5)
|
|
98
|
+
self._errors_window = [ts for ts in self._errors_window if ts > cutoff]
|
|
99
|
+
|
|
100
|
+
def record_provider_success(self, provider_name: str) -> None:
|
|
101
|
+
"""Record successful API call to LLM provider.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
provider_name: Name of the provider (e.g., "openai", "anthropic")
|
|
105
|
+
"""
|
|
106
|
+
self._provider_status[provider_name] = "ok"
|
|
107
|
+
self.logger.debug(f"Provider {provider_name} status: ok")
|
|
108
|
+
|
|
109
|
+
def record_provider_failure(self, provider_name: str) -> None:
|
|
110
|
+
"""Record failed API call to LLM provider.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
provider_name: Name of the provider
|
|
114
|
+
"""
|
|
115
|
+
self._provider_status[provider_name] = "failed"
|
|
116
|
+
self.logger.warning(f"Provider {provider_name} status: failed")
|
|
117
|
+
|
|
118
|
+
def get_provider_status(self, provider_name: str) -> str:
|
|
119
|
+
"""Get current status of LLM provider.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
provider_name: Name of the provider
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
"ok", "failed", or "unknown"
|
|
126
|
+
"""
|
|
127
|
+
return self._provider_status.get(provider_name, "unknown")
|
|
128
|
+
|
|
129
|
+
def update_component_health(self, component: str, healthy: bool, message: str = "") -> None:
|
|
130
|
+
"""Update health status of a component.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
component: Component name (e.g., "nats", "rate_limiter")
|
|
134
|
+
healthy: Whether component is healthy
|
|
135
|
+
message: Health status message
|
|
136
|
+
"""
|
|
137
|
+
self._component_health[component] = ComponentHealth(
|
|
138
|
+
name=component, healthy=healthy, message=message, last_check=datetime.now()
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
self.logger.debug(
|
|
142
|
+
f"Component health updated: {component} = "
|
|
143
|
+
f"{'healthy' if healthy else 'unhealthy'}: {message}"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def determine_health_status(self) -> ServiceHealth:
|
|
147
|
+
"""Determine overall service health status.
|
|
148
|
+
|
|
149
|
+
Implements REQ-003 health state determination.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
ServiceHealth with current state and details
|
|
153
|
+
"""
|
|
154
|
+
# Check critical components
|
|
155
|
+
nats_health = self._component_health.get("nats")
|
|
156
|
+
|
|
157
|
+
# Check LLM provider status (stateless API calls)
|
|
158
|
+
_providers_ok = [name for name, status in self._provider_status.items() if status == "ok"]
|
|
159
|
+
providers_failed = [
|
|
160
|
+
name for name, status in self._provider_status.items() if status == "failed"
|
|
161
|
+
]
|
|
162
|
+
_providers_unknown = [
|
|
163
|
+
name for name, status in self._provider_status.items() if status == "unknown"
|
|
164
|
+
]
|
|
165
|
+
all_providers_failed = len(self._provider_status) > 0 and len(providers_failed) == len(
|
|
166
|
+
self._provider_status
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# Determine health state
|
|
170
|
+
if not nats_health or not nats_health.healthy:
|
|
171
|
+
state = HealthState.FAILING
|
|
172
|
+
message = "NATS connection lost"
|
|
173
|
+
elif all_providers_failed:
|
|
174
|
+
state = HealthState.FAILING
|
|
175
|
+
message = "All LLM providers failing"
|
|
176
|
+
elif self._get_error_rate() > 0.10: # >10% error rate
|
|
177
|
+
state = HealthState.DEGRADED
|
|
178
|
+
message = f"High error rate: {self._get_error_rate():.1%}"
|
|
179
|
+
elif any(
|
|
180
|
+
not comp.healthy
|
|
181
|
+
for comp in self._component_health.values()
|
|
182
|
+
if comp.name not in ["nats"]
|
|
183
|
+
):
|
|
184
|
+
state = HealthState.DEGRADED
|
|
185
|
+
message = "Some components degraded"
|
|
186
|
+
else:
|
|
187
|
+
state = HealthState.HEALTHY
|
|
188
|
+
message = "All systems operational"
|
|
189
|
+
|
|
190
|
+
# Track state changes
|
|
191
|
+
if state != self._current_state:
|
|
192
|
+
self.logger.warning(
|
|
193
|
+
f"Health state changed: {self._current_state.value} -> {state.value}"
|
|
194
|
+
)
|
|
195
|
+
self._current_state = state
|
|
196
|
+
self._state_changed_at = datetime.now()
|
|
197
|
+
|
|
198
|
+
return ServiceHealth(
|
|
199
|
+
state=state,
|
|
200
|
+
message=message,
|
|
201
|
+
components=self._component_health.copy(),
|
|
202
|
+
metrics={
|
|
203
|
+
"messages_processed": self._messages_processed,
|
|
204
|
+
"responses_sent": self._responses_sent,
|
|
205
|
+
"total_errors": self._errors_count,
|
|
206
|
+
"errors_last_5min": len(self._errors_window),
|
|
207
|
+
"error_rate": self._get_error_rate(),
|
|
208
|
+
},
|
|
209
|
+
timestamp=datetime.now(),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def _get_error_rate(self) -> float:
|
|
213
|
+
"""Calculate error rate over last 5 minutes."""
|
|
214
|
+
recent_errors = len(self._errors_window)
|
|
215
|
+
total_processed = self._messages_processed
|
|
216
|
+
|
|
217
|
+
if total_processed == 0:
|
|
218
|
+
return 0.0
|
|
219
|
+
|
|
220
|
+
return recent_errors / total_processed
|
|
221
|
+
|
|
222
|
+
def get_heartbeat_payload(self, uptime_seconds: float) -> dict:
|
|
223
|
+
"""Build heartbeat payload with current health.
|
|
224
|
+
|
|
225
|
+
Implements REQ-002 heartbeat payload.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
uptime_seconds: Service uptime in seconds
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
Dictionary ready for JSON serialization
|
|
232
|
+
"""
|
|
233
|
+
health = self.determine_health_status()
|
|
234
|
+
|
|
235
|
+
# Build per-provider status dict
|
|
236
|
+
llm_providers_status = {}
|
|
237
|
+
for provider_name, status in self._provider_status.items():
|
|
238
|
+
llm_providers_status[provider_name] = status
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
"service": self.config.service_name,
|
|
242
|
+
"version": self.config.service_version,
|
|
243
|
+
"hostname": self._get_hostname(),
|
|
244
|
+
"timestamp": datetime.now().isoformat(),
|
|
245
|
+
"uptime_seconds": uptime_seconds,
|
|
246
|
+
"health": health.state.value,
|
|
247
|
+
"status": {
|
|
248
|
+
"nats_connected": self._component_health.get(
|
|
249
|
+
"nats", ComponentHealth("nats", False, "", datetime.now())
|
|
250
|
+
).healthy,
|
|
251
|
+
"llm_providers": llm_providers_status,
|
|
252
|
+
"rate_limiter_active": self._component_health.get(
|
|
253
|
+
"rate_limiter", ComponentHealth("rate_limiter", True, "", datetime.now())
|
|
254
|
+
).healthy,
|
|
255
|
+
"spam_detector_active": self._component_health.get(
|
|
256
|
+
"spam_detector", ComponentHealth("spam_detector", True, "", datetime.now())
|
|
257
|
+
).healthy,
|
|
258
|
+
"messages_processed": health.metrics["messages_processed"],
|
|
259
|
+
"responses_sent": health.metrics["responses_sent"],
|
|
260
|
+
"errors_last_hour": health.metrics["errors_last_5min"],
|
|
261
|
+
},
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
def _get_hostname(self) -> str:
|
|
265
|
+
"""Get system hostname."""
|
|
266
|
+
return socket.gethostname()
|