cite-agent 1.3.9__py3-none-any.whl → 1.4.3__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.
Files changed (44) hide show
  1. cite_agent/__init__.py +13 -13
  2. cite_agent/__version__.py +1 -1
  3. cite_agent/action_first_mode.py +150 -0
  4. cite_agent/adaptive_providers.py +413 -0
  5. cite_agent/archive_api_client.py +186 -0
  6. cite_agent/auth.py +0 -1
  7. cite_agent/auto_expander.py +70 -0
  8. cite_agent/cache.py +379 -0
  9. cite_agent/circuit_breaker.py +370 -0
  10. cite_agent/citation_network.py +377 -0
  11. cite_agent/cli.py +8 -16
  12. cite_agent/cli_conversational.py +113 -3
  13. cite_agent/confidence_calibration.py +381 -0
  14. cite_agent/deduplication.py +325 -0
  15. cite_agent/enhanced_ai_agent.py +689 -371
  16. cite_agent/error_handler.py +228 -0
  17. cite_agent/execution_safety.py +329 -0
  18. cite_agent/full_paper_reader.py +239 -0
  19. cite_agent/observability.py +398 -0
  20. cite_agent/offline_mode.py +348 -0
  21. cite_agent/paper_comparator.py +368 -0
  22. cite_agent/paper_summarizer.py +420 -0
  23. cite_agent/pdf_extractor.py +350 -0
  24. cite_agent/proactive_boundaries.py +266 -0
  25. cite_agent/quality_gate.py +442 -0
  26. cite_agent/request_queue.py +390 -0
  27. cite_agent/response_enhancer.py +257 -0
  28. cite_agent/response_formatter.py +458 -0
  29. cite_agent/response_pipeline.py +295 -0
  30. cite_agent/response_style_enhancer.py +259 -0
  31. cite_agent/self_healing.py +418 -0
  32. cite_agent/similarity_finder.py +524 -0
  33. cite_agent/streaming_ui.py +13 -9
  34. cite_agent/thinking_blocks.py +308 -0
  35. cite_agent/tool_orchestrator.py +416 -0
  36. cite_agent/trend_analyzer.py +540 -0
  37. cite_agent/unpaywall_client.py +226 -0
  38. {cite_agent-1.3.9.dist-info → cite_agent-1.4.3.dist-info}/METADATA +15 -1
  39. cite_agent-1.4.3.dist-info/RECORD +62 -0
  40. cite_agent-1.3.9.dist-info/RECORD +0 -32
  41. {cite_agent-1.3.9.dist-info → cite_agent-1.4.3.dist-info}/WHEEL +0 -0
  42. {cite_agent-1.3.9.dist-info → cite_agent-1.4.3.dist-info}/entry_points.txt +0 -0
  43. {cite_agent-1.3.9.dist-info → cite_agent-1.4.3.dist-info}/licenses/LICENSE +0 -0
  44. {cite_agent-1.3.9.dist-info → cite_agent-1.4.3.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,370 @@
1
+ """
2
+ Circuit Breaker Pattern Implementation
3
+ Detects failures, fails fast, auto-recovers gracefully
4
+ """
5
+
6
+ import time
7
+ from enum import Enum
8
+ from dataclasses import dataclass, field
9
+ from datetime import datetime, timedelta
10
+ from typing import Optional, Callable, Any, Dict
11
+ import logging
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class CircuitState(Enum):
17
+ """States of the circuit breaker"""
18
+ CLOSED = "closed" # Normal: requests pass through
19
+ OPEN = "open" # Failing: requests fail immediately (fast-fail)
20
+ HALF_OPEN = "half_open" # Testing: one request allowed to test recovery
21
+
22
+
23
+ @dataclass
24
+ class CircuitBreakerConfig:
25
+ """Configuration for circuit breaker behavior"""
26
+ failure_threshold: float = 0.5 # % of requests that must fail to open
27
+ min_requests_for_decision: int = 10 # min requests before making decision
28
+ open_timeout: float = 30.0 # seconds before attempting recovery
29
+ half_open_max_calls: int = 3 # max calls in half-open state before decision
30
+ excluded_exceptions: tuple = () # exceptions that don't trigger circuit break
31
+
32
+
33
+ class CircuitBreakerMetrics:
34
+ """Tracks circuit breaker health"""
35
+ def __init__(self):
36
+ self.total_calls = 0
37
+ self.total_failures = 0
38
+ self.total_successes = 0
39
+ self.consecutive_failures = 0
40
+ self.state_changes: list = [] # [(state, timestamp), ...]
41
+ self.response_times: list = [] # Last N response times
42
+ self.last_failure_message: Optional[str] = None
43
+
44
+ def get_failure_rate(self) -> float:
45
+ """Get recent failure rate (0.0 to 1.0)"""
46
+ if self.total_calls == 0:
47
+ return 0.0
48
+ return self.total_failures / self.total_calls
49
+
50
+ def get_avg_response_time(self) -> float:
51
+ """Get average response time in seconds"""
52
+ if not self.response_times:
53
+ return 0.0
54
+ return sum(self.response_times[-50:]) / len(self.response_times[-50:])
55
+
56
+ def reset(self):
57
+ """Reset metrics for new cycle"""
58
+ self.total_calls = 0
59
+ self.total_failures = 0
60
+ self.total_successes = 0
61
+ self.consecutive_failures = 0
62
+ self.response_times = []
63
+
64
+
65
+ class CircuitBreaker:
66
+ """
67
+ Circuit breaker for preventing cascading failures
68
+
69
+ States:
70
+ 1. CLOSED: Normal operation, requests pass through
71
+ 2. OPEN: Too many failures detected, requests fail immediately (fast-fail)
72
+ 3. HALF_OPEN: Testing recovery, allowing limited requests
73
+
74
+ Usage:
75
+ breaker = CircuitBreaker("backend_api", config)
76
+
77
+ try:
78
+ result = await breaker.call(api_client.query, user_id="user123")
79
+ except CircuitBreakerOpen:
80
+ print("Backend is unavailable, use offline mode")
81
+ """
82
+
83
+ def __init__(
84
+ self,
85
+ name: str,
86
+ config: Optional[CircuitBreakerConfig] = None,
87
+ on_state_change: Optional[Callable] = None
88
+ ):
89
+ self.name = name
90
+ self.config = config or CircuitBreakerConfig()
91
+ self.state = CircuitState.CLOSED
92
+ self.metrics = CircuitBreakerMetrics()
93
+ self.last_state_change = datetime.now()
94
+ self.on_state_change = on_state_change
95
+ self.half_open_calls = 0
96
+
97
+ async def call(
98
+ self,
99
+ func: Callable,
100
+ *args,
101
+ fallback: Optional[Callable] = None,
102
+ **kwargs
103
+ ) -> Any:
104
+ """
105
+ Execute a function with circuit breaker protection
106
+
107
+ Args:
108
+ func: Async function to call
109
+ fallback: Optional fallback function if circuit is open
110
+ *args, **kwargs: Arguments to pass to func
111
+
112
+ Returns:
113
+ Result from func or fallback
114
+
115
+ Raises:
116
+ CircuitBreakerOpen: If circuit is open and no fallback
117
+ """
118
+ # Check if we should transition states
119
+ self._check_state_transition()
120
+
121
+ if self.state == CircuitState.OPEN:
122
+ if fallback:
123
+ logger.warning(f"🔴 {self.name}: Circuit OPEN, using fallback")
124
+ return await fallback(*args, **kwargs)
125
+ else:
126
+ logger.error(f"🔴 {self.name}: Circuit OPEN, no fallback available")
127
+ raise CircuitBreakerOpen(f"{self.name} is temporarily unavailable")
128
+
129
+ if self.state == CircuitState.HALF_OPEN:
130
+ if self.half_open_calls >= self.config.half_open_max_calls:
131
+ self._change_state(CircuitState.OPEN)
132
+ raise CircuitBreakerOpen(f"{self.name} failed recovery test")
133
+ self.half_open_calls += 1
134
+
135
+ # Call the function with timing
136
+ start_time = time.time()
137
+ try:
138
+ result = await func(*args, **kwargs)
139
+ self._on_success()
140
+ response_time = time.time() - start_time
141
+ self.metrics.response_times.append(response_time)
142
+ return result
143
+
144
+ except Exception as e:
145
+ response_time = time.time() - start_time
146
+ self._on_failure(str(e), response_time)
147
+ raise
148
+
149
+ def call_sync(
150
+ self,
151
+ func: Callable,
152
+ *args,
153
+ fallback: Optional[Callable] = None,
154
+ **kwargs
155
+ ) -> Any:
156
+ """Synchronous version of call()"""
157
+ self._check_state_transition()
158
+
159
+ if self.state == CircuitState.OPEN:
160
+ if fallback:
161
+ logger.warning(f"🔴 {self.name}: Circuit OPEN, using fallback")
162
+ return fallback(*args, **kwargs)
163
+ else:
164
+ raise CircuitBreakerOpen(f"{self.name} is temporarily unavailable")
165
+
166
+ if self.state == CircuitState.HALF_OPEN:
167
+ if self.half_open_calls >= self.config.half_open_max_calls:
168
+ self._change_state(CircuitState.OPEN)
169
+ raise CircuitBreakerOpen(f"{self.name} failed recovery test")
170
+ self.half_open_calls += 1
171
+
172
+ start_time = time.time()
173
+ try:
174
+ result = func(*args, **kwargs)
175
+ self._on_success()
176
+ response_time = time.time() - start_time
177
+ self.metrics.response_times.append(response_time)
178
+ return result
179
+
180
+ except Exception as e:
181
+ response_time = time.time() - start_time
182
+ self._on_failure(str(e), response_time)
183
+ raise
184
+
185
+ def _on_success(self):
186
+ """Record successful call"""
187
+ self.metrics.total_calls += 1
188
+ self.metrics.total_successes += 1
189
+ self.metrics.consecutive_failures = 0
190
+
191
+ # If in HALF_OPEN and getting successes, transition to CLOSED
192
+ if self.state == CircuitState.HALF_OPEN:
193
+ self._change_state(CircuitState.CLOSED)
194
+ self.half_open_calls = 0
195
+
196
+ def _on_failure(self, error_message: str, response_time: float):
197
+ """Record failed call"""
198
+ self.metrics.total_calls += 1
199
+ self.metrics.total_failures += 1
200
+ self.metrics.consecutive_failures += 1
201
+ self.metrics.last_failure_message = error_message
202
+ self.metrics.response_times.append(response_time)
203
+
204
+ # Check if we should open the circuit
205
+ if self.state == CircuitState.CLOSED:
206
+ if self._should_open_circuit():
207
+ self._change_state(CircuitState.OPEN)
208
+ logger.error(f"🔴 {self.name}: Circuit OPEN (failure rate: {self.metrics.get_failure_rate():.1%})")
209
+
210
+ elif self.state == CircuitState.HALF_OPEN:
211
+ # Any failure in HALF_OPEN goes back to OPEN
212
+ self._change_state(CircuitState.OPEN)
213
+ logger.warning(f"🔴 {self.name}: Recovery failed, circuit OPEN again")
214
+
215
+ def _should_open_circuit(self) -> bool:
216
+ """Determine if circuit should open"""
217
+ # Need minimum requests before deciding
218
+ if self.metrics.total_calls < self.config.min_requests_for_decision:
219
+ return False
220
+
221
+ # Check failure rate
222
+ failure_rate = self.metrics.get_failure_rate()
223
+ return failure_rate >= self.config.failure_threshold
224
+
225
+ def _check_state_transition(self):
226
+ """Check if we should transition from OPEN to HALF_OPEN"""
227
+ if self.state == CircuitState.OPEN:
228
+ elapsed = (datetime.now() - self.last_state_change).total_seconds()
229
+ if elapsed >= self.config.open_timeout:
230
+ self._change_state(CircuitState.HALF_OPEN)
231
+ logger.info(f"🟡 {self.name}: Circuit HALF_OPEN, testing recovery...")
232
+
233
+ def _change_state(self, new_state: CircuitState):
234
+ """Change circuit state"""
235
+ if new_state != self.state:
236
+ old_state = self.state
237
+ self.state = new_state
238
+ self.last_state_change = datetime.now()
239
+ self.metrics.state_changes.append((new_state, self.last_state_change))
240
+
241
+ # Notify callback
242
+ if self.on_state_change:
243
+ self.on_state_change(old_state, new_state, self.metrics)
244
+
245
+ # Reset metrics for new cycle
246
+ if new_state == CircuitState.CLOSED:
247
+ self.metrics.reset()
248
+ elif new_state == CircuitState.HALF_OPEN:
249
+ self.half_open_calls = 0
250
+
251
+ def reset(self):
252
+ """Manually reset circuit to CLOSED"""
253
+ self._change_state(CircuitState.CLOSED)
254
+ logger.info(f"🟢 {self.name}: Circuit RESET to CLOSED")
255
+
256
+ def get_status(self) -> Dict[str, Any]:
257
+ """Get circuit breaker status"""
258
+ return {
259
+ "name": self.name,
260
+ "state": self.state.value,
261
+ "total_calls": self.metrics.total_calls,
262
+ "total_failures": self.metrics.total_failures,
263
+ "failure_rate": self.metrics.get_failure_rate(),
264
+ "consecutive_failures": self.metrics.consecutive_failures,
265
+ "avg_response_time": self.metrics.get_avg_response_time(),
266
+ "last_failure": self.metrics.last_failure_message,
267
+ "last_state_change": self.last_state_change.isoformat(),
268
+ }
269
+
270
+ def get_status_message(self) -> str:
271
+ """Human-readable status"""
272
+ status = self.get_status()
273
+ state_emoji = {
274
+ "closed": "🟢",
275
+ "open": "🔴",
276
+ "half_open": "🟡"
277
+ }
278
+
279
+ return f"""{state_emoji.get(status['state'], '⚪')} {self.name}: {status['state'].upper()}
280
+ • Calls: {status['total_calls']} | Failures: {status['total_failures']} | Rate: {status['failure_rate']:.1%}
281
+ • Avg latency: {status['avg_response_time']:.2f}s
282
+ • Last issue: {status['last_failure'] or 'None'}"""
283
+
284
+
285
+ class CircuitBreakerOpen(Exception):
286
+ """Raised when circuit breaker is open and request cannot proceed"""
287
+ pass
288
+
289
+
290
+ class CircuitBreakerManager:
291
+ """
292
+ Manages multiple circuit breakers for different services
293
+ """
294
+
295
+ def __init__(self):
296
+ self.breakers: Dict[str, CircuitBreaker] = {}
297
+
298
+ def create(
299
+ self,
300
+ name: str,
301
+ config: Optional[CircuitBreakerConfig] = None
302
+ ) -> CircuitBreaker:
303
+ """Create a new circuit breaker"""
304
+ if name in self.breakers:
305
+ logger.warning(f"Circuit breaker '{name}' already exists")
306
+ return self.breakers[name]
307
+
308
+ breaker = CircuitBreaker(name, config)
309
+ self.breakers[name] = breaker
310
+ return breaker
311
+
312
+ def get(self, name: str) -> Optional[CircuitBreaker]:
313
+ """Get an existing circuit breaker"""
314
+ return self.breakers.get(name)
315
+
316
+ def get_all_status(self) -> Dict[str, Dict]:
317
+ """Get status of all circuit breakers"""
318
+ return {
319
+ name: breaker.get_status()
320
+ for name, breaker in self.breakers.items()
321
+ }
322
+
323
+ def print_status(self):
324
+ """Print status of all circuit breakers"""
325
+ for name, breaker in self.breakers.items():
326
+ print(breaker.get_status_message())
327
+
328
+
329
+ # Global instance
330
+ circuit_breakers = CircuitBreakerManager()
331
+
332
+
333
+ if __name__ == "__main__":
334
+ import asyncio
335
+
336
+ async def test_circuit_breaker():
337
+ """Test the circuit breaker"""
338
+
339
+ # Create a circuit breaker
340
+ config = CircuitBreakerConfig(
341
+ failure_threshold=0.5,
342
+ min_requests_for_decision=3,
343
+ open_timeout=5.0
344
+ )
345
+ breaker = CircuitBreaker("test_api", config)
346
+
347
+ # Simulate failures
348
+ async def failing_request():
349
+ raise Exception("Backend error")
350
+
351
+ async def working_request():
352
+ return "Success"
353
+
354
+ # Make requests
355
+ print("Making requests...\n")
356
+
357
+ for i in range(10):
358
+ try:
359
+ if i < 3:
360
+ await breaker.call(failing_request)
361
+ else:
362
+ await breaker.call(working_request)
363
+ except Exception as e:
364
+ print(f"Request {i}: {type(e).__name__}: {e}")
365
+
366
+ print(breaker.get_status_message())
367
+ print()
368
+ await asyncio.sleep(1)
369
+
370
+ asyncio.run(test_circuit_breaker())