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.
- arionxiv/__init__.py +40 -0
- arionxiv/__main__.py +10 -0
- arionxiv/arxiv_operations/__init__.py +0 -0
- arionxiv/arxiv_operations/client.py +225 -0
- arionxiv/arxiv_operations/fetcher.py +173 -0
- arionxiv/arxiv_operations/searcher.py +122 -0
- arionxiv/arxiv_operations/utils.py +293 -0
- arionxiv/cli/__init__.py +4 -0
- arionxiv/cli/commands/__init__.py +1 -0
- arionxiv/cli/commands/analyze.py +587 -0
- arionxiv/cli/commands/auth.py +365 -0
- arionxiv/cli/commands/chat.py +714 -0
- arionxiv/cli/commands/daily.py +482 -0
- arionxiv/cli/commands/fetch.py +217 -0
- arionxiv/cli/commands/library.py +295 -0
- arionxiv/cli/commands/preferences.py +426 -0
- arionxiv/cli/commands/search.py +254 -0
- arionxiv/cli/commands/settings_unified.py +1407 -0
- arionxiv/cli/commands/trending.py +41 -0
- arionxiv/cli/commands/welcome.py +168 -0
- arionxiv/cli/main.py +407 -0
- arionxiv/cli/ui/__init__.py +1 -0
- arionxiv/cli/ui/global_theme_manager.py +173 -0
- arionxiv/cli/ui/logo.py +127 -0
- arionxiv/cli/ui/splash.py +89 -0
- arionxiv/cli/ui/theme.py +32 -0
- arionxiv/cli/ui/theme_system.py +391 -0
- arionxiv/cli/utils/__init__.py +54 -0
- arionxiv/cli/utils/animations.py +522 -0
- arionxiv/cli/utils/api_client.py +583 -0
- arionxiv/cli/utils/api_config.py +505 -0
- arionxiv/cli/utils/command_suggestions.py +147 -0
- arionxiv/cli/utils/db_config_manager.py +254 -0
- arionxiv/github_actions_runner.py +206 -0
- arionxiv/main.py +23 -0
- arionxiv/prompts/__init__.py +9 -0
- arionxiv/prompts/prompts.py +247 -0
- arionxiv/rag_techniques/__init__.py +8 -0
- arionxiv/rag_techniques/basic_rag.py +1531 -0
- arionxiv/scheduler_daemon.py +139 -0
- arionxiv/server.py +1000 -0
- arionxiv/server_main.py +24 -0
- arionxiv/services/__init__.py +73 -0
- arionxiv/services/llm_client.py +30 -0
- arionxiv/services/llm_inference/__init__.py +58 -0
- arionxiv/services/llm_inference/groq_client.py +469 -0
- arionxiv/services/llm_inference/llm_utils.py +250 -0
- arionxiv/services/llm_inference/openrouter_client.py +564 -0
- arionxiv/services/unified_analysis_service.py +872 -0
- arionxiv/services/unified_auth_service.py +457 -0
- arionxiv/services/unified_config_service.py +456 -0
- arionxiv/services/unified_daily_dose_service.py +823 -0
- arionxiv/services/unified_database_service.py +1633 -0
- arionxiv/services/unified_llm_service.py +366 -0
- arionxiv/services/unified_paper_service.py +604 -0
- arionxiv/services/unified_pdf_service.py +522 -0
- arionxiv/services/unified_prompt_service.py +344 -0
- arionxiv/services/unified_scheduler_service.py +589 -0
- arionxiv/services/unified_user_service.py +954 -0
- arionxiv/utils/__init__.py +51 -0
- arionxiv/utils/api_helpers.py +200 -0
- arionxiv/utils/file_cleanup.py +150 -0
- arionxiv/utils/ip_helper.py +96 -0
- arionxiv-1.0.32.dist-info/METADATA +336 -0
- arionxiv-1.0.32.dist-info/RECORD +69 -0
- arionxiv-1.0.32.dist-info/WHEEL +5 -0
- arionxiv-1.0.32.dist-info/entry_points.txt +4 -0
- arionxiv-1.0.32.dist-info/licenses/LICENSE +21 -0
- 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()
|