Spaces:
Running
Running
feat: complete OpenAlex integration as 4th discovery source (search + download + discipline mapping)
Browse files- app/api/routes/analysis.py +22 -0
- app/api/routes/discovery.py +108 -76
- app/services/openalex_service.py +185 -65
app/api/routes/analysis.py
CHANGED
|
@@ -17,6 +17,7 @@ from app.services.k2_think_engine import K2ThinkEngine
|
|
| 17 |
from app.models.schemas import ScientificDocument, AnalysisRequest as K2AnalysisRequest, DocumentType, ChatRequest, ChatResponse
|
| 18 |
from app.services.export_service import ExportService
|
| 19 |
from app.services.arxiv_service import ArXivService
|
|
|
|
| 20 |
from fastapi.responses import Response, FileResponse
|
| 21 |
|
| 22 |
router = APIRouter()
|
|
@@ -183,6 +184,27 @@ async def get_specific_analysis(
|
|
| 183 |
except Exception as pubmed_err:
|
| 184 |
logger.error(f"DEMO_REAL: PubMed download failed for {pid}: {pubmed_err}")
|
| 185 |
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
else:
|
| 187 |
# ArXiv ID (Standard)
|
| 188 |
logger.info(f"DEMO_REAL: {pid} not found locally. Attempting on-demand download...")
|
|
|
|
| 17 |
from app.models.schemas import ScientificDocument, AnalysisRequest as K2AnalysisRequest, DocumentType, ChatRequest, ChatResponse
|
| 18 |
from app.services.export_service import ExportService
|
| 19 |
from app.services.arxiv_service import ArXivService
|
| 20 |
+
from app.services.openalex_service import OpenAlexService
|
| 21 |
from fastapi.responses import Response, FileResponse
|
| 22 |
|
| 23 |
router = APIRouter()
|
|
|
|
| 184 |
except Exception as pubmed_err:
|
| 185 |
logger.error(f"DEMO_REAL: PubMed download failed for {pid}: {pubmed_err}")
|
| 186 |
continue
|
| 187 |
+
elif pid.startswith("openalex_"):
|
| 188 |
+
logger.info(f"DEMO_REAL: {pid} is OpenAlex. Attempting fetch and download...")
|
| 189 |
+
try:
|
| 190 |
+
openalex_service = OpenAlexService(download_dir=UPLOAD_DIR)
|
| 191 |
+
# Fetch paper metadata to get the OA PDF URL
|
| 192 |
+
# Extract the raw OpenAlex Work ID (e.g. W2987984220)
|
| 193 |
+
clean_id = pid.replace("openalex_", "")
|
| 194 |
+
search_results = openalex_service.fetch_papers(clean_id, max_results=1)
|
| 195 |
+
if search_results and search_results[0].get("url"):
|
| 196 |
+
file_to_process = openalex_service.download_paper(
|
| 197 |
+
search_results[0]["url"], pid
|
| 198 |
+
)
|
| 199 |
+
if not file_to_process:
|
| 200 |
+
logger.error(f"DEMO_REAL: OpenAlex download returned empty path for {pid}")
|
| 201 |
+
continue
|
| 202 |
+
else:
|
| 203 |
+
logger.error(f"DEMO_REAL: No OA PDF URL found for OpenAlex {pid}")
|
| 204 |
+
continue
|
| 205 |
+
except Exception as oa_err:
|
| 206 |
+
logger.error(f"DEMO_REAL: OpenAlex download failed for {pid}: {oa_err}")
|
| 207 |
+
continue
|
| 208 |
else:
|
| 209 |
# ArXiv ID (Standard)
|
| 210 |
logger.info(f"DEMO_REAL: {pid} not found locally. Attempting on-demand download...")
|
app/api/routes/discovery.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
"""
|
| 2 |
-
Discovery API Routes
|
| 3 |
"""
|
| 4 |
from fastapi import APIRouter, Depends, HTTPException
|
| 5 |
from sqlalchemy.orm import Session
|
|
@@ -9,6 +9,7 @@ from app.dependencies import get_db
|
|
| 9 |
from app.services.arxiv_service import ArXivService
|
| 10 |
from app.services.doaj_service import DOAJService
|
| 11 |
from app.services.pubmed_service import PubMedService
|
|
|
|
| 12 |
from app.services.analysis_service import AnalysisService
|
| 13 |
from app.core.logging import logger
|
| 14 |
import os
|
|
@@ -28,135 +29,166 @@ class DiscoveryResponse(BaseModel):
|
|
| 28 |
def map_to_discipline(paper: dict) -> str:
|
| 29 |
"""
|
| 30 |
Maps a paper to one of the 5 target disciplines based on its metadata.
|
|
|
|
| 31 |
"""
|
| 32 |
source = paper.get("source", "")
|
| 33 |
sources = paper.get("sources", [source])
|
| 34 |
-
categories = paper.get("categories", [])
|
| 35 |
title = paper.get("title", "").lower()
|
| 36 |
summary = paper.get("summary", "").lower()
|
| 37 |
|
| 38 |
-
#
|
|
|
|
|
|
|
|
|
|
| 39 |
if "PubMed" in sources:
|
| 40 |
return "Life Sciences & Medicine"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
# Priority 2: Computer Science & AI
|
| 43 |
-
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
| 45 |
return "Computer Science & AI"
|
| 46 |
|
| 47 |
# Priority 3: Physics & Mathematics
|
| 48 |
-
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
| 50 |
return "Physics & Mathematics"
|
| 51 |
|
| 52 |
# Priority 4: Engineering
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
if
|
|
|
|
| 56 |
return "Engineering"
|
| 57 |
|
| 58 |
# Priority 5: Social Sciences
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
if
|
|
|
|
| 62 |
return "Social Sciences"
|
| 63 |
|
| 64 |
-
# Fallback based on DOAJ subjects if still missing
|
| 65 |
-
doaj_subjects = [s.lower() for s in categories] if source == "DOAJ" else []
|
| 66 |
-
if any("medicine" in s or "biology" in s for s in doaj_subjects): return "Life Sciences & Medicine"
|
| 67 |
-
if any("social" in s or "law" in s or "education" in s for s in doaj_subjects): return "Social Sciences"
|
| 68 |
-
if any("mathematics" in s or "physics" in s for s in doaj_subjects): return "Physics & Mathematics"
|
| 69 |
-
if any("technology" in s or "engineering" in s for s in doaj_subjects): return "Engineering"
|
| 70 |
-
if any("computer" in s for s in doaj_subjects): return "Computer Science & AI"
|
| 71 |
-
|
| 72 |
return "General Science"
|
| 73 |
|
| 74 |
@router.post("/search", response_model=DiscoveryResponse)
|
| 75 |
async def discovery_search(request: SearchRequest, db: Session = Depends(get_db)):
|
| 76 |
"""
|
| 77 |
-
Search ArXiv, DOAJ,
|
| 78 |
"""
|
|
|
|
| 79 |
try:
|
| 80 |
# 1. Fetch from ArXiv
|
| 81 |
arxiv_service = ArXivService(download_dir="./uploaded_files")
|
| 82 |
arxiv_data = arxiv_service.fetch_papers(request.query, request.max_results)
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
| 84 |
# 2. Fetch from DOAJ
|
| 85 |
doaj_service = DOAJService(download_dir="./uploaded_files")
|
| 86 |
doaj_data = doaj_service.fetch_papers(request.query, request.max_results)
|
| 87 |
-
|
| 88 |
# 3. Fetch from PubMed
|
| 89 |
pubmed_service = PubMedService(download_dir="./uploaded_files")
|
| 90 |
pubmed_data = pubmed_service.fetch_papers(request.query, request.max_results)
|
| 91 |
-
|
| 92 |
-
# 4.
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
for p in pubmed_data:
|
| 103 |
-
all_papers.append(p)
|
| 104 |
-
|
| 105 |
for paper in all_papers:
|
| 106 |
-
|
| 107 |
-
norm_title = re.sub(r'\W+', '', paper
|
| 108 |
-
|
|
|
|
|
|
|
| 109 |
if norm_title in seen_titles:
|
|
|
|
| 110 |
existing_index = seen_titles[norm_title]
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
if
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
#
|
| 117 |
-
if "
|
| 118 |
-
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
else:
|
| 121 |
-
paper["sources"] = [paper["source"]]
|
| 122 |
-
if paper["source"] == "ArXiv":
|
| 123 |
-
paper["has_pdf"] = True
|
| 124 |
-
|
| 125 |
seen_titles[norm_title] = len(merged_results)
|
| 126 |
merged_results.append({
|
| 127 |
-
"title": paper["title"],
|
| 128 |
"id": paper["id"],
|
| 129 |
-
"
|
| 130 |
-
"
|
| 131 |
-
"
|
|
|
|
| 132 |
"url": paper.get("url"),
|
| 133 |
-
"sources": paper["sources"],
|
| 134 |
"has_pdf": paper.get("has_pdf", False),
|
| 135 |
-
"
|
|
|
|
|
|
|
| 136 |
})
|
| 137 |
-
|
| 138 |
-
#
|
| 139 |
-
for
|
| 140 |
-
|
| 141 |
-
"sources":
|
| 142 |
-
"categories":
|
| 143 |
-
"title":
|
| 144 |
-
"summary":
|
| 145 |
-
}
|
| 146 |
-
|
| 147 |
-
# Remove raw_categories to keep response clean
|
| 148 |
-
merged_results[i].pop("raw_categories", None)
|
| 149 |
|
| 150 |
if not merged_results:
|
| 151 |
raise HTTPException(status_code=404, detail="No papers found for this query")
|
| 152 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
return DiscoveryResponse(
|
| 154 |
-
message=
|
|
|
|
|
|
|
|
|
|
| 155 |
papers=merged_results,
|
| 156 |
-
analysis_id="",
|
| 157 |
status="idle"
|
| 158 |
)
|
| 159 |
-
|
|
|
|
|
|
|
| 160 |
except Exception as e:
|
| 161 |
logger.error(f"Discovery error: {e}")
|
| 162 |
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
| 1 |
"""
|
| 2 |
+
Discovery API Routes — unified search across ArXiv, DOAJ, PubMed, and OpenAlex.
|
| 3 |
"""
|
| 4 |
from fastapi import APIRouter, Depends, HTTPException
|
| 5 |
from sqlalchemy.orm import Session
|
|
|
|
| 9 |
from app.services.arxiv_service import ArXivService
|
| 10 |
from app.services.doaj_service import DOAJService
|
| 11 |
from app.services.pubmed_service import PubMedService
|
| 12 |
+
from app.services.openalex_service import OpenAlexService
|
| 13 |
from app.services.analysis_service import AnalysisService
|
| 14 |
from app.core.logging import logger
|
| 15 |
import os
|
|
|
|
| 29 |
def map_to_discipline(paper: dict) -> str:
|
| 30 |
"""
|
| 31 |
Maps a paper to one of the 5 target disciplines based on its metadata.
|
| 32 |
+
Handles: ArXiv (category codes), PubMed (source), DOAJ (subjects), OpenAlex (topics).
|
| 33 |
"""
|
| 34 |
source = paper.get("source", "")
|
| 35 |
sources = paper.get("sources", [source])
|
| 36 |
+
categories = paper.get("categories", []) # ArXiv codes or topic names
|
| 37 |
title = paper.get("title", "").lower()
|
| 38 |
summary = paper.get("summary", "").lower()
|
| 39 |
|
| 40 |
+
# Lowercase join of all category/topic labels for keyword matching
|
| 41 |
+
cats_lower = " ".join(c.lower() for c in categories)
|
| 42 |
+
|
| 43 |
+
# Priority 1: Life Sciences & Medicine
|
| 44 |
if "PubMed" in sources:
|
| 45 |
return "Life Sciences & Medicine"
|
| 46 |
+
life_keywords = ["medicine", "biology", "clinical", "health", "disease", "genomics",
|
| 47 |
+
"pharmaceutical", "neuroscience", "biomedical", "cancer", "patient"]
|
| 48 |
+
if any(k in cats_lower or k in title or k in summary for k in life_keywords):
|
| 49 |
+
return "Life Sciences & Medicine"
|
| 50 |
|
| 51 |
# Priority 2: Computer Science & AI
|
| 52 |
+
cs_arxiv = any(c.startswith("cs.") for c in categories)
|
| 53 |
+
cs_keywords = ["computer science", "machine learning", "deep learning", "neural network",
|
| 54 |
+
"artificial intelligence", "algorithm", "natural language", "computer vision",
|
| 55 |
+
"reinforcement learning", "large language model"]
|
| 56 |
+
if cs_arxiv or any(k in cats_lower or k in title or k in summary for k in cs_keywords):
|
| 57 |
return "Computer Science & AI"
|
| 58 |
|
| 59 |
# Priority 3: Physics & Mathematics
|
| 60 |
+
math_phys_arxiv = ["math.", "phys.", "stat.", "astro-ph.", "quant-ph.", "nlin.", "gr-qc.", "hep-"]
|
| 61 |
+
math_phys_keywords = ["mathematics", "physics", "quantum", "statistics", "astrophysics",
|
| 62 |
+
"thermodynamics", "algebra", "topology", "calculus"]
|
| 63 |
+
if (any(any(c.startswith(mp) for mp in math_phys_arxiv) for c in categories)
|
| 64 |
+
or any(k in cats_lower or k in title or k in summary for k in math_phys_keywords)):
|
| 65 |
return "Physics & Mathematics"
|
| 66 |
|
| 67 |
# Priority 4: Engineering
|
| 68 |
+
eng_keywords = ["engineering", "electronics", "circuits", "hardware", "material science",
|
| 69 |
+
"robotics", "signal processing", "nanotechnology", "semiconductor"]
|
| 70 |
+
if (any(c.startswith("eess.") for c in categories)
|
| 71 |
+
or any(k in cats_lower or k in title or k in summary for k in eng_keywords)):
|
| 72 |
return "Engineering"
|
| 73 |
|
| 74 |
# Priority 5: Social Sciences
|
| 75 |
+
soc_keywords = ["sociology", "psychology", "economics", "philosophy", "social science",
|
| 76 |
+
"humanities", "political science", "anthropology", "education", "law"]
|
| 77 |
+
if (any(c.startswith(("econ.", "q-fin.")) for c in categories)
|
| 78 |
+
or any(k in cats_lower or k in title or k in summary for k in soc_keywords)):
|
| 79 |
return "Social Sciences"
|
| 80 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
return "General Science"
|
| 82 |
|
| 83 |
@router.post("/search", response_model=DiscoveryResponse)
|
| 84 |
async def discovery_search(request: SearchRequest, db: Session = Depends(get_db)):
|
| 85 |
"""
|
| 86 |
+
Search ArXiv, DOAJ, PubMed, and OpenAlex — returns merged, deduplicated metadata.
|
| 87 |
"""
|
| 88 |
+
import re
|
| 89 |
try:
|
| 90 |
# 1. Fetch from ArXiv
|
| 91 |
arxiv_service = ArXivService(download_dir="./uploaded_files")
|
| 92 |
arxiv_data = arxiv_service.fetch_papers(request.query, request.max_results)
|
| 93 |
+
for p in arxiv_data:
|
| 94 |
+
p["source"] = "ArXiv"
|
| 95 |
+
p["has_pdf"] = True # ArXiv always has PDF
|
| 96 |
+
|
| 97 |
# 2. Fetch from DOAJ
|
| 98 |
doaj_service = DOAJService(download_dir="./uploaded_files")
|
| 99 |
doaj_data = doaj_service.fetch_papers(request.query, request.max_results)
|
| 100 |
+
|
| 101 |
# 3. Fetch from PubMed
|
| 102 |
pubmed_service = PubMedService(download_dir="./uploaded_files")
|
| 103 |
pubmed_data = pubmed_service.fetch_papers(request.query, request.max_results)
|
| 104 |
+
|
| 105 |
+
# 4. Fetch from OpenAlex (only open-access works)
|
| 106 |
+
openalex_service = OpenAlexService(download_dir="./uploaded_files")
|
| 107 |
+
openalex_data = openalex_service.fetch_papers(request.query, request.max_results)
|
| 108 |
+
|
| 109 |
+
# 5. Merge and deduplicate by normalized title
|
| 110 |
+
seen_titles: dict = {}
|
| 111 |
+
merged_results: list = []
|
| 112 |
+
|
| 113 |
+
all_papers = arxiv_data + doaj_data + pubmed_data + openalex_data
|
| 114 |
+
|
|
|
|
|
|
|
|
|
|
| 115 |
for paper in all_papers:
|
| 116 |
+
source = paper.get("source", "Unknown")
|
| 117 |
+
norm_title = re.sub(r'\W+', '', paper.get("title", "").lower())
|
| 118 |
+
if not norm_title:
|
| 119 |
+
continue
|
| 120 |
+
|
| 121 |
if norm_title in seen_titles:
|
| 122 |
+
# Merge into existing entry
|
| 123 |
existing_index = seen_titles[norm_title]
|
| 124 |
+
existing = merged_results[existing_index]
|
| 125 |
+
|
| 126 |
+
if source not in existing["sources"]:
|
| 127 |
+
existing["sources"].append(source)
|
| 128 |
+
|
| 129 |
+
# Upgrade to direct PDF URL if current entry doesn't have one
|
| 130 |
+
if paper.get("has_pdf") and not existing.get("has_pdf"):
|
| 131 |
+
existing["url"] = paper["url"]
|
| 132 |
+
existing["has_pdf"] = True
|
| 133 |
+
|
| 134 |
+
# Merge categories/topics
|
| 135 |
+
current_cats = existing.get("raw_categories", [])
|
| 136 |
+
new_cats = paper.get("categories", [])
|
| 137 |
+
existing["raw_categories"] = list(dict.fromkeys(current_cats + new_cats))
|
| 138 |
+
|
| 139 |
+
# Prefer longer/richer abstract
|
| 140 |
+
if len(paper.get("summary", "")) > len(existing.get("summary", "")):
|
| 141 |
+
existing["summary"] = paper["summary"]
|
| 142 |
+
|
| 143 |
+
# Carry cited_by_count if OpenAlex provides it
|
| 144 |
+
if paper.get("cited_by_count") and not existing.get("cited_by_count"):
|
| 145 |
+
existing["cited_by_count"] = paper["cited_by_count"]
|
| 146 |
else:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
seen_titles[norm_title] = len(merged_results)
|
| 148 |
merged_results.append({
|
|
|
|
| 149 |
"id": paper["id"],
|
| 150 |
+
"title": paper["title"],
|
| 151 |
+
"authors": paper.get("authors", []),
|
| 152 |
+
"summary": paper.get("summary", "No abstract available."),
|
| 153 |
+
"publication_date": paper.get("publication_date", "n.d."),
|
| 154 |
"url": paper.get("url"),
|
|
|
|
| 155 |
"has_pdf": paper.get("has_pdf", False),
|
| 156 |
+
"sources": [source],
|
| 157 |
+
"raw_categories": paper.get("categories", []),
|
| 158 |
+
"cited_by_count": paper.get("cited_by_count", 0),
|
| 159 |
})
|
| 160 |
+
|
| 161 |
+
# 6. Assign disciplines and clean up temp fields
|
| 162 |
+
for entry in merged_results:
|
| 163 |
+
entry["discipline"] = map_to_discipline({
|
| 164 |
+
"sources": entry["sources"],
|
| 165 |
+
"categories": entry.get("raw_categories", []),
|
| 166 |
+
"title": entry["title"],
|
| 167 |
+
"summary": entry["summary"],
|
| 168 |
+
})
|
| 169 |
+
entry.pop("raw_categories", None) # Remove internal field from response
|
|
|
|
|
|
|
| 170 |
|
| 171 |
if not merged_results:
|
| 172 |
raise HTTPException(status_code=404, detail="No papers found for this query")
|
| 173 |
+
|
| 174 |
+
logger.info(
|
| 175 |
+
f"Discovery: {len(merged_results)} unique papers "
|
| 176 |
+
f"(ArXiv:{len(arxiv_data)} DOAJ:{len(doaj_data)} "
|
| 177 |
+
f"PubMed:{len(pubmed_data)} OpenAlex:{len(openalex_data)})"
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
return DiscoveryResponse(
|
| 181 |
+
message=(
|
| 182 |
+
f"Found {len(merged_results)} unique papers from "
|
| 183 |
+
f"ArXiv, DOAJ, PubMed & OpenAlex."
|
| 184 |
+
),
|
| 185 |
papers=merged_results,
|
| 186 |
+
analysis_id="",
|
| 187 |
status="idle"
|
| 188 |
)
|
| 189 |
+
|
| 190 |
+
except HTTPException:
|
| 191 |
+
raise
|
| 192 |
except Exception as e:
|
| 193 |
logger.error(f"Discovery error: {e}")
|
| 194 |
raise HTTPException(status_code=500, detail=str(e))
|
app/services/openalex_service.py
CHANGED
|
@@ -1,85 +1,205 @@
|
|
| 1 |
"""
|
| 2 |
-
Service for searching papers
|
|
|
|
|
|
|
| 3 |
"""
|
| 4 |
import httpx
|
| 5 |
-
|
|
|
|
| 6 |
from app.core.logging import logger
|
| 7 |
|
|
|
|
| 8 |
class OpenAlexService:
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
|
|
|
|
|
|
|
|
|
| 13 |
def fetch_papers(self, query: str, max_results: int = 10) -> List[Dict]:
|
| 14 |
"""
|
| 15 |
-
Search OpenAlex for papers
|
|
|
|
|
|
|
| 16 |
"""
|
| 17 |
-
logger.info(f"Searching OpenAlex for: {query} (max: {max_results})")
|
|
|
|
| 18 |
params = {
|
| 19 |
"search": query,
|
| 20 |
"mailto": self.email,
|
| 21 |
-
"per-page": max_results
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
}
|
|
|
|
| 23 |
results = []
|
| 24 |
try:
|
| 25 |
-
with httpx.Client() as client:
|
| 26 |
-
response = client.get(self.
|
| 27 |
response.raise_for_status()
|
| 28 |
data = response.json()
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
if abs_dict:
|
| 40 |
-
# Find max index to pre-allocate array
|
| 41 |
-
max_idx = -1
|
| 42 |
-
for indices in abs_dict.values():
|
| 43 |
-
for idx in indices:
|
| 44 |
-
if idx > max_idx:
|
| 45 |
-
max_idx = idx
|
| 46 |
-
|
| 47 |
-
if max_idx >= 0:
|
| 48 |
-
words = [""] * (max_idx + 1)
|
| 49 |
-
for word, indices in abs_dict.items():
|
| 50 |
-
for idx in indices:
|
| 51 |
-
words[idx] = word
|
| 52 |
-
abstract = " ".join([w for w in words if w]).strip()
|
| 53 |
-
else:
|
| 54 |
-
abstract = "No abstract available."
|
| 55 |
-
|
| 56 |
-
# Extract PDF url if open access
|
| 57 |
-
oa = item.get("open_access", {})
|
| 58 |
-
pdf_url = oa.get("oa_url")
|
| 59 |
-
|
| 60 |
-
authors = []
|
| 61 |
-
for authorship in item.get("authorships", []):
|
| 62 |
-
author = authorship.get("author", {})
|
| 63 |
-
if author.get("display_name"):
|
| 64 |
-
authors.append(author.get("display_name"))
|
| 65 |
-
|
| 66 |
-
concepts = [c.get("display_name") for c in item.get("concepts", [])[:5]]
|
| 67 |
-
|
| 68 |
-
# Ensure ID is clean (OpenAlex ids look like https://openalex.org/W298...)
|
| 69 |
-
raw_id = item.get("id", "")
|
| 70 |
-
clean_id = raw_id.split("/")[-1] if raw_id else "unknown_id"
|
| 71 |
-
|
| 72 |
-
results.append({
|
| 73 |
-
"id": clean_id,
|
| 74 |
-
"title": item.get("title"),
|
| 75 |
-
"authors": authors,
|
| 76 |
-
"summary": abstract,
|
| 77 |
-
"url": pdf_url or item.get("doi"),
|
| 78 |
-
"has_pdf": bool(pdf_url),
|
| 79 |
-
"categories": concepts,
|
| 80 |
-
"publication_date": item.get("publication_date")
|
| 81 |
-
})
|
| 82 |
except Exception as e:
|
| 83 |
-
logger.error(f"
|
| 84 |
-
|
| 85 |
return results
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
Service for searching and downloading papers from OpenAlex.
|
| 3 |
+
OpenAlex is a fully open catalog of the global research system (200M+ works).
|
| 4 |
+
API docs: https://developers.openalex.org/
|
| 5 |
"""
|
| 6 |
import httpx
|
| 7 |
+
import os
|
| 8 |
+
from typing import List, Dict, Optional
|
| 9 |
from app.core.logging import logger
|
| 10 |
|
| 11 |
+
|
| 12 |
class OpenAlexService:
|
| 13 |
+
"""
|
| 14 |
+
Service complet pour OpenAlex : recherche + téléchargement PDF open-access.
|
| 15 |
+
Suit exactement le même pattern que ArXivService, DOAJService, PubMedService.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
BASE_URL = "https://api.openalex.org/works"
|
| 19 |
+
|
| 20 |
+
def __init__(
|
| 21 |
+
self,
|
| 22 |
+
download_dir: str = "./uploaded_files",
|
| 23 |
+
email: str = "soumanadama93@gmail.com"
|
| 24 |
+
):
|
| 25 |
+
self.download_dir = download_dir
|
| 26 |
+
self.email = email # Required by OpenAlex polite pool (higher rate limits)
|
| 27 |
+
if not os.path.exists(self.download_dir):
|
| 28 |
+
os.makedirs(self.download_dir)
|
| 29 |
|
| 30 |
+
# ------------------------------------------------------------------
|
| 31 |
+
# PUBLIC: fetch_papers — identique aux autres services
|
| 32 |
+
# ------------------------------------------------------------------
|
| 33 |
def fetch_papers(self, query: str, max_results: int = 10) -> List[Dict]:
|
| 34 |
"""
|
| 35 |
+
Search OpenAlex for papers and return normalized metadata.
|
| 36 |
+
Uses 'polite pool' via mailto param for better rate limits.
|
| 37 |
+
Filters: only open-access works with a usable PDF or landing page URL.
|
| 38 |
"""
|
| 39 |
+
logger.info(f"Searching OpenAlex for: '{query}' (max: {max_results})")
|
| 40 |
+
|
| 41 |
params = {
|
| 42 |
"search": query,
|
| 43 |
"mailto": self.email,
|
| 44 |
+
"per-page": min(max_results, 50), # OpenAlex max per-page = 200
|
| 45 |
+
"sort": "relevance_score:desc",
|
| 46 |
+
# Only return open-access works to maximize PDF availability
|
| 47 |
+
"filter": "is_oa:true",
|
| 48 |
+
# Select only the fields we need (faster, lighter response)
|
| 49 |
+
"select": (
|
| 50 |
+
"id,title,authorships,abstract_inverted_index,"
|
| 51 |
+
"open_access,topics,publication_date,doi,"
|
| 52 |
+
"cited_by_count,primary_location"
|
| 53 |
+
),
|
| 54 |
}
|
| 55 |
+
|
| 56 |
results = []
|
| 57 |
try:
|
| 58 |
+
with httpx.Client(timeout=20.0) as client:
|
| 59 |
+
response = client.get(self.BASE_URL, params=params)
|
| 60 |
response.raise_for_status()
|
| 61 |
data = response.json()
|
| 62 |
+
|
| 63 |
+
for item in data.get("results", []):
|
| 64 |
+
parsed = self._parse_item(item)
|
| 65 |
+
if parsed:
|
| 66 |
+
results.append(parsed)
|
| 67 |
+
|
| 68 |
+
logger.info(f"OpenAlex returned {len(results)} results for '{query}'")
|
| 69 |
+
|
| 70 |
+
except httpx.HTTPStatusError as e:
|
| 71 |
+
logger.error(f"OpenAlex HTTP error {e.response.status_code}: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
except Exception as e:
|
| 73 |
+
logger.error(f"OpenAlex search error: {e}")
|
| 74 |
+
|
| 75 |
return results
|
| 76 |
+
|
| 77 |
+
# ------------------------------------------------------------------
|
| 78 |
+
# PUBLIC: download_paper — même signature que DOAJService/PubMedService
|
| 79 |
+
# ------------------------------------------------------------------
|
| 80 |
+
def download_paper(self, paper_url: str, paper_id: str) -> str:
|
| 81 |
+
"""
|
| 82 |
+
Downloads an open-access PDF from OpenAlex oa_url.
|
| 83 |
+
Returns local file path, or empty string on failure.
|
| 84 |
+
"""
|
| 85 |
+
if not paper_url:
|
| 86 |
+
logger.warning(f"OpenAlex: no URL provided for {paper_id}")
|
| 87 |
+
return ""
|
| 88 |
+
|
| 89 |
+
# Sanitize filename (OpenAlex IDs look like "W2987984220")
|
| 90 |
+
safe_id = paper_id.replace("/", "_").replace(":", "_")
|
| 91 |
+
file_name = f"openalex_{safe_id}.pdf"
|
| 92 |
+
file_path = os.path.join(self.download_dir, file_name)
|
| 93 |
+
|
| 94 |
+
if os.path.exists(file_path):
|
| 95 |
+
logger.info(f"OpenAlex: cache hit for {paper_id}")
|
| 96 |
+
return file_path
|
| 97 |
+
|
| 98 |
+
logger.info(f"OpenAlex: downloading {paper_id} from {paper_url}")
|
| 99 |
+
try:
|
| 100 |
+
with httpx.Client(timeout=60.0, follow_redirects=True) as client:
|
| 101 |
+
response = client.get(paper_url)
|
| 102 |
+
response.raise_for_status()
|
| 103 |
+
|
| 104 |
+
content_type = response.headers.get("content-type", "").lower()
|
| 105 |
+
if "application/pdf" not in content_type and len(response.content) < 1000:
|
| 106 |
+
logger.warning(
|
| 107 |
+
f"OpenAlex: response for {paper_id} is not a PDF "
|
| 108 |
+
f"(Content-Type: {content_type})"
|
| 109 |
+
)
|
| 110 |
+
return ""
|
| 111 |
+
|
| 112 |
+
with open(file_path, "wb") as f:
|
| 113 |
+
f.write(response.content)
|
| 114 |
+
|
| 115 |
+
logger.info(f"OpenAlex: saved {paper_id} → {file_path}")
|
| 116 |
+
return file_path
|
| 117 |
+
|
| 118 |
+
except Exception as e:
|
| 119 |
+
logger.error(f"OpenAlex: failed to download {paper_id}: {e}")
|
| 120 |
+
return ""
|
| 121 |
+
|
| 122 |
+
# ------------------------------------------------------------------
|
| 123 |
+
# PRIVATE: helpers
|
| 124 |
+
# ------------------------------------------------------------------
|
| 125 |
+
def _parse_item(self, item: dict) -> Optional[Dict]:
|
| 126 |
+
"""
|
| 127 |
+
Normalizes a single OpenAlex work into the common paper dict format
|
| 128 |
+
shared by all services in this project.
|
| 129 |
+
"""
|
| 130 |
+
title = item.get("title")
|
| 131 |
+
if not title:
|
| 132 |
+
return None # Skip works without title
|
| 133 |
+
|
| 134 |
+
# --- ID ---
|
| 135 |
+
raw_id = item.get("id", "")
|
| 136 |
+
# Looks like "https://openalex.org/W2987984220" → clean to "W2987984220"
|
| 137 |
+
clean_id = f"openalex_{raw_id.split('/')[-1]}" if raw_id else "openalex_unknown"
|
| 138 |
+
|
| 139 |
+
# --- Abstract (inverted index → plain text) ---
|
| 140 |
+
abstract = self._reconstruct_abstract(item.get("abstract_inverted_index"))
|
| 141 |
+
|
| 142 |
+
# --- Authors ---
|
| 143 |
+
authors = []
|
| 144 |
+
for authorship in item.get("authorships", []):
|
| 145 |
+
name = authorship.get("author", {}).get("display_name")
|
| 146 |
+
if name:
|
| 147 |
+
authors.append(name)
|
| 148 |
+
|
| 149 |
+
# --- Open Access PDF URL ---
|
| 150 |
+
oa = item.get("open_access", {})
|
| 151 |
+
pdf_url = oa.get("oa_url") # Direct PDF or landing page
|
| 152 |
+
has_pdf = bool(pdf_url)
|
| 153 |
+
|
| 154 |
+
# Fallback: primary_location landing page
|
| 155 |
+
if not pdf_url:
|
| 156 |
+
primary = item.get("primary_location") or {}
|
| 157 |
+
pdf_url = primary.get("landing_page_url") or primary.get("pdf_url")
|
| 158 |
+
has_pdf = False # It's a landing page, not a direct PDF
|
| 159 |
+
|
| 160 |
+
# Fallback: DOI
|
| 161 |
+
if not pdf_url and item.get("doi"):
|
| 162 |
+
pdf_url = item["doi"]
|
| 163 |
+
|
| 164 |
+
# --- Topics/Categories (replacing deprecated concepts) ---
|
| 165 |
+
topics = []
|
| 166 |
+
for topic in item.get("topics", [])[:5]:
|
| 167 |
+
name = topic.get("display_name")
|
| 168 |
+
if name:
|
| 169 |
+
topics.append(name)
|
| 170 |
+
|
| 171 |
+
return {
|
| 172 |
+
"id": clean_id,
|
| 173 |
+
"title": title,
|
| 174 |
+
"authors": authors if authors else ["Unknown"],
|
| 175 |
+
"summary": abstract,
|
| 176 |
+
"url": pdf_url,
|
| 177 |
+
"has_pdf": has_pdf,
|
| 178 |
+
"categories": topics,
|
| 179 |
+
"publication_date": item.get("publication_date", "n.d."),
|
| 180 |
+
"cited_by_count": item.get("cited_by_count", 0),
|
| 181 |
+
"source": "OpenAlex",
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
@staticmethod
|
| 185 |
+
def _reconstruct_abstract(abstract_inverted_index: Optional[dict]) -> str:
|
| 186 |
+
"""
|
| 187 |
+
OpenAlex stores abstracts as an inverted index: {word: [positions]}.
|
| 188 |
+
This reconstructs the plain text.
|
| 189 |
+
"""
|
| 190 |
+
if not abstract_inverted_index:
|
| 191 |
+
return "No abstract available."
|
| 192 |
+
|
| 193 |
+
try:
|
| 194 |
+
max_idx = max(
|
| 195 |
+
idx
|
| 196 |
+
for positions in abstract_inverted_index.values()
|
| 197 |
+
for idx in positions
|
| 198 |
+
)
|
| 199 |
+
words = [""] * (max_idx + 1)
|
| 200 |
+
for word, positions in abstract_inverted_index.items():
|
| 201 |
+
for idx in positions:
|
| 202 |
+
words[idx] = word
|
| 203 |
+
return " ".join(w for w in words if w).strip()
|
| 204 |
+
except Exception:
|
| 205 |
+
return "Abstract reconstruction failed."
|