cite-agent 1.0.0__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.

Potentially problematic release.


This version of cite-agent might be problematic. Click here for more details.

@@ -0,0 +1,172 @@
1
+ """
2
+ Backend-Only Agent (Distribution Version)
3
+ All LLM queries go through centralized backend API.
4
+ Local API keys are not supported.
5
+ """
6
+
7
+ import os
8
+ import requests
9
+ from typing import Dict, Any, Optional
10
+ from dataclasses import dataclass
11
+ from datetime import datetime, timezone
12
+
13
+ @dataclass
14
+ class ChatRequest:
15
+ question: str
16
+ user_id: str = "default"
17
+ conversation_id: str = "default"
18
+ context: Dict[str, Any] = None
19
+
20
+ @dataclass
21
+ class ChatResponse:
22
+ response: str
23
+ citations: list = None
24
+ tools_used: list = None
25
+ model: str = "backend"
26
+ timestamp: str = None
27
+
28
+ def __post_init__(self):
29
+ if self.timestamp is None:
30
+ self.timestamp = datetime.now(timezone.utc).isoformat()
31
+ if self.citations is None:
32
+ self.citations = []
33
+ if self.tools_used is None:
34
+ self.tools_used = []
35
+
36
+ class EnhancedNocturnalAgent:
37
+ """
38
+ Backend-only agent for distribution.
39
+ Proxies all requests to centralized API.
40
+ """
41
+
42
+ def __init__(self):
43
+ self.backend_url = (
44
+ os.getenv("NOCTURNAL_CONTROL_PLANE_URL")
45
+ or "https://cite-agent-api-720dfadd602c.herokuapp.com"
46
+ )
47
+ self.auth_token = None
48
+ self._load_auth()
49
+
50
+ def _load_auth(self):
51
+ """Load authentication token from config"""
52
+ # Try environment first
53
+ self.auth_token = os.getenv("NOCTURNAL_AUTH_TOKEN")
54
+
55
+ # Try config file
56
+ if not self.auth_token:
57
+ from pathlib import Path
58
+ config_file = Path.home() / ".nocturnal_archive" / "config.env"
59
+ if config_file.exists():
60
+ with open(config_file) as f:
61
+ for line in f:
62
+ if line.startswith("NOCTURNAL_AUTH_TOKEN="):
63
+ self.auth_token = line.split("=", 1)[1].strip()
64
+ break
65
+
66
+ async def initialize(self):
67
+ """Initialize agent"""
68
+ if not self.auth_token:
69
+ raise RuntimeError(
70
+ "Not authenticated. Please run 'cite-agent --setup' first."
71
+ )
72
+ print(f"✅ Connected to backend: {self.backend_url}")
73
+
74
+ async def chat(self, request: ChatRequest) -> ChatResponse:
75
+ """
76
+ Send chat request to backend API.
77
+
78
+ Args:
79
+ request: Chat request with question and context
80
+
81
+ Returns:
82
+ Chat response with answer and citations
83
+
84
+ Raises:
85
+ RuntimeError: If authentication fails or backend unavailable
86
+ """
87
+ if not self.auth_token:
88
+ raise RuntimeError(
89
+ "Not authenticated. Run 'cite-agent --setup' first."
90
+ )
91
+
92
+ try:
93
+ response = requests.post(
94
+ f"{self.backend_url}/api/query",
95
+ headers={
96
+ "Authorization": f"Bearer {self.auth_token}",
97
+ "Content-Type": "application/json"
98
+ },
99
+ json={
100
+ "query": request.question,
101
+ "context": request.context or {},
102
+ "user_id": request.user_id,
103
+ "conversation_id": request.conversation_id,
104
+ },
105
+ timeout=60
106
+ )
107
+
108
+ if response.status_code == 401:
109
+ raise RuntimeError(
110
+ "Authentication expired. Run 'cite-agent --setup' to log in again."
111
+ )
112
+
113
+ if response.status_code == 429:
114
+ raise RuntimeError(
115
+ "Daily quota exceeded (25,000 tokens). Resets tomorrow."
116
+ )
117
+
118
+ if response.status_code >= 400:
119
+ error_detail = response.json().get("detail", response.text)
120
+ raise RuntimeError(f"Backend error: {error_detail}")
121
+
122
+ data = response.json()
123
+
124
+ return ChatResponse(
125
+ response=data.get("response", data.get("answer", "")),
126
+ citations=data.get("citations", []),
127
+ tools_used=data.get("tools_used", []),
128
+ model=data.get("model", "backend"),
129
+ )
130
+
131
+ except requests.RequestException as e:
132
+ raise RuntimeError(
133
+ f"Backend connection failed: {e}. Check your internet connection."
134
+ ) from e
135
+
136
+ async def close(self):
137
+ """Cleanup"""
138
+ pass
139
+
140
+ def get_health_status(self) -> Dict[str, Any]:
141
+ """Get backend health status"""
142
+ try:
143
+ response = requests.get(
144
+ f"{self.backend_url}/api/health/",
145
+ timeout=5
146
+ )
147
+ return response.json()
148
+ except:
149
+ return {"status": "unavailable"}
150
+
151
+ def check_quota(self) -> Dict[str, Any]:
152
+ """Check remaining daily quota"""
153
+ if not self.auth_token:
154
+ raise RuntimeError("Not authenticated")
155
+
156
+ response = requests.get(
157
+ f"{self.backend_url}/api/auth/me",
158
+ headers={"Authorization": f"Bearer {self.auth_token}"},
159
+ timeout=10
160
+ )
161
+
162
+ if response.status_code == 401:
163
+ raise RuntimeError("Authentication expired")
164
+
165
+ response.raise_for_status()
166
+ data = response.json()
167
+
168
+ return {
169
+ "tokens_used": data.get("tokens_used_today", 0),
170
+ "tokens_remaining": data.get("tokens_remaining", 0),
171
+ "daily_limit": 25000,
172
+ }
@@ -0,0 +1,298 @@
1
+ """
2
+ Rate Limiting Configuration based on Groq API limits
3
+ Implements per-user rate limiting with soft degradation
4
+ """
5
+
6
+ from dataclasses import dataclass
7
+ from typing import Dict, Optional
8
+ from datetime import datetime, timedelta
9
+ import json
10
+ from pathlib import Path
11
+
12
+
13
+ @dataclass
14
+ class RateLimitConfig:
15
+ """Rate limit configuration for different tiers"""
16
+ # Groq limits for llama-3.3-70b-versatile (Free tier)
17
+ rpm: int # Requests per minute
18
+ rpd: int # Requests per day
19
+ tpm: int # Tokens per minute
20
+ tpd: int # Tokens per day
21
+
22
+ # Our API limits (on top of Groq)
23
+ archive_api_per_day: int
24
+ finsight_api_per_day: int
25
+ web_search_per_day: int # -1 = unlimited
26
+
27
+
28
+ # Rate limit tiers
29
+ RATE_LIMITS = {
30
+ 'free': RateLimitConfig(
31
+ # Groq limits (llama-3.3-70b-versatile)
32
+ rpm=30,
33
+ rpd=1000,
34
+ tpm=12000,
35
+ tpd=100000,
36
+ # Our limits
37
+ archive_api_per_day=10,
38
+ finsight_api_per_day=20,
39
+ web_search_per_day=-1 # unlimited
40
+ ),
41
+ 'basic': RateLimitConfig(
42
+ # Groq limits (same, user pays for our value-add)
43
+ rpm=30,
44
+ rpd=1000,
45
+ tpm=12000,
46
+ tpd=100000,
47
+ # Our limits (300 NTD/month)
48
+ archive_api_per_day=25,
49
+ finsight_api_per_day=50,
50
+ web_search_per_day=-1 # unlimited
51
+ ),
52
+ 'pro': RateLimitConfig(
53
+ # Groq limits (same)
54
+ rpm=30,
55
+ rpd=1000,
56
+ tpm=12000,
57
+ tpd=100000,
58
+ # Our limits (600 NTD/month)
59
+ archive_api_per_day=-1, # unlimited
60
+ finsight_api_per_day=-1, # unlimited
61
+ web_search_per_day=-1 # unlimited
62
+ )
63
+ }
64
+
65
+
66
+ class RateLimiter:
67
+ """
68
+ Track and enforce rate limits per user
69
+ Implements soft degradation when limits are hit
70
+ """
71
+
72
+ def __init__(self, user_id: str, tier: str = 'basic', storage_dir: Optional[Path] = None):
73
+ self.user_id = user_id
74
+ self.tier = tier
75
+ self.config = RATE_LIMITS.get(tier, RATE_LIMITS['basic'])
76
+
77
+ # Storage for rate limit tracking
78
+ self.storage_dir = storage_dir or Path.home() / ".nocturnal_archive" / "rate_limits"
79
+ self.storage_dir.mkdir(parents=True, exist_ok=True)
80
+ self.storage_file = self.storage_dir / f"{user_id}_limits.json"
81
+
82
+ # Load existing limits
83
+ self.limits = self._load_limits()
84
+
85
+ def _load_limits(self) -> Dict:
86
+ """Load rate limit data from storage"""
87
+ if self.storage_file.exists():
88
+ try:
89
+ with open(self.storage_file, 'r') as f:
90
+ data = json.load(f)
91
+ # Check if data is from today
92
+ if data.get('date') == datetime.now().strftime('%Y-%m-%d'):
93
+ return data
94
+ except Exception:
95
+ pass
96
+
97
+ # Return fresh limits
98
+ return {
99
+ 'date': datetime.now().strftime('%Y-%m-%d'),
100
+ 'groq_requests': 0,
101
+ 'groq_tokens': 0,
102
+ 'archive_api': 0,
103
+ 'finsight_api': 0,
104
+ 'web_search': 0,
105
+ 'last_request_time': None
106
+ }
107
+
108
+ def _save_limits(self):
109
+ """Save rate limit data to storage"""
110
+ try:
111
+ with open(self.storage_file, 'w') as f:
112
+ json.dump(self.limits, f, indent=2)
113
+ except Exception as e:
114
+ print(f"Warning: Could not save rate limits: {e}")
115
+
116
+ def _reset_if_needed(self):
117
+ """Reset limits if it's a new day"""
118
+ current_date = datetime.now().strftime('%Y-%m-%d')
119
+ if self.limits.get('date') != current_date:
120
+ self.limits = {
121
+ 'date': current_date,
122
+ 'groq_requests': 0,
123
+ 'groq_tokens': 0,
124
+ 'archive_api': 0,
125
+ 'finsight_api': 0,
126
+ 'web_search': 0,
127
+ 'last_request_time': None
128
+ }
129
+ self._save_limits()
130
+
131
+ def can_make_request(self, api_name: str = 'groq', tokens: int = 0) -> tuple[bool, Optional[str]]:
132
+ """
133
+ Check if user can make a request
134
+
135
+ Returns:
136
+ (can_proceed, error_message)
137
+ """
138
+ self._reset_if_needed()
139
+
140
+ if api_name == 'groq':
141
+ # Check Groq limits
142
+ if self.limits['groq_requests'] >= self.config.rpd:
143
+ return False, f"Daily Groq request limit reached ({self.config.rpd} requests/day)"
144
+
145
+ if self.limits['groq_tokens'] + tokens > self.config.tpd:
146
+ return False, f"Daily Groq token limit reached ({self.config.tpd} tokens/day)"
147
+
148
+ # Check RPM (requests per minute)
149
+ if self.limits.get('last_request_time'):
150
+ last_time = datetime.fromisoformat(self.limits['last_request_time'])
151
+ if datetime.now() - last_time < timedelta(seconds=2): # Simple RPM approximation
152
+ return False, "Rate limit: Please wait a moment before making another request"
153
+
154
+ elif api_name == 'archive_api':
155
+ if self.config.archive_api_per_day == -1:
156
+ return True, None # Unlimited
157
+ if self.limits['archive_api'] >= self.config.archive_api_per_day:
158
+ return False, f"Daily Archive API limit reached ({self.config.archive_api_per_day} queries/day)"
159
+
160
+ elif api_name == 'finsight_api':
161
+ if self.config.finsight_api_per_day == -1:
162
+ return True, None # Unlimited
163
+ if self.limits['finsight_api'] >= self.config.finsight_api_per_day:
164
+ return False, f"Daily FinSight API limit reached ({self.config.finsight_api_per_day} queries/day)"
165
+
166
+ elif api_name == 'web_search':
167
+ if self.config.web_search_per_day == -1:
168
+ return True, None # Unlimited
169
+ if self.limits['web_search'] >= self.config.web_search_per_day:
170
+ return False, f"Daily web search limit reached"
171
+
172
+ return True, None
173
+
174
+ def record_request(self, api_name: str = 'groq', tokens: int = 0):
175
+ """Record a request"""
176
+ self._reset_if_needed()
177
+
178
+ if api_name == 'groq':
179
+ self.limits['groq_requests'] += 1
180
+ self.limits['groq_tokens'] += tokens
181
+ self.limits['last_request_time'] = datetime.now().isoformat()
182
+ elif api_name == 'archive_api':
183
+ self.limits['archive_api'] += 1
184
+ elif api_name == 'finsight_api':
185
+ self.limits['finsight_api'] += 1
186
+ elif api_name == 'web_search':
187
+ self.limits['web_search'] += 1
188
+
189
+ self._save_limits()
190
+
191
+ def get_remaining(self, api_name: str = 'groq') -> int:
192
+ """Get remaining requests for an API"""
193
+ self._reset_if_needed()
194
+
195
+ if api_name == 'groq':
196
+ return max(0, self.config.rpd - self.limits['groq_requests'])
197
+ elif api_name == 'archive_api':
198
+ if self.config.archive_api_per_day == -1:
199
+ return -1 # Unlimited
200
+ return max(0, self.config.archive_api_per_day - self.limits['archive_api'])
201
+ elif api_name == 'finsight_api':
202
+ if self.config.finsight_api_per_day == -1:
203
+ return -1 # Unlimited
204
+ return max(0, self.config.finsight_api_per_day - self.limits['finsight_api'])
205
+ elif api_name == 'web_search':
206
+ return -1 # Unlimited for now
207
+
208
+ return 0
209
+
210
+ def get_status_message(self) -> str:
211
+ """Get human-readable status of limits"""
212
+ self._reset_if_needed()
213
+
214
+ lines = []
215
+ lines.append(f"**Rate Limit Status** (Tier: {self.tier.upper()})")
216
+ lines.append("")
217
+
218
+ # Groq limits
219
+ groq_remaining = self.get_remaining('groq')
220
+ lines.append(f"• Groq Requests: {self.limits['groq_requests']}/{self.config.rpd} used ({groq_remaining} remaining)")
221
+ lines.append(f"• Groq Tokens: {self.limits['groq_tokens']}/{self.config.tpd} used")
222
+ lines.append("")
223
+
224
+ # API limits
225
+ archive_remaining = self.get_remaining('archive_api')
226
+ if self.config.archive_api_per_day == -1:
227
+ lines.append("• Archive API: Unlimited ✓")
228
+ else:
229
+ lines.append(f"• Archive API: {self.limits['archive_api']}/{self.config.archive_api_per_day} used ({archive_remaining} remaining)")
230
+
231
+ finsight_remaining = self.get_remaining('finsight_api')
232
+ if self.config.finsight_api_per_day == -1:
233
+ lines.append("• FinSight API: Unlimited ✓")
234
+ else:
235
+ lines.append(f"• FinSight API: {self.limits['finsight_api']}/{self.config.finsight_api_per_day} used ({finsight_remaining} remaining)")
236
+
237
+ lines.append("• Web Search: Unlimited ✓")
238
+
239
+ return "\n".join(lines)
240
+
241
+ def get_available_capabilities(self) -> list[str]:
242
+ """Get list of what's still available when rate limited"""
243
+ capabilities = []
244
+
245
+ if self.can_make_request('web_search')[0]:
246
+ capabilities.append("Web searches (unlimited)")
247
+
248
+ if self.can_make_request('archive_api')[0]:
249
+ archive_remaining = self.get_remaining('archive_api')
250
+ if archive_remaining == -1:
251
+ capabilities.append("Academic paper search (unlimited)")
252
+ else:
253
+ capabilities.append(f"Academic paper search ({archive_remaining} remaining today)")
254
+
255
+ if self.can_make_request('finsight_api')[0]:
256
+ finsight_remaining = self.get_remaining('finsight_api')
257
+ if finsight_remaining == -1:
258
+ capabilities.append("Financial data queries (unlimited)")
259
+ else:
260
+ capabilities.append(f"Financial data queries ({finsight_remaining} remaining today)")
261
+
262
+ # Always available
263
+ capabilities.append("Local data analysis (unlimited)")
264
+ capabilities.append("File operations and conversation")
265
+
266
+ return capabilities
267
+
268
+
269
+ # Quick test function
270
+ def test_rate_limiter():
271
+ """Test the rate limiter"""
272
+ limiter = RateLimiter("test_user", "basic")
273
+
274
+ print("Testing Rate Limiter")
275
+ print("=" * 70)
276
+ print(limiter.get_status_message())
277
+ print("\n")
278
+
279
+ # Test making requests
280
+ print("Making 5 Groq requests...")
281
+ for i in range(5):
282
+ can_proceed, error = limiter.can_make_request('groq', tokens=100)
283
+ if can_proceed:
284
+ limiter.record_request('groq', tokens=100)
285
+ print(f" Request {i+1}: ✓")
286
+ else:
287
+ print(f" Request {i+1}: ✗ {error}")
288
+
289
+ print("\n")
290
+ print(limiter.get_status_message())
291
+
292
+ print("\n\nAvailable capabilities:")
293
+ for cap in limiter.get_available_capabilities():
294
+ print(f" • {cap}")
295
+
296
+
297
+ if __name__ == "__main__":
298
+ test_rate_limiter()