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