Dama12 commited on
Commit
e596a0b
·
1 Parent(s): d15b04e

feat: complete OpenAlex integration as 4th discovery source (search + download + discipline mapping)

Browse files
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
- # Priority 1: Life Sciences & Medicine (PubMed always maps here)
 
 
 
39
  if "PubMed" in sources:
40
  return "Life Sciences & Medicine"
 
 
 
 
41
 
42
  # Priority 2: Computer Science & AI
43
- cs_keywords = ["computer science", "neural network", "deep learning", "artificial intelligence", "algorithms"]
44
- if any(c.startswith("cs.") for c in categories) or any(k in title or k in summary for k in cs_keywords):
 
 
 
45
  return "Computer Science & AI"
46
 
47
  # Priority 3: Physics & Mathematics
48
- math_phys_categories = ["math.", "phys.", "stat.", "astro-ph.", "quant-ph.", "nlin."]
49
- if any(any(c.startswith(mp) for mp in math_phys_categories) for c in categories):
 
 
 
50
  return "Physics & Mathematics"
51
 
52
  # Priority 4: Engineering
53
- eng_categories = ["eess."]
54
- eng_keywords = ["engineering", "electronics", "circuits", "hardware", "material science"]
55
- if any(any(c.startswith(ec) for ec in eng_categories) for c in categories) or any(k in title or k in summary for k in eng_keywords):
 
56
  return "Engineering"
57
 
58
  # Priority 5: Social Sciences
59
- soc_categories = ["econ.", "q-fin."]
60
- soc_keywords = ["sociology", "psychology", "economics", "philosophy", "social", "humanities"]
61
- if any(any(c.startswith(sc) for sc in soc_categories) for c in categories) or any(k in title or k in summary for k in soc_keywords):
 
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, and PubMed and return merged metadata for browsing.
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. Merge and deduplicate by title
93
- seen_titles = {}
94
- merged_results = []
95
-
96
- all_papers = []
97
- for p in arxiv_data:
98
- p["source"] = "ArXiv"
99
- all_papers.append(p)
100
- for p in doaj_data:
101
- all_papers.append(p)
102
- for p in pubmed_data:
103
- all_papers.append(p)
104
-
105
  for paper in all_papers:
106
- import re
107
- norm_title = re.sub(r'\W+', '', paper["title"].lower())
108
-
 
 
109
  if norm_title in seen_titles:
 
110
  existing_index = seen_titles[norm_title]
111
- if paper["source"] not in merged_results[existing_index]["sources"]:
112
- merged_results[existing_index]["sources"].append(paper["source"])
113
- if paper.get("has_pdf") and not merged_results[existing_index].get("has_pdf"):
114
- merged_results[existing_index]["url"] = paper["url"]
115
- merged_results[existing_index]["has_pdf"] = True
116
- # Merge categories
117
- if "categories" in paper:
118
- current_cats = merged_results[existing_index].get("raw_categories", [])
119
- merged_results[existing_index]["raw_categories"] = list(set(current_cats + paper["categories"]))
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- "authors": paper["authors"],
130
- "summary": paper["summary"],
131
- "publication_date": paper["publication_date"],
 
132
  "url": paper.get("url"),
133
- "sources": paper["sources"],
134
  "has_pdf": paper.get("has_pdf", False),
135
- "raw_categories": paper.get("categories", [])
 
 
136
  })
137
-
138
- # 5. Assign disciplines
139
- for i in range(len(merged_results)):
140
- paper_for_mapping = {
141
- "sources": merged_results[i]["sources"],
142
- "categories": merged_results[i].get("raw_categories", []),
143
- "title": merged_results[i]["title"],
144
- "summary": merged_results[i]["summary"]
145
- }
146
- merged_results[i]["discipline"] = map_to_discipline(paper_for_mapping)
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=f"Found {len(merged_results)} unique papers matching your query.",
 
 
 
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 via OpenAlex.
 
 
3
  """
4
  import httpx
5
- from typing import List, Dict
 
6
  from app.core.logging import logger
7
 
 
8
  class OpenAlexService:
9
- def __init__(self, email: str = "soumanadama93@gmail.com"):
10
- self.base_url = "https://api.openalex.org/works"
11
- self.email = email
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
 
 
 
13
  def fetch_papers(self, query: str, max_results: int = 10) -> List[Dict]:
14
  """
15
- Search OpenAlex for papers (Metadata).
 
 
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.base_url, params=params, timeout=15.0)
27
  response.raise_for_status()
28
  data = response.json()
29
- items = data.get("results", [])
30
-
31
- for item in items:
32
- # Ignore results with no title
33
- if not item.get("title"):
34
- continue
35
-
36
- # Reconstruct inverted abstract
37
- abstract = ""
38
- abs_dict = item.get("abstract_inverted_index")
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"Error fetching from OpenAlex: {e}")
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."