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,714 @@
1
+ """
2
+ Enhanced Chat Interface for ArionXiv
3
+ Chat with research papers using RAG - Uses hosted API for user data
4
+ """
5
+
6
+ import asyncio
7
+ import logging
8
+ from typing import Optional, Dict, Any, List
9
+
10
+ import click
11
+ from rich.console import Console
12
+ from rich.panel import Panel
13
+ from rich.prompt import Prompt
14
+ from rich.table import Table
15
+ from rich.progress import Progress, SpinnerColumn, TextColumn
16
+
17
+ from ..utils.api_client import api_client, APIClientError
18
+ from ...services.unified_user_service import unified_user_service
19
+ from ...services.unified_analysis_service import rag_chat_system
20
+ from ...arxiv_operations.client import arxiv_client
21
+ from ...arxiv_operations.fetcher import arxiv_fetcher
22
+ from ...arxiv_operations.searcher import arxiv_searcher
23
+ from ...arxiv_operations.utils import ArxivUtils
24
+ from ..ui.theme import create_themed_console, style_text, get_theme_colors, create_themed_table
25
+ from ..utils.animations import left_to_right_reveal, row_by_row_table_reveal
26
+ from ..utils.command_suggestions import show_command_suggestions
27
+
28
+ logger = logging.getLogger(__name__)
29
+ MAX_USER_PAPERS = 10
30
+
31
+
32
+ @click.command()
33
+ @click.option('--paper-id', '-p', help='ArXiv ID to chat with directly')
34
+ def chat_command(paper_id: Optional[str] = None):
35
+ """Start chat session with papers"""
36
+ asyncio.run(run_chat_command(paper_id))
37
+
38
+
39
+ async def run_chat_command(paper_id: Optional[str] = None):
40
+ """Main chat command interface"""
41
+ console = create_themed_console()
42
+ colors = get_theme_colors()
43
+
44
+ # Note: RAG embeddings are cached locally, chat sessions stored via hosted API
45
+
46
+ console.print(Panel(
47
+ f"[bold {colors['primary']}]ArionXiv Chat System[/bold {colors['primary']}]\n"
48
+ f"[bold {colors['primary']}]Intelligent chat with your research papers[/bold {colors['primary']}]",
49
+ title=f"[bold {colors['primary']}]Chat Interface[/bold {colors['primary']}]",
50
+ border_style=f"bold {colors['primary']}"
51
+ ))
52
+
53
+ try:
54
+ # Check authentication
55
+ if not unified_user_service.is_authenticated() and not api_client.is_authenticated():
56
+ left_to_right_reveal(console, "No user logged in. Please login first with: arionxiv login",
57
+ style=f"bold {colors['warning']}", duration=1.0)
58
+ return
59
+
60
+ user_data = unified_user_service.get_current_user()
61
+ user_name = user_data.get('user_name', 'User') if user_data else 'User'
62
+ left_to_right_reveal(console, f"\nLogged in as: {user_name}\n",
63
+ style=f"bold {colors['primary']}", duration=1.0)
64
+
65
+ selected_paper = None
66
+
67
+ if paper_id:
68
+ selected_paper = await _fetch_paper_by_id(console, colors, paper_id)
69
+ else:
70
+ selected_paper = await _show_chat_menu(console, colors, user_name)
71
+
72
+ if not selected_paper:
73
+ show_command_suggestions(console, context='chat')
74
+ return
75
+
76
+ if selected_paper == "SESSION_COMPLETED":
77
+ return
78
+
79
+ await _start_chat_with_paper(console, colors, user_name, selected_paper)
80
+
81
+ except KeyboardInterrupt:
82
+ console.print(f"\n[bold {colors['warning']}]Interrupted by user.[/bold {colors['warning']}]")
83
+ except Exception as e:
84
+ console.print(Panel(
85
+ f"[bold {colors['error']}]Error: {str(e)}[/bold {colors['error']}]",
86
+ title=f"[bold {colors['error']}]Chat Error[/bold {colors['error']}]",
87
+ border_style=f"bold {colors['error']}"
88
+ ))
89
+ logger.error(f"Chat command error: {str(e)}", exc_info=True)
90
+
91
+
92
+ async def _get_user_papers_from_api() -> List[Dict]:
93
+ """Get user's saved papers from API"""
94
+ try:
95
+ result = await api_client.get_library(limit=MAX_USER_PAPERS)
96
+ if result.get("success"):
97
+ return result.get("papers", [])
98
+ except APIClientError as e:
99
+ logger.error(f"Failed to fetch user papers from API: {e.message}", exc_info=True)
100
+ except Exception as e:
101
+ logger.error(f"Failed to fetch user papers: {e}", exc_info=True)
102
+ return []
103
+
104
+
105
+ async def _get_chat_sessions_from_api() -> List[Dict]:
106
+ """Get active chat sessions from API"""
107
+ try:
108
+ result = await api_client.get_chat_sessions(active_only=True)
109
+ if result.get("success"):
110
+ return result.get("sessions", [])
111
+ except APIClientError as e:
112
+ logger.error(f"Failed to fetch chat sessions from API: {e.message}", exc_info=True)
113
+ except Exception as e:
114
+ logger.error(f"Failed to fetch chat sessions: {e}", exc_info=True)
115
+ return []
116
+
117
+
118
+ async def _show_chat_menu(console: Console, colors: Dict, user_name: str) -> Optional[Dict[str, Any]]:
119
+ """Show main chat menu with options"""
120
+
121
+ while True:
122
+ user_papers = await _get_user_papers_from_api()
123
+ active_sessions = await _get_chat_sessions_from_api()
124
+
125
+ left_to_right_reveal(console, "What would you like to do?", style=f"bold {colors['primary']}", duration=0.3)
126
+ console.print()
127
+ left_to_right_reveal(console, "1. Search for a new paper", style=f"bold {colors['primary']}", duration=0.3)
128
+
129
+ if user_papers:
130
+ left_to_right_reveal(console, f"2. Chat with saved papers ({len(user_papers)} saved)",
131
+ style=f"bold {colors['primary']}", duration=0.3)
132
+ else:
133
+ left_to_right_reveal(console, "2. Chat with saved papers (none saved)",
134
+ style=f"bold {colors['primary']}", duration=0.3)
135
+
136
+ if active_sessions:
137
+ left_to_right_reveal(console, f"3. Continue a previous chat ({len(active_sessions)} active)",
138
+ style=f"bold {colors['primary']}", duration=0.3)
139
+ else:
140
+ left_to_right_reveal(console, "3. Continue a previous chat (no active sessions)",
141
+ style=f"bold {colors['primary']}", duration=0.3)
142
+
143
+ left_to_right_reveal(console, "0. Exit", style=f"bold {colors['primary']}", duration=0.2)
144
+
145
+ choice = Prompt.ask(f"\n[bold {colors['primary']}]Select option[/bold {colors['primary']}]",
146
+ choices=["0", "1", "2", "3"], default="1")
147
+
148
+ if choice == "0":
149
+ return None
150
+ elif choice == "1":
151
+ result = await _search_and_select_paper(console, colors)
152
+ if result == "GO_BACK":
153
+ continue
154
+ return result
155
+ elif choice == "2":
156
+ if not user_papers:
157
+ left_to_right_reveal(console, "\nNo saved papers. Please search for a paper first.",
158
+ style=f"bold {colors['warning']}", duration=0.3)
159
+ result = await _search_and_select_paper(console, colors)
160
+ if result == "GO_BACK":
161
+ continue
162
+ return result
163
+ result = await _select_from_saved_papers(console, colors, user_papers)
164
+ if result == "GO_BACK":
165
+ console.print()
166
+ continue
167
+ return result
168
+ elif choice == "3":
169
+ if not active_sessions:
170
+ left_to_right_reveal(console, "\nNo active chat sessions within the last 24 hours.",
171
+ style=f"bold {colors['warning']}", duration=0.3)
172
+ continue
173
+ result = await _select_and_continue_session(console, colors, user_name, active_sessions)
174
+ if result == "GO_BACK":
175
+ console.print()
176
+ continue
177
+ if result == "SESSION_CONTINUED":
178
+ return "SESSION_COMPLETED"
179
+ return result
180
+
181
+
182
+ async def _search_and_select_paper(console: Console, colors: Dict) -> Optional[Dict[str, Any]]:
183
+ """Search arXiv and let user select a paper. Returns 'GO_BACK' to go back to menu."""
184
+
185
+ query = Prompt.ask(f"\n[bold {colors['primary']}]Enter search query (or 0 to go back)[/bold {colors['primary']}]")
186
+
187
+ if not query.strip() or query.strip() == "0":
188
+ return "GO_BACK"
189
+
190
+ left_to_right_reveal(console, "\nSearching arXiv...", style=f"bold {colors['primary']}", duration=0.5)
191
+
192
+ try:
193
+ results = await arxiv_searcher.search(query=query, max_results=10)
194
+
195
+ if not results.get("success") or not results.get("papers"):
196
+ left_to_right_reveal(console, f"No papers found for: {query}",
197
+ style=f"bold {colors['warning']}", duration=0.5)
198
+ return "GO_BACK"
199
+
200
+ papers = results["papers"]
201
+
202
+ left_to_right_reveal(console, f"\nFound {len(papers)} papers:",
203
+ style=f"bold {colors['primary']}", duration=0.5)
204
+ console.print()
205
+
206
+ await _display_papers_table_animated(console, colors, papers, "Search Results")
207
+
208
+ choice = Prompt.ask(f"\n[bold {colors['primary']}]Select paper (1-{len(papers)}) or 0 to go back[/bold {colors['primary']}]")
209
+
210
+ try:
211
+ idx = int(choice) - 1
212
+ if idx == -1:
213
+ return "GO_BACK"
214
+ if idx < 0 or idx >= len(papers):
215
+ left_to_right_reveal(console, "Invalid selection.", style=f"bold {colors['error']}", duration=0.5)
216
+ return None
217
+ except ValueError:
218
+ left_to_right_reveal(console, "Invalid input.", style=f"bold {colors['error']}", duration=0.5)
219
+ return None
220
+
221
+ return papers[idx]
222
+
223
+ except Exception as e:
224
+ left_to_right_reveal(console, f"Search failed: {str(e)}", style=f"bold {colors['error']}", duration=0.5)
225
+ return "GO_BACK"
226
+
227
+
228
+ async def _display_papers_table_animated(console: Console, colors: Dict, papers: List[Dict], title_str: str):
229
+ """Display papers table with row-by-row animation"""
230
+
231
+ def create_table_with_rows(num_rows: int) -> Table:
232
+ table = create_themed_table(title_str)
233
+ table.expand = True
234
+ table.add_column("#", style="bold white", width=4)
235
+ table.add_column("Title", style="white")
236
+ table.add_column("Authors", style="white", width=30)
237
+ table.add_column("Date", style="white", width=12)
238
+
239
+ for i in range(num_rows):
240
+ paper = papers[i]
241
+ title_text = paper.get("title", "Unknown")
242
+ authors = paper.get("authors", [])
243
+ author_str = authors[0] + (f" +{len(authors)-1}" if len(authors) > 1 else "") if authors else "Unknown"
244
+ pub_date = paper.get("published", "")[:10] if paper.get("published") else "Unknown"
245
+ table.add_row(str(i + 1), title_text, author_str, pub_date)
246
+ return table
247
+
248
+ await row_by_row_table_reveal(console, create_table_with_rows, len(papers))
249
+
250
+
251
+ async def _select_from_saved_papers(console: Console, colors: Dict, papers: List[Dict]) -> Optional[Dict[str, Any]]:
252
+ """Let user select from their saved papers. Returns 'GO_BACK' to go back to menu."""
253
+
254
+ left_to_right_reveal(console, "\nYour saved papers:", style=f"bold {colors['primary']}", duration=0.5)
255
+ console.print()
256
+
257
+ await _display_saved_papers_animated(console, colors, papers)
258
+
259
+ choice = Prompt.ask(f"\n[bold {colors['primary']}]Select paper (1-{len(papers)}) or 0 to go back[/bold {colors['primary']}]")
260
+
261
+ try:
262
+ idx = int(choice) - 1
263
+ if idx == -1:
264
+ return "GO_BACK"
265
+ if idx < 0 or idx >= len(papers):
266
+ left_to_right_reveal(console, "Invalid selection.", style=f"bold {colors['error']}", duration=0.5)
267
+ return None
268
+ except ValueError:
269
+ left_to_right_reveal(console, "Invalid input.", style=f"bold {colors['error']}", duration=0.5)
270
+ return None
271
+
272
+ return papers[idx]
273
+
274
+
275
+ async def _display_saved_papers_animated(console: Console, colors: Dict, papers: List[Dict]):
276
+ """Display saved papers table with row-by-row animation"""
277
+
278
+ def create_table_with_rows(num_rows: int) -> Table:
279
+ table = create_themed_table("Saved Papers")
280
+ table.expand = True
281
+ table.add_column("#", style="bold white", width=4)
282
+ table.add_column("Title", style="white")
283
+ table.add_column("ArXiv ID", style="white", width=18)
284
+ table.add_column("Added", style="white", width=12)
285
+
286
+ for i in range(num_rows):
287
+ paper = papers[i]
288
+ title = paper.get("title", "Unknown")
289
+ arxiv_id = paper.get("arxiv_id", "Unknown")
290
+ added_at = paper.get("added_at")
291
+ added_str = added_at.strftime("%Y-%m-%d") if hasattr(added_at, 'strftime') else str(added_at)[:10] if added_at else "Unknown"
292
+ table.add_row(str(i + 1), title, arxiv_id, added_str)
293
+ return table
294
+
295
+ await row_by_row_table_reveal(console, create_table_with_rows, len(papers))
296
+
297
+
298
+ async def _select_and_continue_session(console: Console, colors: Dict, user_name: str, sessions: List[Dict]) -> Optional[str]:
299
+ """Let user select from active chat sessions and continue."""
300
+ from datetime import datetime
301
+
302
+ left_to_right_reveal(console, "\nActive chat sessions (last 24 hours):",
303
+ style=f"bold {colors['primary']}", duration=0.5)
304
+ console.print()
305
+
306
+ await _display_sessions_table_animated(console, colors, sessions)
307
+
308
+ choice = Prompt.ask(f"\n[bold {colors['primary']}]Select session (1-{len(sessions)}) or 0 to go back[/bold {colors['primary']}]")
309
+
310
+ try:
311
+ idx = int(choice) - 1
312
+ if idx == -1:
313
+ return "GO_BACK"
314
+ if idx < 0 or idx >= len(sessions):
315
+ left_to_right_reveal(console, "Invalid selection.", style=f"bold {colors['error']}", duration=0.5)
316
+ return None
317
+ except ValueError:
318
+ left_to_right_reveal(console, "Invalid input.", style=f"bold {colors['error']}", duration=0.5)
319
+ return None
320
+
321
+ selected_session = sessions[idx]
322
+ await _continue_chat_session(console, colors, user_name, selected_session)
323
+
324
+ # Show "What's Next?" after resumed chat ends
325
+ show_command_suggestions(console, context='chat')
326
+
327
+ return "SESSION_CONTINUED"
328
+
329
+
330
+ async def _display_sessions_table_animated(console: Console, colors: Dict, sessions: List[Dict]):
331
+ """Display active chat sessions table with row-by-row animation"""
332
+ from datetime import datetime
333
+
334
+ def create_table_with_rows(num_rows: int) -> Table:
335
+ table = create_themed_table("Active Chat Sessions")
336
+ table.expand = True
337
+ table.add_column("#", style="bold white", width=4)
338
+ table.add_column("Paper Title", style="white")
339
+ table.add_column("Last Activity", style="white", width=18)
340
+ table.add_column("Messages", style="white", width=10)
341
+
342
+ for i in range(num_rows):
343
+ session = sessions[i]
344
+ # Handle both API field names (title) and legacy field names (paper_title)
345
+ title = session.get("title", session.get("paper_title", "Unknown Paper"))
346
+ if len(title) > 45:
347
+ title = title[:42] + "..."
348
+
349
+ last_activity = session.get("last_activity") or session.get("updated_at")
350
+ if last_activity:
351
+ if isinstance(last_activity, datetime):
352
+ time_diff = datetime.utcnow() - last_activity
353
+ if time_diff.total_seconds() < 3600:
354
+ time_str = f"{int(time_diff.total_seconds() / 60)} min ago"
355
+ else:
356
+ time_str = f"{int(time_diff.total_seconds() / 3600)} hrs ago"
357
+ elif isinstance(last_activity, str):
358
+ # Parse ISO datetime string
359
+ try:
360
+ dt = datetime.fromisoformat(last_activity.replace('Z', '+00:00'))
361
+ time_diff = datetime.utcnow() - dt.replace(tzinfo=None)
362
+ if time_diff.total_seconds() < 3600:
363
+ time_str = f"{int(time_diff.total_seconds() / 60)} min ago"
364
+ else:
365
+ time_str = f"{int(time_diff.total_seconds() / 3600)} hrs ago"
366
+ except:
367
+ time_str = str(last_activity)[:16]
368
+ else:
369
+ time_str = str(last_activity)[:16]
370
+ else:
371
+ time_str = "Recent"
372
+
373
+ msg_count = session.get("message_count", len(session.get("messages", [])))
374
+ exchanges = msg_count // 2
375
+
376
+ table.add_row(str(i + 1), title, time_str, str(exchanges))
377
+ return table
378
+
379
+ await row_by_row_table_reveal(console, create_table_with_rows, len(sessions))
380
+
381
+
382
+ async def _continue_chat_session(console: Console, colors: Dict, user_name: str, session: Dict[str, Any]):
383
+ """Continue an existing chat session"""
384
+
385
+ # The session from get_chat_sessions is a summary - fetch full session with messages
386
+ api_session_id = session.get('session_id', '')
387
+ if api_session_id:
388
+ try:
389
+ full_session_result = await api_client.get_chat_session(api_session_id)
390
+ if full_session_result.get('success') and full_session_result.get('session'):
391
+ full_session = full_session_result['session']
392
+ # Map API fields to expected fields and preserve api_session_id
393
+ # Database stores as 'paper_id', check both paper_id and arxiv_id for compatibility
394
+ session = {
395
+ 'session_id': full_session.get('session_id', api_session_id),
396
+ 'api_session_id': api_session_id, # Store for saving messages back to API
397
+ 'paper_id': full_session.get('paper_id', full_session.get('arxiv_id', session.get('paper_id', ''))),
398
+ 'paper_title': full_session.get('title', full_session.get('paper_title', session.get('title', 'Unknown Paper'))),
399
+ 'messages': full_session.get('messages', []),
400
+ 'last_activity': full_session.get('last_activity', full_session.get('updated_at')),
401
+ 'created_at': full_session.get('created_at')
402
+ }
403
+ logger.debug(f"Loaded full session with {len(session.get('messages', []))} messages, paper_id: {session.get('paper_id')}")
404
+ except Exception as e:
405
+ logger.warning(f"Failed to fetch full session details: {e}")
406
+ # Fall back to summary session data with field mapping
407
+ session = {
408
+ 'session_id': api_session_id,
409
+ 'api_session_id': api_session_id,
410
+ 'paper_id': session.get('paper_id', session.get('arxiv_id', '')),
411
+ 'paper_title': session.get('title', session.get('paper_title', 'Unknown Paper')),
412
+ 'messages': session.get('messages', []),
413
+ 'last_activity': session.get('last_activity'),
414
+ }
415
+ else:
416
+ # Map field names for consistency
417
+ session = {
418
+ 'session_id': session.get('session_id', ''),
419
+ 'paper_id': session.get('paper_id', session.get('arxiv_id', '')),
420
+ 'paper_title': session.get('title', session.get('paper_title', 'Unknown Paper')),
421
+ 'messages': session.get('messages', []),
422
+ 'last_activity': session.get('last_activity'),
423
+ }
424
+
425
+ paper_id = session.get('paper_id', '')
426
+ paper_title = session.get('paper_title', 'Unknown Paper')
427
+
428
+ left_to_right_reveal(console, f"\nResuming chat with: {paper_title}", style=f"bold {colors['primary']}", duration=0.5)
429
+
430
+ # Check for cached embeddings via API first - if available, skip PDF download
431
+ cached_data = None
432
+ try:
433
+ result = await api_client.get_embeddings(paper_id)
434
+ if result.get("success") and result.get("embeddings"):
435
+ cached_data = {
436
+ "embeddings": result.get("embeddings", []),
437
+ "chunks": result.get("chunks", [])
438
+ }
439
+ logger.info(f"Found {len(cached_data['embeddings'])} cached embeddings for paper {paper_id}")
440
+ except Exception as e:
441
+ logger.debug(f"No cached embeddings found via API: {e}")
442
+ cached_data = None
443
+
444
+ if cached_data and cached_data.get("embeddings"):
445
+ # Use cached embeddings - no need to download PDF or extract text
446
+ left_to_right_reveal(console, f"Loading cached embeddings ({len(cached_data['chunks'])} chunks)...", style=f"bold {colors['primary']}", duration=0.5)
447
+
448
+ # Fetch minimal paper metadata for the chat
449
+ paper_metadata = await asyncio.to_thread(arxiv_client.get_paper_by_id, paper_id)
450
+ paper_info = {
451
+ 'arxiv_id': paper_id,
452
+ 'title': paper_metadata.get('title', paper_title) if paper_metadata else paper_title,
453
+ 'authors': paper_metadata.get('authors', []) if paper_metadata else [],
454
+ 'abstract': paper_metadata.get('summary', paper_metadata.get('abstract', '')) if paper_metadata else '',
455
+ 'published': paper_metadata.get('published', '') if paper_metadata else '',
456
+ 'full_text': '', # Not needed when using cached embeddings
457
+ '_cached_embeddings': cached_data['embeddings'], # Pass cached embeddings
458
+ '_cached_chunks': cached_data['chunks'] # Pass cached chunks
459
+ }
460
+ else:
461
+ # No cached embeddings - need to download PDF and extract text
462
+ paper_metadata = await asyncio.to_thread(arxiv_client.get_paper_by_id, paper_id)
463
+
464
+ if not paper_metadata:
465
+ left_to_right_reveal(console, f"Could not retrieve paper {paper_id}.", style=f"bold {colors['error']}", duration=0.5)
466
+ return
467
+
468
+ pdf_url = paper_metadata.get('pdf_url')
469
+ if not pdf_url:
470
+ left_to_right_reveal(console, "No PDF URL available for this paper.", style=f"bold {colors['error']}", duration=0.5)
471
+ return
472
+
473
+ left_to_right_reveal(console, "Downloading PDF...", style=f"bold {colors['primary']}", duration=0.5)
474
+ pdf_path = await asyncio.to_thread(arxiv_fetcher.fetch_paper_sync, paper_id, pdf_url)
475
+
476
+ if not pdf_path:
477
+ left_to_right_reveal(console, "Failed to download PDF.", style=f"bold {colors['error']}", duration=0.5)
478
+ return
479
+
480
+ left_to_right_reveal(console, "Extracting text...", style=f"bold {colors['primary']}", duration=0.5)
481
+ from ...services.unified_pdf_service import pdf_processor
482
+ text_content = await pdf_processor.extract_text(pdf_path)
483
+
484
+ if not text_content:
485
+ left_to_right_reveal(console, "Failed to extract text from PDF.", style=f"bold {colors['error']}", duration=0.5)
486
+ return
487
+
488
+ paper_info = {
489
+ 'arxiv_id': paper_id,
490
+ 'title': paper_metadata.get('title', paper_title),
491
+ 'authors': paper_metadata.get('authors', []),
492
+ 'abstract': paper_metadata.get('summary', paper_metadata.get('abstract', '')),
493
+ 'published': paper_metadata.get('published', ''),
494
+ 'full_text': text_content
495
+ }
496
+
497
+ await rag_chat_system.continue_chat_session(session, paper_info)
498
+
499
+
500
+ async def _fetch_paper_by_id(console: Console, colors: Dict, arxiv_id: str) -> Optional[Dict[str, Any]]:
501
+ """Fetch paper metadata by arXiv ID"""
502
+
503
+ left_to_right_reveal(console, f"\nFetching paper {arxiv_id}...", style=f"bold {colors['primary']}", duration=0.5)
504
+
505
+ paper_metadata = await asyncio.to_thread(arxiv_client.get_paper_by_id, arxiv_id)
506
+
507
+ if not paper_metadata:
508
+ left_to_right_reveal(console, f"Failed to fetch paper {arxiv_id} from ArXiv", style=f"bold {colors['error']}", duration=0.5)
509
+ return None
510
+
511
+ return paper_metadata
512
+
513
+
514
+ async def _start_chat_with_paper(console: Console, colors: Dict, user_name: str, paper: Dict[str, Any]):
515
+ """Start chat session with selected paper
516
+
517
+ Flow:
518
+ 1. Check if embeddings are cached in DB (24hr TTL)
519
+ 2. If cached: load from DB, skip PDF download
520
+ 3. If not cached: download PDF, extract text, compute embeddings
521
+ """
522
+
523
+ raw_arxiv_id = paper.get('arxiv_id') or paper.get('id', '')
524
+ arxiv_id = ArxivUtils.normalize_arxiv_id(raw_arxiv_id)
525
+ title = paper.get('title', arxiv_id)
526
+
527
+ left_to_right_reveal(console, f"\nSelected: {title}", style=f"bold {colors['primary']}", duration=0.5)
528
+
529
+ # Check for cached embeddings via API first
530
+ cached_data = None
531
+ try:
532
+ result = await api_client.get_embeddings(arxiv_id)
533
+ if result.get("success") and result.get("embeddings"):
534
+ cached_data = {
535
+ "embeddings": result.get("embeddings", []),
536
+ "chunks": result.get("chunks", [])
537
+ }
538
+ left_to_right_reveal(console, f"Loading cached embeddings ({len(cached_data['chunks'])} chunks)...", style=f"bold {colors['primary']}", duration=0.5)
539
+ except Exception as e:
540
+ logger.debug(f"Could not check cached embeddings: {e}")
541
+
542
+ text_content = None
543
+ if not cached_data or not cached_data.get("embeddings"):
544
+ # No cache - need to download and process PDF
545
+ pdf_url = paper.get('pdf_url')
546
+ if not pdf_url:
547
+ # Saved papers may not have pdf_url - fetch from arXiv
548
+ left_to_right_reveal(console, "Fetching paper info from arXiv...", style=f"bold {colors['primary']}", duration=0.5)
549
+ arxiv_paper = arxiv_client.get_paper_by_id(arxiv_id)
550
+ if arxiv_paper:
551
+ pdf_url = arxiv_paper.get('pdf_url')
552
+ paper.update({
553
+ 'pdf_url': pdf_url,
554
+ 'summary': arxiv_paper.get('summary', paper.get('abstract', '')),
555
+ 'authors': arxiv_paper.get('authors', paper.get('authors', []))
556
+ })
557
+
558
+ if not pdf_url:
559
+ left_to_right_reveal(console, "Could not find PDF URL for this paper", style=f"bold {colors['error']}", duration=0.5)
560
+ return
561
+
562
+ left_to_right_reveal(console, "Downloading PDF...", style=f"bold {colors['primary']}", duration=0.5)
563
+ pdf_path = await asyncio.to_thread(arxiv_fetcher.fetch_paper_sync, arxiv_id, pdf_url)
564
+
565
+ if not pdf_path:
566
+ left_to_right_reveal(console, "Failed to download PDF", style=f"bold {colors['error']}", duration=0.5)
567
+ return
568
+
569
+ left_to_right_reveal(console, "Extracting text...", style=f"bold {colors['primary']}", duration=0.5)
570
+ from ...services.unified_pdf_service import pdf_processor
571
+ text_content = await pdf_processor.extract_text(pdf_path)
572
+
573
+ if not text_content:
574
+ left_to_right_reveal(console, "Failed to extract text from PDF", style=f"bold {colors['error']}", duration=0.5)
575
+ return
576
+
577
+ paper_info = {
578
+ 'arxiv_id': arxiv_id,
579
+ 'title': paper.get('title', arxiv_id),
580
+ 'authors': paper.get('authors', []),
581
+ 'abstract': paper.get('summary', paper.get('abstract', '')),
582
+ 'published': paper.get('published', ''),
583
+ 'full_text': text_content or '', # Empty if using cached embeddings
584
+ '_cached_embeddings': cached_data.get('embeddings') if cached_data else None, # Pass cached embeddings to RAG
585
+ '_cached_chunks': cached_data.get('chunks') if cached_data else None # Pass cached chunks to RAG
586
+ }
587
+
588
+ left_to_right_reveal(console, "\nStarting chat session...\n", style=f"bold {colors['primary']}", duration=0.5)
589
+ await rag_chat_system.start_chat_session([paper_info], user_id=user_name)
590
+
591
+ await _offer_save_paper(console, colors, arxiv_id, title)
592
+ show_command_suggestions(console, context='chat')
593
+
594
+
595
+ async def _offer_save_paper(console: Console, colors: Dict, arxiv_id: str, title: str):
596
+ """Offer to save paper to user's library after chat"""
597
+
598
+ try:
599
+ # Check if already in library
600
+ library_result = await api_client.get_library(limit=100)
601
+ if library_result.get("success"):
602
+ papers = library_result.get("papers", [])
603
+ if any(p.get('arxiv_id') == arxiv_id for p in papers):
604
+ return # Already saved
605
+
606
+ if len(papers) >= MAX_USER_PAPERS:
607
+ left_to_right_reveal(console, f"\nYou have reached the maximum of {MAX_USER_PAPERS} saved papers.", style=f"bold {colors['warning']}", duration=0.5)
608
+ left_to_right_reveal(console, "Use 'arionxiv settings' to manage your saved papers.", style=f"bold {colors['primary']}", duration=0.5)
609
+ return
610
+
611
+ save_choice = Prompt.ask(
612
+ f"\n[bold {colors['primary']}]Save this paper to your library for quick access? (y/n)[/bold {colors['primary']}]",
613
+ choices=["y", "n"],
614
+ default="y"
615
+ )
616
+
617
+ if save_choice == "y":
618
+ from ...arxiv_operations.client import arxiv_client as arxiv_client_local
619
+ paper_metadata = arxiv_client_local.get_paper_by_id(arxiv_id) or {}
620
+
621
+ result = await api_client.add_to_library(
622
+ arxiv_id=arxiv_id,
623
+ title=title or paper_metadata.get('title', ''),
624
+ authors=paper_metadata.get('authors', []),
625
+ categories=paper_metadata.get('categories', []),
626
+ abstract=paper_metadata.get('summary', '')
627
+ )
628
+
629
+ if result.get("success"):
630
+ left_to_right_reveal(console, "Paper saved to your library!", style=f"bold {colors['primary']}", duration=0.5)
631
+ else:
632
+ left_to_right_reveal(console, "Could not save paper at this time.", style=f"bold {colors['warning']}", duration=0.5)
633
+
634
+ except APIClientError as e:
635
+ logger.debug(f"Error offering to save paper: {e.message}")
636
+ except Exception as e:
637
+ logger.debug(f"Error offering to save paper: {e}")
638
+
639
+
640
+ async def delete_user_papers_menu(console: Console, colors: Dict, user_name: str):
641
+ """Show menu to delete saved papers - called from settings"""
642
+
643
+ try:
644
+ result = await api_client.get_library(limit=100)
645
+ if not result.get("success"):
646
+ console.print(f"\n[bold {colors['error']}]Failed to fetch library.[/bold {colors['error']}]")
647
+ return
648
+
649
+ user_papers = result.get("papers", [])
650
+ except APIClientError as e:
651
+ console.print(f"\n[bold {colors['error']}]Failed to fetch library: {e.message}[/bold {colors['error']}]")
652
+ return
653
+ except Exception as e:
654
+ console.print(f"\n[bold {colors['error']}]Failed to fetch library: {e}[/bold {colors['error']}]")
655
+ return
656
+
657
+ if not user_papers:
658
+ console.print(f"\n[bold {colors['warning']}]No saved papers to delete.[/bold {colors['warning']}]")
659
+ return
660
+
661
+ console.print(f"\n[bold {colors['primary']}]Your saved papers:[/bold {colors['primary']}]\n")
662
+
663
+ table = create_themed_table("Saved Papers")
664
+ table.add_column("#", style="bold white", width=3)
665
+ table.add_column("Title", style="white", max_width=50)
666
+ table.add_column("ArXiv ID", style="white", width=15)
667
+
668
+ for i, paper in enumerate(user_papers):
669
+ title = paper.get("title", "Unknown")
670
+ arxiv_id = paper.get("arxiv_id", "Unknown")
671
+ table.add_row(str(i + 1), title, arxiv_id)
672
+
673
+ console.print(table)
674
+
675
+ console.print(f"\n[bold {colors['primary']}]Enter paper numbers to delete (comma-separated, e.g., 1,3,5) or 0 to cancel:[/bold {colors['primary']}]")
676
+
677
+ choice = Prompt.ask(f"[bold {colors['primary']}]Papers to delete[/bold {colors['primary']}]")
678
+
679
+ if choice.strip() == "0" or not choice.strip():
680
+ console.print(f"[bold {colors['primary']}]Cancelled.[/bold {colors['primary']}]")
681
+ return
682
+
683
+ try:
684
+ indices = [int(x.strip()) - 1 for x in choice.split(",")]
685
+ valid_indices = [i for i in indices if 0 <= i < len(user_papers)]
686
+
687
+ if not valid_indices:
688
+ console.print(f"[bold {colors['error']}]No valid selections.[/bold {colors['error']}]")
689
+ return
690
+
691
+ deleted_count = 0
692
+ for idx in valid_indices:
693
+ paper = user_papers[idx]
694
+ arxiv_id = paper.get("arxiv_id")
695
+ if arxiv_id:
696
+ try:
697
+ result = await api_client.remove_from_library(arxiv_id)
698
+ if result.get("success"):
699
+ deleted_count += 1
700
+ except APIClientError as e:
701
+ logger.error(f"Failed to remove paper from library: {e.message}", exc_info=True)
702
+ console.print(f"[bold {colors['error']}]Failed to remove paper from library.[/bold {colors['error']}]")
703
+ except Exception as e:
704
+ logger.error(f"Failed to remove paper from library: {e}", exc_info=True)
705
+ console.print(f"[bold {colors['error']}]Failed to remove paper from library.[/bold {colors['error']}]")
706
+
707
+ console.print(f"\n[bold {colors['primary']}]Deleted {deleted_count} paper(s) from your library.[/bold {colors['primary']}]")
708
+
709
+ except ValueError:
710
+ console.print(f"[bold {colors['error']}]Invalid input. Use comma-separated numbers.[/bold {colors['error']}]")
711
+
712
+
713
+ if __name__ == "__main__":
714
+ chat_command()