Spaces:
Running
Running
Commit Β·
353cfb6
1
Parent(s): ca44fa4
Added Research paper feature, removed python slim in docker, ingested with playwrite
Browse files- Dockerfile +4 -1
- app/config.py +3 -2
- app/main.py +25 -4
- app/models.py +2 -1
- app/routes/audio.py +20 -6
- app/routes/engagement.py +33 -22
- app/routes/research.py +59 -0
- app/services/appwrite_db.py +60 -35
- app/services/audio_service.py +62 -13
- app/services/browser_manager.py +132 -0
- app/services/research_aggregator.py +203 -0
- app/services/scheduler.py +36 -3
- data/test_bloom_filter.bin +0 -0
- data/velocity_tracking.json +113 -113
- requirements.txt +0 -0
- run.py +24 -0
- scripts/add_missing_research_cols.py +71 -0
- scripts/add_url_to_research.py +102 -0
- scripts/init_research_collection.py +169 -0
- scripts/manual_research_schema.py +120 -0
- scripts/setup_research_v2.py +214 -0
- scripts/test_research_flow.py +103 -0
- scripts/verify_research_columns.py +58 -0
- scripts/verify_research_data.py +36 -0
- scripts/verify_research_indexes.py +90 -0
- tools/verify_browser.py +33 -0
Dockerfile
CHANGED
|
@@ -1,9 +1,10 @@
|
|
| 1 |
-
FROM python:
|
| 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 ="
|
| 82 |
-
APPWRITE_MEDIUM_COLLECTION_ID: str ="
|
|
|
|
| 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:
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
database_id=settings.APPWRITE_DATABASE_ID,
|
| 281 |
collection_id=target_collection_id,
|
| 282 |
document_id=doc_id,
|
| 283 |
-
data=
|
| 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":
|
| 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('
|
| 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
|
| 95 |
|
| 96 |
-
def get_row(self, *args, **kwargs):
|
| 97 |
-
return self.db.get_document
|
| 98 |
|
| 99 |
-
def list_rows(self, *args, **kwargs):
|
| 100 |
-
return self.db.list_documents
|
| 101 |
|
| 102 |
-
def delete_row(self, *args, **kwargs):
|
| 103 |
# Mapping delete_document -> delete_row if needed
|
| 104 |
-
return self.db.delete_document
|
| 105 |
|
| 106 |
-
def update_row(self, *args, **kwargs):
|
| 107 |
-
return self.db.update_document
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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':
|
| 218 |
-
'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
|
| 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 |
-
|
| 15 |
-
self.
|
| 16 |
-
|
| 17 |
|
| 18 |
-
|
| 19 |
-
"""
|
| 20 |
try:
|
| 21 |
-
chat_completion =
|
| 22 |
messages=[
|
| 23 |
{
|
| 24 |
"role": "system",
|
|
@@ -29,21 +29,68 @@ class AudioService:
|
|
| 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 generating summary: {e}")
|
| 39 |
return ""
|
| 40 |
|
| 41 |
-
|
| 42 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
try:
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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-
|
| 12 |
-
"total_fetches":
|
| 13 |
-
"total_articles":
|
| 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-
|
| 25 |
-
"total_fetches":
|
| 26 |
-
"total_articles":
|
| 27 |
},
|
| 28 |
"data-governance": {
|
| 29 |
-
"interval":
|
| 30 |
"history": [
|
| 31 |
-
19,
|
| 32 |
-
9,
|
| 33 |
19,
|
| 34 |
0,
|
| 35 |
-
0
|
|
|
|
|
|
|
| 36 |
],
|
| 37 |
-
"last_fetch": "2026-02-
|
| 38 |
-
"total_fetches":
|
| 39 |
-
"total_articles":
|
| 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-
|
| 51 |
-
"total_fetches":
|
| 52 |
-
"total_articles":
|
| 53 |
},
|
| 54 |
"data-engineering": {
|
| 55 |
-
"interval":
|
| 56 |
"history": [
|
| 57 |
-
14,
|
| 58 |
-
14,
|
| 59 |
14,
|
| 60 |
0,
|
| 61 |
-
0
|
|
|
|
|
|
|
| 62 |
],
|
| 63 |
-
"last_fetch": "2026-02-
|
| 64 |
-
"total_fetches":
|
| 65 |
-
"total_articles":
|
| 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-
|
| 77 |
-
"total_fetches":
|
| 78 |
-
"total_articles":
|
| 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-
|
| 90 |
-
"total_fetches":
|
| 91 |
-
"total_articles":
|
| 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-
|
| 103 |
-
"total_fetches":
|
| 104 |
-
"total_articles":
|
| 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-
|
| 116 |
-
"total_fetches":
|
| 117 |
-
"total_articles":
|
| 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-
|
| 129 |
-
"total_fetches":
|
| 130 |
-
"total_articles":
|
| 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-
|
| 142 |
-
"total_fetches":
|
| 143 |
-
"total_articles":
|
| 144 |
},
|
| 145 |
"magazines": {
|
| 146 |
"interval": 60,
|
| 147 |
"history": [
|
| 148 |
-
10,
|
| 149 |
-
2,
|
| 150 |
0,
|
| 151 |
0,
|
| 152 |
-
0
|
|
|
|
|
|
|
| 153 |
],
|
| 154 |
-
"last_fetch": "2026-02-
|
| 155 |
-
"total_fetches":
|
| 156 |
-
"total_articles":
|
| 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-
|
| 168 |
-
"total_fetches":
|
| 169 |
-
"total_articles":
|
| 170 |
},
|
| 171 |
"cloud-aws": {
|
| 172 |
-
"interval":
|
| 173 |
"history": [
|
| 174 |
-
29,
|
| 175 |
-
29,
|
| 176 |
29,
|
| 177 |
0,
|
| 178 |
-
0
|
|
|
|
|
|
|
| 179 |
],
|
| 180 |
-
"last_fetch": "2026-02-
|
| 181 |
-
"total_fetches":
|
| 182 |
-
"total_articles":
|
| 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-
|
| 194 |
-
"total_fetches":
|
| 195 |
-
"total_articles":
|
| 196 |
},
|
| 197 |
"cloud-gcp": {
|
| 198 |
-
"interval":
|
| 199 |
"history": [
|
| 200 |
20,
|
| 201 |
-
|
| 202 |
-
|
| 203 |
0,
|
| 204 |
0
|
| 205 |
],
|
| 206 |
-
"last_fetch": "2026-02-
|
| 207 |
-
"total_fetches":
|
| 208 |
"total_articles": 380
|
| 209 |
},
|
| 210 |
"cloud-oracle": {
|
|
@@ -216,8 +216,8 @@
|
|
| 216 |
0,
|
| 217 |
0
|
| 218 |
],
|
| 219 |
-
"last_fetch": "2026-02-
|
| 220 |
-
"total_fetches":
|
| 221 |
"total_articles": 20
|
| 222 |
},
|
| 223 |
"cloud-ibm": {
|
|
@@ -229,8 +229,8 @@
|
|
| 229 |
0,
|
| 230 |
0
|
| 231 |
],
|
| 232 |
-
"last_fetch": "2026-02-
|
| 233 |
-
"total_fetches":
|
| 234 |
"total_articles": 20
|
| 235 |
},
|
| 236 |
"cloud-alibaba": {
|
|
@@ -242,8 +242,8 @@
|
|
| 242 |
0,
|
| 243 |
0
|
| 244 |
],
|
| 245 |
-
"last_fetch": "2026-02-
|
| 246 |
-
"total_fetches":
|
| 247 |
"total_articles": 20
|
| 248 |
},
|
| 249 |
"cloud-digitalocean": {
|
|
@@ -255,8 +255,8 @@
|
|
| 255 |
0,
|
| 256 |
0
|
| 257 |
],
|
| 258 |
-
"last_fetch": "2026-02-
|
| 259 |
-
"total_fetches":
|
| 260 |
"total_articles": 20
|
| 261 |
},
|
| 262 |
"cloud-huawei": {
|
|
@@ -268,21 +268,21 @@
|
|
| 268 |
0,
|
| 269 |
0
|
| 270 |
],
|
| 271 |
-
"last_fetch": "2026-02-
|
| 272 |
-
"total_fetches":
|
| 273 |
"total_articles": 20
|
| 274 |
},
|
| 275 |
"cloud-cloudflare": {
|
| 276 |
-
"interval":
|
| 277 |
"history": [
|
| 278 |
20,
|
| 279 |
-
|
| 280 |
-
|
| 281 |
0,
|
| 282 |
0
|
| 283 |
],
|
| 284 |
-
"last_fetch": "2026-02-
|
| 285 |
-
"total_fetches":
|
| 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())
|