Adi12345 commited on
Commit
5e08bf7
·
verified ·
1 Parent(s): b66aafa

Create self_learning_graph.py

Browse files
Files changed (1) hide show
  1. dvnc_ai_v2_hf/self_learning_graph.py +1001 -0
dvnc_ai_v2_hf/self_learning_graph.py ADDED
@@ -0,0 +1,1001 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import html
2
+ import os
3
+ import re
4
+ import urllib.parse
5
+ import xml.etree.ElementTree as ET
6
+ from pathlib import Path
7
+ from typing import Dict, List, Optional
8
+ from urllib.parse import quote
9
+
10
+ import gradio as gr
11
+ import requests
12
+
13
+ try:
14
+ import fitz # PyMuPDF
15
+ except Exception:
16
+ fitz = None
17
+
18
+ try:
19
+ from bs4 import BeautifulSoup
20
+ except Exception:
21
+ BeautifulSoup = None
22
+
23
+
24
+ JOURNALS = [
25
+ {"name": "Nature", "url": "https://www.nature.com/search", "desc": "Flagship multidisciplinary research journal."},
26
+ {"name": "Science", "url": "https://www.science.org/search", "desc": "High-impact science journal and family."},
27
+ {"name": "Cell", "url": "https://www.cell.com/search", "desc": "Life sciences and translational biology."},
28
+ {"name": "The Lancet", "url": "https://www.thelancet.com/search", "desc": "Clinical and medical research."},
29
+ {"name": "IEEE Xplore", "url": "https://ieeexplore.ieee.org/search/searchresult.jsp", "desc": "Engineering, AI, signal processing, and systems."},
30
+ ]
31
+
32
+ SEARCH_MODES = ["topic", "title", "doi", "link", "paper_name", "autonomous_web"]
33
+ SOURCE_OPTIONS = ["arxiv", "crossref", "openalex", "semantic_scholar", "europe_pmc"]
34
+ DEFAULT_SOURCES = ["arxiv", "openalex", "crossref", "semantic_scholar", "europe_pmc"]
35
+
36
+ SEMANTIC_SCHOLAR_API_KEY = os.getenv("SEMANTIC_SCHOLAR_API_KEY", "")
37
+ GROBID_URL = os.getenv("GROBID_URL", "").strip()
38
+ REQUEST_TIMEOUT = 25
39
+
40
+
41
+ def safe_text(x, default=""):
42
+ return html.escape(str(x if x is not None else default))
43
+
44
+
45
+ def norm_text(x: Optional[str]) -> str:
46
+ return re.sub(r"\s+", " ", (x or "")).strip()
47
+
48
+
49
+ def detect_query_type(query: str) -> str:
50
+ q = (query or "").strip()
51
+ doi_pattern = r"^10\.\d{4,9}/[-._;()/:A-Z0-9]+$"
52
+ if re.match(doi_pattern, q, flags=re.I):
53
+ return "doi"
54
+ if q.startswith("http://") or q.startswith("https://"):
55
+ return "link"
56
+ return "topic"
57
+
58
+
59
+ def ensure_list(x):
60
+ return x if isinstance(x, list) else []
61
+
62
+
63
+ def build_learning_graph_html(nodes, edges, title="Self-Learning Knowledge Graph"):
64
+ if not nodes:
65
+ return """
66
+ <div class="panel brain-shell">
67
+ <div class="brain-header">
68
+ <div>
69
+ <p class="eyebrow">Learning Graph</p>
70
+ <h3>Self-Learning Knowledge Graph</h3>
71
+ </div>
72
+ </div>
73
+ <div class="brain-stage learning-empty">
74
+ <div class="empty-graph-copy">
75
+ <h4>No papers mapped yet</h4>
76
+ <p>Search papers, pick a topic, select candidates, or upload a PDF to grow the graph in real time.</p>
77
+ </div>
78
+ </div>
79
+ </div>
80
+ """
81
+
82
+ node_items = []
83
+ label_items = []
84
+ edge_items = []
85
+ coords = [(110, 110), (320, 80), (540, 130), (660, 270), (550, 410), (300, 450), (110, 340), (380, 260)]
86
+
87
+ graph_nodes = [dict(n) for n in nodes[:8]]
88
+ for i, node in enumerate(graph_nodes):
89
+ x, y = coords[i]
90
+ node["sx"] = x
91
+ node["sy"] = y
92
+
93
+ node_map = {n["id"]: n for n in graph_nodes}
94
+ for a, b in edges:
95
+ if a in node_map and b in node_map:
96
+ na = node_map[a]
97
+ nb = node_map[b]
98
+ edge_items.append(
99
+ f'<line class="learn-edge" x1="{na["sx"]}" y1="{na["sy"]}" x2="{nb["sx"]}" y2="{nb["sy"]}" />'
100
+ )
101
+
102
+ for node in graph_nodes:
103
+ kind = node.get("kind", "paper")
104
+ klass = f'learn-node {kind}'
105
+ radius = 24 if kind == "query" else 20
106
+ node_items.append(
107
+ f'<circle class="{klass}" cx="{node["sx"]}" cy="{node["sy"]}" r="{radius}" />'
108
+ )
109
+ label_items.append(
110
+ f'<text class="learn-label" x="{node["sx"] + 28}" y="{node["sy"] - 10}">{safe_text(node["label"][:44])}</text>'
111
+ )
112
+
113
+ return f"""
114
+ <div class="panel brain-shell">
115
+ <div class="brain-header">
116
+ <div>
117
+ <p class="eyebrow">Learning Graph</p>
118
+ <h3>{safe_text(title)}</h3>
119
+ </div>
120
+ <div class="brain-legend">
121
+ <span><i class="dot dot-query"></i> query</span>
122
+ <span><i class="dot dot-paper"></i> paper</span>
123
+ <span><i class="dot dot-upload"></i> uploaded PDF</span>
124
+ </div>
125
+ </div>
126
+ <div class="brain-stage">
127
+ <svg viewBox="0 0 760 520" class="brain-svg" role="img" aria-label="Self-learning knowledge graph">
128
+ {''.join(edge_items)}
129
+ {''.join(node_items)}
130
+ {''.join(label_items)}
131
+ </svg>
132
+ </div>
133
+ </div>
134
+ """
135
+
136
+
137
+ def build_journal_html(query):
138
+ q = urllib.parse.quote_plus(query or "biomaterials cardiac repair")
139
+ rows = []
140
+ for journal in JOURNALS:
141
+ url = f"{journal['url']}?q={q}" if "?" not in journal["url"] else f"{journal['url']}&q={q}"
142
+ if "ieeexplore" in journal["url"]:
143
+ url = f"https://ieeexplore.ieee.org/search/searchresult.jsp?queryText={q}"
144
+ rows.append(
145
+ f"""
146
+ <a class="journal-card" href="{safe_text(url)}" target="_blank" rel="noopener noreferrer">
147
+ <div>
148
+ <h4>{safe_text(journal['name'])}</h4>
149
+ <p>{safe_text(journal['desc'])}</p>
150
+ </div>
151
+ <span>Open</span>
152
+ </a>
153
+ """
154
+ )
155
+ return '<div class="journal-grid">' + ''.join(rows) + '</div>'
156
+
157
+
158
+ def search_arxiv(query, max_results=8):
159
+ encoded = urllib.parse.quote(query)
160
+ url = (
161
+ "http://export.arxiv.org/api/query?search_query=all:"
162
+ f"{encoded}&start=0&max_results={max_results}&sortBy=relevance&sortOrder=descending"
163
+ )
164
+ response = requests.get(url, timeout=REQUEST_TIMEOUT)
165
+ response.raise_for_status()
166
+ root = ET.fromstring(response.text)
167
+ ns = {"atom": "http://www.w3.org/2005/Atom"}
168
+ papers = []
169
+ for entry in root.findall("atom:entry", ns):
170
+ title = " ".join((entry.findtext("atom:title", default="", namespaces=ns) or "").split())
171
+ summary = " ".join((entry.findtext("atom:summary", default="", namespaces=ns) or "").split())
172
+ published = entry.findtext("atom:published", default="", namespaces=ns)
173
+ paper_id = entry.findtext("atom:id", default="", namespaces=ns)
174
+ authors = [a.findtext("atom:name", default="", namespaces=ns) for a in entry.findall("atom:author", ns)]
175
+ pdf_url = ""
176
+ for link in entry.findall("atom:link", ns):
177
+ if link.attrib.get("title") == "pdf":
178
+ pdf_url = link.attrib.get("href", "")
179
+ break
180
+ papers.append(
181
+ {
182
+ "id": paper_id or title,
183
+ "title": title,
184
+ "summary": summary,
185
+ "abstract": summary,
186
+ "published": published[:10],
187
+ "authors": [a for a in authors[:8] if a],
188
+ "authors_text": ", ".join([a for a in authors[:4] if a]),
189
+ "url": paper_id,
190
+ "pdf": pdf_url,
191
+ "doi": "",
192
+ "venue": "arXiv",
193
+ "year": published[:4] if published else "",
194
+ "source": "arxiv",
195
+ "score": 0.76,
196
+ "open_access": True,
197
+ "external_ids": {"arxiv": (paper_id or "").split("/")[-1]},
198
+ }
199
+ )
200
+ return papers
201
+
202
+
203
+ def search_crossref(query, mode="topic", max_results=8):
204
+ headers = {"User-Agent": "dvnc-ai-space/0.1"}
205
+ if mode == "doi":
206
+ url = f"https://api.crossref.org/works/{quote(query)}"
207
+ response = requests.get(url, headers=headers, timeout=REQUEST_TIMEOUT)
208
+ if response.status_code != 200:
209
+ return []
210
+ items = [response.json().get("message", {})]
211
+ else:
212
+ params = {"rows": max_results}
213
+ if mode in ("title", "paper_name"):
214
+ params["query.title"] = query
215
+ else:
216
+ params["query.bibliographic"] = query
217
+ response = requests.get("https://api.crossref.org/works", params=params, headers=headers, timeout=REQUEST_TIMEOUT)
218
+ response.raise_for_status()
219
+ items = response.json().get("message", {}).get("items", [])
220
+
221
+ out = []
222
+ for item in items:
223
+ authors = []
224
+ for a in item.get("author", []) or []:
225
+ name = " ".join(filter(None, [a.get("given"), a.get("family")])).strip()
226
+ if name:
227
+ authors.append(name)
228
+ title = (item.get("title") or ["Untitled"])[0]
229
+ year = ""
230
+ for key in ["published-print", "published-online", "created"]:
231
+ if item.get(key, {}).get("date-parts"):
232
+ year = str(item[key]["date-parts"][0][0])
233
+ break
234
+ abstract = item.get("abstract") or ""
235
+ abstract = re.sub("<.*?>", "", abstract)
236
+ doi = item.get("DOI", "")
237
+ out.append({
238
+ "id": doi or title,
239
+ "title": norm_text(title),
240
+ "summary": norm_text(abstract)[:400],
241
+ "abstract": norm_text(abstract),
242
+ "published": year,
243
+ "authors": authors,
244
+ "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
245
+ "url": item.get("URL", ""),
246
+ "pdf": "",
247
+ "doi": doi,
248
+ "venue": (item.get("container-title") or [""])[0],
249
+ "year": year,
250
+ "source": "crossref",
251
+ "score": 0.72,
252
+ "open_access": None,
253
+ "external_ids": {"crossref": doi} if doi else {},
254
+ })
255
+ return out
256
+
257
+
258
+ def search_openalex(query, mode="topic", max_results=8):
259
+ params = {"per-page": max_results}
260
+ if mode == "doi":
261
+ doi = query.lower().replace("https://doi.org/", "").replace("http://doi.org/", "")
262
+ params["filter"] = f"doi:https://doi.org/{doi}"
263
+ else:
264
+ params["search"] = query
265
+ response = requests.get("https://api.openalex.org/works", params=params, timeout=REQUEST_TIMEOUT)
266
+ response.raise_for_status()
267
+ items = response.json().get("results", [])
268
+ out = []
269
+ for item in items:
270
+ authors = []
271
+ for auth in item.get("authorships", [])[:8]:
272
+ author = auth.get("author") or {}
273
+ if author.get("display_name"):
274
+ authors.append(author["display_name"])
275
+ oa = item.get("open_access") or {}
276
+ doi = (item.get("doi") or "").replace("https://doi.org/", "")
277
+ out.append({
278
+ "id": item.get("id") or doi or item.get("title"),
279
+ "title": norm_text(item.get("title")),
280
+ "summary": "",
281
+ "abstract": "",
282
+ "published": str(item.get("publication_year") or ""),
283
+ "authors": authors,
284
+ "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
285
+ "url": (item.get("primary_location") or {}).get("landing_page_url") or item.get("id") or "",
286
+ "pdf": oa.get("oa_url") or "",
287
+ "doi": doi,
288
+ "venue": ((item.get("primary_location") or {}).get("source") or {}).get("display_name") or "",
289
+ "year": str(item.get("publication_year") or ""),
290
+ "source": "openalex",
291
+ "score": 0.80,
292
+ "open_access": oa.get("is_oa"),
293
+ "external_ids": item.get("ids") or {},
294
+ })
295
+ return out
296
+
297
+
298
+ def search_semantic_scholar(query, mode="topic", max_results=8):
299
+ headers = {}
300
+ if SEMANTIC_SCHOLAR_API_KEY:
301
+ headers["x-api-key"] = SEMANTIC_SCHOLAR_API_KEY
302
+ fields = "title,authors,year,abstract,venue,externalIds,url,openAccessPdf"
303
+ if mode == "doi":
304
+ doi = query.lower().replace("https://doi.org/", "").replace("http://doi.org/", "")
305
+ url = f"https://api.semanticscholar.org/graph/v1/paper/DOI:{quote(doi)}"
306
+ response = requests.get(url, params={"fields": fields}, headers=headers, timeout=REQUEST_TIMEOUT)
307
+ if response.status_code != 200:
308
+ return []
309
+ items = [response.json()]
310
+ else:
311
+ response = requests.get(
312
+ "https://api.semanticscholar.org/graph/v1/paper/search",
313
+ params={"query": query, "limit": max_results, "fields": fields},
314
+ headers=headers,
315
+ timeout=REQUEST_TIMEOUT,
316
+ )
317
+ if response.status_code != 200:
318
+ return []
319
+ items = response.json().get("data", [])
320
+
321
+ out = []
322
+ for item in items:
323
+ external = item.get("externalIds") or {}
324
+ authors = [a.get("name") for a in item.get("authors", []) if a.get("name")]
325
+ out.append({
326
+ "id": external.get("CorpusId") or external.get("DOI") or item.get("title"),
327
+ "title": norm_text(item.get("title")),
328
+ "summary": norm_text(item.get("abstract", ""))[:400],
329
+ "abstract": norm_text(item.get("abstract", "")),
330
+ "published": str(item.get("year") or ""),
331
+ "authors": authors,
332
+ "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
333
+ "url": item.get("url") or "",
334
+ "pdf": (item.get("openAccessPdf") or {}).get("url") or "",
335
+ "doi": external.get("DOI", ""),
336
+ "venue": item.get("venue") or "",
337
+ "year": str(item.get("year") or ""),
338
+ "source": "semantic_scholar",
339
+ "score": 0.84,
340
+ "open_access": bool((item.get("openAccessPdf") or {}).get("url")),
341
+ "external_ids": external,
342
+ })
343
+ return out
344
+
345
+
346
+ def search_europe_pmc(query, mode="topic", max_results=8):
347
+ epmc_query = f'DOI:"{query}"' if mode == "doi" else query
348
+ params = {
349
+ "query": epmc_query,
350
+ "format": "json",
351
+ "pageSize": max_results,
352
+ "resultType": "core",
353
+ }
354
+ response = requests.get("https://www.ebi.ac.uk/europepmc/webservices/rest/search", params=params, timeout=REQUEST_TIMEOUT)
355
+ if response.status_code != 200:
356
+ return []
357
+ items = response.json().get("resultList", {}).get("result", [])
358
+ out = []
359
+ for item in items:
360
+ author_string = item.get("authorString", "")
361
+ authors = [x.strip() for x in author_string.split(",")[:8] if x.strip()]
362
+ pmcid = item.get("pmcid", "")
363
+ pdf_url = f"https://europepmc.org/articles/{pmcid}?pdf=render" if pmcid else ""
364
+ landing_url = f"https://europepmc.org/article/PMC/{pmcid}" if pmcid else ""
365
+ out.append({
366
+ "id": item.get("id") or item.get("doi") or item.get("title"),
367
+ "title": norm_text(item.get("title")),
368
+ "summary": norm_text(item.get("abstractText", ""))[:400],
369
+ "abstract": norm_text(item.get("abstractText", "")),
370
+ "published": str(item.get("pubYear") or ""),
371
+ "authors": authors,
372
+ "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
373
+ "url": landing_url,
374
+ "pdf": pdf_url,
375
+ "doi": item.get("doi", ""),
376
+ "venue": item.get("journalTitle", ""),
377
+ "year": str(item.get("pubYear") or ""),
378
+ "source": "europe_pmc",
379
+ "score": 0.78,
380
+ "open_access": bool(pmcid),
381
+ "external_ids": {"pmid": item.get("pmid"), "pmcid": pmcid},
382
+ })
383
+ return out
384
+
385
+
386
+ def resolve_link(query):
387
+ url = (query or "").strip()
388
+ if not url:
389
+ return []
390
+ try:
391
+ response = requests.get(
392
+ url,
393
+ timeout=REQUEST_TIMEOUT,
394
+ allow_redirects=True,
395
+ headers={"User-Agent": "dvnc-ai-space/0.1"},
396
+ )
397
+ content_type = response.headers.get("content-type", "")
398
+ if "pdf" in content_type or url.lower().endswith(".pdf"):
399
+ name = Path(url.split("?")[0]).name or "linked-paper.pdf"
400
+ return [{
401
+ "id": url,
402
+ "title": name,
403
+ "summary": "Direct PDF link detected.",
404
+ "abstract": "",
405
+ "published": "",
406
+ "authors": [],
407
+ "authors_text": "Unknown authors",
408
+ "url": url,
409
+ "pdf": url,
410
+ "doi": "",
411
+ "venue": "Direct PDF",
412
+ "year": "",
413
+ "source": "link",
414
+ "score": 0.66,
415
+ "open_access": True,
416
+ "external_ids": {},
417
+ }]
418
+
419
+ if BeautifulSoup is None:
420
+ return [{
421
+ "id": url,
422
+ "title": url,
423
+ "summary": "Web page resolved. Install beautifulsoup4 for DOI extraction.",
424
+ "abstract": "",
425
+ "published": "",
426
+ "authors": [],
427
+ "authors_text": "Unknown authors",
428
+ "url": url,
429
+ "pdf": "",
430
+ "doi": "",
431
+ "venue": "Web Link",
432
+ "year": "",
433
+ "source": "link",
434
+ "score": 0.48,
435
+ "open_access": None,
436
+ "external_ids": {},
437
+ }]
438
+
439
+ soup = BeautifulSoup(response.text, "html.parser")
440
+ doi = ""
441
+ for meta_name in ["citation_doi", "dc.identifier", "dc.Identifier"]:
442
+ tag = soup.find("meta", attrs={"name": meta_name})
443
+ if tag and tag.get("content"):
444
+ doi = tag["content"].strip()
445
+ break
446
+
447
+ title = soup.title.text.strip() if soup.title else url
448
+ pdf_link = ""
449
+ for a in soup.find_all("a", href=True):
450
+ href = a["href"]
451
+ if ".pdf" in href.lower():
452
+ pdf_link = href if href.startswith("http") else ""
453
+ break
454
+
455
+ if doi:
456
+ results = search_crossref(doi, mode="doi", max_results=1)
457
+ if results:
458
+ if pdf_link and not results[0].get("pdf"):
459
+ results[0]["pdf"] = pdf_link
460
+ if url and not results[0].get("url"):
461
+ results[0]["url"] = url
462
+ return results
463
+
464
+ return [{
465
+ "id": url,
466
+ "title": title,
467
+ "summary": "Landing page resolved from direct link.",
468
+ "abstract": "",
469
+ "published": "",
470
+ "authors": [],
471
+ "authors_text": "Unknown authors",
472
+ "url": url,
473
+ "pdf": pdf_link,
474
+ "doi": doi,
475
+ "venue": "Web Link",
476
+ "year": "",
477
+ "source": "link",
478
+ "score": 0.54,
479
+ "open_access": bool(pdf_link),
480
+ "external_ids": {},
481
+ }]
482
+ except Exception as e:
483
+ return [{
484
+ "id": url,
485
+ "title": "Link resolution error",
486
+ "summary": str(e),
487
+ "abstract": "",
488
+ "published": "",
489
+ "authors": [],
490
+ "authors_text": "Unknown authors",
491
+ "url": url,
492
+ "pdf": "",
493
+ "doi": "",
494
+ "venue": "Link",
495
+ "year": "",
496
+ "source": "link",
497
+ "score": 0.20,
498
+ "open_access": None,
499
+ "external_ids": {},
500
+ }]
501
+
502
+
503
+ def dedupe_papers(items: List[Dict]) -> List[Dict]:
504
+ seen = {}
505
+ for item in items:
506
+ key = (
507
+ (item.get("doi") or "").lower().strip()
508
+ or (item.get("external_ids") or {}).get("arxiv")
509
+ or norm_text(item.get("title", "")).lower()
510
+ or item.get("id")
511
+ )
512
+ if not key:
513
+ key = f"{item.get('source')}::{item.get('title')}"
514
+ if key not in seen or float(item.get("score", 0)) > float(seen[key].get("score", 0)):
515
+ seen[key] = item
516
+ return sorted(seen.values(), key=lambda x: float(x.get("score", 0)), reverse=True)
517
+
518
+
519
+ def discover_papers(query, mode, sources, max_results=10):
520
+ query = (query or "").strip()
521
+ if not query:
522
+ return []
523
+
524
+ mode = detect_query_type(query) if mode == "autonomous_web" else mode
525
+ selected_sources = ensure_list(sources) or DEFAULT_SOURCES
526
+ results = []
527
+
528
+ if mode == "link":
529
+ return dedupe_papers(resolve_link(query))
530
+
531
+ try:
532
+ if "arxiv" in selected_sources and mode != "doi":
533
+ results.extend(search_arxiv(query, max_results=max_results))
534
+ except Exception:
535
+ pass
536
+ try:
537
+ if "crossref" in selected_sources:
538
+ results.extend(search_crossref(query, mode=mode, max_results=max_results))
539
+ except Exception:
540
+ pass
541
+ try:
542
+ if "openalex" in selected_sources:
543
+ results.extend(search_openalex(query, mode=mode, max_results=max_results))
544
+ except Exception:
545
+ pass
546
+ try:
547
+ if "semantic_scholar" in selected_sources:
548
+ results.extend(search_semantic_scholar(query, mode=mode, max_results=max_results))
549
+ except Exception:
550
+ pass
551
+ try:
552
+ if "europe_pmc" in selected_sources:
553
+ results.extend(search_europe_pmc(query, mode=mode, max_results=max_results))
554
+ except Exception:
555
+ pass
556
+
557
+ return dedupe_papers(results)
558
+
559
+
560
+ def format_papers_html(papers):
561
+ if not papers:
562
+ return '<div class="panel papers-panel" style="padding:18px"><p>No papers found yet.</p></div>'
563
+
564
+ items = []
565
+ for i, paper in enumerate(papers, start=1):
566
+ summary = safe_text((paper.get("summary") or "")[:280])
567
+ doi_line = f'<span class="paper-badge doi-badge">{safe_text(paper.get("doi"))}</span>' if paper.get("doi") else ""
568
+ pdf_link = paper.get("pdf") or "#"
569
+ abs_link = paper.get("url") or "#"
570
+ items.append(
571
+ f"""
572
+ <article class="paper-card">
573
+ <div class="paper-topline">
574
+ <span class="paper-badge">{safe_text(paper.get('source', 'paper'))}</span>
575
+ <span class="paper-badge alt">{safe_text(paper.get('published', '') or 'Paper')}</span>
576
+ {doi_line}
577
+ </div>
578
+ <h4>{i}. {safe_text(paper.get('title', 'Untitled'))}</h4>
579
+ <p>{summary or 'No abstract snippet available.'}</p>
580
+ <div class="paper-meta-stack">
581
+ <div><strong>Authors:</strong> {safe_text(paper.get('authors_text', 'Unknown authors'))}</div>
582
+ <div><strong>Venue:</strong> {safe_text(paper.get('venue', 'Unknown venue'))}</div>
583
+ <div><strong>Score:</strong> {safe_text(round(float(paper.get('score', 0)), 3))}</div>
584
+ </div>
585
+ <div class="paper-links">
586
+ <a href="{safe_text(abs_link)}" target="_blank" rel="noopener noreferrer">Abstract</a>
587
+ <a href="{safe_text(pdf_link)}" target="_blank" rel="noopener noreferrer">PDF</a>
588
+ </div>
589
+ </article>
590
+ """
591
+ )
592
+ return '<div class="papers-grid">' + ''.join(items) + '</div>'
593
+
594
+
595
+ def format_selection_choices(papers):
596
+ choices = []
597
+ for i, paper in enumerate(papers):
598
+ label = f"[{paper.get('source', 'src')}] {paper.get('title', 'Untitled')} — {paper.get('authors_text', 'Unknown authors')[:80]}"
599
+ choices.append((label, str(i)))
600
+ return choices
601
+
602
+
603
+ def uploaded_pdf_summary(file_obj):
604
+ if not file_obj:
605
+ return "No PDF uploaded yet."
606
+ path = getattr(file_obj, "name", None) or str(file_obj)
607
+ p = Path(path)
608
+ return f"Uploaded PDF ready for ingestion: {p.name}. Use Parse uploaded PDF to extract title, abstract, sections, and references."
609
+
610
+
611
+ def build_learning_graph_state(query, papers, uploaded_name=None):
612
+ nodes = [{"id": "query", "label": query or "Research topic", "kind": "query"}]
613
+ edges = []
614
+ for i, paper in enumerate(papers[:5], start=1):
615
+ pid = f"paper_{i}"
616
+ nodes.append({"id": pid, "label": paper["title"], "kind": "paper"})
617
+ edges.append(("query", pid))
618
+ if uploaded_name:
619
+ nodes.append({"id": "upload", "label": uploaded_name, "kind": "upload"})
620
+ edges.append(("query", "upload"))
621
+ if len(nodes) > 2:
622
+ edges.append(("upload", "paper_1"))
623
+ return nodes, edges
624
+
625
+
626
+ def graph_from_selected(query, selected_papers, uploaded_name=None):
627
+ nodes = [{"id": "query", "label": query or "Research topic", "kind": "query"}]
628
+ edges = []
629
+ for i, paper in enumerate(selected_papers[:6], start=1):
630
+ pid = f"paper_{i}"
631
+ nodes.append({"id": pid, "label": paper["title"], "kind": "paper"})
632
+ edges.append(("query", pid))
633
+ if paper.get("doi"):
634
+ doi_id = f"doi_{i}"
635
+ nodes.append({"id": doi_id, "label": f"DOI {paper['doi']}", "kind": "paper"})
636
+ edges.append((pid, doi_id))
637
+ if uploaded_name:
638
+ nodes.append({"id": "upload", "label": uploaded_name, "kind": "upload"})
639
+ edges.append(("query", "upload"))
640
+ if len(selected_papers) > 0:
641
+ edges.append(("upload", "paper_1"))
642
+ return nodes, edges
643
+
644
+
645
+ def parse_pdf_with_grobid(pdf_path):
646
+ if not GROBID_URL:
647
+ raise RuntimeError("GROBID_URL is not set")
648
+
649
+ with open(pdf_path, "rb") as f:
650
+ files = {"input": (Path(pdf_path).name, f, "application/pdf")}
651
+ response = requests.post(
652
+ f"{GROBID_URL.rstrip('/')}/api/processFulltextDocument",
653
+ files=files,
654
+ data={"includeRawAffiliations": "1", "segmentSentences": "1"},
655
+ timeout=120,
656
+ )
657
+
658
+ response.raise_for_status()
659
+ tei_xml = response.text
660
+
661
+ if BeautifulSoup is None:
662
+ return {
663
+ "parser": "grobid",
664
+ "title": Path(pdf_path).name,
665
+ "abstract": "",
666
+ "authors": [],
667
+ "sections": [],
668
+ "references": [],
669
+ "raw_text": "",
670
+ "tei_xml": tei_xml[:30000],
671
+ }
672
+
673
+ soup = BeautifulSoup(tei_xml, "xml")
674
+ title_stmt = soup.find("titleStmt")
675
+ title_tag = title_stmt.find("title") if title_stmt else soup.find("title")
676
+ abstract_tag = soup.find("abstract")
677
+
678
+ authors = []
679
+ for author in soup.find_all("author"):
680
+ pers = author.find("persName")
681
+ if pers:
682
+ name = " ".join(
683
+ x.get_text(" ", strip=True)
684
+ for x in pers.find_all(["forename", "surname"])
685
+ ).strip()
686
+ if name:
687
+ authors.append(name)
688
+
689
+ sections = []
690
+ for div in soup.find_all("div"):
691
+ head = div.find("head")
692
+ paras = [p.get_text(" ", strip=True) for p in div.find_all("p")]
693
+ text = "\n".join([p for p in paras if p.strip()])
694
+ if head and text.strip():
695
+ sections.append({"heading": head.get_text(" ", strip=True), "text": text[:4000]})
696
+
697
+ references = []
698
+ for bibl in soup.find_all("biblStruct")[:30]:
699
+ ref_title = ""
700
+ ref_doi = ""
701
+ title_node = bibl.find("title")
702
+ if title_node:
703
+ ref_title = title_node.get_text(" ", strip=True)
704
+ doi_node = bibl.find("idno", attrs={"type": "DOI"})
705
+ if doi_node:
706
+ ref_doi = doi_node.get_text(" ", strip=True)
707
+ references.append({"title": ref_title, "doi": ref_doi})
708
+
709
+ return {
710
+ "parser": "grobid",
711
+ "title": title_tag.get_text(" ", strip=True) if title_tag else Path(pdf_path).name,
712
+ "abstract": abstract_tag.get_text(" ", strip=True) if abstract_tag else "",
713
+ "authors": authors,
714
+ "sections": sections,
715
+ "references": references,
716
+ "raw_text": "",
717
+ "tei_xml": tei_xml[:60000],
718
+ }
719
+
720
+
721
+ def parse_pdf_with_pymupdf(pdf_path):
722
+ if fitz is None:
723
+ raise RuntimeError("PyMuPDF not installed")
724
+
725
+ doc = fitz.open(pdf_path)
726
+ pages = [page.get_text("text") for page in doc]
727
+ raw_text = "\n".join(pages).strip()
728
+ first_page = raw_text[:4000]
729
+ lines = [x.strip() for x in first_page.splitlines() if x.strip()]
730
+ title = lines[0][:300] if lines else Path(pdf_path).name
731
+
732
+ abstract = ""
733
+ match = re.search(r"abstract\s*(.+?)(?:\n\s*\n|\n1[\.\s]|introduction)", raw_text, re.I | re.S)
734
+ if match:
735
+ abstract = norm_text(match.group(1))[:2000]
736
+
737
+ return {
738
+ "parser": "pymupdf",
739
+ "title": title,
740
+ "abstract": abstract,
741
+ "authors": [],
742
+ "sections": [{"heading": "Full Text", "text": raw_text[:12000]}] if raw_text else [],
743
+ "references": [],
744
+ "raw_text": raw_text[:50000],
745
+ "tei_xml": "",
746
+ }
747
+
748
+
749
+ def parse_pdf_with_docling(pdf_path):
750
+ try:
751
+ from docling.document_converter import DocumentConverter
752
+ except Exception as e:
753
+ raise RuntimeError(f"Docling import failed: {e}")
754
+
755
+ converter = DocumentConverter()
756
+ result = converter.convert(pdf_path)
757
+ doc = result.document
758
+ markdown = doc.export_to_markdown()
759
+
760
+ title = Path(pdf_path).name
761
+ first_nonempty = next((line.strip("# ").strip() for line in markdown.splitlines() if line.strip()), "")
762
+ if first_nonempty:
763
+ title = first_nonempty[:300]
764
+
765
+ return {
766
+ "parser": "docling",
767
+ "title": title,
768
+ "abstract": "",
769
+ "authors": [],
770
+ "sections": [{"heading": "Document", "text": markdown[:12000]}] if markdown else [],
771
+ "references": [],
772
+ "raw_text": markdown[:50000],
773
+ "tei_xml": "",
774
+ }
775
+
776
+
777
+ def parse_uploaded_pdf(file_obj, parser_order):
778
+ if not file_obj:
779
+ return "No PDF uploaded yet.", {}
780
+
781
+ path = getattr(file_obj, "name", None) or str(file_obj)
782
+ parser_order = ensure_list(parser_order) or ["grobid", "docling", "pymupdf"]
783
+ errors = []
784
+
785
+ for parser_name in parser_order:
786
+ try:
787
+ if parser_name == "grobid":
788
+ result = parse_pdf_with_grobid(path)
789
+ elif parser_name == "docling":
790
+ result = parse_pdf_with_docling(path)
791
+ elif parser_name == "pymupdf":
792
+ result = parse_pdf_with_pymupdf(path)
793
+ else:
794
+ continue
795
+
796
+ summary = (
797
+ f"### Parsed PDF\n\n"
798
+ f"- Parser used: {result['parser']}\n"
799
+ f"- Title: {result.get('title') or 'Unknown'}\n"
800
+ f"- Authors: {', '.join(result.get('authors')[:6]) if result.get('authors') else 'Unknown'}\n"
801
+ f"- Abstract found: {'Yes' if result.get('abstract') else 'No'}\n"
802
+ f"- Sections extracted: {len(result.get('sections') or [])}\n"
803
+ f"- References extracted: {len(result.get('references') or [])}\n"
804
+ )
805
+ return summary, result
806
+ except Exception as e:
807
+ errors.append(f"{parser_name}: {e}")
808
+
809
+ fail_summary = "### PDF parsing failed\n\n" + "\n".join([f"- {x}" for x in errors])
810
+ return fail_summary, {"parser": None, "errors": errors}
811
+
812
+
813
+ def render_parse_result(parsed):
814
+ if not parsed:
815
+ return '<div class="panel" style="padding:18px"><p>No parsed document yet.</p></div>'
816
+
817
+ sections_html = []
818
+ for section in parsed.get("sections", [])[:6]:
819
+ sections_html.append(
820
+ f"""
821
+ <details class="agent-step">
822
+ <summary class="agent-summary">
823
+ <div class="agent-index">§</div>
824
+ <div class="agent-head">
825
+ <h4>{safe_text(section.get('heading', 'Section'))}</h4>
826
+ <span>section</span>
827
+ </div>
828
+ </summary>
829
+ <div class="agent-copy">
830
+ <p>{safe_text(section.get('text', '')[:1800])}</p>
831
+ </div>
832
+ </details>
833
+ """
834
+ )
835
+
836
+ refs = parsed.get("references", [])[:12]
837
+ refs_html = "".join(
838
+ f"<li>{safe_text(r.get('title') or 'Untitled')} {'· DOI ' + safe_text(r.get('doi')) if r.get('doi') else ''}</li>"
839
+ for r in refs
840
+ ) or "<li>No references extracted.</li>"
841
+
842
+ title = safe_text(parsed.get("title") or "Parsed document")
843
+ abstract = safe_text((parsed.get("abstract") or "")[:2400]) or "No abstract extracted."
844
+ parser_name = safe_text(parsed.get("parser") or "unknown")
845
+
846
+ return f"""
847
+ <div class="panel" style="padding:18px">
848
+ <div class="brain-header">
849
+ <div>
850
+ <p class="eyebrow">PDF Parse</p>
851
+ <h3>{title}</h3>
852
+ </div>
853
+ <div class="brain-legend"><span><i class="dot dot-upload"></i> {parser_name}</span></div>
854
+ </div>
855
+ <div class="parse-grid">
856
+ <div class="parse-card">
857
+ <h4>Abstract</h4>
858
+ <p>{abstract}</p>
859
+ </div>
860
+ <div class="parse-card">
861
+ <h4>References</h4>
862
+ <ul class="ref-list">{refs_html}</ul>
863
+ </div>
864
+ </div>
865
+ <div class="timeline" style="margin-top:14px;">
866
+ {''.join(sections_html) if sections_html else '<div class="panel" style="padding:16px;"><p>No sections extracted.</p></div>'}
867
+ </div>
868
+ </div>
869
+ """
870
+
871
+
872
+ def build_ingest_payload(query, selected_papers, parsed_pdf=None):
873
+ nodes = [{"id": "topic:query", "type": "Topic", "label": query or "Research topic"}]
874
+ edges = []
875
+
876
+ for i, p in enumerate(selected_papers, start=1):
877
+ paper_id = p.get("doi") or (p.get("external_ids") or {}).get("arxiv") or f"paper:{i}"
878
+ nodes.append({
879
+ "id": paper_id,
880
+ "type": "Paper",
881
+ "title": p.get("title"),
882
+ "year": p.get("year"),
883
+ "venue": p.get("venue"),
884
+ "doi": p.get("doi"),
885
+ "source": p.get("source"),
886
+ "url": p.get("url"),
887
+ "pdf": p.get("pdf"),
888
+ })
889
+ edges.append({"source": "topic:query", "target": paper_id, "type": "ABOUT", "weight": p.get("score", 0)})
890
+
891
+ for author in p.get("authors", [])[:8]:
892
+ author_id = f"author:{author.lower()}"
893
+ nodes.append({"id": author_id, "type": "Author", "label": author})
894
+ edges.append({"source": paper_id, "target": author_id, "type": "WRITTEN_BY"})
895
+
896
+ if parsed_pdf and parsed_pdf.get("title"):
897
+ doc_id = "upload:pdf"
898
+ nodes.append({
899
+ "id": doc_id,
900
+ "type": "UploadedPDF",
901
+ "title": parsed_pdf.get("title"),
902
+ "parser": parsed_pdf.get("parser"),
903
+ })
904
+ edges.append({"source": "topic:query", "target": doc_id, "type": "UPLOADED_SOURCE"})
905
+ for idx, section in enumerate(parsed_pdf.get("sections", [])[:8], start=1):
906
+ sec_id = f"{doc_id}:section:{idx}"
907
+ nodes.append({"id": sec_id, "type": "Section", "label": section.get("heading") or f"Section {idx}"})
908
+ edges.append({"source": doc_id, "target": sec_id, "type": "HAS_SECTION"})
909
+ for idx, ref in enumerate(parsed_pdf.get("references", [])[:12], start=1):
910
+ ref_id = f"{doc_id}:ref:{idx}"
911
+ nodes.append({"id": ref_id, "type": "Reference", "label": ref.get("title") or f"Reference {idx}", "doi": ref.get("doi")})
912
+ edges.append({"source": doc_id, "target": ref_id, "type": "CITES"})
913
+
914
+ return {"status": "ok", "nodes": nodes, "edges": edges}
915
+
916
+
917
+ def run_paper_discovery(query, search_mode, sources, pdf_file):
918
+ query_text = (query or "").strip()
919
+
920
+ if not query_text and not pdf_file:
921
+ empty_graph = build_learning_graph_html([], [], "Self-Learning Knowledge Graph")
922
+ return (
923
+ empty_graph,
924
+ '<div class="panel papers-panel" style="padding:18px"><p>Enter a topic, title, DOI, link, or upload a PDF to start learning.</p></div>',
925
+ build_journal_html("biomaterials cardiac repair"),
926
+ "No PDF uploaded yet.",
927
+ gr.update(choices=[], value=[]),
928
+ [],
929
+ "### No discovery results yet.",
930
+ )
931
+
932
+ papers = []
933
+ if query_text:
934
+ try:
935
+ papers = discover_papers(query_text, search_mode, sources, max_results=10)
936
+ except Exception as e:
937
+ graph_nodes, graph_edges = build_learning_graph_state(
938
+ query_text,
939
+ [],
940
+ Path(getattr(pdf_file, "name", "uploaded.pdf")).name if pdf_file else None,
941
+ )
942
+ error_html = f'<div class="panel papers-panel" style="padding:18px"><p>Paper search failed: {safe_text(str(e))}</p></div>'
943
+ return (
944
+ build_learning_graph_html(graph_nodes, graph_edges),
945
+ error_html,
946
+ build_journal_html(query_text or "biomaterials cardiac repair"),
947
+ uploaded_pdf_summary(pdf_file),
948
+ gr.update(choices=[], value=[]),
949
+ [],
950
+ f"### Discovery failed.\n\n- Error: {safe_text(str(e))}",
951
+ )
952
+
953
+ uploaded_name = Path(getattr(pdf_file, "name", str(pdf_file))).name if pdf_file else None
954
+ graph_nodes, graph_edges = build_learning_graph_state(query_text, papers[:5], uploaded_name)
955
+ graph_html = build_learning_graph_html(graph_nodes, graph_edges)
956
+ papers_html = format_papers_html(papers)
957
+ journals_html = build_journal_html(query_text or "biomaterials cardiac repair")
958
+ pdf_summary = uploaded_pdf_summary(pdf_file)
959
+ choices = format_selection_choices(papers)
960
+
961
+ status_md = (
962
+ f"### Discovery results\n\n"
963
+ f"- Search mode: {search_mode}\n"
964
+ f"- Sources: {', '.join(ensure_list(sources) or DEFAULT_SOURCES)}\n"
965
+ f"- Candidates found: {len(papers)}\n"
966
+ f"- Select papers below, then click **Ingest selected into graph**.\n"
967
+ )
968
+ return graph_html, papers_html, journals_html, pdf_summary, gr.update(choices=choices, value=[]), papers, status_md
969
+
970
+
971
+ def ingest_selected_papers(query, selected_indices, papers_state, pdf_file, parsed_state):
972
+ papers = ensure_list(papers_state)
973
+ selected_indices = ensure_list(selected_indices)
974
+
975
+ selected = []
976
+ for idx in selected_indices:
977
+ try:
978
+ selected.append(papers[int(idx)])
979
+ except Exception:
980
+ pass
981
+
982
+ uploaded_name = Path(getattr(pdf_file, "name", str(pdf_file))).name if pdf_file else None
983
+
984
+ if not selected and not parsed_state:
985
+ graph_html = build_learning_graph_html([], [], "Self-Learning Knowledge Graph")
986
+ return graph_html, "### Nothing ingested yet.\n\nSelect papers or parse an uploaded PDF first.", {"status": "empty", "nodes": [], "edges": []}
987
+
988
+ graph_nodes, graph_edges = graph_from_selected(query, selected, uploaded_name)
989
+ graph_html = build_learning_graph_html(graph_nodes, graph_edges, "Selected Research Graph")
990
+ payload = build_ingest_payload(query, selected, parsed_state if isinstance(parsed_state, dict) else None)
991
+
992
+ summary_lines = [
993
+ "### Graph ingest ready",
994
+ "",
995
+ f"- Topic: {query or 'Research topic'}",
996
+ f"- Selected papers: {len(selected)}",
997
+ f"- Uploaded PDF parsed: {'Yes' if parsed_state and isinstance(parsed_state, dict) and parsed_state.get('title') else 'No'}",
998
+ f"- Nodes created: {len(payload['nodes'])}",
999
+ f"- Edges created: {len(payload['edges'])}",
1000
+ ]
1001
+ return graph_html, "\n".join(summary_lines), payload