Adi12345 commited on
Commit
fecaa51
·
verified ·
1 Parent(s): 36c0a66

Update dvnc_ai_v2_hf/self_learning_graph.py

Browse files
Files changed (1) hide show
  1. dvnc_ai_v2_hf/self_learning_graph.py +908 -291
dvnc_ai_v2_hf/self_learning_graph.py CHANGED
@@ -1,14 +1,10 @@
1
  import html
2
  import json
3
- import math
4
- import os
5
  import re
6
- import time
7
  import urllib.parse
8
  import xml.etree.ElementTree as ET
9
- from collections import Counter
10
  from pathlib import Path
11
- from typing import Dict, List, Optional
12
 
13
  import gradio as gr
14
  import requests
@@ -18,24 +14,16 @@ try:
18
  except Exception:
19
  fitz = None
20
 
21
- try:
22
- from bs4 import BeautifulSoup
23
- except Exception:
24
- BeautifulSoup = None
25
 
 
 
 
 
26
 
27
- def build_learning_graph_html(nodes, edges, title="Self-Learning Knowledge Graph"):
28
- return f"""
29
- <div class="panel">
30
- <h3>{title}</h3>
31
- <p>Nodes: {len(nodes or [])}</p>
32
- <p>Edges: {len(edges or [])}</p>
33
- </div>
34
- """
35
-
36
- # ---------------------------------------------------------------------------
37
- # Constants
38
- # ---------------------------------------------------------------------------
39
 
40
  JOURNALS = [
41
  {"name": "Nature", "url": "https://www.nature.com/search", "desc": "Flagship multidisciplinary research journal."},
@@ -45,41 +33,24 @@ JOURNALS = [
45
  {"name": "IEEE Xplore", "url": "https://ieeexplore.ieee.org/search/searchresult.jsp", "desc": "Engineering, AI, signal processing, and systems."},
46
  ]
47
 
48
- SEARCH_MODES = ["topic", "title", "doi", "link", "paper_name", "autonomous_web"]
49
- SOURCE_OPTIONS = ["arxiv", "crossref", "openalex", "semantic_scholar", "europe_pmc"]
50
- DEFAULT_SOURCES = ["arxiv", "crossref", "openalex", "semantic_scholar", "europe_pmc"]
51
-
52
- SEMANTIC_SCHOLAR_API_KEY = os.getenv("SEMANTIC_SCHOLAR_API_KEY", "").strip()
53
- GROBID_URL = os.getenv("GROBID_URL", "").strip()
54
- REQUEST_TIMEOUT = int(os.getenv("REQUEST_TIMEOUT", "25"))
55
- GRAPH_MAX_CONCEPTS = int(os.getenv("GRAPH_MAX_CONCEPTS", "10"))
56
- GRAPH_MAX_CLAIMS = int(os.getenv("GRAPH_MAX_CLAIMS", "6"))
57
- GRAPH_MAX_RESULTS = int(os.getenv("GRAPH_MAX_RESULTS", "10"))
58
-
59
  STOPWORDS = {
60
  "a", "an", "and", "are", "as", "at", "be", "been", "being", "by", "can", "could",
61
  "did", "do", "does", "for", "from", "had", "has", "have", "if", "in", "into", "is",
62
  "it", "its", "may", "might", "of", "on", "or", "our", "such", "that", "the", "their",
63
  "there", "these", "this", "those", "to", "using", "use", "used", "via", "was", "were",
64
  "will", "with", "within", "without", "we", "they", "you", "your", "study", "paper",
65
- "research", "results", "method", "methods", "analysis", "approach", "toward", "towards",
66
- "based", "new", "novel", "effect", "effects", "model", "models", "system", "systems",
67
- "show", "shows",
68
  }
69
 
70
  GRAPH_MEMORY = {
71
  "papers": {},
72
  "nodes": {},
73
  "edges": [],
74
- "concept_counts": Counter(),
75
- "claim_counts": Counter(),
76
  "queries": [],
77
- "events": [],
78
  }
79
 
80
- # ---------------------------------------------------------------------------
81
- # Text utilities
82
- # ---------------------------------------------------------------------------
83
 
84
  def safe_text(x, default=""):
85
  return html.escape(str(x if x is not None else default))
@@ -89,18 +60,23 @@ def norm_text(x: Optional[str]) -> str:
89
  return re.sub(r"\s+", " ", (x or "")).strip()
90
 
91
 
92
- def slugify(text: str) -> str:
93
- return re.sub(r"[^a-z0-9]+", "-", (text or "").lower()).strip("-")
94
-
95
-
96
  def ensure_list(x):
97
  return x if isinstance(x, list) else []
98
 
99
 
 
 
 
 
 
 
 
 
 
100
  def normalize_doi(text: str) -> str:
101
  text = (text or "").strip()
102
- text = text.replace("https://doi.org/", "").replace("http://doi.org/", "")
103
- return text.strip()
104
 
105
 
106
  def detect_query_type(query: str) -> str:
@@ -114,171 +90,214 @@ def detect_query_type(query: str) -> str:
114
 
115
 
116
  def tokenize(text: str) -> List[str]:
117
- return [t for t in re.findall(r"[a-zA-Z][a-zA-Z0-9\-]{2,}", (text or "").lower()) if t not in STOPWORDS]
 
 
 
118
 
119
 
120
- def unique_keep_order(items: List[str]) -> List[str]:
121
- seen = set()
122
- out = []
123
- for item in items:
124
- key = norm_text(item).lower()
125
- if key and key not in seen:
126
- seen.add(key)
127
- out.append(norm_text(item))
128
- return out
 
 
 
 
 
 
 
129
 
 
 
 
 
 
 
 
 
130
 
131
- def text_overlap_score(a: str, b: str) -> float:
132
- sa = set(tokenize(a))
133
- sb = set(tokenize(b))
134
- if not sa or not sb:
135
- return 0.0
136
- return len(sa & sb) / max(1, len(sa | sb))
137
 
 
 
 
 
 
 
 
138
 
139
- def compute_recency_bonus(year: str) -> float:
140
- try:
141
- y = int(str(year)[:4])
142
- except Exception:
143
- return 0.0
144
- current = time.gmtime().tm_year
145
- age = max(current - y, 0)
146
- return max(0.0, 0.12 - age * 0.015)
147
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
 
149
- def extract_candidate_phrases(text: str, max_terms: int = 20) -> List[str]:
150
- text = norm_text(text)
151
- if not text:
152
- return []
153
- tokens = re.findall(r"[A-Za-z][A-Za-z0-9\-]{2,}", text)
154
- phrases = []
155
  for n in (3, 2, 1):
156
- for i in range(len(tokens) - n + 1):
157
- phrase = " ".join(tokens[i:i + n]).strip().lower()
158
- if len(phrase) < 4:
 
159
  continue
160
- parts = phrase.split()
161
- if any(p in STOPWORDS for p in parts):
162
  continue
163
- if all(len(p) <= 2 for p in parts):
164
- continue
165
- phrases.append(phrase)
166
- counts = Counter(phrases)
167
- ranked = [p for p, _ in counts.most_common(max_terms * 3)]
168
- filtered = []
169
- for phrase in ranked:
170
- if phrase in filtered:
 
 
 
 
 
 
 
 
 
 
 
171
  continue
172
- if any(phrase != other and phrase in other for other in filtered):
173
  continue
174
- filtered.append(phrase)
175
- if len(filtered) >= max_terms:
 
176
  break
177
- return filtered
178
-
179
 
180
- def extract_concepts_from_text(text: str, max_terms: int = GRAPH_MAX_CONCEPTS) -> List[str]:
181
- return extract_candidate_phrases(text, max_terms=max_terms)
182
 
183
 
184
  def extract_claim_like_sentences(text: str, max_items: int = GRAPH_MAX_CLAIMS) -> List[str]:
185
- text = norm_text(text)
186
- if not text:
187
- return []
188
- parts = re.split(r"(?<=[.!?])\s+", text)
189
  scored = []
190
- for sentence in parts:
191
- s = norm_text(sentence)
192
- if len(s) < 40 or len(s) > 280:
193
  continue
194
- lower = s.lower()
195
  score = 0.0
196
- if any(k in lower for k in ["improves", "reduces", "increases", "suggests", "demonstrates",
197
- "shows", "reveals", "predicts", "achieves", "outperforms"]):
198
  score += 2.0
199
- if any(k in lower for k in ["significant", "associated", "correlated", "effective",
200
- "robust", "accurate", "validated"]):
201
  score += 1.0
202
- score += min(len(tokenize(s)) / 15.0, 2.0)
203
- scored.append((score, s))
204
- return [s for _, s in sorted(scored, key=lambda x: x[0], reverse=True)[:max_items]]
205
-
206
-
207
- def parse_openalex_abstract(inverted_index) -> str:
208
- if not inverted_index or not isinstance(inverted_index, dict):
209
- return ""
210
- pos_to_word = {}
211
- for word, positions in inverted_index.items():
212
- for pos in positions:
213
- pos_to_word[pos] = word
214
- if not pos_to_word:
215
- return ""
216
- return " ".join(pos_to_word[i] for i in sorted(pos_to_word))
217
-
218
- # ---------------------------------------------------------------------------
219
- # Enrichment
220
- # ---------------------------------------------------------------------------
221
-
222
- def enrich_paper_semantics(query: str, paper: Dict) -> Dict:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
  paper = dict(paper)
224
- title = paper.get("title", "")
225
- abstract = paper.get("abstract", "") or paper.get("summary", "")
226
- venue = paper.get("venue", "")
227
- base_text = " ".join([title, abstract, venue]).strip()
228
- concepts = extract_concepts_from_text(base_text, max_terms=GRAPH_MAX_CONCEPTS)
229
- claims = extract_claim_like_sentences(abstract, max_items=GRAPH_MAX_CLAIMS)
230
- rel = text_overlap_score(query, f"{title} {abstract}")
231
- recency = compute_recency_bonus(paper.get("year"))
232
- doi_bonus = 0.02 if paper.get("doi") else 0.0
233
- oa_bonus = 0.03 if paper.get("open_access") else 0.0
234
- concept_bonus = min(len(concepts), 8) * 0.01
235
- learned_score = float(paper.get("score", 0)) + rel * 0.5 + recency + doi_bonus + oa_bonus + concept_bonus
236
- paper["concepts"] = concepts[:GRAPH_MAX_CONCEPTS]
237
- paper["claims"] = claims[:GRAPH_MAX_CLAIMS]
238
- paper["relevance"] = round(rel, 4)
239
- paper["learned_score"] = round(learned_score, 4)
240
  return paper
241
 
242
 
243
- def paper_identity_key(paper: Dict) -> str:
244
- return (
245
- normalize_doi(paper.get("doi") or "")
246
- or (paper.get("external_ids") or {}).get("arxiv")
247
- or norm_text(paper.get("title", "")).lower()
248
- or str(paper.get("id"))
249
- )
250
 
251
- # ---------------------------------------------------------------------------
252
- # Journal links
253
- # ---------------------------------------------------------------------------
254
 
255
- def journal_query_links(query: str):
256
- q = urllib.parse.quote_plus(query or "biomaterials cardiac repair")
257
- rows = []
258
- for journal in JOURNALS:
259
- url = f"{journal['url']}?q={q}" if "?" not in journal["url"] else f"{journal['url']}&q={q}"
260
- if "ieeexplore" in journal["url"]:
261
- url = f"https://ieeexplore.ieee.org/search/searchresult.jsp?queryText={q}"
262
- rows.append({"name": journal["name"], "desc": journal["desc"], "url": url})
263
- return rows
264
-
265
-
266
- def build_journal_html(query):
267
- rows = []
268
- for journal in journal_query_links(query):
269
- rows.append(
270
- f"""<div style="border:1px solid #e2e8f0;border-radius:8px;padding:12px 16px;margin:6px 0;display:flex;align-items:center;justify-content:space-between;background:#f8fafc;">
271
- <div><strong>{safe_text(journal['name'])}</strong><br/><span style="color:#64748b;font-size:0.85em;">{safe_text(journal['desc'])}</span></div>
272
- <a href="{safe_text(journal['url'])}" target="_blank" style="background:#6366f1;color:#fff;padding:6px 14px;border-radius:6px;text-decoration:none;font-size:0.85em;">Open ↗</a>
273
- </div>"""
274
- )
275
- return '<div style="padding:8px 0;">' + "".join(rows) + "</div>"
276
 
277
- # ---------------------------------------------------------------------------
278
- # Source search functions
279
- # ---------------------------------------------------------------------------
280
 
281
- def search_arxiv(query, max_results=8):
282
  encoded = urllib.parse.quote(query)
283
  url = (
284
  "http://export.arxiv.org/api/query?search_query=all:"
@@ -286,20 +305,26 @@ def search_arxiv(query, max_results=8):
286
  )
287
  response = requests.get(url, timeout=REQUEST_TIMEOUT)
288
  response.raise_for_status()
 
289
  root = ET.fromstring(response.text)
290
  ns = {"atom": "http://www.w3.org/2005/Atom"}
291
  papers = []
 
292
  for entry in root.findall("atom:entry", ns):
293
- title = " ".join((entry.findtext("atom:title", default="", namespaces=ns) or "").split())
294
- summary = " ".join((entry.findtext("atom:summary", default="", namespaces=ns) or "").split())
295
- published = entry.findtext("atom:published", default="", namespaces=ns)
296
- paper_id = entry.findtext("atom:id", default="", namespaces=ns)
297
- authors = [a.findtext("atom:name", default="", namespaces=ns) for a in entry.findall("atom:author", ns)]
 
 
 
298
  pdf_url = ""
299
  for link in entry.findall("atom:link", ns):
300
  if link.attrib.get("title") == "pdf":
301
  pdf_url = link.attrib.get("href", "")
302
  break
 
303
  papers.append({
304
  "id": paper_id or title,
305
  "title": title,
@@ -314,17 +339,17 @@ def search_arxiv(query, max_results=8):
314
  "venue": "arXiv",
315
  "year": published[:4] if published else "",
316
  "source": "arxiv",
317
- "score": 0.76,
318
  "open_access": True,
319
- "external_ids": {"arxiv": (paper_id or "").split("/")[-1]},
320
  })
 
321
  return papers
322
 
323
 
324
- def search_crossref(query, mode="topic", max_results=8):
325
- headers = {"User-Agent": "dvnc-ai-space/0.3"}
326
  if mode == "doi":
327
- url = f"https://api.crossref.org/works/{urllib.parse.quote(query)}"
328
  response = requests.get(url, headers=headers, timeout=REQUEST_TIMEOUT)
329
  if response.status_code != 200:
330
  return []
@@ -338,135 +363,727 @@ def search_crossref(query, mode="topic", max_results=8):
338
  response = requests.get("https://api.crossref.org/works", params=params, headers=headers, timeout=REQUEST_TIMEOUT)
339
  response.raise_for_status()
340
  items = response.json().get("message", {}).get("items", [])
 
341
  out = []
342
  for item in items:
343
  authors = []
344
  for a in item.get("author", []) or []:
345
  name = " ".join(filter(None, [a.get("given"), a.get("family")])).strip()
346
  if name:
347
- authors.append(name)
348
- title = (item.get("title") or ["Untitled"])[0]
 
 
349
  year = ""
350
  for key in ["published-print", "published-online", "created"]:
351
  if item.get(key, {}).get("date-parts"):
352
  year = str(item[key]["date-parts"][0][0])
353
  break
354
- abstract = re.sub("<.*?>", "", item.get("abstract") or "")
355
- doi = normalize_doi(item.get("DOI", ""))
356
  out.append({
357
- "id": doi or title,
358
- "title": norm_text(title),
359
- "summary": norm_text(abstract)[:500],
360
- "abstract": norm_text(abstract),
361
  "published": year,
362
  "authors": authors,
363
  "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
364
  "url": item.get("URL", ""),
365
  "pdf": "",
366
- "doi": doi,
367
- "venue": (item.get("container-title") or [""])[0],
368
  "year": year,
369
  "source": "crossref",
370
  "score": 0.72,
371
  "open_access": None,
372
- "external_ids": {"crossref": doi} if doi else {},
373
  })
 
374
  return out
375
 
376
 
377
- def search_openalex(query, mode="topic", max_results=8):
378
- params = {"per-page": max_results}
379
- if mode == "doi":
380
- doi = normalize_doi(query)
381
- params["filter"] = f"doi:https://doi.org/{doi}"
382
- else:
383
- params["search"] = query
384
- response = requests.get("https://api.openalex.org/works", params=params, timeout=REQUEST_TIMEOUT)
385
- response.raise_for_status()
386
- items = response.json().get("results", [])
387
- out = []
388
- for item in items:
389
- authors = []
390
- for auth in item.get("authorships", [])[:8]:
391
- author = auth.get("author") or {}
392
- if author.get("display_name"):
393
- authors.append(author["display_name"])
394
- oa = item.get("open_access") or {}
395
- doi = normalize_doi(item.get("doi") or "")
396
- abstract = parse_openalex_abstract(item.get("abstract_inverted_index"))
397
- out.append({
398
- "id": item.get("id") or doi or item.get("title"),
399
- "title": norm_text(item.get("title")),
400
- "summary": norm_text(abstract)[:500],
401
- "abstract": norm_text(abstract),
402
- "published": str(item.get("publication_year") or ""),
403
- "authors": authors,
404
- "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
405
- "url": (item.get("primary_location") or {}).get("landing_page_url") or item.get("id") or "",
406
- "pdf": oa.get("oa_url") or "",
407
- "doi": doi,
408
- "venue": ((item.get("primary_location") or {}).get("source") or {}).get("display_name") or "",
409
- "year": str(item.get("publication_year") or ""),
410
- "source": "openalex",
411
- "score": 0.80,
412
- "open_access": oa.get("is_oa"),
413
- "external_ids": item.get("ids") or {},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
414
  })
415
- return out
416
 
 
 
 
 
 
 
 
 
417
 
418
- def search_semantic_scholar(query, mode="topic", max_results=8):
419
- headers = {}
420
- if SEMANTIC_SCHOLAR_API_KEY:
421
- headers["x-api-key"] = SEMANTIC_SCHOLAR_API_KEY
422
- fields = "title,authors,year,abstract,venue,externalIds,url,openAccessPdf"
423
- if mode == "doi":
424
- doi = normalize_doi(query)
425
- url = f"https://api.semanticscholar.org/graph/v1/paper/DOI:{urllib.parse.quote(doi)}"
426
- response = requests.get(url, params={"fields": fields}, headers=headers, timeout=REQUEST_TIMEOUT)
427
- if response.status_code != 200:
428
- return []
429
- items = [response.json()]
430
- else:
431
- response = requests.get(
432
- "https://api.semanticscholar.org/graph/v1/paper/search",
433
- params={"query": query, "limit": max_results, "fields": fields},
434
- headers=headers,
435
- timeout=REQUEST_TIMEOUT,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
436
  )
437
- if response.status_code != 200:
438
- return []
439
- items = response.json().get("data", [])
440
- out = []
441
- for item in items:
442
- external = item.get("externalIds") or {}
443
- authors = [a.get("name") for a in item.get("authors", []) if a.get("name")]
444
- out.append({
445
- "id": external.get("CorpusId") or external.get("DOI") or item.get("title"),
446
- "title": norm_text(item.get("title")),
447
- "summary": norm_text(item.get("abstract", ""))[:500],
448
- "abstract": norm_text(item.get("abstract", "")),
449
- "published": str(item.get("year") or ""),
450
- "authors": authors,
451
- "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
452
- "url": item.get("url") or "",
453
- "pdf": (item.get("openAccessPdf") or {}).get("url") or "",
454
- "doi": normalize_doi(external.get("DOI", "")),
455
- "venue": item.get("venue") or "",
456
- "year": str(item.get("year") or ""),
457
- "source": "semantic_scholar",
458
- "score": 0.84,
459
- "open_access": bool((item.get("openAccessPdf") or {}).get("url")),
460
- "external_ids": external,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
461
  })
462
- return out
463
 
 
464
 
465
- def search_europe_pmc(query, mode="topic", max_results=8):
466
- epmc_query = f'DOI:"{query}"' if mode == "doi" else query
467
- params = {"query": epmc_query, "format": "json", "pageSize": max_results, "resultType": "core"}
468
- response = requests.get("https://www.ebi.ac.uk/europepmc/webservices/rest/search", params=params, timeout=REQUEST_TIMEOUT)
469
- if response.status_code != 200:
470
- return []
471
- items = response.json().get("resultList", {}).get("result", [])
472
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import html
2
  import json
 
 
3
  import re
 
4
  import urllib.parse
5
  import xml.etree.ElementTree as ET
 
6
  from pathlib import Path
7
+ from typing import Any, Dict, List, Optional
8
 
9
  import gradio as gr
10
  import requests
 
14
  except Exception:
15
  fitz = None
16
 
 
 
 
 
17
 
18
+ SEARCH_MODES = ["topic", "title", "doi", "link", "paper_name", "autonomous_web"]
19
+ SOURCE_OPTIONS = ["arxiv", "crossref", "openalex", "semantic_scholar", "europe_pmc"]
20
+ DEFAULT_SOURCES = ["arxiv", "crossref", "openalex", "semantic_scholar", "europe_pmc"]
21
+ PDF_PARSERS = ["pymupdf"]
22
 
23
+ REQUEST_TIMEOUT = 25
24
+ GRAPH_MAX_RESULTS = 10
25
+ GRAPH_MAX_CONCEPTS = 10
26
+ GRAPH_MAX_CLAIMS = 6
 
 
 
 
 
 
 
 
27
 
28
  JOURNALS = [
29
  {"name": "Nature", "url": "https://www.nature.com/search", "desc": "Flagship multidisciplinary research journal."},
 
33
  {"name": "IEEE Xplore", "url": "https://ieeexplore.ieee.org/search/searchresult.jsp", "desc": "Engineering, AI, signal processing, and systems."},
34
  ]
35
 
 
 
 
 
 
 
 
 
 
 
 
36
  STOPWORDS = {
37
  "a", "an", "and", "are", "as", "at", "be", "been", "being", "by", "can", "could",
38
  "did", "do", "does", "for", "from", "had", "has", "have", "if", "in", "into", "is",
39
  "it", "its", "may", "might", "of", "on", "or", "our", "such", "that", "the", "their",
40
  "there", "these", "this", "those", "to", "using", "use", "used", "via", "was", "were",
41
  "will", "with", "within", "without", "we", "they", "you", "your", "study", "paper",
42
+ "research", "results", "result", "method", "methods", "analysis", "approach", "based",
43
+ "new", "novel", "effect", "effects", "model", "models", "system", "systems", "show",
44
+ "shows", "shown", "however", "therefore", "also", "between", "across", "among", "et", "al",
45
  }
46
 
47
  GRAPH_MEMORY = {
48
  "papers": {},
49
  "nodes": {},
50
  "edges": [],
 
 
51
  "queries": [],
 
52
  }
53
 
 
 
 
54
 
55
  def safe_text(x, default=""):
56
  return html.escape(str(x if x is not None else default))
 
60
  return re.sub(r"\s+", " ", (x or "")).strip()
61
 
62
 
 
 
 
 
63
  def ensure_list(x):
64
  return x if isinstance(x, list) else []
65
 
66
 
67
+ def truncate_text(text: str, limit: int) -> str:
68
+ text = norm_text(text)
69
+ return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
70
+
71
+
72
+ def slugify(text: str) -> str:
73
+ return re.sub(r"[^a-z0-9]+", "-", (text or "").lower()).strip("-")
74
+
75
+
76
  def normalize_doi(text: str) -> str:
77
  text = (text or "").strip()
78
+ text = re.sub(r"^https?://(dx\.)?doi\.org/", "", text, flags=re.I)
79
+ return text.strip().rstrip("/")
80
 
81
 
82
  def detect_query_type(query: str) -> str:
 
90
 
91
 
92
  def tokenize(text: str) -> List[str]:
93
+ return [
94
+ t for t in re.findall(r"[A-Za-z][A-Za-z0-9\-/+]{2,}", (text or ""))
95
+ if t.lower() not in STOPWORDS
96
+ ]
97
 
98
 
99
+ def clean_pdf_text(text: str) -> str:
100
+ text = text or ""
101
+ replacements = {
102
+ "\u00ad": "",
103
+ "\ufb01": "fi",
104
+ "\ufb02": "fl",
105
+ "\u2010": "-",
106
+ "\u2011": "-",
107
+ "\u2012": "-",
108
+ "\u2013": "-",
109
+ "\u2014": "-",
110
+ "\u2212": "-",
111
+ "\u00a0": " ",
112
+ }
113
+ for old, new in replacements.items():
114
+ text = text.replace(old, new)
115
 
116
+ text = re.sub(r"(\w)-\s*\n\s*(\w)", r"\1\2", text)
117
+ text = re.sub(r"([a-z])\n([a-z])", r"\1 \2", text)
118
+ text = re.sub(r"\bnjectable\b", "injectable", text, flags=re.I)
119
+ text = re.sub(r"\bfhydrogel\b", "hydrogel", text, flags=re.I)
120
+ text = re.sub(r"\b[a-z](hydrogel|conductive|responsive|scaffold|biomaterial|regeneration)\b", r"\1", text, flags=re.I)
121
+ text = re.sub(r"[ \t]+", " ", text)
122
+ text = re.sub(r"\n{3,}", "\n\n", text)
123
+ return text.strip()
124
 
 
 
 
 
 
 
125
 
126
+ def normalize_phrase(phrase: str) -> str:
127
+ phrase = norm_text(phrase)
128
+ mapping = {"ph": "pH", "ai": "AI", "ml": "ML", "3d": "3D"}
129
+ out = []
130
+ for part in phrase.split():
131
+ out.append(mapping.get(part.lower(), part))
132
+ return " ".join(out)
133
 
 
 
 
 
 
 
 
 
134
 
135
+ def looks_bad_phrase(phrase: str) -> bool:
136
+ phrase = norm_text(phrase)
137
+ if not phrase or len(phrase) < 4 or len(phrase) > 80:
138
+ return True
139
+ parts = phrase.split()
140
+ if len(parts) > 5:
141
+ return True
142
+ for p in parts:
143
+ stripped = p.strip("-.,;:()[]{}")
144
+ if len(stripped) == 1:
145
+ return True
146
+ if re.match(r"^[bcdfghjklmnpqrstvwxyz]{5,}$", stripped.lower()):
147
+ return True
148
+ return False
149
+
150
+
151
+ def extract_concepts_from_text(text: str, max_terms: int = GRAPH_MAX_CONCEPTS) -> List[str]:
152
+ text = clean_pdf_text(text)
153
+ words = re.findall(r"[A-Za-z][A-Za-z0-9\-/+]{2,}", text)
154
+ candidates = []
155
 
 
 
 
 
 
 
156
  for n in (3, 2, 1):
157
+ for i in range(len(words) - n + 1):
158
+ phrase = " ".join(words[i:i + n])
159
+ low = phrase.lower()
160
+ if any(tok in STOPWORDS for tok in low.split()):
161
  continue
162
+ if looks_bad_phrase(low):
 
163
  continue
164
+ candidates.append(low)
165
+
166
+ ranked = []
167
+ counts = {}
168
+ for phrase in candidates:
169
+ counts[phrase] = counts.get(phrase, 0) + 1
170
+
171
+ for phrase, count in sorted(counts.items(), key=lambda x: x[1], reverse=True):
172
+ score = count + 0.2 * len(phrase.split())
173
+ if any(k in phrase for k in ["hydrogel", "conductive", "responsive", "injectable", "graph", "neural", "cardiac", "biomaterial"]):
174
+ score += 1.0
175
+ ranked.append((score, phrase))
176
+
177
+ output = []
178
+ seen = set()
179
+ for _, phrase in sorted(ranked, key=lambda x: x[0], reverse=True):
180
+ clean = normalize_phrase(phrase)
181
+ low = clean.lower()
182
+ if low in seen:
183
  continue
184
+ if any((low in s or s in low) and abs(len(low) - len(s)) <= 2 for s in seen):
185
  continue
186
+ seen.add(low)
187
+ output.append(clean)
188
+ if len(output) >= max_terms:
189
  break
 
 
190
 
191
+ return output
 
192
 
193
 
194
  def extract_claim_like_sentences(text: str, max_items: int = GRAPH_MAX_CLAIMS) -> List[str]:
195
+ text = clean_pdf_text(text)
196
+ sentences = [norm_text(s) for s in re.split(r"(?<=[\.\!\?])\s+", text) if norm_text(s)]
 
 
197
  scored = []
198
+
199
+ for sentence in sentences:
200
+ if len(sentence) < 40 or len(sentence) > 320:
201
  continue
202
+ lower = sentence.lower()
203
  score = 0.0
204
+ if any(k in lower for k in ["improves", "reduces", "increases", "demonstrates", "shows", "reveals", "predicts", "achieves", "outperforms", "enables"]):
 
205
  score += 2.0
206
+ if any(k in lower for k in ["significant", "associated", "correlated", "effective", "robust", "accurate", "validated"]):
 
207
  score += 1.0
208
+ score += min(len(tokenize(sentence)) / 18.0, 2.0)
209
+ scored.append((score, sentence))
210
+
211
+ scored.sort(key=lambda x: x[0], reverse=True)
212
+ out = []
213
+ seen = set()
214
+ for _, sentence in scored:
215
+ key = sentence.lower()
216
+ if key not in seen:
217
+ seen.add(key)
218
+ out.append(sentence)
219
+ if len(out) >= max_items:
220
+ break
221
+ return out
222
+
223
+
224
+ def extract_title_from_text(text: str, fallback: str = "Uploaded PDF") -> str:
225
+ text = clean_pdf_text(text)
226
+ lines = [norm_text(x) for x in text.splitlines() if norm_text(x)]
227
+ for line in lines[:18]:
228
+ if 8 <= len(line) <= 220 and not re.search(r"doi|http|www\.|@", line, flags=re.I):
229
+ return truncate_text(line.strip(" -:;"), 240)
230
+ return fallback
231
+
232
+
233
+ def extract_references_from_text(text: str) -> List[Dict[str, str]]:
234
+ refs = []
235
+ seen = set()
236
+ doi_re = re.compile(r"10\.\d{4,9}/[-._;()/:A-Z0-9]+", re.I)
237
+
238
+ for line in [norm_text(x) for x in clean_pdf_text(text).splitlines() if norm_text(x)][-250:]:
239
+ doi_match = doi_re.search(line)
240
+ doi = normalize_doi(doi_match.group(0)) if doi_match else ""
241
+ title = line.replace(doi_match.group(0), "").strip(" .;,-") if doi_match else line
242
+ if len(title) < 12:
243
+ continue
244
+ key = (title.lower(), doi.lower())
245
+ if key in seen:
246
+ continue
247
+ seen.add(key)
248
+ refs.append({"title": truncate_text(title, 220), "doi": doi})
249
+ if len(refs) >= 30:
250
+ break
251
+ return refs
252
+
253
+
254
+ def score_overlap(query: str, text: str) -> float:
255
+ qa = {x.lower() for x in tokenize(query)}
256
+ ta = {x.lower() for x in tokenize(text)}
257
+ if not qa or not ta:
258
+ return 0.0
259
+ return len(qa & ta) / max(1, len(qa | ta))
260
+
261
+
262
+ def enrich_paper(query: str, paper: Dict[str, Any]) -> Dict[str, Any]:
263
  paper = dict(paper)
264
+ title = clean_pdf_text(paper.get("title", ""))
265
+ abstract = clean_pdf_text(paper.get("abstract", "") or paper.get("summary", ""))
266
+ venue = clean_pdf_text(paper.get("venue", ""))
267
+ concepts = extract_concepts_from_text(f"{title} {abstract} {venue}", GRAPH_MAX_CONCEPTS)
268
+ claims = extract_claim_like_sentences(abstract, GRAPH_MAX_CLAIMS)
269
+ overlap = score_overlap(query, f"{title} {abstract}")
270
+ score = float(paper.get("score", 0)) + overlap * 0.5 + min(len(concepts), 8) * 0.02
271
+
272
+ paper["title"] = title or "Untitled"
273
+ paper["abstract"] = abstract
274
+ paper["summary"] = truncate_text(abstract or paper.get("summary", ""), 420)
275
+ paper["venue"] = venue
276
+ paper["concepts"] = concepts
277
+ paper["claims"] = claims
278
+ paper["learned_score"] = round(score, 4)
 
279
  return paper
280
 
281
 
282
+ def paper_identity_key(paper: Dict[str, Any]) -> str:
283
+ return normalize_doi(paper.get("doi", "")) or norm_text(paper.get("title", "")).lower()
 
 
 
 
 
284
 
 
 
 
285
 
286
+ def dedupe_papers(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
287
+ seen = {}
288
+ for item in items:
289
+ key = paper_identity_key(item) or f"{item.get('source', 'src')}::{item.get('title', 'paper')}"
290
+ current = seen.get(key)
291
+ cur_score = float(current.get("learned_score", current.get("score", 0))) if current else -1
292
+ new_score = float(item.get("learned_score", item.get("score", 0)))
293
+ if current is None or new_score > cur_score:
294
+ seen[key] = item
295
+ out = list(seen.values())
296
+ out.sort(key=lambda x: float(x.get("learned_score", x.get("score", 0))), reverse=True)
297
+ return out
 
 
 
 
 
 
 
 
 
298
 
 
 
 
299
 
300
+ def search_arxiv(query: str, max_results: int = 6) -> List[Dict[str, Any]]:
301
  encoded = urllib.parse.quote(query)
302
  url = (
303
  "http://export.arxiv.org/api/query?search_query=all:"
 
305
  )
306
  response = requests.get(url, timeout=REQUEST_TIMEOUT)
307
  response.raise_for_status()
308
+
309
  root = ET.fromstring(response.text)
310
  ns = {"atom": "http://www.w3.org/2005/Atom"}
311
  papers = []
312
+
313
  for entry in root.findall("atom:entry", ns):
314
+ title = clean_pdf_text(entry.findtext("atom:title", default="", namespaces=ns) or "")
315
+ summary = clean_pdf_text(entry.findtext("atom:summary", default="", namespaces=ns) or "")
316
+ published = entry.findtext("atom:published", default="", namespaces=ns) or ""
317
+ paper_id = entry.findtext("atom:id", default="", namespaces=ns) or ""
318
+ authors = [
319
+ clean_pdf_text(a.findtext("atom:name", default="", namespaces=ns) or "")
320
+ for a in entry.findall("atom:author", ns)
321
+ ]
322
  pdf_url = ""
323
  for link in entry.findall("atom:link", ns):
324
  if link.attrib.get("title") == "pdf":
325
  pdf_url = link.attrib.get("href", "")
326
  break
327
+
328
  papers.append({
329
  "id": paper_id or title,
330
  "title": title,
 
339
  "venue": "arXiv",
340
  "year": published[:4] if published else "",
341
  "source": "arxiv",
342
+ "score": 0.75,
343
  "open_access": True,
 
344
  })
345
+
346
  return papers
347
 
348
 
349
+ def search_crossref(query: str, mode: str = "topic", max_results: int = 6) -> List[Dict[str, Any]]:
350
+ headers = {"User-Agent": "dvnc-ai-space/1.0"}
351
  if mode == "doi":
352
+ url = f"https://api.crossref.org/works/{urllib.parse.quote(normalize_doi(query))}"
353
  response = requests.get(url, headers=headers, timeout=REQUEST_TIMEOUT)
354
  if response.status_code != 200:
355
  return []
 
363
  response = requests.get("https://api.crossref.org/works", params=params, headers=headers, timeout=REQUEST_TIMEOUT)
364
  response.raise_for_status()
365
  items = response.json().get("message", {}).get("items", [])
366
+
367
  out = []
368
  for item in items:
369
  authors = []
370
  for a in item.get("author", []) or []:
371
  name = " ".join(filter(None, [a.get("given"), a.get("family")])).strip()
372
  if name:
373
+ authors.append(clean_pdf_text(name))
374
+
375
+ title = clean_pdf_text((item.get("title") or ["Untitled"])[0])
376
+ abstract = clean_pdf_text(re.sub("<.*?>", " ", item.get("abstract") or ""))
377
  year = ""
378
  for key in ["published-print", "published-online", "created"]:
379
  if item.get(key, {}).get("date-parts"):
380
  year = str(item[key]["date-parts"][0][0])
381
  break
382
+
 
383
  out.append({
384
+ "id": normalize_doi(item.get("DOI", "")) or title,
385
+ "title": title,
386
+ "summary": truncate_text(abstract, 420),
387
+ "abstract": abstract,
388
  "published": year,
389
  "authors": authors,
390
  "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
391
  "url": item.get("URL", ""),
392
  "pdf": "",
393
+ "doi": normalize_doi(item.get("DOI", "")),
394
+ "venue": clean_pdf_text((item.get("container-title") or [""])[0]),
395
  "year": year,
396
  "source": "crossref",
397
  "score": 0.72,
398
  "open_access": None,
 
399
  })
400
+
401
  return out
402
 
403
 
404
+ def discover_papers(query: str, search_mode: str, sources: List[str], max_results: int = GRAPH_MAX_RESULTS) -> List[Dict[str, Any]]:
405
+ query = norm_text(query)
406
+ if not query:
407
+ return []
408
+
409
+ mode = detect_query_type(query) if search_mode == "autonomous_web" else search_mode
410
+ items = []
411
+
412
+ if mode == "link":
413
+ doi_match = re.search(r"10\.\d{4,9}/[-._;()/:A-Z0-9]+", query, flags=re.I)
414
+ if doi_match:
415
+ mode = "doi"
416
+ query = doi_match.group(0)
417
+ else:
418
+ return [{
419
+ "id": query,
420
+ "title": query,
421
+ "summary": "Direct web link supplied.",
422
+ "abstract": "",
423
+ "published": "",
424
+ "authors": [],
425
+ "authors_text": "Unknown authors",
426
+ "url": query,
427
+ "pdf": query if query.lower().endswith(".pdf") else "",
428
+ "doi": "",
429
+ "venue": "Web link",
430
+ "year": "",
431
+ "source": "link",
432
+ "score": 0.4,
433
+ "open_access": None,
434
+ "concepts": [],
435
+ "claims": [],
436
+ "learned_score": 0.4,
437
+ }]
438
+
439
+ selected = ensure_list(sources) or DEFAULT_SOURCES
440
+
441
+ if "arxiv" in selected and mode != "doi":
442
+ try:
443
+ items.extend(search_arxiv(query, min(max_results, 6)))
444
+ except Exception:
445
+ pass
446
+
447
+ if "crossref" in selected:
448
+ try:
449
+ items.extend(search_crossref(query, mode=mode, max_results=min(max_results, 8)))
450
+ except Exception:
451
+ pass
452
+
453
+ enriched = [enrich_paper(query, item) for item in items]
454
+ enriched = dedupe_papers(enriched)
455
+ return enriched[:max_results]
456
+
457
+
458
+ def build_journal_html(query: str) -> str:
459
+ q = urllib.parse.quote_plus(query or "biomaterials cardiac repair")
460
+ cards = []
461
+
462
+ for journal in JOURNALS:
463
+ if "ieeexplore" in journal["url"]:
464
+ url = f"https://ieeexplore.ieee.org/search/searchresult.jsp?queryText={q}"
465
+ elif "?" in journal["url"]:
466
+ url = f"{journal['url']}&q={q}"
467
+ else:
468
+ url = f"{journal['url']}?q={q}"
469
+
470
+ cards.append(
471
+ f"""
472
+ <a class="journal-card" href="{safe_text(url)}" target="_blank" rel="noopener noreferrer">
473
+ <div>
474
+ <h4>{safe_text(journal['name'])}</h4>
475
+ <p>{safe_text(journal['desc'])}</p>
476
+ </div>
477
+ <span>Open</span>
478
+ </a>
479
+ """
480
+ )
481
+
482
+ return """
483
+ <style>
484
+ .journal-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px}
485
+ .journal-card{display:flex;justify-content:space-between;gap:12px;padding:14px 16px;border-radius:14px;
486
+ border:1px solid #dbe3f0;background:#f8fbff;text-decoration:none;color:#0f172a}
487
+ .journal-card h4{margin:0 0 4px;font-size:15px}
488
+ .journal-card p{margin:0;color:#475569;font-size:13px;line-height:1.45}
489
+ .journal-card span{align-self:center;font-size:12px;font-weight:700;color:#2563eb}
490
+ </style>
491
+ <div class="journal-grid">""" + "".join(cards) + "</div>"
492
+
493
+
494
+ def render_papers_html(papers: List[Dict[str, Any]]) -> str:
495
+ if not papers:
496
+ return '<div style="padding:18px;border:1px solid #e2e8f0;border-radius:16px;background:#fff">No papers found yet.</div>'
497
+
498
+ cards = []
499
+ for i, paper in enumerate(papers, start=1):
500
+ concepts = ", ".join((paper.get("concepts") or [])[:5]) or "None extracted"
501
+ cards.append(
502
+ f"""
503
+ <article style="border:1px solid #e2e8f0;border-radius:16px;padding:16px;background:#fff;display:flex;flex-direction:column;gap:10px">
504
+ <div style="display:flex;gap:8px;flex-wrap:wrap">
505
+ <span style="padding:4px 8px;border-radius:999px;background:#eff6ff;color:#1d4ed8;font-size:12px;font-weight:700">{safe_text(paper.get('source','paper'))}</span>
506
+ <span style="padding:4px 8px;border-radius:999px;background:#f8fafc;color:#475569;font-size:12px">{safe_text(paper.get('year',''))}</span>
507
+ </div>
508
+ <h4 style="margin:0;font-size:16px;line-height:1.35">{i}. {safe_text(paper.get('title','Untitled'))}</h4>
509
+ <p style="margin:0;color:#334155;line-height:1.55">{safe_text(paper.get('summary','No abstract snippet available.'))}</p>
510
+ <div style="font-size:13px;color:#475569;line-height:1.6">
511
+ <div><strong>Authors:</strong> {safe_text(paper.get('authors_text','Unknown authors'))}</div>
512
+ <div><strong>Venue:</strong> {safe_text(paper.get('venue','Unknown venue'))}</div>
513
+ <div><strong>Score:</strong> {safe_text(paper.get('learned_score', paper.get('score', 0)))}</div>
514
+ <div><strong>Concepts:</strong> {safe_text(concepts)}</div>
515
+ </div>
516
+ </article>
517
+ """
518
+ )
519
+
520
+ return '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:14px">' + "".join(cards) + "</div>"
521
+
522
+
523
+ def uploaded_pdf_summary(file_obj) -> str:
524
+ if not file_obj:
525
+ return "No PDF uploaded yet."
526
+ path = getattr(file_obj, "name", None) or str(file_obj)
527
+ return f"Uploaded PDF ready: {Path(path).name}"
528
+
529
+
530
+ def _graph_kind_style(kind: str) -> Dict[str, Any]:
531
+ return {
532
+ "query": {"color": "#1f8ef1", "size": 14},
533
+ "paper": {"color": "#00c49a", "size": 10},
534
+ "upload": {"color": "#ff9f43", "size": 11},
535
+ "concept": {"color": "#a66cff", "size": 8},
536
+ "author": {"color": "#f368e0", "size": 7},
537
+ "claim": {"color": "#ff6b6b", "size": 8},
538
+ "reference": {"color": "#6b7280", "size": 7},
539
+ }.get(kind, {"color": "#94a3b8", "size": 7})
540
+
541
+
542
+ def build_learning_graph_html(nodes, edges, title="Self-Learning Knowledge Graph"):
543
+ nodes = ensure_list(nodes)
544
+ edges = ensure_list(edges)
545
+
546
+ if not nodes:
547
+ return """
548
+ <div style="padding:18px;border:1px solid #dbe3f0;border-radius:18px;background:#fff;overflow:auto">
549
+ <h3 style="margin:0 0 8px">Self-Learning Knowledge Graph</h3>
550
+ <p style="margin:0;color:#475569">No papers mapped yet.</p>
551
+ </div>
552
+ """
553
+
554
+ graph_nodes = []
555
+ for node in nodes:
556
+ kind = (node.get("kind") or node.get("type") or "paper").lower()
557
+ if kind == "topic":
558
+ kind = "query"
559
+ if kind == "uploadedpdf":
560
+ kind = "upload"
561
+ style = _graph_kind_style(kind)
562
+ graph_nodes.append({
563
+ "id": node.get("id"),
564
+ "label": truncate_text(node.get("label") or node.get("title") or node.get("id") or "node", 100),
565
+ "kind": kind,
566
+ "color": style["color"],
567
+ "val": style["size"],
568
+ "detail": {
569
+ "type": kind,
570
+ "title": node.get("title") or node.get("label") or "",
571
+ "source": node.get("source") or "",
572
+ "year": node.get("year") or "",
573
+ "doi": node.get("doi") or "",
574
+ "authors_text": node.get("authors_text") or "",
575
+ "text": node.get("text") or "",
576
+ },
577
  })
 
578
 
579
+ graph_links = []
580
+ for edge in edges:
581
+ graph_links.append({
582
+ "source": edge.get("source"),
583
+ "target": edge.get("target"),
584
+ "label": edge.get("type", "RELATES_TO"),
585
+ "type": edge.get("type", "RELATES_TO"),
586
+ })
587
 
588
+ payload = json.dumps({"nodes": graph_nodes, "links": graph_links, "title": title}, ensure_ascii=False)
589
+
590
+ iframe_doc = f"""
591
+ <!doctype html>
592
+ <html>
593
+ <head>
594
+ <meta charset="utf-8">
595
+ <meta name="viewport" content="width=device-width, initial-scale=1">
596
+ <style>
597
+ html,body{{margin:0;height:100%;overflow:hidden;background:#0b1020;color:#eef2ff;font-family:Inter,Arial,sans-serif}}
598
+ #graph{{position:absolute;inset:0}}
599
+ .panel{{position:absolute;left:14px;top:14px;z-index:10;max-width:430px;padding:12px 14px;border-radius:14px;
600
+ background:rgba(8,12,24,.76);border:1px solid rgba(255,255,255,.12);backdrop-filter:blur(12px)}}
601
+ .panel h3{{margin:0 0 6px;font-size:17px}}
602
+ .panel p{{margin:0;font-size:12px;line-height:1.5;color:#cbd5e1}}
603
+ .details{{position:absolute;right:14px;top:14px;z-index:10;width:min(320px,calc(100% - 28px));max-height:calc(100% - 28px);overflow:auto;
604
+ padding:12px 14px;border-radius:14px;background:rgba(8,12,24,.76);border:1px solid rgba(255,255,255,.12);backdrop-filter:blur(12px)}}
605
+ .details h4{{margin:0 0 8px;font-size:14px}}
606
+ .details dl{{display:grid;grid-template-columns:auto 1fr;gap:6px 10px;margin:0;font-size:12px}}
607
+ .details dt{{color:#93c5fd}} .details dd{{margin:0;color:#e2e8f0;word-break:break-word}}
608
+ .hint{{position:absolute;left:14px;bottom:14px;z-index:10;padding:9px 12px;border-radius:12px;
609
+ font-size:12px;background:rgba(8,12,24,.76);border:1px solid rgba(255,255,255,.10);color:#dbeafe}}
610
+ </style>
611
+ <script src="https://unpkg.com/three@0.160.0/build/three.min.js"></script>
612
+ <script src="https://unpkg.com/3d-force-graph"></script>
613
+ </head>
614
+ <body>
615
+ <div id="graph"></div>
616
+ <div class="panel">
617
+ <h3>{safe_text(title)}</h3>
618
+ <p>Drag background to orbit, scroll to zoom, right-drag to pan, and drag a node to move and pin it in 3D space.</p>
619
+ </div>
620
+ <div class="details" id="details">
621
+ <h4>Node details</h4>
622
+ <p style="margin:0;color:#cbd5e1;font-size:12px">Click a node to inspect it.</p>
623
+ </div>
624
+ <div class="hint">3D graph enabled: orbit, zoom, pan, drag nodes.</div>
625
+ <script>
626
+ const data = {payload};
627
+
628
+ const Graph = ForceGraph3D()(document.getElementById('graph'))
629
+ .backgroundColor('#00000000')
630
+ .graphData(data)
631
+ .nodeRelSize(6)
632
+ .nodeLabel(node => `<div style="padding:6px 8px"><strong>${{node.label}}</strong><br/>${{node.kind}}</div>`)
633
+ .linkLabel(link => link.label)
634
+ .linkWidth(link => ['ABOUT','UPLOADED_SOURCE'].includes(link.type) ? 2.4 : 1.2)
635
+ .linkDirectionalParticles(link => ['ABOUT','UPLOADED_SOURCE'].includes(link.type) ? 2 : 0)
636
+ .linkDirectionalParticleWidth(2)
637
+ .nodeColor(node => node.color)
638
+ .nodeVal(node => node.val)
639
+ .onNodeClick(node => {{
640
+ const d = node.detail || {{}};
641
+ document.getElementById('details').innerHTML = `
642
+ <h4>Node details</h4>
643
+ <dl>
644
+ <dt>Label</dt><dd>${{node.label || ''}}</dd>
645
+ <dt>Type</dt><dd>${{d.type || node.kind || ''}}</dd>
646
+ <dt>Title</dt><dd>${{d.title || '—'}}</dd>
647
+ <dt>Source</dt><dd>${{d.source || '—'}}</dd>
648
+ <dt>Year</dt><dd>${{d.year || '—'}}</dd>
649
+ <dt>DOI</dt><dd>${{d.doi || '—'}}</dd>
650
+ <dt>Authors</dt><dd>${{d.authors_text || '—'}}</dd>
651
+ <dt>Text</dt><dd>${{d.text || '—'}}</dd>
652
+ </dl>`;
653
+ }})
654
+ .onNodeDragEnd(node => {{
655
+ node.fx = node.x;
656
+ node.fy = node.y;
657
+ node.fz = node.z;
658
+ }});
659
+
660
+ Graph.cameraPosition({{ z: 180 }});
661
+ </script>
662
+ </body>
663
+ </html>
664
+ """
665
+ return f"""
666
+ <div style="border:1px solid #dbe3f0;border-radius:18px;background:#fff;overflow:auto">
667
+ <iframe
668
+ title="{safe_text(title)}"
669
+ srcdoc="{html.escape(iframe_doc, quote=True)}"
670
+ style="width:100%;height:760px;border:0;border-radius:18px;overflow:auto;background:#0b1020"
671
+ sandbox="allow-scripts allow-same-origin"
672
+ ></iframe>
673
+ </div>
674
+ """
675
+
676
+
677
+ def seed_graph_state(query: str, papers: List[Dict[str, Any]], uploaded_name: Optional[str] = None):
678
+ nodes = [{"id": "query", "label": query or "Research topic", "kind": "query"}]
679
+ edges = []
680
+
681
+ for i, paper in enumerate(papers[:6], start=1):
682
+ pid = f"paper_{i}"
683
+ nodes.append({
684
+ "id": pid,
685
+ "label": paper.get("title", f"Paper {i}"),
686
+ "kind": "paper",
687
+ "title": paper.get("title"),
688
+ "year": paper.get("year"),
689
+ "source": paper.get("source"),
690
+ "authors_text": paper.get("authors_text"),
691
+ })
692
+ edges.append({"source": "query", "target": pid, "type": "ABOUT"})
693
+
694
+ for concept in (paper.get("concepts") or [])[:3]:
695
+ cid = f"concept_{i}_{slugify(concept)[:30]}"
696
+ nodes.append({"id": cid, "label": concept, "kind": "concept"})
697
+ edges.append({"source": pid, "target": cid, "type": "MENTIONS"})
698
+
699
+ if uploaded_name:
700
+ nodes.append({"id": "upload", "label": uploaded_name, "kind": "upload"})
701
+ edges.append({"source": "query", "target": "upload", "type": "UPLOADED_SOURCE"})
702
+
703
+ return nodes, edges
704
+
705
+
706
+ def paper_choice_value(index: int, paper: Dict[str, Any]) -> str:
707
+ return f"{index}|{slugify(paper.get('title', 'paper'))[:40]}"
708
+
709
+
710
+ def paper_choice_label(index: int, paper: Dict[str, Any]) -> str:
711
+ title = paper.get("title", "Untitled")
712
+ authors = paper.get("authors_text", "Unknown authors")
713
+ score = paper.get("learned_score", paper.get("score", 0))
714
+ return f"[{paper.get('source','paper')}] {title} — {authors} — score {score}"
715
+
716
+
717
+ def format_selection_choices(papers: List[Dict[str, Any]]):
718
+ return [(paper_choice_label(i, paper), paper_choice_value(i, paper)) for i, paper in enumerate(papers)]
719
+
720
+
721
+ def resolve_selected_papers(selected_indices, papers_state):
722
+ papers = ensure_list(papers_state)
723
+ selected_indices = ensure_list(selected_indices)
724
+ selected = []
725
+
726
+ for item in selected_indices:
727
+ if isinstance(item, int) and 0 <= item < len(papers):
728
+ selected.append(papers[item])
729
+ continue
730
+ item = str(item)
731
+ if item.isdigit():
732
+ idx = int(item)
733
+ if 0 <= idx < len(papers):
734
+ selected.append(papers[idx])
735
+ continue
736
+ if "|" in item and item.split("|", 1)[0].isdigit():
737
+ idx = int(item.split("|", 1)[0])
738
+ if 0 <= idx < len(papers):
739
+ selected.append(papers[idx])
740
+
741
+ deduped = []
742
+ seen = set()
743
+ for paper in selected:
744
+ key = paper_identity_key(paper)
745
+ if key not in seen:
746
+ seen.add(key)
747
+ deduped.append(paper)
748
+ return deduped
749
+
750
+
751
+ def parse_uploaded_pdf(file_obj, parser_order=None):
752
+ if not file_obj:
753
+ return "### PDF parse status\n\n- No PDF uploaded yet.", {}
754
+
755
+ path = getattr(file_obj, "name", None) or str(file_obj)
756
+ file_name = Path(path).name
757
+
758
+ if fitz is None:
759
+ parsed = {
760
+ "parser": "none",
761
+ "title": file_name,
762
+ "abstract": "",
763
+ "authors": [],
764
+ "sections": [],
765
+ "references": [],
766
+ "concepts": [],
767
+ "claims": [],
768
+ "raw_text": "",
769
+ }
770
+ return "### PDF parse status\n\n- PyMuPDF is unavailable in this runtime.", parsed
771
+
772
+ try:
773
+ doc = fitz.open(path)
774
+ text = "\n".join(page.get_text("text") for page in doc[: min(len(doc), 8)])
775
+ text = clean_pdf_text(text)
776
+ except Exception as e:
777
+ return f"### PDF parse status\n\n- Failed to parse PDF\n- Error: {safe_text(str(e))}", {}
778
+
779
+ title = extract_title_from_text(text, fallback=file_name)
780
+
781
+ abstract = ""
782
+ match = re.search(r"abstract\s*(.+?)(?:\n\s*\n|\n(?:1|i)[\.\s]|\nintroduction)", text, re.I | re.S)
783
+ if match:
784
+ abstract = truncate_text(clean_pdf_text(match.group(1)), 2400)
785
+
786
+ sections = []
787
+ blocks = re.split(r"\n(?=[A-Z][A-Z0-9 \-]{3,80}\n)", text)
788
+ for block in blocks[:10]:
789
+ lines = [norm_text(x) for x in block.splitlines() if norm_text(x)]
790
+ if not lines:
791
+ continue
792
+ heading = lines[0][:80]
793
+ body = " ".join(lines[1:] if len(lines) > 1 else [])
794
+ if len(body) > 80:
795
+ sections.append({"heading": heading, "text": truncate_text(body, 1800)})
796
+
797
+ parsed = {
798
+ "parser": "pymupdf",
799
+ "title": title,
800
+ "abstract": abstract,
801
+ "authors": [],
802
+ "sections": sections,
803
+ "references": extract_references_from_text(text),
804
+ "concepts": extract_concepts_from_text(f"{title} {abstract} {text[:16000]}", GRAPH_MAX_CONCEPTS),
805
+ "claims": extract_claim_like_sentences(text, GRAPH_MAX_CLAIMS),
806
+ "raw_text": text[:30000],
807
+ }
808
+
809
+ summary = "\n".join([
810
+ "### PDF parse status",
811
+ "",
812
+ f"- Parser used: {parsed['parser']}",
813
+ f"- Title: {parsed['title']}",
814
+ f"- Abstract found: {'Yes' if parsed['abstract'] else 'No'}",
815
+ f"- Sections extracted: {len(parsed['sections'])}",
816
+ f"- References extracted: {len(parsed['references'])}",
817
+ f"- Concepts extracted: {len(parsed['concepts'])}",
818
+ f"- Claims extracted: {len(parsed['claims'])}",
819
+ ])
820
+ return summary, parsed
821
+
822
+
823
+ def render_parse_result(parsed):
824
+ if not parsed or not isinstance(parsed, dict) or not parsed.get("title"):
825
+ return '<div style="padding:18px;border:1px solid #e2e8f0;border-radius:16px;background:#fff">No parsed document yet.</div>'
826
+
827
+ sections_html = []
828
+ for section in parsed.get("sections", [])[:8]:
829
+ sections_html.append(
830
+ f"""
831
+ <details style="border:1px solid #e2e8f0;border-radius:14px;padding:12px 14px;background:#fff">
832
+ <summary style="cursor:pointer;font-weight:700">{safe_text(section.get('heading','Section'))}</summary>
833
+ <p style="margin:10px 0 0;color:#334155;line-height:1.6">{safe_text(section.get('text',''))}</p>
834
+ </details>
835
+ """
836
  )
837
+
838
+ concepts = "".join(f"<li>{safe_text(x)}</li>" for x in parsed.get("concepts", [])[:12]) or "<li>None</li>"
839
+ claims = "".join(f"<li>{safe_text(x)}</li>" for x in parsed.get("claims", [])[:8]) or "<li>None</li>"
840
+ refs = "".join(
841
+ f"<li>{safe_text(r.get('title','Untitled'))}{' · DOI ' + safe_text(r.get('doi')) if r.get('doi') else ''}</li>"
842
+ for r in parsed.get("references", [])[:12]
843
+ ) or "<li>None</li>"
844
+
845
+ return f"""
846
+ <div style="display:grid;gap:14px">
847
+ <div style="padding:18px;border:1px solid #e2e8f0;border-radius:16px;background:#fff">
848
+ <h3 style="margin:0 0 8px">{safe_text(parsed.get('title','Parsed document'))}</h3>
849
+ <p style="margin:0;color:#475569;line-height:1.6">{safe_text(parsed.get('abstract','No abstract extracted.'))}</p>
850
+ </div>
851
+ <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:14px">
852
+ <div style="padding:16px;border:1px solid #e2e8f0;border-radius:16px;background:#fff"><h4>Concepts</h4><ul>{concepts}</ul></div>
853
+ <div style="padding:16px;border:1px solid #e2e8f0;border-radius:16px;background:#fff"><h4>Claims</h4><ul>{claims}</ul></div>
854
+ <div style="padding:16px;border:1px solid #e2e8f0;border-radius:16px;background:#fff"><h4>References</h4><ul>{refs}</ul></div>
855
+ </div>
856
+ <div style="display:grid;gap:12px">{''.join(sections_html) if sections_html else ''}</div>
857
+ </div>
858
+ """
859
+
860
+
861
+ def build_ingest_payload(query: str, selected_papers: List[Dict[str, Any]], parsed_pdf: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
862
+ nodes = []
863
+ edges = []
864
+
865
+ def add_node(node_id: str, node_type: str, label: str, **attrs):
866
+ node = {"id": node_id, "type": node_type, "label": label}
867
+ node.update(attrs)
868
+ nodes.append(node)
869
+
870
+ def add_edge(source: str, target: str, edge_type: str):
871
+ edges.append({"source": source, "target": target, "type": edge_type})
872
+
873
+ topic_id = "topic:query"
874
+ add_node(topic_id, "Topic", query or "Research topic", query=query)
875
+
876
+ for i, paper in enumerate(selected_papers, start=1):
877
+ paper_id = normalize_doi(paper.get("doi")) or f"paper:{i}:{slugify(paper.get('title','paper'))[:40]}"
878
+ add_node(
879
+ paper_id,
880
+ "Paper",
881
+ paper.get("title", f"Paper {i}"),
882
+ title=paper.get("title"),
883
+ year=paper.get("year"),
884
+ venue=paper.get("venue"),
885
+ doi=paper.get("doi"),
886
+ source=paper.get("source"),
887
+ authors_text=paper.get("authors_text"),
888
+ )
889
+ add_edge(topic_id, paper_id, "ABOUT")
890
+
891
+ for author in paper.get("authors", [])[:4]:
892
+ author_id = f"author:{slugify(author)[:40]}"
893
+ add_node(author_id, "Author", author, name=author)
894
+ add_edge(paper_id, author_id, "WRITTEN_BY")
895
+
896
+ for concept in (paper.get("concepts") or [])[:6]:
897
+ concept_id = f"concept:{slugify(concept)[:48]}"
898
+ add_node(concept_id, "Concept", concept, name=concept)
899
+ add_edge(paper_id, concept_id, "MENTIONS")
900
+
901
+ for claim in (paper.get("claims") or [])[:3]:
902
+ claim_id = f"claim:{slugify(claim)[:48]}"
903
+ add_node(claim_id, "Claim", truncate_text(claim, 120), text=claim)
904
+ add_edge(paper_id, claim_id, "ASSERTS")
905
+
906
+ if parsed_pdf and parsed_pdf.get("title"):
907
+ upload_id = "upload:pdf"
908
+ add_node(upload_id, "UploadedPDF", parsed_pdf.get("title"), parser=parsed_pdf.get("parser"))
909
+ add_edge(topic_id, upload_id, "UPLOADED_SOURCE")
910
+
911
+ for concept in (parsed_pdf.get("concepts") or [])[:6]:
912
+ concept_id = f"concept:{slugify(concept)[:48]}"
913
+ add_node(concept_id, "Concept", concept, name=concept)
914
+ add_edge(upload_id, concept_id, "MENTIONS")
915
+
916
+ for claim in (parsed_pdf.get("claims") or [])[:4]:
917
+ claim_id = f"claim:{slugify(claim)[:48]}"
918
+ add_node(claim_id, "Claim", truncate_text(claim, 120), text=claim)
919
+ add_edge(upload_id, claim_id, "ASSERTS")
920
+
921
+ for idx, ref in enumerate(parsed_pdf.get("references", [])[:12], start=1):
922
+ rid = normalize_doi(ref.get("doi")) or f"ref:{idx}:{slugify(ref.get('title','reference'))[:40]}"
923
+ add_node(rid, "Reference", ref.get("title", f"Reference {idx}"), doi=ref.get("doi", ""))
924
+ add_edge(upload_id, rid, "CITES")
925
+
926
+ dedup_nodes = {}
927
+ for node in nodes:
928
+ dedup_nodes[node["id"]] = node
929
+
930
+ return {"status": "ok", "nodes": list(dedup_nodes.values()), "edges": edges}
931
+
932
+
933
+ def ingest_selected_papers(query, selected_indices, papers_state, pdf_file, parsed_state):
934
+ papers = ensure_list(papers_state)
935
+ selected = resolve_selected_papers(selected_indices, papers)
936
+
937
+ if not selected and papers:
938
+ selected = papers[:3]
939
+
940
+ parsed = parsed_state if isinstance(parsed_state, dict) else {}
941
+ query_text = norm_text(query or "") or parsed.get("title") or "Research topic"
942
+
943
+ if not selected and not parsed.get("title"):
944
+ empty_payload = {"status": "empty", "nodes": [], "edges": []}
945
+ graph_html = build_learning_graph_html([], [], "Self-Learning Knowledge Graph")
946
+ status_md = "### Graph ingest status\n\n- No papers selected.\n- No parsed PDF available."
947
+ return graph_html, status_md, empty_payload
948
+
949
+ payload = build_ingest_payload(query_text, selected, parsed)
950
+
951
+ GRAPH_MEMORY["queries"].append(query_text)
952
+ GRAPH_MEMORY["nodes"] = {node["id"]: node for node in payload["nodes"]}
953
+ GRAPH_MEMORY["edges"] = payload["edges"]
954
+ for node in payload["nodes"]:
955
+ if node.get("type") == "Paper":
956
+ GRAPH_MEMORY["papers"][node["id"]] = node
957
+
958
+ graph_nodes = []
959
+ for node in payload["nodes"]:
960
+ kind = node.get("type", "Paper")
961
+ if kind == "Topic":
962
+ kind = "query"
963
+ elif kind == "Paper":
964
+ kind = "paper"
965
+ elif kind == "UploadedPDF":
966
+ kind = "upload"
967
+ elif kind == "Concept":
968
+ kind = "concept"
969
+ elif kind == "Author":
970
+ kind = "author"
971
+ elif kind == "Claim":
972
+ kind = "claim"
973
+ elif kind == "Reference":
974
+ kind = "reference"
975
+ else:
976
+ kind = "paper"
977
+
978
+ graph_nodes.append({
979
+ "id": node["id"],
980
+ "label": node.get("label", node["id"]),
981
+ "kind": kind,
982
+ "title": node.get("title", node.get("label", "")),
983
+ "year": node.get("year", ""),
984
+ "doi": node.get("doi", ""),
985
+ "source": node.get("source", ""),
986
+ "authors_text": node.get("authors_text", ""),
987
+ "text": node.get("text", ""),
988
  })
 
989
 
990
+ graph_html = build_learning_graph_html(graph_nodes, payload["edges"], "Selected Research Graph")
991
 
992
+ node_lines = [f"- [{n.get('type')}] {n.get('label')}" for n in payload["nodes"][:24]]
993
+ edge_lines = [f"- {e.get('source')} --{e.get('type')}--> {e.get('target')}" for e in payload["edges"][:30]]
994
+
995
+ status_md = "\n".join([
996
+ "### Graph ingest status",
997
+ "",
998
+ f"- Topic: {query_text}",
999
+ f"- Selected papers ingested: {len(selected)}",
1000
+ f"- Uploaded PDF parsed: {'Yes' if parsed.get('title') else 'No'}",
1001
+ f"- Total nodes created: {len(payload['nodes'])}",
1002
+ f"- Total edges created: {len(payload['edges'])}",
1003
+ "",
1004
+ "### Nodes",
1005
+ *(node_lines or ["- None"]),
1006
+ "",
1007
+ "### Edges",
1008
+ *(edge_lines or ["- None"]),
1009
+ ])
1010
+
1011
+ return graph_html, status_md, payload
1012
+
1013
+
1014
+ def run_paper_discovery(query, search_mode, sources, pdf_file):
1015
+ query_text = norm_text(query or "")
1016
+ chosen_sources = ensure_list(sources) or DEFAULT_SOURCES
1017
+ uploaded_name = Path(getattr(pdf_file, "name", str(pdf_file))).name if pdf_file else None
1018
+
1019
+ if not query_text and not pdf_file:
1020
+ empty_graph = build_learning_graph_html([], [], "Self-Learning Knowledge Graph")
1021
+ return (
1022
+ empty_graph,
1023
+ '<div style="padding:18px;border:1px solid #e2e8f0;border-radius:16px;background:#fff">Enter a query or upload a PDF to begin.</div>',
1024
+ build_journal_html("biomaterials cardiac repair"),
1025
+ "No PDF uploaded yet.",
1026
+ gr.update(choices=[], value=[]),
1027
+ [],
1028
+ "### No discovery results yet.",
1029
+ )
1030
+
1031
+ if not query_text and pdf_file:
1032
+ nodes, edges = seed_graph_state("", [], uploaded_name)
1033
+ return (
1034
+ build_learning_graph_html(nodes, edges, "Uploaded PDF Waiting for Parse"),
1035
+ '<div style="padding:18px;border:1px solid #e2e8f0;border-radius:16px;background:#fff">Upload detected. Parse the PDF or enter a topic.</div>',
1036
+ build_journal_html("biomaterials cardiac repair"),
1037
+ uploaded_pdf_summary(pdf_file),
1038
+ gr.update(choices=[], value=[]),
1039
+ [],
1040
+ "### Upload detected.\n\n- Parse the PDF to extract concepts, claims, and references.",
1041
+ )
1042
+
1043
+ try:
1044
+ papers = discover_papers(query_text, search_mode, chosen_sources, GRAPH_MAX_RESULTS)
1045
+ except Exception as e:
1046
+ papers = []
1047
+ error_message = str(e)
1048
+ else:
1049
+ error_message = ""
1050
+
1051
+ nodes, edges = seed_graph_state(query_text, papers[:6], uploaded_name)
1052
+ graph_html = build_learning_graph_html(nodes, edges, "Self-Learning Knowledge Graph")
1053
+ papers_html = render_papers_html(papers) if not error_message else f'<div style="padding:18px;border:1px solid #fecaca;border-radius:16px;background:#fff1f2">Paper search failed: {safe_text(error_message)}</div>'
1054
+ journals_html = build_journal_html(query_text or "biomaterials cardiac repair")
1055
+ pdf_summary = uploaded_pdf_summary(pdf_file)
1056
+ choices = format_selection_choices(papers)
1057
+
1058
+ status_md = "\n".join([
1059
+ "### Discovery results",
1060
+ "",
1061
+ f"- Query: {query_text}",
1062
+ f"- Sources: {', '.join(chosen_sources)}",
1063
+ f"- Candidates found: {len(papers)}",
1064
+ "- Select papers below, or ingest the top papers automatically.",
1065
+ ])
1066
+
1067
+ return (
1068
+ graph_html,
1069
+ papers_html,
1070
+ journals_html,
1071
+ pdf_summary,
1072
+ gr.update(choices=choices, value=[]),
1073
+ papers,
1074
+ status_md,
1075
+ )
1076
+
1077
+
1078
+ __all__ = [
1079
+ "DEFAULT_SOURCES",
1080
+ "SEARCH_MODES",
1081
+ "SOURCE_OPTIONS",
1082
+ "build_learning_graph_html",
1083
+ "build_journal_html",
1084
+ "ingest_selected_papers",
1085
+ "parse_uploaded_pdf",
1086
+ "render_parse_result",
1087
+ "run_paper_discovery",
1088
+ "safe_text",
1089
+ ] html