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,254 @@
|
|
|
1
|
+
"""Enhanced Configuration Manager with Database Integration"""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import json
|
|
5
|
+
import asyncio
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Dict, Any, Optional
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
# Import from parent directory
|
|
11
|
+
import sys
|
|
12
|
+
backend_path = Path(__file__).parent.parent.parent
|
|
13
|
+
sys.path.insert(0, str(backend_path))
|
|
14
|
+
|
|
15
|
+
from ..ui.global_theme_manager import global_theme_manager
|
|
16
|
+
from ...services.unified_user_service import unified_user_service
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
class DatabaseConfigManager:
|
|
21
|
+
"""Manages CLI configuration with database integration for authenticated users"""
|
|
22
|
+
|
|
23
|
+
def __init__(self):
|
|
24
|
+
self.config_dir = Path.home() / ".arionxiv"
|
|
25
|
+
self.local_config_file = self.config_dir / "local_config.json"
|
|
26
|
+
self.config = {}
|
|
27
|
+
self._initialized = False
|
|
28
|
+
|
|
29
|
+
# Ensure directories exist
|
|
30
|
+
self.config_dir.mkdir(exist_ok=True)
|
|
31
|
+
|
|
32
|
+
def _ensure_initialized(self):
|
|
33
|
+
"""Ensure basic initialization without async calls"""
|
|
34
|
+
if not self._initialized:
|
|
35
|
+
try:
|
|
36
|
+
self.config = self._load_local_config()
|
|
37
|
+
self._initialized = True
|
|
38
|
+
except Exception:
|
|
39
|
+
self.config = self.get_default_config()
|
|
40
|
+
self._initialized = True
|
|
41
|
+
|
|
42
|
+
async def load_config(self, quiet: bool = True) -> Dict[str, Any]:
|
|
43
|
+
"""Load configuration (from database if authenticated, local otherwise)"""
|
|
44
|
+
try:
|
|
45
|
+
# Check if user is authenticated
|
|
46
|
+
if unified_user_service.is_authenticated():
|
|
47
|
+
return await self._load_from_database(quiet=quiet)
|
|
48
|
+
else:
|
|
49
|
+
return self._load_local_config(quiet=quiet)
|
|
50
|
+
except Exception:
|
|
51
|
+
# Silently fall back to defaults - no need to spam console
|
|
52
|
+
return self.get_default_config()
|
|
53
|
+
|
|
54
|
+
async def _load_from_database(self, quiet: bool = True) -> Dict[str, Any]:
|
|
55
|
+
"""Load configuration - uses local config (hosted API handles cloud data)"""
|
|
56
|
+
# With hosted Vercel API, we don't need local MongoDB
|
|
57
|
+
# Settings are synced via API, local config is used for CLI preferences
|
|
58
|
+
return self._load_local_config(quiet=quiet)
|
|
59
|
+
|
|
60
|
+
def _load_local_config(self, quiet: bool = True) -> Dict[str, Any]:
|
|
61
|
+
"""Load configuration from local file"""
|
|
62
|
+
try:
|
|
63
|
+
if self.local_config_file.exists():
|
|
64
|
+
with open(self.local_config_file, 'r') as f:
|
|
65
|
+
self.config = json.load(f)
|
|
66
|
+
else:
|
|
67
|
+
self.config = self.get_default_config()
|
|
68
|
+
self._save_local_config()
|
|
69
|
+
|
|
70
|
+
self.config["database_mode"] = False
|
|
71
|
+
return self.config
|
|
72
|
+
|
|
73
|
+
except Exception:
|
|
74
|
+
# Silently fall back to defaults
|
|
75
|
+
self.config = self.get_default_config()
|
|
76
|
+
self.config["database_mode"] = False
|
|
77
|
+
return self.config
|
|
78
|
+
|
|
79
|
+
def _save_local_config(self) -> bool:
|
|
80
|
+
"""Save configuration to local file"""
|
|
81
|
+
try:
|
|
82
|
+
with open(self.local_config_file, 'w') as f:
|
|
83
|
+
json.dump(self.config, f, indent=2)
|
|
84
|
+
return True
|
|
85
|
+
except Exception as e:
|
|
86
|
+
console.print(f"[red]Error saving local config: {e}[/red]")
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
async def save_config(self) -> bool:
|
|
90
|
+
"""Save configuration (to database if authenticated, local otherwise)"""
|
|
91
|
+
try:
|
|
92
|
+
if unified_user_service.is_authenticated():
|
|
93
|
+
return await self._save_to_database()
|
|
94
|
+
else:
|
|
95
|
+
return self._save_local_config()
|
|
96
|
+
except Exception as e:
|
|
97
|
+
console.print(f"[red]Error saving config: {e}[/red]")
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
async def _save_to_database(self) -> bool:
|
|
101
|
+
"""Save configuration locally (hosted API handles cloud sync separately)"""
|
|
102
|
+
# With hosted Vercel API, we save locally and API syncs settings
|
|
103
|
+
return self._save_local_config()
|
|
104
|
+
|
|
105
|
+
def get_default_config(self) -> Dict[str, Any]:
|
|
106
|
+
"""Get default configuration"""
|
|
107
|
+
return {
|
|
108
|
+
"user": {
|
|
109
|
+
"id": "",
|
|
110
|
+
"user_name": "",
|
|
111
|
+
"email": "",
|
|
112
|
+
"full_name": "",
|
|
113
|
+
"preferences": {
|
|
114
|
+
"categories": ["cs.AI", "cs.LG", "cs.CL"],
|
|
115
|
+
"keywords": [],
|
|
116
|
+
"max_daily_papers": 10,
|
|
117
|
+
"analysis_depth": "standard",
|
|
118
|
+
"auto_download": False,
|
|
119
|
+
"email_notifications": False
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
"display": {
|
|
123
|
+
"theme": "auto",
|
|
124
|
+
"theme_color": "blue",
|
|
125
|
+
"table_style": "grid",
|
|
126
|
+
"show_abstracts": True,
|
|
127
|
+
"max_abstract_length": 200,
|
|
128
|
+
"papers_per_page": 10
|
|
129
|
+
},
|
|
130
|
+
"paths": {
|
|
131
|
+
"downloads": str(self.config_dir / "downloads"),
|
|
132
|
+
"data": str(self.config_dir / "data"),
|
|
133
|
+
"cache": str(self.config_dir / "data" / "cache")
|
|
134
|
+
},
|
|
135
|
+
"first_time_user": True,
|
|
136
|
+
"database_mode": False
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
140
|
+
"""Get configuration value using dot notation"""
|
|
141
|
+
self._ensure_initialized()
|
|
142
|
+
keys = key.split('.')
|
|
143
|
+
value = self.config
|
|
144
|
+
for k in keys:
|
|
145
|
+
if isinstance(value, dict) and k in value:
|
|
146
|
+
value = value[k]
|
|
147
|
+
else:
|
|
148
|
+
return default
|
|
149
|
+
return value
|
|
150
|
+
|
|
151
|
+
async def set(self, key: str, value: Any) -> bool:
|
|
152
|
+
"""Set configuration value using dot notation"""
|
|
153
|
+
keys = key.split('.')
|
|
154
|
+
config = self.config
|
|
155
|
+
for k in keys[:-1]:
|
|
156
|
+
if k not in config:
|
|
157
|
+
config[k] = {}
|
|
158
|
+
config = config[k]
|
|
159
|
+
config[keys[-1]] = value
|
|
160
|
+
return await self.save_config()
|
|
161
|
+
|
|
162
|
+
async def update_user_preferences(self, preferences: Dict[str, Any]) -> bool:
|
|
163
|
+
"""Update user preferences"""
|
|
164
|
+
if "user" not in self.config:
|
|
165
|
+
self.config["user"] = {}
|
|
166
|
+
if "preferences" not in self.config["user"]:
|
|
167
|
+
self.config["user"]["preferences"] = {}
|
|
168
|
+
|
|
169
|
+
self.config["user"]["preferences"].update(preferences)
|
|
170
|
+
return await self.save_config()
|
|
171
|
+
|
|
172
|
+
def is_theme_configured(self) -> bool:
|
|
173
|
+
"""Check if theme color has been configured by user"""
|
|
174
|
+
# If authenticated, theme is always considered configured
|
|
175
|
+
if unified_user_service.is_authenticated():
|
|
176
|
+
return True
|
|
177
|
+
return self.get("display.theme_color_configured", False)
|
|
178
|
+
|
|
179
|
+
async def set_theme_color(self, color: str) -> bool:
|
|
180
|
+
"""Set the theme color and mark as configured"""
|
|
181
|
+
success = await self.set("display.theme_color", color)
|
|
182
|
+
if success:
|
|
183
|
+
# Update global theme manager
|
|
184
|
+
try:
|
|
185
|
+
global_theme_manager.set_theme(color)
|
|
186
|
+
except Exception:
|
|
187
|
+
pass # Silently ignore if theme manager not available
|
|
188
|
+
|
|
189
|
+
if not unified_user_service.is_authenticated():
|
|
190
|
+
# Only set configured flag for local config
|
|
191
|
+
success = await self.set("display.theme_color_configured", True)
|
|
192
|
+
return success
|
|
193
|
+
|
|
194
|
+
def get_theme_color(self) -> str:
|
|
195
|
+
"""Get the current theme color"""
|
|
196
|
+
self._ensure_initialized()
|
|
197
|
+
color = self.get("display.theme_color", "blue")
|
|
198
|
+
|
|
199
|
+
# Sync with global theme manager
|
|
200
|
+
try:
|
|
201
|
+
current = global_theme_manager.get_current_theme()
|
|
202
|
+
if current != color:
|
|
203
|
+
global_theme_manager.set_theme(color)
|
|
204
|
+
except Exception:
|
|
205
|
+
pass # Silently ignore if theme manager not available
|
|
206
|
+
|
|
207
|
+
return color
|
|
208
|
+
|
|
209
|
+
def is_authenticated(self) -> bool:
|
|
210
|
+
"""Check if user is authenticated"""
|
|
211
|
+
try:
|
|
212
|
+
return unified_user_service.is_authenticated()
|
|
213
|
+
except Exception:
|
|
214
|
+
return False
|
|
215
|
+
|
|
216
|
+
def get_current_user(self) -> Optional[Dict[str, Any]]:
|
|
217
|
+
"""Get current authenticated user"""
|
|
218
|
+
try:
|
|
219
|
+
return unified_user_service.get_current_user()
|
|
220
|
+
except Exception:
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
def is_database_mode(self) -> bool:
|
|
224
|
+
"""Check if running in database mode"""
|
|
225
|
+
self._ensure_initialized()
|
|
226
|
+
return self.get("database_mode", False)
|
|
227
|
+
|
|
228
|
+
async def reset_config(self) -> bool:
|
|
229
|
+
"""Reset configuration to defaults"""
|
|
230
|
+
# Reset local config (hosted API handles cloud settings separately)
|
|
231
|
+
self.config = self.get_default_config()
|
|
232
|
+
return self._save_local_config()
|
|
233
|
+
|
|
234
|
+
# Global configuration manager instance
|
|
235
|
+
db_config_manager = DatabaseConfigManager()
|
|
236
|
+
|
|
237
|
+
# For backward compatibility
|
|
238
|
+
def DbConfigManager():
|
|
239
|
+
"""Factory function to get config manager"""
|
|
240
|
+
return db_config_manager
|
|
241
|
+
|
|
242
|
+
# Async wrapper for backward compatibility
|
|
243
|
+
def load_config_sync():
|
|
244
|
+
"""Synchronous wrapper for loading config"""
|
|
245
|
+
try:
|
|
246
|
+
loop = asyncio.get_event_loop()
|
|
247
|
+
return loop.run_until_complete(db_config_manager.load_config())
|
|
248
|
+
except:
|
|
249
|
+
return db_config_manager.get_default_config()
|
|
250
|
+
|
|
251
|
+
# Helper function for synchronous access to default config
|
|
252
|
+
def get_default_config():
|
|
253
|
+
"""Get default config synchronously"""
|
|
254
|
+
return db_config_manager.get_default_config()
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GitHub Actions Runner for ArionXiv Daily Dose
|
|
3
|
+
|
|
4
|
+
This module is executed by GitHub Actions to run daily dose for users
|
|
5
|
+
who have their scheduled time matching the current hour (UTC).
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
python -m arionxiv.github_actions_runner
|
|
9
|
+
|
|
10
|
+
Environment Variables:
|
|
11
|
+
MONGODB_URI: MongoDB connection string (required)
|
|
12
|
+
OPENROUTER_API_KEY: OpenRouter API key for LLM (required, FREE tier available)
|
|
13
|
+
GEMINI_API_KEY: Gemini API key for embeddings (optional)
|
|
14
|
+
GROQ_API_KEY: Groq API key as fallback LLM (optional)
|
|
15
|
+
FORCE_HOUR: Force run for specific hour (optional, for testing)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from typing import List, Dict, Any
|
|
24
|
+
|
|
25
|
+
# Configure logging for GitHub Actions
|
|
26
|
+
logging.basicConfig(
|
|
27
|
+
level=logging.INFO,
|
|
28
|
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
|
29
|
+
handlers=[logging.StreamHandler(sys.stdout)]
|
|
30
|
+
)
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def get_users_for_hour(hour: int) -> List[Dict[str, Any]]:
|
|
35
|
+
"""
|
|
36
|
+
Get all users who have daily dose enabled and scheduled for the given hour.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
hour: Hour in 24-hour format (0-23)
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
List of user documents matching the scheduled hour
|
|
43
|
+
"""
|
|
44
|
+
from .services.unified_database_service import unified_database_service
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
# Connect to MongoDB
|
|
48
|
+
await unified_database_service.connect_mongodb()
|
|
49
|
+
|
|
50
|
+
# Query users with daily dose enabled and matching hour
|
|
51
|
+
# Time format is "HH:MM", so we match the hour part
|
|
52
|
+
hour_prefix = f"{hour:02d}:"
|
|
53
|
+
|
|
54
|
+
# Access users collection directly via db attribute
|
|
55
|
+
users_collection = unified_database_service.db.users
|
|
56
|
+
|
|
57
|
+
# Find users where:
|
|
58
|
+
# 1. Daily dose is enabled
|
|
59
|
+
# 2. Scheduled time starts with the current hour
|
|
60
|
+
query = {
|
|
61
|
+
"$or": [
|
|
62
|
+
# Vercel API format: settings.daily_dose.enabled and settings.daily_dose.scheduled_time
|
|
63
|
+
{
|
|
64
|
+
"settings.daily_dose.enabled": True,
|
|
65
|
+
"settings.daily_dose.scheduled_time": {"$regex": f"^{hour_prefix}"}
|
|
66
|
+
},
|
|
67
|
+
# New format: preferences.daily_dose.enabled and preferences.daily_dose.scheduled_time
|
|
68
|
+
{
|
|
69
|
+
"preferences.daily_dose.enabled": True,
|
|
70
|
+
"preferences.daily_dose.scheduled_time": {"$regex": f"^{hour_prefix}"}
|
|
71
|
+
},
|
|
72
|
+
# Legacy format: preferences.daily_dose_enabled and preferences.daily_dose_time
|
|
73
|
+
{
|
|
74
|
+
"preferences.daily_dose_enabled": True,
|
|
75
|
+
"preferences.daily_dose_time": {"$regex": f"^{hour_prefix}"}
|
|
76
|
+
}
|
|
77
|
+
]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
cursor = users_collection.find(query)
|
|
81
|
+
users = await cursor.to_list(length=None)
|
|
82
|
+
|
|
83
|
+
logger.info(f"Found {len(users)} users scheduled for hour {hour:02d}:00 UTC")
|
|
84
|
+
return users
|
|
85
|
+
|
|
86
|
+
except Exception as e:
|
|
87
|
+
logger.error(f"Error fetching users for hour {hour}: {e}")
|
|
88
|
+
return []
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def run_daily_dose_for_user(user_id: str, user_email: str) -> bool:
|
|
92
|
+
"""
|
|
93
|
+
Run daily dose for a specific user.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
user_id: MongoDB user ID
|
|
97
|
+
user_email: User email for logging
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
True if successful, False otherwise
|
|
101
|
+
"""
|
|
102
|
+
from .services.unified_daily_dose_service import unified_daily_dose_service
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
logger.info(f"Running daily dose for user: {user_email}")
|
|
106
|
+
|
|
107
|
+
# execute_daily_dose is the correct method name
|
|
108
|
+
result = await unified_daily_dose_service.execute_daily_dose(user_id)
|
|
109
|
+
|
|
110
|
+
if result.get("success"):
|
|
111
|
+
papers_count = result.get("papers_count", 0)
|
|
112
|
+
logger.info(f"Daily dose completed for {user_email}: {papers_count} papers analyzed")
|
|
113
|
+
return True
|
|
114
|
+
else:
|
|
115
|
+
error = result.get("message", "Unknown error")
|
|
116
|
+
logger.error(f"Daily dose failed for {user_email}: {error}")
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
except Exception as e:
|
|
120
|
+
logger.error(f"Exception running daily dose for {user_email}: {e}")
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
async def main():
|
|
125
|
+
"""Main entry point for GitHub Actions runner."""
|
|
126
|
+
from .services.unified_database_service import unified_database_service
|
|
127
|
+
|
|
128
|
+
logger.info("=" * 60)
|
|
129
|
+
logger.info("ArionXiv Daily Dose - GitHub Actions Runner")
|
|
130
|
+
logger.info("=" * 60)
|
|
131
|
+
|
|
132
|
+
# Check required environment variables
|
|
133
|
+
# OpenRouter is the primary LLM provider (FREE tier available)
|
|
134
|
+
required_vars = ["MONGODB_URI", "OPENROUTER_API_KEY"]
|
|
135
|
+
missing_vars = [var for var in required_vars if not os.environ.get(var)]
|
|
136
|
+
|
|
137
|
+
if missing_vars:
|
|
138
|
+
logger.error(f"Missing required environment variables: {', '.join(missing_vars)}")
|
|
139
|
+
logger.error("Please add these as GitHub Actions secrets.")
|
|
140
|
+
logger.error("Get a FREE OpenRouter API key at: https://openrouter.ai/")
|
|
141
|
+
sys.exit(1)
|
|
142
|
+
|
|
143
|
+
# Determine the hour to process
|
|
144
|
+
force_hour = os.environ.get("FORCE_HOUR", "").strip()
|
|
145
|
+
|
|
146
|
+
if force_hour:
|
|
147
|
+
try:
|
|
148
|
+
current_hour = int(force_hour)
|
|
149
|
+
if not 0 <= current_hour <= 23:
|
|
150
|
+
raise ValueError("Hour must be between 0 and 23")
|
|
151
|
+
logger.info(f"Using forced hour: {current_hour:02d}:00 UTC")
|
|
152
|
+
except ValueError as e:
|
|
153
|
+
logger.error(f"Invalid FORCE_HOUR value '{force_hour}': {e}")
|
|
154
|
+
sys.exit(1)
|
|
155
|
+
else:
|
|
156
|
+
current_hour = datetime.now(timezone.utc).hour
|
|
157
|
+
logger.info(f"Current time: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')} UTC")
|
|
158
|
+
logger.info(f"Processing hour: {current_hour:02d}:00 UTC")
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
# Get users scheduled for this hour
|
|
162
|
+
users = await get_users_for_hour(current_hour)
|
|
163
|
+
|
|
164
|
+
if not users:
|
|
165
|
+
logger.info(f"No users scheduled for {current_hour:02d}:00 UTC. Exiting.")
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
# Process each user
|
|
169
|
+
success_count = 0
|
|
170
|
+
failure_count = 0
|
|
171
|
+
|
|
172
|
+
for user in users:
|
|
173
|
+
user_id = str(user["_id"])
|
|
174
|
+
user_email = user.get("email", "unknown")
|
|
175
|
+
|
|
176
|
+
if await run_daily_dose_for_user(user_id, user_email):
|
|
177
|
+
success_count += 1
|
|
178
|
+
else:
|
|
179
|
+
failure_count += 1
|
|
180
|
+
|
|
181
|
+
# Small delay between users to avoid rate limiting
|
|
182
|
+
await asyncio.sleep(2)
|
|
183
|
+
|
|
184
|
+
# Summary
|
|
185
|
+
logger.info("=" * 60)
|
|
186
|
+
logger.info("Daily Dose Run Complete")
|
|
187
|
+
logger.info(f" Successful: {success_count}")
|
|
188
|
+
logger.info(f" Failed: {failure_count}")
|
|
189
|
+
logger.info(f" Total: {len(users)}")
|
|
190
|
+
logger.info("=" * 60)
|
|
191
|
+
|
|
192
|
+
# Exit with error if any failures
|
|
193
|
+
if failure_count > 0:
|
|
194
|
+
sys.exit(1)
|
|
195
|
+
|
|
196
|
+
finally:
|
|
197
|
+
# Always cleanup database connection
|
|
198
|
+
try:
|
|
199
|
+
await unified_database_service.disconnect()
|
|
200
|
+
logger.info("Database connection closed")
|
|
201
|
+
except Exception as e:
|
|
202
|
+
logger.warning(f"Error closing database connection: {e}")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
if __name__ == "__main__":
|
|
206
|
+
asyncio.run(main())
|
arionxiv/main.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Main CLI entry point for ArionXiv package
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
def main():
|
|
11
|
+
"""Main entry point for the arionxiv CLI command"""
|
|
12
|
+
try:
|
|
13
|
+
from .cli.main import cli
|
|
14
|
+
cli()
|
|
15
|
+
except KeyboardInterrupt:
|
|
16
|
+
logger.info("Operation cancelled by user")
|
|
17
|
+
sys.exit(0)
|
|
18
|
+
except Exception as e:
|
|
19
|
+
logger.error(f"CLI error: {e}", exc_info=True)
|
|
20
|
+
sys.exit(1)
|
|
21
|
+
|
|
22
|
+
if __name__ == "__main__":
|
|
23
|
+
main()
|