adamshafishaik commited on
Commit
353cfb6
Β·
1 Parent(s): ca44fa4

Added Research paper feature, removed python slim in docker, ingested with playwrite

Browse files
Dockerfile CHANGED
@@ -1,9 +1,10 @@
1
- FROM python:3.11-slim
2
 
3
  # Set working directory
4
  WORKDIR /app
5
 
6
  # Install system dependencies
 
7
  RUN apt-get update && apt-get install -y \
8
  gcc \
9
  build-essential \
@@ -14,7 +15,9 @@ RUN apt-get update && apt-get install -y \
14
  COPY requirements.txt .
15
 
16
  # Install Python dependencies
 
17
  RUN pip install --no-cache-dir -r requirements.txt
 
18
 
19
  # Copy application code
20
  COPY app ./app
 
1
+ FROM mcr.microsoft.com/playwright/python:v1.41.0-jammy
2
 
3
  # Set working directory
4
  WORKDIR /app
5
 
6
  # Install system dependencies
7
+ # Playwright image already has browsers, but might need basic build tools for some python packages
8
  RUN apt-get update && apt-get install -y \
9
  gcc \
10
  build-essential \
 
15
  COPY requirements.txt .
16
 
17
  # Install Python dependencies
18
+ # Use --no-cache-dir to keep image small
19
  RUN pip install --no-cache-dir -r requirements.txt
20
+ RUN playwright install chromium
21
 
22
  # Copy application code
23
  COPY app ./app
app/config.py CHANGED
@@ -78,8 +78,9 @@ class Settings(BaseSettings):
78
  APPWRITE_AI_COLLECTION_ID: str ="6985d84600311fce57c2"
79
  APPWRITE_DATA_COLLECTION_ID: str ="69845bcf00095c406439"
80
  APPWRITE_CLOUD_COLLECTION_ID: str ="cloud_articles"
81
- APPWRITE_MAGAZINE_COLLECTION_ID: str ="69845cdd001712f4ac41"
82
- APPWRITE_MEDIUM_COLLECTION_ID: str ="69845cf100332a456f74"
 
83
  # Admin Alerting (Optional - Discord/Slack webhook URL)
84
  ADMIN_WEBHOOK_URL: Optional[str] = None
85
 
 
78
  APPWRITE_AI_COLLECTION_ID: str ="6985d84600311fce57c2"
79
  APPWRITE_DATA_COLLECTION_ID: str ="69845bcf00095c406439"
80
  APPWRITE_CLOUD_COLLECTION_ID: str ="cloud_articles"
81
+ APPWRITE_MAGAZINE_COLLECTION_ID: str = "6798e285002a24aa3d63"
82
+ APPWRITE_MEDIUM_COLLECTION_ID: str = "679a0ec3001889753820"
83
+ APPWRITE_RESEARCH_COLLECTION_ID: str = "research_papers_v2"
84
  # Admin Alerting (Optional - Discord/Slack webhook URL)
85
  ADMIN_WEBHOOK_URL: Optional[str] = None
86
 
app/main.py CHANGED
@@ -1,6 +1,19 @@
 
 
1
  from fastapi import FastAPI
2
  import warnings
3
  from fastapi.middleware.cors import CORSMiddleware
 
 
 
 
 
 
 
 
 
 
 
4
  from contextlib import asynccontextmanager
5
  from app.config import settings
6
  # Suppress Pydantic V2 warnings from LangChain (known upstream issue)
@@ -22,27 +35,31 @@ from app.routes import news, search, analytics, subscription, admin, audio
22
  from app.services.scheduler import start_scheduler, shutdown_scheduler
23
 
24
 
 
 
25
  @asynccontextmanager
26
  async def lifespan(app: FastAPI):
27
  """
28
  Application lifespan manager
29
 
30
  Handles startup and shutdown events for background tasks:
31
- - Startup: Initialize and start APScheduler
32
- - Shutdown: Gracefully stop all background jobs
33
  """
34
- # Startup: Start background scheduler
35
  print("=" * 60)
36
  print("πŸš€ Starting Segmento Pulse Backend...")
37
  start_scheduler()
 
38
  print("=" * 60)
39
 
40
  yield # Application runs here
41
 
42
- # Shutdown: Stop background scheduler
43
  print("=" * 60)
44
  print("πŸ‘‹ Shutting down Segmento Pulse Backend...")
45
  shutdown_scheduler()
 
46
  print("=" * 60)
47
 
48
 
@@ -72,6 +89,10 @@ app.include_router(subscription.router, tags=["Subscription"])
72
  app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
73
  app.include_router(audio.router, prefix="/api/audio", tags=["Audio"])
74
 
 
 
 
 
75
  # Phase 3: Engagement tracking
76
  from app.routes import engagement
77
  app.include_router(engagement.router, prefix="/api/engagement", tags=["Engagement"])
 
1
+ import asyncio
2
+ import sys
3
  from fastapi import FastAPI
4
  import warnings
5
  from fastapi.middleware.cors import CORSMiddleware
6
+
7
+ # Windows-specific fix for Playwright + asyncio subprocesses
8
+ if sys.platform == 'win32':
9
+ asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
10
+
11
+ try:
12
+ import nest_asyncio
13
+ nest_asyncio.apply()
14
+ except ImportError:
15
+ pass
16
+
17
  from contextlib import asynccontextmanager
18
  from app.config import settings
19
  # Suppress Pydantic V2 warnings from LangChain (known upstream issue)
 
35
  from app.services.scheduler import start_scheduler, shutdown_scheduler
36
 
37
 
38
+ from app.services.browser_manager import browser_manager
39
+
40
  @asynccontextmanager
41
  async def lifespan(app: FastAPI):
42
  """
43
  Application lifespan manager
44
 
45
  Handles startup and shutdown events for background tasks:
46
+ - Startup: Initialize and start APScheduler, BrowserManager
47
+ - Shutdown: Gracefully stop all background jobs and BrowserManager
48
  """
49
+ # Startup: Start background scheduler and browser
50
  print("=" * 60)
51
  print("πŸš€ Starting Segmento Pulse Backend...")
52
  start_scheduler()
53
+ await browser_manager.start()
54
  print("=" * 60)
55
 
56
  yield # Application runs here
57
 
58
+ # Shutdown: Stop background scheduler and browser
59
  print("=" * 60)
60
  print("πŸ‘‹ Shutting down Segmento Pulse Backend...")
61
  shutdown_scheduler()
62
+ await browser_manager.shutdown()
63
  print("=" * 60)
64
 
65
 
 
89
  app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
90
  app.include_router(audio.router, prefix="/api/audio", tags=["Audio"])
91
 
92
+ # Phase 6: Research Papers
93
+ from app.routes import research
94
+ app.include_router(research.router, prefix="/api/research", tags=["Research"])
95
+
96
  # Phase 3: Engagement tracking
97
  from app.routes import engagement
98
  app.include_router(engagement.router, prefix="/api/engagement", tags=["Engagement"])
app/models.py CHANGED
@@ -8,8 +8,9 @@ class Article(BaseModel):
8
  model_config = ConfigDict(populate_by_name=True)
9
 
10
  title: str
 
11
  description: Optional[str] = ""
12
- url: HttpUrl
13
  # Direct mapping to DB fields (snake_case)
14
  image_url: Optional[str] = ""
15
  published_at: datetime
 
8
  model_config = ConfigDict(populate_by_name=True)
9
 
10
  title: str
11
+ id: Optional[str] = Field(None, alias="$id") # Appwrite ID
12
  description: Optional[str] = ""
13
+ url: Optional[str] = None # Relaxed validation for compatibility
14
  # Direct mapping to DB fields (snake_case)
15
  image_url: Optional[str] = ""
16
  published_at: datetime
app/routes/audio.py CHANGED
@@ -57,7 +57,7 @@ async def _find_article(appwrite, article_id: str, category: Optional[str] = Non
57
  # Try to find article in target collections
58
  for collection_id in target_collection_ids:
59
  try:
60
- article = appwrite.tablesDB.get_row(
61
  database_id=settings.APPWRITE_DATABASE_ID,
62
  collection_id=collection_id,
63
  document_id=article_id
@@ -154,7 +154,7 @@ async def generate_audio_summary(request: AudioGenerationRequest):
154
  "url_hash": url_hash # Store full hash
155
  }
156
 
157
- appwrite.tablesDB.create_row(
158
  database_id=settings.APPWRITE_DATABASE_ID,
159
  collection_id=target_collection_id,
160
  document_id=article_id,
@@ -162,7 +162,7 @@ async def generate_audio_summary(request: AudioGenerationRequest):
162
  )
163
 
164
  # Fetch it back
165
- article = appwrite.tablesDB.get_row(
166
  database_id=settings.APPWRITE_DATABASE_ID,
167
  collection_id=target_collection_id,
168
  document_id=article_id
@@ -170,6 +170,7 @@ async def generate_audio_summary(request: AudioGenerationRequest):
170
  found_collection_id = target_collection_id
171
  print(f"βœ… Created article in collection: {target_collection_id}")
172
 
 
173
  # 2. Check if audio already exists
174
  if article.get('audio_url'):
175
  return AudioResponse(
@@ -179,8 +180,10 @@ async def generate_audio_summary(request: AudioGenerationRequest):
179
  message="Audio already exists"
180
  )
181
 
 
 
182
  # 3. Prepare text for summary
183
- # FETCH FULL CONTENT using Trafilatura
184
  import trafilatura
185
 
186
  # Determine URL to scrape
@@ -188,9 +191,20 @@ async def generate_audio_summary(request: AudioGenerationRequest):
188
 
189
  # Scrape
190
  print(f"Scraping content from: {target_view_url}")
191
- downloaded = trafilatura.fetch_url(target_view_url)
192
- extracted_text = trafilatura.extract(downloaded) if downloaded else None
193
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  # Fallback to description if scraping fails
195
  if not extracted_text or len(extracted_text) < 100:
196
  print("Scraping failed or content too short, falling back to description")
 
57
  # Try to find article in target collections
58
  for collection_id in target_collection_ids:
59
  try:
60
+ article = await appwrite.tablesDB.get_row(
61
  database_id=settings.APPWRITE_DATABASE_ID,
62
  collection_id=collection_id,
63
  document_id=article_id
 
154
  "url_hash": url_hash # Store full hash
155
  }
156
 
157
+ await appwrite.tablesDB.create_row(
158
  database_id=settings.APPWRITE_DATABASE_ID,
159
  collection_id=target_collection_id,
160
  document_id=article_id,
 
162
  )
163
 
164
  # Fetch it back
165
+ article = await appwrite.tablesDB.get_row(
166
  database_id=settings.APPWRITE_DATABASE_ID,
167
  collection_id=target_collection_id,
168
  document_id=article_id
 
170
  found_collection_id = target_collection_id
171
  print(f"βœ… Created article in collection: {target_collection_id}")
172
 
173
+
174
  # 2. Check if audio already exists
175
  if article.get('audio_url'):
176
  return AudioResponse(
 
180
  message="Audio already exists"
181
  )
182
 
183
+ from app.services.browser_manager import browser_manager
184
+
185
  # 3. Prepare text for summary
186
+ # FETCH FULL CONTENT using Playwright (via BrowserManager) for SPA support
187
  import trafilatura
188
 
189
  # Determine URL to scrape
 
191
 
192
  # Scrape
193
  print(f"Scraping content from: {target_view_url}")
 
 
194
 
195
+ # Use simple try-except loop for robustness, though BrowserManager handles most errors
196
+ extracted_text = None
197
+ try:
198
+ # 1. Fetch raw HTML using Headless Browser
199
+ raw_html = await browser_manager.get_content(target_view_url)
200
+
201
+ # 2. Extract text from HTML
202
+ if raw_html:
203
+ extracted_text = trafilatura.extract(raw_html, include_comments=False)
204
+
205
+ except Exception as e:
206
+ print(f"Scraping error: {e}")
207
+
208
  # Fallback to description if scraping fails
209
  if not extracted_text or len(extracted_text) < 100:
210
  print("Scraping failed or content too short, falling back to description")
app/routes/engagement.py CHANGED
@@ -70,7 +70,8 @@ async def get_article_stats(article_id: str, category: Optional[str] = None):
70
  settings.APPWRITE_AI_COLLECTION_ID,
71
  settings.APPWRITE_DATA_COLLECTION_ID,
72
  settings.APPWRITE_MAGAZINE_COLLECTION_ID,
73
- settings.APPWRITE_MEDIUM_COLLECTION_ID
 
74
  ]
75
 
76
  for cid in fallback_collections:
@@ -82,7 +83,7 @@ async def get_article_stats(article_id: str, category: Optional[str] = None):
82
 
83
  for collection_id in target_collection_ids:
84
  try:
85
- doc = appwrite_db.tablesDB.get_row(
86
  database_id=settings.APPWRITE_DATABASE_ID,
87
  collection_id=collection_id,
88
  document_id=doc_id
@@ -106,7 +107,7 @@ async def get_article_stats(article_id: str, category: Optional[str] = None):
106
  return {
107
  "article_id": doc_id,
108
  "likes": doc.get('likes', 0),
109
- "dislikes": doc.get('dislike', 0),
110
  "views": doc.get('views', 0),
111
  "success": True
112
  }
@@ -144,7 +145,7 @@ async def like_article(article_id: str, request: EngagementRequest = None):
144
 
145
  # 1. Try to get document from the TARGETED collection
146
  try:
147
- doc = appwrite_db.tablesDB.get_row(
148
  database_id=settings.APPWRITE_DATABASE_ID,
149
  collection_id=target_collection_id,
150
  document_id=doc_id
@@ -169,14 +170,14 @@ async def like_article(article_id: str, request: EngagementRequest = None):
169
 
170
  logger.info(f"πŸ“ Creating new article with data: {new_doc}")
171
 
172
- appwrite_db.tablesDB.create_row(
173
  database_id=settings.APPWRITE_DATABASE_ID,
174
  collection_id=target_collection_id,
175
  document_id=doc_id,
176
  data=new_doc
177
  )
178
  # Fetch the newly created doc
179
- doc = appwrite_db.tablesDB.get_row(
180
  database_id=settings.APPWRITE_DATABASE_ID,
181
  collection_id=target_collection_id,
182
  document_id=doc_id
@@ -194,7 +195,7 @@ async def like_article(article_id: str, request: EngagementRequest = None):
194
 
195
  new_likes = current_likes + 1
196
 
197
- updated_doc = appwrite_db.tablesDB.update_row(
198
  database_id=settings.APPWRITE_DATABASE_ID,
199
  collection_id=target_collection_id,
200
  document_id=doc_id,
@@ -234,7 +235,7 @@ async def dislike_article(article_id: str, request: EngagementRequest = None):
234
  target_collection_id = appwrite_db.get_collection_id(request.category)
235
 
236
  try:
237
- doc = appwrite_db.tablesDB.get_row(
238
  database_id=settings.APPWRITE_DATABASE_ID,
239
  collection_id=target_collection_id,
240
  document_id=doc_id
@@ -255,13 +256,13 @@ async def dislike_article(article_id: str, request: EngagementRequest = None):
255
  "views": 0,
256
  "category": request.category or "wildcard"
257
  }
258
- appwrite_db.tablesDB.create_row(
259
  database_id=settings.APPWRITE_DATABASE_ID,
260
  collection_id=target_collection_id,
261
  document_id=doc_id,
262
  data=new_doc
263
  )
264
- doc = appwrite_db.tablesDB.get_row(
265
  database_id=settings.APPWRITE_DATABASE_ID,
266
  collection_id=target_collection_id,
267
  document_id=doc_id
@@ -271,23 +272,33 @@ async def dislike_article(article_id: str, request: EngagementRequest = None):
271
  else:
272
  raise HTTPException(status_code=404, detail=f"Article not found in {target_collection_id}")
273
 
274
- current_dislikes = doc.get('dislike', 0)
275
  if current_dislikes is None: current_dislikes = 0
276
 
277
  new_dislikes = current_dislikes + 1
278
 
279
- updated_doc = appwrite_db.tablesDB.update_row(
 
 
 
 
 
 
 
280
  database_id=settings.APPWRITE_DATABASE_ID,
281
  collection_id=target_collection_id,
282
  document_id=doc_id,
283
- data={"dislike": new_dislikes}
284
  )
285
 
 
 
 
286
  logger.info(f"πŸ‘Ž Article {doc_id[:8]}... disliked (total: {updated_doc['dislike']})")
287
 
288
  return {
289
  "article_id": doc_id,
290
- "dislikes": updated_doc['dislike'],
291
  "success": True
292
  }
293
 
@@ -316,7 +327,7 @@ async def track_view(article_id: str, request: EngagementRequest = None):
316
  target_collection_id = appwrite_db.get_collection_id(request.category)
317
 
318
  try:
319
- doc = appwrite_db.tablesDB.get_row(
320
  database_id=settings.APPWRITE_DATABASE_ID,
321
  collection_id=target_collection_id,
322
  document_id=doc_id
@@ -336,13 +347,13 @@ async def track_view(article_id: str, request: EngagementRequest = None):
336
  "views": 0,
337
  "category": request.category or "wildcard"
338
  }
339
- appwrite_db.tablesDB.create_row(
340
  database_id=settings.APPWRITE_DATABASE_ID,
341
  collection_id=target_collection_id,
342
  document_id=doc_id,
343
  data=new_doc
344
  )
345
- doc = appwrite_db.tablesDB.get_row(
346
  database_id=settings.APPWRITE_DATABASE_ID,
347
  collection_id=target_collection_id,
348
  document_id=doc_id
@@ -359,7 +370,7 @@ async def track_view(article_id: str, request: EngagementRequest = None):
359
 
360
  new_views = current_views + 1
361
 
362
- updated_doc = appwrite_db.tablesDB.update_row(
363
  database_id=settings.APPWRITE_DATABASE_ID,
364
  collection_id=target_collection_id,
365
  document_id=doc_id,
@@ -414,11 +425,11 @@ async def get_trending_articles(
414
  collection_id = settings.APPWRITE_COLLECTION_ID
415
 
416
  # Query articles, sorted by views (descending)
417
- response = appwrite_db.tablesDB.list_rows(
418
  database_id=settings.APPWRITE_DATABASE_ID,
419
  collection_id=collection_id,
420
  queries=[
421
- Query.greater_than('publishedAt', cutoff),
422
  Query.order_desc('views'),
423
  Query.limit(limit)
424
  ]
@@ -431,7 +442,7 @@ async def get_trending_articles(
431
  for article in articles:
432
  views = article.get('views', 0)
433
  likes = article.get('likes', 0)
434
- dislikes = article.get('dislike', 0)
435
  article['engagement_score'] = views + (likes * 5) - (dislikes * 3)
436
 
437
  # Sort by engagement score
@@ -482,7 +493,7 @@ async def get_popular_cloud_articles(provider: Optional[str] = None, limit: int
482
  if provider:
483
  queries.insert(0, Query.equal('provider', provider))
484
 
485
- response = appwrite_db.tablesDB.list_rows(
486
  database_id=settings.APPWRITE_DATABASE_ID,
487
  collection_id=settings.APPWRITE_CLOUD_COLLECTION_ID,
488
  queries=queries
 
70
  settings.APPWRITE_AI_COLLECTION_ID,
71
  settings.APPWRITE_DATA_COLLECTION_ID,
72
  settings.APPWRITE_MAGAZINE_COLLECTION_ID,
73
+ settings.APPWRITE_MEDIUM_COLLECTION_ID,
74
+ settings.APPWRITE_RESEARCH_COLLECTION_ID
75
  ]
76
 
77
  for cid in fallback_collections:
 
83
 
84
  for collection_id in target_collection_ids:
85
  try:
86
+ doc = await appwrite_db.tablesDB.get_row(
87
  database_id=settings.APPWRITE_DATABASE_ID,
88
  collection_id=collection_id,
89
  document_id=doc_id
 
107
  return {
108
  "article_id": doc_id,
109
  "likes": doc.get('likes', 0),
110
+ "dislikes": doc.get('dislikes') or doc.get('dislike', 0),
111
  "views": doc.get('views', 0),
112
  "success": True
113
  }
 
145
 
146
  # 1. Try to get document from the TARGETED collection
147
  try:
148
+ doc = await appwrite_db.tablesDB.get_row(
149
  database_id=settings.APPWRITE_DATABASE_ID,
150
  collection_id=target_collection_id,
151
  document_id=doc_id
 
170
 
171
  logger.info(f"πŸ“ Creating new article with data: {new_doc}")
172
 
173
+ await appwrite_db.tablesDB.create_row(
174
  database_id=settings.APPWRITE_DATABASE_ID,
175
  collection_id=target_collection_id,
176
  document_id=doc_id,
177
  data=new_doc
178
  )
179
  # Fetch the newly created doc
180
+ doc = await appwrite_db.tablesDB.get_row(
181
  database_id=settings.APPWRITE_DATABASE_ID,
182
  collection_id=target_collection_id,
183
  document_id=doc_id
 
195
 
196
  new_likes = current_likes + 1
197
 
198
+ updated_doc = await appwrite_db.tablesDB.update_row(
199
  database_id=settings.APPWRITE_DATABASE_ID,
200
  collection_id=target_collection_id,
201
  document_id=doc_id,
 
235
  target_collection_id = appwrite_db.get_collection_id(request.category)
236
 
237
  try:
238
+ doc = await appwrite_db.tablesDB.get_row(
239
  database_id=settings.APPWRITE_DATABASE_ID,
240
  collection_id=target_collection_id,
241
  document_id=doc_id
 
256
  "views": 0,
257
  "category": request.category or "wildcard"
258
  }
259
+ await appwrite_db.tablesDB.create_row(
260
  database_id=settings.APPWRITE_DATABASE_ID,
261
  collection_id=target_collection_id,
262
  document_id=doc_id,
263
  data=new_doc
264
  )
265
+ doc = await appwrite_db.tablesDB.get_row(
266
  database_id=settings.APPWRITE_DATABASE_ID,
267
  collection_id=target_collection_id,
268
  document_id=doc_id
 
272
  else:
273
  raise HTTPException(status_code=404, detail=f"Article not found in {target_collection_id}")
274
 
275
+ current_dislikes = doc.get('dislikes') or doc.get('dislike', 0)
276
  if current_dislikes is None: current_dislikes = 0
277
 
278
  new_dislikes = current_dislikes + 1
279
 
280
+ # Schema Compat: Research uses 'dislikes' (plural), others use 'dislike' (singular)
281
+ update_data = {}
282
+ if target_collection_id == settings.APPWRITE_RESEARCH_COLLECTION_ID:
283
+ update_data = {"dislikes": new_dislikes}
284
+ else:
285
+ update_data = {"dislike": new_dislikes}
286
+
287
+ updated_doc = await appwrite_db.tablesDB.update_row(
288
  database_id=settings.APPWRITE_DATABASE_ID,
289
  collection_id=target_collection_id,
290
  document_id=doc_id,
291
+ data=update_data
292
  )
293
 
294
+ # Return result (normalize key)
295
+ final_dislikes = updated_doc.get('dislikes') if 'dislikes' in updated_doc else updated_doc.get('dislike')
296
+
297
  logger.info(f"πŸ‘Ž Article {doc_id[:8]}... disliked (total: {updated_doc['dislike']})")
298
 
299
  return {
300
  "article_id": doc_id,
301
+ "dislikes": final_dislikes,
302
  "success": True
303
  }
304
 
 
327
  target_collection_id = appwrite_db.get_collection_id(request.category)
328
 
329
  try:
330
+ doc = await appwrite_db.tablesDB.get_row(
331
  database_id=settings.APPWRITE_DATABASE_ID,
332
  collection_id=target_collection_id,
333
  document_id=doc_id
 
347
  "views": 0,
348
  "category": request.category or "wildcard"
349
  }
350
+ await appwrite_db.tablesDB.create_row(
351
  database_id=settings.APPWRITE_DATABASE_ID,
352
  collection_id=target_collection_id,
353
  document_id=doc_id,
354
  data=new_doc
355
  )
356
+ doc = await appwrite_db.tablesDB.get_row(
357
  database_id=settings.APPWRITE_DATABASE_ID,
358
  collection_id=target_collection_id,
359
  document_id=doc_id
 
370
 
371
  new_views = current_views + 1
372
 
373
+ updated_doc = await appwrite_db.tablesDB.update_row(
374
  database_id=settings.APPWRITE_DATABASE_ID,
375
  collection_id=target_collection_id,
376
  document_id=doc_id,
 
425
  collection_id = settings.APPWRITE_COLLECTION_ID
426
 
427
  # Query articles, sorted by views (descending)
428
+ response = await appwrite_db.tablesDB.list_rows(
429
  database_id=settings.APPWRITE_DATABASE_ID,
430
  collection_id=collection_id,
431
  queries=[
432
+ Query.greater_than('published_at', cutoff),
433
  Query.order_desc('views'),
434
  Query.limit(limit)
435
  ]
 
442
  for article in articles:
443
  views = article.get('views', 0)
444
  likes = article.get('likes', 0)
445
+ dislikes = article.get('dislikes') or article.get('dislike', 0)
446
  article['engagement_score'] = views + (likes * 5) - (dislikes * 3)
447
 
448
  # Sort by engagement score
 
493
  if provider:
494
  queries.insert(0, Query.equal('provider', provider))
495
 
496
+ response = await appwrite_db.tablesDB.list_rows(
497
  database_id=settings.APPWRITE_DATABASE_ID,
498
  collection_id=settings.APPWRITE_CLOUD_COLLECTION_ID,
499
  queries=queries
app/routes/research.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ from fastapi import APIRouter, HTTPException
3
+ from typing import Dict, Any, Optional
4
+ from app.services.appwrite_db import get_appwrite_db
5
+ from app.config import settings
6
+ import logging
7
+
8
+ logger = logging.getLogger(__name__)
9
+ router = APIRouter()
10
+
11
+ @router.get("/{paper_id}")
12
+ async def get_research_paper(paper_id: str):
13
+ """
14
+ Get a single research paper by ID.
15
+ """
16
+ try:
17
+ appwrite_db = get_appwrite_db()
18
+
19
+ # Try to find by ID in research collection
20
+ try:
21
+ doc = await appwrite_db.tablesDB.get_row(
22
+ database_id=settings.APPWRITE_DATABASE_ID,
23
+ collection_id=settings.APPWRITE_RESEARCH_COLLECTION_ID,
24
+ document_id=paper_id
25
+ )
26
+
27
+ # Helper to map fields
28
+ if doc:
29
+ return {
30
+ "success": True,
31
+ "paper": {
32
+ "$id": doc.get('$id'),
33
+ "title": doc.get('title'),
34
+ "summary": doc.get('summary'),
35
+ "authors": doc.get('authors'),
36
+ "published_at": doc.get('published_at'),
37
+ "pdf_url": doc.get('pdf_url'),
38
+ "category": doc.get('category'),
39
+ "likes": doc.get('likes', 0),
40
+ "views": doc.get('views', 0),
41
+ "text_summary": doc.get('summary'), # Compat
42
+ "description": doc.get('summary'), # Compat
43
+ "url": doc.get('pdf_url'), # Compat
44
+ "image_url": doc.get('image_url'),
45
+ "id": doc.get('$id'), # Compat
46
+ "source": "ArXiv"
47
+ }
48
+ }
49
+ except Exception as e:
50
+ logger.warning(f"Paper {paper_id} not found: {e}")
51
+ pass
52
+
53
+ raise HTTPException(status_code=404, detail="Research paper not found")
54
+
55
+ except HTTPException:
56
+ raise
57
+ except Exception as e:
58
+ logger.error(f"Error fetching paper {paper_id}: {e}")
59
+ raise HTTPException(status_code=500, detail=str(e))
app/services/appwrite_db.py CHANGED
@@ -90,21 +90,21 @@ class AppwriteDatabase:
90
  def __init__(self, db_service):
91
  self.db = db_service
92
 
93
- def create_row(self, *args, **kwargs):
94
- return self.db.create_document(*args, **kwargs)
95
 
96
- def get_row(self, *args, **kwargs):
97
- return self.db.get_document(*args, **kwargs)
98
 
99
- def list_rows(self, *args, **kwargs):
100
- return self.db.list_documents(*args, **kwargs)
101
 
102
- def delete_row(self, *args, **kwargs):
103
  # Mapping delete_document -> delete_row if needed
104
- return self.db.delete_document(*args, **kwargs)
105
 
106
- def update_row(self, *args, **kwargs):
107
- return self.db.update_document(*args, **kwargs)
108
 
109
  self.tablesDB = TablesDBWrapper(self.databases)
110
 
@@ -140,7 +140,11 @@ class AppwriteDatabase:
140
  if cat.startswith('cloud-'):
141
  return settings.APPWRITE_CLOUD_COLLECTION_ID
142
 
143
- # 3. Data Vertical (Security, Governance, etc.)
 
 
 
 
144
  if cat.startswith('data-') or cat.startswith('business-') or cat == 'customer-data-platform':
145
  return settings.APPWRITE_DATA_COLLECTION_ID
146
 
@@ -193,29 +197,48 @@ class AppwriteDatabase:
193
  'published_at',
194
  'source',
195
  'category',
196
- 'url_hash'
 
 
 
197
  ]
198
 
199
  # Query with projection
200
- response = self.tablesDB.list_rows(
 
 
 
 
 
 
 
 
 
 
 
201
  database_id=settings.APPWRITE_DATABASE_ID,
202
  collection_id=target_collection_id,
203
- queries=[
204
- Query.equal('category', category),
205
- Query.order_desc('published_at'), # Uses index!
206
- Query.limit(limit),
207
- Query.offset(offset)
208
- ]
209
  )
210
 
211
  # Convert Appwrite documents to Article dictionaries
212
  articles = []
213
  for doc in response['documents']:
214
  try:
 
 
 
 
 
 
 
 
 
215
  article = {
 
216
  'title': doc.get('title'),
217
- 'description': doc.get('description', ''),
218
- 'url': doc.get('url'),
219
  'image_url': doc.get('image_url', ''),
220
  'publishedAt': doc.get('published_at'),
221
  'published_at': doc.get('published_at'), # Standard schema field
@@ -223,7 +246,9 @@ class AppwriteDatabase:
223
  'category': doc.get('category'),
224
  'likes': doc.get('likes', 0),
225
  'dislikes': doc.get('dislike', 0),
226
- 'views': doc.get('views', 0)
 
 
227
  }
228
  articles.append(article)
229
  except Exception as e:
@@ -280,7 +305,7 @@ class AppwriteDatabase:
280
 
281
  logger.info(f"πŸš€ [QUERY] Executing query on Collection: {target_collection_id}")
282
 
283
- response = self.tablesDB.list_rows(
284
  database_id=settings.APPWRITE_DATABASE_ID,
285
  collection_id=target_collection_id,
286
  queries=queries
@@ -419,7 +444,7 @@ class AppwriteDatabase:
419
  # Only the 'image' field uses legacy naming
420
 
421
  # Try to create document
422
- self.tablesDB.create_row(
423
  database_id=settings.APPWRITE_DATABASE_ID,
424
  collection_id=target_collection_id,
425
  document_id=doc_id, # Truncated ID
@@ -488,7 +513,7 @@ class AppwriteDatabase:
488
  cutoff_date = (datetime.now() - timedelta(days=days)).isoformat()
489
 
490
  # Query old articles
491
- response = self.tablesDB.list_rows(
492
  database_id=settings.APPWRITE_DATABASE_ID,
493
  collection_id=settings.APPWRITE_COLLECTION_ID,
494
  queries=[
@@ -500,7 +525,7 @@ class AppwriteDatabase:
500
  deleted_count = 0
501
  for doc in response['documents']:
502
  try:
503
- self.tablesDB.delete_row(
504
  database_id=settings.APPWRITE_DATABASE_ID,
505
  collection_id=settings.APPWRITE_COLLECTION_ID,
506
  document_id=doc['$id']
@@ -553,7 +578,7 @@ class AppwriteDatabase:
553
  # Using MD5 of email ensures idempotent writes (same email = same ID)
554
  doc_id = hashlib.md5(email.lower().encode()).hexdigest()
555
 
556
- self.tablesDB.create_row(
557
  database_id=settings.APPWRITE_DATABASE_ID,
558
  collection_id=settings.APPWRITE_SUBSCRIBERS_COLLECTION_ID,
559
  document_id=doc_id,
@@ -581,7 +606,7 @@ class AppwriteDatabase:
581
  return None
582
 
583
  try:
584
- documents = self.tablesDB.list_rows(
585
  database_id=settings.APPWRITE_DATABASE_ID,
586
  collection_id=settings.APPWRITE_SUBSCRIBERS_COLLECTION_ID,
587
  queries=[Query.equal("email", email)]
@@ -617,7 +642,7 @@ class AppwriteDatabase:
617
  if "Monthly" in preferences: data["sub_monthly"] = preferences["Monthly"]
618
 
619
  # Note: tablesDB wrapper now has update_row
620
- self.tablesDB.update_row(
621
  database_id=settings.APPWRITE_DATABASE_ID,
622
  collection_id=settings.APPWRITE_SUBSCRIBERS_COLLECTION_ID,
623
  document_id=doc_id,
@@ -633,7 +658,7 @@ class AppwriteDatabase:
633
  async def get_subscriber_by_token(self, token: str) -> Optional[Dict]:
634
  """Get subscriber by unsubscribe token"""
635
  try:
636
- documents = self.tablesDB.list_rows(
637
  database_id=settings.APPWRITE_DATABASE_ID,
638
  collection_id=settings.APPWRITE_SUBSCRIBERS_COLLECTION_ID,
639
  queries=[Query.equal("token", token)]
@@ -653,7 +678,7 @@ class AppwriteDatabase:
653
  return False
654
 
655
  try:
656
- self.tablesDB.update_row(
657
  database_id=settings.APPWRITE_DATABASE_ID,
658
  collection_id=collection_id,
659
  document_id=document_id,
@@ -692,7 +717,7 @@ class AppwriteDatabase:
692
 
693
  data = {field: is_active}
694
 
695
- self.tablesDB.update_row(
696
  database_id=settings.APPWRITE_DATABASE_ID,
697
  collection_id=settings.APPWRITE_SUBSCRIBERS_COLLECTION_ID,
698
  document_id=subscriber['$id'],
@@ -719,7 +744,7 @@ class AppwriteDatabase:
719
 
720
  data = {"isActive": subscribed}
721
 
722
- self.tablesDB.update_row(
723
  database_id=settings.APPWRITE_DATABASE_ID,
724
  collection_id=settings.APPWRITE_SUBSCRIBERS_COLLECTION_ID,
725
  document_id=subscriber['$id'],
@@ -750,7 +775,7 @@ class AppwriteDatabase:
750
  # Store in UTC ISO format
751
  utc_now = datetime.now(pytz.UTC).isoformat()
752
 
753
- self.tablesDB.update_row(
754
  database_id=settings.APPWRITE_DATABASE_ID,
755
  collection_id=settings.APPWRITE_SUBSCRIBERS_COLLECTION_ID,
756
  document_id=subscriber['$id'],
@@ -794,7 +819,7 @@ class AppwriteDatabase:
794
  # 1. Must be globally active (isActive=true)
795
  # 2. Must be subscribed to specific preference (sub_X=true)
796
 
797
- documents = self.tablesDB.list_rows(
798
  database_id=settings.APPWRITE_DATABASE_ID,
799
  collection_id=settings.APPWRITE_SUBSCRIBERS_COLLECTION_ID,
800
  queries=[
 
90
  def __init__(self, db_service):
91
  self.db = db_service
92
 
93
+ async def create_row(self, *args, **kwargs):
94
+ return await asyncio.to_thread(self.db.create_document, *args, **kwargs)
95
 
96
+ async def get_row(self, *args, **kwargs):
97
+ return await asyncio.to_thread(self.db.get_document, *args, **kwargs)
98
 
99
+ async def list_rows(self, *args, **kwargs):
100
+ return await asyncio.to_thread(self.db.list_documents, *args, **kwargs)
101
 
102
+ async def delete_row(self, *args, **kwargs):
103
  # Mapping delete_document -> delete_row if needed
104
+ return await asyncio.to_thread(self.db.delete_document, *args, **kwargs)
105
 
106
+ async def update_row(self, *args, **kwargs):
107
+ return await asyncio.to_thread(self.db.update_document, *args, **kwargs)
108
 
109
  self.tablesDB = TablesDBWrapper(self.databases)
110
 
 
140
  if cat.startswith('cloud-'):
141
  return settings.APPWRITE_CLOUD_COLLECTION_ID
142
 
143
+ # 3. Research Vertical (New)
144
+ if cat == 'research' or cat.startswith('research-'):
145
+ return settings.APPWRITE_RESEARCH_COLLECTION_ID
146
+
147
+ # 4. Data Vertical (Security, Governance, etc.)
148
  if cat.startswith('data-') or cat.startswith('business-') or cat == 'customer-data-platform':
149
  return settings.APPWRITE_DATA_COLLECTION_ID
150
 
 
197
  'published_at',
198
  'source',
199
  'category',
200
+ 'url_hash',
201
+ 'authors', # Research specific
202
+ 'pdf_url', # Research specific
203
+ 'summary' # Research specific (mapped to description)
204
  ]
205
 
206
  # Query with projection
207
+ queries = [
208
+ Query.order_desc('published_at'), # Uses index!
209
+ Query.limit(limit),
210
+ Query.offset(offset)
211
+ ]
212
+
213
+ # Apply category filter ONLY if it's not the root 'research' category
214
+ # (Because 'research' collection only contains research papers, so no filter = All Research)
215
+ if category != 'research':
216
+ queries.insert(0, Query.equal('category', category))
217
+
218
+ response = await self.tablesDB.list_rows(
219
  database_id=settings.APPWRITE_DATABASE_ID,
220
  collection_id=target_collection_id,
221
+ queries=queries
 
 
 
 
 
222
  )
223
 
224
  # Convert Appwrite documents to Article dictionaries
225
  articles = []
226
  for doc in response['documents']:
227
  try:
228
+ # Smart Mapping for Research Papers
229
+ description = doc.get('description', '')
230
+ if not description and doc.get('summary'):
231
+ description = doc.get('summary')
232
+
233
+ url = doc.get('url', '')
234
+ if not url and doc.get('pdf_url'):
235
+ url = doc.get('pdf_url')
236
+
237
  article = {
238
+ '$id': doc.get('$id'), # Ensure $id is passed!
239
  'title': doc.get('title'),
240
+ 'description': description,
241
+ 'url': url,
242
  'image_url': doc.get('image_url', ''),
243
  'publishedAt': doc.get('published_at'),
244
  'published_at': doc.get('published_at'), # Standard schema field
 
246
  'category': doc.get('category'),
247
  'likes': doc.get('likes', 0),
248
  'dislikes': doc.get('dislike', 0),
249
+ 'views': doc.get('views', 0),
250
+ 'author': doc.get('authors') # Map authors to author (singular for compat)
251
+ # 'authors': doc.get('authors') # Keep plural if needed
252
  }
253
  articles.append(article)
254
  except Exception as e:
 
305
 
306
  logger.info(f"πŸš€ [QUERY] Executing query on Collection: {target_collection_id}")
307
 
308
+ response = await self.tablesDB.list_rows(
309
  database_id=settings.APPWRITE_DATABASE_ID,
310
  collection_id=target_collection_id,
311
  queries=queries
 
444
  # Only the 'image' field uses legacy naming
445
 
446
  # Try to create document
447
+ await self.tablesDB.create_row(
448
  database_id=settings.APPWRITE_DATABASE_ID,
449
  collection_id=target_collection_id,
450
  document_id=doc_id, # Truncated ID
 
513
  cutoff_date = (datetime.now() - timedelta(days=days)).isoformat()
514
 
515
  # Query old articles
516
+ response = await self.tablesDB.list_rows(
517
  database_id=settings.APPWRITE_DATABASE_ID,
518
  collection_id=settings.APPWRITE_COLLECTION_ID,
519
  queries=[
 
525
  deleted_count = 0
526
  for doc in response['documents']:
527
  try:
528
+ await self.tablesDB.delete_row(
529
  database_id=settings.APPWRITE_DATABASE_ID,
530
  collection_id=settings.APPWRITE_COLLECTION_ID,
531
  document_id=doc['$id']
 
578
  # Using MD5 of email ensures idempotent writes (same email = same ID)
579
  doc_id = hashlib.md5(email.lower().encode()).hexdigest()
580
 
581
+ await self.tablesDB.create_row(
582
  database_id=settings.APPWRITE_DATABASE_ID,
583
  collection_id=settings.APPWRITE_SUBSCRIBERS_COLLECTION_ID,
584
  document_id=doc_id,
 
606
  return None
607
 
608
  try:
609
+ documents = await self.tablesDB.list_rows(
610
  database_id=settings.APPWRITE_DATABASE_ID,
611
  collection_id=settings.APPWRITE_SUBSCRIBERS_COLLECTION_ID,
612
  queries=[Query.equal("email", email)]
 
642
  if "Monthly" in preferences: data["sub_monthly"] = preferences["Monthly"]
643
 
644
  # Note: tablesDB wrapper now has update_row
645
+ await self.tablesDB.update_row(
646
  database_id=settings.APPWRITE_DATABASE_ID,
647
  collection_id=settings.APPWRITE_SUBSCRIBERS_COLLECTION_ID,
648
  document_id=doc_id,
 
658
  async def get_subscriber_by_token(self, token: str) -> Optional[Dict]:
659
  """Get subscriber by unsubscribe token"""
660
  try:
661
+ documents = await self.tablesDB.list_rows(
662
  database_id=settings.APPWRITE_DATABASE_ID,
663
  collection_id=settings.APPWRITE_SUBSCRIBERS_COLLECTION_ID,
664
  queries=[Query.equal("token", token)]
 
678
  return False
679
 
680
  try:
681
+ await self.tablesDB.update_row(
682
  database_id=settings.APPWRITE_DATABASE_ID,
683
  collection_id=collection_id,
684
  document_id=document_id,
 
717
 
718
  data = {field: is_active}
719
 
720
+ await self.tablesDB.update_row(
721
  database_id=settings.APPWRITE_DATABASE_ID,
722
  collection_id=settings.APPWRITE_SUBSCRIBERS_COLLECTION_ID,
723
  document_id=subscriber['$id'],
 
744
 
745
  data = {"isActive": subscribed}
746
 
747
+ await self.tablesDB.update_row(
748
  database_id=settings.APPWRITE_DATABASE_ID,
749
  collection_id=settings.APPWRITE_SUBSCRIBERS_COLLECTION_ID,
750
  document_id=subscriber['$id'],
 
775
  # Store in UTC ISO format
776
  utc_now = datetime.now(pytz.UTC).isoformat()
777
 
778
+ await self.tablesDB.update_row(
779
  database_id=settings.APPWRITE_DATABASE_ID,
780
  collection_id=settings.APPWRITE_SUBSCRIBERS_COLLECTION_ID,
781
  document_id=subscriber['$id'],
 
819
  # 1. Must be globally active (isActive=true)
820
  # 2. Must be subscribed to specific preference (sub_X=true)
821
 
822
+ documents = await self.tablesDB.list_rows(
823
  database_id=settings.APPWRITE_DATABASE_ID,
824
  collection_id=settings.APPWRITE_SUBSCRIBERS_COLLECTION_ID,
825
  queries=[
app/services/audio_service.py CHANGED
@@ -5,20 +5,20 @@ import hashlib
5
  from typing import Optional, Dict
6
  from datetime import datetime
7
  import edge_tts
8
- from groq import AsyncGroq
9
  from app.services.appwrite_db import get_appwrite_db
10
  from app.config import settings
11
 
12
  class AudioService:
13
  def __init__(self):
14
- self.groq_client = AsyncGroq(api_key=settings.GROQ_API_KEY)
15
- self.voice = "en-US-AndrewNeural" # Professional male voice
16
- # self.voice = "en-US-AvaNeural" # Professional female voice
17
 
18
- async def generate_summary(self, content: str) -> str:
19
- """Generate a concise audio-friendly summary using Groq"""
20
  try:
21
- chat_completion = await self.groq_client.chat.completions.create(
22
  messages=[
23
  {
24
  "role": "system",
@@ -29,21 +29,68 @@ class AudioService:
29
  "content": content,
30
  }
31
  ],
32
- model="llama-3.3-70b-versatile", # Default efficient model
33
  temperature=0.5,
34
  max_tokens=150,
35
  )
36
  return chat_completion.choices[0].message.content.strip()
 
 
 
 
 
 
 
 
 
37
  except Exception as e:
38
  print(f"Error generating summary: {e}")
39
  return ""
40
 
41
- async def generate_audio(self, text: str, output_path: str) -> bool:
42
- """Generate audio file from text using Edge TTS"""
 
 
 
 
 
43
  try:
44
- communicate = edge_tts.Communicate(text, self.voice)
45
- await communicate.save(output_path)
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  return True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  except Exception as e:
48
  print(f"Error generating audio: {e}")
49
  return False
@@ -62,7 +109,9 @@ class AudioService:
62
  # Use InputFile for file upload
63
  from appwrite.input_file import InputFile
64
 
65
- result = appwrite.storage.create_file(
 
 
66
  bucket_id=bucket_id,
67
  file_id='unique()',
68
  file=InputFile.from_path(file_path)
 
5
  from typing import Optional, Dict
6
  from datetime import datetime
7
  import edge_tts
8
+ from groq import Groq
9
  from app.services.appwrite_db import get_appwrite_db
10
  from app.config import settings
11
 
12
  class AudioService:
13
  def __init__(self):
14
+ # Use Sync client to avoid 'unknown async library' errors with anyio/Proactor on Windows
15
+ self.groq_client = Groq(api_key=settings.GROQ_API_KEY)
16
+ self.voice = "en-US-AndrewNeural"
17
 
18
+ def _generate_summary_sync(self, content: str) -> str:
19
+ """Synchronous wrapper for Groq API"""
20
  try:
21
+ chat_completion = self.groq_client.chat.completions.create(
22
  messages=[
23
  {
24
  "role": "system",
 
29
  "content": content,
30
  }
31
  ],
32
+ model="llama-3.3-70b-versatile",
33
  temperature=0.5,
34
  max_tokens=150,
35
  )
36
  return chat_completion.choices[0].message.content.strip()
37
+ except Exception as e:
38
+ print(f"Error in Groq Sync API: {e}")
39
+ raise e
40
+
41
+ async def generate_summary(self, content: str) -> str:
42
+ """Generate a concise audio-friendly summary using Groq (Threaded)"""
43
+ try:
44
+ # Run blocking sync IO in a separate thread to keep event loop free
45
+ return await asyncio.to_thread(self._generate_summary_sync, content)
46
  except Exception as e:
47
  print(f"Error generating summary: {e}")
48
  return ""
49
 
50
+ def _generate_audio_subprocess(self, text: str, output_path: str) -> bool:
51
+ """Helper to run edge-tts in a separate process for stability"""
52
+ import subprocess
53
+ import sys
54
+ import tempfile
55
+
56
+ temp_file_path = None
57
  try:
58
+ # Create temp file with utf-8 encoding
59
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, encoding='utf-8') as f:
60
+ f.write(text)
61
+ temp_file_path = f.name
62
+
63
+ # Construct command: python -m edge_tts --file <temp> --write-media <out> --voice <voice>
64
+ cmd = [
65
+ sys.executable, "-m", "edge_tts",
66
+ "--file", temp_file_path,
67
+ "--write-media", output_path,
68
+ "--voice", self.voice
69
+ ]
70
+
71
+ # Run blocking subprocess (safe in thread)
72
+ result = subprocess.run(cmd, check=True, capture_output=True, text=True)
73
  return True
74
+
75
+ except subprocess.CalledProcessError as e:
76
+ print(f"Error running edge-tts subprocess: {e.stderr}")
77
+ return False
78
+ except Exception as e:
79
+ print(f"General error in audio subprocess: {e}")
80
+ return False
81
+ finally:
82
+ # Cleanup temp file
83
+ if temp_file_path and os.path.exists(temp_file_path):
84
+ try:
85
+ os.unlink(temp_file_path)
86
+ except:
87
+ pass
88
+
89
+ async def generate_audio(self, text: str, output_path: str) -> bool:
90
+ """Generate audio file from text using Edge TTS (Subprocess)"""
91
+ try:
92
+ # Run the subprocess wrapper in a thread to keep main loop free
93
+ return await asyncio.to_thread(self._generate_audio_subprocess, text, output_path)
94
  except Exception as e:
95
  print(f"Error generating audio: {e}")
96
  return False
 
109
  # Use InputFile for file upload
110
  from appwrite.input_file import InputFile
111
 
112
+ # Run blocking storage upload in a thread
113
+ result = await asyncio.to_thread(
114
+ appwrite.storage.create_file,
115
  bucket_id=bucket_id,
116
  file_id='unique()',
117
  file=InputFile.from_path(file_path)
app/services/browser_manager.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import logging
3
+ from typing import Optional
4
+ from playwright.async_api import async_playwright, Browser, Playwright, BrowserContext
5
+ import random
6
+ import sys
7
+
8
+ # Critical Fix for Windows: Force ProactorEventLoop for subprocess support
9
+ if sys.platform == 'win32':
10
+ asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
11
+
12
+ # Configure logging
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # List of realistic User-Agents
16
+ USER_AGENTS = [
17
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
18
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36",
19
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36"
20
+ ]
21
+
22
+ class BrowserManager:
23
+ _instance = None
24
+
25
+ def __new__(cls):
26
+ if cls._instance is None:
27
+ cls._instance = super(BrowserManager, cls).__new__(cls)
28
+ cls._instance._initialized = False
29
+ return cls._instance
30
+
31
+ def __init__(self):
32
+ if self._initialized:
33
+ return
34
+
35
+ self.playwright: Optional[Playwright] = None
36
+ self.browser: Optional[Browser] = None
37
+ # Limit concurrent scraping operations to prevent OOM
38
+ # 3 concurrent tabs is a safe starting point for a typical container (1-2GB RAM)
39
+ self.semaphore = asyncio.Semaphore(3)
40
+ self._initialized = True
41
+ logger.info("BrowserManager initialized (Semaphore: 3)")
42
+
43
+ async def start(self):
44
+ """Initialize the global browser instance"""
45
+ if self.browser:
46
+ return
47
+
48
+ try:
49
+ logger.info("Starting Playwright...")
50
+ self.playwright = await async_playwright().start()
51
+ self.browser = await self.playwright.chromium.launch(
52
+ headless=True,
53
+ # Add arguments to improve stability in container environments
54
+ args=[
55
+ '--no-sandbox',
56
+ '--disable-setuid-sandbox',
57
+ '--disable-dev-shm-usage', # Overcome limited /dev/shm size
58
+ '--disable-gpu' # Not needed for headless
59
+ ]
60
+ )
61
+ logger.info("Global Browser Instance Started successfully")
62
+ except Exception as e:
63
+ logger.error(f"Failed to start Playwright: {e}")
64
+ raise
65
+
66
+ async def shutdown(self):
67
+ """Gracefully close the global browser instance"""
68
+ logger.info("Shutting down BrowserManager...")
69
+ if self.browser:
70
+ await self.browser.close()
71
+ self.browser = None
72
+
73
+ if self.playwright:
74
+ await self.playwright.stop()
75
+ self.playwright = None
76
+
77
+ logger.info("BrowserManager shutdown complete")
78
+
79
+ async def get_content(self, url: str) -> Optional[str]:
80
+ """
81
+ Fetch dynamic content using a fresh context from the shared browser.
82
+ Controlled by semaphore to prevention resource exhaustion.
83
+ """
84
+ if not self.browser:
85
+ logger.error("Browser not initialized! Call start() first.")
86
+ return None
87
+
88
+ async with self.semaphore:
89
+ context: Optional[BrowserContext] = None
90
+ page = None
91
+ try:
92
+ # Create a lightweight context (incognito-like)
93
+ # Randomize user agent for basic anti-bot evasion
94
+ context = await self.browser.new_context(
95
+ user_agent=random.choice(USER_AGENTS),
96
+ viewport={'width': 1920, 'height': 1080},
97
+ java_script_enabled=True
98
+ )
99
+
100
+ page = await context.new_page()
101
+
102
+ logger.info(f"Navigating to {url}")
103
+ # "domcontentloaded" is faster than "networkidle" and usually sufficient for text
104
+ await page.goto(url, wait_until="domcontentloaded", timeout=15000)
105
+
106
+ # Wait a bit for JS hydration (React/Next.js)
107
+ await page.wait_for_timeout(2000)
108
+
109
+ content = await page.content()
110
+ logger.info(f"Successfully scraped {len(content)} bytes from {url}")
111
+ return content
112
+
113
+ except Exception as e:
114
+ logger.error(f"Scraping failed for {url}: {e}")
115
+ return None
116
+
117
+ finally:
118
+ # CRITICAL: Always close context to free memory
119
+ if page:
120
+ try:
121
+ await page.close()
122
+ except:
123
+ pass
124
+
125
+ if context:
126
+ try:
127
+ await context.close()
128
+ except:
129
+ pass
130
+
131
+ # Global Singleton Instance
132
+ browser_manager = BrowserManager()
app/services/research_aggregator.py ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import asyncio
3
+ import arxiv
4
+ import logging
5
+ from datetime import datetime
6
+ from typing import List, Dict, Any
7
+ from appwrite.query import Query
8
+
9
+ from app.config import settings
10
+ from app.services.appwrite_db import get_appwrite_db
11
+
12
+ # Configure logger
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # Category Mapping: ArXiv -> Segmento Pulse
16
+ CATEGORY_MAPPING = {
17
+ # AI & Machine Learning
18
+ "cs.AI": "research-ai",
19
+ "cs.LG": "research-ml", # Machine Learning
20
+ "cs.CL": "research-ai", # Computation and Language (NLP)
21
+ "cs.CV": "research-ai", # Computer Vision
22
+ "cs.NE": "research-ml", # Neural and Evolutionary Computing
23
+
24
+ # Cloud & Distributed Computing
25
+ "cs.DC": "research-cloud", # Distributed, Parallel, and Cluster Computing
26
+ "cs.OS": "research-cloud", # Operating Systems
27
+ "cs.NI": "research-cloud", # Networking and Internet Architecture
28
+
29
+ # Data & Databases
30
+ "cs.DB": "research-data", # Databases
31
+ "cs.DS": "research-data", # Data Structures and Algorithms
32
+ "cs.IR": "research-data", # Information Retrieval
33
+ "cs.CR": "research-data", # Cryptography and Security (Data Security)
34
+ }
35
+
36
+ # Reverse mapping for display/debugging
37
+ INTERNAL_TO_DISPLAY = {
38
+ "research-ai": "Artificial Intelligence",
39
+ "research-ml": "Machine Learning",
40
+ "research-cloud": "Cloud Computing",
41
+ "research-data": "Data Engineering"
42
+ }
43
+
44
+ class ResearchAggregator:
45
+ """
46
+ Fetches research papers from ArXiv and stores them in Appwrite.
47
+ """
48
+ def __init__(self):
49
+ self.client = arxiv.Client(
50
+ page_size=20,
51
+ delay_seconds=3.0,
52
+ num_retries=3
53
+ )
54
+
55
+ async def fetch_and_process_daily_papers(self):
56
+ """
57
+ Main entry point: Fetches papers for all mapped categories.
58
+ """
59
+ logger.info("πŸ”¬ [RESEARCH AGGREGATOR] Starting daily fetch...")
60
+
61
+ total_fetched = 0
62
+ total_saved = 0
63
+
64
+ # Group ArXiv categories by our internal buckets to query efficiently
65
+ # Actually, query arXiv by category groups
66
+ # We can query multiple categories at once: 'cat:cs.AI OR cat:cs.LG'
67
+
68
+ # 1. Build Query Strings
69
+ # AI Group
70
+ ai_query = "cat:cs.AI OR cat:cs.LG OR cat:cs.CL OR cat:cs.CV OR cat:cs.NE"
71
+ # Cloud Group
72
+ cloud_query = "cat:cs.DC OR cat:cs.OS OR cat:cs.NI"
73
+ # Data Group
74
+ data_query = "cat:cs.DB OR cat:cs.DS OR cat:cs.IR OR cat:cs.CR"
75
+
76
+ queries = [
77
+ ("AI/ML", ai_query),
78
+ ("Cloud", cloud_query),
79
+ ("Data", data_query)
80
+ ]
81
+
82
+ for group_name, query_str in queries:
83
+ logger.info(f" πŸ” Querying ArXiv for {group_name}...")
84
+
85
+ # Construct search
86
+ search = arxiv.Search(
87
+ query=query_str,
88
+ max_results=30, # Limit per group to avoid spam
89
+ sort_by=arxiv.SortCriterion.SubmittedDate,
90
+ sort_order=arxiv.SortOrder.Descending
91
+ )
92
+
93
+ # Execute sync generator in async context (blocking? ArXiv lib is sync)
94
+ # We should ideally run this in a thread executor if it blocks too long,
95
+ # but for 30 items it's okay for background job.
96
+
97
+ results = list(self.client.results(search))
98
+ logger.info(f" found {len(results)} papers for {group_name}")
99
+
100
+ for paper in results:
101
+ total_fetched += 1
102
+ processed_paper = self._process_paper(paper)
103
+ if processed_paper:
104
+ saved = await self._save_paper(processed_paper)
105
+ if saved:
106
+ total_saved += 1
107
+
108
+ logger.info(f"βœ… [RESEARCH AGGREGATOR] Completed. Fetched: {total_fetched}, Saved: {total_saved}")
109
+ return total_saved
110
+
111
+ def _process_paper(self, paper: arxiv.Result) -> Dict[str, Any]:
112
+ """
113
+ Transforms ArXiv result into our Appwrite Schema.
114
+ """
115
+ # 1. Determine Primary Category
116
+ # ArXiv results have .categories list. We take the first one that matches our mapping.
117
+ primary_cat = paper.categories[0]
118
+ internal_cat = CATEGORY_MAPPING.get(primary_cat)
119
+
120
+ if not internal_cat:
121
+ # Fallback: check other categories
122
+ for cat in paper.categories:
123
+ if cat in CATEGORY_MAPPING:
124
+ internal_cat = CATEGORY_MAPPING[cat]
125
+ primary_cat = cat
126
+ break
127
+
128
+ if not internal_cat:
129
+ return None # Skip if not in our scope
130
+
131
+ # 2. Format Data
132
+ return {
133
+ "paper_id": self._get_short_id(paper.entry_id),
134
+ "title": paper.title.replace("\n", " ").strip(),
135
+ "summary": paper.summary.replace("\n", " ").strip(),
136
+ "authors": [a.name for a in paper.authors],
137
+ "published_at": paper.published.isoformat(),
138
+ "pdf_url": paper.pdf_url,
139
+ "url": paper.pdf_url, # COMPATIBILITY: Map pdf_url to url for frontend/models
140
+ "category": internal_cat, # research-ai
141
+ "original_category": primary_cat, # cs.AI
142
+ "sub_category": INTERNAL_TO_DISPLAY.get(internal_cat, "Research"), # Friendly name
143
+ "source": "arXiv"
144
+ }
145
+
146
+ def _get_short_id(self, entry_id: str) -> str:
147
+ # ArXiv IDs are like http://arxiv.org/abs/2101.12345v1
148
+ # We want "2101.12345v1"
149
+ return entry_id.split("/")[-1]
150
+
151
+ async def _save_paper(self, paper_data: Dict[str, Any]) -> bool:
152
+ """
153
+ Saves to Appwrite if not exists.
154
+ """
155
+ appwrite = get_appwrite_db()
156
+ if not appwrite.initialized:
157
+ logger.error("Appwrite not initialized")
158
+ return False
159
+
160
+ try:
161
+ # 1. Create (Atomic)
162
+ # We rely on the unique index on paper_id to throw 409 Conflict if exists.
163
+ # This avoids the "Check-Then-Act" race condition.
164
+
165
+ # 2. Create
166
+ # We need to flatten authors list to string because Appwrite String array
167
+ # logic depends on how we created schema.
168
+ # Wait, `authors` attribute in script was `type: string, size: 5000`.
169
+ # It's a single string, not array.
170
+ # So we join them.
171
+ paper_data['authors'] = ", ".join(paper_data['authors'])
172
+
173
+ await appwrite.tablesDB.create_row(
174
+ database_id=settings.APPWRITE_DATABASE_ID,
175
+ collection_id=settings.APPWRITE_RESEARCH_COLLECTION_ID,
176
+ document_id="unique()",
177
+ data=paper_data
178
+ )
179
+ logger.info(f" πŸ’Ύ Saved: {paper_data['title'][:50]}...")
180
+ return True
181
+
182
+ except Exception as e:
183
+ # Check for 409 Conflict (Appwrite throws Exception with message)
184
+ if "Document already exists" in str(e) or "409" in str(e):
185
+ logger.debug(f" ⏭️ Skipping duplicate (Atomic): {paper_data['paper_id']}")
186
+ return False
187
+
188
+ logger.error(f" ❌ Error saving paper {paper_data['paper_id']}: {e}")
189
+ return False
190
+
191
+ # Standalone run for testing
192
+ if __name__ == "__main__":
193
+ import sys
194
+ import os
195
+
196
+ # Add project root to path
197
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../')))
198
+
199
+ async def run():
200
+ agg = ResearchAggregator()
201
+ await agg.fetch_and_process_daily_papers()
202
+
203
+ asyncio.run(run())
app/services/scheduler.py CHANGED
@@ -14,6 +14,7 @@ from app.services.news_aggregator import NewsAggregator
14
  from app.services.appwrite_db import get_appwrite_db
15
  from app.services.cache_service import CacheService
16
  from app.services.adaptive_scheduler import get_adaptive_scheduler, AdaptiveScheduler
 
17
  from app.config import settings
18
 
19
  # Setup logging
@@ -210,6 +211,27 @@ async def fetch_all_news():
210
  adaptive.print_summary()
211
 
212
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  async def fetch_and_validate_category(category: str) -> tuple:
214
  """
215
  Fetch and validate articles for a single category
@@ -342,7 +364,7 @@ async def cleanup_old_news():
342
  # -------------------------------------------------------------
343
  # 1. SMART CHECK: "Hey collection, do you have old data?"
344
  # -------------------------------------------------------------
345
- check_response = appwrite_db.tablesDB.list_rows(
346
  database_id=settings.APPWRITE_DATABASE_ID,
347
  collection_id=collection_id,
348
  queries=[
@@ -364,7 +386,7 @@ async def cleanup_old_news():
364
 
365
  while True:
366
  # Query old articles (Batch of 500)
367
- response = appwrite_db.tablesDB.list_rows(
368
  database_id=settings.APPWRITE_DATABASE_ID,
369
  collection_id=collection_id,
370
  queries=[
@@ -386,7 +408,7 @@ async def cleanup_old_news():
386
  try:
387
  # This deletes the FULL DOCUMENT (Row) including all attributes
388
  # (published_at, url, image, likes, views, dislikes, etc.)
389
- appwrite_db.tablesDB.delete_row(
390
  database_id=settings.APPWRITE_DATABASE_ID,
391
  collection_id=collection_id,
392
  document_id=doc['$id']
@@ -521,6 +543,17 @@ def start_scheduler():
521
  logger.info("")
522
  logger.info(f"βœ… Job #{job_counter} Registered: πŸ“Š Monthly Newsletter")
523
 
 
 
 
 
 
 
 
 
 
 
 
524
  # Start the scheduler
525
  logger.info("")
526
  logger.info("πŸš€ Starting scheduler engine...")
 
14
  from app.services.appwrite_db import get_appwrite_db
15
  from app.services.cache_service import CacheService
16
  from app.services.adaptive_scheduler import get_adaptive_scheduler, AdaptiveScheduler
17
+ from app.services.research_aggregator import ResearchAggregator
18
  from app.config import settings
19
 
20
  # Setup logging
 
211
  adaptive.print_summary()
212
 
213
 
214
+ async def fetch_daily_research():
215
+ """
216
+ Background Job: Fetch Research Papers from ArXiv
217
+ Runs daily at 02:00 IST
218
+ """
219
+ logger.info("═" * 80)
220
+ logger.info("πŸ”¬ [RESEARCH FETCHER] Starting daily research fetch...")
221
+ logger.info("πŸ• Start Time: %s", datetime.now().strftime('%Y-%m-%d %H:%M:%S'))
222
+ logger.info("═" * 80)
223
+
224
+ try:
225
+ aggregator = ResearchAggregator()
226
+ saved_count = await aggregator.fetch_and_process_daily_papers()
227
+ logger.info(f"βœ… [RESEARCH FETCHER] Completed. Saved {saved_count} new papers.")
228
+
229
+ except Exception as e:
230
+ logger.error(f"❌ [RESEARCH FETCHER] Failed: {e}", exc_info=True)
231
+
232
+ logger.info("═" * 80)
233
+
234
+
235
  async def fetch_and_validate_category(category: str) -> tuple:
236
  """
237
  Fetch and validate articles for a single category
 
364
  # -------------------------------------------------------------
365
  # 1. SMART CHECK: "Hey collection, do you have old data?"
366
  # -------------------------------------------------------------
367
+ check_response = await appwrite_db.tablesDB.list_rows(
368
  database_id=settings.APPWRITE_DATABASE_ID,
369
  collection_id=collection_id,
370
  queries=[
 
386
 
387
  while True:
388
  # Query old articles (Batch of 500)
389
+ response = await appwrite_db.tablesDB.list_rows(
390
  database_id=settings.APPWRITE_DATABASE_ID,
391
  collection_id=collection_id,
392
  queries=[
 
408
  try:
409
  # This deletes the FULL DOCUMENT (Row) including all attributes
410
  # (published_at, url, image, likes, views, dislikes, etc.)
411
+ await appwrite_db.tablesDB.delete_row(
412
  database_id=settings.APPWRITE_DATABASE_ID,
413
  collection_id=collection_id,
414
  document_id=doc['$id']
 
543
  logger.info("")
544
  logger.info(f"βœ… Job #{job_counter} Registered: πŸ“Š Monthly Newsletter")
545
 
546
+ # Research Papers Job (Daily at 02:00 IST)
547
+ scheduler.add_job(
548
+ fetch_daily_research,
549
+ trigger=CronTrigger(hour=2, minute=0, timezone=IST),
550
+ id='fetch_research_papers',
551
+ name='Research Fetcher (Daily 02:00 IST)',
552
+ replace_existing=True
553
+ )
554
+ logger.info("")
555
+ logger.info(f"βœ… Job #{job_counter + 1} Registered: πŸ”¬ Research Fetcher")
556
+
557
  # Start the scheduler
558
  logger.info("")
559
  logger.info("πŸš€ Starting scheduler engine...")
data/test_bloom_filter.bin DELETED
Binary file (1.03 kB)
 
data/velocity_tracking.json CHANGED
@@ -2,209 +2,209 @@
2
  "ai": {
3
  "interval": 15,
4
  "history": [
5
- 10,
6
- 9,
7
  26,
8
  0,
9
- 0
 
 
10
  ],
11
- "last_fetch": "2026-02-09T15:13:49.323857",
12
- "total_fetches": 19,
13
- "total_articles": 268
14
  },
15
  "data-security": {
16
  "interval": 15,
17
  "history": [
18
- 9,
19
- 9,
20
  22,
21
  0,
22
- 0
 
 
23
  ],
24
- "last_fetch": "2026-02-09T15:13:49.328546",
25
- "total_fetches": 19,
26
- "total_articles": 181
27
  },
28
  "data-governance": {
29
- "interval": 15,
30
  "history": [
31
- 19,
32
- 9,
33
  19,
34
  0,
35
- 0
 
 
36
  ],
37
- "last_fetch": "2026-02-09T15:13:49.332767",
38
- "total_fetches": 19,
39
- "total_articles": 173
40
  },
41
  "data-privacy": {
42
  "interval": 15,
43
  "history": [
44
- 7,
45
- 7,
46
  23,
47
  0,
48
- 0
 
 
49
  ],
50
- "last_fetch": "2026-02-09T15:13:49.336592",
51
- "total_fetches": 19,
52
- "total_articles": 200
53
  },
54
  "data-engineering": {
55
- "interval": 15,
56
  "history": [
57
- 14,
58
- 14,
59
  14,
60
  0,
61
- 0
 
 
62
  ],
63
- "last_fetch": "2026-02-09T15:13:49.340993",
64
- "total_fetches": 19,
65
- "total_articles": 184
66
  },
67
  "data-management": {
68
  "interval": 15,
69
  "history": [
70
- 19,
71
- 10,
72
  10,
73
  0,
74
- 0
 
 
75
  ],
76
- "last_fetch": "2026-02-09T15:13:49.344310",
77
- "total_fetches": 19,
78
- "total_articles": 251
79
  },
80
  "business-intelligence": {
81
  "interval": 15,
82
  "history": [
83
- 9,
84
- 9,
85
  23,
86
  0,
87
- 0
 
 
88
  ],
89
- "last_fetch": "2026-02-09T15:13:49.347805",
90
- "total_fetches": 19,
91
- "total_articles": 239
92
  },
93
  "business-analytics": {
94
  "interval": 15,
95
  "history": [
96
- 19,
97
- 5,
98
  20,
99
  0,
100
- 0
 
 
101
  ],
102
- "last_fetch": "2026-02-09T15:13:49.351590",
103
- "total_fetches": 19,
104
- "total_articles": 191
105
  },
106
  "customer-data-platform": {
107
  "interval": 15,
108
  "history": [
109
- 9,
110
- 9,
111
  26,
112
  0,
113
- 0
 
 
114
  ],
115
- "last_fetch": "2026-02-09T15:13:49.355413",
116
- "total_fetches": 19,
117
- "total_articles": 229
118
  },
119
  "data-centers": {
120
  "interval": 15,
121
  "history": [
122
- 27,
123
- 17,
124
  27,
125
  0,
126
- 0
 
 
127
  ],
128
- "last_fetch": "2026-02-09T15:13:49.358619",
129
- "total_fetches": 19,
130
- "total_articles": 306
131
  },
132
  "cloud-computing": {
133
  "interval": 15,
134
  "history": [
135
- 24,
136
- 24,
137
  24,
138
  0,
139
- 0
 
 
140
  ],
141
- "last_fetch": "2026-02-09T15:13:49.361966",
142
- "total_fetches": 19,
143
- "total_articles": 388
144
  },
145
  "magazines": {
146
  "interval": 60,
147
  "history": [
148
- 10,
149
- 2,
150
  0,
151
  0,
152
- 0
 
 
153
  ],
154
- "last_fetch": "2026-02-09T15:13:49.365217",
155
- "total_fetches": 19,
156
- "total_articles": 95
157
  },
158
  "data-laws": {
159
  "interval": 15,
160
  "history": [
161
- 10,
162
- 10,
163
  29,
164
  0,
165
- 0
 
 
166
  ],
167
- "last_fetch": "2026-02-09T15:13:49.368458",
168
- "total_fetches": 19,
169
- "total_articles": 309
170
  },
171
  "cloud-aws": {
172
- "interval": 5,
173
  "history": [
174
- 29,
175
- 29,
176
  29,
177
  0,
178
- 0
 
 
179
  ],
180
- "last_fetch": "2026-02-09T15:13:49.371139",
181
- "total_fetches": 19,
182
- "total_articles": 494
183
  },
184
  "cloud-azure": {
185
  "interval": 15,
186
  "history": [
187
- 20,
188
- 20,
189
  20,
190
  0,
191
- 0
 
 
192
  ],
193
- "last_fetch": "2026-02-09T15:13:49.374325",
194
- "total_fetches": 19,
195
- "total_articles": 340
196
  },
197
  "cloud-gcp": {
198
- "interval": 15,
199
  "history": [
200
  20,
201
- 20,
202
- 20,
203
  0,
204
  0
205
  ],
206
- "last_fetch": "2026-02-09T15:13:49.377372",
207
- "total_fetches": 19,
208
  "total_articles": 380
209
  },
210
  "cloud-oracle": {
@@ -216,8 +216,8 @@
216
  0,
217
  0
218
  ],
219
- "last_fetch": "2026-02-09T15:13:49.380479",
220
- "total_fetches": 19,
221
  "total_articles": 20
222
  },
223
  "cloud-ibm": {
@@ -229,8 +229,8 @@
229
  0,
230
  0
231
  ],
232
- "last_fetch": "2026-02-09T15:13:49.384721",
233
- "total_fetches": 19,
234
  "total_articles": 20
235
  },
236
  "cloud-alibaba": {
@@ -242,8 +242,8 @@
242
  0,
243
  0
244
  ],
245
- "last_fetch": "2026-02-09T15:13:49.387837",
246
- "total_fetches": 19,
247
  "total_articles": 20
248
  },
249
  "cloud-digitalocean": {
@@ -255,8 +255,8 @@
255
  0,
256
  0
257
  ],
258
- "last_fetch": "2026-02-09T15:13:49.392634",
259
- "total_fetches": 19,
260
  "total_articles": 20
261
  },
262
  "cloud-huawei": {
@@ -268,21 +268,21 @@
268
  0,
269
  0
270
  ],
271
- "last_fetch": "2026-02-09T15:13:49.396316",
272
- "total_fetches": 19,
273
  "total_articles": 20
274
  },
275
  "cloud-cloudflare": {
276
- "interval": 15,
277
  "history": [
278
  20,
279
- 20,
280
- 20,
281
  0,
282
  0
283
  ],
284
- "last_fetch": "2026-02-09T15:13:49.400555",
285
- "total_fetches": 19,
286
  "total_articles": 360
287
  }
288
  }
 
2
  "ai": {
3
  "interval": 15,
4
  "history": [
 
 
5
  26,
6
  0,
7
+ 0,
8
+ 6,
9
+ 9
10
  ],
11
+ "last_fetch": "2026-02-14T13:58:05.405121",
12
+ "total_fetches": 21,
13
+ "total_articles": 283
14
  },
15
  "data-security": {
16
  "interval": 15,
17
  "history": [
 
 
18
  22,
19
  0,
20
+ 0,
21
+ 3,
22
+ 2
23
  ],
24
+ "last_fetch": "2026-02-14T13:58:05.410615",
25
+ "total_fetches": 21,
26
+ "total_articles": 186
27
  },
28
  "data-governance": {
29
+ "interval": 60,
30
  "history": [
 
 
31
  19,
32
  0,
33
+ 0,
34
+ 3,
35
+ 2
36
  ],
37
+ "last_fetch": "2026-02-14T13:58:05.416621",
38
+ "total_fetches": 21,
39
+ "total_articles": 178
40
  },
41
  "data-privacy": {
42
  "interval": 15,
43
  "history": [
 
 
44
  23,
45
  0,
46
+ 0,
47
+ 5,
48
+ 5
49
  ],
50
+ "last_fetch": "2026-02-14T13:58:05.421392",
51
+ "total_fetches": 21,
52
+ "total_articles": 210
53
  },
54
  "data-engineering": {
55
+ "interval": 60,
56
  "history": [
 
 
57
  14,
58
  0,
59
+ 0,
60
+ 2,
61
+ 5
62
  ],
63
+ "last_fetch": "2026-02-14T13:58:05.424148",
64
+ "total_fetches": 21,
65
+ "total_articles": 191
66
  },
67
  "data-management": {
68
  "interval": 15,
69
  "history": [
 
 
70
  10,
71
  0,
72
+ 0,
73
+ 10,
74
+ 10
75
  ],
76
+ "last_fetch": "2026-02-14T13:58:05.430647",
77
+ "total_fetches": 21,
78
+ "total_articles": 271
79
  },
80
  "business-intelligence": {
81
  "interval": 15,
82
  "history": [
 
 
83
  23,
84
  0,
85
+ 0,
86
+ 8,
87
+ 8
88
  ],
89
+ "last_fetch": "2026-02-14T13:58:05.435766",
90
+ "total_fetches": 21,
91
+ "total_articles": 255
92
  },
93
  "business-analytics": {
94
  "interval": 15,
95
  "history": [
 
 
96
  20,
97
  0,
98
+ 0,
99
+ 6,
100
+ 6
101
  ],
102
+ "last_fetch": "2026-02-14T13:58:05.438090",
103
+ "total_fetches": 21,
104
+ "total_articles": 203
105
  },
106
  "customer-data-platform": {
107
  "interval": 15,
108
  "history": [
 
 
109
  26,
110
  0,
111
+ 0,
112
+ 9,
113
+ 9
114
  ],
115
+ "last_fetch": "2026-02-14T13:58:05.440124",
116
+ "total_fetches": 21,
117
+ "total_articles": 247
118
  },
119
  "data-centers": {
120
  "interval": 15,
121
  "history": [
 
 
122
  27,
123
  0,
124
+ 0,
125
+ 6,
126
+ 6
127
  ],
128
+ "last_fetch": "2026-02-14T13:58:05.443427",
129
+ "total_fetches": 21,
130
+ "total_articles": 318
131
  },
132
  "cloud-computing": {
133
  "interval": 15,
134
  "history": [
 
 
135
  24,
136
  0,
137
+ 0,
138
+ 5,
139
+ 7
140
  ],
141
+ "last_fetch": "2026-02-14T13:58:05.448267",
142
+ "total_fetches": 21,
143
+ "total_articles": 400
144
  },
145
  "magazines": {
146
  "interval": 60,
147
  "history": [
 
 
148
  0,
149
  0,
150
+ 0,
151
+ 6,
152
+ 5
153
  ],
154
+ "last_fetch": "2026-02-14T13:58:05.451427",
155
+ "total_fetches": 21,
156
+ "total_articles": 106
157
  },
158
  "data-laws": {
159
  "interval": 15,
160
  "history": [
 
 
161
  29,
162
  0,
163
+ 0,
164
+ 9,
165
+ 9
166
  ],
167
+ "last_fetch": "2026-02-14T13:58:05.455106",
168
+ "total_fetches": 21,
169
+ "total_articles": 327
170
  },
171
  "cloud-aws": {
172
+ "interval": 15,
173
  "history": [
 
 
174
  29,
175
  0,
176
+ 0,
177
+ 10,
178
+ 10
179
  ],
180
+ "last_fetch": "2026-02-14T13:58:05.456886",
181
+ "total_fetches": 21,
182
+ "total_articles": 514
183
  },
184
  "cloud-azure": {
185
  "interval": 15,
186
  "history": [
 
 
187
  20,
188
  0,
189
+ 0,
190
+ 10,
191
+ 10
192
  ],
193
+ "last_fetch": "2026-02-14T13:58:05.459585",
194
+ "total_fetches": 21,
195
+ "total_articles": 360
196
  },
197
  "cloud-gcp": {
198
+ "interval": 60,
199
  "history": [
200
  20,
201
+ 0,
202
+ 0,
203
  0,
204
  0
205
  ],
206
+ "last_fetch": "2026-02-14T13:58:05.464655",
207
+ "total_fetches": 21,
208
  "total_articles": 380
209
  },
210
  "cloud-oracle": {
 
216
  0,
217
  0
218
  ],
219
+ "last_fetch": "2026-02-14T13:58:05.467651",
220
+ "total_fetches": 21,
221
  "total_articles": 20
222
  },
223
  "cloud-ibm": {
 
229
  0,
230
  0
231
  ],
232
+ "last_fetch": "2026-02-14T13:58:05.471190",
233
+ "total_fetches": 21,
234
  "total_articles": 20
235
  },
236
  "cloud-alibaba": {
 
242
  0,
243
  0
244
  ],
245
+ "last_fetch": "2026-02-14T13:58:05.472734",
246
+ "total_fetches": 21,
247
  "total_articles": 20
248
  },
249
  "cloud-digitalocean": {
 
255
  0,
256
  0
257
  ],
258
+ "last_fetch": "2026-02-14T13:58:05.474379",
259
+ "total_fetches": 21,
260
  "total_articles": 20
261
  },
262
  "cloud-huawei": {
 
268
  0,
269
  0
270
  ],
271
+ "last_fetch": "2026-02-14T13:58:05.478287",
272
+ "total_fetches": 21,
273
  "total_articles": 20
274
  },
275
  "cloud-cloudflare": {
276
+ "interval": 60,
277
  "history": [
278
  20,
279
+ 0,
280
+ 0,
281
  0,
282
  0
283
  ],
284
+ "last_fetch": "2026-02-14T13:58:05.483222",
285
+ "total_fetches": 21,
286
  "total_articles": 360
287
  }
288
  }
requirements.txt CHANGED
Binary files a/requirements.txt and b/requirements.txt differ
 
run.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uvicorn
2
+ import asyncio
3
+ import sys
4
+ import os
5
+
6
+ if __name__ == "__main__":
7
+ # Force WindowsProactorEventLoopPolicy on Windows
8
+ # This must be done before any asyncio loop is created
9
+ if sys.platform == 'win32':
10
+ asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
11
+
12
+ # Run Uvicorn programmatically
13
+ # DISABLE RELOAD to fix Windows Asyncio Subprocess issue
14
+ # Playwright on Windows requires the main thread's event loop to be Proactor,
15
+ # and Uvicorn's reloader messes this up.
16
+ uvicorn.run(
17
+ "app.main:app",
18
+ host="127.0.0.1",
19
+ port=8000,
20
+ reload=False,
21
+ workers=1,
22
+ loop="asyncio",
23
+ log_level="info"
24
+ )
scripts/add_missing_research_cols.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import os
3
+ import sys
4
+ import time
5
+ from dotenv import load_dotenv
6
+ from appwrite.client import Client
7
+ from appwrite.services.databases import Databases
8
+
9
+ load_dotenv()
10
+
11
+ APPWRITE_ENDPOINT = os.getenv("APPWRITE_ENDPOINT")
12
+ APPWRITE_PROJECT_ID = os.getenv("APPWRITE_PROJECT_ID")
13
+ APPWRITE_API_KEY = os.getenv("APPWRITE_API_KEY")
14
+ APPWRITE_DATABASE_ID = os.getenv("APPWRITE_DATABASE_ID")
15
+ RESEARCH_COLLECTION_ID = os.getenv("APPWRITE_RESEARCH_COLLECTION_ID")
16
+
17
+ client = Client()
18
+ client.set_endpoint(APPWRITE_ENDPOINT)
19
+ client.set_project(APPWRITE_PROJECT_ID)
20
+ client.set_key(APPWRITE_API_KEY)
21
+
22
+ databases = Databases(client)
23
+
24
+ def wait_for_attribute(collection_id, key):
25
+ print(f" ⏳ Waiting for attribute '{key}'...", end="", flush=True)
26
+ for _ in range(30):
27
+ try:
28
+ response = databases.list_attributes(APPWRITE_DATABASE_ID, collection_id)
29
+ attrs = response.get('attributes', [])
30
+ target = next((a for a in attrs if a['key'] == key), None)
31
+ if target and target['status'] == 'available':
32
+ print(" βœ… Ready.")
33
+ return True
34
+ time.sleep(2)
35
+ print(".", end="", flush=True)
36
+ except Exception:
37
+ time.sleep(2)
38
+ print(" ❌ Timeout.")
39
+ return False
40
+
41
+ def add_missing():
42
+ print(f"πŸ”§ Adding missing attributes to {RESEARCH_COLLECTION_ID}...")
43
+
44
+ # 1. original_category
45
+ print(" -> original_category")
46
+ try:
47
+ databases.create_string_attribute(APPWRITE_DATABASE_ID, RESEARCH_COLLECTION_ID, "original_category", 50, False)
48
+ except Exception as e:
49
+ if "already exists" not in str(e): print(f"Error: {e}")
50
+ wait_for_attribute(RESEARCH_COLLECTION_ID, "original_category")
51
+
52
+ # 2. sub_category
53
+ print(" -> sub_category")
54
+ try:
55
+ databases.create_string_attribute(APPWRITE_DATABASE_ID, RESEARCH_COLLECTION_ID, "sub_category", 100, False)
56
+ except Exception as e:
57
+ if "already exists" not in str(e): print(f"Error: {e}")
58
+ wait_for_attribute(RESEARCH_COLLECTION_ID, "sub_category")
59
+
60
+ # 3. source
61
+ print(" -> source")
62
+ try:
63
+ databases.create_string_attribute(APPWRITE_DATABASE_ID, RESEARCH_COLLECTION_ID, "source", 50, False)
64
+ except Exception as e:
65
+ if "already exists" not in str(e): print(f"Error: {e}")
66
+ wait_for_attribute(RESEARCH_COLLECTION_ID, "source")
67
+
68
+ print("\nβœ… Schema Update Complete.")
69
+
70
+ if __name__ == "__main__":
71
+ add_missing()
scripts/add_url_to_research.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import os
3
+ import sys
4
+ import time
5
+ from dotenv import load_dotenv
6
+ from appwrite.client import Client
7
+ from appwrite.services.databases import Databases
8
+
9
+ load_dotenv()
10
+
11
+ APPWRITE_ENDPOINT = os.getenv("APPWRITE_ENDPOINT")
12
+ APPWRITE_PROJECT_ID = os.getenv("APPWRITE_PROJECT_ID")
13
+ APPWRITE_API_KEY = os.getenv("APPWRITE_API_KEY")
14
+ APPWRITE_DATABASE_ID = os.getenv("APPWRITE_DATABASE_ID")
15
+ RESEARCH_COLLECTION_ID = os.getenv("APPWRITE_RESEARCH_COLLECTION_ID")
16
+
17
+ client = Client()
18
+ client.set_endpoint(APPWRITE_ENDPOINT)
19
+ client.set_project(APPWRITE_PROJECT_ID)
20
+ client.set_key(APPWRITE_API_KEY)
21
+
22
+ databases = Databases(client)
23
+
24
+ def wait_for_attribute(collection_id, key):
25
+ print(f" ⏳ Waiting for attribute '{key}'...", end="", flush=True)
26
+ for _ in range(30):
27
+ try:
28
+ response = databases.list_attributes(APPWRITE_DATABASE_ID, collection_id)
29
+ attrs = response.get('attributes', [])
30
+ target = next((a for a in attrs if a['key'] == key), None)
31
+ if target and target['status'] == 'available':
32
+ print(" βœ… Ready.")
33
+ return True
34
+ time.sleep(2)
35
+ print(".", end="", flush=True)
36
+ except Exception:
37
+ time.sleep(2)
38
+ print(" ❌ Timeout.")
39
+ return False
40
+
41
+ def add_url_and_backfill():
42
+ print(f"πŸ”§ Adding 'url' attribute to {RESEARCH_COLLECTION_ID}...")
43
+
44
+ # 1. Create Attribute
45
+ try:
46
+ # url (string, 2000 chars, required=False)
47
+ databases.create_url_attribute(APPWRITE_DATABASE_ID, RESEARCH_COLLECTION_ID, "url", False)
48
+ except Exception as e:
49
+ if "already exists" not in str(e): print(f"Error creating attribute: {e}")
50
+
51
+ if wait_for_attribute(RESEARCH_COLLECTION_ID, "url"):
52
+ print("βœ… Attribute 'url' created.")
53
+
54
+ # 2. Backfill existing documents
55
+ print("πŸ”„ Backfilling 'url' from 'pdf_url'...")
56
+ try:
57
+ # 1. List all documents (cursor pagination if many, but assuming few for now)
58
+ has_more = True
59
+ cursor = None
60
+ total_updated = 0
61
+
62
+ while has_more:
63
+ from appwrite.query import Query
64
+ q = [Query.limit(100)]
65
+ if cursor:
66
+ q.append(Query.cursor_after(cursor))
67
+
68
+ response = databases.list_documents(
69
+ database_id=APPWRITE_DATABASE_ID,
70
+ collection_id=RESEARCH_COLLECTION_ID,
71
+ queries=q
72
+ )
73
+
74
+ docs = response.get('documents', [])
75
+ if not docs:
76
+ break
77
+
78
+ for doc in docs:
79
+ if not doc.get('url') and doc.get('pdf_url'):
80
+ try:
81
+ databases.update_document(
82
+ database_id=APPWRITE_DATABASE_ID,
83
+ collection_id=RESEARCH_COLLECTION_ID,
84
+ document_id=doc['$id'],
85
+ data={'url': doc['pdf_url']}
86
+ )
87
+ total_updated += 1
88
+ print(f" updated {doc['$id']}")
89
+ except Exception as e:
90
+ print(f" failed to update {doc['$id']}: {e}")
91
+
92
+ cursor = docs[-1]['$id']
93
+ if len(docs) < 100:
94
+ has_more = False
95
+
96
+ print(f"βœ… Backfill complete. Updated {total_updated} documents.")
97
+
98
+ except Exception as e:
99
+ print(f"❌ Backfill failed: {e}")
100
+
101
+ if __name__ == "__main__":
102
+ add_url_and_backfill()
scripts/init_research_collection.py ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ import warnings
4
+ from dotenv import load_dotenv
5
+ from appwrite.client import Client
6
+ from appwrite.services.databases import Databases
7
+
8
+ # Suppress deprecation warnings
9
+ warnings.filterwarnings("ignore", category=DeprecationWarning)
10
+
11
+ # Load environment variables
12
+ load_dotenv()
13
+
14
+ # Configuration
15
+ APPWRITE_ENDPOINT = os.getenv("APPWRITE_ENDPOINT", "https://cloud.appwrite.io/v1")
16
+ APPWRITE_PROJECT_ID = os.getenv("APPWRITE_PROJECT_ID")
17
+ APPWRITE_API_KEY = os.getenv("APPWRITE_API_KEY")
18
+ APPWRITE_DATABASE_ID = os.getenv("APPWRITE_DATABASE_ID")
19
+ RESEARCH_COLLECTION_ID = os.getenv("APPWRITE_RESEARCH_COLLECTION_ID", "69845c19002c864d4d3f")
20
+
21
+ if not all([APPWRITE_ENDPOINT, APPWRITE_PROJECT_ID, APPWRITE_API_KEY, APPWRITE_DATABASE_ID]):
22
+ print("❌ Missing environment variables. Please check .env file.")
23
+ sys.exit(1)
24
+
25
+ # Initialize Appwrite Client
26
+ client = Client()
27
+ client.set_endpoint(APPWRITE_ENDPOINT)
28
+ client.set_project(APPWRITE_PROJECT_ID)
29
+ client.set_key(APPWRITE_API_KEY)
30
+
31
+ databases = Databases(client)
32
+
33
+ def init_research_schema():
34
+ print(f"πŸ”¬ Initializing Schema for Collection: {RESEARCH_COLLECTION_ID}")
35
+
36
+ # 1. Verify Collection Exists
37
+ try:
38
+ databases.get_collection(APPWRITE_DATABASE_ID, RESEARCH_COLLECTION_ID)
39
+ print("βœ… Collection exists.")
40
+ except Exception as e:
41
+ print(f"❌ Collection not found: {e}")
42
+ return
43
+
44
+ # 2. Define Required Attributes
45
+ required_attributes = [
46
+ {"key": "paper_id", "type": "string", "size": 255, "required": True},
47
+ {"key": "title", "type": "string", "size": 500, "required": True},
48
+ {"key": "summary", "type": "string", "size": 5000, "required": False}, # Abstract
49
+ {"key": "authors", "type": "string", "size": 5000, "required": False},
50
+ {"key": "published_at", "type": "datetime", "required": True},
51
+ {"key": "pdf_url", "type": "url", "required": True},
52
+ {"key": "category", "type": "string", "size": 255, "required": True}, # Internal ID (research-ai)
53
+ {"key": "sub_category", "type": "string", "size": 255, "required": False}, # New strict sub-category
54
+ {"key": "original_category", "type": "string", "size": 255, "required": True}, # ArXiv ID (cs.AI)
55
+ {"key": "likes", "type": "integer", "required": False, "default": 0},
56
+ {"key": "dislike", "type": "integer", "required": False, "default": 0}, # Note: 'dislike' singular to match news
57
+ {"key": "views", "type": "integer", "required": False, "default": 0},
58
+ ]
59
+
60
+ # 3. Check and Create Attributes
61
+ import time
62
+
63
+ try:
64
+ attrs = databases.list_attributes(APPWRITE_DATABASE_ID, RESEARCH_COLLECTION_ID)
65
+ existing_attributes = {attr['key']: attr for attr in attrs['attributes']}
66
+ existing_keys = list(existing_attributes.keys())
67
+ print(f"Existing Attributes: {existing_keys}")
68
+
69
+ for attr in required_attributes:
70
+ key = attr['key']
71
+ if key not in existing_keys:
72
+ print(f"βš™οΈ Creating attribute: {key}...")
73
+ try:
74
+ if attr['type'] == "string":
75
+ databases.create_string_attribute(
76
+ database_id=APPWRITE_DATABASE_ID,
77
+ collection_id=RESEARCH_COLLECTION_ID,
78
+ key=key,
79
+ size=attr['size'],
80
+ required=attr['required'],
81
+ default=attr.get('default')
82
+ )
83
+ elif attr['type'] == "datetime":
84
+ databases.create_datetime_attribute(
85
+ database_id=APPWRITE_DATABASE_ID,
86
+ collection_id=RESEARCH_COLLECTION_ID,
87
+ key=key,
88
+ required=attr['required'],
89
+ default=attr.get('default')
90
+ )
91
+ elif attr['type'] == "url":
92
+ databases.create_url_attribute(
93
+ database_id=APPWRITE_DATABASE_ID,
94
+ collection_id=RESEARCH_COLLECTION_ID,
95
+ key=key,
96
+ required=attr['required'],
97
+ default=attr.get('default')
98
+ )
99
+ elif attr['type'] == "integer":
100
+ databases.create_integer_attribute(
101
+ database_id=APPWRITE_DATABASE_ID,
102
+ collection_id=RESEARCH_COLLECTION_ID,
103
+ key=key,
104
+ required=attr['required'],
105
+ min=0,
106
+ max=None,
107
+ default=attr.get('default')
108
+ )
109
+ print(f" βœ… Request sent for: {key}")
110
+ except Exception as attr_error:
111
+ print(f" οΏ½οΏ½οΏ½ Failed to create {key}: {attr_error}")
112
+ else:
113
+ print(f" πŸ”Ή Exists: {key}")
114
+
115
+ # 3.5 WAIT FOR ATTRIBUTES TO BE AVAILABLE
116
+ print("\n⏳ Waiting for attributes to become 'available'...")
117
+ pending_attrs = [attr['key'] for attr in required_attributes]
118
+ max_retries = 30 # 30 * 2 = 60 seconds
119
+
120
+ for key in pending_attrs:
121
+ for attempt in range(max_retries):
122
+ try:
123
+ # Generic get_attribute doesn't exist mainly, need typed check or list loop
124
+ # robust way: list all and check specific
125
+ curr_attrs = databases.list_attributes(APPWRITE_DATABASE_ID, RESEARCH_COLLECTION_ID)['attributes']
126
+ target = next((a for a in curr_attrs if a['key'] == key), None)
127
+
128
+ if target and target['status'] == 'available':
129
+ print(f" βœ… {key} is available.")
130
+ break
131
+ elif target and target['status'] == 'failed':
132
+ print(f" ❌ {key} creation FAILED in Appwrite.")
133
+ break
134
+ else:
135
+ if attempt % 5 == 0:
136
+ print(f" ... waiting for {key} (attempt {attempt+1}/{max_retries})")
137
+ time.sleep(2)
138
+ except Exception as e:
139
+ print(f"Error checking status for {key}: {e}")
140
+ time.sleep(2)
141
+ else:
142
+ print(f" ⚠️ Timeout waiting for {key} to be available.")
143
+
144
+ # 4. Create Index on paper_id (if not exists)
145
+ # Only try to create index if paper_id is available
146
+ print("\nβš™οΈ Checking/Creating index on paper_id...")
147
+ try:
148
+ databases.create_index(
149
+ database_id=APPWRITE_DATABASE_ID,
150
+ collection_id=RESEARCH_COLLECTION_ID,
151
+ key="unique_paper_id",
152
+ type="unique",
153
+ attributes=["paper_id"]
154
+ )
155
+ print(" βœ… Index created.")
156
+ except Exception as e:
157
+ # If error contains "already exists" or 409, it's fine
158
+ if "already exists" in str(e) or "409" in str(e):
159
+ print(" πŸ”Ή Index already exists.")
160
+ elif "attribute not found" in str(e).lower() or "processing" in str(e).lower():
161
+ print(f" ❌ Index creation failed: Attributes still processing or missing. ({e})")
162
+ else:
163
+ print(f" ⚠️ Could not create index: {e}")
164
+
165
+ except Exception as e:
166
+ print(f"❌ Error during schema initialization: {e}")
167
+
168
+ if __name__ == "__main__":
169
+ init_research_schema()
scripts/manual_research_schema.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import os
3
+ import sys
4
+ import warnings
5
+ from dotenv import load_dotenv
6
+ from appwrite.client import Client
7
+ from appwrite.services.databases import Databases
8
+ from appwrite.id import ID
9
+
10
+ # Suppress warnings
11
+ warnings.filterwarnings("ignore")
12
+
13
+ # Load environment variables
14
+ load_dotenv()
15
+
16
+ APPWRITE_ENDPOINT = os.getenv("APPWRITE_ENDPOINT")
17
+ APPWRITE_PROJECT_ID = os.getenv("APPWRITE_PROJECT_ID")
18
+ APPWRITE_API_KEY = os.getenv("APPWRITE_API_KEY")
19
+ APPWRITE_DATABASE_ID = os.getenv("APPWRITE_DATABASE_ID")
20
+ RESEARCH_COLLECTION_ID = os.getenv("APPWRITE_RESEARCH_COLLECTION_ID")
21
+
22
+ if not all([APPWRITE_ENDPOINT, APPWRITE_PROJECT_ID, APPWRITE_API_KEY, APPWRITE_DATABASE_ID, RESEARCH_COLLECTION_ID]):
23
+ print("❌ Missing environment variables. Check .env file.")
24
+ sys.exit(1)
25
+
26
+ client = Client()
27
+ client.set_endpoint(APPWRITE_ENDPOINT)
28
+ client.set_project(APPWRITE_PROJECT_ID)
29
+ client.set_key(APPWRITE_API_KEY)
30
+
31
+ databases = Databases(client)
32
+
33
+ def create_attribute_safe(func, *args, **kwargs):
34
+ try:
35
+ func(*args, **kwargs)
36
+ print(f" βœ… Created attribute/index.")
37
+ except Exception as e:
38
+ if "already exists" in str(e):
39
+ print(f" πŸ”Ή Already exists.")
40
+ else:
41
+ print(f" ❌ Error: {e}")
42
+
43
+ def manual_setup():
44
+ print(f"πŸ”¬ Manual Schema Setup for Collection: {RESEARCH_COLLECTION_ID}")
45
+
46
+ # 1. Attributes
47
+ print("\n1. creating Attributes...")
48
+
49
+ print(" - paper_id (string, 100 char, required)")
50
+ create_attribute_safe(databases.create_string_attribute, APPWRITE_DATABASE_ID, RESEARCH_COLLECTION_ID, "paper_id", 100, True)
51
+
52
+ print(" - title (string, 500 char, required)")
53
+ create_attribute_safe(databases.create_string_attribute, APPWRITE_DATABASE_ID, RESEARCH_COLLECTION_ID, "title", 500, True)
54
+
55
+ print(" - summary (string, 5000 char, required)")
56
+ create_attribute_safe(databases.create_string_attribute, APPWRITE_DATABASE_ID, RESEARCH_COLLECTION_ID, "summary", 5000, True)
57
+
58
+ print(" - authors (string, 5000 char, optional - storing as JSON string or comma separated)")
59
+ create_attribute_safe(databases.create_string_attribute, APPWRITE_DATABASE_ID, RESEARCH_COLLECTION_ID, "authors", 5000, False)
60
+
61
+ print(" - published_at (datetime, required)")
62
+ create_attribute_safe(databases.create_datetime_attribute, APPWRITE_DATABASE_ID, RESEARCH_COLLECTION_ID, "published_at", True)
63
+
64
+ print(" - pdf_url (url, required - using string 2000)")
65
+ create_attribute_safe(databases.create_url_attribute, APPWRITE_DATABASE_ID, RESEARCH_COLLECTION_ID, "pdf_url", True)
66
+
67
+ print(" - category (string, 50 char, required)")
68
+ create_attribute_safe(databases.create_string_attribute, APPWRITE_DATABASE_ID, RESEARCH_COLLECTION_ID, "category", 50, True)
69
+
70
+ # Engagement stats
71
+ print(" - likes (integer, min 0, default 0)")
72
+ create_attribute_safe(databases.create_integer_attribute, APPWRITE_DATABASE_ID, RESEARCH_COLLECTION_ID, "likes", False, 0, 2147483647, 0)
73
+
74
+ print(" - dislikes (integer, min 0, default 0)")
75
+ create_attribute_safe(databases.create_integer_attribute, APPWRITE_DATABASE_ID, RESEARCH_COLLECTION_ID, "dislikes", False, 0, 2147483647, 0)
76
+
77
+ print(" - views (integer, min 0, default 0)")
78
+ create_attribute_safe(databases.create_integer_attribute, APPWRITE_DATABASE_ID, RESEARCH_COLLECTION_ID, "views", False, 0, 2147483647, 0)
79
+
80
+ # 2. Indexes
81
+ print("\n2. Creating Indexes...")
82
+
83
+ print(" - paper_id (unique)")
84
+ create_attribute_safe(
85
+ databases.create_index,
86
+ APPWRITE_DATABASE_ID,
87
+ RESEARCH_COLLECTION_ID,
88
+ "unique_paper_id",
89
+ "unique",
90
+ ["paper_id"],
91
+ ["ASC"]
92
+ )
93
+
94
+ print(" - published_at (key)")
95
+ create_attribute_safe(
96
+ databases.create_index,
97
+ APPWRITE_DATABASE_ID,
98
+ RESEARCH_COLLECTION_ID,
99
+ "idx_published_at",
100
+ "key",
101
+ ["published_at"],
102
+ ["DESC"]
103
+ )
104
+
105
+ print(" - category (key)")
106
+ create_attribute_safe(
107
+ databases.create_index,
108
+ APPWRITE_DATABASE_ID,
109
+ RESEARCH_COLLECTION_ID,
110
+ "idx_category",
111
+ "key",
112
+ ["category"],
113
+ ["ASC"]
114
+ )
115
+
116
+ print("\nβœ… Setup commands sent. Attribute creation is ASYNC in Appwrite.")
117
+ print(" Wait ~30-60 seconds before running ingestion.")
118
+
119
+ if __name__ == "__main__":
120
+ manual_setup()
scripts/setup_research_v2.py ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import os
3
+ import sys
4
+ import time
5
+ import warnings
6
+ from dotenv import load_dotenv
7
+ from appwrite.client import Client
8
+ from appwrite.services.databases import Databases
9
+
10
+ # Suppress warnings
11
+ warnings.filterwarnings("ignore")
12
+
13
+ # Load environment variables
14
+ load_dotenv()
15
+
16
+ APPWRITE_ENDPOINT = os.getenv("APPWRITE_ENDPOINT")
17
+ APPWRITE_PROJECT_ID = os.getenv("APPWRITE_PROJECT_ID")
18
+ APPWRITE_API_KEY = os.getenv("APPWRITE_API_KEY")
19
+ APPWRITE_DATABASE_ID = os.getenv("APPWRITE_DATABASE_ID")
20
+
21
+ if not all([APPWRITE_ENDPOINT, APPWRITE_PROJECT_ID, APPWRITE_API_KEY, APPWRITE_DATABASE_ID]):
22
+ print("❌ Missing environment variables. Check .env file.")
23
+ sys.exit(1)
24
+
25
+ client = Client()
26
+ client.set_endpoint(APPWRITE_ENDPOINT)
27
+ client.set_project(APPWRITE_PROJECT_ID)
28
+ client.set_key(APPWRITE_API_KEY)
29
+
30
+ databases = Databases(client)
31
+
32
+ def wait_for_attribute(collection_id, key):
33
+ """Polls Appwrite until the attribute status is 'available'."""
34
+ print(f" ⏳ Waiting for attribute '{key}' to be available...", end="", flush=True)
35
+ retries = 0
36
+ max_retries = 60 # 2 minutes
37
+
38
+ while retries < max_retries:
39
+ try:
40
+ # Check attribute status by listing or getting
41
+ # The SDK doesn't have 'get_attribute' easily for all types, so we list
42
+ response = databases.list_attributes(APPWRITE_DATABASE_ID, collection_id)
43
+ attrs = response.get('attributes', [])
44
+
45
+ target = next((a for a in attrs if a['key'] == key), None)
46
+
47
+ if target:
48
+ if target['status'] == 'available':
49
+ print(" βœ… Ready.")
50
+ return True
51
+ elif target['status'] == 'failed':
52
+ print(" ❌ Failed.")
53
+ return False
54
+
55
+ time.sleep(2)
56
+ print(".", end="", flush=True)
57
+ retries += 1
58
+ except Exception as e:
59
+ print(f" Error: {e}")
60
+ time.sleep(2)
61
+ retries += 1
62
+
63
+ print(" ❌ Timeout.")
64
+ return False
65
+
66
+ def wait_for_index(collection_id, key):
67
+ """Polls Appwrite until the index status is 'available'."""
68
+ print(f" ⏳ Waiting for index '{key}' to be available...", end="", flush=True)
69
+ retries = 0
70
+ max_retries = 60
71
+
72
+ while retries < max_retries:
73
+ try:
74
+ response = databases.list_indexes(APPWRITE_DATABASE_ID, collection_id)
75
+ indexes = response.get('indexes', [])
76
+
77
+ target = next((i for i in indexes if i['key'] == key), None)
78
+
79
+ if target:
80
+ if target['status'] == 'available':
81
+ print(" βœ… Ready.")
82
+ return True
83
+ elif target['status'] == 'failed':
84
+ print(" ❌ Failed.")
85
+ return False
86
+
87
+ time.sleep(2)
88
+ print(".", end="", flush=True)
89
+ retries += 1
90
+ except Exception as e:
91
+ print(f" Error: {e}")
92
+ time.sleep(2)
93
+ retries += 1
94
+
95
+ print(" ❌ Timeout.")
96
+ return False
97
+
98
+ def setup_v2(collection_id):
99
+ print(f"πŸš€ Starting Robust Schema Setup for: {collection_id}")
100
+
101
+ # Define Attributes
102
+ # (func, key, size/opts, required, default, array)
103
+ # Simplified structure for the loop
104
+
105
+ # 1. Attributes
106
+ print("\nπŸ“¦ Creating Attributes (Synchronous Mode)...")
107
+
108
+ try:
109
+ # paper_id
110
+ print(" -> paper_id")
111
+ try:
112
+ databases.create_string_attribute(APPWRITE_DATABASE_ID, collection_id, "paper_id", 100, True)
113
+ except Exception as e:
114
+ if "already exists" not in str(e): print(f"Error: {e}")
115
+ wait_for_attribute(collection_id, "paper_id")
116
+
117
+ # title
118
+ print(" -> title")
119
+ try:
120
+ databases.create_string_attribute(APPWRITE_DATABASE_ID, collection_id, "title", 500, True)
121
+ except Exception as e:
122
+ if "already exists" not in str(e): print(f"Error: {e}")
123
+ wait_for_attribute(collection_id, "title")
124
+
125
+ # summary
126
+ print(" -> summary")
127
+ try:
128
+ databases.create_string_attribute(APPWRITE_DATABASE_ID, collection_id, "summary", 5000, True)
129
+ except Exception as e:
130
+ if "already exists" not in str(e): print(f"Error: {e}")
131
+ wait_for_attribute(collection_id, "summary")
132
+
133
+ # authors
134
+ print(" -> authors")
135
+ try:
136
+ databases.create_string_attribute(APPWRITE_DATABASE_ID, collection_id, "authors", 5000, False)
137
+ except Exception as e:
138
+ if "already exists" not in str(e): print(f"Error: {e}")
139
+ wait_for_attribute(collection_id, "authors")
140
+
141
+ # published_at
142
+ print(" -> published_at")
143
+ try:
144
+ databases.create_datetime_attribute(APPWRITE_DATABASE_ID, collection_id, "published_at", True)
145
+ except Exception as e:
146
+ if "already exists" not in str(e): print(f"Error: {e}")
147
+ wait_for_attribute(collection_id, "published_at")
148
+
149
+ # pdf_url
150
+ print(" -> pdf_url")
151
+ try:
152
+ databases.create_url_attribute(APPWRITE_DATABASE_ID, collection_id, "pdf_url", True)
153
+ except Exception as e:
154
+ if "already exists" not in str(e): print(f"Error: {e}")
155
+ wait_for_attribute(collection_id, "pdf_url")
156
+
157
+ # category
158
+ print(" -> category")
159
+ try:
160
+ databases.create_string_attribute(APPWRITE_DATABASE_ID, collection_id, "category", 50, True)
161
+ except Exception as e:
162
+ if "already exists" not in str(e): print(f"Error: {e}")
163
+ wait_for_attribute(collection_id, "category")
164
+
165
+ # Stats
166
+ for stat in ["likes", "dislikes", "views"]:
167
+ print(f" -> {stat}")
168
+ try:
169
+ databases.create_integer_attribute(APPWRITE_DATABASE_ID, collection_id, stat, False, 0, 2147483647, 0)
170
+ except Exception as e:
171
+ if "already exists" not in str(e): print(f"Error: {e}")
172
+ wait_for_attribute(collection_id, stat)
173
+
174
+ except Exception as e:
175
+ print(f"❌ Critical Error during attribute creation: {e}")
176
+ return
177
+
178
+ # 2. Indexes
179
+ print("\nπŸ—‚οΈ Creating Indexes (Now that attributes are ready)...")
180
+
181
+ # unique_paper_id
182
+ print(" -> unique_paper_id")
183
+ try:
184
+ databases.create_index(APPWRITE_DATABASE_ID, collection_id, "unique_paper_id", "unique", ["paper_id"], ["ASC"])
185
+ except Exception as e:
186
+ if "already exists" not in str(e): print(f"Error: {e}")
187
+ wait_for_index(collection_id, "unique_paper_id")
188
+
189
+ # idx_published_at
190
+ print(" -> idx_published_at")
191
+ try:
192
+ databases.create_index(APPWRITE_DATABASE_ID, collection_id, "idx_published_at", "key", ["published_at"], ["DESC"])
193
+ except Exception as e:
194
+ if "already exists" not in str(e): print(f"Error: {e}")
195
+ wait_for_index(collection_id, "idx_published_at")
196
+
197
+ # idx_category
198
+ print(" -> idx_category")
199
+ try:
200
+ databases.create_index(APPWRITE_DATABASE_ID, collection_id, "idx_category", "key", ["category"], ["ASC"])
201
+ except Exception as e:
202
+ if "already exists" not in str(e): print(f"Error: {e}")
203
+ wait_for_index(collection_id, "idx_category")
204
+
205
+ print("\nβœ… Verification Complete: All attributes and indexes are AVAILABLE.")
206
+ print(" You may now run the ingestion script.")
207
+
208
+ if __name__ == "__main__":
209
+ if len(sys.argv) < 2:
210
+ print("Usage: python scripts/setup_research_v2.py <NEW_COLLECTION_ID>")
211
+ sys.exit(1)
212
+
213
+ col_id = sys.argv[1]
214
+ setup_v2(col_id)
scripts/test_research_flow.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import asyncio
3
+ import os
4
+ import sys
5
+ import httpx
6
+ from dotenv import load_dotenv
7
+
8
+ # Add project root to path
9
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../')))
10
+
11
+ from app.config import settings
12
+ from app.services.appwrite_db import get_appwrite_db
13
+ from app.services.research_aggregator import ResearchAggregator
14
+
15
+ load_dotenv()
16
+
17
+ async def test_flow():
18
+ print("πŸ§ͺ Starting End-to-End Research Verification...")
19
+ print(f" Target Collection: {settings.APPWRITE_RESEARCH_COLLECTION_ID}")
20
+
21
+ # 1. Simulate Ingestion (Fetch 1 paper from ArXiv)
22
+ print("\n1. Testing Ingestion (Dry Run logic)...")
23
+ agg = ResearchAggregator()
24
+
25
+ # We'll mock the fetch to avoid hitting ArXiv rate limits or waiting
26
+ # We just want to test the _save_paper logic with the new schema.
27
+
28
+ test_paper = {
29
+ "paper_id": "test.12345",
30
+ "title": "Test Paper for Verification",
31
+ "summary": "This is a test summary for verification purposes.",
32
+ "authors": ["Test Author"],
33
+ "published_at": "2023-10-27T00:00:00+00:00",
34
+ "pdf_url": "http://arxiv.org/pdf/test.12345",
35
+ "category": "research-ai",
36
+ "original_category": "cs.AI",
37
+ "sub_category": "Artificial Intelligence", # Friendly name
38
+ "source": "arXiv"
39
+ }
40
+
41
+ print(f" Attempting to save test paper: {test_paper['paper_id']}")
42
+ saved = await agg._save_paper(test_paper)
43
+
44
+ if saved:
45
+ print(" βœ… Ingestion Successful (Paper Saved/Created).")
46
+ else:
47
+ print(" ⚠️ Ingestion Skipped (Likely Duplicate or Error).")
48
+
49
+ # 2. Verify Data in DB
50
+ print("\n2. Verifying Data in Appwrite...")
51
+ appwrite = get_appwrite_db()
52
+ try:
53
+ # We need to find the document ID of the paper we just saved (or existing one)
54
+ # We can query by paper_id
55
+ from appwrite.query import Query
56
+ response = await appwrite.tablesDB.list_rows(
57
+ database_id=settings.APPWRITE_DATABASE_ID,
58
+ collection_id=settings.APPWRITE_RESEARCH_COLLECTION_ID,
59
+ queries=[Query.equal("paper_id", "test.12345")]
60
+ )
61
+
62
+ if response['total'] == 0:
63
+ print(" ❌ Error: Paper not found in DB after ingestion.")
64
+ return
65
+
66
+ doc = response['documents'][0]
67
+ doc_id = doc['$id']
68
+ print(f" βœ… Paper found! Doc ID: {doc_id}")
69
+ print(f" Current Stats -> Likes: {doc.get('likes')}, Views: {doc.get('views')}")
70
+
71
+ except Exception as e:
72
+ print(f" ❌ Error verifying data: {e}")
73
+ return
74
+
75
+ # 3. Test Engagement (Like)
76
+ # This simulates what the Frontend does (POST /api/engagement/articles/{id}/like)
77
+ # We will use the internal router function logic or just call DB update manually to verify schema.
78
+ # Calling DB manually is better to verify the *schema* accepts updates.
79
+
80
+ print("\n3. Testing Stats Update (Simulation)...")
81
+ try:
82
+ current_likes = doc.get('likes', 0) or 0
83
+ new_likes = current_likes + 1
84
+
85
+ updated_doc = await appwrite.tablesDB.update_row(
86
+ database_id=settings.APPWRITE_DATABASE_ID,
87
+ collection_id=settings.APPWRITE_RESEARCH_COLLECTION_ID,
88
+ document_id=doc_id,
89
+ data={"likes": new_likes}
90
+ )
91
+
92
+ if updated_doc['likes'] == new_likes:
93
+ print(f" βœ… Stats Update Successful! Likes: {current_likes} -> {updated_doc['likes']}")
94
+ else:
95
+ print(f" ❌ Stats Update Mismatch: Expected {new_likes}, got {updated_doc['likes']}")
96
+
97
+ except Exception as e:
98
+ print(f" ❌ Error updating stats: {e}")
99
+
100
+ print("\nπŸŽ‰ Verification Complete!")
101
+
102
+ if __name__ == "__main__":
103
+ asyncio.run(test_flow())
scripts/verify_research_columns.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import os
3
+ import sys
4
+ from dotenv import load_dotenv
5
+ from appwrite.client import Client
6
+ from appwrite.services.databases import Databases
7
+
8
+ load_dotenv()
9
+
10
+ APPWRITE_ENDPOINT = os.getenv("APPWRITE_ENDPOINT")
11
+ APPWRITE_PROJECT_ID = os.getenv("APPWRITE_PROJECT_ID")
12
+ APPWRITE_API_KEY = os.getenv("APPWRITE_API_KEY")
13
+ APPWRITE_DATABASE_ID = os.getenv("APPWRITE_DATABASE_ID")
14
+ RESEARCH_COLLECTION_ID = os.getenv("APPWRITE_RESEARCH_COLLECTION_ID")
15
+
16
+ client = Client()
17
+ client.set_endpoint(APPWRITE_ENDPOINT)
18
+ client.set_project(APPWRITE_PROJECT_ID)
19
+ client.set_key(APPWRITE_API_KEY)
20
+
21
+ databases = Databases(client)
22
+
23
+ def list_attributes():
24
+ print(f"πŸ“Š Checking Attributes for Collection: {RESEARCH_COLLECTION_ID}")
25
+ try:
26
+ # Fetch list of attributes
27
+ # Note: Appwrite returns a list of attribute objects
28
+ # We need to handle pagination if there are many, but usually < 25
29
+ response = databases.list_attributes(
30
+ database_id=APPWRITE_DATABASE_ID,
31
+ collection_id=RESEARCH_COLLECTION_ID
32
+ )
33
+
34
+ attributes = response.get('attributes', [])
35
+ print(f" Found {len(attributes)} attributes.")
36
+
37
+ found_attrs = set()
38
+ for attr in attributes:
39
+ key = attr.get('key')
40
+ status = attr.get('status')
41
+ typ = attr.get('type')
42
+ print(f" - {key} ({typ}) [Status: {status}]")
43
+ found_attrs.add(key)
44
+
45
+ # Check against expected
46
+ expected = ['paper_id', 'title', 'summary', 'authors', 'published_at', 'pdf_url', 'category', 'likes', 'dislikes', 'views']
47
+ missing = [x for x in expected if x not in found_attrs]
48
+
49
+ if missing:
50
+ print(f"\n❌ MISSING Attributes: {missing}")
51
+ else:
52
+ print(f"\nβœ… All expected attributes exist.")
53
+
54
+ except Exception as e:
55
+ print(f"❌ Error fetching attributes: {e}")
56
+
57
+ if __name__ == "__main__":
58
+ list_attributes()
scripts/verify_research_data.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ from app.services.appwrite_db import get_appwrite_db
3
+ from app.config import settings
4
+
5
+ async def verify_research():
6
+ db = get_appwrite_db()
7
+ if not db.initialized:
8
+ print("DB not initialized")
9
+ return
10
+
11
+ collection_id = settings.APPWRITE_RESEARCH_COLLECTION_ID
12
+ print(f"Research Collection ID: {collection_id}")
13
+
14
+ try:
15
+ # List documents
16
+ docs = await db.tablesDB.list_rows(
17
+ database_id=settings.APPWRITE_DATABASE_ID,
18
+ collection_id=collection_id,
19
+ queries=[]
20
+ )
21
+ print(f"Total Documents: {docs['total']}")
22
+ if docs['documents']:
23
+ print("Sample Document:", docs['documents'][0])
24
+ else:
25
+ print("No documents found.")
26
+
27
+ # Test get_articles logic
28
+ articles = await db.get_articles('research', limit=5)
29
+ print(f"get_articles('research') returned: {len(articles)}")
30
+ except Exception as e:
31
+ print(f"Error: {e}")
32
+
33
+ if __name__ == "__main__":
34
+ loop = asyncio.new_event_loop()
35
+ asyncio.set_event_loop(loop)
36
+ loop.run_until_complete(verify_research())
scripts/verify_research_indexes.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import os
3
+ import sys
4
+ import asyncio
5
+ from dotenv import load_dotenv
6
+ from appwrite.client import Client
7
+ from appwrite.services.databases import Databases
8
+ from appwrite.query import Query
9
+
10
+ load_dotenv()
11
+
12
+ APPWRITE_ENDPOINT = os.getenv("APPWRITE_ENDPOINT")
13
+ APPWRITE_PROJECT_ID = os.getenv("APPWRITE_PROJECT_ID")
14
+ APPWRITE_API_KEY = os.getenv("APPWRITE_API_KEY")
15
+ APPWRITE_DATABASE_ID = os.getenv("APPWRITE_DATABASE_ID")
16
+ RESEARCH_COLLECTION_ID = os.getenv("APPWRITE_RESEARCH_COLLECTION_ID")
17
+
18
+ client = Client()
19
+ client.set_endpoint(APPWRITE_ENDPOINT)
20
+ client.set_project(APPWRITE_PROJECT_ID)
21
+ client.set_key(APPWRITE_API_KEY)
22
+
23
+ databases = Databases(client)
24
+
25
+ async def probe_index(attr, query_type):
26
+ print(f"πŸ•΅οΈ Probing index for '{attr}'...")
27
+ try:
28
+ # We don't need async here actually, the python SDK is sync unless we use the async client,
29
+ # but my environment context shows async usage in services.
30
+ # The standard appwrite python SDK is synchronous (CHECK: 'appwrite' package).
31
+ # Wait, the code I viewed in `research_aggregator.py` imports `arxiv` but uses `app.services.appwrite_db` handling stuff.
32
+ # Standard `appwrite` package on PyPI is synchronous.
33
+ # But `appwrite_db.py` wraps it? No, checking `appwrite_db.py`: `from appwrite.services.databases import Databases`.
34
+ # Wait, step 768 `appwrite_db.py` has `await self.tablesDB.list_rows`.
35
+ # If the user is using `appwrite` python package < 4.0 it might be sync, or if they operate it differently.
36
+ # BUT, to be safe and simple, I will use the SYNC calls here as it's a script.
37
+
38
+ # Testing Sort
39
+ q = [Query.order_desc(attr), Query.limit(1)]
40
+ databases.list_documents(
41
+ database_id=APPWRITE_DATABASE_ID,
42
+ collection_id=RESEARCH_COLLECTION_ID,
43
+ queries=q
44
+ )
45
+ print(f" βœ… Index '{attr}' appears ACTIVE (Sort query worked).")
46
+ return True
47
+ except Exception as e:
48
+ if "Index not found" in str(e) or "missing index" in str(e).lower():
49
+ print(f" ❌ Index '{attr}' is MISSING or INVALID.")
50
+ print(f" Error: {e}")
51
+ return False
52
+ else:
53
+ print(f" ⚠️ Query failed (Unrelated?): {e}")
54
+ return False
55
+
56
+ def verify():
57
+ print(f"πŸ§ͺ Verifying Indexes for {RESEARCH_COLLECTION_ID}")
58
+
59
+ # 1. Check 'published_at' (Sortable)
60
+ probe_index("published_at", "order_desc")
61
+
62
+ # 2. Check 'category' (Filterable/Sortable)
63
+ # To check filter index, we try to filter.
64
+ print(f"πŸ•΅οΈ Probing index for 'category' (Filter)...")
65
+ try:
66
+ databases.list_documents(
67
+ database_id=APPWRITE_DATABASE_ID,
68
+ collection_id=RESEARCH_COLLECTION_ID,
69
+ queries=[Query.equal("category", "research-ai"), Query.limit(1)]
70
+ )
71
+ print(f" βœ… Index 'category' appears ACTIVE (Filter query worked).")
72
+ except Exception as e:
73
+ print(f" ❌ Index 'category' is MISSING/INVALID: {e}")
74
+
75
+ # 3. Check 'paper_id' (Unique)
76
+ # Hard to test unique without creating, but we can try to filter by it if it's a key.
77
+ # Usually unique indexes also allow filtering.
78
+ print(f"πŸ•΅οΈ Probing index for 'paper_id' (Filter)...")
79
+ try:
80
+ databases.list_documents(
81
+ database_id=APPWRITE_DATABASE_ID,
82
+ collection_id=RESEARCH_COLLECTION_ID,
83
+ queries=[Query.equal("paper_id", "test"), Query.limit(1)]
84
+ )
85
+ print(f" βœ… Index 'paper_id' appears ACTIVE (Filter query worked).")
86
+ except Exception as e:
87
+ print(f" ❌ Index 'paper_id' is MISSING/INVALID: {e}")
88
+
89
+ if __name__ == "__main__":
90
+ verify()
tools/verify_browser.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import logging
3
+ import sys
4
+ import os
5
+
6
+ # Add project root to path
7
+ sys.path.append(os.getcwd())
8
+
9
+ from app.services.browser_manager import browser_manager
10
+
11
+ logging.basicConfig(level=logging.INFO)
12
+
13
+ async def test_browser_manager():
14
+ print("Testing BrowserManager...")
15
+ try:
16
+ await browser_manager.start()
17
+
18
+ url = "https://example.com"
19
+ print(f"Fetching {url}...")
20
+ content = await browser_manager.get_content(url)
21
+
22
+ if content and "Example Domain" in content:
23
+ print("βœ… Successfully fetched content!")
24
+ else:
25
+ print("❌ Failed to fetch content or content mismatch.")
26
+
27
+ except Exception as e:
28
+ print(f"❌ Error: {e}")
29
+ finally:
30
+ await browser_manager.shutdown()
31
+
32
+ if __name__ == "__main__":
33
+ asyncio.run(test_browser_manager())