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.
- cite_agent/__init__.py +13 -13
- cite_agent/__version__.py +1 -1
- cite_agent/action_first_mode.py +150 -0
- cite_agent/adaptive_providers.py +413 -0
- cite_agent/archive_api_client.py +186 -0
- cite_agent/auth.py +0 -1
- cite_agent/auto_expander.py +70 -0
- cite_agent/cache.py +379 -0
- cite_agent/circuit_breaker.py +370 -0
- cite_agent/citation_network.py +377 -0
- cite_agent/cli.py +8 -16
- cite_agent/cli_conversational.py +113 -3
- cite_agent/confidence_calibration.py +381 -0
- cite_agent/deduplication.py +325 -0
- cite_agent/enhanced_ai_agent.py +689 -371
- cite_agent/error_handler.py +228 -0
- cite_agent/execution_safety.py +329 -0
- cite_agent/full_paper_reader.py +239 -0
- cite_agent/observability.py +398 -0
- cite_agent/offline_mode.py +348 -0
- cite_agent/paper_comparator.py +368 -0
- cite_agent/paper_summarizer.py +420 -0
- cite_agent/pdf_extractor.py +350 -0
- cite_agent/proactive_boundaries.py +266 -0
- cite_agent/quality_gate.py +442 -0
- cite_agent/request_queue.py +390 -0
- cite_agent/response_enhancer.py +257 -0
- cite_agent/response_formatter.py +458 -0
- cite_agent/response_pipeline.py +295 -0
- cite_agent/response_style_enhancer.py +259 -0
- cite_agent/self_healing.py +418 -0
- cite_agent/similarity_finder.py +524 -0
- cite_agent/streaming_ui.py +13 -9
- cite_agent/thinking_blocks.py +308 -0
- cite_agent/tool_orchestrator.py +416 -0
- cite_agent/trend_analyzer.py +540 -0
- cite_agent/unpaywall_client.py +226 -0
- {cite_agent-1.3.9.dist-info → cite_agent-1.4.3.dist-info}/METADATA +15 -1
- cite_agent-1.4.3.dist-info/RECORD +62 -0
- cite_agent-1.3.9.dist-info/RECORD +0 -32
- {cite_agent-1.3.9.dist-info → cite_agent-1.4.3.dist-info}/WHEEL +0 -0
- {cite_agent-1.3.9.dist-info → cite_agent-1.4.3.dist-info}/entry_points.txt +0 -0
- {cite_agent-1.3.9.dist-info → cite_agent-1.4.3.dist-info}/licenses/LICENSE +0 -0
- {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())
|