arionxiv 1.0.32__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 (69) hide show
  1. arionxiv/__init__.py +40 -0
  2. arionxiv/__main__.py +10 -0
  3. arionxiv/arxiv_operations/__init__.py +0 -0
  4. arionxiv/arxiv_operations/client.py +225 -0
  5. arionxiv/arxiv_operations/fetcher.py +173 -0
  6. arionxiv/arxiv_operations/searcher.py +122 -0
  7. arionxiv/arxiv_operations/utils.py +293 -0
  8. arionxiv/cli/__init__.py +4 -0
  9. arionxiv/cli/commands/__init__.py +1 -0
  10. arionxiv/cli/commands/analyze.py +587 -0
  11. arionxiv/cli/commands/auth.py +365 -0
  12. arionxiv/cli/commands/chat.py +714 -0
  13. arionxiv/cli/commands/daily.py +482 -0
  14. arionxiv/cli/commands/fetch.py +217 -0
  15. arionxiv/cli/commands/library.py +295 -0
  16. arionxiv/cli/commands/preferences.py +426 -0
  17. arionxiv/cli/commands/search.py +254 -0
  18. arionxiv/cli/commands/settings_unified.py +1407 -0
  19. arionxiv/cli/commands/trending.py +41 -0
  20. arionxiv/cli/commands/welcome.py +168 -0
  21. arionxiv/cli/main.py +407 -0
  22. arionxiv/cli/ui/__init__.py +1 -0
  23. arionxiv/cli/ui/global_theme_manager.py +173 -0
  24. arionxiv/cli/ui/logo.py +127 -0
  25. arionxiv/cli/ui/splash.py +89 -0
  26. arionxiv/cli/ui/theme.py +32 -0
  27. arionxiv/cli/ui/theme_system.py +391 -0
  28. arionxiv/cli/utils/__init__.py +54 -0
  29. arionxiv/cli/utils/animations.py +522 -0
  30. arionxiv/cli/utils/api_client.py +583 -0
  31. arionxiv/cli/utils/api_config.py +505 -0
  32. arionxiv/cli/utils/command_suggestions.py +147 -0
  33. arionxiv/cli/utils/db_config_manager.py +254 -0
  34. arionxiv/github_actions_runner.py +206 -0
  35. arionxiv/main.py +23 -0
  36. arionxiv/prompts/__init__.py +9 -0
  37. arionxiv/prompts/prompts.py +247 -0
  38. arionxiv/rag_techniques/__init__.py +8 -0
  39. arionxiv/rag_techniques/basic_rag.py +1531 -0
  40. arionxiv/scheduler_daemon.py +139 -0
  41. arionxiv/server.py +1000 -0
  42. arionxiv/server_main.py +24 -0
  43. arionxiv/services/__init__.py +73 -0
  44. arionxiv/services/llm_client.py +30 -0
  45. arionxiv/services/llm_inference/__init__.py +58 -0
  46. arionxiv/services/llm_inference/groq_client.py +469 -0
  47. arionxiv/services/llm_inference/llm_utils.py +250 -0
  48. arionxiv/services/llm_inference/openrouter_client.py +564 -0
  49. arionxiv/services/unified_analysis_service.py +872 -0
  50. arionxiv/services/unified_auth_service.py +457 -0
  51. arionxiv/services/unified_config_service.py +456 -0
  52. arionxiv/services/unified_daily_dose_service.py +823 -0
  53. arionxiv/services/unified_database_service.py +1633 -0
  54. arionxiv/services/unified_llm_service.py +366 -0
  55. arionxiv/services/unified_paper_service.py +604 -0
  56. arionxiv/services/unified_pdf_service.py +522 -0
  57. arionxiv/services/unified_prompt_service.py +344 -0
  58. arionxiv/services/unified_scheduler_service.py +589 -0
  59. arionxiv/services/unified_user_service.py +954 -0
  60. arionxiv/utils/__init__.py +51 -0
  61. arionxiv/utils/api_helpers.py +200 -0
  62. arionxiv/utils/file_cleanup.py +150 -0
  63. arionxiv/utils/ip_helper.py +96 -0
  64. arionxiv-1.0.32.dist-info/METADATA +336 -0
  65. arionxiv-1.0.32.dist-info/RECORD +69 -0
  66. arionxiv-1.0.32.dist-info/WHEEL +5 -0
  67. arionxiv-1.0.32.dist-info/entry_points.txt +4 -0
  68. arionxiv-1.0.32.dist-info/licenses/LICENSE +21 -0
  69. arionxiv-1.0.32.dist-info/top_level.txt +1 -0
@@ -0,0 +1,583 @@
1
+ """
2
+ API Client for ArionXiv CLI
3
+ Handles communication with the ArionXiv backend server
4
+ """
5
+
6
+ import os
7
+ import logging
8
+ from typing import Dict, Any, Optional, List
9
+ from pathlib import Path
10
+ import json
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ # Default API URL - the hosted ArionXiv backend on Vercel
15
+ DEFAULT_API_URL = "https://arion-xiv.vercel.app"
16
+
17
+
18
+ class APIClientError(Exception):
19
+ """Custom exception for API client errors"""
20
+ def __init__(self, message: str, status_code: int = None, details: Dict = None):
21
+ self.message = message
22
+ self.status_code = status_code
23
+ self.details = details or {}
24
+ super().__init__(self.message)
25
+
26
+
27
+ class ArionXivAPIClient:
28
+ """
29
+ API client for ArionXiv backend.
30
+ Provides methods for all API endpoints.
31
+ Uses httpx for async HTTP requests.
32
+ """
33
+
34
+ def __init__(self, base_url: str = None):
35
+ self.base_url = base_url or os.getenv("ARIONXIV_API_URL", DEFAULT_API_URL)
36
+ self._token: Optional[str] = None
37
+ self._token_file = Path.home() / ".arionxiv" / "token.json"
38
+ self._httpx_client = None
39
+ self._load_token()
40
+
41
+ @property
42
+ def httpx_client(self):
43
+ """Lazy load httpx client"""
44
+ if self._httpx_client is None:
45
+ try:
46
+ import httpx
47
+ self._httpx_client = httpx.AsyncClient(
48
+ base_url=self.base_url,
49
+ timeout=30.0
50
+ )
51
+ except ImportError:
52
+ logger.warning("httpx not installed, API client will not work")
53
+ raise ImportError("httpx is required for API client. Install with: pip install httpx")
54
+ return self._httpx_client
55
+
56
+ def _load_token(self) -> None:
57
+ """Load stored token from file"""
58
+ try:
59
+ if self._token_file.exists():
60
+ data = json.loads(self._token_file.read_text())
61
+ self._token = data.get("token")
62
+ except Exception as e:
63
+ logger.debug(f"Could not load token: {e}")
64
+
65
+ def _save_token(self, token: str) -> None:
66
+ """Save token to file"""
67
+ try:
68
+ self._token_file.parent.mkdir(parents=True, exist_ok=True)
69
+ self._token_file.write_text(json.dumps({"token": token}))
70
+ self._token = token
71
+ except Exception as e:
72
+ logger.warning(f"Could not save token: {e}")
73
+
74
+ def _clear_token(self) -> None:
75
+ """Clear stored token"""
76
+ try:
77
+ if self._token_file.exists():
78
+ self._token_file.unlink()
79
+ self._token = None
80
+ except Exception as e:
81
+ logger.warning(f"Could not clear token: {e}")
82
+
83
+ def _get_headers(self, auth_required: bool = True) -> Dict[str, str]:
84
+ """Get request headers"""
85
+ headers = {"Content-Type": "application/json"}
86
+ if auth_required and self._token:
87
+ headers["Authorization"] = f"Bearer {self._token}"
88
+ return headers
89
+
90
+ async def _handle_response(self, response) -> Dict[str, Any]:
91
+ """Handle API response"""
92
+ try:
93
+ data = response.json()
94
+ except Exception:
95
+ data = {"message": response.text}
96
+
97
+ if response.status_code >= 400:
98
+ # Extract error message from various possible formats
99
+ error_msg = data.get("detail", data.get("error", data.get("message", "")))
100
+ if isinstance(error_msg, dict):
101
+ error_msg = error_msg.get("message", "") or str(error_msg) if error_msg else ""
102
+ if not error_msg or error_msg == "{}":
103
+ error_msg = f"API error {response.status_code}"
104
+
105
+ raise APIClientError(
106
+ message=str(error_msg),
107
+ status_code=response.status_code,
108
+ details=data
109
+ )
110
+
111
+ return data
112
+
113
+ # =========================================================================
114
+ # AUTHENTICATION ENDPOINTS
115
+ # =========================================================================
116
+
117
+ async def register(
118
+ self,
119
+ email: str,
120
+ user_name: str,
121
+ password: str,
122
+ full_name: str = ""
123
+ ) -> Dict[str, Any]:
124
+ """Register a new user"""
125
+ response = await self.httpx_client.post(
126
+ "/auth/register",
127
+ json={
128
+ "email": email,
129
+ "user_name": user_name,
130
+ "password": password,
131
+ "full_name": full_name
132
+ },
133
+ headers=self._get_headers(auth_required=False)
134
+ )
135
+ return await self._handle_response(response)
136
+
137
+ async def login(self, identifier: str, password: str) -> Dict[str, Any]:
138
+ """Login user and store token"""
139
+ response = await self.httpx_client.post(
140
+ "/auth/login",
141
+ json={"identifier": identifier, "password": password},
142
+ headers=self._get_headers(auth_required=False)
143
+ )
144
+ data = await self._handle_response(response)
145
+
146
+ if data.get("success") and data.get("token"):
147
+ self._save_token(data["token"])
148
+
149
+ return data
150
+
151
+ async def logout(self) -> Dict[str, Any]:
152
+ """Logout user and clear token"""
153
+ try:
154
+ response = await self.httpx_client.post(
155
+ "/auth/logout",
156
+ headers=self._get_headers()
157
+ )
158
+ await self._handle_response(response)
159
+ except Exception:
160
+ pass # Logout should always succeed client-side
161
+
162
+ self._clear_token()
163
+ return {"success": True, "message": "Logged out"}
164
+
165
+ async def refresh_token(self) -> Dict[str, Any]:
166
+ """Refresh authentication token"""
167
+ if not self._token:
168
+ raise APIClientError("No token to refresh", status_code=401)
169
+
170
+ response = await self.httpx_client.post(
171
+ "/auth/refresh",
172
+ json={"token": self._token},
173
+ headers=self._get_headers(auth_required=False)
174
+ )
175
+ data = await self._handle_response(response)
176
+
177
+ if data.get("success") and data.get("token"):
178
+ self._save_token(data["token"])
179
+
180
+ return data
181
+
182
+ def is_authenticated(self) -> bool:
183
+ """Check if user has a stored token"""
184
+ return self._token is not None
185
+
186
+ # =========================================================================
187
+ # USER ENDPOINTS
188
+ # =========================================================================
189
+
190
+ async def get_profile(self) -> Dict[str, Any]:
191
+ """Get current user profile"""
192
+ response = await self.httpx_client.get(
193
+ "/auth/profile",
194
+ headers=self._get_headers()
195
+ )
196
+ return await self._handle_response(response)
197
+
198
+ async def get_settings(self) -> Dict[str, Any]:
199
+ """Get user settings"""
200
+ response = await self.httpx_client.get(
201
+ "/settings",
202
+ headers=self._get_headers()
203
+ )
204
+ return await self._handle_response(response)
205
+
206
+ async def update_settings(self, settings: Dict[str, Any]) -> Dict[str, Any]:
207
+ """Update user settings"""
208
+ response = await self.httpx_client.put(
209
+ "/settings",
210
+ json=settings,
211
+ headers=self._get_headers()
212
+ )
213
+ return await self._handle_response(response)
214
+
215
+ # =========================================================================
216
+ # PAPER ENDPOINTS
217
+ # =========================================================================
218
+
219
+ async def search_papers(
220
+ self,
221
+ query: str,
222
+ max_results: int = 10,
223
+ category: str = None
224
+ ) -> Dict[str, Any]:
225
+ """Search arXiv papers"""
226
+ params = {"query": query, "max_results": max_results}
227
+ if category:
228
+ params["category"] = category
229
+
230
+ response = await self.httpx_client.get(
231
+ "/papers/search",
232
+ params=params,
233
+ headers=self._get_headers()
234
+ )
235
+ return await self._handle_response(response)
236
+
237
+ async def fetch_paper(self, arxiv_id: str) -> Dict[str, Any]:
238
+ """Fetch a paper from arXiv"""
239
+ response = await self.httpx_client.post(
240
+ f"/papers/{arxiv_id}/fetch",
241
+ headers=self._get_headers()
242
+ )
243
+ return await self._handle_response(response)
244
+
245
+ async def analyze_paper(self, paper_id: str) -> Dict[str, Any]:
246
+ """Analyze a paper"""
247
+ response = await self.httpx_client.post(
248
+ f"/papers/{paper_id}/analyze",
249
+ headers=self._get_headers()
250
+ )
251
+ return await self._handle_response(response)
252
+
253
+ async def get_user_papers(
254
+ self,
255
+ limit: int = 20,
256
+ skip: int = 0
257
+ ) -> Dict[str, Any]:
258
+ """Get user's papers"""
259
+ response = await self.httpx_client.get(
260
+ "/papers/user",
261
+ params={"limit": limit, "skip": skip},
262
+ headers=self._get_headers()
263
+ )
264
+ return await self._handle_response(response)
265
+
266
+ # =========================================================================
267
+ # LIBRARY ENDPOINTS
268
+ # =========================================================================
269
+
270
+ async def get_library(
271
+ self,
272
+ limit: int = 20,
273
+ skip: int = 0
274
+ ) -> Dict[str, Any]:
275
+ """Get user's library"""
276
+ response = await self.httpx_client.get(
277
+ "/library",
278
+ params={"limit": limit, "skip": skip},
279
+ headers=self._get_headers()
280
+ )
281
+ return await self._handle_response(response)
282
+
283
+ async def add_to_library(
284
+ self,
285
+ arxiv_id: str,
286
+ title: str = "",
287
+ authors: List[str] = None,
288
+ categories: List[str] = None,
289
+ abstract: str = "",
290
+ tags: List[str] = None,
291
+ notes: str = ""
292
+ ) -> Dict[str, Any]:
293
+ """Add paper to library"""
294
+ response = await self.httpx_client.post(
295
+ "/library",
296
+ json={
297
+ "arxiv_id": arxiv_id,
298
+ "title": title,
299
+ "authors": authors or [],
300
+ "categories": categories or [],
301
+ "abstract": abstract,
302
+ "tags": tags or [],
303
+ "notes": notes
304
+ },
305
+ headers=self._get_headers()
306
+ )
307
+ return await self._handle_response(response)
308
+
309
+ async def remove_from_library(self, arxiv_id: str) -> Dict[str, Any]:
310
+ """Remove paper from library"""
311
+ response = await self.httpx_client.delete(
312
+ f"/library/{arxiv_id}",
313
+ headers=self._get_headers()
314
+ )
315
+ return await self._handle_response(response)
316
+
317
+ async def update_library_paper(
318
+ self,
319
+ arxiv_id: str,
320
+ tags: List[str] = None,
321
+ notes: str = None
322
+ ) -> Dict[str, Any]:
323
+ """Update paper in library"""
324
+ response = await self.httpx_client.put(
325
+ f"/library/{arxiv_id}",
326
+ json={"tags": tags, "notes": notes},
327
+ headers=self._get_headers()
328
+ )
329
+ return await self._handle_response(response)
330
+
331
+ async def search_library(self, query: str) -> Dict[str, Any]:
332
+ """Search user's library"""
333
+ response = await self.httpx_client.get(
334
+ "/library/search",
335
+ params={"query": query},
336
+ headers=self._get_headers()
337
+ )
338
+ return await self._handle_response(response)
339
+
340
+ # =========================================================================
341
+ # CHAT ENDPOINTS
342
+ # =========================================================================
343
+
344
+ async def create_chat_session(
345
+ self,
346
+ paper_id: str,
347
+ title: str = None
348
+ ) -> Dict[str, Any]:
349
+ """Create a new chat session"""
350
+ response = await self.httpx_client.post(
351
+ "/chat/session",
352
+ json={"paper_id": paper_id, "title": title},
353
+ headers=self._get_headers()
354
+ )
355
+ return await self._handle_response(response)
356
+
357
+ async def update_chat_session(
358
+ self,
359
+ session_id: str,
360
+ messages: List[Dict[str, Any]]
361
+ ) -> Dict[str, Any]:
362
+ """Update chat session with new messages"""
363
+ response = await self.httpx_client.put(
364
+ f"/chat/session/{session_id}",
365
+ json=messages,
366
+ headers=self._get_headers()
367
+ )
368
+ return await self._handle_response(response)
369
+
370
+ async def send_chat_message(
371
+ self,
372
+ message: str,
373
+ paper_id: str,
374
+ session_id: str = None,
375
+ context: str = None,
376
+ paper_title: str = None
377
+ ) -> Dict[str, Any]:
378
+ """Send a chat message with optional RAG context"""
379
+ payload = {
380
+ "message": message,
381
+ "paper_id": paper_id,
382
+ "session_id": session_id
383
+ }
384
+ # Include context if provided (for RAG-enhanced responses)
385
+ if context:
386
+ payload["context"] = context
387
+ if paper_title:
388
+ payload["paper_title"] = paper_title
389
+
390
+ response = await self.httpx_client.post(
391
+ "/chat/message",
392
+ json=payload,
393
+ headers=self._get_headers()
394
+ )
395
+ return await self._handle_response(response)
396
+
397
+ async def get_chat_sessions(self, active_only: bool = True) -> Dict[str, Any]:
398
+ """Get user's chat sessions"""
399
+ response = await self.httpx_client.get(
400
+ "/chat/sessions",
401
+ params={"active_only": active_only},
402
+ headers=self._get_headers()
403
+ )
404
+ return await self._handle_response(response)
405
+
406
+ async def get_chat_session(self, session_id: str) -> Dict[str, Any]:
407
+ """Get a specific chat session"""
408
+ response = await self.httpx_client.get(
409
+ f"/chat/session/{session_id}",
410
+ headers=self._get_headers()
411
+ )
412
+ return await self._handle_response(response)
413
+
414
+ async def delete_chat_session(self, session_id: str) -> Dict[str, Any]:
415
+ """Delete a chat session"""
416
+ response = await self.httpx_client.delete(
417
+ f"/chat/session/{session_id}",
418
+ headers=self._get_headers()
419
+ )
420
+ return await self._handle_response(response)
421
+
422
+ # =========================================================================
423
+ # DAILY DOSE ENDPOINTS
424
+ # =========================================================================
425
+
426
+ async def get_daily_analysis(self, date: str = None) -> Dict[str, Any]:
427
+ """Get daily analysis for today or specified date"""
428
+ params = {}
429
+ if date:
430
+ params["date"] = date
431
+
432
+ response = await self.httpx_client.get(
433
+ "/daily",
434
+ params=params,
435
+ headers=self._get_headers()
436
+ )
437
+ return await self._handle_response(response)
438
+
439
+ async def get_daily_dose_settings(self) -> Dict[str, Any]:
440
+ """Get daily dose settings"""
441
+ response = await self.httpx_client.get(
442
+ "/daily/settings",
443
+ headers=self._get_headers()
444
+ )
445
+ return await self._handle_response(response)
446
+
447
+ async def update_daily_dose_settings(self, settings: Dict[str, Any]) -> Dict[str, Any]:
448
+ """Update daily dose settings"""
449
+ response = await self.httpx_client.put(
450
+ "/daily/settings",
451
+ json=settings,
452
+ headers=self._get_headers()
453
+ )
454
+ return await self._handle_response(response)
455
+
456
+ async def run_daily_dose(self) -> Dict[str, Any]:
457
+ """Run daily dose analysis"""
458
+ response = await self.httpx_client.post(
459
+ "/daily/run",
460
+ headers=self._get_headers()
461
+ )
462
+ return await self._handle_response(response)
463
+
464
+ # =========================================================================
465
+ # EMBEDDINGS CACHE ENDPOINTS
466
+ # =========================================================================
467
+
468
+ async def get_embeddings(self, paper_id: str) -> Dict[str, Any]:
469
+ """Get cached embeddings for a paper"""
470
+ response = await self.httpx_client.get(
471
+ f"/embeddings/{paper_id}",
472
+ headers=self._get_headers()
473
+ )
474
+ return await self._handle_response(response)
475
+
476
+ async def save_embeddings(self, paper_id: str, embeddings: List, chunks: List) -> Dict[str, Any]:
477
+ """Save embeddings for a paper in batches to avoid size limits"""
478
+ import json
479
+
480
+ # Stay under 3MB per request to provide safety margin for Vercel's 4.5MB payload limit
481
+ MAX_BATCH_SIZE_BYTES = 3_000_000
482
+
483
+ # Dynamically calculate batch size based on actual embedding dimensions
484
+ # Embeddings can vary (384 dims vs 1536 dims), so we estimate from first chunk
485
+ if embeddings and chunks:
486
+ sample_payload = json.dumps({"embeddings": [embeddings[0]], "chunks": [chunks[0]]})
487
+ chunk_size = len(sample_payload)
488
+ batch_size = max(10, min(100, MAX_BATCH_SIZE_BYTES // chunk_size))
489
+ else:
490
+ batch_size = 50 # Default fallback
491
+
492
+ # If total payload is small, send in one request
493
+ payload = {"embeddings": embeddings, "chunks": chunks}
494
+ total_size = len(json.dumps(payload))
495
+ if total_size < MAX_BATCH_SIZE_BYTES:
496
+ response = await self.httpx_client.post(
497
+ f"/embeddings/{paper_id}",
498
+ json={**payload, "batch_index": 0, "total_batches": 1},
499
+ headers=self._get_headers()
500
+ )
501
+ return await self._handle_response(response)
502
+
503
+ # Send in batches for large papers
504
+ total_chunks = len(embeddings)
505
+ total_batches = (total_chunks + batch_size - 1) // batch_size
506
+
507
+ for i in range(0, total_chunks, batch_size):
508
+ batch_embeddings = embeddings[i:i + batch_size]
509
+ batch_chunks = chunks[i:i + batch_size]
510
+ batch_index = i // batch_size
511
+
512
+ response = await self.httpx_client.post(
513
+ f"/embeddings/{paper_id}",
514
+ json={
515
+ "embeddings": batch_embeddings,
516
+ "chunks": batch_chunks,
517
+ "batch_index": batch_index,
518
+ "total_batches": total_batches
519
+ },
520
+ headers=self._get_headers()
521
+ )
522
+
523
+ result = await self._handle_response(response)
524
+ if not result.get("success"):
525
+ return result
526
+
527
+ return {"success": True, "message": f"Saved {total_chunks} embeddings in {total_batches} batches"}
528
+
529
+ # =========================================================================
530
+ # HEALTH CHECK
531
+ # =========================================================================
532
+
533
+ async def health_check(self) -> Dict[str, Any]:
534
+ """Check API health"""
535
+ response = await self.httpx_client.get("/health")
536
+ return await self._handle_response(response)
537
+
538
+ async def close(self):
539
+ """Close the HTTP client"""
540
+ if self._httpx_client:
541
+ await self._httpx_client.aclose()
542
+ self._httpx_client = None
543
+
544
+
545
+ # Global instance
546
+ api_client = ArionXivAPIClient()
547
+
548
+
549
+ # Convenience functions for direct import
550
+ async def login(identifier: str, password: str) -> Dict[str, Any]:
551
+ """Login convenience function"""
552
+ return await api_client.login(identifier, password)
553
+
554
+
555
+ async def register(
556
+ email: str,
557
+ user_name: str,
558
+ password: str,
559
+ full_name: str = ""
560
+ ) -> Dict[str, Any]:
561
+ """Register convenience function"""
562
+ return await api_client.register(email, user_name, password, full_name)
563
+
564
+
565
+ async def logout() -> Dict[str, Any]:
566
+ """Logout convenience function"""
567
+ return await api_client.logout()
568
+
569
+
570
+ def is_authenticated() -> bool:
571
+ """Check authentication status"""
572
+ return api_client.is_authenticated()
573
+
574
+
575
+ __all__ = [
576
+ 'ArionXivAPIClient',
577
+ 'APIClientError',
578
+ 'api_client',
579
+ 'login',
580
+ 'register',
581
+ 'logout',
582
+ 'is_authenticated'
583
+ ]