Adi12345 commited on
Commit
f69a228
·
verified ·
1 Parent(s): 8783327

Create self_learning_graph.py

Browse files
Files changed (1) hide show
  1. dvnc_ai_v2_hf/self_learning_graph.py +1430 -0
dvnc_ai_v2_hf/self_learning_graph.py ADDED
@@ -0,0 +1,1430 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import html
2
+ import os
3
+ import re
4
+ import time
5
+ import urllib.parse
6
+ import xml.etree.ElementTree as ET
7
+ from collections import Counter
8
+ from pathlib import Path
9
+ from typing import Dict, List, Optional
10
+
11
+ import gradio as gr
12
+ import requests
13
+
14
+ try:
15
+ import fitz # PyMuPDF
16
+ except Exception:
17
+ fitz = None
18
+
19
+ try:
20
+ from bs4 import BeautifulSoup
21
+ except Exception:
22
+ BeautifulSoup = None
23
+
24
+
25
+ JOURNALS = [
26
+ {"name": "Nature", "url": "https://www.nature.com/search", "desc": "Flagship multidisciplinary research journal."},
27
+ {"name": "Science", "url": "https://www.science.org/search", "desc": "High-impact science journal and family."},
28
+ {"name": "Cell", "url": "https://www.cell.com/search", "desc": "Life sciences and translational biology."},
29
+ {"name": "The Lancet", "url": "https://www.thelancet.com/search", "desc": "Clinical and medical research."},
30
+ {"name": "IEEE Xplore", "url": "https://ieeexplore.ieee.org/search/searchresult.jsp", "desc": "Engineering, AI, signal processing, and systems."},
31
+ ]
32
+
33
+ SEARCH_MODES = ["topic", "title", "doi", "link", "paper_name", "autonomous_web"]
34
+ SOURCE_OPTIONS = ["arxiv", "crossref", "openalex", "semantic_scholar", "europe_pmc"]
35
+ DEFAULT_SOURCES = ["arxiv", "crossref", "openalex", "semantic_scholar", "europe_pmc"]
36
+
37
+ SEMANTIC_SCHOLAR_API_KEY = os.getenv("SEMANTIC_SCHOLAR_API_KEY", "").strip()
38
+ GROBID_URL = os.getenv("GROBID_URL", "").strip()
39
+ REQUEST_TIMEOUT = int(os.getenv("REQUEST_TIMEOUT", "25"))
40
+ GRAPH_MAX_CONCEPTS = int(os.getenv("GRAPH_MAX_CONCEPTS", "10"))
41
+ GRAPH_MAX_CLAIMS = int(os.getenv("GRAPH_MAX_CLAIMS", "6"))
42
+ GRAPH_MAX_RESULTS = int(os.getenv("GRAPH_MAX_RESULTS", "10"))
43
+
44
+ STOPWORDS = {
45
+ "a", "an", "and", "are", "as", "at", "be", "been", "being", "by", "can", "could", "did", "do", "does",
46
+ "for", "from", "had", "has", "have", "if", "in", "into", "is", "it", "its", "may", "might", "of", "on",
47
+ "or", "our", "such", "that", "the", "their", "there", "these", "this", "those", "to", "using", "use",
48
+ "used", "via", "was", "were", "will", "with", "within", "without", "we", "they", "you", "your", "study",
49
+ "paper", "research", "results", "method", "methods", "analysis", "approach", "toward", "towards",
50
+ "based", "new", "novel", "effect", "effects", "model", "models", "system", "systems", "show", "shows",
51
+ }
52
+
53
+ GRAPH_MEMORY = {
54
+ "papers": {},
55
+ "nodes": {},
56
+ "edges": [],
57
+ "concept_counts": Counter(),
58
+ "claim_counts": Counter(),
59
+ "queries": [],
60
+ "events": [],
61
+ }
62
+
63
+
64
+ def safe_text(x, default=""):
65
+ return html.escape(str(x if x is not None else default))
66
+
67
+
68
+ def norm_text(x: Optional[str]) -> str:
69
+ return re.sub(r"\s+", " ", (x or "")).strip()
70
+
71
+
72
+ def slugify(text: str) -> str:
73
+ return re.sub(r"[^a-z0-9]+", "-", (text or "").lower()).strip("-")
74
+
75
+
76
+ def ensure_list(x):
77
+ return x if isinstance(x, list) else []
78
+
79
+
80
+ def normalize_doi(text: str) -> str:
81
+ text = (text or "").strip()
82
+ text = text.replace("https://doi.org/", "").replace("http://doi.org/", "")
83
+ return text.strip()
84
+
85
+
86
+ def detect_query_type(query: str) -> str:
87
+ q = (query or "").strip()
88
+ doi_pattern = r"^10\.\d{4,9}/[-._;()/:A-Z0-9]+$"
89
+ if re.match(doi_pattern, q, flags=re.I):
90
+ return "doi"
91
+ if q.startswith("http://") or q.startswith("https://"):
92
+ return "link"
93
+ return "topic"
94
+
95
+
96
+ def tokenize(text: str) -> List[str]:
97
+ return [t for t in re.findall(r"[a-zA-Z][a-zA-Z0-9\-]{2,}", (text or "").lower()) if t not in STOPWORDS]
98
+
99
+
100
+ def unique_keep_order(items: List[str]) -> List[str]:
101
+ seen = set()
102
+ out = []
103
+ for item in items:
104
+ key = norm_text(item).lower()
105
+ if key and key not in seen:
106
+ seen.add(key)
107
+ out.append(norm_text(item))
108
+ return out
109
+
110
+
111
+ def text_overlap_score(a: str, b: str) -> float:
112
+ sa = set(tokenize(a))
113
+ sb = set(tokenize(b))
114
+ if not sa or not sb:
115
+ return 0.0
116
+ return len(sa & sb) / max(1, len(sa | sb))
117
+
118
+
119
+ def compute_recency_bonus(year: str) -> float:
120
+ try:
121
+ y = int(str(year)[:4])
122
+ except Exception:
123
+ return 0.0
124
+ current = time.gmtime().tm_year
125
+ age = max(current - y, 0)
126
+ return max(0.0, 0.12 - age * 0.015)
127
+
128
+
129
+ def extract_candidate_phrases(text: str, max_terms: int = 20) -> List[str]:
130
+ text = norm_text(text)
131
+ if not text:
132
+ return []
133
+ tokens = re.findall(r"[A-Za-z][A-Za-z0-9\-]{2,}", text)
134
+ phrases = []
135
+
136
+ for n in (3, 2, 1):
137
+ for i in range(len(tokens) - n + 1):
138
+ phrase = " ".join(tokens[i:i + n]).strip().lower()
139
+ if len(phrase) < 4:
140
+ continue
141
+ parts = phrase.split()
142
+ if any(p in STOPWORDS for p in parts):
143
+ continue
144
+ if all(len(p) <= 2 for p in parts):
145
+ continue
146
+ phrases.append(phrase)
147
+
148
+ counts = Counter(phrases)
149
+ ranked = [p for p, _ in counts.most_common(max_terms * 3)]
150
+
151
+ filtered = []
152
+ for phrase in ranked:
153
+ if phrase in filtered:
154
+ continue
155
+ if any(phrase != other and phrase in other for other in filtered):
156
+ continue
157
+ filtered.append(phrase)
158
+ if len(filtered) >= max_terms:
159
+ break
160
+
161
+ return filtered
162
+
163
+
164
+ def extract_concepts_from_text(text: str, max_terms: int = GRAPH_MAX_CONCEPTS) -> List[str]:
165
+ return extract_candidate_phrases(text, max_terms=max_terms)
166
+
167
+
168
+ def extract_claim_like_sentences(text: str, max_items: int = GRAPH_MAX_CLAIMS) -> List[str]:
169
+ text = norm_text(text)
170
+ if not text:
171
+ return []
172
+
173
+ parts = re.split(r"(?<=[\.\!\?])\s+", text)
174
+ scored = []
175
+ for sentence in parts:
176
+ s = norm_text(sentence)
177
+ if len(s) < 40 or len(s) > 280:
178
+ continue
179
+ lower = s.lower()
180
+ score = 0.0
181
+ if any(k in lower for k in ["improves", "reduces", "increases", "suggests", "demonstrates", "shows", "reveals", "predicts", "achieves", "outperforms"]):
182
+ score += 2.0
183
+ if any(k in lower for k in ["significant", "associated", "correlated", "effective", "robust", "accurate", "validated"]):
184
+ score += 1.0
185
+ score += min(len(tokenize(s)) / 15.0, 2.0)
186
+ scored.append((score, s))
187
+ return [s for _, s in sorted(scored, key=lambda x: x[0], reverse=True)[:max_items]]
188
+
189
+
190
+ def parse_openalex_abstract(inverted_index) -> str:
191
+ if not inverted_index or not isinstance(inverted_index, dict):
192
+ return ""
193
+ pos_to_word = {}
194
+ for word, positions in inverted_index.items():
195
+ for pos in positions:
196
+ pos_to_word[pos] = word
197
+ if not pos_to_word:
198
+ return ""
199
+ return " ".join(pos_to_word[i] for i in sorted(pos_to_word))
200
+
201
+
202
+ def enrich_paper_semantics(query: str, paper: Dict) -> Dict:
203
+ paper = dict(paper)
204
+ title = paper.get("title", "")
205
+ abstract = paper.get("abstract", "") or paper.get("summary", "")
206
+ venue = paper.get("venue", "")
207
+ base_text = " ".join([title, abstract, venue]).strip()
208
+
209
+ concepts = extract_concepts_from_text(base_text, max_terms=GRAPH_MAX_CONCEPTS)
210
+ claims = extract_claim_like_sentences(abstract, max_items=GRAPH_MAX_CLAIMS)
211
+
212
+ rel = text_overlap_score(query, f"{title} {abstract}")
213
+ recency = compute_recency_bonus(paper.get("year"))
214
+ doi_bonus = 0.02 if paper.get("doi") else 0.0
215
+ oa_bonus = 0.03 if paper.get("open_access") else 0.0
216
+ concept_bonus = min(len(concepts), 8) * 0.01
217
+
218
+ learned_score = float(paper.get("score", 0)) + rel * 0.5 + recency + doi_bonus + oa_bonus + concept_bonus
219
+
220
+ paper["concepts"] = concepts[:GRAPH_MAX_CONCEPTS]
221
+ paper["claims"] = claims[:GRAPH_MAX_CLAIMS]
222
+ paper["relevance"] = round(rel, 4)
223
+ paper["learned_score"] = round(learned_score, 4)
224
+ return paper
225
+
226
+
227
+ def paper_identity_key(paper: Dict) -> str:
228
+ return (
229
+ normalize_doi(paper.get("doi") or "")
230
+ or (paper.get("external_ids") or {}).get("arxiv")
231
+ or norm_text(paper.get("title", "")).lower()
232
+ or str(paper.get("id"))
233
+ )
234
+
235
+
236
+ def journal_query_links(query: str):
237
+ q = urllib.parse.quote_plus(query or "biomaterials cardiac repair")
238
+ rows = []
239
+ for journal in JOURNALS:
240
+ url = f"{journal['url']}?q={q}" if "?" not in journal["url"] else f"{journal['url']}&q={q}"
241
+ if "ieeexplore" in journal["url"]:
242
+ url = f"https://ieeexplore.ieee.org/search/searchresult.jsp?queryText={q}"
243
+ rows.append({"name": journal["name"], "desc": journal["desc"], "url": url})
244
+ return rows
245
+
246
+
247
+ def build_journal_html(query):
248
+ rows = []
249
+ for journal in journal_query_links(query):
250
+ rows.append(
251
+ f"""
252
+ <a class="journal-card" href="{safe_text(journal['url'])}" target="_blank" rel="noopener noreferrer">
253
+ <div>
254
+ <h4>{safe_text(journal['name'])}</h4>
255
+ <p>{safe_text(journal['desc'])}</p>
256
+ </div>
257
+ <span>Open</span>
258
+ </a>
259
+ """
260
+ )
261
+ return '<div class="journal-grid">' + ''.join(rows) + '</div>'
262
+
263
+
264
+ def search_arxiv(query, max_results=8):
265
+ encoded = urllib.parse.quote(query)
266
+ url = (
267
+ "http://export.arxiv.org/api/query?search_query=all:"
268
+ f"{encoded}&start=0&max_results={max_results}&sortBy=relevance&sortOrder=descending"
269
+ )
270
+ response = requests.get(url, timeout=REQUEST_TIMEOUT)
271
+ response.raise_for_status()
272
+
273
+ root = ET.fromstring(response.text)
274
+ ns = {"atom": "http://www.w3.org/2005/Atom"}
275
+ papers = []
276
+
277
+ for entry in root.findall("atom:entry", ns):
278
+ title = " ".join((entry.findtext("atom:title", default="", namespaces=ns) or "").split())
279
+ summary = " ".join((entry.findtext("atom:summary", default="", namespaces=ns) or "").split())
280
+ published = entry.findtext("atom:published", default="", namespaces=ns)
281
+ paper_id = entry.findtext("atom:id", default="", namespaces=ns)
282
+ authors = [a.findtext("atom:name", default="", namespaces=ns) for a in entry.findall("atom:author", ns)]
283
+ pdf_url = ""
284
+
285
+ for link in entry.findall("atom:link", ns):
286
+ if link.attrib.get("title") == "pdf":
287
+ pdf_url = link.attrib.get("href", "")
288
+ break
289
+
290
+ papers.append({
291
+ "id": paper_id or title,
292
+ "title": title,
293
+ "summary": summary,
294
+ "abstract": summary,
295
+ "published": published[:10],
296
+ "authors": [a for a in authors[:8] if a],
297
+ "authors_text": ", ".join([a for a in authors[:4] if a]) or "Unknown authors",
298
+ "url": paper_id,
299
+ "pdf": pdf_url,
300
+ "doi": "",
301
+ "venue": "arXiv",
302
+ "year": published[:4] if published else "",
303
+ "source": "arxiv",
304
+ "score": 0.76,
305
+ "open_access": True,
306
+ "external_ids": {"arxiv": (paper_id or "").split("/")[-1]},
307
+ })
308
+
309
+ return papers
310
+
311
+
312
+ def search_crossref(query, mode="topic", max_results=8):
313
+ headers = {"User-Agent": "dvnc-ai-space/0.3"}
314
+
315
+ if mode == "doi":
316
+ url = f"https://api.crossref.org/works/{urllib.parse.quote(query)}"
317
+ response = requests.get(url, headers=headers, timeout=REQUEST_TIMEOUT)
318
+ if response.status_code != 200:
319
+ return []
320
+ items = [response.json().get("message", {})]
321
+ else:
322
+ params = {"rows": max_results}
323
+ if mode in ("title", "paper_name"):
324
+ params["query.title"] = query
325
+ else:
326
+ params["query.bibliographic"] = query
327
+ response = requests.get("https://api.crossref.org/works", params=params, headers=headers, timeout=REQUEST_TIMEOUT)
328
+ response.raise_for_status()
329
+ items = response.json().get("message", {}).get("items", [])
330
+
331
+ out = []
332
+ for item in items:
333
+ authors = []
334
+ for a in item.get("author", []) or []:
335
+ name = " ".join(filter(None, [a.get("given"), a.get("family")])).strip()
336
+ if name:
337
+ authors.append(name)
338
+
339
+ title = (item.get("title") or ["Untitled"])[0]
340
+ year = ""
341
+ for key in ["published-print", "published-online", "created"]:
342
+ if item.get(key, {}).get("date-parts"):
343
+ year = str(item[key]["date-parts"][0][0])
344
+ break
345
+
346
+ abstract = re.sub("<.*?>", "", item.get("abstract") or "")
347
+ doi = normalize_doi(item.get("DOI", ""))
348
+
349
+ out.append({
350
+ "id": doi or title,
351
+ "title": norm_text(title),
352
+ "summary": norm_text(abstract)[:500],
353
+ "abstract": norm_text(abstract),
354
+ "published": year,
355
+ "authors": authors,
356
+ "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
357
+ "url": item.get("URL", ""),
358
+ "pdf": "",
359
+ "doi": doi,
360
+ "venue": (item.get("container-title") or [""])[0],
361
+ "year": year,
362
+ "source": "crossref",
363
+ "score": 0.72,
364
+ "open_access": None,
365
+ "external_ids": {"crossref": doi} if doi else {},
366
+ })
367
+
368
+ return out
369
+
370
+
371
+ def search_openalex(query, mode="topic", max_results=8):
372
+ params = {"per-page": max_results}
373
+ if mode == "doi":
374
+ doi = normalize_doi(query)
375
+ params["filter"] = f"doi:https://doi.org/{doi}"
376
+ else:
377
+ params["search"] = query
378
+
379
+ response = requests.get("https://api.openalex.org/works", params=params, timeout=REQUEST_TIMEOUT)
380
+ response.raise_for_status()
381
+ items = response.json().get("results", [])
382
+
383
+ out = []
384
+ for item in items:
385
+ authors = []
386
+ for auth in item.get("authorships", [])[:8]:
387
+ author = auth.get("author") or {}
388
+ if author.get("display_name"):
389
+ authors.append(author["display_name"])
390
+
391
+ oa = item.get("open_access") or {}
392
+ doi = normalize_doi(item.get("doi") or "")
393
+ abstract = parse_openalex_abstract(item.get("abstract_inverted_index"))
394
+
395
+ out.append({
396
+ "id": item.get("id") or doi or item.get("title"),
397
+ "title": norm_text(item.get("title")),
398
+ "summary": norm_text(abstract)[:500],
399
+ "abstract": norm_text(abstract),
400
+ "published": str(item.get("publication_year") or ""),
401
+ "authors": authors,
402
+ "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
403
+ "url": (item.get("primary_location") or {}).get("landing_page_url") or item.get("id") or "",
404
+ "pdf": oa.get("oa_url") or "",
405
+ "doi": doi,
406
+ "venue": ((item.get("primary_location") or {}).get("source") or {}).get("display_name") or "",
407
+ "year": str(item.get("publication_year") or ""),
408
+ "source": "openalex",
409
+ "score": 0.80,
410
+ "open_access": oa.get("is_oa"),
411
+ "external_ids": item.get("ids") or {},
412
+ })
413
+
414
+ return out
415
+
416
+
417
+ def search_semantic_scholar(query, mode="topic", max_results=8):
418
+ headers = {}
419
+ if SEMANTIC_SCHOLAR_API_KEY:
420
+ headers["x-api-key"] = SEMANTIC_SCHOLAR_API_KEY
421
+
422
+ fields = "title,authors,year,abstract,venue,externalIds,url,openAccessPdf"
423
+
424
+ if mode == "doi":
425
+ doi = normalize_doi(query)
426
+ url = f"https://api.semanticscholar.org/graph/v1/paper/DOI:{urllib.parse.quote(doi)}"
427
+ response = requests.get(url, params={"fields": fields}, headers=headers, timeout=REQUEST_TIMEOUT)
428
+ if response.status_code != 200:
429
+ return []
430
+ items = [response.json()]
431
+ else:
432
+ response = requests.get(
433
+ "https://api.semanticscholar.org/graph/v1/paper/search",
434
+ params={"query": query, "limit": max_results, "fields": fields},
435
+ headers=headers,
436
+ timeout=REQUEST_TIMEOUT,
437
+ )
438
+ if response.status_code != 200:
439
+ return []
440
+ items = response.json().get("data", [])
441
+
442
+ out = []
443
+ for item in items:
444
+ external = item.get("externalIds") or {}
445
+ authors = [a.get("name") for a in item.get("authors", []) if a.get("name")]
446
+
447
+ out.append({
448
+ "id": external.get("CorpusId") or external.get("DOI") or item.get("title"),
449
+ "title": norm_text(item.get("title")),
450
+ "summary": norm_text(item.get("abstract", ""))[:500],
451
+ "abstract": norm_text(item.get("abstract", "")),
452
+ "published": str(item.get("year") or ""),
453
+ "authors": authors,
454
+ "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
455
+ "url": item.get("url") or "",
456
+ "pdf": (item.get("openAccessPdf") or {}).get("url") or "",
457
+ "doi": normalize_doi(external.get("DOI", "")),
458
+ "venue": item.get("venue") or "",
459
+ "year": str(item.get("year") or ""),
460
+ "source": "semantic_scholar",
461
+ "score": 0.84,
462
+ "open_access": bool((item.get("openAccessPdf") or {}).get("url")),
463
+ "external_ids": external,
464
+ })
465
+
466
+ return out
467
+
468
+
469
+ def search_europe_pmc(query, mode="topic", max_results=8):
470
+ epmc_query = f'DOI:"{query}"' if mode == "doi" else query
471
+ params = {"query": epmc_query, "format": "json", "pageSize": max_results, "resultType": "core"}
472
+ response = requests.get("https://www.ebi.ac.uk/europepmc/webservices/rest/search", params=params, timeout=REQUEST_TIMEOUT)
473
+ if response.status_code != 200:
474
+ return []
475
+
476
+ items = response.json().get("resultList", {}).get("result", [])
477
+ out = []
478
+ for item in items:
479
+ author_string = item.get("authorString", "")
480
+ authors = [x.strip() for x in author_string.split(",")[:8] if x.strip()]
481
+ pmcid = item.get("pmcid", "")
482
+ pdf_url = f"https://europepmc.org/articles/{pmcid}?pdf=render" if pmcid else ""
483
+ landing_url = f"https://europepmc.org/article/PMC/{pmcid}" if pmcid else ""
484
+
485
+ out.append({
486
+ "id": item.get("id") or item.get("doi") or item.get("title"),
487
+ "title": norm_text(item.get("title")),
488
+ "summary": norm_text(item.get("abstractText", ""))[:500],
489
+ "abstract": norm_text(item.get("abstractText", "")),
490
+ "published": str(item.get("pubYear") or ""),
491
+ "authors": authors,
492
+ "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
493
+ "url": landing_url,
494
+ "pdf": pdf_url,
495
+ "doi": normalize_doi(item.get("doi", "")),
496
+ "venue": item.get("journalTitle", ""),
497
+ "year": str(item.get("pubYear") or ""),
498
+ "source": "europe_pmc",
499
+ "score": 0.78,
500
+ "open_access": bool(pmcid),
501
+ "external_ids": {"pmid": item.get("pmid"), "pmcid": pmcid},
502
+ })
503
+
504
+ return out
505
+
506
+
507
+ def resolve_link(query):
508
+ url = (query or "").strip()
509
+ if not url:
510
+ return []
511
+
512
+ try:
513
+ response = requests.get(
514
+ url,
515
+ timeout=REQUEST_TIMEOUT,
516
+ allow_redirects=True,
517
+ headers={"User-Agent": "dvnc-ai-space/0.3"},
518
+ )
519
+
520
+ content_type = response.headers.get("content-type", "")
521
+ if "pdf" in content_type or url.lower().endswith(".pdf"):
522
+ name = Path(url.split("?")[0]).name or "linked-paper.pdf"
523
+ return [{
524
+ "id": url,
525
+ "title": name,
526
+ "summary": "Direct PDF link detected.",
527
+ "abstract": "",
528
+ "published": "",
529
+ "authors": [],
530
+ "authors_text": "Unknown authors",
531
+ "url": url,
532
+ "pdf": url,
533
+ "doi": "",
534
+ "venue": "Direct PDF",
535
+ "year": "",
536
+ "source": "link",
537
+ "score": 0.66,
538
+ "open_access": True,
539
+ "external_ids": {},
540
+ }]
541
+
542
+ doi = ""
543
+ title = url
544
+ pdf_link = ""
545
+
546
+ if BeautifulSoup is not None:
547
+ soup = BeautifulSoup(response.text, "html.parser")
548
+ title = soup.title.text.strip() if soup.title else url
549
+
550
+ for meta_name in ["citation_doi", "dc.identifier", "dc.Identifier"]:
551
+ tag = soup.find("meta", attrs={"name": meta_name})
552
+ if tag and tag.get("content"):
553
+ doi = normalize_doi(tag["content"].strip())
554
+ break
555
+
556
+ for a in soup.find_all("a", href=True):
557
+ href = a["href"]
558
+ if ".pdf" in href.lower():
559
+ pdf_link = href if href.startswith("http") else ""
560
+ break
561
+
562
+ if doi:
563
+ results = search_crossref(doi, mode="doi", max_results=1)
564
+ if results:
565
+ if pdf_link and not results[0].get("pdf"):
566
+ results[0]["pdf"] = pdf_link
567
+ if url and not results[0].get("url"):
568
+ results[0]["url"] = url
569
+ return results
570
+
571
+ return [{
572
+ "id": url,
573
+ "title": title,
574
+ "summary": "Landing page resolved from direct link.",
575
+ "abstract": "",
576
+ "published": "",
577
+ "authors": [],
578
+ "authors_text": "Unknown authors",
579
+ "url": url,
580
+ "pdf": pdf_link,
581
+ "doi": doi,
582
+ "venue": "Web Link",
583
+ "year": "",
584
+ "source": "link",
585
+ "score": 0.54,
586
+ "open_access": bool(pdf_link),
587
+ "external_ids": {},
588
+ }]
589
+ except Exception as e:
590
+ return [{
591
+ "id": url,
592
+ "title": "Link resolution error",
593
+ "summary": str(e),
594
+ "abstract": "",
595
+ "published": "",
596
+ "authors": [],
597
+ "authors_text": "Unknown authors",
598
+ "url": url,
599
+ "pdf": "",
600
+ "doi": "",
601
+ "venue": "Link",
602
+ "year": "",
603
+ "source": "link",
604
+ "score": 0.20,
605
+ "open_access": None,
606
+ "external_ids": {},
607
+ }]
608
+
609
+
610
+ def dedupe_papers(items: List[Dict]) -> List[Dict]:
611
+ seen = {}
612
+ for item in items:
613
+ key = paper_identity_key(item) or f"{item.get('source', 'src')}::{item.get('title', 'paper')}"
614
+ if key not in seen or float(item.get("score", 0)) > float(seen[key].get("score", 0)):
615
+ seen[key] = item
616
+ return sorted(seen.values(), key=lambda x: float(x.get("score", 0)), reverse=True)
617
+
618
+
619
+ def discover_papers(query, mode, sources, max_results=10):
620
+ query = (query or "").strip()
621
+ if not query:
622
+ return []
623
+
624
+ mode = detect_query_type(query) if mode == "autonomous_web" else mode
625
+ selected_sources = ensure_list(sources) or DEFAULT_SOURCES
626
+ results = []
627
+
628
+ if mode == "link":
629
+ return dedupe_papers(resolve_link(query))
630
+
631
+ if "arxiv" in selected_sources and mode != "doi":
632
+ try:
633
+ results.extend(search_arxiv(query, max_results=min(max_results, GRAPH_MAX_RESULTS)))
634
+ except Exception:
635
+ pass
636
+
637
+ if "crossref" in selected_sources:
638
+ try:
639
+ results.extend(search_crossref(query, mode=mode, max_results=min(max_results, GRAPH_MAX_RESULTS)))
640
+ except Exception:
641
+ pass
642
+
643
+ if "openalex" in selected_sources:
644
+ try:
645
+ results.extend(search_openalex(query, mode=mode, max_results=min(max_results, GRAPH_MAX_RESULTS)))
646
+ except Exception:
647
+ pass
648
+
649
+ if "semantic_scholar" in selected_sources:
650
+ try:
651
+ results.extend(search_semantic_scholar(query, mode=mode, max_results=min(max_results, GRAPH_MAX_RESULTS)))
652
+ except Exception:
653
+ pass
654
+
655
+ if "europe_pmc" in selected_sources:
656
+ try:
657
+ results.extend(search_europe_pmc(query, mode=mode, max_results=min(max_results, GRAPH_MAX_RESULTS)))
658
+ except Exception:
659
+ pass
660
+
661
+ papers = dedupe_papers(results)
662
+ papers = [enrich_paper_semantics(query, p) for p in papers]
663
+ papers = sorted(papers, key=lambda x: float(x.get("learned_score", x.get("score", 0))), reverse=True)
664
+ return papers
665
+
666
+
667
+ def paper_choice_value(index: int, paper: Dict) -> str:
668
+ doi = normalize_doi(paper.get("doi") or "")
669
+ title_slug = slugify(paper.get("title", ""))[:40]
670
+ return f"{index}|{doi}|{title_slug}"
671
+
672
+
673
+ def paper_choice_label(index: int, paper: Dict) -> str:
674
+ score = round(float(paper.get("learned_score", paper.get("score", 0))), 3)
675
+ title = paper.get("title", "Untitled")
676
+ authors_text = paper.get("authors_text", "Unknown authors")[:80]
677
+ source = paper.get("source", "src")
678
+ return f"[{source}] {title} — {authors_text} — score {score}"
679
+
680
+
681
+ def format_selection_choices(papers):
682
+ return [(paper_choice_label(i, paper), paper_choice_value(i, paper)) for i, paper in enumerate(papers)]
683
+
684
+
685
+ def format_papers_html(papers):
686
+ if not papers:
687
+ return '<div class="panel papers-panel" style="padding:18px"><p>No papers found yet.</p></div>'
688
+
689
+ items = []
690
+ for i, paper in enumerate(papers, start=1):
691
+ summary = safe_text((paper.get("summary") or paper.get("abstract") or "")[:280])
692
+ doi_line = f'<span class="paper-badge doi-badge">{safe_text(paper.get("doi"))}</span>' if paper.get("doi") else ""
693
+ pdf_link = paper.get("pdf") or "#"
694
+ abs_link = paper.get("url") or "#"
695
+ concepts_text = ", ".join((paper.get("concepts") or [])[:4])
696
+
697
+ items.append(
698
+ f"""
699
+ <article class="paper-card">
700
+ <div class="paper-topline">
701
+ <span class="paper-badge">{safe_text(paper.get('source', 'paper'))}</span>
702
+ <span class="paper-badge alt">{safe_text(paper.get('published', '') or 'Paper')}</span>
703
+ {doi_line}
704
+ </div>
705
+ <h4>{i}. {safe_text(paper.get('title', 'Untitled'))}</h4>
706
+ <p>{summary or 'No abstract snippet available.'}</p>
707
+ <div class="paper-meta-stack">
708
+ <div><strong>Authors:</strong> {safe_text(paper.get('authors_text', 'Unknown authors'))}</div>
709
+ <div><strong>Venue:</strong> {safe_text(paper.get('venue', 'Unknown venue'))}</div>
710
+ <div><strong>Learned score:</strong> {safe_text(round(float(paper.get('learned_score', paper.get('score', 0))), 3))}</div>
711
+ <div><strong>Concepts:</strong> {safe_text(concepts_text or 'None extracted')}</div>
712
+ </div>
713
+ <div class="paper-links">
714
+ <a href="{safe_text(abs_link)}" target="_blank" rel="noopener noreferrer">Abstract</a>
715
+ <a href="{safe_text(pdf_link)}" target="_blank" rel="noopener noreferrer">PDF</a>
716
+ </div>
717
+ </article>
718
+ """
719
+ )
720
+
721
+ return '<div class="papers-grid">' + ''.join(items) + '</div>'
722
+
723
+
724
+ def uploaded_pdf_summary(file_obj):
725
+ if not file_obj:
726
+ return "No PDF uploaded yet."
727
+ path = getattr(file_obj, "name", None) or str(file_obj)
728
+ p = Path(path)
729
+ return f"Uploaded PDF ready for ingestion: {p.name}. Use Parse uploaded PDF to extract title, abstract, sections, references, concepts, and claims."
730
+
731
+
732
+ def build_learning_graph_html(nodes, edges, title="Self-Learning Knowledge Graph"):
733
+ if not nodes:
734
+ return """
735
+ <div class="panel brain-shell">
736
+ <div class="brain-header">
737
+ <div>
738
+ <p class="eyebrow">Learning Graph</p>
739
+ <h3>Self-Learning Knowledge Graph</h3>
740
+ </div>
741
+ </div>
742
+ <div class="brain-stage learning-empty">
743
+ <div class="empty-graph-copy">
744
+ <h4>No papers mapped yet</h4>
745
+ <p>Search papers, pick a topic, select candidates, or upload a PDF to grow the graph in real time.</p>
746
+ </div>
747
+ </div>
748
+ </div>
749
+ """
750
+
751
+ coords = [
752
+ (100, 90), (250, 60), (420, 75), (590, 115), (690, 250), (620, 395),
753
+ (455, 455), (280, 455), (110, 395), (60, 250), (215, 250), (365, 245),
754
+ (525, 250), (300, 145), (480, 340), (180, 340), (545, 175), (130, 170)
755
+ ]
756
+
757
+ graph_nodes = [dict(n) for n in nodes[:18]]
758
+ for i, node in enumerate(graph_nodes):
759
+ x, y = coords[i % len(coords)]
760
+ node["sx"] = x
761
+ node["sy"] = y
762
+
763
+ node_map = {n["id"]: n for n in graph_nodes}
764
+ edge_items = []
765
+ node_items = []
766
+ label_items = []
767
+
768
+ for edge in edges[:80]:
769
+ source = edge.get("source")
770
+ target = edge.get("target")
771
+ edge_type = edge.get("type", "")
772
+ if source in node_map and target in node_map:
773
+ a = node_map[source]
774
+ b = node_map[target]
775
+ edge_items.append(
776
+ f'<line class="learn-edge edge-{safe_text(edge_type.lower())}" x1="{a["sx"]}" y1="{a["sy"]}" x2="{b["sx"]}" y2="{b["sy"]}" />'
777
+ )
778
+
779
+ for node in graph_nodes:
780
+ kind = (node.get("kind") or node.get("type") or "paper").lower()
781
+ if kind == "topic":
782
+ kind = "query"
783
+ if kind == "uploadedpdf":
784
+ kind = "upload"
785
+
786
+ radius = 25 if kind == "query" else 18 if kind in {"concept", "author", "claim", "reference"} else 20
787
+ css_class = f"learn-node {kind}"
788
+
789
+ node_items.append(
790
+ f'<circle class="{css_class}" cx="{node["sx"]}" cy="{node["sy"]}" r="{radius}" />'
791
+ )
792
+
793
+ label = node.get("label") or node.get("title") or node.get("id")
794
+ label_items.append(
795
+ f'<text class="learn-label" x="{node["sx"] + 26}" y="{node["sy"] - 8}">{safe_text(str(label)[:46])}</text>'
796
+ )
797
+
798
+ return f"""
799
+ <div class="panel brain-shell">
800
+ <div class="brain-header">
801
+ <div>
802
+ <p class="eyebrow">Learning Graph</p>
803
+ <h3>{safe_text(title)}</h3>
804
+ </div>
805
+ <div class="brain-legend">
806
+ <span><i class="dot dot-query"></i> topic</span>
807
+ <span><i class="dot dot-paper"></i> paper</span>
808
+ <span><i class="dot dot-upload"></i> uploaded PDF</span>
809
+ <span><i class="dot dot-concept"></i> concept</span>
810
+ <span><i class="dot dot-author"></i> author</span>
811
+ <span><i class="dot dot-ref"></i> reference</span>
812
+ </div>
813
+ </div>
814
+ <div class="brain-stage">
815
+ <svg viewBox="0 0 760 520" class="brain-svg" role="img" aria-label="Self-learning knowledge graph">
816
+ {''.join(edge_items)}
817
+ {''.join(node_items)}
818
+ {''.join(label_items)}
819
+ </svg>
820
+ </div>
821
+ </div>
822
+ """
823
+
824
+
825
+ def build_learning_graph_state(query, papers, uploaded_name=None):
826
+ nodes = [{"id": "query", "label": query or "Research topic", "kind": "query"}]
827
+ edges = []
828
+
829
+ for i, paper in enumerate(papers[:5], start=1):
830
+ pid = f"paper_{i}"
831
+ nodes.append({"id": pid, "label": paper.get("title", f"Paper {i}"), "kind": "paper"})
832
+ edges.append({"source": "query", "target": pid, "type": "ABOUT"})
833
+
834
+ for concept in (paper.get("concepts") or [])[:2]:
835
+ cid = f"concept_{i}_{slugify(concept)[:20]}"
836
+ nodes.append({"id": cid, "label": concept, "kind": "concept"})
837
+ edges.append({"source": pid, "target": cid, "type": "MENTIONS"})
838
+
839
+ if uploaded_name:
840
+ nodes.append({"id": "upload", "label": uploaded_name, "kind": "upload"})
841
+ edges.append({"source": "query", "target": "upload", "type": "UPLOADED_SOURCE"})
842
+
843
+ return nodes, edges
844
+
845
+
846
+ def graph_from_selected(query, selected_papers, uploaded_name=None, parsed_state=None):
847
+ nodes = [{"id": "query", "label": query or "Research topic", "kind": "query"}]
848
+ edges = []
849
+
850
+ for i, paper in enumerate(selected_papers[:6], start=1):
851
+ pid = f"paper_{i}"
852
+ nodes.append({"id": pid, "label": paper.get("title", f"Paper {i}"), "kind": "paper"})
853
+ edges.append({"source": "query", "target": pid, "type": "ABOUT"})
854
+
855
+ for author in paper.get("authors", [])[:2]:
856
+ aid = f"author_{i}_{slugify(author)[:24]}"
857
+ nodes.append({"id": aid, "label": author, "kind": "author"})
858
+ edges.append({"source": pid, "target": aid, "type": "WRITTEN_BY"})
859
+
860
+ for concept in (paper.get("concepts") or [])[:2]:
861
+ cid = f"concept_{i}_{slugify(concept)[:24]}"
862
+ nodes.append({"id": cid, "label": concept, "kind": "concept"})
863
+ edges.append({"source": pid, "target": cid, "type": "MENTIONS"})
864
+
865
+ for claim in (paper.get("claims") or [])[:1]:
866
+ cid = f"claim_{i}_{slugify(claim)[:24]}"
867
+ nodes.append({"id": cid, "label": claim[:42], "kind": "claim"})
868
+ edges.append({"source": pid, "target": cid, "type": "ASSERTS"})
869
+
870
+ if uploaded_name:
871
+ nodes.append({"id": "upload", "label": uploaded_name, "kind": "upload"})
872
+ edges.append({"source": "query", "target": "upload", "type": "UPLOADED_SOURCE"})
873
+
874
+ if parsed_state and isinstance(parsed_state, dict):
875
+ for concept in (parsed_state.get("concepts") or [])[:3]:
876
+ cid = f"upload_concept_{slugify(concept)[:24]}"
877
+ nodes.append({"id": cid, "label": concept, "kind": "concept"})
878
+ edges.append({"source": "upload", "target": cid, "type": "MENTIONS"})
879
+
880
+ return nodes, edges
881
+
882
+
883
+ def parse_pdf_with_grobid(pdf_path):
884
+ if not GROBID_URL:
885
+ raise RuntimeError("GROBID_URL is not set")
886
+
887
+ with open(pdf_path, "rb") as f:
888
+ files = {"input": (Path(pdf_path).name, f, "application/pdf")}
889
+ response = requests.post(
890
+ f"{GROBID_URL.rstrip('/')}/api/processFulltextDocument",
891
+ files=files,
892
+ data={"includeRawAffiliations": "1", "segmentSentences": "1"},
893
+ timeout=120,
894
+ )
895
+
896
+ response.raise_for_status()
897
+ tei_xml = response.text
898
+ root = ET.fromstring(tei_xml)
899
+ ns = {"tei": "http://www.tei-c.org/ns/1.0"}
900
+
901
+ title = root.findtext(".//tei:titleStmt/tei:title", default="", namespaces=ns)
902
+ if not title:
903
+ title = Path(pdf_path).name
904
+
905
+ abstract_parts = []
906
+ for p in root.findall(".//tei:profileDesc/tei:abstract//tei:p", ns):
907
+ abstract_parts.append(" ".join(list(p.itertext())))
908
+ abstract = norm_text(" ".join(abstract_parts))
909
+
910
+ authors = []
911
+ for author in root.findall(".//tei:sourceDesc//tei:author", ns):
912
+ parts = []
913
+ forename = author.findall(".//tei:forename", ns)
914
+ surname = author.findall(".//tei:surname", ns)
915
+ parts.extend([norm_text(" ".join(x.itertext())) for x in forename])
916
+ parts.extend([norm_text(" ".join(x.itertext())) for x in surname])
917
+ name = norm_text(" ".join(parts))
918
+ if name:
919
+ authors.append(name)
920
+
921
+ sections = []
922
+ text_pool = []
923
+ for div in root.findall(".//tei:text//tei:body//tei:div", ns):
924
+ head = div.findtext("./tei:head", default="", namespaces=ns)
925
+ paras = []
926
+ for p in div.findall(".//tei:p", ns):
927
+ para_text = norm_text(" ".join(list(p.itertext())))
928
+ if para_text:
929
+ paras.append(para_text)
930
+ joined = "\n".join(paras)
931
+ if head or joined:
932
+ sections.append({"heading": head or "Section", "text": joined[:4000]})
933
+ if joined:
934
+ text_pool.append(joined)
935
+
936
+ references = []
937
+ for bibl in root.findall(".//tei:listBibl//tei:biblStruct", ns)[:40]:
938
+ ref_title = bibl.findtext(".//tei:title", default="", namespaces=ns)
939
+ ref_doi = ""
940
+ for idno in bibl.findall(".//tei:idno", ns):
941
+ if (idno.attrib.get("type") or "").lower() == "doi":
942
+ ref_doi = norm_text(" ".join(idno.itertext()))
943
+ break
944
+ references.append({"title": norm_text(ref_title), "doi": normalize_doi(ref_doi)})
945
+
946
+ semantic_text = " ".join([abstract] + text_pool[:4])
947
+
948
+ return {
949
+ "parser": "grobid",
950
+ "title": norm_text(title),
951
+ "abstract": abstract,
952
+ "authors": authors[:12],
953
+ "sections": sections[:12],
954
+ "references": references[:40],
955
+ "claims": extract_claim_like_sentences(semantic_text, max_items=GRAPH_MAX_CLAIMS),
956
+ "concepts": extract_concepts_from_text(semantic_text, max_terms=GRAPH_MAX_CONCEPTS),
957
+ "raw_text": "",
958
+ }
959
+
960
+
961
+ def parse_pdf_with_pymupdf(pdf_path):
962
+ if fitz is None:
963
+ raise RuntimeError("PyMuPDF not installed")
964
+
965
+ doc = fitz.open(pdf_path)
966
+ raw_text = "\n".join(page.get_text("text") for page in doc).strip()
967
+ first_page = raw_text[:4000]
968
+ lines = [x.strip() for x in first_page.splitlines() if x.strip()]
969
+ title = lines[0][:300] if lines else Path(pdf_path).name
970
+
971
+ abstract = ""
972
+ match = re.search(r"abstract\s*(.+?)(?:\n\s*\n|\n1[\.\s]|introduction)", raw_text, re.I | re.S)
973
+ if match:
974
+ abstract = norm_text(match.group(1))[:2500]
975
+
976
+ return {
977
+ "parser": "pymupdf",
978
+ "title": title,
979
+ "abstract": abstract,
980
+ "authors": [],
981
+ "sections": [{"heading": "Full Text", "text": raw_text[:12000]}] if raw_text else [],
982
+ "references": [],
983
+ "claims": extract_claim_like_sentences(raw_text, max_items=GRAPH_MAX_CLAIMS),
984
+ "concepts": extract_concepts_from_text(raw_text, max_terms=GRAPH_MAX_CONCEPTS),
985
+ "raw_text": raw_text[:50000],
986
+ }
987
+
988
+
989
+ def parse_pdf_with_docling(pdf_path):
990
+ try:
991
+ from docling.document_converter import DocumentConverter
992
+ except Exception as e:
993
+ raise RuntimeError(f"Docling import failed: {e}")
994
+
995
+ converter = DocumentConverter()
996
+ result = converter.convert(pdf_path)
997
+ doc = result.document
998
+ markdown = doc.export_to_markdown()
999
+
1000
+ title = Path(pdf_path).name
1001
+ first_nonempty = next((line.strip("# ").strip() for line in markdown.splitlines() if line.strip()), "")
1002
+ if first_nonempty:
1003
+ title = first_nonempty[:300]
1004
+
1005
+ return {
1006
+ "parser": "docling",
1007
+ "title": title,
1008
+ "abstract": "",
1009
+ "authors": [],
1010
+ "sections": [{"heading": "Document", "text": markdown[:12000]}] if markdown else [],
1011
+ "references": [],
1012
+ "claims": extract_claim_like_sentences(markdown, max_items=GRAPH_MAX_CLAIMS),
1013
+ "concepts": extract_concepts_from_text(markdown, max_terms=GRAPH_MAX_CONCEPTS),
1014
+ "raw_text": markdown[:50000],
1015
+ }
1016
+
1017
+
1018
+ def parse_uploaded_pdf(file_obj, parser_order):
1019
+ if not file_obj:
1020
+ return "### PDF parse status\n\nNo PDF uploaded yet.", {}
1021
+
1022
+ path = getattr(file_obj, "name", None) or str(file_obj)
1023
+ parser_order = ensure_list(parser_order) or ["grobid", "docling", "pymupdf"]
1024
+ errors = []
1025
+
1026
+ for parser_name in parser_order:
1027
+ try:
1028
+ if parser_name == "grobid":
1029
+ result = parse_pdf_with_grobid(path)
1030
+ elif parser_name == "docling":
1031
+ result = parse_pdf_with_docling(path)
1032
+ elif parser_name == "pymupdf":
1033
+ result = parse_pdf_with_pymupdf(path)
1034
+ else:
1035
+ continue
1036
+
1037
+ summary = (
1038
+ f"### PDF parse status\n\n"
1039
+ f"- Parser used: {result['parser']}\n"
1040
+ f"- Title: {result.get('title') or 'Unknown'}\n"
1041
+ f"- Authors: {', '.join(result.get('authors')[:6]) if result.get('authors') else 'Unknown'}\n"
1042
+ f"- Abstract found: {'Yes' if result.get('abstract') else 'No'}\n"
1043
+ f"- Sections extracted: {len(result.get('sections') or [])}\n"
1044
+ f"- References extracted: {len(result.get('references') or [])}\n"
1045
+ f"- Concepts extracted: {len(result.get('concepts') or [])}\n"
1046
+ f"- Claims extracted: {len(result.get('claims') or [])}\n"
1047
+ )
1048
+ return summary, result
1049
+ except Exception as e:
1050
+ errors.append(f"{parser_name}: {e}")
1051
+
1052
+ fail_summary = "### PDF parse status\n\n" + "\n".join([f"- {x}" for x in errors])
1053
+ return fail_summary, {"parser": None, "errors": errors}
1054
+
1055
+
1056
+ def render_parse_result(parsed):
1057
+ if not parsed or not isinstance(parsed, dict) or (not parsed.get("title") and not parsed.get("sections")):
1058
+ return '<div class="panel" style="padding:18px"><p>No parsed document yet.</p></div>'
1059
+
1060
+ sections_html = []
1061
+ for section in parsed.get("sections", [])[:6]:
1062
+ sections_html.append(
1063
+ f"""
1064
+ <details class="agent-step">
1065
+ <summary class="agent-summary">
1066
+ <div class="agent-index">§</div>
1067
+ <div class="agent-head">
1068
+ <h4>{safe_text(section.get('heading', 'Section'))}</h4>
1069
+ <span>section</span>
1070
+ </div>
1071
+ </summary>
1072
+ <div class="agent-copy">
1073
+ <p>{safe_text(section.get('text', '')[:1800])}</p>
1074
+ </div>
1075
+ </details>
1076
+ """
1077
+ )
1078
+
1079
+ refs = parsed.get("references", [])[:12]
1080
+ refs_html = "".join(
1081
+ f"<li>{safe_text(r.get('title') or 'Untitled')} {'· DOI ' + safe_text(r.get('doi')) if r.get('doi') else ''}</li>"
1082
+ for r in refs
1083
+ ) or "<li>No references extracted.</li>"
1084
+
1085
+ concepts = parsed.get("concepts", [])[:10]
1086
+ claims = parsed.get("claims", [])[:6]
1087
+ concepts_html = "".join(f"<li>{safe_text(x)}</li>" for x in concepts) or "<li>No concepts extracted.</li>"
1088
+ claims_html = "".join(f"<li>{safe_text(x)}</li>" for x in claims) or "<li>No claims extracted.</li>"
1089
+
1090
+ title = safe_text(parsed.get("title") or "Parsed document")
1091
+ abstract = safe_text((parsed.get("abstract") or "")[:2400]) or "No abstract extracted."
1092
+ parser_name = safe_text(parsed.get("parser") or "unknown")
1093
+
1094
+ return f"""
1095
+ <div class="panel" style="padding:18px">
1096
+ <div class="brain-header">
1097
+ <div>
1098
+ <p class="eyebrow">PDF Parse</p>
1099
+ <h3>{title}</h3>
1100
+ </div>
1101
+ <div class="brain-legend"><span><i class="dot dot-upload"></i> {parser_name}</span></div>
1102
+ </div>
1103
+ <div class="parse-grid">
1104
+ <div class="parse-card">
1105
+ <h4>Abstract</h4>
1106
+ <p>{abstract}</p>
1107
+ </div>
1108
+ <div class="parse-card">
1109
+ <h4>References</h4>
1110
+ <ul class="ref-list">{refs_html}</ul>
1111
+ </div>
1112
+ <div class="parse-card">
1113
+ <h4>Concepts</h4>
1114
+ <ul class="ref-list">{concepts_html}</ul>
1115
+ </div>
1116
+ <div class="parse-card">
1117
+ <h4>Claims</h4>
1118
+ <ul class="ref-list">{claims_html}</ul>
1119
+ </div>
1120
+ </div>
1121
+ <div class="timeline" style="margin-top:14px;">
1122
+ {''.join(sections_html) if sections_html else '<div class="panel" style="padding:16px;"><p>No sections extracted.</p></div>'}
1123
+ </div>
1124
+ </div>
1125
+ """
1126
+
1127
+
1128
+ def add_node(nodes_by_id: Dict[str, Dict], node_id: str, node_type: str, label: str = "", **attrs):
1129
+ if not node_id:
1130
+ return
1131
+ current = nodes_by_id.get(node_id, {})
1132
+ merged = {"id": node_id, "type": node_type, "label": label or current.get("label", node_id)}
1133
+ merged.update(current)
1134
+ for key, value in attrs.items():
1135
+ if value not in [None, ""]:
1136
+ merged[key] = value
1137
+ nodes_by_id[node_id] = merged
1138
+
1139
+
1140
+ def add_edge(edges: List[Dict], source: str, target: str, edge_type: str, **attrs):
1141
+ if not source or not target or source == target:
1142
+ return
1143
+ edge = {"source": source, "target": target, "type": edge_type}
1144
+ for key, value in attrs.items():
1145
+ if value not in [None, ""]:
1146
+ edge[key] = value
1147
+ edges.append(edge)
1148
+
1149
+
1150
+ def build_ingest_payload(query, selected_papers, parsed_pdf=None):
1151
+ nodes_by_id = {}
1152
+ edges = []
1153
+
1154
+ topic_id = "topic:query"
1155
+ add_node(nodes_by_id, topic_id, "Topic", label=query or "Research topic", query=query or "")
1156
+
1157
+ for i, paper in enumerate(selected_papers, start=1):
1158
+ paper_id = normalize_doi(paper.get("doi")) or (paper.get("external_ids") or {}).get("arxiv") or f"paper:{i}:{slugify(paper.get('title', 'paper'))[:32]}"
1159
+ add_node(
1160
+ nodes_by_id,
1161
+ paper_id,
1162
+ "Paper",
1163
+ label=paper.get("title") or f"Paper {i}",
1164
+ title=paper.get("title"),
1165
+ year=paper.get("year"),
1166
+ venue=paper.get("venue"),
1167
+ doi=normalize_doi(paper.get("doi")),
1168
+ source=paper.get("source"),
1169
+ url=paper.get("url"),
1170
+ pdf=paper.get("pdf"),
1171
+ score=paper.get("score"),
1172
+ learned_score=paper.get("learned_score", paper.get("score")),
1173
+ open_access=paper.get("open_access"),
1174
+ )
1175
+ add_edge(edges, topic_id, paper_id, "ABOUT", weight=paper.get("learned_score", paper.get("score", 0)))
1176
+
1177
+ for author in paper.get("authors", [])[:6]:
1178
+ author_id = f"author:{slugify(author)[:64]}"
1179
+ add_node(nodes_by_id, author_id, "Author", label=author, name=author)
1180
+ add_edge(edges, paper_id, author_id, "WRITTEN_BY")
1181
+
1182
+ for concept in (paper.get("concepts") or [])[:6]:
1183
+ concept_id = f"concept:{slugify(concept)[:72]}"
1184
+ add_node(nodes_by_id, concept_id, "Concept", label=concept, name=concept)
1185
+ add_edge(edges, paper_id, concept_id, "MENTIONS")
1186
+
1187
+ for claim in (paper.get("claims") or [])[:3]:
1188
+ claim_id = f"claim:{slugify(claim)[:72]}"
1189
+ add_node(nodes_by_id, claim_id, "Claim", label=claim[:120], text=claim)
1190
+ add_edge(edges, paper_id, claim_id, "ASSERTS")
1191
+
1192
+ if parsed_pdf and isinstance(parsed_pdf, dict) and parsed_pdf.get("title"):
1193
+ doc_id = "upload:pdf"
1194
+ add_node(nodes_by_id, doc_id, "UploadedPDF", label=parsed_pdf.get("title"), title=parsed_pdf.get("title"), parser=parsed_pdf.get("parser"))
1195
+ add_edge(edges, topic_id, doc_id, "UPLOADED_SOURCE")
1196
+
1197
+ for concept in (parsed_pdf.get("concepts") or [])[:6]:
1198
+ concept_id = f"concept:{slugify(concept)[:72]}"
1199
+ add_node(nodes_by_id, concept_id, "Concept", label=concept, name=concept)
1200
+ add_edge(edges, doc_id, concept_id, "MENTIONS")
1201
+
1202
+ for claim in (parsed_pdf.get("claims") or [])[:4]:
1203
+ claim_id = f"claim:{slugify(claim)[:72]}"
1204
+ add_node(nodes_by_id, claim_id, "Claim", label=claim[:120], text=claim)
1205
+ add_edge(edges, doc_id, claim_id, "ASSERTS")
1206
+
1207
+ for idx, ref in enumerate(parsed_pdf.get("references", [])[:12], start=1):
1208
+ ref_title = ref.get("title") or f"Reference {idx}"
1209
+ ref_doi = normalize_doi(ref.get("doi") or "")
1210
+ ref_id = ref_doi or f"ref:{idx}:{slugify(ref_title)[:32]}"
1211
+ add_node(nodes_by_id, ref_id, "Reference", label=ref_title, title=ref_title, doi=ref_doi)
1212
+ add_edge(edges, doc_id, ref_id, "CITES")
1213
+
1214
+ return {"status": "ok", "nodes": list(nodes_by_id.values()), "edges": edges}
1215
+
1216
+
1217
+ def learn_from_payload(payload: Dict, query: str = "") -> Dict:
1218
+ if not payload:
1219
+ return GRAPH_MEMORY
1220
+
1221
+ GRAPH_MEMORY["queries"].append(query or "")
1222
+ GRAPH_MEMORY["events"].append({
1223
+ "ts": time.time(),
1224
+ "query": query or "",
1225
+ "nodes": len(payload.get("nodes", [])),
1226
+ "edges": len(payload.get("edges", [])),
1227
+ })
1228
+
1229
+ for node in payload.get("nodes", []):
1230
+ node_id = node.get("id")
1231
+ if not node_id:
1232
+ continue
1233
+ GRAPH_MEMORY["nodes"][node_id] = node
1234
+
1235
+ node_type = (node.get("type") or "").lower()
1236
+ if node_type == "paper":
1237
+ GRAPH_MEMORY["papers"][node_id] = node
1238
+ if node_type == "concept" and node.get("label"):
1239
+ GRAPH_MEMORY["concept_counts"][node["label"].lower()] += 1
1240
+ if node_type == "claim" and node.get("label"):
1241
+ GRAPH_MEMORY["claim_counts"][node["label"].lower()] += 1
1242
+
1243
+ GRAPH_MEMORY["edges"].extend(payload.get("edges", []))
1244
+ return GRAPH_MEMORY
1245
+
1246
+
1247
+ def resolve_selected_papers(selected_indices, papers_state):
1248
+ papers = ensure_list(papers_state)
1249
+ selected_indices = ensure_list(selected_indices)
1250
+ selected = []
1251
+
1252
+ if not selected_indices:
1253
+ return selected
1254
+
1255
+ value_map = {paper_choice_value(i, paper): paper for i, paper in enumerate(papers)}
1256
+ label_map = {paper_choice_label(i, paper): paper for i, paper in enumerate(papers)}
1257
+
1258
+ for idx in selected_indices:
1259
+ try:
1260
+ if isinstance(idx, int):
1261
+ if 0 <= idx < len(papers):
1262
+ selected.append(papers[idx])
1263
+ continue
1264
+
1265
+ idx_str = str(idx)
1266
+ if idx_str in value_map:
1267
+ selected.append(value_map[idx_str])
1268
+ continue
1269
+
1270
+ if idx_str.isdigit():
1271
+ num = int(idx_str)
1272
+ if 0 <= num < len(papers):
1273
+ selected.append(papers[num])
1274
+ continue
1275
+
1276
+ if "|" in idx_str:
1277
+ left = idx_str.split("|", 1)[0]
1278
+ if left.isdigit():
1279
+ num = int(left)
1280
+ if 0 <= num < len(papers):
1281
+ selected.append(papers[num])
1282
+ continue
1283
+
1284
+ if idx_str in label_map:
1285
+ selected.append(label_map[idx_str])
1286
+ continue
1287
+ except Exception:
1288
+ continue
1289
+
1290
+ out = []
1291
+ seen = set()
1292
+ for paper in selected:
1293
+ key = paper_identity_key(paper)
1294
+ if key not in seen:
1295
+ seen.add(key)
1296
+ out.append(paper)
1297
+ return out
1298
+
1299
+
1300
+ def summarize_learning_state(query_text, papers, selected_sources):
1301
+ concept_pool = []
1302
+ for paper in papers[:8]:
1303
+ concept_pool.extend((paper.get("concepts") or [])[:3])
1304
+
1305
+ top_concepts = [c for c, _ in Counter([c.lower() for c in concept_pool]).most_common(6)]
1306
+
1307
+ return (
1308
+ "### Discovery results\n\n"
1309
+ f"- Query: {query_text}\n"
1310
+ f"- Sources: {', '.join(selected_sources)}\n"
1311
+ f"- Candidates found: {len(papers)}\n"
1312
+ f"- Top learned concepts: {', '.join(top_concepts) if top_concepts else 'None'}\n"
1313
+ "- Select papers below, then click **Ingest selected into graph**.\n"
1314
+ )
1315
+
1316
+
1317
+ def run_paper_discovery(query, search_mode, sources, pdf_file):
1318
+ query_text = norm_text(query or "")
1319
+ selected_sources = ensure_list(sources) or DEFAULT_SOURCES
1320
+
1321
+ if not query_text and not pdf_file:
1322
+ empty_graph = build_learning_graph_html([], [], "Self-Learning Knowledge Graph")
1323
+ return (
1324
+ empty_graph,
1325
+ '<div class="panel papers-panel" style="padding:18px"><p>Enter a topic, title, DOI, link, or upload a PDF to start learning.</p></div>',
1326
+ build_journal_html("biomaterials cardiac repair"),
1327
+ "No PDF uploaded yet.",
1328
+ gr.update(choices=[], value=[]),
1329
+ [],
1330
+ "### No discovery results yet.",
1331
+ )
1332
+
1333
+ if not query_text and pdf_file:
1334
+ uploaded_name = Path(getattr(pdf_file, "name", str(pdf_file))).name
1335
+ graph_nodes, graph_edges = build_learning_graph_state("", [], uploaded_name)
1336
+ return (
1337
+ build_learning_graph_html(graph_nodes, graph_edges, "Uploaded PDF Waiting for Parse"),
1338
+ '<div class="panel papers-panel" style="padding:18px"><p>No query yet. Parse the uploaded PDF or enter a research topic to begin discovery.</p></div>',
1339
+ build_journal_html("biomaterials cardiac repair"),
1340
+ uploaded_pdf_summary(pdf_file),
1341
+ gr.update(choices=[], value=[]),
1342
+ [],
1343
+ "### Upload detected.\n\n- Parse the PDF to extract structure.\n- Or enter a topic to start discovery.",
1344
+ )
1345
+
1346
+ uploaded_name = Path(getattr(pdf_file, "name", str(pdf_file))).name if pdf_file else None
1347
+
1348
+ try:
1349
+ papers = discover_papers(query_text, search_mode, selected_sources, max_results=GRAPH_MAX_RESULTS)
1350
+ graph_nodes, graph_edges = build_learning_graph_state(query_text, papers[:6], uploaded_name)
1351
+ graph_html = build_learning_graph_html(graph_nodes, graph_edges, "Self-Learning Knowledge Graph")
1352
+ papers_html = format_papers_html(papers)
1353
+ journals_html = build_journal_html(query_text or "biomaterials cardiac repair")
1354
+ pdf_summary = uploaded_pdf_summary(pdf_file)
1355
+ choices = format_selection_choices(papers)
1356
+ status_md = summarize_learning_state(query_text, papers, selected_sources)
1357
+
1358
+ return (
1359
+ graph_html,
1360
+ papers_html,
1361
+ journals_html,
1362
+ pdf_summary,
1363
+ gr.update(choices=choices, value=[]),
1364
+ papers,
1365
+ status_md,
1366
+ )
1367
+
1368
+ except Exception as e:
1369
+ graph_nodes, graph_edges = build_learning_graph_state(query_text, [], uploaded_name)
1370
+ error_html = f'<div class="panel papers-panel" style="padding:18px"><p>Paper search failed: {safe_text(str(e))}</p></div>'
1371
+ return (
1372
+ build_learning_graph_html(graph_nodes, graph_edges),
1373
+ error_html,
1374
+ build_journal_html(query_text or "biomaterials cardiac repair"),
1375
+ uploaded_pdf_summary(pdf_file),
1376
+ gr.update(choices=[], value=[]),
1377
+ [],
1378
+ f"### Discovery failed.\n\n- Error: {safe_text(str(e))}",
1379
+ )
1380
+
1381
+
1382
+ def ingest_selected_papers(query, selected_indices, papers_state, pdf_file, parsed_state):
1383
+ papers = ensure_list(papers_state)
1384
+ selected = resolve_selected_papers(selected_indices, papers)
1385
+ uploaded_name = Path(getattr(pdf_file, "name", str(pdf_file))).name if pdf_file else None
1386
+
1387
+ if not selected and parsed_state and isinstance(parsed_state, dict) and parsed_state.get("title") and papers:
1388
+ selected = papers[:3]
1389
+
1390
+ if not selected and not (parsed_state and isinstance(parsed_state, dict) and parsed_state.get("title")):
1391
+ graph_html = build_learning_graph_html([], [], "Self-Learning Knowledge Graph")
1392
+ return (
1393
+ graph_html,
1394
+ "### Graph ingest status\n\nSelect papers or parse an uploaded PDF first.",
1395
+ {"status": "empty", "nodes": [], "edges": []},
1396
+ )
1397
+
1398
+ query_text = norm_text(query or "")
1399
+ if not query_text and isinstance(parsed_state, dict):
1400
+ query_text = parsed_state.get("title") or "Research topic"
1401
+ if not query_text:
1402
+ query_text = "Research topic"
1403
+
1404
+ selected = [enrich_paper_semantics(query_text, paper) for paper in selected]
1405
+ graph_nodes, graph_edges = graph_from_selected(query_text, selected, uploaded_name, parsed_state if isinstance(parsed_state, dict) else None)
1406
+ graph_html = build_learning_graph_html(graph_nodes, graph_edges, "Selected Research Graph")
1407
+
1408
+ payload = build_ingest_payload(query_text, selected, parsed_state if isinstance(parsed_state, dict) else None)
1409
+ learn_from_payload(payload, query=query_text)
1410
+
1411
+ top_concepts = []
1412
+ for paper in selected:
1413
+ top_concepts.extend((paper.get("concepts") or [])[:3])
1414
+ if isinstance(parsed_state, dict):
1415
+ top_concepts.extend((parsed_state.get("concepts") or [])[:3])
1416
+
1417
+ summary_lines = [
1418
+ "### Graph ingest status",
1419
+ "",
1420
+ f"- Topic: {query_text}",
1421
+ f"- Selected papers: {len(selected)}",
1422
+ f"- Uploaded PDF parsed: {'Yes' if parsed_state and isinstance(parsed_state, dict) and parsed_state.get('title') else 'No'}",
1423
+ f"- Nodes created: {len(payload['nodes'])}",
1424
+ f"- Edges created: {len(payload['edges'])}",
1425
+ f"- Learned concepts: {', '.join(unique_keep_order(top_concepts)[:8]) if top_concepts else 'None'}",
1426
+ f"- Memory papers stored: {len(GRAPH_MEMORY['papers'])}",
1427
+ f"- Memory concepts stored: {len(GRAPH_MEMORY['concept_counts'])}",
1428
+ ]
1429
+
1430
+ return graph_html, "\n".join(summary_lines), payload