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,348 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Offline Mode - Graceful Degradation When Backend Unavailable
|
|
4
|
+
|
|
5
|
+
Allows cite-agent to continue functioning with reduced features when:
|
|
6
|
+
- Backend is unreachable
|
|
7
|
+
- Network is down
|
|
8
|
+
- User wants to work offline
|
|
9
|
+
|
|
10
|
+
Features available offline:
|
|
11
|
+
- Browse local library
|
|
12
|
+
- Search local papers
|
|
13
|
+
- Export citations
|
|
14
|
+
- View history
|
|
15
|
+
- Read saved PDFs
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
import asyncio
|
|
20
|
+
from typing import Optional, Dict, Any, List
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
import json
|
|
23
|
+
from datetime import datetime
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class OfflineMode:
|
|
29
|
+
"""
|
|
30
|
+
Manages offline functionality
|
|
31
|
+
|
|
32
|
+
Provides graceful degradation when backend is unavailable
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, data_dir: str = "~/.cite_agent"):
|
|
36
|
+
self.data_dir = Path(data_dir).expanduser()
|
|
37
|
+
self.data_dir.mkdir(exist_ok=True)
|
|
38
|
+
|
|
39
|
+
# Local data directories
|
|
40
|
+
self.cache_dir = self.data_dir / "cache"
|
|
41
|
+
self.library_dir = self.data_dir / "papers"
|
|
42
|
+
self.history_dir = self.data_dir / "sessions"
|
|
43
|
+
|
|
44
|
+
# Create directories
|
|
45
|
+
self.cache_dir.mkdir(exist_ok=True)
|
|
46
|
+
self.library_dir.mkdir(exist_ok=True)
|
|
47
|
+
self.history_dir.mkdir(exist_ok=True)
|
|
48
|
+
|
|
49
|
+
self.is_offline = False
|
|
50
|
+
|
|
51
|
+
def enable_offline_mode(self):
|
|
52
|
+
"""Enable offline mode"""
|
|
53
|
+
self.is_offline = True
|
|
54
|
+
logger.info("📴 Offline mode enabled")
|
|
55
|
+
|
|
56
|
+
def disable_offline_mode(self):
|
|
57
|
+
"""Disable offline mode"""
|
|
58
|
+
self.is_offline = False
|
|
59
|
+
logger.info("📡 Online mode enabled")
|
|
60
|
+
|
|
61
|
+
def search_local_library(self, query: str, user_id: str = "default") -> List[Dict[str, Any]]:
|
|
62
|
+
"""
|
|
63
|
+
Search papers in local library
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
query: Search query (title, author, keywords)
|
|
67
|
+
user_id: User identifier
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
List of matching papers
|
|
71
|
+
"""
|
|
72
|
+
results = []
|
|
73
|
+
query_lower = query.lower()
|
|
74
|
+
|
|
75
|
+
# Search through all saved papers
|
|
76
|
+
pattern = f"{user_id}_*.json" if user_id != "default" else "*.json"
|
|
77
|
+
|
|
78
|
+
for paper_file in self.library_dir.glob(pattern):
|
|
79
|
+
try:
|
|
80
|
+
with open(paper_file, 'r') as f:
|
|
81
|
+
paper_data = json.load(f)
|
|
82
|
+
|
|
83
|
+
paper = paper_data.get("paper", {})
|
|
84
|
+
|
|
85
|
+
# Simple text matching
|
|
86
|
+
title = paper.get("title", "").lower()
|
|
87
|
+
authors = " ".join([a.get("name", "") for a in paper.get("authors", [])]).lower()
|
|
88
|
+
abstract = paper.get("abstract", "").lower()
|
|
89
|
+
|
|
90
|
+
if (query_lower in title or
|
|
91
|
+
query_lower in authors or
|
|
92
|
+
query_lower in abstract):
|
|
93
|
+
results.append(paper)
|
|
94
|
+
|
|
95
|
+
except Exception as e:
|
|
96
|
+
logger.warning(f"Error reading paper file {paper_file}: {e}")
|
|
97
|
+
|
|
98
|
+
logger.info(f"🔍 Found {len(results)} papers in local library")
|
|
99
|
+
return results
|
|
100
|
+
|
|
101
|
+
def get_cached_search(self, query: str) -> Optional[Dict[str, Any]]:
|
|
102
|
+
"""
|
|
103
|
+
Get cached search results
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
query: Search query
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
Cached results if available, None otherwise
|
|
110
|
+
"""
|
|
111
|
+
# Create cache key from query
|
|
112
|
+
import hashlib
|
|
113
|
+
cache_key = hashlib.sha256(query.encode()).hexdigest()[:16]
|
|
114
|
+
cache_file = self.cache_dir / f"search_{cache_key}.json"
|
|
115
|
+
|
|
116
|
+
if cache_file.exists():
|
|
117
|
+
try:
|
|
118
|
+
with open(cache_file, 'r') as f:
|
|
119
|
+
cached = json.load(f)
|
|
120
|
+
|
|
121
|
+
# Check if cache is stale (older than 24 hours)
|
|
122
|
+
cached_time = datetime.fromisoformat(cached.get("timestamp", "2000-01-01"))
|
|
123
|
+
age_hours = (datetime.now() - cached_time).total_seconds() / 3600
|
|
124
|
+
|
|
125
|
+
if age_hours < 24:
|
|
126
|
+
logger.info(f"💾 Using cached results (age: {age_hours:.1f}h)")
|
|
127
|
+
return cached.get("results")
|
|
128
|
+
else:
|
|
129
|
+
logger.info(f"⏰ Cache expired (age: {age_hours:.1f}h)")
|
|
130
|
+
|
|
131
|
+
except Exception as e:
|
|
132
|
+
logger.warning(f"Error reading cache: {e}")
|
|
133
|
+
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
def cache_search_results(self, query: str, results: Dict[str, Any]):
|
|
137
|
+
"""
|
|
138
|
+
Cache search results for offline use
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
query: Search query
|
|
142
|
+
results: API results to cache
|
|
143
|
+
"""
|
|
144
|
+
import hashlib
|
|
145
|
+
cache_key = hashlib.sha256(query.encode()).hexdigest()[:16]
|
|
146
|
+
cache_file = self.cache_dir / f"search_{cache_key}.json"
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
cache_data = {
|
|
150
|
+
"query": query,
|
|
151
|
+
"results": results,
|
|
152
|
+
"timestamp": datetime.now().isoformat()
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
with open(cache_file, 'w') as f:
|
|
156
|
+
json.dump(cache_data, f, indent=2)
|
|
157
|
+
|
|
158
|
+
logger.debug(f"💾 Cached results for: {query}")
|
|
159
|
+
|
|
160
|
+
except Exception as e:
|
|
161
|
+
logger.warning(f"Failed to cache results: {e}")
|
|
162
|
+
|
|
163
|
+
def get_library_stats(self, user_id: str = "default") -> Dict[str, Any]:
|
|
164
|
+
"""
|
|
165
|
+
Get statistics about local library
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
user_id: User identifier
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
Library statistics
|
|
172
|
+
"""
|
|
173
|
+
pattern = f"{user_id}_*.json" if user_id != "default" else "*.json"
|
|
174
|
+
paper_files = list(self.library_dir.glob(pattern))
|
|
175
|
+
|
|
176
|
+
total_papers = len(paper_files)
|
|
177
|
+
total_size = sum(f.stat().st_size for f in paper_files)
|
|
178
|
+
|
|
179
|
+
# Count by year
|
|
180
|
+
by_year = {}
|
|
181
|
+
for paper_file in paper_files:
|
|
182
|
+
try:
|
|
183
|
+
with open(paper_file, 'r') as f:
|
|
184
|
+
paper_data = json.load(f)
|
|
185
|
+
year = paper_data.get("paper", {}).get("year", "unknown")
|
|
186
|
+
by_year[year] = by_year.get(year, 0) + 1
|
|
187
|
+
except:
|
|
188
|
+
pass
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
"total_papers": total_papers,
|
|
192
|
+
"total_size_mb": total_size / (1024 * 1024),
|
|
193
|
+
"papers_by_year": by_year,
|
|
194
|
+
"library_path": str(self.library_dir)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
def get_recent_queries(self, user_id: str = "default", limit: int = 10) -> List[Dict[str, Any]]:
|
|
198
|
+
"""
|
|
199
|
+
Get recent queries from history
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
user_id: User identifier
|
|
203
|
+
limit: Maximum number of queries to return
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
List of recent queries
|
|
207
|
+
"""
|
|
208
|
+
pattern = f"{user_id}_*.json" if user_id != "default" else "*.json"
|
|
209
|
+
session_files = list(self.history_dir.glob(pattern))
|
|
210
|
+
|
|
211
|
+
# Sort by modification time
|
|
212
|
+
session_files.sort(key=lambda f: f.stat().st_mtime, reverse=True)
|
|
213
|
+
|
|
214
|
+
recent = []
|
|
215
|
+
for session_file in session_files[:limit]:
|
|
216
|
+
try:
|
|
217
|
+
with open(session_file, 'r') as f:
|
|
218
|
+
session_data = json.load(f)
|
|
219
|
+
recent.append({
|
|
220
|
+
"query": session_data.get("query"),
|
|
221
|
+
"timestamp": session_data.get("timestamp"),
|
|
222
|
+
"session_id": session_data.get("session_id")
|
|
223
|
+
})
|
|
224
|
+
except Exception as e:
|
|
225
|
+
logger.warning(f"Error reading session file: {e}")
|
|
226
|
+
|
|
227
|
+
return recent
|
|
228
|
+
|
|
229
|
+
def can_work_offline(self) -> bool:
|
|
230
|
+
"""
|
|
231
|
+
Check if offline work is possible
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
True if local library has data
|
|
235
|
+
"""
|
|
236
|
+
has_papers = len(list(self.library_dir.glob("*.json"))) > 0
|
|
237
|
+
has_cache = len(list(self.cache_dir.glob("*.json"))) > 0
|
|
238
|
+
|
|
239
|
+
return has_papers or has_cache
|
|
240
|
+
|
|
241
|
+
def get_offline_capabilities(self) -> Dict[str, bool]:
|
|
242
|
+
"""
|
|
243
|
+
Get what features are available offline
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
Dictionary of feature availability
|
|
247
|
+
"""
|
|
248
|
+
stats = self.get_library_stats()
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
"search_library": stats["total_papers"] > 0,
|
|
252
|
+
"export_citations": stats["total_papers"] > 0,
|
|
253
|
+
"view_history": len(list(self.history_dir.glob("*.json"))) > 0,
|
|
254
|
+
"cached_searches": len(list(self.cache_dir.glob("search_*.json"))) > 0,
|
|
255
|
+
"api_searches": False, # Never available offline
|
|
256
|
+
"pdf_reading": False, # Requires network for download
|
|
257
|
+
"financial_data": False # Requires network
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
def show_offline_message(self):
|
|
261
|
+
"""Display helpful offline mode message"""
|
|
262
|
+
from rich.console import Console
|
|
263
|
+
from rich.panel import Panel
|
|
264
|
+
from rich.markdown import Markdown
|
|
265
|
+
|
|
266
|
+
console = Console()
|
|
267
|
+
|
|
268
|
+
capabilities = self.get_offline_capabilities()
|
|
269
|
+
stats = self.get_library_stats()
|
|
270
|
+
|
|
271
|
+
message = f"""
|
|
272
|
+
# 📴 Offline Mode
|
|
273
|
+
|
|
274
|
+
You're currently offline, but you can still:
|
|
275
|
+
|
|
276
|
+
{'✅ **Search your library** (' + str(stats['total_papers']) + ' papers)' if capabilities['search_library'] else '❌ Search your library (no papers saved)'}
|
|
277
|
+
{'✅ **Export citations** to BibTeX' if capabilities['export_citations'] else '❌ Export citations (no papers saved)'}
|
|
278
|
+
{'✅ **View search history**' if capabilities['view_history'] else '❌ View search history (no history)'}
|
|
279
|
+
{'✅ **Use cached searches**' if capabilities['cached_searches'] else '❌ Use cached searches (no cache)'}
|
|
280
|
+
|
|
281
|
+
## Not Available Offline:
|
|
282
|
+
- ❌ New paper searches (requires network)
|
|
283
|
+
- ❌ PDF downloads (requires network)
|
|
284
|
+
- ❌ Financial data (requires network)
|
|
285
|
+
|
|
286
|
+
## To Get Back Online:
|
|
287
|
+
1. Check your internet connection
|
|
288
|
+
2. Restart cite-agent
|
|
289
|
+
3. Run `cite-agent --doctor` to diagnose
|
|
290
|
+
|
|
291
|
+
## Offline Commands:
|
|
292
|
+
```bash
|
|
293
|
+
cite-agent --library # Browse saved papers
|
|
294
|
+
cite-agent --export-bibtex # Export citations
|
|
295
|
+
cite-agent --history # View past queries
|
|
296
|
+
```
|
|
297
|
+
"""
|
|
298
|
+
|
|
299
|
+
console.print(Panel(
|
|
300
|
+
Markdown(message),
|
|
301
|
+
title="[bold yellow]Offline Mode Active[/]",
|
|
302
|
+
border_style="yellow"
|
|
303
|
+
))
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# Global offline mode instance
|
|
307
|
+
_offline_mode = None
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def get_offline_mode() -> OfflineMode:
|
|
311
|
+
"""Get global offline mode instance"""
|
|
312
|
+
global _offline_mode
|
|
313
|
+
if _offline_mode is None:
|
|
314
|
+
_offline_mode = OfflineMode()
|
|
315
|
+
return _offline_mode
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
async def check_backend_available(backend_url: str, timeout: float = 5.0) -> bool:
|
|
319
|
+
"""
|
|
320
|
+
Check if backend is available
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
backend_url: Backend URL to check
|
|
324
|
+
timeout: Timeout in seconds
|
|
325
|
+
|
|
326
|
+
Returns:
|
|
327
|
+
True if backend is reachable
|
|
328
|
+
"""
|
|
329
|
+
import aiohttp
|
|
330
|
+
|
|
331
|
+
try:
|
|
332
|
+
async with aiohttp.ClientSession() as session:
|
|
333
|
+
async with session.get(
|
|
334
|
+
f"{backend_url}/api/health",
|
|
335
|
+
timeout=aiohttp.ClientTimeout(total=timeout)
|
|
336
|
+
) as response:
|
|
337
|
+
return response.status == 200
|
|
338
|
+
except:
|
|
339
|
+
try:
|
|
340
|
+
# Fallback: try root endpoint
|
|
341
|
+
async with aiohttp.ClientSession() as session:
|
|
342
|
+
async with session.get(
|
|
343
|
+
backend_url,
|
|
344
|
+
timeout=aiohttp.ClientTimeout(total=timeout)
|
|
345
|
+
) as response:
|
|
346
|
+
return response.status < 500
|
|
347
|
+
except:
|
|
348
|
+
return False
|