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