app.py CHANGED
@@ -1,29 +1,755 @@
1
- import importlib.util
2
- import sys
 
 
 
 
 
 
 
 
 
 
 
 
3
  from pathlib import Path
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
- ORIGINAL_APP_PATH = Path("dvnc_ai_v2_hf/app.py")
6
- PATCH_PATH = Path("dvnc_flip_insight_patch.py")
7
 
 
 
 
 
 
8
 
9
- def load_module(module_name: str, file_path: Path):
10
- spec = importlib.util.spec_from_file_location(module_name, file_path)
11
- if spec is None or spec.loader is None:
12
- raise RuntimeError(f"Could not load module {module_name} from {file_path}")
13
- module = importlib.util.module_from_spec(spec)
14
- sys.modules[module_name] = module
15
- spec.loader.exec_module(module)
16
- return module
17
 
 
18
 
19
- original_app = load_module("dvnc_original_app", ORIGINAL_APP_PATH)
20
- patch_module = load_module("dvnc_flip_insight_patch", PATCH_PATH)
 
 
 
21
 
22
- patch_module.apply_patch(original_app)
 
 
 
 
 
 
 
 
23
 
24
- demo = getattr(original_app, "demo", None)
25
- app = getattr(original_app, "app", None)
 
 
 
26
 
27
  if __name__ == "__main__":
28
- if demo is not None:
29
- demo.launch()
 
1
+ """
2
+ DVNC.AI — app.py
3
+ Refactored for functional "Use as main insight" logic with academic rigor.
4
+ """
5
+
6
+ # ── Standard library ────────────────────────────────────────────────────────
7
+ import html
8
+ import json
9
+ import math
10
+ import os
11
+ import random
12
+ import re
13
+ import urllib.parse
14
+ import xml.etree.ElementTree as ET
15
  from pathlib import Path
16
+ from typing import Dict, List, Optional
17
+ from urllib.parse import quote
18
+
19
+ # ── Third-party ──────────────────────────────────────────────────────────────
20
+ import gradio as gr
21
+ import requests
22
+
23
+ try:
24
+ import fitz # PyMuPDF
25
+ except Exception:
26
+ fitz = None
27
+
28
+ try:
29
+ from bs4 import BeautifulSoup
30
+ except Exception:
31
+ BeautifulSoup = None
32
+
33
+ # ── Internal modules ─────────────────────────────────────────────────────────
34
+ from dvnc_ai_v2_hf.agent_route_cards import build_agent_route_cards_html
35
+ from dvnc_ai_v2_hf.discovery_app_bridge import (
36
+ get_default_route_state,
37
+ get_discovery_css,
38
+ get_initial_discovery_timeline_html,
39
+ )
40
+ from dvnc_ai_v2_hf.dvnc_ui_layout import get_dvnc_layout_css
41
+ from dvnc_ai_v2_hf.self_learning_graph import (
42
+ DEFAULT_SOURCES,
43
+ SEARCH_MODES,
44
+ SOURCE_OPTIONS,
45
+ build_learning_graph_html,
46
+ build_journal_html,
47
+ ingest_selected_papers,
48
+ parse_uploaded_pdf,
49
+ render_parse_result,
50
+ run_paper_discovery,
51
+ safe_text,
52
+ )
53
+
54
+ # ── Constants ────────────────────────────────────────────────────────────────
55
+ MODELS = [
56
+ {"name": "DVNC Sovereign", "tag": "flagship", "desc": "Maximum depth orchestration for frontier discovery"},
57
+ {"name": "DVNC Atlas", "tag": "research", "desc": "Balanced reasoning, graph traversal, and synthesis"},
58
+ {"name": "DVNC Curie", "tag": "lab", "desc": "Experimental hypothesis generation for anomalous signals"},
59
+ ]
60
+
61
+ AGENTS = [
62
+ "Query Interpreter",
63
+ "Graph Divergence Mapper",
64
+ "Evidence Harvester",
65
+ "Analogy Engine",
66
+ "Hypothesis Composer",
67
+ "Adversarial Critic",
68
+ "Experimental Program Designer",
69
+ ]
70
+
71
+ NODES = [
72
+ {"id": "seed", "label": "Seed Query", "group": "core", "x": 10, "y": 0, "z": 0},
73
+ {"id": "bio", "label": "Biomaterials", "group": "domain", "x": 24, "y": 12, "z": -8},
74
+ {"id": "card", "label": "Cardiac Repair", "group": "domain", "x": 38, "y": 3, "z": 14},
75
+ {"id": "nano", "label": "Nanostructure", "group": "bridge", "x": 24, "y": -18, "z": 16},
76
+ {"id": "selfasm", "label": "Self-Assembly", "group": "bridge", "x": 40, "y": -16, "z": -16},
77
+ {"id": "electro", "label": "Electro-signalling", "group": "mechanism", "x": 58, "y": 10, "z": -10},
78
+ {"id": "immune", "label": "Immune Modulation", "group": "mechanism", "x": 64, "y": -8, "z": 10},
79
+ {"id": "trial", "label": "Validation Path", "group": "outcome", "x": 80, "y": 0, "z": 0},
80
+ {"id": "alt1", "label": "Piezoelectric Scaffold","group": "candidate", "x": 56, "y": 26, "z": 14},
81
+ {"id": "alt2", "label": "Peptide Mesh", "group": "candidate", "x": 54, "y": -27, "z": -14},
82
+ ]
83
+
84
+ EDGES = [
85
+ ("seed", "bio"), ("seed", "nano"),
86
+ ("bio", "card"), ("nano", "selfasm"),
87
+ ("selfasm", "electro"),("card", "immune"),
88
+ ("electro", "trial"), ("immune", "trial"),
89
+ ("card", "alt1"), ("selfasm","alt2"),
90
+ ("alt1", "trial"), ("alt2", "trial"),
91
+ ]
92
+
93
+ DEFAULT_PATH = ["seed", "nano", "selfasm", "electro", "trial"]
94
+
95
+ CANDIDATES = [
96
+ {
97
+ "title": "Piezoelectric Scaffold Cascade",
98
+ "front": "Use mechano-electric scaffolds to convert cardiac strain into micro-current signalling.",
99
+ "back": "Discovery path: anomalous healing signal -> piezoelectric analog -> ion-channel entrainment -> tissue regeneration. Risk: power density and fibrosis coupling.",
100
+ "score": 92,
101
+ "novelty": "High",
102
+ "agent": "Hypothesis Composer",
103
+ },
104
+ {
105
+ "title": "Peptide Self-Assembly Mesh",
106
+ "front": "Deploy dynamic peptide meshes that self-assemble around damaged myocardium and guide repair.",
107
+ "back": "Discovery path: self-assembly -> local immune choreography -> regenerative substrate formation. Risk: degradation timing and targeting specificity.",
108
+ "score": 88,
109
+ "novelty": "High",
110
+ "agent": "Analogy Engine",
111
+ },
112
+ {
113
+ "title": "Immune-Tuned Conductive Hydrogel",
114
+ "front": "Blend conductivity with macrophage-state modulation to reduce scarring and restore conduction.",
115
+ "back": "Discovery path: inflammation mismatch -> conductive medium -> macrophage polarization -> synchronized healing. Risk: persistence and biocompatibility.",
116
+ "score": 85,
117
+ "novelty": "Medium-High",
118
+ "agent": "Adversarial Critic",
119
+ },
120
+ ]
121
+
122
+ ACADEMIC_INSIGHTS = [
123
+ {
124
+ "hypothesis": "Implementation of mechano-electric scaffolds to transduce cardiac strain into localized micro-current signalling for myocardial regeneration.",
125
+ "metrics": {"Novelty": 92, "Mechanistic clarity": 85, "Experimental tractability": 78, "Cross-domain distance": 94},
126
+ "outline": "1. Synthesize candidate piezoelectric biomaterial scaffolds with tunable strain-electric coupling.\n2. Evaluate *in vitro* electromechanical transduction and subsequent ion-channel entrainment.\n3. Conduct *in vivo* comparative models to assess regenerative efficacy against gold-standard substrates.\n4. Rigorously validate to exclude pathological fibrosis and power-density toxicity.",
127
+ "path": ["seed", "bio", "card", "alt1", "trial"]
128
+ },
129
+ {
130
+ "hypothesis": "Deployment of dynamic peptide networks that self-assemble post-infarction to orchestrate local immunological responses and guide substrate regeneration.",
131
+ "metrics": {"Novelty": 88, "Mechanistic clarity": 82, "Experimental tractability": 86, "Cross-domain distance": 85},
132
+ "outline": "1. Formulate peptide sequences programmed for triggered *in situ* self-assembly within the myocardial infarct zone.\n2. Quantify macrophage polarization and local immune choreography post-deployment.\n3. Map the temporospatial degradation profile against *de novo* tissue formation.\n4. Falsify against off-target aggregation and delayed clearance risks.",
133
+ "path": ["seed", "nano", "selfasm", "alt2", "trial"]
134
+ },
135
+ {
136
+ "hypothesis": "Integration of conductive hydrogels with immunomodulatory properties to simultaneously bridge electrical uncoupling and mitigate adverse fibrotic scarring.",
137
+ "metrics": {"Novelty": 85, "Mechanistic clarity": 90, "Experimental tractability": 88, "Cross-domain distance": 79},
138
+ "outline": "1. Fabricate biocompatible hydrogels featuring precisely tuned electrical conductivity and immunomodulatory motifs.\n2. Monitor electrophysiological synchronization across the scaffold-tissue interface.\n3. Assess macrophage state transitions and suppression of adverse fibrotic remodelling.\n4. Validate long-term persistence, hemocompatibility, and mechanical integration.",
139
+ "path": ["seed", "bio", "card", "immune", "trial"]
140
+ }
141
+ ]
142
+
143
+ JOURNALS = [
144
+ {"name": "Nature", "url": "https://www.nature.com/search", "desc": "Flagship multidisciplinary research journal."},
145
+ {"name": "Science", "url": "https://www.science.org/search", "desc": "High-impact science journal and family."},
146
+ {"name": "Cell", "url": "https://www.cell.com/search", "desc": "Life sciences and translational biology."},
147
+ {"name": "The Lancet", "url": "https://www.thelancet.com/search", "desc": "Clinical and medical research."},
148
+ {"name": "IEEE Xplore","url": "https://ieeexplore.ieee.org/search/searchresult.jsp", "desc": "Engineering, AI, signal processing, and systems."},
149
+ ]
150
+
151
+ SEMANTIC_SCHOLAR_API_KEY = os.getenv("SEMANTIC_SCHOLAR_API_KEY", "")
152
+ GROBID_URL = os.getenv("GROBID_URL", "").strip()
153
+ REQUEST_TIMEOUT = 25
154
+
155
+
156
+ # ── Utility helpers ──────────────────────────────────────────────────────────
157
+ def safe_text(x, default: str = "") -> str:
158
+ return html.escape(str(x if x is not None else default))
159
+
160
+
161
+ def norm_text(x: Optional[str]) -> str:
162
+ return re.sub(r"\s+", " ", (x or "")).strip()
163
+
164
+
165
+ def detect_query_type(query: str) -> str:
166
+ q = (query or "").strip()
167
+ if re.match(r"^10\.\d{4,9}/[-._;()/:A-Z0-9]+$", q, flags=re.I):
168
+ return "doi"
169
+ if q.startswith("http://") or q.startswith("https://"):
170
+ return "link"
171
+ return "topic"
172
+
173
+
174
+ def ensure_list(x):
175
+ return x if isinstance(x, list) else []
176
+
177
+
178
+ # ── HTML builders ─────────────────────────────────────────────────────────────
179
+ def build_connectome_html(path_ids: List[str]) -> str:
180
+ active = set(path_ids)
181
+ node_map = {n["id"]: n for n in NODES}
182
+ path_pairs = {
183
+ pair
184
+ for i in range(len(path_ids) - 1)
185
+ for pair in [(path_ids[i], path_ids[i + 1]), (path_ids[i + 1], path_ids[i])]
186
+ }
187
+
188
+ base_lines, active_lines, circles, labels = [], [], [], []
189
+
190
+ for a, b in EDGES:
191
+ na, nb = node_map[a], node_map[b]
192
+ x1, y1 = na["x"] * 8 + 80, na["y"] * 6 + 280
193
+ x2, y2 = nb["x"] * 8 + 80, nb["y"] * 6 + 280
194
+ base_lines.append(f'<line class="edge" x1="{x1:.1f}" y1="{y1:.1f}" x2="{x2:.1f}" y2="{y2:.1f}" />')
195
+ if (a, b) in path_pairs:
196
+ active_lines.append(f'<line class="edge active" x1="{x1:.1f}" y1="{y1:.1f}" x2="{x2:.1f}" y2="{y2:.1f}" />')
197
+
198
+ for n in NODES:
199
+ cx, cy = n["x"] * 8 + 80, n["y"] * 6 + 280
200
+ is_active = n["id"] in active
201
+ state = "chosen" if is_active else "idle"
202
+ halo_cls = "halo active" if is_active else "halo"
203
+ lbl_cls = "label active" if is_active else "label"
204
+ radius = 18 if is_active else 13
205
+ halo_r = 30 if is_active else 0
206
+ circles.append(
207
+ f'<g class="node-wrap">'
208
+ f'<circle class="{halo_cls}" cx="{cx:.1f}" cy="{cy:.1f}" r="{halo_r}" />'
209
+ f'<circle class="node {n["group"]} {state}" cx="{cx:.1f}" cy="{cy:.1f}" r="{radius}" />'
210
+ f'</g>'
211
+ )
212
+ labels.append(f'<text class="{lbl_cls}" x="{cx + 18:.1f}" y="{cy - 16:.1f}">{safe_text(n["label"])}</text>')
213
+
214
+ return f"""
215
+ <div class="panel brain-shell">
216
+ <div class="brain-header">
217
+ <div>
218
+ <p class="eyebrow">Connectome</p>
219
+ <h3>3D Connectome</h3>
220
+ </div>
221
+ <div class="brain-legend">
222
+ <span><i class="dot dot-live"></i> lit path</span>
223
+ <span><i class="dot dot-chosen"></i> chosen node</span>
224
+ <span><i class="dot dot-idle"></i> available node</span>
225
+ </div>
226
+ </div>
227
+ <div class="brain-stage">
228
+ <svg viewBox="0 0 780 560" class="brain-svg" role="img" aria-label="DVNC 3D connectome visualisation">
229
+ {"".join(base_lines)}
230
+ {"".join(active_lines)}
231
+ {"".join(circles)}
232
+ {"".join(labels)}
233
+ </svg>
234
+ </div>
235
+ </div>
236
+ """
237
+
238
+
239
+ def build_cards_html(cards: List[Dict]) -> str:
240
+ items = []
241
+ for i, c in enumerate(cards):
242
+ items.append(f"""
243
+ <article class="candidate-card" tabindex="0">
244
+ <div class="candidate-card-inner">
245
+ <div class="candidate-face candidate-front">
246
+ <div class="candidate-top">
247
+ <span class="chip">{safe_text(c["agent"])}</span>
248
+ <span class="score">{safe_text(c["score"])}</span>
249
+ </div>
250
+ <h4>{safe_text(c["title"])}</h4>
251
+ <p>{safe_text(c["front"])}</p>
252
+ <div class="meta-row"><span>Novelty</span><strong>{safe_text(c["novelty"])}</strong></div>
253
+ <button class="mini" type="button" onclick="triggerRouteSwap('{i}')">Use as main insight</button>
254
+ </div>
255
+ <div class="candidate-face candidate-back">
256
+ <div class="candidate-top">
257
+ <span class="chip alt">Alternative path</span>
258
+ <span class="score">{safe_text(c["score"])}</span>
259
+ </div>
260
+ <h4>{safe_text(c["title"])}</h4>
261
+ <p>{safe_text(c["back"])}</p>
262
+ <div class="meta-row"><span>Swap into route</span><strong>Enabled</strong></div>
263
+ <button class="mini" type="button" onclick="triggerRouteSwap('{i}')">Use as main insight</button>
264
+ </div>
265
+ </div>
266
+ </article>""")
267
+ return '<div class="panel" style="padding:20px;"><div class="candidate-grid">' + "".join(items) + "</div></div>"
268
+
269
+
270
+ def build_agent_timeline(reasoning: List[Dict]) -> str:
271
+ rows = []
272
+ for r in reasoning:
273
+ rows.append(f"""
274
+ <details class="agent-step" {"open" if r["step"] == 1 else ""}>
275
+ <summary class="agent-summary">
276
+ <div class="agent-index">{safe_text(r["step"])}</div>
277
+ <div class="agent-head">
278
+ <h4>{safe_text(r["agent"])}</h4>
279
+ <span>{safe_text(r["tag"])}</span>
280
+ </div>
281
+ </summary>
282
+ <div class="agent-copy">
283
+ <p>{safe_text(r["summary"])}</p>
284
+ </div>
285
+ </details>""")
286
+ return '<div class="panel" style="padding:18px;"><div class="timeline">' + "".join(rows) + "</div></div>"
287
+
288
+
289
+ def build_chat_html(query: str, result: Dict) -> str:
290
+ return f"""
291
+ <div class="panel chat-panel">
292
+ <div class="chat-thread">
293
+ <div class="bubble bubble-user">
294
+ <span class="role">You</span>
295
+ <p>{safe_text(query)}</p>
296
+ </div>
297
+ <div class="bubble bubble-ai">
298
+ <span class="role">DVNC Sovereign</span>
299
+ <p>{safe_text(result["summary"])}</p>
300
+ </div>
301
+ <div class="bubble bubble-system">
302
+ <span class="role">Discovery Signal</span>
303
+ <p><strong>Primary hypothesis:</strong> {safe_text(result["primary_hypothesis"])}</p>
304
+ </div>
305
+ </div>
306
+ </div>
307
+ """
308
+
309
+
310
+ def build_models_html(selected: str) -> str:
311
+ items = []
312
+ for m in MODELS:
313
+ active = "active" if m["name"] == selected else ""
314
+ items.append(f"""
315
+ <div class="model-pill {active}">
316
+ <span class="model-name">{safe_text(m["name"])}</span>
317
+ <span class="model-tag">{safe_text(m["tag"])}</span>
318
+ <small>{safe_text(m["desc"])}</small>
319
+ </div>""")
320
+ return '<div class="panel" style="padding:18px;"><div class="model-switcher">' + "".join(items) + "</div></div>"
321
+
322
+
323
+ # ── Discovery logic ───────────────────────────────────────────────────────────
324
+ def run_discovery(query: str, model_name: str):
325
+ """
326
+ Runs the 7-agent discovery pipeline.
327
+ """
328
+ random.seed(len(query) + len(model_name))
329
+
330
+ if "curie" in query.lower() or "einstein" in query.lower():
331
+ primary = "Map the anomaly first, then force a distant analogy before composing the experimental programme."
332
+ path = ["seed", "bio", "card", "immune", "trial"]
333
+ else:
334
+ primary = "Utilization of a self-assembling conductive scaffold to transduce mechanical strain into localized regenerative signalling pathways."
335
+ path = DEFAULT_PATH
336
+
337
+ summaries = [
338
+ "Normalises the user prompt into a graph-searchable seed and isolates the tension inside the question.",
339
+ "Finds remote conceptual bridges instead of staying near the starting domain cluster.",
340
+ "Pulls evidence packets and conflict signals required for grounded hypothesis formation.",
341
+ "Generates cross-domain analogies with a bias toward mechanism transfer rather than keyword similarity.",
342
+ "Composes the lead hypothesis and two structurally different variants.",
343
+ "Attacks weak assumptions, hidden confounders, and feasibility gaps.",
344
+ "Produces a staged validation plan with measurable falsification criteria.",
345
+ ]
346
+
347
+ tags = ["input", "graph", "evidence", "analogy", "compose", "critique", "experiment"]
348
+
349
+ reasoning = [
350
+ {"step": i + 1, "agent": AGENTS[i], "tag": tags[i], "summary": summaries[i]}
351
+ for i in range(7)
352
+ ]
353
+
354
+ result = {
355
+ "summary": "A deeper route was chosen through the connectome, with live alternatives preserved as swappable cards so the reasoning path can be inspected rather than hidden.",
356
+ "primary_hypothesis": primary,
357
+ "reasoning": reasoning,
358
+ "cards": CANDIDATES,
359
+ "path": path,
360
+ "metrics": {
361
+ "Novelty": 93,
362
+ "Mechanistic clarity": 89,
363
+ "Experimental tractability": 82,
364
+ "Cross-domain distance": 91,
365
+ },
366
+ }
367
+
368
+ chat_html = build_chat_html(query, result)
369
+ connectome_html = build_connectome_html(path)
370
+ timeline_html = build_agent_route_cards_html(reasoning)
371
+
372
+ metrics_md = "\n".join(f"- {k}: {v}/100" for k, v in result["metrics"].items())
373
+ hypothesis_md = (
374
+ "# Discovery Output\n\n"
375
+ f"**Model:** {model_name}\n\n"
376
+ f"**Primary hypothesis:** {result['primary_hypothesis']}\n\n"
377
+ "## Scoring\n"
378
+ f"{metrics_md}\n\n"
379
+ "## Experimental outline\n"
380
+ "1. Construct the candidate material or protocol.\n"
381
+ "2. Test mechanistic signal expression under controlled conditions.\n"
382
+ "3. Compare against baseline and nearest-neighbour alternatives.\n"
383
+ "4. Falsify using the adversarial risk criteria surfaced in the reasoning path.\n"
384
+ )
385
+
386
+ cards_html = build_cards_html(CANDIDATES)
387
+ route_state = get_default_route_state()
388
+
389
+ return chat_html, connectome_html, timeline_html, cards_html, hypothesis_md, build_models_html(model_name), route_state
390
+
391
+
392
+ def apply_route_swap(query: str, model_name: str, route_swap_payload: str, route_state):
393
+ """
394
+ Called when a user clicks 'Use as main insight' on a candidate card.
395
+ Sanitizes the output, adopts academic rigor, updates the connectome and discovery output.
396
+ """
397
+ try:
398
+ idx = int(route_swap_payload)
399
+ except ValueError:
400
+ idx = 0
401
+
402
+ if not (0 <= idx < len(ACADEMIC_INSIGHTS)):
403
+ idx = 0
404
+
405
+ academic = ACADEMIC_INSIGHTS[idx]
406
+
407
+ # Update Connectome
408
+ connectome_html = build_connectome_html(academic["path"])
409
+
410
+ # Update Chat Feedback
411
+ result = {
412
+ "summary": "Main insight formally adopted. The connectome pathway and validation protocol have been realigned to the selected candidate methodology.",
413
+ "primary_hypothesis": academic["hypothesis"]
414
+ }
415
+ chat_html = build_chat_html(query, result)
416
+
417
+ # Format Oxford-tier markdown output
418
+ metrics_md = "\n".join(f"- {k}: {v}/100" for k, v in academic["metrics"].items())
419
+
420
+ hypothesis_md = (
421
+ "# Discovery Output\n\n"
422
+ f"**Model:** {model_name}\n\n"
423
+ f"**Primary hypothesis:** {academic['hypothesis']}\n\n"
424
+ "## Scoring\n"
425
+ f"{metrics_md}\n\n"
426
+ "## Experimental outline\n"
427
+ f"{academic['outline']}\n"
428
+ )
429
+
430
+ return chat_html, connectome_html, gr.update(), hypothesis_md, route_state
431
+
432
+
433
+ # ── Example loaders ─────────────────���─────────────────────────────────────────
434
+ def load_example() -> str:
435
+ return "How could a self-assembling conductive biomaterial improve cardiac tissue regeneration by converting mechanical strain into repair signalling?"
436
+
437
+ def load_paper_topic() -> str:
438
+ return "self-assembling conductive biomaterials for cardiac repair"
439
+
440
+ # ── CSS / HEAD ────────────────────────────────────────────────────────────────
441
+ BASE_CSS = r"""
442
+ :root {
443
+ --bg: #ffffff; --panel: #ffffff; --line: rgba(0,0,0,.12);
444
+ --text: #111111; --muted: #5b5b5b; --soft: rgba(0,0,0,.62);
445
+ --gold: #ff6600; --teal: #17b8a6; --blue: #628dff;
446
+ --chosen: #ff7a1a; --idle: #b8d8ff; --idle-stroke: #5e8fe6;
447
+ --query-node: #ffd8b3; --paper-node: #d7f6f2; --upload-node: #e7defe;
448
+ --shadow: 0 16px 40px rgba(0,0,0,.12);
449
+ }
450
+ html,body,.gradio-container { background:#ffffff !important; font-family:Inter,ui-sans-serif,system-ui,sans-serif; }
451
+ .gradio-container { max-width:1640px !important; padding:20px !important; }
452
+ #dvnc-shell { border:1px solid var(--line); border-radius:28px; overflow:hidden; background:#ffffff; box-shadow:var(--shadow); padding:20px 22px 22px; }
453
+ .hero-bar { display:flex; justify-content:space-between; align-items:center; gap:16px; padding-bottom:12px; border-bottom:1px solid rgba(0,0,0,.06); margin-bottom:16px; }
454
+ .brand { display:flex; align-items:center; gap:14px; }
455
+ .logo { width:42px; height:42px; border-radius:14px; display:grid; place-items:center; color:var(--gold); background:linear-gradient(135deg,rgba(255,122,26,.12),rgba(23,184,166,.10)); border:1px solid rgba(0,0,0,.08); }
456
+ .logo svg { width:24px; height:24px; }
457
+ .brand h1 { font-size:1.05rem; margin:0; font-weight:700; letter-spacing:.12em; text-transform:uppercase; }
458
+ .brand p { margin:3px 0 0; color:var(--muted); font-size:.84rem; }
459
+ .status { display:flex; gap:10px; align-items:center; color:var(--soft); font-size:.85rem; }
460
+ .status-dot { width:10px; height:10px; border-radius:50%; background:var(--teal); box-shadow:0 0 0 6px rgba(23,184,166,.10),0 0 14px rgba(23,184,166,.25); }
461
+ .panel { background:#ffffff; border:1px solid var(--line); border-radius:22px; box-shadow:inset 0 1px 0 rgba(255,255,255,.8); }
462
+ .querybox textarea,.querybox input { background:transparent !important; color:var(--text) !important; }
463
+ .querybox,.querybox>div { background:#ffffff !important; border-radius:18px !important; border-color:var(--line) !important; }
464
+ .chat-panel { padding:18px; min-height:280px; }
465
+ .chat-thread { display:flex; flex-direction:column; gap:14px; }
466
+ .bubble { max-width:88%; padding:16px 18px; border-radius:22px; border:1px solid var(--line); }
467
+ .bubble p { margin:8px 0 0; line-height:1.6; font-size:.96rem; color:var(--text); }
468
+ .bubble .role { font-size:.72rem; letter-spacing:.12em; text-transform:uppercase; color:var(--muted); }
469
+ .bubble-user { align-self:flex-end; background:linear-gradient(135deg,rgba(98,141,255,.16),rgba(98,141,255,.08)); }
470
+ .bubble-ai { align-self:flex-start; background:#ffffff; }
471
+ .bubble-system { align-self:flex-start; background:linear-gradient(135deg,rgba(255,122,26,.10),rgba(255,122,26,.04)); }
472
+ .model-switcher { display:grid; grid-template-columns:repeat(3,1fr); gap:12px; }
473
+ .model-pill { padding:14px; border:1px solid var(--line); border-radius:18px; display:flex; flex-direction:column; gap:4px; min-height:98px; background:#ffffff; }
474
+ .model-pill.active { border-color:rgba(255,122,26,.40); background:linear-gradient(135deg,rgba(255,122,26,.10),rgba(255,255,255,.96)); }
475
+ .model-name { font-weight:650; color:var(--text); }
476
+ .model-tag { font-size:.76rem; text-transform:uppercase; letter-spacing:.12em; color:var(--gold); }
477
+ .model-pill small { color:var(--muted); line-height:1.45; }
478
+ .brain-shell { padding:18px; }
479
+ .brain-header { display:flex; justify-content:space-between; align-items:flex-end; gap:16px; margin-bottom:10px; }
480
+ .eyebrow { font-size:.72rem; letter-spacing:.16em; text-transform:uppercase; color:var(--gold); margin:0 0 4px; }
481
+ .brain-header h3 { margin:0; font-size:1.12rem; color:var(--text); }
482
+ .brain-legend { display:flex; gap:14px; color:var(--muted); font-size:.8rem; flex-wrap:wrap; }
483
+ .dot { width:10px; height:10px; display:inline-block; border-radius:50%; margin-right:6px; }
484
+ .dot-live { background:var(--chosen); box-shadow:0 0 10px rgba(255,122,26,.35); }
485
+ .dot-chosen { background:var(--chosen); }
486
+ .dot-idle { background:var(--idle); border:1px solid var(--idle-stroke); }
487
+ .dot-query { background:var(--query-node); border:1px solid #de9e58; }
488
+ .dot-paper { background:var(--paper-node); border:1px solid #4fb3a5; }
489
+ .dot-upload { background:var(--upload-node); border:1px solid #8f73d9; }
490
+ .brain-stage { position:relative; min-height:420px; overflow:hidden; background:linear-gradient(180deg,rgba(250,250,250,1),rgba(255,255,255,1)); border:1px solid rgba(0,0,0,.05); border-radius:20px; }
491
+ .brain-svg { width:100%; height:520px; display:block; }
492
+ .edge { stroke:rgba(0,0,0,.12); stroke-width:2.4; }
493
+ .edge.active { stroke:var(--chosen); stroke-width:4.2; stroke-linecap:round; filter:drop-shadow(0 0 6px rgba(255,122,26,.45)); stroke-dasharray:8 12; animation:pulseEdge 1.5s linear infinite; }
494
+ .node { stroke-width:2.2; transition:all .25s ease; }
495
+ .node.idle { fill:var(--idle); stroke:var(--idle-stroke); }
496
+ .node.chosen { fill:var(--chosen); stroke:#ffb16d; }
497
+ .halo { fill:none; }
498
+ .halo.active { stroke:rgba(255,122,26,.18); stroke-width:12; }
499
+ .label { fill:#2c2c2c; font-size:13px; font-weight:500; letter-spacing:.01em; }
500
+ .label.active { fill:#111111; font-weight:700; }
501
+ .learn-edge { stroke:rgba(0,0,0,.18); stroke-width:2.2; stroke-linecap:round; }
502
+ .learn-node { stroke-width:2.2; }
503
+ .learn-node.query { fill:var(--query-node); stroke:#de9e58; }
504
+ .learn-node.paper { fill:var(--paper-node); stroke:#36a091; }
505
+ .learn-node.upload { fill:var(--upload-node); stroke:#7e63cb; }
506
+ .learn-label { fill:#1e1e1e; font-size:12px; font-weight:600; }
507
+ .learning-empty { display:grid; place-items:center; }
508
+ .empty-graph-copy { text-align:center; max-width:440px; padding:40px 20px; }
509
+ .empty-graph-copy h4 { margin:0 0 10px; font-size:1.05rem; }
510
+ .empty-graph-copy p { margin:0; color:var(--muted); line-height:1.6; }
511
+ .timeline { display:flex; flex-direction:column; gap:10px; }
512
+ .agent-step { border:1px solid var(--line); border-radius:18px; background:#ffffff; overflow:hidden; }
513
+ .agent-summary { list-style:none; display:grid; grid-template-columns:42px 1fr; gap:12px; align-items:center; padding:12px; cursor:pointer; }
514
+ .agent-summary::-webkit-details-marker { display:none; }
515
+ .agent-index { width:42px; height:42px; border-radius:14px; display:grid; place-items:center; font-weight:700; color:var(--gold); background:rgba(255,122,26,.08); border:1px solid rgba(255,122,26,.18); }
516
+ .agent-head { display:flex; justify-content:space-between; gap:12px; align-items:center; }
517
+ .agent-head h4 { margin:0; font-size:.98rem; color:var(--text); }
518
+ .agent-head span { font-size:.72rem; letter-spacing:.12em; text-transform:uppercase; color:var(--muted); }
519
+ .agent-copy { padding:0 14px 16px 66px; }
520
+ .agent-copy p { margin:0; color:#2d2d2d; font-size:.93rem; line-height:1.6; }
521
+ .candidate-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:18px; }
522
+ .candidate-card { background:none; perspective:1400px; min-height:330px; }
523
+ .candidate-card-inner { position:relative; width:100%; min-height:330px; transition:transform .8s cubic-bezier(.2,.7,.1,1); transform-style:preserve-3d; }
524
+ .candidate-card:hover .candidate-card-inner,.candidate-card:focus .candidate-card-inner,.candidate-card:focus-within .candidate-card-inner { transform:rotateY(180deg); }
525
+ .candidate-face { position:absolute; inset:0; padding:20px; border-radius:22px; border:1px solid var(--line); background:#ffffff; color:var(--text); backface-visibility:hidden; box-shadow:0 12px 24px rgba(0,0,0,.06); display:flex; flex-direction:column; gap:14px; }
526
+ .candidate-back { transform:rotateY(180deg); }
527
+ .candidate-top { display:flex; justify-content:space-between; align-items:center; gap:8px; }
528
+ .chip { font-size:.72rem; text-transform:uppercase; letter-spacing:.12em; color:#0b6f66; padding:7px 10px; border-radius:999px; background:rgba(23,184,166,.08); border:1px solid rgba(23,184,166,.18); }
529
+ .chip.alt { color:var(--gold); background:rgba(255,122,26,.08); border-color:rgba(255,122,26,.18); }
530
+ .score { font-weight:700; color:var(--gold); }
531
+ .candidate-face h4 { margin:0; font-size:1.08rem; line-height:1.35; }
532
+ .candidate-face p { margin:0; color:#1e1e1e; line-height:1.65; font-size:.96rem; overflow-wrap:anywhere; }
533
+ .meta-row { margin-top:auto; display:flex; justify-content:space-between; color:var(--muted); font-size:.88rem; gap:14px; }
534
+ .mini { cursor:pointer; margin-top:8px; align-self:flex-start; color:var(--text); padding:10px 12px; border-radius:14px; border:1px solid var(--line); background:#ffffff; transition:all 0.2s; }
535
+ .mini:hover { background: #f5f5f5; border-color: var(--chosen); }
536
+ .papers-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:14px; }
537
+ .paper-card { border:1px solid var(--line); border-radius:18px; padding:16px; background:#ffffff; }
538
+ .paper-topline { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:10px; }
539
+ .paper-badge { font-size:.72rem; padding:6px 10px; border-radius:999px; background:rgba(98,141,255,.08); color:#3456b5; border:1px solid rgba(98,141,255,.18); }
540
+ .paper-badge.alt { background:rgba(0,0,0,.04); color:#444; border-color:rgba(0,0,0,.08); }
541
+ .doi-badge { background:rgba(255,122,26,.08); color:#8a4105; border-color:rgba(255,122,26,.18); }
542
+ .paper-card h4 { margin:0 0 10px; line-height:1.35; font-size:1rem; }
543
+ .paper-card p { margin:0 0 12px; line-height:1.6; color:#222; }
544
+ .paper-links { display:flex; gap:12px; flex-wrap:wrap; }
545
+ .paper-meta-stack { display:flex; flex-direction:column; gap:6px; color:#444; margin-bottom:12px; font-size:.9rem; }
546
+ .paper-links a,.journal-card,.upload-note a { color:#0b63ce; text-decoration:none; }
547
+ .journal-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:14px; }
548
+ .journal-card { border:1px solid var(--line); border-radius:18px; padding:16px; display:flex; justify-content:space-between; gap:14px; align-items:center; background:#ffffff; }
549
+ .journal-card h4 { margin:0 0 6px; }
550
+ .journal-card p { margin:0; color:var(--muted); line-height:1.5; }
551
+ .upload-note { border:1px dashed rgba(0,0,0,.16); border-radius:18px; padding:16px; background:rgba(0,0,0,.015); color:#1f1f1f; line-height:1.6; }
552
+ .prosebox { padding:18px; white-space:pre-wrap; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; line-height:1.55; color:#1b1b1b; }
553
+ .gr-button-primary { background:linear-gradient(135deg,rgba(255,122,26,.92),rgba(240,108,22,.92)) !important; color:#ffffff !important; border:none !important; }
554
+ .gr-button-secondary { background:#ffffff !important; color:var(--text) !important; border:1px solid var(--line) !important; }
555
+ .ref-list { margin:0; padding-left:18px; }
556
+ .ref-list li { margin-bottom:8px; line-height:1.5; }
557
+ .parse-grid { display:grid; grid-template-columns:1.2fr 1fr; gap:14px; }
558
+ .parse-card { border:1px solid var(--line); border-radius:18px; padding:16px; background:#ffffff; }
559
+ .selection-panel { padding:18px; }
560
+ footer { display:none !important; }
561
+ @keyframes pulseEdge { to { stroke-dashoffset:-40; } }
562
+ @media (max-width:1180px) {
563
+ .model-switcher,.candidate-grid,.papers-grid,.journal-grid,.parse-grid { grid-template-columns:1fr; }
564
+ .brain-svg { height:460px; }
565
+ }
566
+ """
567
+
568
+ CSS = BASE_CSS + "\n" + get_dvnc_layout_css()
569
+ HEAD = """
570
+ <link rel="preconnect" href="https://fonts.googleapis.com">
571
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
572
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
573
+ <script>
574
+ function triggerRouteSwap(idx) {
575
+ const container = document.getElementById('route_swap_payload');
576
+ if(!container) return;
577
+ const input = container.querySelector('textarea') || container.querySelector('input');
578
+ if(input) {
579
+ input.value = idx.toString();
580
+ input.dispatchEvent(new Event('input', { bubbles: true }));
581
+ setTimeout(() => {
582
+ const btn = document.getElementById('route_swap_apply');
583
+ if(btn) btn.click();
584
+ }, 150);
585
+ }
586
+ }
587
+ </script>
588
+ """
589
+
590
+ # ── Gradio layout ─────────────────────────────────────────────────────────────
591
+ with gr.Blocks(css=CSS, head=HEAD, theme=gr.themes.Base(), fill_height=True) as demo:
592
+
593
+ # ── Shared state ──────────────────────────────────────────────────────────
594
+ papers_state = gr.State([])
595
+ parsed_pdf_state = gr.State({})
596
+ ingest_payload_state = gr.State({})
597
+ route_state = gr.State(get_default_route_state())
598
+
599
+ # ── Header ────────────────────────────────────────────────────────────────
600
+ gr.HTML("""
601
+ <div id="dvnc-shell">
602
+ <div class="hero-bar">
603
+ <div class="brand">
604
+ <div class="logo" aria-hidden="true">
605
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7">
606
+ <path d="M5 17L12 4l7 13"/>
607
+ <path d="M8.5 12.5h7"/>
608
+ <circle cx="12" cy="12" r="1.8" fill="currentColor" stroke="none"/>
609
+ </svg>
610
+ </div>
611
+ <div>
612
+ <h1>DVNC.AI</h1>
613
+ <p>Sovereign discovery instrument · connectome-native reasoning</p>
614
+ </div>
615
+ </div>
616
+ <div class="status"><span class="status-dot"></span><span>Live orchestration</span></div>
617
+ </div>
618
+ </div>
619
+ """)
620
+
621
+ with gr.Tabs():
622
+
623
+ # ── Tab 1 · Discovery Engine ──────────────────────────────────────────
624
+ with gr.Tab("Discovery Engine"):
625
+ model_html = gr.HTML(build_models_html("DVNC Sovereign"))
626
+
627
+ with gr.Row():
628
+ with gr.Column(scale=2):
629
+ model = gr.Dropdown(
630
+ choices=[m["name"] for m in MODELS],
631
+ value="DVNC Sovereign",
632
+ label="Model tier",
633
+ )
634
+ query = gr.Textbox(
635
+ label="Discovery query",
636
+ elem_classes=["querybox"],
637
+ placeholder="Enter a scientific question, anomaly, or breakthrough direction…",
638
+ lines=4,
639
+ )
640
+ with gr.Row():
641
+ run_btn = gr.Button("Run discovery", variant="primary")
642
+ example_btn = gr.Button("Load example", variant="secondary")
643
+ chat = gr.HTML("""
644
+ <div class="panel chat-panel">
645
+ <div class="chat-thread">
646
+ <div class="bubble bubble-ai">
647
+ <span class="role">DVNC</span>
648
+ <p>Enter a query to activate the 7-agent discovery stack and illuminate the chosen path through the 3D connectome.</p>
649
+ </div>
650
+ </div>
651
+ </div>
652
+ """)
653
+
654
+ with gr.Column(scale=3):
655
+ connectome = gr.HTML(build_connectome_html(DEFAULT_PATH))
656
+ cards = gr.HTML("")
657
+
658
+ output = gr.Markdown("# Discovery Output\n\nAwaiting query.")
659
+ timeline = gr.HTML(get_initial_discovery_timeline_html())
660
+
661
+ route_swap_payload = gr.Textbox(value="", visible=False, elem_id="route_swap_payload")
662
+ route_swap_apply = gr.Button("Apply route swap", visible=False, elem_id="route_swap_apply")
663
+
664
+ # ── Tab 2 · Self-Learning Graph ───────────────────────────────────────
665
+ with gr.Tab("Self-Learning Graph"):
666
+ with gr.Row():
667
+ with gr.Column(scale=2):
668
+ paper_query = gr.Textbox(
669
+ label="Research topic / title / DOI / link",
670
+ elem_classes=["querybox"],
671
+ placeholder="e.g. self-assembling conductive biomaterials for cardiac repair",
672
+ lines=3,
673
+ )
674
+ search_mode = gr.Dropdown(
675
+ choices=SEARCH_MODES,
676
+ value="topic",
677
+ label="Search mode",
678
+ )
679
+ source_selector = gr.CheckboxGroup(
680
+ choices=SOURCE_OPTIONS,
681
+ value=DEFAULT_SOURCES,
682
+ label="Sources",
683
+ )
684
+ pdf_upload = gr.File(label="Upload PDF papers", file_types=[".pdf"], file_count="single")
685
+
686
+ with gr.Row():
687
+ learn_btn = gr.Button("Discover papers", variant="primary")
688
+ load_topic_btn = gr.Button("Load example topic", variant="secondary")
689
+
690
+ upload_status = gr.Markdown("No PDF uploaded yet.")
691
+ discovery_status = gr.Markdown("### No discovery results yet.")
692
+ journal_panel = gr.HTML(build_journal_html("biomaterials cardiac repair"))
693
+
694
+ gr.HTML('<div class="panel selection-panel"><h3 style="margin:0 0 12px;">Select papers to ingest</h3></div>')
695
+ selection_box = gr.CheckboxGroup(choices=[], value=[], label="Candidate papers")
696
+
697
+ parser_order = gr.CheckboxGroup(
698
+ choices=["grobid", "docling", "pymupdf"],
699
+ value=["grobid", "docling", "pymupdf"],
700
+ label="Parser routing order",
701
+ )
702
+
703
+ with gr.Row():
704
+ parse_btn = gr.Button("Parse uploaded PDF", variant="secondary")
705
+ ingest_btn = gr.Button("Ingest selected into graph", variant="primary")
706
+
707
+ with gr.Column(scale=3):
708
+ learning_graph = gr.HTML(build_learning_graph_html([], []))
709
+ papers_panel = gr.HTML('<div class="panel papers-panel" style="padding:18px"><p>Search by topic, title, DOI, or link, then select papers before graph ingestion.</p></div>')
710
+ parse_summary = gr.Markdown("### PDF parse status\n\nAwaiting upload.")
711
+ parse_panel = gr.HTML('<div class="panel" style="padding:18px"><p>No parsed document yet.</p></div>')
712
+ ingest_summary = gr.Markdown("### Graph ingest status\n\nAwaiting paper selection.")
713
+ ingest_payload = gr.JSON(label="Graph ingest payload", value={"status": "empty", "nodes": [], "edges": []})
714
 
715
+ # ── Event wiring ──────────────────────────────────────────────────────────
716
+ example_btn.click(fn=load_example, outputs=query)
717
 
718
+ run_btn.click(
719
+ fn=run_discovery,
720
+ inputs=[query, model],
721
+ outputs=[chat, connectome, timeline, cards, output, model_html, route_state],
722
+ )
723
 
724
+ route_swap_apply.click(
725
+ fn=apply_route_swap,
726
+ inputs=[query, model, route_swap_payload, route_state],
727
+ outputs=[chat, connectome, timeline, output, route_state],
728
+ )
 
 
 
729
 
730
+ load_topic_btn.click(fn=load_paper_topic, outputs=paper_query)
731
 
732
+ learn_btn.click(
733
+ fn=run_paper_discovery,
734
+ inputs=[paper_query, search_mode, source_selector, pdf_upload],
735
+ outputs=[learning_graph, papers_panel, journal_panel, upload_status, selection_box, papers_state, discovery_status],
736
+ )
737
 
738
+ parse_btn.click(
739
+ fn=parse_uploaded_pdf,
740
+ inputs=[pdf_upload, parser_order],
741
+ outputs=[parse_summary, parsed_pdf_state],
742
+ ).then(
743
+ fn=render_parse_result,
744
+ inputs=[parsed_pdf_state],
745
+ outputs=[parse_panel],
746
+ )
747
 
748
+ ingest_btn.click(
749
+ fn=ingest_selected_papers,
750
+ inputs=[paper_query, selection_box, papers_state, pdf_upload, parsed_pdf_state],
751
+ outputs=[learning_graph, ingest_summary, ingest_payload],
752
+ )
753
 
754
  if __name__ == "__main__":
755
+ demo.launch()
 
app_old2.py DELETED
@@ -1,770 +0,0 @@
1
- """
2
- DVNC.AI — app.py
3
- Refactored for functional "Use as main insight" logic with academic rigor.
4
- """
5
- from dvnc_ai_v2_hf.graph_canvas_patch import render_graph_canvas_html
6
- def build_learning_graph_html(nodes, edges, title="Self-Learning Knowledge Graph"):
7
- return render_graph_canvas_html(
8
- {
9
- "status": "ok" if (nodes or edges) else "empty",
10
- "nodes": nodes or [],
11
- "edges": edges or [],
12
- },
13
- title=title,
14
- height=780,
15
- )
16
-
17
- # ── Standard library ────────────────────────────────────────────────────────
18
- import html
19
- import json
20
- import math
21
- import os
22
- import random
23
- import re
24
- import urllib.parse
25
- import xml.etree.ElementTree as ET
26
- from pathlib import Path
27
- from typing import Dict, List, Optional
28
- from urllib.parse import quote
29
- from dvnc_ai_v2_hf.graph_canvas_patch import render_graph_canvas_html
30
-
31
- # ── Third-party ──────────────────────────────────────────────────────────────
32
- import gradio as gr
33
- import requests
34
-
35
- try:
36
- import fitz # PyMuPDF
37
- except Exception:
38
- fitz = None
39
-
40
- try:
41
- from bs4 import BeautifulSoup
42
- except Exception:
43
- BeautifulSoup = None
44
-
45
- learning_graph = gr.HTML(build_learning_graph_html([], []))
46
-
47
- # ── Internal modules ─────────────────────────────────────────────────────────
48
- from dvnc_ai_v2_hf.agent_route_cards import build_agent_route_cards_html
49
- from dvnc_ai_v2_hf.discovery_app_bridge import (
50
- get_default_route_state,
51
- get_discovery_css,
52
- get_initial_discovery_timeline_html,
53
- )
54
- from dvnc_ai_v2_hf.dvnc_ui_layout import get_dvnc_layout_css
55
- from dvnc_ai_v2_hf.self_learning_graph import (
56
- DEFAULT_SOURCES,
57
- SEARCH_MODES,
58
- SOURCE_OPTIONS,
59
- build_journal_html,
60
- ingest_selected_papers,
61
- parse_uploaded_pdf,
62
- render_parse_result,
63
- run_paper_discovery,
64
- safe_text,
65
- )
66
-
67
- from dvnc_ai_v2_hf.graph_canvas_patch import render_graph_canvas_html
68
-
69
- # ── Constants ────────────────────────────────────────────────────────────────
70
- MODELS = [
71
- {"name": "DVNC Sovereign", "tag": "flagship", "desc": "Maximum depth orchestration for frontier discovery"},
72
- {"name": "DVNC Atlas", "tag": "research", "desc": "Balanced reasoning, graph traversal, and synthesis"},
73
- {"name": "DVNC Curie", "tag": "lab", "desc": "Experimental hypothesis generation for anomalous signals"},
74
- ]
75
-
76
- AGENTS = [
77
- "Query Interpreter",
78
- "Graph Divergence Mapper",
79
- "Evidence Harvester",
80
- "Analogy Engine",
81
- "Hypothesis Composer",
82
- "Adversarial Critic",
83
- "Experimental Program Designer",
84
- ]
85
-
86
- NODES = [
87
- {"id": "seed", "label": "Seed Query", "group": "core", "x": 10, "y": 0, "z": 0},
88
- {"id": "bio", "label": "Biomaterials", "group": "domain", "x": 24, "y": 12, "z": -8},
89
- {"id": "card", "label": "Cardiac Repair", "group": "domain", "x": 38, "y": 3, "z": 14},
90
- {"id": "nano", "label": "Nanostructure", "group": "bridge", "x": 24, "y": -18, "z": 16},
91
- {"id": "selfasm", "label": "Self-Assembly", "group": "bridge", "x": 40, "y": -16, "z": -16},
92
- {"id": "electro", "label": "Electro-signalling", "group": "mechanism", "x": 58, "y": 10, "z": -10},
93
- {"id": "immune", "label": "Immune Modulation", "group": "mechanism", "x": 64, "y": -8, "z": 10},
94
- {"id": "trial", "label": "Validation Path", "group": "outcome", "x": 80, "y": 0, "z": 0},
95
- {"id": "alt1", "label": "Piezoelectric Scaffold","group": "candidate", "x": 56, "y": 26, "z": 14},
96
- {"id": "alt2", "label": "Peptide Mesh", "group": "candidate", "x": 54, "y": -27, "z": -14},
97
- ]
98
-
99
- EDGES = [
100
- ("seed", "bio"), ("seed", "nano"),
101
- ("bio", "card"), ("nano", "selfasm"),
102
- ("selfasm", "electro"),("card", "immune"),
103
- ("electro", "trial"), ("immune", "trial"),
104
- ("card", "alt1"), ("selfasm","alt2"),
105
- ("alt1", "trial"), ("alt2", "trial"),
106
- ]
107
-
108
- DEFAULT_PATH = ["seed", "nano", "selfasm", "electro", "trial"]
109
-
110
- CANDIDATES = [
111
- {
112
- "title": "Piezoelectric Scaffold Cascade",
113
- "front": "Use mechano-electric scaffolds to convert cardiac strain into micro-current signalling.",
114
- "back": "Discovery path: anomalous healing signal -> piezoelectric analog -> ion-channel entrainment -> tissue regeneration. Risk: power density and fibrosis coupling.",
115
- "score": 92,
116
- "novelty": "High",
117
- "agent": "Hypothesis Composer",
118
- },
119
- {
120
- "title": "Peptide Self-Assembly Mesh",
121
- "front": "Deploy dynamic peptide meshes that self-assemble around damaged myocardium and guide repair.",
122
- "back": "Discovery path: self-assembly -> local immune choreography -> regenerative substrate formation. Risk: degradation timing and targeting specificity.",
123
- "score": 88,
124
- "novelty": "High",
125
- "agent": "Analogy Engine",
126
- },
127
- {
128
- "title": "Immune-Tuned Conductive Hydrogel",
129
- "front": "Blend conductivity with macrophage-state modulation to reduce scarring and restore conduction.",
130
- "back": "Discovery path: inflammation mismatch -> conductive medium -> macrophage polarization -> synchronized healing. Risk: persistence and biocompatibility.",
131
- "score": 85,
132
- "novelty": "Medium-High",
133
- "agent": "Adversarial Critic",
134
- },
135
- ]
136
-
137
- ACADEMIC_INSIGHTS = [
138
- {
139
- "hypothesis": "Implementation of mechano-electric scaffolds to transduce cardiac strain into localized micro-current signalling for myocardial regeneration.",
140
- "metrics": {"Novelty": 92, "Mechanistic clarity": 85, "Experimental tractability": 78, "Cross-domain distance": 94},
141
- "outline": "1. Synthesize candidate piezoelectric biomaterial scaffolds with tunable strain-electric coupling.\n2. Evaluate *in vitro* electromechanical transduction and subsequent ion-channel entrainment.\n3. Conduct *in vivo* comparative models to assess regenerative efficacy against gold-standard substrates.\n4. Rigorously validate to exclude pathological fibrosis and power-density toxicity.",
142
- "path": ["seed", "bio", "card", "alt1", "trial"]
143
- },
144
- {
145
- "hypothesis": "Deployment of dynamic peptide networks that self-assemble post-infarction to orchestrate local immunological responses and guide substrate regeneration.",
146
- "metrics": {"Novelty": 88, "Mechanistic clarity": 82, "Experimental tractability": 86, "Cross-domain distance": 85},
147
- "outline": "1. Formulate peptide sequences programmed for triggered *in situ* self-assembly within the myocardial infarct zone.\n2. Quantify macrophage polarization and local immune choreography post-deployment.\n3. Map the temporospatial degradation profile against *de novo* tissue formation.\n4. Falsify against off-target aggregation and delayed clearance risks.",
148
- "path": ["seed", "nano", "selfasm", "alt2", "trial"]
149
- },
150
- {
151
- "hypothesis": "Integration of conductive hydrogels with immunomodulatory properties to simultaneously bridge electrical uncoupling and mitigate adverse fibrotic scarring.",
152
- "metrics": {"Novelty": 85, "Mechanistic clarity": 90, "Experimental tractability": 88, "Cross-domain distance": 79},
153
- "outline": "1. Fabricate biocompatible hydrogels featuring precisely tuned electrical conductivity and immunomodulatory motifs.\n2. Monitor electrophysiological synchronization across the scaffold-tissue interface.\n3. Assess macrophage state transitions and suppression of adverse fibrotic remodelling.\n4. Validate long-term persistence, hemocompatibility, and mechanical integration.",
154
- "path": ["seed", "bio", "card", "immune", "trial"]
155
- }
156
- ]
157
-
158
- JOURNALS = [
159
- {"name": "Nature", "url": "https://www.nature.com/search", "desc": "Flagship multidisciplinary research journal."},
160
- {"name": "Science", "url": "https://www.science.org/search", "desc": "High-impact science journal and family."},
161
- {"name": "Cell", "url": "https://www.cell.com/search", "desc": "Life sciences and translational biology."},
162
- {"name": "The Lancet", "url": "https://www.thelancet.com/search", "desc": "Clinical and medical research."},
163
- {"name": "IEEE Xplore","url": "https://ieeexplore.ieee.org/search/searchresult.jsp", "desc": "Engineering, AI, signal processing, and systems."},
164
- ]
165
-
166
- SEMANTIC_SCHOLAR_API_KEY = os.getenv("SEMANTIC_SCHOLAR_API_KEY", "")
167
- GROBID_URL = os.getenv("GROBID_URL", "").strip()
168
- REQUEST_TIMEOUT = 25
169
-
170
-
171
- # ── Utility helpers ──────────────────────────────────────────────────────────
172
- def safe_text(x, default: str = "") -> str:
173
- return html.escape(str(x if x is not None else default))
174
-
175
-
176
- def norm_text(x: Optional[str]) -> str:
177
- return re.sub(r"\s+", " ", (x or "")).strip()
178
-
179
-
180
- def detect_query_type(query: str) -> str:
181
- q = (query or "").strip()
182
- if re.match(r"^10\.\d{4,9}/[-._;()/:A-Z0-9]+$", q, flags=re.I):
183
- return "doi"
184
- if q.startswith("http://") or q.startswith("https://"):
185
- return "link"
186
- return "topic"
187
-
188
-
189
- def ensure_list(x):
190
- return x if isinstance(x, list) else []
191
-
192
-
193
- # ── HTML builders ─────────────────────────────────────────────────────────────
194
- def build_connectome_html(path_ids: List[str]) -> str:
195
- active = set(path_ids)
196
- node_map = {n["id"]: n for n in NODES}
197
- path_pairs = {
198
- pair
199
- for i in range(len(path_ids) - 1)
200
- for pair in [(path_ids[i], path_ids[i + 1]), (path_ids[i + 1], path_ids[i])]
201
- }
202
-
203
- base_lines, active_lines, circles, labels = [], [], [], []
204
-
205
- for a, b in EDGES:
206
- na, nb = node_map[a], node_map[b]
207
- x1, y1 = na["x"] * 8 + 80, na["y"] * 6 + 280
208
- x2, y2 = nb["x"] * 8 + 80, nb["y"] * 6 + 280
209
- base_lines.append(f'<line class="edge" x1="{x1:.1f}" y1="{y1:.1f}" x2="{x2:.1f}" y2="{y2:.1f}" />')
210
- if (a, b) in path_pairs:
211
- active_lines.append(f'<line class="edge active" x1="{x1:.1f}" y1="{y1:.1f}" x2="{x2:.1f}" y2="{y2:.1f}" />')
212
-
213
- for n in NODES:
214
- cx, cy = n["x"] * 8 + 80, n["y"] * 6 + 280
215
- is_active = n["id"] in active
216
- state = "chosen" if is_active else "idle"
217
- halo_cls = "halo active" if is_active else "halo"
218
- lbl_cls = "label active" if is_active else "label"
219
- radius = 18 if is_active else 13
220
- halo_r = 30 if is_active else 0
221
- circles.append(
222
- f'<g class="node-wrap">'
223
- f'<circle class="{halo_cls}" cx="{cx:.1f}" cy="{cy:.1f}" r="{halo_r}" />'
224
- f'<circle class="node {n["group"]} {state}" cx="{cx:.1f}" cy="{cy:.1f}" r="{radius}" />'
225
- f'</g>'
226
- )
227
- labels.append(f'<text class="{lbl_cls}" x="{cx + 18:.1f}" y="{cy - 16:.1f}">{safe_text(n["label"])}</text>')
228
-
229
- return f"""
230
- <div class="panel brain-shell">
231
- <div class="brain-header">
232
- <div>
233
- <p class="eyebrow">Connectome</p>
234
- <h3>3D Connectome</h3>
235
- </div>
236
- <div class="brain-legend">
237
- <span><i class="dot dot-live"></i> lit path</span>
238
- <span><i class="dot dot-chosen"></i> chosen node</span>
239
- <span><i class="dot dot-idle"></i> available node</span>
240
- </div>
241
- </div>
242
- <div class="brain-stage">
243
- <svg viewBox="0 0 780 560" class="brain-svg" role="img" aria-label="DVNC 3D connectome visualisation">
244
- {"".join(base_lines)}
245
- {"".join(active_lines)}
246
- {"".join(circles)}
247
- {"".join(labels)}
248
- </svg>
249
- </div>
250
- </div>
251
- """
252
-
253
-
254
- def build_cards_html(cards: List[Dict]) -> str:
255
- items = []
256
- for i, c in enumerate(cards):
257
- items.append(f"""
258
- <article class="candidate-card" tabindex="0">
259
- <div class="candidate-card-inner">
260
- <div class="candidate-face candidate-front">
261
- <div class="candidate-top">
262
- <span class="chip">{safe_text(c["agent"])}</span>
263
- <span class="score">{safe_text(c["score"])}</span>
264
- </div>
265
- <h4>{safe_text(c["title"])}</h4>
266
- <p>{safe_text(c["front"])}</p>
267
- <div class="meta-row"><span>Novelty</span><strong>{safe_text(c["novelty"])}</strong></div>
268
- <button class="mini" type="button" onclick="triggerRouteSwap('{i}')">Use as main insight</button>
269
- </div>
270
- <div class="candidate-face candidate-back">
271
- <div class="candidate-top">
272
- <span class="chip alt">Alternative path</span>
273
- <span class="score">{safe_text(c["score"])}</span>
274
- </div>
275
- <h4>{safe_text(c["title"])}</h4>
276
- <p>{safe_text(c["back"])}</p>
277
- <div class="meta-row"><span>Swap into route</span><strong>Enabled</strong></div>
278
- <button class="mini" type="button" onclick="triggerRouteSwap('{i}')">Use as main insight</button>
279
- </div>
280
- </div>
281
- </article>""")
282
- return '<div class="panel" style="padding:20px;"><div class="candidate-grid">' + "".join(items) + "</div></div>"
283
-
284
-
285
- def build_agent_timeline(reasoning: List[Dict]) -> str:
286
- rows = []
287
- for r in reasoning:
288
- rows.append(f"""
289
- <details class="agent-step" {"open" if r["step"] == 1 else ""}>
290
- <summary class="agent-summary">
291
- <div class="agent-index">{safe_text(r["step"])}</div>
292
- <div class="agent-head">
293
- <h4>{safe_text(r["agent"])}</h4>
294
- <span>{safe_text(r["tag"])}</span>
295
- </div>
296
- </summary>
297
- <div class="agent-copy">
298
- <p>{safe_text(r["summary"])}</p>
299
- </div>
300
- </details>""")
301
- return '<div class="panel" style="padding:18px;"><div class="timeline">' + "".join(rows) + "</div></div>"
302
-
303
-
304
- def build_chat_html(query: str, result: Dict) -> str:
305
- return f"""
306
- <div class="panel chat-panel">
307
- <div class="chat-thread">
308
- <div class="bubble bubble-user">
309
- <span class="role">You</span>
310
- <p>{safe_text(query)}</p>
311
- </div>
312
- <div class="bubble bubble-ai">
313
- <span class="role">DVNC Sovereign</span>
314
- <p>{safe_text(result["summary"])}</p>
315
- </div>
316
- <div class="bubble bubble-system">
317
- <span class="role">Discovery Signal</span>
318
- <p><strong>Primary hypothesis:</strong> {safe_text(result["primary_hypothesis"])}</p>
319
- </div>
320
- </div>
321
- </div>
322
- """
323
-
324
-
325
- def build_models_html(selected: str) -> str:
326
- items = []
327
- for m in MODELS:
328
- active = "active" if m["name"] == selected else ""
329
- items.append(f"""
330
- <div class="model-pill {active}">
331
- <span class="model-name">{safe_text(m["name"])}</span>
332
- <span class="model-tag">{safe_text(m["tag"])}</span>
333
- <small>{safe_text(m["desc"])}</small>
334
- </div>""")
335
- return '<div class="panel" style="padding:18px;"><div class="model-switcher">' + "".join(items) + "</div></div>"
336
-
337
-
338
- # ── Discovery logic ───────────────────────────────────────────────────────────
339
- def run_discovery(query: str, model_name: str):
340
- """
341
- Runs the 7-agent discovery pipeline.
342
- """
343
- random.seed(len(query) + len(model_name))
344
-
345
- if "curie" in query.lower() or "einstein" in query.lower():
346
- primary = "Map the anomaly first, then force a distant analogy before composing the experimental programme."
347
- path = ["seed", "bio", "card", "immune", "trial"]
348
- else:
349
- primary = "Utilization of a self-assembling conductive scaffold to transduce mechanical strain into localized regenerative signalling pathways."
350
- path = DEFAULT_PATH
351
-
352
- summaries = [
353
- "Normalises the user prompt into a graph-searchable seed and isolates the tension inside the question.",
354
- "Finds remote conceptual bridges instead of staying near the starting domain cluster.",
355
- "Pulls evidence packets and conflict signals required for grounded hypothesis formation.",
356
- "Generates cross-domain analogies with a bias toward mechanism transfer rather than keyword similarity.",
357
- "Composes the lead hypothesis and two structurally different variants.",
358
- "Attacks weak assumptions, hidden confounders, and feasibility gaps.",
359
- "Produces a staged validation plan with measurable falsification criteria.",
360
- ]
361
-
362
- tags = ["input", "graph", "evidence", "analogy", "compose", "critique", "experiment"]
363
-
364
- reasoning = [
365
- {"step": i + 1, "agent": AGENTS[i], "tag": tags[i], "summary": summaries[i]}
366
- for i in range(7)
367
- ]
368
-
369
- result = {
370
- "summary": "A deeper route was chosen through the connectome, with live alternatives preserved as swappable cards so the reasoning path can be inspected rather than hidden.",
371
- "primary_hypothesis": primary,
372
- "reasoning": reasoning,
373
- "cards": CANDIDATES,
374
- "path": path,
375
- "metrics": {
376
- "Novelty": 93,
377
- "Mechanistic clarity": 89,
378
- "Experimental tractability": 82,
379
- "Cross-domain distance": 91,
380
- },
381
- }
382
-
383
- chat_html = build_chat_html(query, result)
384
- connectome_html = build_connectome_html(path)
385
- timeline_html = build_agent_route_cards_html(reasoning)
386
-
387
- metrics_md = "\n".join(f"- {k}: {v}/100" for k, v in result["metrics"].items())
388
- hypothesis_md = (
389
- "# Discovery Output\n\n"
390
- f"**Model:** {model_name}\n\n"
391
- f"**Primary hypothesis:** {result['primary_hypothesis']}\n\n"
392
- "## Scoring\n"
393
- f"{metrics_md}\n\n"
394
- "## Experimental outline\n"
395
- "1. Construct the candidate material or protocol.\n"
396
- "2. Test mechanistic signal expression under controlled conditions.\n"
397
- "3. Compare against baseline and nearest-neighbour alternatives.\n"
398
- "4. Falsify using the adversarial risk criteria surfaced in the reasoning path.\n"
399
- )
400
-
401
- cards_html = build_cards_html(CANDIDATES)
402
- route_state = get_default_route_state()
403
-
404
- return chat_html, connectome_html, timeline_html, cards_html, hypothesis_md, build_models_html(model_name), route_state
405
-
406
-
407
- def apply_route_swap(query: str, model_name: str, route_swap_payload: str, route_state):
408
- """
409
- Called when a user clicks 'Use as main insight' on a candidate card.
410
- Sanitizes the output, adopts academic rigor, updates the connectome and discovery output.
411
- """
412
- try:
413
- idx = int(route_swap_payload)
414
- except ValueError:
415
- idx = 0
416
-
417
- if not (0 <= idx < len(ACADEMIC_INSIGHTS)):
418
- idx = 0
419
-
420
- academic = ACADEMIC_INSIGHTS[idx]
421
-
422
- # Update Connectome
423
- connectome_html = build_connectome_html(academic["path"])
424
-
425
- # Update Chat Feedback
426
- result = {
427
- "summary": "Main insight formally adopted. The connectome pathway and validation protocol have been realigned to the selected candidate methodology.",
428
- "primary_hypothesis": academic["hypothesis"]
429
- }
430
- chat_html = build_chat_html(query, result)
431
-
432
- # Format Oxford-tier markdown output
433
- metrics_md = "\n".join(f"- {k}: {v}/100" for k, v in academic["metrics"].items())
434
-
435
- hypothesis_md = (
436
- "# Discovery Output\n\n"
437
- f"**Model:** {model_name}\n\n"
438
- f"**Primary hypothesis:** {academic['hypothesis']}\n\n"
439
- "## Scoring\n"
440
- f"{metrics_md}\n\n"
441
- "## Experimental outline\n"
442
- f"{academic['outline']}\n"
443
- )
444
-
445
- return chat_html, connectome_html, gr.update(), hypothesis_md, route_state
446
-
447
-
448
- # ── Example loaders ───────────────────────────────────────────────────────────
449
- def load_example() -> str:
450
- return "How could a self-assembling conductive biomaterial improve cardiac tissue regeneration by converting mechanical strain into repair signalling?"
451
-
452
- def load_paper_topic() -> str:
453
- return "self-assembling conductive biomaterials for cardiac repair"
454
-
455
- # ── CSS / HEAD ────────────────────────────────────────────────────────────────
456
- BASE_CSS = r"""
457
- :root {
458
- --bg: #ffffff; --panel: #ffffff; --line: rgba(0,0,0,.12);
459
- --text: #111111; --muted: #5b5b5b; --soft: rgba(0,0,0,.62);
460
- --gold: #ff6600; --teal: #17b8a6; --blue: #628dff;
461
- --chosen: #ff7a1a; --idle: #b8d8ff; --idle-stroke: #5e8fe6;
462
- --query-node: #ffd8b3; --paper-node: #d7f6f2; --upload-node: #e7defe;
463
- --shadow: 0 16px 40px rgba(0,0,0,.12);
464
- }
465
- html,body,.gradio-container { background:#ffffff !important; font-family:Inter,ui-sans-serif,system-ui,sans-serif; }
466
- .gradio-container { max-width:1640px !important; padding:20px !important; }
467
- #dvnc-shell { border:1px solid var(--line); border-radius:28px; overflow:hidden; background:#ffffff; box-shadow:var(--shadow); padding:20px 22px 22px; }
468
- .hero-bar { display:flex; justify-content:space-between; align-items:center; gap:16px; padding-bottom:12px; border-bottom:1px solid rgba(0,0,0,.06); margin-bottom:16px; }
469
- .brand { display:flex; align-items:center; gap:14px; }
470
- .logo { width:42px; height:42px; border-radius:14px; display:grid; place-items:center; color:var(--gold); background:linear-gradient(135deg,rgba(255,122,26,.12),rgba(23,184,166,.10)); border:1px solid rgba(0,0,0,.08); }
471
- .logo svg { width:24px; height:24px; }
472
- .brand h1 { font-size:1.05rem; margin:0; font-weight:700; letter-spacing:.12em; text-transform:uppercase; }
473
- .brand p { margin:3px 0 0; color:var(--muted); font-size:.84rem; }
474
- .status { display:flex; gap:10px; align-items:center; color:var(--soft); font-size:.85rem; }
475
- .status-dot { width:10px; height:10px; border-radius:50%; background:var(--teal); box-shadow:0 0 0 6px rgba(23,184,166,.10),0 0 14px rgba(23,184,166,.25); }
476
- .panel { background:#ffffff; border:1px solid var(--line); border-radius:22px; box-shadow:inset 0 1px 0 rgba(255,255,255,.8); }
477
- .querybox textarea,.querybox input { background:transparent !important; color:var(--text) !important; }
478
- .querybox,.querybox>div { background:#ffffff !important; border-radius:18px !important; border-color:var(--line) !important; }
479
- .chat-panel { padding:18px; min-height:280px; }
480
- .chat-thread { display:flex; flex-direction:column; gap:14px; }
481
- .bubble { max-width:88%; padding:16px 18px; border-radius:22px; border:1px solid var(--line); }
482
- .bubble p { margin:8px 0 0; line-height:1.6; font-size:.96rem; color:var(--text); }
483
- .bubble .role { font-size:.72rem; letter-spacing:.12em; text-transform:uppercase; color:var(--muted); }
484
- .bubble-user { align-self:flex-end; background:linear-gradient(135deg,rgba(98,141,255,.16),rgba(98,141,255,.08)); }
485
- .bubble-ai { align-self:flex-start; background:#ffffff; }
486
- .bubble-system { align-self:flex-start; background:linear-gradient(135deg,rgba(255,122,26,.10),rgba(255,122,26,.04)); }
487
- .model-switcher { display:grid; grid-template-columns:repeat(3,1fr); gap:12px; }
488
- .model-pill { padding:14px; border:1px solid var(--line); border-radius:18px; display:flex; flex-direction:column; gap:4px; min-height:98px; background:#ffffff; }
489
- .model-pill.active { border-color:rgba(255,122,26,.40); background:linear-gradient(135deg,rgba(255,122,26,.10),rgba(255,255,255,.96)); }
490
- .model-name { font-weight:650; color:var(--text); }
491
- .model-tag { font-size:.76rem; text-transform:uppercase; letter-spacing:.12em; color:var(--gold); }
492
- .model-pill small { color:var(--muted); line-height:1.45; }
493
- .brain-shell { padding:18px; }
494
- .brain-header { display:flex; justify-content:space-between; align-items:flex-end; gap:16px; margin-bottom:10px; }
495
- .eyebrow { font-size:.72rem; letter-spacing:.16em; text-transform:uppercase; color:var(--gold); margin:0 0 4px; }
496
- .brain-header h3 { margin:0; font-size:1.12rem; color:var(--text); }
497
- .brain-legend { display:flex; gap:14px; color:var(--muted); font-size:.8rem; flex-wrap:wrap; }
498
- .dot { width:10px; height:10px; display:inline-block; border-radius:50%; margin-right:6px; }
499
- .dot-live { background:var(--chosen); box-shadow:0 0 10px rgba(255,122,26,.35); }
500
- .dot-chosen { background:var(--chosen); }
501
- .dot-idle { background:var(--idle); border:1px solid var(--idle-stroke); }
502
- .dot-query { background:var(--query-node); border:1px solid #de9e58; }
503
- .dot-paper { background:var(--paper-node); border:1px solid #4fb3a5; }
504
- .dot-upload { background:var(--upload-node); border:1px solid #8f73d9; }
505
- .brain-stage { position:relative; min-height:420px; overflow:hidden; background:linear-gradient(180deg,rgba(250,250,250,1),rgba(255,255,255,1)); border:1px solid rgba(0,0,0,.05); border-radius:20px; }
506
- .brain-svg { width:100%; height:520px; display:block; }
507
- .edge { stroke:rgba(0,0,0,.12); stroke-width:2.4; }
508
- .edge.active { stroke:var(--chosen); stroke-width:4.2; stroke-linecap:round; filter:drop-shadow(0 0 6px rgba(255,122,26,.45)); stroke-dasharray:8 12; animation:pulseEdge 1.5s linear infinite; }
509
- .node { stroke-width:2.2; transition:all .25s ease; }
510
- .node.idle { fill:var(--idle); stroke:var(--idle-stroke); }
511
- .node.chosen { fill:var(--chosen); stroke:#ffb16d; }
512
- .halo { fill:none; }
513
- .halo.active { stroke:rgba(255,122,26,.18); stroke-width:12; }
514
- .label { fill:#2c2c2c; font-size:13px; font-weight:500; letter-spacing:.01em; }
515
- .label.active { fill:#111111; font-weight:700; }
516
- .learn-edge { stroke:rgba(0,0,0,.18); stroke-width:2.2; stroke-linecap:round; }
517
- .learn-node { stroke-width:2.2; }
518
- .learn-node.query { fill:var(--query-node); stroke:#de9e58; }
519
- .learn-node.paper { fill:var(--paper-node); stroke:#36a091; }
520
- .learn-node.upload { fill:var(--upload-node); stroke:#7e63cb; }
521
- .learn-label { fill:#1e1e1e; font-size:12px; font-weight:600; }
522
- .learning-empty { display:grid; place-items:center; }
523
- .empty-graph-copy { text-align:center; max-width:440px; padding:40px 20px; }
524
- .empty-graph-copy h4 { margin:0 0 10px; font-size:1.05rem; }
525
- .empty-graph-copy p { margin:0; color:var(--muted); line-height:1.6; }
526
- .timeline { display:flex; flex-direction:column; gap:10px; }
527
- .agent-step { border:1px solid var(--line); border-radius:18px; background:#ffffff; overflow:hidden; }
528
- .agent-summary { list-style:none; display:grid; grid-template-columns:42px 1fr; gap:12px; align-items:center; padding:12px; cursor:pointer; }
529
- .agent-summary::-webkit-details-marker { display:none; }
530
- .agent-index { width:42px; height:42px; border-radius:14px; display:grid; place-items:center; font-weight:700; color:var(--gold); background:rgba(255,122,26,.08); border:1px solid rgba(255,122,26,.18); }
531
- .agent-head { display:flex; justify-content:space-between; gap:12px; align-items:center; }
532
- .agent-head h4 { margin:0; font-size:.98rem; color:var(--text); }
533
- .agent-head span { font-size:.72rem; letter-spacing:.12em; text-transform:uppercase; color:var(--muted); }
534
- .agent-copy { padding:0 14px 16px 66px; }
535
- .agent-copy p { margin:0; color:#2d2d2d; font-size:.93rem; line-height:1.6; }
536
- .candidate-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:18px; }
537
- .candidate-card { background:none; perspective:1400px; min-height:330px; }
538
- .candidate-card-inner { position:relative; width:100%; min-height:330px; transition:transform .8s cubic-bezier(.2,.7,.1,1); transform-style:preserve-3d; }
539
- .candidate-card:hover .candidate-card-inner,.candidate-card:focus .candidate-card-inner,.candidate-card:focus-within .candidate-card-inner { transform:rotateY(180deg); }
540
- .candidate-face { position:absolute; inset:0; padding:20px; border-radius:22px; border:1px solid var(--line); background:#ffffff; color:var(--text); backface-visibility:hidden; box-shadow:0 12px 24px rgba(0,0,0,.06); display:flex; flex-direction:column; gap:14px; }
541
- .candidate-back { transform:rotateY(180deg); }
542
- .candidate-top { display:flex; justify-content:space-between; align-items:center; gap:8px; }
543
- .chip { font-size:.72rem; text-transform:uppercase; letter-spacing:.12em; color:#0b6f66; padding:7px 10px; border-radius:999px; background:rgba(23,184,166,.08); border:1px solid rgba(23,184,166,.18); }
544
- .chip.alt { color:var(--gold); background:rgba(255,122,26,.08); border-color:rgba(255,122,26,.18); }
545
- .score { font-weight:700; color:var(--gold); }
546
- .candidate-face h4 { margin:0; font-size:1.08rem; line-height:1.35; }
547
- .candidate-face p { margin:0; color:#1e1e1e; line-height:1.65; font-size:.96rem; overflow-wrap:anywhere; }
548
- .meta-row { margin-top:auto; display:flex; justify-content:space-between; color:var(--muted); font-size:.88rem; gap:14px; }
549
- .mini { cursor:pointer; margin-top:8px; align-self:flex-start; color:var(--text); padding:10px 12px; border-radius:14px; border:1px solid var(--line); background:#ffffff; transition:all 0.2s; }
550
- .mini:hover { background: #f5f5f5; border-color: var(--chosen); }
551
- .papers-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:14px; }
552
- .paper-card { border:1px solid var(--line); border-radius:18px; padding:16px; background:#ffffff; }
553
- .paper-topline { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:10px; }
554
- .paper-badge { font-size:.72rem; padding:6px 10px; border-radius:999px; background:rgba(98,141,255,.08); color:#3456b5; border:1px solid rgba(98,141,255,.18); }
555
- .paper-badge.alt { background:rgba(0,0,0,.04); color:#444; border-color:rgba(0,0,0,.08); }
556
- .doi-badge { background:rgba(255,122,26,.08); color:#8a4105; border-color:rgba(255,122,26,.18); }
557
- .paper-card h4 { margin:0 0 10px; line-height:1.35; font-size:1rem; }
558
- .paper-card p { margin:0 0 12px; line-height:1.6; color:#222; }
559
- .paper-links { display:flex; gap:12px; flex-wrap:wrap; }
560
- .paper-meta-stack { display:flex; flex-direction:column; gap:6px; color:#444; margin-bottom:12px; font-size:.9rem; }
561
- .paper-links a,.journal-card,.upload-note a { color:#0b63ce; text-decoration:none; }
562
- .journal-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:14px; }
563
- .journal-card { border:1px solid var(--line); border-radius:18px; padding:16px; display:flex; justify-content:space-between; gap:14px; align-items:center; background:#ffffff; }
564
- .journal-card h4 { margin:0 0 6px; }
565
- .journal-card p { margin:0; color:var(--muted); line-height:1.5; }
566
- .upload-note { border:1px dashed rgba(0,0,0,.16); border-radius:18px; padding:16px; background:rgba(0,0,0,.015); color:#1f1f1f; line-height:1.6; }
567
- .prosebox { padding:18px; white-space:pre-wrap; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; line-height:1.55; color:#1b1b1b; }
568
- .gr-button-primary { background:linear-gradient(135deg,rgba(255,122,26,.92),rgba(240,108,22,.92)) !important; color:#ffffff !important; border:none !important; }
569
- .gr-button-secondary { background:#ffffff !important; color:var(--text) !important; border:1px solid var(--line) !important; }
570
- .ref-list { margin:0; padding-left:18px; }
571
- .ref-list li { margin-bottom:8px; line-height:1.5; }
572
- .parse-grid { display:grid; grid-template-columns:1.2fr 1fr; gap:14px; }
573
- .parse-card { border:1px solid var(--line); border-radius:18px; padding:16px; background:#ffffff; }
574
- .selection-panel { padding:18px; }
575
- footer { display:none !important; }
576
- @keyframes pulseEdge { to { stroke-dashoffset:-40; } }
577
- @media (max-width:1180px) {
578
- .model-switcher,.candidate-grid,.papers-grid,.journal-grid,.parse-grid { grid-template-columns:1fr; }
579
- .brain-svg { height:460px; }
580
- }
581
- """
582
-
583
- CSS = BASE_CSS + "\n" + get_dvnc_layout_css()
584
- HEAD = """
585
- <link rel="preconnect" href="https://fonts.googleapis.com">
586
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
587
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
588
- <script>
589
- function triggerRouteSwap(idx) {
590
- const container = document.getElementById('route_swap_payload');
591
- if(!container) return;
592
- const input = container.querySelector('textarea') || container.querySelector('input');
593
- if(input) {
594
- input.value = idx.toString();
595
- input.dispatchEvent(new Event('input', { bubbles: true }));
596
- setTimeout(() => {
597
- const btn = document.getElementById('route_swap_apply');
598
- if(btn) btn.click();
599
- }, 150);
600
- }
601
- }
602
- </script>
603
- """
604
-
605
- # ── Gradio layout ─────────────────────────────────────────────────────────────
606
- with gr.Blocks(fill_height=True) as demo:
607
-
608
- # ── Shared state ──────────────────────────────────────────────────────────
609
- papers_state = gr.State([])
610
- parsed_pdf_state = gr.State({})
611
- ingest_payload_state = gr.State({})
612
- route_state = gr.State(get_default_route_state())
613
-
614
- # ── Header ────────────────────────────────────────────────────────────────
615
- gr.HTML("""
616
- <div id="dvnc-shell">
617
- <div class="hero-bar">
618
- <div class="brand">
619
- <div class="logo" aria-hidden="true">
620
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7">
621
- <path d="M5 17L12 4l7 13"/>
622
- <path d="M8.5 12.5h7"/>
623
- <circle cx="12" cy="12" r="1.8" fill="currentColor" stroke="none"/>
624
- </svg>
625
- </div>
626
- <div>
627
- <h1>DVNC.AI</h1>
628
- <p>Sovereign discovery instrument · connectome-native reasoning</p>
629
- </div>
630
- </div>
631
- <div class="status"><span class="status-dot"></span><span>Live orchestration</span></div>
632
- </div>
633
- </div>
634
- """)
635
-
636
- with gr.Tabs():
637
-
638
- # ── Tab 1 · Discovery Engine ──────────────────────────────────────────
639
- with gr.Tab("Discovery Engine"):
640
- model_html = gr.HTML(build_models_html("DVNC Sovereign"))
641
-
642
- with gr.Row():
643
- with gr.Column(scale=2):
644
- model = gr.Dropdown(
645
- choices=[m["name"] for m in MODELS],
646
- value="DVNC Sovereign",
647
- label="Model tier",
648
- )
649
- query = gr.Textbox(
650
- label="Discovery query",
651
- elem_classes=["querybox"],
652
- placeholder="Enter a scientific question, anomaly, or breakthrough direction…",
653
- lines=4,
654
- )
655
- with gr.Row():
656
- run_btn = gr.Button("Run discovery", variant="primary")
657
- example_btn = gr.Button("Load example", variant="secondary")
658
- chat = gr.HTML("""
659
- <div class="panel chat-panel">
660
- <div class="chat-thread">
661
- <div class="bubble bubble-ai">
662
- <span class="role">DVNC</span>
663
- <p>Enter a query to activate the 7-agent discovery stack and illuminate the chosen path through the 3D connectome.</p>
664
- </div>
665
- </div>
666
- </div>
667
- """)
668
-
669
- with gr.Column(scale=3):
670
- connectome = gr.HTML(build_connectome_html(DEFAULT_PATH))
671
- cards = gr.HTML("")
672
-
673
- output = gr.Markdown("# Discovery Output\n\nAwaiting query.")
674
- timeline = gr.HTML(get_initial_discovery_timeline_html())
675
-
676
- route_swap_payload = gr.Textbox(value="", visible=False, elem_id="route_swap_payload")
677
- route_swap_apply = gr.Button("Apply route swap", visible=False, elem_id="route_swap_apply")
678
-
679
- # ── Tab 2 · Self-Learning Graph ───────────────────────────────────────
680
- with gr.Tab("Self-Learning Graph"):
681
- with gr.Row():
682
- with gr.Column(scale=2):
683
- paper_query = gr.Textbox(
684
- label="Research topic / title / DOI / link",
685
- elem_classes=["querybox"],
686
- placeholder="e.g. self-assembling conductive biomaterials for cardiac repair",
687
- lines=3,
688
- )
689
- search_mode = gr.Dropdown(
690
- choices=SEARCH_MODES,
691
- value="topic",
692
- label="Search mode",
693
- )
694
- source_selector = gr.CheckboxGroup(
695
- choices=SOURCE_OPTIONS,
696
- value=DEFAULT_SOURCES,
697
- label="Sources",
698
- )
699
- pdf_upload = gr.File(label="Upload PDF papers", file_types=[".pdf"], file_count="single")
700
-
701
- with gr.Row():
702
- learn_btn = gr.Button("Discover papers", variant="primary")
703
- load_topic_btn = gr.Button("Load example topic", variant="secondary")
704
-
705
- upload_status = gr.Markdown("No PDF uploaded yet.")
706
- discovery_status = gr.Markdown("### No discovery results yet.")
707
- journal_panel = gr.HTML(build_journal_html("biomaterials cardiac repair"))
708
-
709
- gr.HTML('<div class="panel selection-panel"><h3 style="margin:0 0 12px;">Select papers to ingest</h3></div>')
710
- selection_box = gr.CheckboxGroup(choices=[], value=[], label="Candidate papers")
711
-
712
- parser_order = gr.CheckboxGroup(
713
- choices=["grobid", "docling", "pymupdf"],
714
- value=["grobid", "docling", "pymupdf"],
715
- label="Parser routing order",
716
- )
717
-
718
- with gr.Row():
719
- parse_btn = gr.Button("Parse uploaded PDF", variant="secondary")
720
- ingest_btn = gr.Button("Ingest selected into graph", variant="primary")
721
-
722
- with gr.Column(scale=3):
723
- learning_graph = gr.HTML(build_learning_graph_html([], []))
724
- papers_panel = gr.HTML('<div class="panel papers-panel" style="padding:18px"><p>Search by topic, title, DOI, or link, then select papers before graph ingestion.</p></div>')
725
- parse_summary = gr.Markdown("### PDF parse status\n\nAwaiting upload.")
726
- parse_panel = gr.HTML('<div class="panel" style="padding:18px"><p>No parsed document yet.</p></div>')
727
- ingest_summary = gr.Markdown("### Graph ingest status\n\nAwaiting paper selection.")
728
- ingest_payload = gr.JSON(label="Graph ingest payload", value={"status": "empty", "nodes": [], "edges": []})
729
- graph_html = render_graph_canvas_html({"status": "ok", "nodes": NODES, "edges": EDGES},title="Some title",height=780,)
730
- # ── Event wiring ────────────────────────────────────────────────────���─────
731
- example_btn.click(fn=load_example, outputs=query)
732
-
733
- run_btn.click(
734
- fn=run_discovery,
735
- inputs=[query, model],
736
- outputs=[chat, connectome, timeline, cards, output, model_html, route_state],
737
- )
738
-
739
- route_swap_apply.click(
740
- fn=apply_route_swap,
741
- inputs=[query, model, route_swap_payload, route_state],
742
- outputs=[chat, connectome, timeline, output, route_state],
743
- )
744
-
745
- load_topic_btn.click(fn=load_paper_topic, outputs=paper_query)
746
-
747
- learn_btn.click(
748
- fn=run_paper_discovery,
749
- inputs=[paper_query, search_mode, source_selector, pdf_upload],
750
- outputs=[learning_graph, papers_panel, journal_panel, upload_status, selection_box, papers_state, discovery_status],
751
- )
752
-
753
- parse_btn.click(
754
- fn=parse_uploaded_pdf,
755
- inputs=[pdf_upload, parser_order],
756
- outputs=[parse_summary, parsed_pdf_state],
757
- ).then(
758
- fn=render_parse_result,
759
- inputs=[parsed_pdf_state],
760
- outputs=[parse_panel],
761
- )
762
-
763
- ingest_btn.click(
764
- fn=ingest_selected_papers,
765
- inputs=[paper_query, selection_box, papers_state, pdf_upload, parsed_pdf_state],
766
- outputs=[learning_graph, ingest_summary, ingest_payload],
767
- )
768
-
769
- if __name__ == "__main__":
770
- demo.launch(css=CSS, head=HEAD, theme=gr.themes.Base())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app_old3.py DELETED
@@ -1,782 +0,0 @@
1
- """
2
- DVNC.AI — root app.py
3
- Refactored to fix Gradio runtime startup and preserve current
4
- dvncaiv2hf self-learning graph integration.
5
- """
6
-
7
- # ── Standard library ────────────────────────────────────────────────────────
8
- import html
9
- import random
10
- import re
11
- from typing import Dict, List, Optional
12
-
13
- # ── Third-party ─────────────────────────────────────────────────────────────
14
- import gradio as gr
15
-
16
- # ── Internal modules ────────────────────────────────────────────────────────
17
- from dvncaiv2hf.agentroutecards import buildagentroutecardshtml
18
- from dvncaiv2hf.discoveryappbridge import (
19
- getdefaultroutestate,
20
- getdiscoverycss,
21
- getinitialdiscoverytimelinehtml,
22
- )
23
- from dvncaiv2hf.dvncuilayout import getdvnclayoutcss
24
- from dvncaiv2hf.graphcanvaspatch import rendergraphcanvashtml
25
- from dvncaiv2hf.selflearninggraph import (
26
- DEFAULTSOURCES,
27
- SEARCHMODES,
28
- SOURCEOPTIONS,
29
- buildjournalhtml,
30
- ingestselectedpapers,
31
- parseuploadedpdf,
32
- renderparseresult,
33
- runpaperdiscovery,
34
- safetext,
35
- )
36
-
37
- # ── Constants ───────────────────────────────────────────────────────────────
38
- MODELS = [
39
- {
40
- "name": "DVNC Sovereign",
41
- "tag": "flagship",
42
- "desc": "Maximum depth orchestration for frontier discovery",
43
- },
44
- {
45
- "name": "DVNC Atlas",
46
- "tag": "research",
47
- "desc": "Balanced reasoning, graph traversal, and synthesis",
48
- },
49
- {
50
- "name": "DVNC Curie",
51
- "tag": "lab",
52
- "desc": "Experimental hypothesis generation for anomalous signals",
53
- },
54
- ]
55
-
56
- AGENTS = [
57
- "Query Interpreter",
58
- "Graph Divergence Mapper",
59
- "Evidence Harvester",
60
- "Analogy Engine",
61
- "Hypothesis Composer",
62
- "Adversarial Critic",
63
- "Experimental Program Designer",
64
- ]
65
-
66
- NODES = [
67
- {"id": "seed", "label": "Seed Query", "group": "core", "x": 10, "y": 0, "z": 0},
68
- {"id": "bio", "label": "Biomaterials", "group": "domain", "x": 24, "y": 12, "z": -8},
69
- {"id": "card", "label": "Cardiac Repair", "group": "domain", "x": 38, "y": 3, "z": 14},
70
- {"id": "nano", "label": "Nanostructure", "group": "bridge", "x": 24, "y": -18, "z": 16},
71
- {"id": "selfasm", "label": "Self-Assembly", "group": "bridge", "x": 40, "y": -16, "z": -16},
72
- {"id": "electro", "label": "Electro-signalling", "group": "mechanism", "x": 58, "y": 10, "z": -10},
73
- {"id": "immune", "label": "Immune Modulation", "group": "mechanism", "x": 64, "y": -8, "z": 10},
74
- {"id": "trial", "label": "Validation Path", "group": "outcome", "x": 80, "y": 0, "z": 0},
75
- {"id": "alt1", "label": "Piezoelectric Scaffold", "group": "candidate", "x": 56, "y": 26, "z": 14},
76
- {"id": "alt2", "label": "Peptide Mesh", "group": "candidate", "x": 54, "y": -27, "z": -14},
77
- ]
78
-
79
- EDGES = [
80
- ("seed", "bio"),
81
- ("seed", "nano"),
82
- ("bio", "card"),
83
- ("nano", "selfasm"),
84
- ("selfasm", "electro"),
85
- ("card", "immune"),
86
- ("electro", "trial"),
87
- ("immune", "trial"),
88
- ("card", "alt1"),
89
- ("selfasm", "alt2"),
90
- ("alt1", "trial"),
91
- ("alt2", "trial"),
92
- ]
93
-
94
- DEFAULTPATH = ["seed", "nano", "selfasm", "electro", "trial"]
95
-
96
- CANDIDATES = [
97
- {
98
- "title": "Piezoelectric Scaffold Cascade",
99
- "front": "Use mechano-electric scaffolds to convert cardiac strain into micro-current signalling.",
100
- "back": "Discovery path: anomalous healing signal -> piezoelectric analog -> ion-channel entrainment -> tissue regeneration. Risk: power density and fibrosis coupling.",
101
- "score": 92,
102
- "novelty": "High",
103
- "agent": "Hypothesis Composer",
104
- },
105
- {
106
- "title": "Peptide Self-Assembly Mesh",
107
- "front": "Deploy dynamic peptide meshes that self-assemble around damaged myocardium and guide repair.",
108
- "back": "Discovery path: self-assembly -> local immune choreography -> regenerative substrate formation. Risk: degradation timing and targeting specificity.",
109
- "score": 88,
110
- "novelty": "High",
111
- "agent": "Analogy Engine",
112
- },
113
- {
114
- "title": "Immune-Tuned Conductive Hydrogel",
115
- "front": "Blend conductivity with macrophage-state modulation to reduce scarring and restore conduction.",
116
- "back": "Discovery path: inflammation mismatch -> conductive medium -> macrophage polarization -> synchronized healing. Risk: persistence and biocompatibility.",
117
- "score": 85,
118
- "novelty": "Medium-High",
119
- "agent": "Adversarial Critic",
120
- },
121
- ]
122
-
123
- ACADEMICINSIGHTS = [
124
- {
125
- "hypothesis": "Implementation of mechano-electric scaffolds to transduce cardiac strain into localized micro-current signalling for myocardial regeneration.",
126
- "metrics": {
127
- "Novelty": 92,
128
- "Mechanistic clarity": 85,
129
- "Experimental tractability": 78,
130
- "Cross-domain distance": 94,
131
- },
132
- "outline": (
133
- "1. Synthesize candidate piezoelectric biomaterial scaffolds with tunable strain-electric coupling.\n"
134
- "2. Evaluate in vitro electromechanical transduction and subsequent ion-channel entrainment.\n"
135
- "3. Conduct in vivo comparative models to assess regenerative efficacy against gold-standard substrates.\n"
136
- "4. Rigorously validate to exclude pathological fibrosis and power-density toxicity."
137
- ),
138
- "path": ["seed", "bio", "card", "alt1", "trial"],
139
- },
140
- {
141
- "hypothesis": "Deployment of dynamic peptide networks that self-assemble post-infarction to orchestrate local immunological responses and guide substrate regeneration.",
142
- "metrics": {
143
- "Novelty": 88,
144
- "Mechanistic clarity": 82,
145
- "Experimental tractability": 86,
146
- "Cross-domain distance": 85,
147
- },
148
- "outline": (
149
- "1. Formulate peptide sequences programmed for triggered in situ self-assembly within the myocardial infarct zone.\n"
150
- "2. Quantify macrophage polarization and local immune choreography post-deployment.\n"
151
- "3. Map the temporospatial degradation profile against de novo tissue formation.\n"
152
- "4. Falsify against off-target aggregation and delayed clearance risks."
153
- ),
154
- "path": ["seed", "nano", "selfasm", "alt2", "trial"],
155
- },
156
- {
157
- "hypothesis": "Integration of conductive hydrogels with immunomodulatory properties to simultaneously bridge electrical uncoupling and mitigate adverse fibrotic scarring.",
158
- "metrics": {
159
- "Novelty": 85,
160
- "Mechanistic clarity": 90,
161
- "Experimental tractability": 88,
162
- "Cross-domain distance": 79,
163
- },
164
- "outline": (
165
- "1. Fabricate biocompatible hydrogels featuring precisely tuned electrical conductivity and immunomodulatory motifs.\n"
166
- "2. Monitor electrophysiological synchronization across the scaffold-tissue interface.\n"
167
- "3. Assess macrophage state transitions and suppression of adverse fibrotic remodelling.\n"
168
- "4. Validate long-term persistence, hemocompatibility, and mechanical integration."
169
- ),
170
- "path": ["seed", "bio", "card", "immune", "trial"],
171
- },
172
- ]
173
-
174
-
175
- # ── Utility helpers ─────────────────────────────────────────────────────────
176
- def normtext(x: Optional[str]) -> str:
177
- return re.sub(r"\s+", " ", (x or "")).strip()
178
-
179
-
180
- def buildlearninggraphhtml(nodes, edges, title="Self-Learning Knowledge Graph"):
181
- return rendergraphcanvashtml(
182
- {
183
- "status": "ok" if nodes or edges else "empty",
184
- "nodes": nodes or [],
185
- "edges": edges or [],
186
- },
187
- title=title,
188
- height=780,
189
- )
190
-
191
-
192
- # ── HTML builders ───────────────────────────────────────────────────────────
193
- def buildconnectomehtml(pathids: List[str]) -> str:
194
- active = set(pathids)
195
- nodemap = {n["id"]: n for n in NODES}
196
- pathpairs = {
197
- pair
198
- for i in range(len(pathids) - 1)
199
- for pair in [(pathids[i], pathids[i + 1]), (pathids[i + 1], pathids[i])]
200
- }
201
-
202
- baselines, activelines, circles, labels = [], [], [], []
203
-
204
- for a, b in EDGES:
205
- na, nb = nodemap[a], nodemap[b]
206
- x1, y1 = na["x"] * 8 + 80, na["y"] * 6 + 280
207
- x2, y2 = nb["x"] * 8 + 80, nb["y"] * 6 + 280
208
- baselines.append(
209
- f'<line class="edge" x1="{x1:.1f}" y1="{y1:.1f}" x2="{x2:.1f}" y2="{y2:.1f}" />'
210
- )
211
- if (a, b) in pathpairs:
212
- activelines.append(
213
- f'<line class="edge active" x1="{x1:.1f}" y1="{y1:.1f}" x2="{x2:.1f}" y2="{y2:.1f}" />'
214
- )
215
-
216
- for n in NODES:
217
- cx, cy = n["x"] * 8 + 80, n["y"] * 6 + 280
218
- isactive = n["id"] in active
219
- state = "chosen" if isactive else "idle"
220
- halocls = "halo active" if isactive else "halo"
221
- lblcls = "label active" if isactive else "label"
222
- radius = 18 if isactive else 13
223
- halor = 30 if isactive else 0
224
-
225
- circles.append(
226
- f'<g class="node-wrap">'
227
- f'<circle class="{halocls}" cx="{cx:.1f}" cy="{cy:.1f}" r="{halor}" />'
228
- f'<circle class="node {n["group"]} {state}" cx="{cx:.1f}" cy="{cy:.1f}" r="{radius}" />'
229
- f"</g>"
230
- )
231
- labels.append(
232
- f'<text class="{lblcls}" x="{cx + 18:.1f}" y="{cy - 16:.1f}">{safe_text(n["label"])}</text>'
233
- )
234
-
235
- return f"""
236
- <div class="panel brain-shell">
237
- <div class="brain-header">
238
- <div>
239
- <p class="eyebrow">Connectome</p>
240
- <h3>3D Connectome</h3>
241
- </div>
242
- <div class="brain-legend">
243
- <span><i class="dot dot-live"></i> lit path</span>
244
- <span><i class="dot dot-chosen"></i> chosen node</span>
245
- <span><i class="dot dot-idle"></i> available node</span>
246
- </div>
247
- </div>
248
- <div class="brain-stage">
249
- <svg viewBox="0 0 780 560" class="brain-svg" role="img" aria-label="DVNC 3D connectome visualisation">
250
- {''.join(baselines)}
251
- {''.join(activelines)}
252
- {''.join(circles)}
253
- {''.join(labels)}
254
- </svg>
255
- </div>
256
- </div>
257
- """
258
-
259
-
260
- def buildcardshtml(cards: List[Dict]) -> str:
261
- items = []
262
- for i, c in enumerate(cards):
263
- items.append(
264
- f"""
265
- <article class="candidate-card" tabindex="0">
266
- <div class="candidate-card-inner">
267
- <div class="candidate-face candidate-front">
268
- <div class="candidate-top">
269
- <span class="chip">{safe_text(c["agent"])}</span>
270
- <span class="score">{safe_text(c["score"])}</span>
271
- </div>
272
- <h4>{safe_text(c["title"])}</h4>
273
- <p>{safe_text(c["front"])}</p>
274
- <div class="meta-row"><span>Novelty</span><strong>{safe_text(c["novelty"])}</strong></div>
275
- <button class="mini" type="button" onclick="triggerRouteSwap('{i}')">Use as main insight</button>
276
- </div>
277
- <div class="candidate-face candidate-back">
278
- <div class="candidate-top">
279
- <span class="chip alt">Alternative path</span>
280
- <span class="score">{safe_text(c["score"])}</span>
281
- </div>
282
- <h4>{safe_text(c["title"])}</h4>
283
- <p>{safe_text(c["back"])}</p>
284
- <div class="meta-row"><span>Swap into route</span><strong>Enabled</strong></div>
285
- <button class="mini" type="button" onclick="triggerRouteSwap('{i}')">Use as main insight</button>
286
- </div>
287
- </div>
288
- </article>
289
- """
290
- )
291
- return '<div class="panel" style="padding:20px;"><div class="candidate-grid">' + "".join(items) + "</div></div>"
292
-
293
-
294
- def buildchathtml(query: str, result: Dict) -> str:
295
- return f"""
296
- <div class="panel chat-panel">
297
- <div class="chat-thread">
298
- <div class="bubble bubble-user">
299
- <span class="role">You</span>
300
- <p>{safe_text(query)}</p>
301
- </div>
302
- <div class="bubble bubble-ai">
303
- <span class="role">DVNC Sovereign</span>
304
- <p>{safe_text(result["summary"])}</p>
305
- </div>
306
- <div class="bubble bubble-system">
307
- <span class="role">Discovery Signal</span>
308
- <p><strong>Primary hypothesis:</strong> {safe_text(result["primary_hypothesis"])}</p>
309
- </div>
310
- </div>
311
- </div>
312
- """
313
-
314
-
315
- def buildmodelshtml(selected: str) -> str:
316
- items = []
317
- for m in MODELS:
318
- active = "active" if m["name"] == selected else ""
319
- items.append(
320
- f"""
321
- <div class="model-pill {active}">
322
- <span class="model-name">{safe_text(m["name"])}</span>
323
- <span class="model-tag">{safe_text(m["tag"])}</span>
324
- <small>{safe_text(m["desc"])}</small>
325
- </div>
326
- """
327
- )
328
- return '<div class="panel" style="padding:18px;"><div class="model-switcher">' + "".join(items) + "</div></div>"
329
-
330
-
331
- # ── Discovery logic ─────────────────────────────────────────────────────────
332
- def rundiscovery(query: str, modelname: str):
333
- random.seed(len(query or "") + len(modelname or ""))
334
-
335
- if "curie" in (query or "").lower() or "einstein" in (query or "").lower():
336
- primary = "Map the anomaly first, then force a distant analogy before composing the experimental programme."
337
- path = ["seed", "bio", "card", "immune", "trial"]
338
- else:
339
- primary = (
340
- "Utilization of a self-assembling conductive scaffold to transduce mechanical "
341
- "strain into localized regenerative signalling pathways."
342
- )
343
- path = DEFAULTPATH
344
-
345
- summaries = [
346
- "Normalises the user prompt into a graph-searchable seed and isolates the tension inside the question.",
347
- "Finds remote conceptual bridges instead of staying near the starting domain cluster.",
348
- "Pulls evidence packets and conflict signals required for grounded hypothesis formation.",
349
- "Generates cross-domain analogies with a bias toward mechanism transfer rather than keyword similarity.",
350
- "Composes the lead hypothesis and two structurally different variants.",
351
- "Attacks weak assumptions, hidden confounders, and feasibility gaps.",
352
- "Produces a staged validation plan with measurable falsification criteria.",
353
- ]
354
- tags = ["input", "graph", "evidence", "analogy", "compose", "critique", "experiment"]
355
- reasoning = [
356
- {"step": i + 1, "agent": AGENTS[i], "tag": tags[i], "summary": summaries[i]}
357
- for i in range(7)
358
- ]
359
-
360
- result = {
361
- "summary": (
362
- "A deeper route was chosen through the connectome, with live alternatives preserved "
363
- "as swappable cards so the reasoning path can be inspected rather than hidden."
364
- ),
365
- "primary_hypothesis": primary,
366
- "reasoning": reasoning,
367
- "cards": CANDIDATES,
368
- "path": path,
369
- "metrics": {
370
- "Novelty": 93,
371
- "Mechanistic clarity": 89,
372
- "Experimental tractability": 82,
373
- "Cross-domain distance": 91,
374
- },
375
- }
376
-
377
- chathtml = buildchathtml(query, result)
378
- connectomehtml = buildconnectomehtml(path)
379
- timelinehtml = buildagentroutecardshtml(reasoning)
380
- metricsmd = "\n".join(f"- {k}: {v}/100" for k, v in result["metrics"].items())
381
-
382
- hypothesismd = (
383
- "# Discovery Output\n\n"
384
- f"**Model:** {modelname}\n\n"
385
- f"**Primary hypothesis:** {result['primary_hypothesis']}\n\n"
386
- "## Scoring\n"
387
- f"{metricsmd}\n\n"
388
- "## Experimental outline\n"
389
- "1. Construct the candidate material or protocol.\n"
390
- "2. Test mechanistic signal expression under controlled conditions.\n"
391
- "3. Compare against baseline and nearest-neighbour alternatives.\n"
392
- "4. Falsify using the adversarial risk criteria surfaced in the reasoning path.\n"
393
- )
394
-
395
- cardshtml = buildcardshtml(CANDIDATES)
396
- routestate = getdefaultroutestate()
397
- return (
398
- chathtml,
399
- connectomehtml,
400
- timelinehtml,
401
- cardshtml,
402
- hypothesismd,
403
- buildmodelshtml(modelname),
404
- routestate,
405
- )
406
-
407
-
408
- def applyrouteswap(query: str, modelname: str, routeswappayload: str, routestate):
409
- try:
410
- idx = int(routeswappayload)
411
- except Exception:
412
- idx = 0
413
-
414
- if not (0 <= idx < len(ACADEMICINSIGHTS)):
415
- idx = 0
416
-
417
- academic = ACADEMICINSIGHTS[idx]
418
- connectomehtml = buildconnectomehtml(academic["path"])
419
-
420
- result = {
421
- "summary": (
422
- "Main insight formally adopted. The connectome pathway and validation protocol "
423
- "have been realigned to the selected candidate methodology."
424
- ),
425
- "primary_hypothesis": academic["hypothesis"],
426
- }
427
- chathtml = buildchathtml(query, result)
428
-
429
- metricsmd = "\n".join(f"- {k}: {v}/100" for k, v in academic["metrics"].items())
430
- hypothesismd = (
431
- "# Discovery Output\n\n"
432
- f"**Model:** {modelname}\n\n"
433
- f"**Primary hypothesis:** {academic['hypothesis']}\n\n"
434
- "## Scoring\n"
435
- f"{metricsmd}\n\n"
436
- "## Experimental outline\n"
437
- f"{academic['outline']}\n"
438
- )
439
-
440
- return chathtml, connectomehtml, gr.update(), hypothesismd, routestate
441
-
442
-
443
- # ── Example loaders ────────────────────────────────────────────────────────
444
- def loadexample() -> str:
445
- return "How could a self-assembling conductive biomaterial improve cardiac tissue regeneration by converting mechanical strain into repair signalling?"
446
-
447
-
448
- def loadpapertopic() -> str:
449
- return "self-assembling conductive biomaterials for cardiac repair"
450
-
451
-
452
- # ── CSS / HEAD ──────────────────────────────────────────────────────────────
453
- BASECSS = r"""
454
- :root {
455
- --bg: #ffffff; --panel: #ffffff; --line: rgba(0,0,0,.12);
456
- --text: #111111; --muted: #5b5b5b; --soft: rgba(0,0,0,.62);
457
- --gold: #ff6600; --teal: #17b8a6; --blue: #628dff;
458
- --chosen: #ff7a1a; --idle: #b8d8ff; --idle-stroke: #5e8fe6;
459
- --query-node: #ffd8b3; --paper-node: #d7f6f2; --upload-node: #e7defe;
460
- --shadow: 0 16px 40px rgba(0,0,0,.12);
461
- }
462
- html,body,.gradio-container { background:#ffffff !important; font-family:Inter,ui-sans-serif,system-ui,sans-serif; }
463
- .gradio-container { max-width:1640px !important; padding:20px !important; }
464
- #dvnc-shell { border:1px solid var(--line); border-radius:28px; overflow:hidden; background:#ffffff; box-shadow:var(--shadow); padding:20px 22px 22px; }
465
- .hero-bar { display:flex; justify-content:space-between; align-items:center; gap:16px; padding-bottom:12px; border-bottom:1px solid rgba(0,0,0,.06); margin-bottom:16px; }
466
- .brand { display:flex; align-items:center; gap:14px; }
467
- .logo { width:42px; height:42px; border-radius:14px; display:grid; place-items:center; color:var(--gold); background:linear-gradient(135deg,rgba(255,122,26,.12),rgba(23,184,166,.10)); border:1px solid rgba(0,0,0,.08); }
468
- .logo svg { width:24px; height:24px; }
469
- .brand h1 { font-size:1.05rem; margin:0; font-weight:700; letter-spacing:.12em; text-transform:uppercase; }
470
- .brand p { margin:3px 0 0; color:var(--muted); font-size:.84rem; }
471
- .status { display:flex; gap:10px; align-items:center; color:var(--soft); font-size:.85rem; }
472
- .status-dot { width:10px; height:10px; border-radius:50%; background:var(--teal); box-shadow:0 0 0 6px rgba(23,184,166,.10),0 0 14px rgba(23,184,166,.25); }
473
- .panel { background:#ffffff; border:1px solid var(--line); border-radius:22px; box-shadow:inset 0 1px 0 rgba(255,255,255,.8); }
474
- .querybox textarea,.querybox input { background:transparent !important; color:var(--text) !important; }
475
- .querybox,.querybox>div { background:#ffffff !important; border-radius:18px !important; border-color:var(--line) !important; }
476
- .chat-panel { padding:18px; min-height:280px; }
477
- .chat-thread { display:flex; flex-direction:column; gap:14px; }
478
- .bubble { max-width:88%; padding:16px 18px; border-radius:22px; border:1px solid var(--line); }
479
- .bubble p { margin:8px 0 0; line-height:1.6; font-size:.96rem; color:var(--text); }
480
- .bubble .role { font-size:.72rem; letter-spacing:.12em; text-transform:uppercase; color:var(--muted); }
481
- .bubble-user { align-self:flex-end; background:linear-gradient(135deg,rgba(98,141,255,.16),rgba(98,141,255,.08)); }
482
- .bubble-ai { align-self:flex-start; background:#ffffff; }
483
- .bubble-system { align-self:flex-start; background:linear-gradient(135deg,rgba(255,122,26,.10),rgba(255,122,26,.04)); }
484
- .model-switcher { display:grid; grid-template-columns:repeat(3,1fr); gap:12px; }
485
- .model-pill { padding:14px; border:1px solid var(--line); border-radius:18px; display:flex; flex-direction:column; gap:4px; min-height:98px; background:#ffffff; }
486
- .model-pill.active { border-color:rgba(255,122,26,.40); background:linear-gradient(135deg,rgba(255,122,26,.10),rgba(255,255,255,.96)); }
487
- .model-name { font-weight:650; color:var(--text); }
488
- .model-tag { font-size:.76rem; text-transform:uppercase; letter-spacing:.12em; color:var(--gold); }
489
- .model-pill small { color:var(--muted); line-height:1.45; }
490
- .brain-shell { padding:18px; }
491
- .brain-header { display:flex; justify-content:space-between; align-items:flex-end; gap:16px; margin-bottom:10px; }
492
- .eyebrow { font-size:.72rem; letter-spacing:.16em; text-transform:uppercase; color:var(--gold); margin:0 0 4px; }
493
- .brain-header h3 { margin:0; font-size:1.12rem; color:var(--text); }
494
- .brain-legend { display:flex; gap:14px; color:var(--muted); font-size:.8rem; flex-wrap:wrap; }
495
- .dot { width:10px; height:10px; display:inline-block; border-radius:50%; margin-right:6px; }
496
- .dot-live { background:var(--chosen); box-shadow:0 0 10px rgba(255,122,26,.35); }
497
- .dot-chosen { background:var(--chosen); }
498
- .dot-idle { background:var(--idle); border:1px solid var(--idle-stroke); }
499
- .dot-query { background:var(--query-node); border:1px solid #de9e58; }
500
- .dot-paper { background:var(--paper-node); border:1px solid #4fb3a5; }
501
- .dot-upload { background:var(--upload-node); border:1px solid #8f73d9; }
502
- .brain-stage { position:relative; min-height:420px; overflow:hidden; background:linear-gradient(180deg,rgba(250,250,250,1),rgba(255,255,255,1)); border:1px solid rgba(0,0,0,.05); border-radius:20px; }
503
- .brain-svg { width:100%; height:520px; display:block; }
504
- .edge { stroke:rgba(0,0,0,.12); stroke-width:2.4; }
505
- .edge.active { stroke:var(--chosen); stroke-width:4.2; stroke-linecap:round; filter:drop-shadow(0 0 6px rgba(255,122,26,.45)); stroke-dasharray:8 12; animation:pulseEdge 1.5s linear infinite; }
506
- .node { stroke-width:2.2; transition:all .25s ease; }
507
- .node.idle { fill:var(--idle); stroke:var(--idle-stroke); }
508
- .node.chosen { fill:var(--chosen); stroke:#ffb16d; }
509
- .halo { fill:none; }
510
- .halo.active { stroke:rgba(255,122,26,.18); stroke-width:12; }
511
- .label { fill:#2c2c2c; font-size:13px; font-weight:500; letter-spacing:.01em; }
512
- .label.active { fill:#111111; font-weight:700; }
513
- .timeline { display:flex; flex-direction:column; gap:10px; }
514
- .agent-step { border:1px solid var(--line); border-radius:18px; background:#ffffff; overflow:hidden; }
515
- .agent-summary { list-style:none; display:grid; grid-template-columns:42px 1fr; gap:12px; align-items:center; padding:12px; cursor:pointer; }
516
- .agent-summary::-webkit-details-marker { display:none; }
517
- .agent-index { width:42px; height:42px; border-radius:14px; display:grid; place-items:center; font-weight:700; color:var(--gold); background:rgba(255,122,26,.08); border:1px solid rgba(255,122,26,.18); }
518
- .agent-head { display:flex; justify-content:space-between; gap:12px; align-items:center; }
519
- .agent-head h4 { margin:0; font-size:.98rem; color:var(--text); }
520
- .agent-head span { font-size:.72rem; letter-spacing:.12em; text-transform:uppercase; color:var(--muted); }
521
- .agent-copy { padding:0 14px 16px 66px; }
522
- .agent-copy p { margin:0; color:#2d2d2d; font-size:.93rem; line-height:1.6; }
523
- .candidate-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:18px; }
524
- .candidate-card { background:none; perspective:1400px; min-height:330px; }
525
- .candidate-card-inner { position:relative; width:100%; min-height:330px; transition:transform .8s cubic-bezier(.2,.7,.1,1); transform-style:preserve-3d; }
526
- .candidate-card:hover .candidate-card-inner,.candidate-card:focus .candidate-card-inner,.candidate-card:focus-within .candidate-card-inner { transform:rotateY(180deg); }
527
- .candidate-face { position:absolute; inset:0; padding:20px; border-radius:22px; border:1px solid var(--line); background:#ffffff; color:var(--text); backface-visibility:hidden; box-shadow:0 12px 24px rgba(0,0,0,.06); display:flex; flex-direction:column; gap:14px; }
528
- .candidate-back { transform:rotateY(180deg); }
529
- .candidate-top { display:flex; justify-content:space-between; align-items:center; gap:8px; }
530
- .chip { font-size:.72rem; text-transform:uppercase; letter-spacing:.12em; color:#0b6f66; padding:7px 10px; border-radius:999px; background:rgba(23,184,166,.08); border:1px solid rgba(23,184,166,.18); }
531
- .chip.alt { color:var(--gold); background:rgba(255,122,26,.08); border-color:rgba(255,122,26,.18); }
532
- .score { font-weight:700; color:var(--gold); }
533
- .candidate-face h4 { margin:0; font-size:1.08rem; line-height:1.35; }
534
- .candidate-face p { margin:0; color:#1e1e1e; line-height:1.65; font-size:.96rem; overflow-wrap:anywhere; }
535
- .meta-row { margin-top:auto; display:flex; justify-content:space-between; color:var(--muted); font-size:.88rem; gap:14px; }
536
- .mini { cursor:pointer; margin-top:8px; align-self:flex-start; color:var(--text); padding:10px 12px; border-radius:14px; border:1px solid var(--line); background:#ffffff; transition:all 0.2s; }
537
- .mini:hover { background:#f5f5f5; border-color:var(--chosen); }
538
- .papers-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:14px; }
539
- .paper-card { border:1px solid var(--line); border-radius:18px; padding:16px; background:#ffffff; }
540
- .paper-topline { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:10px; }
541
- .paper-badge { font-size:.72rem; padding:6px 10px; border-radius:999px; background:rgba(98,141,255,.08); color:#3456b5; border:1px solid rgba(98,141,255,.18); }
542
- .paper-badge.alt { background:rgba(0,0,0,.04); color:#444; border-color:rgba(0,0,0,.08); }
543
- .doi-badge { background:rgba(255,122,26,.08); color:#8a4105; border-color:rgba(255,122,26,.18); }
544
- .paper-card h4 { margin:0 0 10px; line-height:1.35; font-size:1rem; }
545
- .paper-card p { margin:0 0 12px; line-height:1.6; color:#222; }
546
- .paper-links { display:flex; gap:12px; flex-wrap:wrap; }
547
- .paper-meta-stack { display:flex; flex-direction:column; gap:6px; color:#444; margin-bottom:12px; font-size:.9rem; }
548
- .paper-links a,.journal-card,.upload-note a { color:#0b63ce; text-decoration:none; }
549
- .journal-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:14px; }
550
- .journal-card { border:1px solid var(--line); border-radius:18px; padding:16px; display:flex; justify-content:space-between; gap:14px; align-items:center; background:#ffffff; }
551
- .journal-card h4 { margin:0 0 6px; }
552
- .journal-card p { margin:0; color:var(--muted); line-height:1.5; }
553
- .selection-panel { padding:18px; }
554
- .parse-grid { display:grid; grid-template-columns:1.2fr 1fr; gap:14px; }
555
- .parse-card { border:1px solid var(--line); border-radius:18px; padding:16px; background:#ffffff; }
556
- .ref-list { margin:0; padding-left:18px; }
557
- .ref-list li { margin-bottom:8px; line-height:1.5; }
558
- .gr-button-primary { background:linear-gradient(135deg,rgba(255,122,26,.92),rgba(240,108,22,.92)) !important; color:#ffffff !important; border:none !important; }
559
- .gr-button-secondary { background:#ffffff !important; color:var(--text) !important; border:1px solid var(--line) !important; }
560
- footer { display:none !important; }
561
- @keyframes pulseEdge { to { stroke-dashoffset:-40; } }
562
- @media (max-width:1180px) {
563
- .model-switcher,.candidate-grid,.papers-grid,.journal-grid,.parse-grid { grid-template-columns:1fr; }
564
- .brain-svg { height:460px; }
565
- }
566
- """
567
-
568
- CSS = BASECSS + "\n" + getdvnclayoutcss() + "\n" + getdiscoverycss()
569
-
570
- HEAD = """
571
- <link rel="preconnect" href="https://fonts.googleapis.com">
572
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
573
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
574
- <script>
575
- function triggerRouteSwap(idx) {
576
- const container = document.getElementById('route_swap_payload');
577
- if (!container) return;
578
- const input = container.querySelector('textarea') || container.querySelector('input');
579
- if (input) {
580
- input.value = idx.toString();
581
- input.dispatchEvent(new Event('input', { bubbles: true }));
582
- setTimeout(() => {
583
- const btn = document.getElementById('route_swap_apply');
584
- if (btn) btn.click();
585
- }, 150);
586
- }
587
- }
588
- </script>
589
- """
590
-
591
-
592
- # ── Gradio layout ───────────────────────────────────────────────────────────
593
- with gr.Blocks(css=CSS, head=HEAD, theme=gr.themes.Base(), fill_height=True) as demo:
594
- papersstate = gr.State([])
595
- parsedpdfstate = gr.State({})
596
- ingestpayloadstate = gr.State({})
597
- routestate = gr.State(getdefaultroutestate())
598
-
599
- gr.HTML(
600
- """
601
- <div id="dvnc-shell">
602
- <div class="hero-bar">
603
- <div class="brand">
604
- <div class="logo" aria-hidden="true">
605
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7">
606
- <path d="M5 17L12 4l7 13"/>
607
- <path d="M8.5 12.5h7"/>
608
- <circle cx="12" cy="12" r="1.8" fill="currentColor" stroke="none"/>
609
- </svg>
610
- </div>
611
- <div>
612
- <h1>DVNC.AI</h1>
613
- <p>Sovereign discovery instrument · connectome-native reasoning</p>
614
- </div>
615
- </div>
616
- <div class="status"><span class="status-dot"></span><span>Live orchestration</span></div>
617
- </div>
618
- </div>
619
- """
620
- )
621
-
622
- with gr.Tabs():
623
- with gr.Tab("Discovery Engine"):
624
- modelhtml = gr.HTML(buildmodelshtml("DVNC Sovereign"))
625
-
626
- with gr.Row():
627
- with gr.Column(scale=2):
628
- model = gr.Dropdown(
629
- choices=[m["name"] for m in MODELS],
630
- value="DVNC Sovereign",
631
- label="Model tier",
632
- )
633
- query = gr.Textbox(
634
- label="Discovery query",
635
- elem_classes=["querybox"],
636
- placeholder="Enter a scientific question, anomaly, or breakthrough direction…",
637
- lines=4,
638
- )
639
- with gr.Row():
640
- runbtn = gr.Button("Run discovery", variant="primary")
641
- examplebtn = gr.Button("Load example", variant="secondary")
642
-
643
- chat = gr.HTML(
644
- """
645
- <div class="panel chat-panel">
646
- <div class="chat-thread">
647
- <div class="bubble bubble-ai">
648
- <span class="role">DVNC</span>
649
- <p>Enter a query to activate the 7-agent discovery stack and illuminate the chosen path through the 3D connectome.</p>
650
- </div>
651
- </div>
652
- </div>
653
- """
654
- )
655
-
656
- with gr.Column(scale=3):
657
- connectome = gr.HTML(buildconnectomehtml(DEFAULTPATH))
658
- cards = gr.HTML("")
659
-
660
- output = gr.Markdown("# Discovery Output\n\nAwaiting query.")
661
- timeline = gr.HTML(getinitialdiscoverytimelinehtml())
662
- routeswappayload = gr.Textbox(value="", visible=False, elem_id="route_swap_payload")
663
- routeswapapply = gr.Button("Apply route swap", visible=False, elem_id="route_swap_apply")
664
-
665
- with gr.Tab("Self-Learning Graph"):
666
- with gr.Row():
667
- with gr.Column(scale=2):
668
- paperquery = gr.Textbox(
669
- label="Research topic / title / DOI / link",
670
- elem_classes=["querybox"],
671
- placeholder="e.g. self-assembling conductive biomaterials for cardiac repair",
672
- lines=3,
673
- )
674
- searchmode = gr.Dropdown(
675
- choices=SEARCHMODES,
676
- value="topic",
677
- label="Search mode",
678
- )
679
- sourceselector = gr.CheckboxGroup(
680
- choices=SOURCEOPTIONS,
681
- value=DEFAULTSOURCES,
682
- label="Sources",
683
- )
684
- pdfupload = gr.File(
685
- label="Upload PDF papers",
686
- file_types=[".pdf"],
687
- file_count="single",
688
- )
689
-
690
- with gr.Row():
691
- learnbtn = gr.Button("Discover papers", variant="primary")
692
- loadtopicbtn = gr.Button("Load example topic", variant="secondary")
693
-
694
- uploadstatus = gr.Markdown("No PDF uploaded yet.")
695
- discoverystatus = gr.Markdown("### No discovery results yet.")
696
- journalpanel = gr.HTML(buildjournalhtml("biomaterials cardiac repair"))
697
-
698
- gr.HTML(
699
- '<div class="panel selection-panel"><h3 style="margin:0 0 12px;">Select papers to ingest</h3></div>'
700
- )
701
-
702
- selectionbox = gr.CheckboxGroup(
703
- choices=[],
704
- value=[],
705
- label="Candidate papers",
706
- )
707
-
708
- parserorder = gr.CheckboxGroup(
709
- choices=["grobid", "docling", "pymupdf"],
710
- value=["grobid", "docling", "pymupdf"],
711
- label="Parser routing order",
712
- )
713
-
714
- with gr.Row():
715
- parsebtn = gr.Button("Parse uploaded PDF", variant="secondary")
716
- ingestbtn = gr.Button("Ingest selected into graph", variant="primary")
717
-
718
- with gr.Column(scale=3):
719
- learninggraph = gr.HTML(buildlearninggraphhtml([], []))
720
- paperspanel = gr.HTML(
721
- '<div class="panel papers-panel" style="padding:18px"><p>Search by topic, title, DOI, or link, then select papers before graph ingestion.</p></div>'
722
- )
723
- parsesummary = gr.Markdown("### PDF parse status\n\nAwaiting upload.")
724
- parsepanel = gr.HTML(
725
- '<div class="panel" style="padding:18px"><p>No parsed document yet.</p></div>'
726
- )
727
- ingestsummary = gr.Markdown("### Graph ingest status\n\nAwaiting paper selection.")
728
- ingestpayload = gr.JSON(
729
- label="Graph ingest payload",
730
- value={"status": "empty", "nodes": [], "edges": []},
731
- )
732
-
733
- # ── Event wiring ────────────────────────────────────────────────────────
734
- examplebtn.click(fn=loadexample, outputs=query)
735
-
736
- runbtn.click(
737
- fn=rundiscovery,
738
- inputs=[query, model],
739
- outputs=[chat, connectome, timeline, cards, output, modelhtml, routestate],
740
- )
741
-
742
- routeswapapply.click(
743
- fn=applyrouteswap,
744
- inputs=[query, model, routeswappayload, routestate],
745
- outputs=[chat, connectome, timeline, output, routestate],
746
- )
747
-
748
- loadtopicbtn.click(fn=loadpapertopic, outputs=paperquery)
749
-
750
- learnbtn.click(
751
- fn=runpaperdiscovery,
752
- inputs=[paperquery, searchmode, sourceselector, pdfupload],
753
- outputs=[
754
- learninggraph,
755
- paperspanel,
756
- journalpanel,
757
- uploadstatus,
758
- selectionbox,
759
- papersstate,
760
- discoverystatus,
761
- ],
762
- )
763
-
764
- parsebtn.click(
765
- fn=parseuploadedpdf,
766
- inputs=[pdfupload, parserorder],
767
- outputs=[parsesummary, parsedpdfstate],
768
- ).then(
769
- fn=renderparseresult,
770
- inputs=[parsedpdfstate],
771
- outputs=[parsepanel],
772
- )
773
-
774
- ingestbtn.click(
775
- fn=ingestselectedpapers,
776
- inputs=[paperquery, selectionbox, papersstate, pdfupload, parsedpdfstate],
777
- outputs=[learninggraph, ingestsummary, ingestpayload],
778
- )
779
-
780
-
781
- if __name__ == "__main__":
782
- demo.launch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app_old4.py DELETED
@@ -1,412 +0,0 @@
1
- """
2
- DVNC.AI — root app.py
3
- Fixed: all internal module imports now use correct underscore names
4
- matching the actual function/constant names in dvnc_ai_v2_hf/.
5
- """
6
-
7
- # ── Standard library ──────────────────────────────────────────────────────
8
- import html
9
- import random
10
- import re
11
- import sys
12
- from pathlib import Path
13
- from typing import Dict, List, Optional
14
-
15
- # Ensure the repository root is on sys.path so the package is importable
16
- ROOT = Path(__file__).resolve().parent
17
- if str(ROOT) not in sys.path:
18
- sys.path.insert(0, str(ROOT))
19
-
20
- # ── Third-party ───────────────────────────────────────────────────────────
21
- import gradio as gr
22
-
23
- # ── Internal modules ──────────────────────────────────────────────────────
24
- from dvnc_ai_v2_hf.agent_route_cards import build_agent_route_cards_html
25
- from dvnc_ai_v2_hf.discovery_app_bridge import (
26
- get_default_route_state,
27
- get_discovery_css,
28
- get_initial_discovery_timeline_html,
29
- )
30
- from dvnc_ai_v2_hf.dvnc_ui_layout import get_dvnc_layout_css
31
- from dvnc_ai_v2_hf.graph_canvas_patch import render_graph_canvas_html
32
- from dvnc_ai_v2_hf.self_learning_graph import (
33
- DEFAULT_SOURCES,
34
- SEARCH_MODES,
35
- SOURCE_OPTIONS,
36
- build_journal_html,
37
- ingest_selected_papers,
38
- parse_uploaded_pdf,
39
- render_parse_result,
40
- run_paper_discovery,
41
- safe_text,
42
- )
43
-
44
- # ── Constants ─────────────────────────────────────────────────────────────
45
- MODELS = [
46
- {
47
- "name": "DVNC Sovereign",
48
- "tag": "flagship",
49
- "desc": "Maximum depth orchestration for frontier discovery",
50
- },
51
- {
52
- "name": "DVNC Atlas",
53
- "tag": "research",
54
- "desc": "Balanced reasoning, graph traversal, and synthesis",
55
- },
56
- {
57
- "name": "DVNC Curie",
58
- "tag": "lab",
59
- "desc": "Experimental hypothesis generation for anomalous signals",
60
- },
61
- ]
62
-
63
- AGENTS = [
64
- "Query Interpreter",
65
- "Graph Divergence Mapper",
66
- "Evidence Harvester",
67
- "Analogy Engine",
68
- "Hypothesis Composer",
69
- "Adversarial Critic",
70
- "Experimental Program Designer",
71
- ]
72
-
73
- NODES = [
74
- {"id": "seed", "label": "Seed Query", "group": "core", "x": 10, "y": 0, "z": 0},
75
- {"id": "bio", "label": "Biomaterials", "group": "domain", "x": 24, "y": 12, "z": -8},
76
- {"id": "card", "label": "Cardiac Repair", "group": "domain", "x": 38, "y": 3, "z": 14},
77
- {"id": "nano", "label": "Nanostructure", "group": "bridge", "x": 24, "y": -18, "z": 16},
78
- {"id": "selfasm", "label": "Self-Assembly", "group": "bridge", "x": 40, "y": -16, "z": -16},
79
- {"id": "electro", "label": "Electro-signalling", "group": "mechanism", "x": 58, "y": 10, "z": -10},
80
- {"id": "immune", "label": "Immune Modulation", "group": "mechanism", "x": 64, "y": -8, "z": 10},
81
- {"id": "trial", "label": "Validation Path", "group": "outcome", "x": 80, "y": 0, "z": 0},
82
- {"id": "alt1", "label": "Piezoelectric Scaffold", "group": "candidate", "x": 56, "y": 26, "z": 14},
83
- {"id": "alt2", "label": "Peptide Mesh", "group": "candidate", "x": 54, "y": -27, "z": -14},
84
- ]
85
-
86
- EDGES = [
87
- ("seed", "bio"), ("seed", "nano"), ("bio", "card"), ("nano", "selfasm"),
88
- ("selfasm", "electro"), ("card", "immune"), ("electro", "trial"),
89
- ("immune", "trial"), ("card", "alt1"), ("selfasm", "alt2"),
90
- ("alt1", "trial"), ("alt2", "trial"),
91
- ]
92
-
93
- DEFAULT_PATH = ["seed", "nano", "selfasm", "electro", "trial"]
94
-
95
- CANDIDATES = [
96
- {
97
- "title": "Piezoelectric Scaffold Cascade",
98
- "front": "Use mechano-electric scaffolds to convert cardiac strain into micro-current signalling.",
99
- "back": "Discovery path: anomalous healing signal -> piezoelectric analog -> ion-channel entrainment -> tissue regeneration. Risk: power density and fibrosis coupling.",
100
- "score": 92,
101
- "novelty": "High",
102
- "agent": "Hypothesis Composer",
103
- },
104
- {
105
- "title": "Peptide Self-Assembly Mesh",
106
- "front": "Deploy dynamic peptide meshes that self-assemble around damaged myocardium and guide repair.",
107
- "back": "Discovery path: self-assembly -> local immune choreography -> regenerative substrate formation. Risk: degradation timing and targeting specificity.",
108
- "score": 88,
109
- "novelty": "High",
110
- "agent": "Analogy Engine",
111
- },
112
- {
113
- "title": "Immune-Tuned Conductive Hydrogel",
114
- "front": "Blend conductivity with macrophage-state modulation to reduce scarring and restore conduction.",
115
- "back": "Discovery path: inflammation mismatch -> conductive medium -> macrophage polarization -> synchronized healing. Risk: persistence and biocompatibility.",
116
- "score": 85,
117
- "novelty": "Medium-High",
118
- "agent": "Adversarial Critic",
119
- },
120
- ]
121
-
122
- ACADEMIC_INSIGHTS = [
123
- {
124
- "hypothesis": "Implementation of mechano-electric scaffolds to transduce cardiac strain into localized micro-current signalling for myocardial regeneration.",
125
- "metrics": {
126
- "Novelty": 92,
127
- "Mechanistic clarity": 85,
128
- "Experimental tractability": 78,
129
- "Cross-domain distance": 94,
130
- },
131
- "outline": (
132
- "1. Synthesize candidate piezoelectric biomaterial scaffolds with tunable strain-electric coupling.\n"
133
- "2. Evaluate in vitro electromechanical transduction and subsequent ion-channel entrainment.\n"
134
- "3. Conduct in vivo comparative models to assess regenerative efficacy against gold-standard substrates.\n"
135
- "4. Rigorously validate to exclude pathological fibrosis and power-density toxicity."
136
- ),
137
- "path": ["seed", "bio", "card", "alt1", "trial"],
138
- },
139
- {
140
- "hypothesis": "Deployment of dynamic peptide networks that self-assemble post-infarction to orchestrate local immunological responses and guide substrate regeneration.",
141
- "metrics": {
142
- "Novelty": 88,
143
- "Mechanistic clarity": 82,
144
- "Experimental tractability": 86,
145
- "Cross-domain distance": 85,
146
- },
147
- "outline": (
148
- "1. Formulate peptide sequences programmed for triggered in situ self-assembly within the myocardial infarct zone.\n"
149
- "2. Quantify macrophage polarization and local immune choreography post-deployment.\n"
150
- "3. Map the temporospatial degradation profile against de novo tissue formation.\n"
151
- "4. Falsify against off-target aggregation and delayed clearance risks."
152
- ),
153
- "path": ["seed", "nano", "selfasm", "alt2", "trial"],
154
- },
155
- {
156
- "hypothesis": "Integration of conductive hydrogels with immunomodulatory properties to simultaneously bridge electrical uncoupling and mitigate adverse fibrotic scarring.",
157
- "metrics": {
158
- "Novelty": 85,
159
- "Mechanistic clarity": 90,
160
- "Experimental tractability": 88,
161
- "Cross-domain distance": 79,
162
- },
163
- "outline": (
164
- "1. Fabricate biocompatible hydrogels featuring precisely tuned electrical conductivity and immunomodulatory motifs.\n"
165
- "2. Monitor electrophysiological synchronization across the scaffold-tissue interface.\n"
166
- "3. Assess macrophage state transitions and suppression of adverse fibrotic remodelling.\n"
167
- "4. Validate long-term persistence, hemocompatibility, and mechanical integration."
168
- ),
169
- "path": ["seed", "bio", "card", "immune", "trial"],
170
- },
171
- ]
172
-
173
- # ── Utility helpers ───────────────────────────────────────────────────────
174
-
175
- def norm_text(x: Optional[str]) -> str:
176
- return re.sub(r"\s+", " ", (x or "")).strip()
177
-
178
-
179
- def build_learning_graph_html(nodes, edges, title="Self-Learning Knowledge Graph"):
180
- return render_graph_canvas_html(
181
- {
182
- "status": "ok" if (nodes or edges) else "empty",
183
- "nodes": nodes or [],
184
- "edges": edges or [],
185
- },
186
- title=title,
187
- height=780,
188
- )
189
-
190
- # ── HTML builders ─────────────────────────────────────────────────────────
191
-
192
- def build_connectome_html(path_ids: List[str]) -> str:
193
- active = set(path_ids)
194
- node_map = {n["id"]: n for n in NODES}
195
- path_pairs = {
196
- pair
197
- for i in range(len(path_ids) - 1)
198
- for pair in [(path_ids[i], path_ids[i + 1]), (path_ids[i + 1], path_ids[i])]
199
- }
200
- baselines, activelines, circles, labels = [], [], [], []
201
- for a, b in EDGES:
202
- na, nb = node_map[a], node_map[b]
203
- x1, y1 = na["x"] * 8 + 80, na["y"] * 6 + 280
204
- x2, y2 = nb["x"] * 8 + 80, nb["y"] * 6 + 280
205
- baselines.append(f'e class="edge" x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}"/>')
206
- if (a, b) in path_pairs:
207
- activelines.append(f'e class="edge active" x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}"/>')
208
- for n in NODES:
209
- cx, cy = n["x"] * 8 + 80, n["y"] * 6 + 280
210
- is_active = n["id"] in active
211
- state = "chosen" if is_active else "idle"
212
- halo_cls = "halo active" if is_active else "halo"
213
- lbl_cls = "label active" if is_active else "label"
214
- radius = 18 if is_active else 13
215
- halo_r = 30 if is_active else 0
216
- circles.append(
217
- f'ircle class="{halo_cls}" cx="{cx}" cy="{cy}" r="{halo_r}"/>'
218
- f'ircle class="node {state}" cx="{cx}" cy="{cy}" r="{radius}"/>'
219
- f'<title>{safe_text(n["label"])}</title>'
220
- f'<text class="{lbl_cls}" x="{cx}" y="{cy + radius + 14}" text-anchor="middle">{safe_text(n["label"][:18])}</text>'
221
- )
222
- return f"""<div class="brain-shell panel">
223
- <div class="brain-header">
224
- <div><p class="eyebrow">Connectome</p><h3>3D Connectome</h3></div>
225
- <div class="brain-legend">
226
- <span><span class="dot dot-live"></span>lit path</span>
227
- <span><span class="dot dot-chosen"></span>chosen node</span>
228
- <span><span class="dot dot-idle"></span>available node</span>
229
- </div></div>
230
- <div class="brain-stage">
231
- <svg class="brain-svg" viewBox="0 0 880 560">
232
- {''.join(baselines)} {''.join(activelines)} {''.join(circles)}
233
- </svg></div></div>"""
234
-
235
-
236
- def build_cards_html(cards: List[Dict]) -> str:
237
- items = []
238
- for i, c in enumerate(cards):
239
- items.append(
240
- f"""<div class="candidate-card" tabindex="0">
241
- <div class="candidate-card-inner">
242
- <div class="candidate-face">
243
- <div class="candidate-top"><span class="chip">{safe_text(c["agent"])}</span><span class="score">{safe_text(c["score"])}</span></div>
244
- <h4>{safe_text(c["title"])}</h4>
245
- <p>{safe_text(c["front"])}</p>
246
- <div class="meta-row"><span>Novelty <strong>{safe_text(c["novelty"])}</strong></span></div>
247
- <button class="mini" onclick="triggerRouteSwap({i})">Use as main insight</button>
248
- </div>
249
- <div class="candidate-face candidate-back">
250
- <div class="candidate-top"><span class="chip alt">Alternative path</span><span class="score">{safe_text(c["score"])}</span></div>
251
- <h4>{safe_text(c["title"])}</h4>
252
- <p>{safe_text(c["back"])}</p>
253
- <div class="meta-row"><span>Swap into route <strong>Enabled</strong></span></div>
254
- <button class="mini" onclick="triggerRouteSwap({i})">Use as main insight</button>
255
- </div>
256
- </div></div>"""
257
- )
258
- return '<div class="candidate-grid">' + "".join(items) + "</div>"
259
-
260
-
261
- def build_chat_html(query: str, result: Dict) -> str:
262
- return f"""<div class="chat-panel panel"><div class="chat-thread">
263
- <div class="bubble bubble-user"><span class="role">You</span><p>{safe_text(query)}</p></div>
264
- <div class="bubble bubble-ai"><span class="role">DVNC Sovereign</span><p>{safe_text(result["summary"])}</p></div>
265
- <div class="bubble bubble-system"><span class="role">Discovery Signal</span>
266
- <p><strong>Primary hypothesis:</strong> {safe_text(result["primary_hypothesis"])}</p>
267
- </div></div></div>"""
268
-
269
-
270
- def build_models_html(selected: str) -> str:
271
- items = []
272
- for m in MODELS:
273
- active = "active" if m["name"] == selected else ""
274
- items.append(
275
- f'<div class="model-pill {active}"><span class="model-name">{safe_text(m["name"])}</span>'
276
- f'<span class="model-tag">{safe_text(m["tag"])}</span>'
277
- f'<small>{safe_text(m["desc"])}</small></div>'
278
- )
279
- return '<div class="model-switcher">' + "".join(items) + "</div>"
280
-
281
- # ── Discovery logic ───────────────────────────────────────────────────────
282
-
283
- def run_discovery(query: str, model_name: str):
284
- random.seed(len(query or "") + len(model_name or ""))
285
- if "curie" in (query or "").lower() or "einstein" in (query or "").lower():
286
- primary = "Map the anomaly first, then force a distant analogy before composing the experimental programme."
287
- path = ["seed", "bio", "card", "immune", "trial"]
288
- else:
289
- primary = (
290
- "Utilization of a self-assembling conductive scaffold to transduce mechanical "
291
- "strain into localized regenerative signalling pathways."
292
- )
293
- path = DEFAULT_PATH
294
-
295
- summaries = [
296
- "Normalises the user prompt into a graph-searchable seed and isolates the tension inside the question.",
297
- "Finds remote conceptual bridges instead of staying near the starting domain cluster.",
298
- "Pulls evidence packets and conflict signals required for grounded hypothesis formation.",
299
- "Generates cross-domain analogies with a bias toward mechanism transfer rather than keyword similarity.",
300
- "Composes the lead hypothesis and two structurally different variants.",
301
- "Attacks weak assumptions, hidden confounders, and feasibility gaps.",
302
- "Produces a staged validation plan with measurable falsification criteria.",
303
- ]
304
- tags = ["input", "graph", "evidence", "analogy", "compose", "critique", "experiment"]
305
- reasoning = [
306
- {"step": i + 1, "agent": AGENTS[i], "tag": tags[i], "summary": summaries[i]}
307
- for i in range(7)
308
- ]
309
- result = {
310
- "summary": (
311
- "A deeper route was chosen through the connectome, with live alternatives preserved "
312
- "as swappable cards so the reasoning path can be inspected rather than hidden."
313
- ),
314
- "primary_hypothesis": primary,
315
- "reasoning": reasoning,
316
- "cards": CANDIDATES,
317
- "path": path,
318
- "metrics": {
319
- "Novelty": 93,
320
- "Mechanistic clarity": 89,
321
- "Experimental tractability": 82,
322
- "Cross-domain distance": 91,
323
- },
324
- }
325
- chat_html = build_chat_html(query, result)
326
- connectome_html = build_connectome_html(path)
327
- timeline_html = build_agent_route_cards_html(reasoning)
328
- metrics_md = "\n".join(f"- {k}: {v}/100" for k, v in result["metrics"].items())
329
- hypothesis_md = (
330
- "# Discovery Output\n\n"
331
- f"**Model:** {model_name}\n\n"
332
- f"**Primary hypothesis:** {result['primary_hypothesis']}\n\n"
333
- "## Scoring\n"
334
- f"{metrics_md}\n\n"
335
- "## Experimental outline\n"
336
- "1. Construct the candidate material or protocol.\n"
337
- "2. Test mechanistic signal expression under controlled conditions.\n"
338
- "3. Compare against baseline and nearest-neighbour alternatives.\n"
339
- "4. Falsify using the adversarial risk criteria surfaced in the reasoning path.\n"
340
- )
341
- cards_html = build_cards_html(CANDIDATES)
342
- route_state = get_default_route_state()
343
- return (
344
- chat_html,
345
- connectome_html,
346
- timeline_html,
347
- cards_html,
348
- hypothesis_md,
349
- build_models_html(model_name),
350
- route_state,
351
- )
352
-
353
-
354
- def apply_route_swap(query: str, model_name: str, route_swap_payload: str, route_state):
355
- try:
356
- idx = int(route_swap_payload)
357
- except Exception:
358
- idx = 0
359
- if not (0 <= idx < len(ACADEMIC_INSIGHTS)):
360
- idx = 0
361
- academic = ACADEMIC_INSIGHTS[idx]
362
- connectome_html = build_connectome_html(academic["path"])
363
- result = {
364
- "summary": (
365
- "Main insight formally adopted. The connectome pathway and validation protocol "
366
- "have been realigned to the selected candidate methodology."
367
- ),
368
- "primary_hypothesis": academic["hypothesis"],
369
- }
370
- chat_html = build_chat_html(query, result)
371
- metrics_md = "\n".join(f"- {k}: {v}/100" for k, v in academic["metrics"].items())
372
- hypothesis_md = (
373
- "# Discovery Output\n\n"
374
- f"**Model:** {model_name}\n\n"
375
- f"**Primary hypothesis:** {academic['hypothesis']}\n\n"
376
- "## Scoring\n"
377
- f"{metrics_md}\n\n"
378
- "## Experimental outline\n"
379
- f"{academic['outline']}\n"
380
- )
381
- return chat_html, connectome_html, gr.update(), hypothesis_md, route_state
382
-
383
- # ── Example loaders ──────────────────────────────────────────────────────
384
-
385
- def load_example() -> str:
386
- return "How could a self-assembling conductive biomaterial improve cardiac tissue regeneration by converting mechanical strain into repair signalling?"
387
-
388
-
389
- def load_paper_topic() -> str:
390
- return "self-assembling conductive biomaterials for cardiac repair"
391
-
392
- # ── CSS / HEAD ────────────────────────────────────────────────────────────
393
-
394
- BASE_CSS = r"""
395
- :root {
396
- --bg:#ffffff; --panel:#ffffff; --line:rgba(0,0,0,.12); --text:#111111;
397
- --muted:#5b5b5b; --soft:rgba(0,0,0,.62); --gold:#ff6600; --teal:#17b8a6;
398
- --blue:#628dff; --chosen:#ff7a1a; --idle:#b8d8ff; --idle-stroke:#5e8fe6;
399
- --query-node:#ffd8b3; --paper-node:#d7f6f2; --upload-node:#e7defe;
400
- --shadow:0 16px 40px rgba(0,0,0,.12);
401
- }
402
- html,body,.gradio-container{background:#ffffff !important;font-family:Inter,ui-sans-serif,system-ui,sans-serif;}
403
- .gradio-container{max-width:1640px !important;padding:20px !important;}
404
- #dvnc-shell{border:1px solid var(--line);border-radius:28px;overflow:hidden;background:#ffffff;box-shadow:var(--shadow);padding:20px 22px 22px;}
405
- .hero-bar{display:flex;justify-content:space-between;align-items:center;gap:16px;padding-bottom:12px;border-bottom:1px solid rgba(0,0,0,.06);margin-bottom:16px;}
406
- .brand{display:flex;align-items:center;gap:14px;}
407
- .logo{width:42px;height:42px;border-radius:14px;display:grid;place-items:center;color:var(--gold);background:linear-gradient(135deg,rgba(255,122,26,.12),rgba(23,184,166,.10));border:1px solid rgba(0,0,0,.08);}
408
- .brand h1{font-size:1.05rem;margin:0;font-weight:700;letter-spacing:.12em;text-transform:uppercase;}
409
- .brand p{margin:3px 0 0;color:var(--muted);font-size:.84rem;}
410
- .status{display:flex;gap:10px;align-items:center;color:var(--soft);font-size:.85rem;}
411
- .status-dot{width:10px;height:10px;border-radius:50%;background:var(--teal);box-shadow:0 0 0 6px rgba(23,184,166,.10),0 0 14px rgba(23,184,166,.25);}
412
- .panel{background:#ffffff;border:1px solid var(--line);border-radius:22px;box-shadow:inset 0 1px 0 rgba(255,255,255,.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app_old5.py DELETED
@@ -1,52 +0,0 @@
1
- """
2
- DVNC.AI — root app.py
3
- Fixed: corrected all imports to use dvnc_ai_v2_hf package with proper underscore function names.
4
- """
5
-
6
- # ── Standard library ──────────────────────────────────────────────────────
7
- import html
8
- import random
9
- import re
10
- import sys
11
- from pathlib import Path
12
- from typing import Dict, List, Optional
13
-
14
- # Ensure repository root is on sys.path
15
- ROOT = Path(__file__).resolve().parent
16
- if str(ROOT) not in sys.path:
17
- sys.path.insert(0, str(ROOT))
18
-
19
- # ── Third-party ───────────────────────────────────────────────────────────
20
- import gradio as gr
21
-
22
- # ── Internal modules ──────────────────────────────────────────────────────
23
- from dvnc_ai_v2_hf.agent_route_cards import build_agent_route_cards_html
24
- from dvnc_ai_v2_hf.discovery_app_bridge import (
25
- get_default_route_state,
26
- get_discovery_css,
27
- get_initial_discovery_timeline_html,
28
- )
29
- from dvnc_ai_v2_hf.dvnc_ui_layout import get_dvnc_layout_css
30
- from dvnc_ai_v2_hf.graph_canvas_patch import render_graph_canvas_html
31
- from dvnc_ai_v2_hf.self_learning_graph import (
32
- DEFAULT_SOURCES,
33
- SEARCH_MODES,
34
- SOURCE_OPTIONS,
35
- build_journal_html,
36
- ingest_selected_papers,
37
- parse_uploaded_pdf,
38
- render_parse_result,
39
- run_paper_discovery,
40
- safe_text,
41
- )
42
-
43
- # ── Simplified launcher: use existing app from dvnc_ai_v2_hf ─────────────
44
- if __name__ == "__main__":
45
- try:
46
- from dvnc_ai_v2_hf import app as internal_app
47
- internal_app.demo.launch()
48
- except (ImportError, AttributeError):
49
- print("Could not import dvnc_ai_v2_hf.app — falling back to minimal Gradio demo")
50
- with gr.Blocks() as demo:
51
- gr.Markdown("# DVNC.AI\n\nSpace configuration in progress. Check Files tab for dvnc_ai_v2_hf/app.py")
52
- demo.launch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dvnc_ai_v2_hf/app.py CHANGED
@@ -1,18 +1,31 @@
1
- """
2
  DVNC.AI — app.py
3
- Production Gradio entrypoint for the DVNC discovery workspace.
4
- Aligned to the current dvnc_ai_v2_hf.self_learning_graph interface.
5
  """
6
-
7
  # ── Standard library ────────────────────────────────────────────────────────
 
 
 
 
8
  import random
9
  import re
 
 
 
10
  from typing import Dict, List, Optional
11
-
12
- # ── Third-party ─────────────────────────────────────────────────────────────
13
  import gradio as gr
14
-
15
- # ── Internal modules ────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
16
  from dvnc_ai_v2_hf.agent_route_cards import build_agent_route_cards_html
17
  from dvnc_ai_v2_hf.discovery_app_bridge import (
18
  get_default_route_state,
@@ -20,39 +33,24 @@ from dvnc_ai_v2_hf.discovery_app_bridge import (
20
  get_initial_discovery_timeline_html,
21
  )
22
  from dvnc_ai_v2_hf.dvnc_ui_layout import get_dvnc_layout_css
23
- from dvnc_ai_v2_hf import self_learning_graph as slg
24
  from dvnc_ai_v2_hf.self_learning_graph import (
25
  DEFAULT_SOURCES,
26
  SEARCH_MODES,
27
  SOURCE_OPTIONS,
28
- build_journal_html,
29
  build_learning_graph_html,
 
30
  ingest_selected_papers,
31
  parse_uploaded_pdf,
32
  render_parse_result,
33
  run_paper_discovery,
34
  safe_text,
35
  )
36
-
37
- # ── Constants ───────────────────────────────────────────────────────────────
38
  MODELS = [
39
- {
40
- "name": "DVNC Sovereign",
41
- "tag": "flagship",
42
- "desc": "Maximum depth orchestration for frontier discovery",
43
- },
44
- {
45
- "name": "DVNC Atlas",
46
- "tag": "research",
47
- "desc": "Balanced reasoning, graph traversal, and synthesis",
48
- },
49
- {
50
- "name": "DVNC Curie",
51
- "tag": "lab",
52
- "desc": "Experimental hypothesis generation for anomalous signals",
53
- },
54
  ]
55
-
56
  AGENTS = [
57
  "Query Interpreter",
58
  "Graph Divergence Mapper",
@@ -62,171 +60,129 @@ AGENTS = [
62
  "Adversarial Critic",
63
  "Experimental Program Designer",
64
  ]
65
-
66
  NODES = [
67
- {"id": "seed", "label": "Seed Query", "group": "core", "x": 10, "y": 0, "z": 0},
68
- {"id": "bio", "label": "Biomaterials", "group": "domain", "x": 24, "y": 12, "z": -8},
69
- {"id": "card", "label": "Cardiac Repair", "group": "domain", "x": 38, "y": 3, "z": 14},
70
- {"id": "nano", "label": "Nanostructure", "group": "bridge", "x": 24, "y": -18, "z": 16},
71
- {"id": "selfasm", "label": "Self-Assembly", "group": "bridge", "x": 40, "y": -16, "z": -16},
72
- {"id": "electro", "label": "Electro-signalling", "group": "mechanism", "x": 58, "y": 10, "z": -10},
73
- {"id": "immune", "label": "Immune Modulation", "group": "mechanism", "x": 64, "y": -8, "z": 10},
74
- {"id": "trial", "label": "Validation Path", "group": "outcome", "x": 80, "y": 0, "z": 0},
75
- {"id": "alt1", "label": "Piezoelectric Scaffold", "group": "candidate", "x": 56, "y": 26, "z": 14},
76
- {"id": "alt2", "label": "Peptide Mesh", "group": "candidate", "x": 54, "y": -27, "z": -14},
77
  ]
78
-
79
  EDGES = [
80
- ("seed", "bio"),
81
- ("seed", "nano"),
82
- ("bio", "card"),
83
- ("nano", "selfasm"),
84
- ("selfasm", "electro"),
85
- ("card", "immune"),
86
- ("electro", "trial"),
87
- ("immune", "trial"),
88
- ("card", "alt1"),
89
- ("selfasm", "alt2"),
90
- ("alt1", "trial"),
91
- ("alt2", "trial"),
92
  ]
93
-
94
  DEFAULT_PATH = ["seed", "nano", "selfasm", "electro", "trial"]
95
-
96
  CANDIDATES = [
97
  {
98
- "title": "Piezoelectric Scaffold Cascade",
99
- "front": "Use mechano-electric scaffolds to convert cardiac strain into micro-current signalling.",
100
- "back": "Discovery path: anomalous healing signal -> piezoelectric analog -> ion-channel entrainment -> tissue regeneration. Risk: power density and fibrosis coupling.",
101
- "score": 92,
102
  "novelty": "High",
103
- "agent": "Hypothesis Composer",
104
  },
105
  {
106
- "title": "Peptide Self-Assembly Mesh",
107
- "front": "Deploy dynamic peptide meshes that self-assemble around damaged myocardium and guide repair.",
108
- "back": "Discovery path: self-assembly -> local immune choreography -> regenerative substrate formation. Risk: degradation timing and targeting specificity.",
109
- "score": 88,
110
  "novelty": "High",
111
- "agent": "Analogy Engine",
112
  },
113
  {
114
- "title": "Immune-Tuned Conductive Hydrogel",
115
- "front": "Blend conductivity with macrophage-state modulation to reduce scarring and restore conduction.",
116
- "back": "Discovery path: inflammation mismatch -> conductive medium -> macrophage polarization -> synchronized healing. Risk: persistence and biocompatibility.",
117
- "score": 85,
118
  "novelty": "Medium-High",
119
- "agent": "Adversarial Critic",
120
  },
121
  ]
122
-
123
  ACADEMIC_INSIGHTS = [
124
  {
125
  "hypothesis": "Implementation of mechano-electric scaffolds to transduce cardiac strain into localized micro-current signalling for myocardial regeneration.",
126
- "metrics": {
127
- "Novelty": 92,
128
- "Mechanistic clarity": 85,
129
- "Experimental tractability": 78,
130
- "Cross-domain distance": 94,
131
- },
132
- "outline": (
133
- "1. Synthesize candidate piezoelectric biomaterial scaffolds with tunable strain-electric coupling.\n"
134
- "2. Evaluate in vitro electromechanical transduction and subsequent ion-channel entrainment.\n"
135
- "3. Conduct in vivo comparative models to assess regenerative efficacy against gold-standard substrates.\n"
136
- "4. Rigorously validate to exclude pathological fibrosis and power-density toxicity."
137
- ),
138
- "path": ["seed", "bio", "card", "alt1", "trial"],
139
  },
140
  {
141
  "hypothesis": "Deployment of dynamic peptide networks that self-assemble post-infarction to orchestrate local immunological responses and guide substrate regeneration.",
142
- "metrics": {
143
- "Novelty": 88,
144
- "Mechanistic clarity": 82,
145
- "Experimental tractability": 86,
146
- "Cross-domain distance": 85,
147
- },
148
- "outline": (
149
- "1. Formulate peptide sequences programmed for triggered in situ self-assembly within the myocardial infarct zone.\n"
150
- "2. Quantify macrophage polarization and local immune choreography post-deployment.\n"
151
- "3. Map the temporospatial degradation profile against de novo tissue formation.\n"
152
- "4. Falsify against off-target aggregation and delayed clearance risks."
153
- ),
154
- "path": ["seed", "nano", "selfasm", "alt2", "trial"],
155
  },
156
  {
157
  "hypothesis": "Integration of conductive hydrogels with immunomodulatory properties to simultaneously bridge electrical uncoupling and mitigate adverse fibrotic scarring.",
158
- "metrics": {
159
- "Novelty": 85,
160
- "Mechanistic clarity": 90,
161
- "Experimental tractability": 88,
162
- "Cross-domain distance": 79,
163
- },
164
- "outline": (
165
- "1. Fabricate biocompatible hydrogels featuring precisely tuned electrical conductivity and immunomodulatory motifs.\n"
166
- "2. Monitor electrophysiological synchronization across the scaffold-tissue interface.\n"
167
- "3. Assess macrophage state transitions and suppression of adverse fibrotic remodelling.\n"
168
- "4. Validate long-term persistence, hemocompatibility, and mechanical integration."
169
- ),
170
- "path": ["seed", "bio", "card", "immune", "trial"],
171
- },
172
  ]
173
-
174
- PDF_PARSER_OPTIONS = getattr(slg, "PDF_PARSERS", ["pymupdf"])
175
-
176
-
177
- # ── Utility helpers ─────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
178
  def norm_text(x: Optional[str]) -> str:
179
- return re.sub(r"\s+", " ", (x or "")).strip()
180
-
181
-
182
- def build_metrics_markdown(metrics: Dict[str, int]) -> str:
183
- return "\n".join(f"- {key}: {value}/100" for key, value in metrics.items())
184
-
185
-
186
- # ── HTML builders ────────────────────────────────────────────────────────────
 
 
 
187
  def build_connectome_html(path_ids: List[str]) -> str:
188
- active = set(path_ids)
189
- node_map = {n["id"]: n for n in NODES}
190
-
191
  path_pairs = {
192
  pair
193
  for i in range(len(path_ids) - 1)
194
  for pair in [(path_ids[i], path_ids[i + 1]), (path_ids[i + 1], path_ids[i])]
195
  }
196
-
197
  base_lines, active_lines, circles, labels = [], [], [], []
198
-
199
  for a, b in EDGES:
200
  na, nb = node_map[a], node_map[b]
201
  x1, y1 = na["x"] * 8 + 80, na["y"] * 6 + 280
202
  x2, y2 = nb["x"] * 8 + 80, nb["y"] * 6 + 280
203
- base_lines.append(
204
- f'<line class="edge" x1="{x1:.1f}" y1="{y1:.1f}" x2="{x2:.1f}" y2="{y2:.1f}" />'
205
- )
206
  if (a, b) in path_pairs:
207
- active_lines.append(
208
- f'<line class="edge active" x1="{x1:.1f}" y1="{y1:.1f}" x2="{x2:.1f}" y2="{y2:.1f}" />'
209
- )
210
-
211
  for n in NODES:
212
- cx, cy = n["x"] * 8 + 80, n["y"] * 6 + 280
213
  is_active = n["id"] in active
214
- state = "chosen" if is_active else "idle"
215
  halo_cls = "halo active" if is_active else "halo"
216
- lbl_cls = "label active" if is_active else "label"
217
- radius = 18 if is_active else 13
218
- halo_r = 30 if is_active else 0
219
-
220
  circles.append(
221
- f'<g class="node-wrap">'
222
- f'<circle class="{halo_cls}" cx="{cx:.1f}" cy="{cy:.1f}" r="{halo_r}" />'
223
- f'<circle class="node {n["group"]} {state}" cx="{cx:.1f}" cy="{cy:.1f}" r="{radius}" />'
224
- f"</g>"
225
- )
226
- labels.append(
227
- f'<text class="{lbl_cls}" x="{cx + 18:.1f}" y="{cy - 16:.1f}">{safe_text(n["label"])}</text>'
228
  )
229
-
230
  return f"""
231
  <div class="panel brain-shell">
232
  <div class="brain-header">
@@ -242,50 +198,60 @@ def build_connectome_html(path_ids: List[str]) -> str:
242
  </div>
243
  <div class="brain-stage">
244
  <svg viewBox="0 0 780 560" class="brain-svg" role="img" aria-label="DVNC 3D connectome visualisation">
245
- {''.join(base_lines)}
246
- {''.join(active_lines)}
247
- {''.join(circles)}
248
- {''.join(labels)}
249
  </svg>
250
  </div>
251
  </div>
252
  """
253
-
254
-
255
  def build_cards_html(cards: List[Dict]) -> str:
256
  items = []
257
  for i, c in enumerate(cards):
258
- items.append(
259
- f"""
260
- <article class="candidate-card" tabindex="0">
261
- <div class="candidate-card-inner">
262
- <div class="candidate-face candidate-front">
263
- <div class="candidate-top">
264
- <span class="chip">{safe_text(c["agent"])}</span>
265
- <span class="score">{safe_text(c["score"])}</span>
266
- </div>
267
- <h4>{safe_text(c["title"])}</h4>
268
- <p>{safe_text(c["front"])}</p>
269
- <div class="meta-row"><span>Novelty</span><strong>{safe_text(c["novelty"])}</strong></div>
270
- <button class="mini" type="button" onclick="triggerRouteSwap('{i}')">Use as main insight</button>
271
- </div>
272
- <div class="candidate-face candidate-back">
273
- <div class="candidate-top">
274
- <span class="chip alt">Alternative path</span>
275
- <span class="score">{safe_text(c["score"])}</span>
276
- </div>
277
- <h4>{safe_text(c["title"])}</h4>
278
- <p>{safe_text(c["back"])}</p>
279
- <div class="meta-row"><span>Swap into route</span><strong>Enabled</strong></div>
280
- <button class="mini" type="button" onclick="triggerRouteSwap('{i}')">Use as main insight</button>
281
- </div>
282
  </div>
283
- </article>
284
- """
285
- )
286
- return '<div class="panel" style="padding:20px;"><div class="candidate-grid">' + "".join(items) + "</div></div>"
287
-
288
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
289
  def build_chat_html(query: str, result: Dict) -> str:
290
  return f"""
291
  <div class="panel chat-panel">
@@ -305,41 +271,29 @@ def build_chat_html(query: str, result: Dict) -> str:
305
  </div>
306
  </div>
307
  """
308
-
309
-
310
  def build_models_html(selected: str) -> str:
311
  items = []
312
  for m in MODELS:
313
  active = "active" if m["name"] == selected else ""
314
- items.append(
315
- f"""
316
  <div class="model-pill {active}">
317
  <span class="model-name">{safe_text(m["name"])}</span>
318
  <span class="model-tag">{safe_text(m["tag"])}</span>
319
  <small>{safe_text(m["desc"])}</small>
320
- </div>
321
- """
322
- )
323
- return '<div class="panel" style="padding:18px;"><div class="model-switcher">' + "".join(items) + "</div></div>"
324
-
325
-
326
- # ── Discovery logic ──────────────────────────────────────────────────────────
327
  def run_discovery(query: str, model_name: str):
328
- query_text = norm_text(query)
329
- model_text = norm_text(model_name) or "DVNC Sovereign"
330
-
331
- random.seed(len(query_text) + len(model_text))
332
-
333
- if "curie" in query_text.lower() or "einstein" in query_text.lower():
334
  primary = "Map the anomaly first, then force a distant analogy before composing the experimental programme."
335
- path = ["seed", "bio", "card", "immune", "trial"]
336
  else:
337
- primary = (
338
- "Utilization of a self-assembling conductive scaffold to transduce mechanical strain "
339
- "into localized regenerative signalling pathways."
340
- )
341
- path = DEFAULT_PATH
342
-
343
  summaries = [
344
  "Normalises the user prompt into a graph-searchable seed and isolates the tension inside the question.",
345
  "Finds remote conceptual bridges instead of staying near the starting domain cluster.",
@@ -350,912 +304,240 @@ def run_discovery(query: str, model_name: str):
350
  "Produces a staged validation plan with measurable falsification criteria.",
351
  ]
352
  tags = ["input", "graph", "evidence", "analogy", "compose", "critique", "experiment"]
353
-
354
  reasoning = [
355
  {"step": i + 1, "agent": AGENTS[i], "tag": tags[i], "summary": summaries[i]}
356
  for i in range(7)
357
  ]
358
-
359
  result = {
360
- "summary": (
361
- "A deeper route was chosen through the connectome, with live alternatives preserved "
362
- "as swappable cards so the reasoning path can be inspected rather than hidden."
363
- ),
364
  "primary_hypothesis": primary,
365
- "reasoning": reasoning,
366
- "cards": CANDIDATES,
367
- "path": path,
368
  "metrics": {
369
- "Novelty": 93,
370
- "Mechanistic clarity": 89,
371
  "Experimental tractability": 82,
372
- "Cross-domain distance": 91,
373
  },
374
  }
375
-
376
- chat_html = build_chat_html(query_text, result)
377
  connectome_html = build_connectome_html(path)
378
- timeline_html = build_agent_route_cards_html(reasoning)
379
- metrics_md = build_metrics_markdown(result["metrics"])
380
- cards_html = build_cards_html(CANDIDATES)
381
- route_state = get_default_route_state()
382
-
383
- hypothesis_md = (
384
- "# Discovery Output\n\n"
385
- f"**Model:** {model_text}\n\n"
386
- f"**Primary hypothesis:** {result['primary_hypothesis']}\n\n"
387
- "## Scoring\n"
388
- f"{metrics_md}\n\n"
389
- "## Experimental outline\n"
390
- "1. Construct the candidate material or protocol.\n"
391
- "2. Test mechanistic signal expression under controlled conditions.\n"
392
- "3. Compare against baseline and nearest-neighbour alternatives.\n"
393
- "4. Falsify using the adversarial risk criteria surfaced in the reasoning path.\n"
394
- )
395
-
396
- return (
397
- chat_html,
398
- connectome_html,
399
- timeline_html,
400
- cards_html,
401
- hypothesis_md,
402
- build_models_html(model_text),
403
- route_state,
404
  )
405
-
406
-
 
407
  def apply_route_swap(query: str, model_name: str, route_swap_payload: str, route_state):
 
 
 
 
408
  try:
409
- idx = int((route_swap_payload or "").strip())
410
- except Exception:
411
  idx = 0
412
-
413
  if not (0 <= idx < len(ACADEMIC_INSIGHTS)):
414
  idx = 0
415
-
416
  academic = ACADEMIC_INSIGHTS[idx]
 
 
417
  connectome_html = build_connectome_html(academic["path"])
418
-
 
419
  result = {
420
- "summary": (
421
- "Main insight formally adopted. The connectome pathway and validation protocol "
422
- "have been realigned to the selected candidate methodology."
423
- ),
424
- "primary_hypothesis": academic["hypothesis"],
425
  }
426
- chat_html = build_chat_html(norm_text(query), result)
427
- metrics_md = build_metrics_markdown(academic["metrics"])
428
-
 
429
  hypothesis_md = (
430
- "# Discovery Output\n\n"
431
- f"**Model:** {norm_text(model_name) or 'DVNC Sovereign'}\n\n"
432
- f"**Primary hypothesis:** {academic['hypothesis']}\n\n"
433
- "## Scoring\n"
434
- f"{metrics_md}\n\n"
435
- "## Experimental outline\n"
436
- f"{academic['outline']}\n"
437
  )
438
-
439
  return chat_html, connectome_html, gr.update(), hypothesis_md, route_state
440
-
441
-
442
- # ── Example loaders ──────────────────────────────────────────────────────────
443
  def load_example() -> str:
444
- return (
445
- "How could a self-assembling conductive biomaterial improve cardiac tissue "
446
- "regeneration by converting mechanical strain into repair signalling?"
447
- )
448
-
449
-
450
  def load_paper_topic() -> str:
451
  return "self-assembling conductive biomaterials for cardiac repair"
452
-
453
-
454
- # ── CSS / HEAD ───────────────────────────────────────────────────────────────
455
  BASE_CSS = r"""
456
  :root {
457
- --bg: #ffffff;
458
- --panel: #ffffff;
459
- --line: rgba(0,0,0,.12);
460
- --text: #111111;
461
- --muted: #5b5b5b;
462
- --soft: rgba(0,0,0,.62);
463
- --gold: #ff6600;
464
- --teal: #17b8a6;
465
- --blue: #628dff;
466
- --chosen: #ff7a1a;
467
- --idle: #b8d8ff;
468
- --idle-stroke: #5e8fe6;
469
- --query-node: #ffd8b3;
470
- --paper-node: #d7f6f2;
471
- --upload-node: #e7defe;
472
  --shadow: 0 16px 40px rgba(0,0,0,.12);
473
  }
474
-
475
- html, body, .gradio-container {
476
- background: #ffffff !important;
477
- font-family: Inter, ui-sans-serif, system-ui, sans-serif;
478
- }
479
-
480
- .gradio-container {
481
- max-width: 1640px !important;
482
- padding: 20px !important;
483
- }
484
-
485
- #dvnc-shell {
486
- border: 1px solid var(--line);
487
- border-radius: 28px;
488
- overflow: hidden;
489
- background: #ffffff;
490
- box-shadow: var(--shadow);
491
- padding: 20px 22px 22px;
492
- }
493
-
494
- .hero-bar {
495
- display: flex;
496
- justify-content: space-between;
497
- align-items: center;
498
- gap: 16px;
499
- padding-bottom: 12px;
500
- border-bottom: 1px solid rgba(0,0,0,.06);
501
- margin-bottom: 16px;
502
- }
503
-
504
- .brand {
505
- display: flex;
506
- align-items: center;
507
- gap: 14px;
508
- }
509
-
510
- .logo {
511
- width: 42px;
512
- height: 42px;
513
- border-radius: 14px;
514
- display: grid;
515
- place-items: center;
516
- color: var(--gold);
517
- background: linear-gradient(135deg, rgba(255,122,26,.12), rgba(23,184,166,.10));
518
- border: 1px solid rgba(0,0,0,.08);
519
- }
520
-
521
- .logo svg {
522
- width: 24px;
523
- height: 24px;
524
- }
525
-
526
- .brand h1 {
527
- font-size: 1.05rem;
528
- margin: 0;
529
- font-weight: 700;
530
- letter-spacing: .12em;
531
- text-transform: uppercase;
532
- }
533
-
534
- .brand p {
535
- margin: 3px 0 0;
536
- color: var(--muted);
537
- font-size: .84rem;
538
- }
539
-
540
- .status {
541
- display: flex;
542
- gap: 10px;
543
- align-items: center;
544
- color: var(--soft);
545
- font-size: .85rem;
546
- }
547
-
548
- .status-dot {
549
- width: 10px;
550
- height: 10px;
551
- border-radius: 50%;
552
- background: var(--teal);
553
- box-shadow: 0 0 0 6px rgba(23,184,166,.10), 0 0 14px rgba(23,184,166,.25);
554
- }
555
-
556
- .panel {
557
- background: #ffffff;
558
- border: 1px solid var(--line);
559
- border-radius: 22px;
560
- box-shadow: inset 0 1px 0 rgba(255,255,255,.8);
561
- }
562
-
563
- .querybox textarea,
564
- .querybox input {
565
- background: transparent !important;
566
- color: var(--text) !important;
567
- }
568
-
569
- .querybox,
570
- .querybox > div {
571
- background: #ffffff !important;
572
- border-radius: 18px !important;
573
- border-color: var(--line) !important;
574
- }
575
-
576
- .chat-panel {
577
- padding: 18px;
578
- min-height: 280px;
579
- }
580
-
581
- .chat-thread {
582
- display: flex;
583
- flex-direction: column;
584
- gap: 14px;
585
- }
586
-
587
- .bubble {
588
- max-width: 88%;
589
- padding: 16px 18px;
590
- border-radius: 22px;
591
- border: 1px solid var(--line);
592
- }
593
-
594
- .bubble p {
595
- margin: 8px 0 0;
596
- line-height: 1.6;
597
- font-size: .96rem;
598
- color: var(--text);
599
- }
600
-
601
- .bubble .role {
602
- font-size: .72rem;
603
- letter-spacing: .12em;
604
- text-transform: uppercase;
605
- color: var(--muted);
606
- }
607
-
608
- .bubble-user {
609
- align-self: flex-end;
610
- background: linear-gradient(135deg, rgba(98,141,255,.16), rgba(98,141,255,.08));
611
- }
612
-
613
- .bubble-ai {
614
- align-self: flex-start;
615
- background: #ffffff;
616
- }
617
-
618
- .bubble-system {
619
- align-self: flex-start;
620
- background: linear-gradient(135deg, rgba(255,122,26,.10), rgba(255,122,26,.04));
621
- }
622
-
623
- .model-switcher {
624
- display: grid;
625
- grid-template-columns: repeat(3, 1fr);
626
- gap: 12px;
627
- }
628
-
629
- .model-pill {
630
- padding: 14px;
631
- border: 1px solid var(--line);
632
- border-radius: 18px;
633
- display: flex;
634
- flex-direction: column;
635
- gap: 4px;
636
- min-height: 98px;
637
- background: #ffffff;
638
- }
639
-
640
- .model-pill.active {
641
- border-color: rgba(255,122,26,.40);
642
- background: linear-gradient(135deg, rgba(255,122,26,.10), rgba(255,255,255,.96));
643
- }
644
-
645
- .model-name {
646
- font-weight: 650;
647
- color: var(--text);
648
- }
649
-
650
- .model-tag {
651
- font-size: .76rem;
652
- text-transform: uppercase;
653
- letter-spacing: .12em;
654
- color: var(--gold);
655
- }
656
-
657
- .model-pill small {
658
- color: var(--muted);
659
- line-height: 1.45;
660
- }
661
-
662
- .brain-shell {
663
- padding: 18px;
664
- }
665
-
666
- .brain-header {
667
- display: flex;
668
- justify-content: space-between;
669
- align-items: flex-end;
670
- gap: 16px;
671
- margin-bottom: 10px;
672
- }
673
-
674
- .eyebrow {
675
- font-size: .72rem;
676
- letter-spacing: .16em;
677
- text-transform: uppercase;
678
- color: var(--gold);
679
- margin: 0 0 4px;
680
- }
681
-
682
- .brain-header h3 {
683
- margin: 0;
684
- font-size: 1.12rem;
685
- color: var(--text);
686
- }
687
-
688
- .brain-legend {
689
- display: flex;
690
- gap: 14px;
691
- color: var(--muted);
692
- font-size: .8rem;
693
- flex-wrap: wrap;
694
- }
695
-
696
- .dot {
697
- width: 10px;
698
- height: 10px;
699
- display: inline-block;
700
- border-radius: 50%;
701
- margin-right: 6px;
702
- }
703
-
704
- .dot-live {
705
- background: var(--chosen);
706
- box-shadow: 0 0 10px rgba(255,122,26,.35);
707
- }
708
-
709
- .dot-chosen {
710
- background: var(--chosen);
711
- }
712
-
713
- .dot-idle {
714
- background: var(--idle);
715
- border: 1px solid var(--idle-stroke);
716
- }
717
-
718
- .dot-query {
719
- background: var(--query-node);
720
- border: 1px solid #de9e58;
721
- }
722
-
723
- .dot-paper {
724
- background: var(--paper-node);
725
- border: 1px solid #4fb3a5;
726
- }
727
-
728
- .dot-upload {
729
- background: var(--upload-node);
730
- border: 1px solid #8f73d9;
731
- }
732
-
733
- .brain-stage {
734
- position: relative;
735
- min-height: 420px;
736
- overflow: hidden;
737
- background: linear-gradient(180deg, rgba(250,250,250,1), rgba(255,255,255,1));
738
- border: 1px solid rgba(0,0,0,.05);
739
- border-radius: 20px;
740
- }
741
-
742
- .brain-svg {
743
- width: 100%;
744
- height: 520px;
745
- display: block;
746
- }
747
-
748
- .edge {
749
- stroke: rgba(0,0,0,.12);
750
- stroke-width: 2.4;
751
- }
752
-
753
- .edge.active {
754
- stroke: var(--chosen);
755
- stroke-width: 4.2;
756
- stroke-linecap: round;
757
- filter: drop-shadow(0 0 6px rgba(255,122,26,.45));
758
- stroke-dasharray: 8 12;
759
- animation: pulseEdge 1.5s linear infinite;
760
- }
761
-
762
- .node {
763
- stroke-width: 2.2;
764
- transition: all .25s ease;
765
- }
766
-
767
- .node.idle {
768
- fill: var(--idle);
769
- stroke: var(--idle-stroke);
770
- }
771
-
772
- .node.chosen {
773
- fill: var(--chosen);
774
- stroke: #ffb16d;
775
- }
776
-
777
- .halo {
778
- fill: none;
779
- }
780
-
781
- .halo.active {
782
- stroke: rgba(255,122,26,.18);
783
- stroke-width: 12;
784
- }
785
-
786
- .label {
787
- fill: #2c2c2c;
788
- font-size: 13px;
789
- font-weight: 500;
790
- letter-spacing: .01em;
791
- }
792
-
793
- .label.active {
794
- fill: #111111;
795
- font-weight: 700;
796
- }
797
-
798
- .learn-edge {
799
- stroke: rgba(0,0,0,.18);
800
- stroke-width: 2.2;
801
- stroke-linecap: round;
802
- }
803
-
804
- .learn-node {
805
- stroke-width: 2.2;
806
- }
807
-
808
- .learn-node.query {
809
- fill: var(--query-node);
810
- stroke: #de9e58;
811
- }
812
-
813
- .learn-node.paper {
814
- fill: var(--paper-node);
815
- stroke: #36a091;
816
- }
817
-
818
- .learn-node.upload {
819
- fill: var(--upload-node);
820
- stroke: #7e63cb;
821
- }
822
-
823
- .learn-label {
824
- fill: #1e1e1e;
825
- font-size: 12px;
826
- font-weight: 600;
827
- }
828
-
829
- .learning-empty {
830
- display: grid;
831
- place-items: center;
832
- }
833
-
834
- .empty-graph-copy {
835
- text-align: center;
836
- max-width: 440px;
837
- padding: 40px 20px;
838
- }
839
-
840
- .empty-graph-copy h4 {
841
- margin: 0 0 10px;
842
- font-size: 1.05rem;
843
- }
844
-
845
- .empty-graph-copy p {
846
- margin: 0;
847
- color: var(--muted);
848
- line-height: 1.6;
849
- }
850
-
851
- .timeline {
852
- display: flex;
853
- flex-direction: column;
854
- gap: 10px;
855
- }
856
-
857
- .agent-step {
858
- border: 1px solid var(--line);
859
- border-radius: 18px;
860
- background: #ffffff;
861
- overflow: hidden;
862
- }
863
-
864
- .agent-summary {
865
- list-style: none;
866
- display: grid;
867
- grid-template-columns: 42px 1fr;
868
- gap: 12px;
869
- align-items: center;
870
- padding: 12px;
871
- cursor: pointer;
872
- }
873
-
874
- .agent-summary::-webkit-details-marker {
875
- display: none;
876
- }
877
-
878
- .agent-index {
879
- width: 42px;
880
- height: 42px;
881
- border-radius: 14px;
882
- display: grid;
883
- place-items: center;
884
- font-weight: 700;
885
- color: var(--gold);
886
- background: rgba(255,122,26,.08);
887
- border: 1px solid rgba(255,122,26,.18);
888
- }
889
-
890
- .agent-head {
891
- display: flex;
892
- justify-content: space-between;
893
- gap: 12px;
894
- align-items: center;
895
- }
896
-
897
- .agent-head h4 {
898
- margin: 0;
899
- font-size: .98rem;
900
- color: var(--text);
901
- }
902
-
903
- .agent-head span {
904
- font-size: .72rem;
905
- letter-spacing: .12em;
906
- text-transform: uppercase;
907
- color: var(--muted);
908
- }
909
-
910
- .agent-copy {
911
- padding: 0 14px 16px 66px;
912
- }
913
-
914
- .agent-copy p {
915
- margin: 0;
916
- color: #2d2d2d;
917
- font-size: .93rem;
918
- line-height: 1.6;
919
- }
920
-
921
- .candidate-grid {
922
- display: grid;
923
- grid-template-columns: repeat(3, minmax(0,1fr));
924
- gap: 18px;
925
- }
926
-
927
- .candidate-card {
928
- background: none;
929
- perspective: 1400px;
930
- min-height: 330px;
931
- }
932
-
933
- .candidate-card-inner {
934
- position: relative;
935
- width: 100%;
936
- min-height: 330px;
937
- transition: transform .8s cubic-bezier(.2,.7,.1,1);
938
- transform-style: preserve-3d;
939
- }
940
-
941
- .candidate-card:hover .candidate-card-inner,
942
- .candidate-card:focus .candidate-card-inner,
943
- .candidate-card:focus-within .candidate-card-inner {
944
- transform: rotateY(180deg);
945
- }
946
-
947
- .candidate-face {
948
- position: absolute;
949
- inset: 0;
950
- padding: 20px;
951
- border-radius: 22px;
952
- border: 1px solid var(--line);
953
- background: #ffffff;
954
- color: var(--text);
955
- backface-visibility: hidden;
956
- box-shadow: 0 12px 24px rgba(0,0,0,.06);
957
- display: flex;
958
- flex-direction: column;
959
- gap: 14px;
960
- }
961
-
962
- .candidate-back {
963
- transform: rotateY(180deg);
964
- }
965
-
966
- .candidate-top {
967
- display: flex;
968
- justify-content: space-between;
969
- align-items: center;
970
- gap: 8px;
971
- }
972
-
973
- .chip {
974
- font-size: .72rem;
975
- text-transform: uppercase;
976
- letter-spacing: .12em;
977
- color: #0b6f66;
978
- padding: 7px 10px;
979
- border-radius: 999px;
980
- background: rgba(23,184,166,.08);
981
- border: 1px solid rgba(23,184,166,.18);
982
- }
983
-
984
- .chip.alt {
985
- color: var(--gold);
986
- background: rgba(255,122,26,.08);
987
- border-color: rgba(255,122,26,.18);
988
- }
989
-
990
- .score {
991
- font-weight: 700;
992
- color: var(--gold);
993
- }
994
-
995
- .candidate-face h4 {
996
- margin: 0;
997
- font-size: 1.08rem;
998
- line-height: 1.35;
999
- }
1000
-
1001
- .candidate-face p {
1002
- margin: 0;
1003
- color: #1e1e1e;
1004
- line-height: 1.65;
1005
- font-size: .96rem;
1006
- overflow-wrap: anywhere;
1007
- }
1008
-
1009
- .meta-row {
1010
- margin-top: auto;
1011
- display: flex;
1012
- justify-content: space-between;
1013
- color: var(--muted);
1014
- font-size: .88rem;
1015
- gap: 14px;
1016
- }
1017
-
1018
- .mini {
1019
- cursor: pointer;
1020
- margin-top: 8px;
1021
- align-self: flex-start;
1022
- color: var(--text);
1023
- padding: 10px 12px;
1024
- border-radius: 14px;
1025
- border: 1px solid var(--line);
1026
- background: #ffffff;
1027
- transition: all 0.2s;
1028
- }
1029
-
1030
- .mini:hover {
1031
- background: #f5f5f5;
1032
- border-color: var(--chosen);
1033
- }
1034
-
1035
- .papers-grid {
1036
- display: grid;
1037
- grid-template-columns: repeat(2, minmax(0,1fr));
1038
- gap: 14px;
1039
- }
1040
-
1041
- .paper-card {
1042
- border: 1px solid var(--line);
1043
- border-radius: 18px;
1044
- padding: 16px;
1045
- background: #ffffff;
1046
- }
1047
-
1048
- .paper-topline {
1049
- display: flex;
1050
- gap: 8px;
1051
- flex-wrap: wrap;
1052
- margin-bottom: 10px;
1053
- }
1054
-
1055
- .paper-badge {
1056
- font-size: .72rem;
1057
- padding: 6px 10px;
1058
- border-radius: 999px;
1059
- background: rgba(98,141,255,.08);
1060
- color: #3456b5;
1061
- border: 1px solid rgba(98,141,255,.18);
1062
- }
1063
-
1064
- .paper-badge.alt {
1065
- background: rgba(0,0,0,.04);
1066
- color: #444;
1067
- border-color: rgba(0,0,0,.08);
1068
- }
1069
-
1070
- .doi-badge {
1071
- background: rgba(255,122,26,.08);
1072
- color: #8a4105;
1073
- border-color: rgba(255,122,26,.18);
1074
- }
1075
-
1076
- .paper-card h4 {
1077
- margin: 0 0 10px;
1078
- line-height: 1.35;
1079
- font-size: 1rem;
1080
- }
1081
-
1082
- .paper-card p {
1083
- margin: 0 0 12px;
1084
- line-height: 1.6;
1085
- color: #222;
1086
- }
1087
-
1088
- .paper-links {
1089
- display: flex;
1090
- gap: 12px;
1091
- flex-wrap: wrap;
1092
- }
1093
-
1094
- .paper-meta-stack {
1095
- display: flex;
1096
- flex-direction: column;
1097
- gap: 6px;
1098
- color: #444;
1099
- margin-bottom: 12px;
1100
- font-size: .9rem;
1101
- }
1102
-
1103
- .paper-links a,
1104
- .journal-card,
1105
- .upload-note a {
1106
- color: #0b63ce;
1107
- text-decoration: none;
1108
- }
1109
-
1110
- .journal-grid {
1111
- display: grid;
1112
- grid-template-columns: repeat(2, minmax(0,1fr));
1113
- gap: 14px;
1114
- }
1115
-
1116
- .journal-card {
1117
- border: 1px solid var(--line);
1118
- border-radius: 18px;
1119
- padding: 16px;
1120
- display: flex;
1121
- justify-content: space-between;
1122
- gap: 14px;
1123
- align-items: center;
1124
- background: #ffffff;
1125
- }
1126
-
1127
- .journal-card h4 {
1128
- margin: 0 0 6px;
1129
- }
1130
-
1131
- .journal-card p {
1132
- margin: 0;
1133
- color: var(--muted);
1134
- line-height: 1.5;
1135
- }
1136
-
1137
- .upload-note {
1138
- border: 1px dashed rgba(0,0,0,.16);
1139
- border-radius: 18px;
1140
- padding: 16px;
1141
- background: rgba(0,0,0,.015);
1142
- color: #1f1f1f;
1143
- line-height: 1.6;
1144
- }
1145
-
1146
- .selection-panel {
1147
- padding: 18px;
1148
- }
1149
-
1150
- .parse-grid {
1151
- display: grid;
1152
- grid-template-columns: 1.2fr 1fr;
1153
- gap: 14px;
1154
- }
1155
-
1156
- .parse-card {
1157
- border: 1px solid var(--line);
1158
- border-radius: 18px;
1159
- padding: 16px;
1160
- background: #ffffff;
1161
- }
1162
-
1163
- .ref-list {
1164
- margin: 0;
1165
- padding-left: 18px;
1166
- }
1167
-
1168
- .ref-list li {
1169
- margin-bottom: 8px;
1170
- line-height: 1.5;
1171
- }
1172
-
1173
- .prosebox {
1174
- padding: 18px;
1175
- white-space: pre-wrap;
1176
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1177
- line-height: 1.55;
1178
- color: #1b1b1b;
1179
- }
1180
-
1181
- .gr-button-primary {
1182
- background: linear-gradient(135deg, rgba(255,122,26,.92), rgba(240,108,22,.92)) !important;
1183
- color: #ffffff !important;
1184
- border: none !important;
1185
- }
1186
-
1187
- .gr-button-secondary {
1188
- background: #ffffff !important;
1189
- color: var(--text) !important;
1190
- border: 1px solid var(--line) !important;
1191
- }
1192
-
1193
- footer {
1194
- display: none !important;
1195
- }
1196
-
1197
- @keyframes pulseEdge {
1198
- to { stroke-dashoffset: -40; }
1199
- }
1200
-
1201
- @media (max-width: 1180px) {
1202
- .model-switcher,
1203
- .candidate-grid,
1204
- .papers-grid,
1205
- .journal-grid,
1206
- .parse-grid {
1207
- grid-template-columns: 1fr;
1208
- }
1209
-
1210
- .brain-svg {
1211
- height: 460px;
1212
- }
1213
  }
1214
  """
1215
-
1216
- CSS = BASE_CSS + "\n" + get_dvnc_layout_css() + "\n" + get_discovery_css()
1217
-
1218
  HEAD = """
1219
  <link rel="preconnect" href="https://fonts.googleapis.com">
1220
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
1221
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
1222
  <script>
1223
  function triggerRouteSwap(idx) {
1224
- const container = document.getElementById("route_swap_payload");
1225
- if (!container) return;
1226
- const input = container.querySelector("textarea, input");
1227
- if (!input) return;
1228
-
1229
- input.value = String(idx);
1230
- input.dispatchEvent(new Event("input", { bubbles: true }));
1231
- input.dispatchEvent(new Event("change", { bubbles: true }));
1232
-
1233
- setTimeout(() => {
1234
- const btn = document.getElementById("route_swap_apply");
1235
- if (btn) btn.click();
1236
- }, 120);
1237
  }
1238
  </script>
1239
  """
1240
-
1241
-
1242
- # ── Gradio layout ────────────────────────────────────────────────────────────
1243
- with gr.Blocks(
1244
- css=CSS,
1245
- head=HEAD,
1246
- theme=gr.themes.Base(),
1247
- fill_height=True,
1248
- title="DVNC.AI",
1249
- ) as demo:
1250
- # Shared state
1251
- papers_state = gr.State([])
1252
- parsed_pdf_state = gr.State({})
1253
  ingest_payload_state = gr.State({})
1254
- route_state = gr.State(get_default_route_state())
1255
-
1256
- # Header
1257
- gr.HTML(
1258
- """
1259
  <div id="dvnc-shell">
1260
  <div class="hero-bar">
1261
  <div class="brand">
@@ -1274,14 +556,11 @@ with gr.Blocks(
1274
  <div class="status"><span class="status-dot"></span><span>Live orchestration</span></div>
1275
  </div>
1276
  </div>
1277
- """
1278
- )
1279
-
1280
  with gr.Tabs():
1281
- # ── Tab 1: Discovery Engine ──────────────────────────────────────────
1282
  with gr.Tab("Discovery Engine"):
1283
  model_html = gr.HTML(build_models_html("DVNC Sovereign"))
1284
-
1285
  with gr.Row():
1286
  with gr.Column(scale=2):
1287
  model = gr.Dropdown(
@@ -1296,11 +575,9 @@ with gr.Blocks(
1296
  lines=4,
1297
  )
1298
  with gr.Row():
1299
- run_btn = gr.Button("Run discovery", variant="primary")
1300
- example_btn = gr.Button("Load example", variant="secondary")
1301
-
1302
- chat = gr.HTML(
1303
- """
1304
  <div class="panel chat-panel">
1305
  <div class="chat-thread">
1306
  <div class="bubble bubble-ai">
@@ -1309,20 +586,15 @@ with gr.Blocks(
1309
  </div>
1310
  </div>
1311
  </div>
1312
- """
1313
- )
1314
-
1315
  with gr.Column(scale=3):
1316
  connectome = gr.HTML(build_connectome_html(DEFAULT_PATH))
1317
- cards = gr.HTML("")
1318
-
1319
- output = gr.Markdown("# Discovery Output\n\nAwaiting query.")
1320
  timeline = gr.HTML(get_initial_discovery_timeline_html())
1321
-
1322
  route_swap_payload = gr.Textbox(value="", visible=False, elem_id="route_swap_payload")
1323
- route_swap_apply = gr.Button("Apply route swap", visible=False, elem_id="route_swap_apply")
1324
-
1325
- # ── Tab 2: Self-Learning Graph ───────────────────────────────────────
1326
  with gr.Tab("Self-Learning Graph"):
1327
  with gr.Row():
1328
  with gr.Column(scale=2):
@@ -1342,86 +614,48 @@ with gr.Blocks(
1342
  value=DEFAULT_SOURCES,
1343
  label="Sources",
1344
  )
1345
- pdf_upload = gr.File(
1346
- label="Upload PDF papers",
1347
- file_types=[".pdf"],
1348
- file_count="single",
1349
- )
1350
-
1351
  with gr.Row():
1352
- learn_btn = gr.Button("Discover papers", variant="primary")
1353
- load_topic_btn = gr.Button("Load example topic", variant="secondary")
1354
-
1355
- upload_status = gr.Markdown("No PDF uploaded yet.")
1356
  discovery_status = gr.Markdown("### No discovery results yet.")
1357
- journal_panel = gr.HTML(build_journal_html("biomaterials cardiac repair"))
1358
-
1359
- gr.HTML(
1360
- '<div class="panel selection-panel"><h3 style="margin:0 0 12px;">Select papers to ingest</h3></div>'
1361
- )
1362
-
1363
- selection_box = gr.CheckboxGroup(
1364
- choices=[],
1365
- value=[],
1366
- label="Candidate papers",
1367
- )
1368
-
1369
  parser_order = gr.CheckboxGroup(
1370
- choices=PDF_PARSER_OPTIONS,
1371
- value=PDF_PARSER_OPTIONS,
1372
  label="Parser routing order",
1373
  )
1374
-
1375
  with gr.Row():
1376
- parse_btn = gr.Button("Parse uploaded PDF", variant="secondary")
1377
  ingest_btn = gr.Button("Ingest selected into graph", variant="primary")
1378
-
1379
  with gr.Column(scale=3):
1380
  learning_graph = gr.HTML(build_learning_graph_html([], []))
1381
- papers_panel = gr.HTML(
1382
- '<div class="panel papers-panel" style="padding:18px"><p>Search by topic, title, DOI, or link, then select papers before graph ingestion.</p></div>'
1383
- )
1384
- parse_summary = gr.Markdown("### PDF parse status\n\nAwaiting upload.")
1385
- parse_panel = gr.HTML(
1386
- '<div class="panel" style="padding:18px"><p>No parsed document yet.</p></div>'
1387
- )
1388
- ingest_summary = gr.Markdown("### Graph ingest status\n\nAwaiting paper selection.")
1389
- ingest_payload = gr.JSON(
1390
- label="Graph ingest payload",
1391
- value={"status": "empty", "nodes": [], "edges": []},
1392
- )
1393
-
1394
- # ── Event wiring ─────────────────────────────────────────────────────────
1395
  example_btn.click(fn=load_example, outputs=query)
1396
-
1397
  run_btn.click(
1398
  fn=run_discovery,
1399
  inputs=[query, model],
1400
  outputs=[chat, connectome, timeline, cards, output, model_html, route_state],
1401
  )
1402
-
1403
  route_swap_apply.click(
1404
  fn=apply_route_swap,
1405
  inputs=[query, model, route_swap_payload, route_state],
1406
  outputs=[chat, connectome, timeline, output, route_state],
1407
  )
1408
-
1409
  load_topic_btn.click(fn=load_paper_topic, outputs=paper_query)
1410
-
1411
  learn_btn.click(
1412
  fn=run_paper_discovery,
1413
  inputs=[paper_query, search_mode, source_selector, pdf_upload],
1414
- outputs=[
1415
- learning_graph,
1416
- papers_panel,
1417
- journal_panel,
1418
- upload_status,
1419
- selection_box,
1420
- papers_state,
1421
- discovery_status,
1422
- ],
1423
  )
1424
-
1425
  parse_btn.click(
1426
  fn=parse_uploaded_pdf,
1427
  inputs=[pdf_upload, parser_order],
@@ -1431,17 +665,14 @@ with gr.Blocks(
1431
  inputs=[parsed_pdf_state],
1432
  outputs=[parse_panel],
1433
  )
1434
-
1435
  ingest_btn.click(
1436
  fn=ingest_selected_papers,
1437
  inputs=[paper_query, selection_box, papers_state, pdf_upload, parsed_pdf_state],
1438
  outputs=[learning_graph, ingest_summary, ingest_payload],
1439
- ).then(
1440
- fn=lambda payload: payload,
1441
- inputs=[ingest_payload],
1442
- outputs=[ingest_payload_state],
1443
  )
1444
-
1445
-
1446
  if __name__ == "__main__":
1447
- demo.launch()
 
 
 
 
 
1
+ app_py = '''"""
2
  DVNC.AI — app.py
3
+ Refactored for functional "Use as main insight" logic with academic rigor.
 
4
  """
 
5
  # ── Standard library ────────────────────────────────────────────────────────
6
+ import html
7
+ import json
8
+ import math
9
+ import os
10
  import random
11
  import re
12
+ import urllib.parse
13
+ import xml.etree.ElementTree as ET
14
+ from pathlib import Path
15
  from typing import Dict, List, Optional
16
+ from urllib.parse import quote
17
+ # ── Third-party ─────────────────────────────────────────────────────────────
18
  import gradio as gr
19
+ import requests
20
+ try:
21
+ import fitz # PyMuPDF
22
+ except Exception:
23
+ fitz = None
24
+ try:
25
+ from bs4 import BeautifulSoup
26
+ except Exception:
27
+ BeautifulSoup = None
28
+ # ── Internal modules ─────────────────────────────────────────────────────────
29
  from dvnc_ai_v2_hf.agent_route_cards import build_agent_route_cards_html
30
  from dvnc_ai_v2_hf.discovery_app_bridge import (
31
  get_default_route_state,
 
33
  get_initial_discovery_timeline_html,
34
  )
35
  from dvnc_ai_v2_hf.dvnc_ui_layout import get_dvnc_layout_css
 
36
  from dvnc_ai_v2_hf.self_learning_graph import (
37
  DEFAULT_SOURCES,
38
  SEARCH_MODES,
39
  SOURCE_OPTIONS,
 
40
  build_learning_graph_html,
41
+ build_journal_html,
42
  ingest_selected_papers,
43
  parse_uploaded_pdf,
44
  render_parse_result,
45
  run_paper_discovery,
46
  safe_text,
47
  )
48
+ # ── Constants ────────────────────────────────────────────────────────────────
 
49
  MODELS = [
50
+ {"name": "DVNC Sovereign", "tag": "flagship", "desc": "Maximum depth orchestration for frontier discovery"},
51
+ {"name": "DVNC Atlas", "tag": "research", "desc": "Balanced reasoning, graph traversal, and synthesis"},
52
+ {"name": "DVNC Curie", "tag": "lab", "desc": "Experimental hypothesis generation for anomalous signals"},
 
 
 
 
 
 
 
 
 
 
 
 
53
  ]
 
54
  AGENTS = [
55
  "Query Interpreter",
56
  "Graph Divergence Mapper",
 
60
  "Adversarial Critic",
61
  "Experimental Program Designer",
62
  ]
 
63
  NODES = [
64
+ {"id": "seed", "label": "Seed Query", "group": "core", "x": 10, "y": 0, "z": 0},
65
+ {"id": "bio", "label": "Biomaterials", "group": "domain", "x": 24, "y": 12, "z": -8},
66
+ {"id": "card", "label": "Cardiac Repair", "group": "domain", "x": 38, "y": 3, "z": 14},
67
+ {"id": "nano", "label": "Nanostructure", "group": "bridge", "x": 24, "y": -18, "z": 16},
68
+ {"id": "selfasm", "label": "Self-Assembly", "group": "bridge", "x": 40, "y": -16, "z": -16},
69
+ {"id": "electro", "label": "Electro-signalling", "group": "mechanism", "x": 58, "y": 10, "z": -10},
70
+ {"id": "immune", "label": "Immune Modulation", "group": "mechanism", "x": 64, "y": -8, "z": 10},
71
+ {"id": "trial", "label": "Validation Path", "group": "outcome", "x": 80, "y": 0, "z": 0},
72
+ {"id": "alt1", "label": "Piezoelectric Scaffold","group": "candidate", "x": 56, "y": 26, "z": 14},
73
+ {"id": "alt2", "label": "Peptide Mesh", "group": "candidate", "x": 54, "y": -27, "z": -14},
74
  ]
 
75
  EDGES = [
76
+ ("seed", "bio"), ("seed", "nano"),
77
+ ("bio", "card"), ("nano", "selfasm"),
78
+ ("selfasm", "electro"),("card", "immune"),
79
+ ("electro", "trial"), ("immune", "trial"),
80
+ ("card", "alt1"), ("selfasm","alt2"),
81
+ ("alt1", "trial"), ("alt2", "trial"),
 
 
 
 
 
 
82
  ]
 
83
  DEFAULT_PATH = ["seed", "nano", "selfasm", "electro", "trial"]
 
84
  CANDIDATES = [
85
  {
86
+ "title": "Piezoelectric Scaffold Cascade",
87
+ "front": "Use mechano-electric scaffolds to convert cardiac strain into micro-current signalling.",
88
+ "back": "Discovery path: anomalous healing signal -> piezoelectric analog -> ion-channel entrainment -> tissue regeneration. Risk: power density and fibrosis coupling.",
89
+ "score": 92,
90
  "novelty": "High",
91
+ "agent": "Hypothesis Composer",
92
  },
93
  {
94
+ "title": "Peptide Self-Assembly Mesh",
95
+ "front": "Deploy dynamic peptide meshes that self-assemble around damaged myocardium and guide repair.",
96
+ "back": "Discovery path: self-assembly -> local immune choreography -> regenerative substrate formation. Risk: degradation timing and targeting specificity.",
97
+ "score": 88,
98
  "novelty": "High",
99
+ "agent": "Analogy Engine",
100
  },
101
  {
102
+ "title": "Immune-Tuned Conductive Hydrogel",
103
+ "front": "Blend conductivity with macrophage-state modulation to reduce scarring and restore conduction.",
104
+ "back": "Discovery path: inflammation mismatch -> conductive medium -> macrophage polarization -> synchronized healing. Risk: persistence and biocompatibility.",
105
+ "score": 85,
106
  "novelty": "Medium-High",
107
+ "agent": "Adversarial Critic",
108
  },
109
  ]
 
110
  ACADEMIC_INSIGHTS = [
111
  {
112
  "hypothesis": "Implementation of mechano-electric scaffolds to transduce cardiac strain into localized micro-current signalling for myocardial regeneration.",
113
+ "metrics": {"Novelty": 92, "Mechanistic clarity": 85, "Experimental tractability": 78, "Cross-domain distance": 94},
114
+ "outline": "1. Synthesize candidate piezoelectric biomaterial scaffolds with tunable strain-electric coupling.\\n2. Evaluate *in vitro* electromechanical transduction and subsequent ion-channel entrainment.\\n3. Conduct *in vivo* comparative models to assess regenerative efficacy against gold-standard substrates.\\n4. Rigorously validate to exclude pathological fibrosis and power-density toxicity.",
115
+ "path": ["seed", "bio", "card", "alt1", "trial"]
 
 
 
 
 
 
 
 
 
 
116
  },
117
  {
118
  "hypothesis": "Deployment of dynamic peptide networks that self-assemble post-infarction to orchestrate local immunological responses and guide substrate regeneration.",
119
+ "metrics": {"Novelty": 88, "Mechanistic clarity": 82, "Experimental tractability": 86, "Cross-domain distance": 85},
120
+ "outline": "1. Formulate peptide sequences programmed for triggered *in situ* self-assembly within the myocardial infarct zone.\\n2. Quantify macrophage polarization and local immune choreography post-deployment.\\n3. Map the temporospatial degradation profile against *de novo* tissue formation.\\n4. Falsify against off-target aggregation and delayed clearance risks.",
121
+ "path": ["seed", "nano", "selfasm", "alt2", "trial"]
 
 
 
 
 
 
 
 
 
 
122
  },
123
  {
124
  "hypothesis": "Integration of conductive hydrogels with immunomodulatory properties to simultaneously bridge electrical uncoupling and mitigate adverse fibrotic scarring.",
125
+ "metrics": {"Novelty": 85, "Mechanistic clarity": 90, "Experimental tractability": 88, "Cross-domain distance": 79},
126
+ "outline": "1. Fabricate biocompatible hydrogels featuring precisely tuned electrical conductivity and immunomodulatory motifs.\\n2. Monitor electrophysiological synchronization across the scaffold-tissue interface.\\n3. Assess macrophage state transitions and suppression of adverse fibrotic remodelling.\\n4. Validate long-term persistence, hemocompatibility, and mechanical integration.",
127
+ "path": ["seed", "bio", "card", "immune", "trial"]
128
+ }
 
 
 
 
 
 
 
 
 
 
129
  ]
130
+ JOURNALS = [
131
+ {"name": "Nature", "url": "https://www.nature.com/search", "desc": "Flagship multidisciplinary research journal."},
132
+ {"name": "Science", "url": "https://www.science.org/search", "desc": "High-impact science journal and family."},
133
+ {"name": "Cell", "url": "https://www.cell.com/search", "desc": "Life sciences and translational biology."},
134
+ {"name": "The Lancet", "url": "https://www.thelancet.com/search", "desc": "Clinical and medical research."},
135
+ {"name": "IEEE Xplore","url": "https://ieeexplore.ieee.org/search/searchresult.jsp", "desc": "Engineering, AI, signal processing, and systems."},
136
+ ]
137
+ SEMANTIC_SCHOLAR_API_KEY = os.getenv("SEMANTIC_SCHOLAR_API_KEY", "")
138
+ GROBID_URL = os.getenv("GROBID_URL", "").strip()
139
+ REQUEST_TIMEOUT = 25
140
+ # ── Utility helpers ──────────────────────────────────────────────────────────
141
+ def safe_text(x, default: str = "") -> str:
142
+ return html.escape(str(x if x is not None else default))
143
  def norm_text(x: Optional[str]) -> str:
144
+ return re.sub(r"\\s+", " ", (x or "")).strip()
145
+ def detect_query_type(query: str) -> str:
146
+ q = (query or "").strip()
147
+ if re.match(r"^10\\.\\d{4,9}/[-._;()/:A-Z0-9]+$", q, flags=re.I):
148
+ return "doi"
149
+ if q.startswith("http://") or q.startswith("https://"):
150
+ return "link"
151
+ return "topic"
152
+ def ensure_list(x):
153
+ return x if isinstance(x, list) else []
154
+ # ── HTML builders ─────────────────────────────────────────────────────────────
155
  def build_connectome_html(path_ids: List[str]) -> str:
156
+ active = set(path_ids)
157
+ node_map = {n["id"]: n for n in NODES}
 
158
  path_pairs = {
159
  pair
160
  for i in range(len(path_ids) - 1)
161
  for pair in [(path_ids[i], path_ids[i + 1]), (path_ids[i + 1], path_ids[i])]
162
  }
 
163
  base_lines, active_lines, circles, labels = [], [], [], []
 
164
  for a, b in EDGES:
165
  na, nb = node_map[a], node_map[b]
166
  x1, y1 = na["x"] * 8 + 80, na["y"] * 6 + 280
167
  x2, y2 = nb["x"] * 8 + 80, nb["y"] * 6 + 280
168
+ base_lines.append(f\'<line class="edge" x1="{x1:.1f}" y1="{y1:.1f}" x2="{x2:.1f}" y2="{y2:.1f}" />\')
 
 
169
  if (a, b) in path_pairs:
170
+ active_lines.append(f\'<line class="edge active" x1="{x1:.1f}" y1="{y1:.1f}" x2="{x2:.1f}" y2="{y2:.1f}" />\')
 
 
 
171
  for n in NODES:
172
+ cx, cy = n["x"] * 8 + 80, n["y"] * 6 + 280
173
  is_active = n["id"] in active
174
+ state = "chosen" if is_active else "idle"
175
  halo_cls = "halo active" if is_active else "halo"
176
+ lbl_cls = "label active" if is_active else "label"
177
+ radius = 18 if is_active else 13
178
+ halo_r = 30 if is_active else 0
 
179
  circles.append(
180
+ f\'<g class="node-wrap">\'
181
+ f\'<circle class="{halo_cls}" cx="{cx:.1f}" cy="{cy:.1f}" r="{halo_r}" />\'
182
+ f\'<circle class="node {n["group"]} {state}" cx="{cx:.1f}" cy="{cy:.1f}" r="{radius}" />\'
183
+ f\'</g>\'
 
 
 
184
  )
185
+ labels.append(f\'<text class="{lbl_cls}" x="{cx + 18:.1f}" y="{cy - 16:.1f}">{safe_text(n["label"])}</text>\')
186
  return f"""
187
  <div class="panel brain-shell">
188
  <div class="brain-header">
 
198
  </div>
199
  <div class="brain-stage">
200
  <svg viewBox="0 0 780 560" class="brain-svg" role="img" aria-label="DVNC 3D connectome visualisation">
201
+ {"".join(base_lines)}
202
+ {"".join(active_lines)}
203
+ {"".join(circles)}
204
+ {"".join(labels)}
205
  </svg>
206
  </div>
207
  </div>
208
  """
 
 
209
  def build_cards_html(cards: List[Dict]) -> str:
210
  items = []
211
  for i, c in enumerate(cards):
212
+ items.append(f"""
213
+ <article class="candidate-card" tabindex="0">
214
+ <div class="candidate-card-inner">
215
+ <div class="candidate-face candidate-front">
216
+ <div class="candidate-top">
217
+ <span class="chip">{safe_text(c["agent"])}</span>
218
+ <span class="score">{safe_text(c["score"])}</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
  </div>
220
+ <h4>{safe_text(c["title"])}</h4>
221
+ <p>{safe_text(c["front"])}</p>
222
+ <div class="meta-row"><span>Novelty</span><strong>{safe_text(c["novelty"])}</strong></div>
223
+ <button class="mini" type="button" onclick="triggerRouteSwap('{i}')">Use as main insight</button>
224
+ </div>
225
+ <div class="candidate-face candidate-back">
226
+ <div class="candidate-top">
227
+ <span class="chip alt">Alternative path</span>
228
+ <span class="score">{safe_text(c["score"])}</span>
229
+ </div>
230
+ <h4>{safe_text(c["title"])}</h4>
231
+ <p>{safe_text(c["back"])}</p>
232
+ <div class="meta-row"><span>Swap into route</span><strong>Enabled</strong></div>
233
+ <button class="mini" type="button" onclick="triggerRouteSwap('{i}')">Use as main insight</button>
234
+ </div>
235
+ </div>
236
+ </article>""")
237
+ return \'<div class="panel" style="padding:20px;"><div class="candidate-grid">\' + "".join(items) + "</div></div>"
238
+ def build_agent_timeline(reasoning: List[Dict]) -> str:
239
+ rows = []
240
+ for r in reasoning:
241
+ rows.append(f"""
242
+ <details class="agent-step" {"open" if r["step"] == 1 else ""}>
243
+ <summary class="agent-summary">
244
+ <div class="agent-index">{safe_text(r["step"])}</div>
245
+ <div class="agent-head">
246
+ <h4>{safe_text(r["agent"])}</h4>
247
+ <span>{safe_text(r["tag"])}</span>
248
+ </div>
249
+ </summary>
250
+ <div class="agent-copy">
251
+ <p>{safe_text(r["summary"])}</p>
252
+ </div>
253
+ </details>""")
254
+ return \'<div class="panel" style="padding:18px;"><div class="timeline">\' + "".join(rows) + "</div></div>"
255
  def build_chat_html(query: str, result: Dict) -> str:
256
  return f"""
257
  <div class="panel chat-panel">
 
271
  </div>
272
  </div>
273
  """
 
 
274
  def build_models_html(selected: str) -> str:
275
  items = []
276
  for m in MODELS:
277
  active = "active" if m["name"] == selected else ""
278
+ items.append(f"""
 
279
  <div class="model-pill {active}">
280
  <span class="model-name">{safe_text(m["name"])}</span>
281
  <span class="model-tag">{safe_text(m["tag"])}</span>
282
  <small>{safe_text(m["desc"])}</small>
283
+ </div>""")
284
+ return \'<div class="panel" style="padding:18px;"><div class="model-switcher">\' + "".join(items) + "</div></div>"
285
+ # ── Discovery logic ───────────────────────────────────────────────────────────
 
 
 
 
286
  def run_discovery(query: str, model_name: str):
287
+ """
288
+ Runs the 7-agent discovery pipeline.
289
+ """
290
+ random.seed(len(query) + len(model_name))
291
+ if "curie" in query.lower() or "einstein" in query.lower():
 
292
  primary = "Map the anomaly first, then force a distant analogy before composing the experimental programme."
293
+ path = ["seed", "bio", "card", "immune", "trial"]
294
  else:
295
+ primary = "Utilization of a self-assembling conductive scaffold to transduce mechanical strain into localized regenerative signalling pathways."
296
+ path = DEFAULT_PATH
 
 
 
 
297
  summaries = [
298
  "Normalises the user prompt into a graph-searchable seed and isolates the tension inside the question.",
299
  "Finds remote conceptual bridges instead of staying near the starting domain cluster.",
 
304
  "Produces a staged validation plan with measurable falsification criteria.",
305
  ]
306
  tags = ["input", "graph", "evidence", "analogy", "compose", "critique", "experiment"]
 
307
  reasoning = [
308
  {"step": i + 1, "agent": AGENTS[i], "tag": tags[i], "summary": summaries[i]}
309
  for i in range(7)
310
  ]
 
311
  result = {
312
+ "summary": "A deeper route was chosen through the connectome, with live alternatives preserved as swappable cards so the reasoning path can be inspected rather than hidden.",
 
 
 
313
  "primary_hypothesis": primary,
314
+ "reasoning": reasoning,
315
+ "cards": CANDIDATES,
316
+ "path": path,
317
  "metrics": {
318
+ "Novelty": 93,
319
+ "Mechanistic clarity": 89,
320
  "Experimental tractability": 82,
321
+ "Cross-domain distance": 91,
322
  },
323
  }
324
+ chat_html = build_chat_html(query, result)
 
325
  connectome_html = build_connectome_html(path)
326
+ timeline_html = build_agent_route_cards_html(reasoning)
327
+ metrics_md = "\\n".join(f"- {k}: {v}/100" for k, v in result["metrics"].items())
328
+ hypothesis_md = (
329
+ "# Discovery Output\\n\\n"
330
+ f"**Model:** {model_name}\\n\\n"
331
+ f"**Primary hypothesis:** {result['primary_hypothesis']}\\n\\n"
332
+ "## Scoring\\n"
333
+ f"{metrics_md}\\n\\n"
334
+ "## Experimental outline\\n"
335
+ "1. Construct the candidate material or protocol.\\n"
336
+ "2. Test mechanistic signal expression under controlled conditions.\\n"
337
+ "3. Compare against baseline and nearest-neighbour alternatives.\\n"
338
+ "4. Falsify using the adversarial risk criteria surfaced in the reasoning path.\\n"
 
 
 
 
 
 
 
 
 
 
 
 
 
339
  )
340
+ cards_html = build_cards_html(CANDIDATES)
341
+ route_state = get_default_route_state()
342
+ return chat_html, connectome_html, timeline_html, cards_html, hypothesis_md, build_models_html(model_name), route_state
343
  def apply_route_swap(query: str, model_name: str, route_swap_payload: str, route_state):
344
+ """
345
+ Called when a user clicks 'Use as main insight' on a candidate card.
346
+ Sanitizes the output, adopts academic rigor, updates the connectome and discovery output.
347
+ """
348
  try:
349
+ idx = int(route_swap_payload)
350
+ except ValueError:
351
  idx = 0
 
352
  if not (0 <= idx < len(ACADEMIC_INSIGHTS)):
353
  idx = 0
 
354
  academic = ACADEMIC_INSIGHTS[idx]
355
+
356
+ # Update Connectome
357
  connectome_html = build_connectome_html(academic["path"])
358
+
359
+ # Update Chat Feedback
360
  result = {
361
+ "summary": "Main insight formally adopted. The connectome pathway and validation protocol have been realigned to the selected candidate methodology.",
362
+ "primary_hypothesis": academic["hypothesis"]
 
 
 
363
  }
364
+ chat_html = build_chat_html(query, result)
365
+ # Format Oxford-tier markdown output
366
+ metrics_md = "\\n".join(f"- {k}: {v}/100" for k, v in academic["metrics"].items())
367
+
368
  hypothesis_md = (
369
+ "# Discovery Output\\n\\n"
370
+ f"**Model:** {model_name}\\n\\n"
371
+ f"**Primary hypothesis:** {academic['hypothesis']}\\n\\n"
372
+ "## Scoring\\n"
373
+ f"{metrics_md}\\n\\n"
374
+ "## Experimental outline\\n"
375
+ f"{academic['outline']}\\n"
376
  )
377
+ # We return the new chat, new connectome, leave timeline alone (gr.update()), new output, new state
378
  return chat_html, connectome_html, gr.update(), hypothesis_md, route_state
379
+ # ── Example loaders ───────────────────────────────────────────────────────────
 
 
380
  def load_example() -> str:
381
+ return "How could a self-assembling conductive biomaterial improve cardiac tissue regeneration by converting mechanical strain into repair signalling?"
 
 
 
 
 
382
  def load_paper_topic() -> str:
383
  return "self-assembling conductive biomaterials for cardiac repair"
384
+ # ── CSS / HEAD ────────────────────────────────────────────────────────────────
 
 
385
  BASE_CSS = r"""
386
  :root {
387
+ --bg: #ffffff; --panel: #ffffff; --line: rgba(0,0,0,.12);
388
+ --text: #111111; --muted: #5b5b5b; --soft: rgba(0,0,0,.62);
389
+ --gold: #ff6600; --teal: #17b8a6; --blue: #628dff;
390
+ --chosen: #ff7a1a; --idle: #b8d8ff; --idle-stroke: #5e8fe6;
391
+ --query-node: #ffd8b3; --paper-node: #d7f6f2; --upload-node: #e7defe;
 
 
 
 
 
 
 
 
 
 
392
  --shadow: 0 16px 40px rgba(0,0,0,.12);
393
  }
394
+ html,body,.gradio-container { background:#ffffff !important; font-family:Inter,ui-sans-serif,system-ui,sans-serif; }
395
+ .gradio-container { max-width:1640px !important; padding:20px !important; }
396
+ #dvnc-shell { border:1px solid var(--line); border-radius:28px; overflow:hidden; background:#ffffff; box-shadow:var(--shadow); padding:20px 22px 22px; }
397
+ .hero-bar { display:flex; justify-content:space-between; align-items:center; gap:16px; padding-bottom:12px; border-bottom:1px solid rgba(0,0,0,.06); margin-bottom:16px; }
398
+ .brand { display:flex; align-items:center; gap:14px; }
399
+ .logo { width:42px; height:42px; border-radius:14px; display:grid; place-items:center; color:var(--gold); background:linear-gradient(135deg,rgba(255,122,26,.12),rgba(23,184,166,.10)); border:1px solid rgba(0,0,0,.08); }
400
+ .logo svg { width:24px; height:24px; }
401
+ .brand h1 { font-size:1.05rem; margin:0; font-weight:700; letter-spacing:.12em; text-transform:uppercase; }
402
+ .brand p { margin:3px 0 0; color:var(--muted); font-size:.84rem; }
403
+ .status { display:flex; gap:10px; align-items:center; color:var(--soft); font-size:.85rem; }
404
+ .status-dot { width:10px; height:10px; border-radius:50%; background:var(--teal); box-shadow:0 0 0 6px rgba(23,184,166,.10),0 0 14px rgba(23,184,166,.25); }
405
+ .panel { background:#ffffff; border:1px solid var(--line); border-radius:22px; box-shadow:inset 0 1px 0 rgba(255,255,255,.8); }
406
+ .querybox textarea,.querybox input { background:transparent !important; color:var(--text) !important; }
407
+ .querybox,.querybox>div { background:#ffffff !important; border-radius:18px !important; border-color:var(--line) !important; }
408
+ .chat-panel { padding:18px; min-height:280px; }
409
+ .chat-thread { display:flex; flex-direction:column; gap:14px; }
410
+ .bubble { max-width:88%; padding:16px 18px; border-radius:22px; border:1px solid var(--line); }
411
+ .bubble p { margin:8px 0 0; line-height:1.6; font-size:.96rem; color:var(--text); }
412
+ .bubble .role { font-size:.72rem; letter-spacing:.12em; text-transform:uppercase; color:var(--muted); }
413
+ .bubble-user { align-self:flex-end; background:linear-gradient(135deg,rgba(98,141,255,.16),rgba(98,141,255,.08)); }
414
+ .bubble-ai { align-self:flex-start; background:#ffffff; }
415
+ .bubble-system { align-self:flex-start; background:linear-gradient(135deg,rgba(255,122,26,.10),rgba(255,122,26,.04)); }
416
+ .model-switcher { display:grid; grid-template-columns:repeat(3,1fr); gap:12px; }
417
+ .model-pill { padding:14px; border:1px solid var(--line); border-radius:18px; display:flex; flex-direction:column; gap:4px; min-height:98px; background:#ffffff; }
418
+ .model-pill.active { border-color:rgba(255,122,26,.40); background:linear-gradient(135deg,rgba(255,122,26,.10),rgba(255,255,255,.96)); }
419
+ .model-name { font-weight:650; color:var(--text); }
420
+ .model-tag { font-size:.76rem; text-transform:uppercase; letter-spacing:.12em; color:var(--gold); }
421
+ .model-pill small { color:var(--muted); line-height:1.45; }
422
+ .brain-shell { padding:18px; }
423
+ .brain-header { display:flex; justify-content:space-between; align-items:flex-end; gap:16px; margin-bottom:10px; }
424
+ .eyebrow { font-size:.72rem; letter-spacing:.16em; text-transform:uppercase; color:var(--gold); margin:0 0 4px; }
425
+ .brain-header h3 { margin:0; font-size:1.12rem; color:var(--text); }
426
+ .brain-legend { display:flex; gap:14px; color:var(--muted); font-size:.8rem; flex-wrap:wrap; }
427
+ .dot { width:10px; height:10px; display:inline-block; border-radius:50%; margin-right:6px; }
428
+ .dot-live { background:var(--chosen); box-shadow:0 0 10px rgba(255,122,26,.35); }
429
+ .dot-chosen { background:var(--chosen); }
430
+ .dot-idle { background:var(--idle); border:1px solid var(--idle-stroke); }
431
+ .dot-query { background:var(--query-node); border:1px solid #de9e58; }
432
+ .dot-paper { background:var(--paper-node); border:1px solid #4fb3a5; }
433
+ .dot-upload { background:var(--upload-node); border:1px solid #8f73d9; }
434
+ .brain-stage { position:relative; min-height:420px; overflow:hidden; background:linear-gradient(180deg,rgba(250,250,250,1),rgba(255,255,255,1)); border:1px solid rgba(0,0,0,.05); border-radius:20px; }
435
+ .brain-svg { width:100%; height:520px; display:block; }
436
+ .edge { stroke:rgba(0,0,0,.12); stroke-width:2.4; }
437
+ .edge.active { stroke:var(--chosen); stroke-width:4.2; stroke-linecap:round; filter:drop-shadow(0 0 6px rgba(255,122,26,.45)); stroke-dasharray:8 12; animation:pulseEdge 1.5s linear infinite; }
438
+ .node { stroke-width:2.2; transition:all .25s ease; }
439
+ .node.idle { fill:var(--idle); stroke:var(--idle-stroke); }
440
+ .node.chosen { fill:var(--chosen); stroke:#ffb16d; }
441
+ .halo { fill:none; }
442
+ .halo.active { stroke:rgba(255,122,26,.18); stroke-width:12; }
443
+ .label { fill:#2c2c2c; font-size:13px; font-weight:500; letter-spacing:.01em; }
444
+ .label.active { fill:#111111; font-weight:700; }
445
+ .learn-edge { stroke:rgba(0,0,0,.18); stroke-width:2.2; stroke-linecap:round; }
446
+ .learn-node { stroke-width:2.2; }
447
+ .learn-node.query { fill:var(--query-node); stroke:#de9e58; }
448
+ .learn-node.paper { fill:var(--paper-node); stroke:#36a091; }
449
+ .learn-node.upload { fill:var(--upload-node); stroke:#7e63cb; }
450
+ .learn-label { fill:#1e1e1e; font-size:12px; font-weight:600; }
451
+ .learning-empty { display:grid; place-items:center; }
452
+ .empty-graph-copy { text-align:center; max-width:440px; padding:40px 20px; }
453
+ .empty-graph-copy h4 { margin:0 0 10px; font-size:1.05rem; }
454
+ .empty-graph-copy p { margin:0; color:var(--muted); line-height:1.6; }
455
+ .timeline { display:flex; flex-direction:column; gap:10px; }
456
+ .agent-step { border:1px solid var(--line); border-radius:18px; background:#ffffff; overflow:hidden; }
457
+ .agent-summary { list-style:none; display:grid; grid-template-columns:42px 1fr; gap:12px; align-items:center; padding:12px; cursor:pointer; }
458
+ .agent-summary::-webkit-details-marker { display:none; }
459
+ .agent-index { width:42px; height:42px; border-radius:14px; display:grid; place-items:center; font-weight:700; color:var(--gold); background:rgba(255,122,26,.08); border:1px solid rgba(255,122,26,.18); }
460
+ .agent-head { display:flex; justify-content:space-between; gap:12px; align-items:center; }
461
+ .agent-head h4 { margin:0; font-size:.98rem; color:var(--text); }
462
+ .agent-head span { font-size:.72rem; letter-spacing:.12em; text-transform:uppercase; color:var(--muted); }
463
+ .agent-copy { padding:0 14px 16px 66px; }
464
+ .agent-copy p { margin:0; color:#2d2d2d; font-size:.93rem; line-height:1.6; }
465
+ .candidate-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:18px; }
466
+ .candidate-card { background:none; perspective:1400px; min-height:330px; }
467
+ .candidate-card-inner { position:relative; width:100%; min-height:330px; transition:transform .8s cubic-bezier(.2,.7,.1,1); transform-style:preserve-3d; }
468
+ .candidate-card:hover .candidate-card-inner,.candidate-card:focus .candidate-card-inner,.candidate-card:focus-within .candidate-card-inner { transform:rotateY(180deg); }
469
+ .candidate-face { position:absolute; inset:0; padding:20px; border-radius:22px; border:1px solid var(--line); background:#ffffff; color:var(--text); backface-visibility:hidden; box-shadow:0 12px 24px rgba(0,0,0,.06); display:flex; flex-direction:column; gap:14px; }
470
+ .candidate-back { transform:rotateY(180deg); }
471
+ .candidate-top { display:flex; justify-content:space-between; align-items:center; gap:8px; }
472
+ .chip { font-size:.72rem; text-transform:uppercase; letter-spacing:.12em; color:#0b6f66; padding:7px 10px; border-radius:999px; background:rgba(23,184,166,.08); border:1px solid rgba(23,184,166,.18); }
473
+ .chip.alt { color:var(--gold); background:rgba(255,122,26,.08); border-color:rgba(255,122,26,.18); }
474
+ .score { font-weight:700; color:var(--gold); }
475
+ .candidate-face h4 { margin:0; font-size:1.08rem; line-height:1.35; }
476
+ .candidate-face p { margin:0; color:#1e1e1e; line-height:1.65; font-size:.96rem; overflow-wrap:anywhere; }
477
+ .meta-row { margin-top:auto; display:flex; justify-content:space-between; color:var(--muted); font-size:.88rem; gap:14px; }
478
+ .mini { cursor:pointer; margin-top:8px; align-self:flex-start; color:var(--text); padding:10px 12px; border-radius:14px; border:1px solid var(--line); background:#ffffff; transition:all 0.2s; }
479
+ .mini:hover { background: #f5f5f5; border-color: var(--chosen); }
480
+ .papers-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:14px; }
481
+ .paper-card { border:1px solid var(--line); border-radius:18px; padding:16px; background:#ffffff; }
482
+ .paper-topline { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:10px; }
483
+ .paper-badge { font-size:.72rem; padding:6px 10px; border-radius:999px; background:rgba(98,141,255,.08); color:#3456b5; border:1px solid rgba(98,141,255,.18); }
484
+ .paper-badge.alt { background:rgba(0,0,0,.04); color:#444; border-color:rgba(0,0,0,.08); }
485
+ .doi-badge { background:rgba(255,122,26,.08); color:#8a4105; border-color:rgba(255,122,26,.18); }
486
+ .paper-card h4 { margin:0 0 10px; line-height:1.35; font-size:1rem; }
487
+ .paper-card p { margin:0 0 12px; line-height:1.6; color:#222; }
488
+ .paper-links { display:flex; gap:12px; flex-wrap:wrap; }
489
+ .paper-meta-stack { display:flex; flex-direction:column; gap:6px; color:#444; margin-bottom:12px; font-size:.9rem; }
490
+ .paper-links a,.journal-card,.upload-note a { color:#0b63ce; text-decoration:none; }
491
+ .journal-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:14px; }
492
+ .journal-card { border:1px solid var(--line); border-radius:18px; padding:16px; display:flex; justify-content:space-between; gap:14px; align-items:center; background:#ffffff; }
493
+ .journal-card h4 { margin:0 0 6px; }
494
+ .journal-card p { margin:0; color:var(--muted); line-height:1.5; }
495
+ .upload-note { border:1px dashed rgba(0,0,0,.16); border-radius:18px; padding:16px; background:rgba(0,0,0,.015); color:#1f1f1f; line-height:1.6; }
496
+ .prosebox { padding:18px; white-space:pre-wrap; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; line-height:1.55; color:#1b1b1b; }
497
+ .gr-button-primary { background:linear-gradient(135deg,rgba(255,122,26,.92),rgba(240,108,22,.92)) !important; color:#ffffff !important; border:none !important; }
498
+ .gr-button-secondary { background:#ffffff !important; color:var(--text) !important; border:1px solid var(--line) !important; }
499
+ .ref-list { margin:0; padding-left:18px; }
500
+ .ref-list li { margin-bottom:8px; line-height:1.5; }
501
+ .parse-grid { display:grid; grid-template-columns:1.2fr 1fr; gap:14px; }
502
+ .parse-card { border:1px solid var(--line); border-radius:18px; padding:16px; background:#ffffff; }
503
+ .selection-panel { padding:18px; }
504
+ footer { display:none !important; }
505
+ @keyframes pulseEdge { to { stroke-dashoffset:-40; } }
506
+ @media (max-width:1180px) {
507
+ .model-switcher,.candidate-grid,.papers-grid,.journal-grid,.parse-grid { grid-template-columns:1fr; }
508
+ .brain-svg { height:460px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
509
  }
510
  """
511
+ CSS = BASE_CSS + "\\n" + get_dvnc_layout_css()
 
 
512
  HEAD = """
513
  <link rel="preconnect" href="https://fonts.googleapis.com">
514
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
515
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
516
  <script>
517
  function triggerRouteSwap(idx) {
518
+ const container = document.getElementById('route_swap_payload');
519
+ if(!container) return;
520
+ const input = container.querySelector('textarea') || container.querySelector('input');
521
+ if(input) {
522
+ input.value = idx.toString();
523
+ input.dispatchEvent(new Event('input', { bubbles: true }));
524
+ setTimeout(() => {
525
+ const btn = document.getElementById('route_swap_apply');
526
+ if(btn) btn.click();
527
+ }, 150);
528
+ }
 
 
529
  }
530
  </script>
531
  """
532
+ # ── Gradio layout ─────────────────────────────────────────────────────────────
533
+ with gr.Blocks(css=CSS, head=HEAD, theme=gr.themes.Base(), fill_height=True) as demo:
534
+ # ── Shared state ──────────────────────────────────────────────────────────
535
+ papers_state = gr.State([])
536
+ parsed_pdf_state = gr.State({})
 
 
 
 
 
 
 
 
537
  ingest_payload_state = gr.State({})
538
+ route_state = gr.State(get_default_route_state())
539
+ # ── Header ────────────────────────────────────────────────────────────────
540
+ gr.HTML("""
 
 
541
  <div id="dvnc-shell">
542
  <div class="hero-bar">
543
  <div class="brand">
 
556
  <div class="status"><span class="status-dot"></span><span>Live orchestration</span></div>
557
  </div>
558
  </div>
559
+ """)
 
 
560
  with gr.Tabs():
561
+ # ── Tab 1 · Discovery Engine ──────────────────────────────────────────
562
  with gr.Tab("Discovery Engine"):
563
  model_html = gr.HTML(build_models_html("DVNC Sovereign"))
 
564
  with gr.Row():
565
  with gr.Column(scale=2):
566
  model = gr.Dropdown(
 
575
  lines=4,
576
  )
577
  with gr.Row():
578
+ run_btn = gr.Button("Run discovery", variant="primary")
579
+ example_btn = gr.Button("Load example", variant="secondary")
580
+ chat = gr.HTML("""
 
 
581
  <div class="panel chat-panel">
582
  <div class="chat-thread">
583
  <div class="bubble bubble-ai">
 
586
  </div>
587
  </div>
588
  </div>
589
+ """)
 
 
590
  with gr.Column(scale=3):
591
  connectome = gr.HTML(build_connectome_html(DEFAULT_PATH))
592
+ cards = gr.HTML("")
593
+ output = gr.Markdown("# Discovery Output\\n\\nAwaiting query.")
 
594
  timeline = gr.HTML(get_initial_discovery_timeline_html())
 
595
  route_swap_payload = gr.Textbox(value="", visible=False, elem_id="route_swap_payload")
596
+ route_swap_apply = gr.Button("Apply route swap", visible=False, elem_id="route_swap_apply")
597
+ # ── Tab 2 · Self-Learning Graph ───────────────────────────────────────
 
598
  with gr.Tab("Self-Learning Graph"):
599
  with gr.Row():
600
  with gr.Column(scale=2):
 
614
  value=DEFAULT_SOURCES,
615
  label="Sources",
616
  )
617
+ pdf_upload = gr.File(label="Upload PDF papers", file_types=[".pdf"], file_count="single")
 
 
 
 
 
618
  with gr.Row():
619
+ learn_btn = gr.Button("Discover papers", variant="primary")
620
+ load_topic_btn = gr.Button("Load example topic", variant="secondary")
621
+ upload_status = gr.Markdown("No PDF uploaded yet.")
 
622
  discovery_status = gr.Markdown("### No discovery results yet.")
623
+ journal_panel = gr.HTML(build_journal_html("biomaterials cardiac repair"))
624
+ gr.HTML(\'<div class="panel selection-panel"><h3 style="margin:0 0 12px;">Select papers to ingest</h3></div>\')
625
+ selection_box = gr.CheckboxGroup(choices=[], value=[], label="Candidate papers")
 
 
 
 
 
 
 
 
 
626
  parser_order = gr.CheckboxGroup(
627
+ choices=["grobid", "docling", "pymupdf"],
628
+ value=["grobid", "docling", "pymupdf"],
629
  label="Parser routing order",
630
  )
 
631
  with gr.Row():
632
+ parse_btn = gr.Button("Parse uploaded PDF", variant="secondary")
633
  ingest_btn = gr.Button("Ingest selected into graph", variant="primary")
 
634
  with gr.Column(scale=3):
635
  learning_graph = gr.HTML(build_learning_graph_html([], []))
636
+ papers_panel = gr.HTML(\'<div class="panel papers-panel" style="padding:18px"><p>Search by topic, title, DOI, or link, then select papers before graph ingestion.</p></div>\')
637
+ parse_summary = gr.Markdown("### PDF parse status\\n\\nAwaiting upload.")
638
+ parse_panel = gr.HTML(\'<div class="panel" style="padding:18px"><p>No parsed document yet.</p></div>\')
639
+ ingest_summary = gr.Markdown("### Graph ingest status\\n\\nAwaiting paper selection.")
640
+ ingest_payload = gr.JSON(label="Graph ingest payload", value={"status": "empty", "nodes": [], "edges": []})
641
+ # ── Event wiring ──────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
642
  example_btn.click(fn=load_example, outputs=query)
 
643
  run_btn.click(
644
  fn=run_discovery,
645
  inputs=[query, model],
646
  outputs=[chat, connectome, timeline, cards, output, model_html, route_state],
647
  )
 
648
  route_swap_apply.click(
649
  fn=apply_route_swap,
650
  inputs=[query, model, route_swap_payload, route_state],
651
  outputs=[chat, connectome, timeline, output, route_state],
652
  )
 
653
  load_topic_btn.click(fn=load_paper_topic, outputs=paper_query)
 
654
  learn_btn.click(
655
  fn=run_paper_discovery,
656
  inputs=[paper_query, search_mode, source_selector, pdf_upload],
657
+ outputs=[learning_graph, papers_panel, journal_panel, upload_status, selection_box, papers_state, discovery_status],
 
 
 
 
 
 
 
 
658
  )
 
659
  parse_btn.click(
660
  fn=parse_uploaded_pdf,
661
  inputs=[pdf_upload, parser_order],
 
665
  inputs=[parsed_pdf_state],
666
  outputs=[parse_panel],
667
  )
 
668
  ingest_btn.click(
669
  fn=ingest_selected_papers,
670
  inputs=[paper_query, selection_box, papers_state, pdf_upload, parsed_pdf_state],
671
  outputs=[learning_graph, ingest_summary, ingest_payload],
 
 
 
 
672
  )
 
 
673
  if __name__ == "__main__":
674
+ demo.launch()
675
+ '''
676
+
677
+ with open("app.py", "w") as f:
678
+ f.write(app_py)
dvnc_ai_v2_hf/{deprecated/app_old11.py → app_old1.py} RENAMED
File without changes
dvnc_ai_v2_hf/deprecated/app_old10.py DELETED
@@ -1,677 +0,0 @@
1
- app_py = """
2
- DVNC.AI — app.py
3
- Refactored for functional "Use as main insight" logic with academic rigor.
4
- """
5
- # ── Standard library ────────────────────────────────────────────────────────
6
- import html
7
- import json
8
- import math
9
- import os
10
- import random
11
- import re
12
- import urllib.parse
13
- import xml.etree.ElementTree as ET
14
- from pathlib import Path
15
- from typing import Dict, List, Optional
16
- from urllib.parse import quote
17
- # ── Third-party ──────────────────────────────────────────────────────────────
18
- import gradio as gr
19
- import requests
20
- try:
21
- import fitz # PyMuPDF
22
- except Exception:
23
- fitz = None
24
- try:
25
- from bs4 import BeautifulSoup
26
- except Exception:
27
- BeautifulSoup = None
28
- # ── Internal modules ─────────────────────────────────────────────────────────
29
- from dvnc_ai_v2_hf.agent_route_cards import build_agent_route_cards_html
30
- from dvnc_ai_v2_hf.discovery_app_bridge import (
31
- get_default_route_state,
32
- get_discovery_css,
33
- get_initial_discovery_timeline_html,
34
- )
35
- from dvnc_ai_v2_hf.dvnc_ui_layout import get_dvnc_layout_css
36
- from dvnc_ai_v2_hf.self_learning_graph import (
37
- DEFAULT_SOURCES,
38
- SEARCH_MODES,
39
- SOURCE_OPTIONS,
40
- build_learning_graph_html,
41
- build_journal_html,
42
- ingest_selected_papers,
43
- parse_uploaded_pdf,
44
- render_parse_result,
45
- run_paper_discovery,
46
- safe_text,
47
- )
48
- # ── Constants ────────────────────────────────────────────────────────────────
49
- MODELS = [
50
- {"name": "DVNC Sovereign", "tag": "flagship", "desc": "Maximum depth orchestration for frontier discovery"},
51
- {"name": "DVNC Atlas", "tag": "research", "desc": "Balanced reasoning, graph traversal, and synthesis"},
52
- {"name": "DVNC Curie", "tag": "lab", "desc": "Experimental hypothesis generation for anomalous signals"},
53
- ]
54
- AGENTS = [
55
- "Query Interpreter",
56
- "Graph Divergence Mapper",
57
- "Evidence Harvester",
58
- "Analogy Engine",
59
- "Hypothesis Composer",
60
- "Adversarial Critic",
61
- "Experimental Program Designer",
62
- ]
63
- NODES = [
64
- {"id": "seed", "label": "Seed Query", "group": "core", "x": 10, "y": 0, "z": 0},
65
- {"id": "bio", "label": "Biomaterials", "group": "domain", "x": 24, "y": 12, "z": -8},
66
- {"id": "card", "label": "Cardiac Repair", "group": "domain", "x": 38, "y": 3, "z": 14},
67
- {"id": "nano", "label": "Nanostructure", "group": "bridge", "x": 24, "y": -18, "z": 16},
68
- {"id": "selfasm", "label": "Self-Assembly", "group": "bridge", "x": 40, "y": -16, "z": -16},
69
- {"id": "electro", "label": "Electro-signalling", "group": "mechanism", "x": 58, "y": 10, "z": -10},
70
- {"id": "immune", "label": "Immune Modulation", "group": "mechanism", "x": 64, "y": -8, "z": 10},
71
- {"id": "trial", "label": "Validation Path", "group": "outcome", "x": 80, "y": 0, "z": 0},
72
- {"id": "alt1", "label": "Piezoelectric Scaffold","group": "candidate", "x": 56, "y": 26, "z": 14},
73
- {"id": "alt2", "label": "Peptide Mesh", "group": "candidate", "x": 54, "y": -27, "z": -14},
74
- ]
75
- EDGES = [
76
- ("seed", "bio"), ("seed", "nano"),
77
- ("bio", "card"), ("nano", "selfasm"),
78
- ("selfasm", "electro"),("card", "immune"),
79
- ("electro", "trial"), ("immune", "trial"),
80
- ("card", "alt1"), ("selfasm","alt2"),
81
- ("alt1", "trial"), ("alt2", "trial"),
82
- ]
83
- DEFAULT_PATH = ["seed", "nano", "selfasm", "electro", "trial"]
84
- CANDIDATES = [
85
- {
86
- "title": "Piezoelectric Scaffold Cascade",
87
- "front": "Use mechano-electric scaffolds to convert cardiac strain into micro-current signalling.",
88
- "back": "Discovery path: anomalous healing signal -> piezoelectric analog -> ion-channel entrainment -> tissue regeneration. Risk: power density and fibrosis coupling.",
89
- "score": 92,
90
- "novelty": "High",
91
- "agent": "Hypothesis Composer",
92
- },
93
- {
94
- "title": "Peptide Self-Assembly Mesh",
95
- "front": "Deploy dynamic peptide meshes that self-assemble around damaged myocardium and guide repair.",
96
- "back": "Discovery path: self-assembly -> local immune choreography -> regenerative substrate formation. Risk: degradation timing and targeting specificity.",
97
- "score": 88,
98
- "novelty": "High",
99
- "agent": "Analogy Engine",
100
- },
101
- {
102
- "title": "Immune-Tuned Conductive Hydrogel",
103
- "front": "Blend conductivity with macrophage-state modulation to reduce scarring and restore conduction.",
104
- "back": "Discovery path: inflammation mismatch -> conductive medium -> macrophage polarization -> synchronized healing. Risk: persistence and biocompatibility.",
105
- "score": 85,
106
- "novelty": "Medium-High",
107
- "agent": "Adversarial Critic",
108
- },
109
- ]
110
- ACADEMIC_INSIGHTS = [
111
- {
112
- "hypothesis": "Implementation of mechano-electric scaffolds to transduce cardiac strain into localized micro-current signalling for myocardial regeneration.",
113
- "metrics": {"Novelty": 92, "Mechanistic clarity": 85, "Experimental tractability": 78, "Cross-domain distance": 94},
114
- "outline": "1. Synthesize candidate piezoelectric biomaterial scaffolds with tunable strain-electric coupling.\\n2. Evaluate *in vitro* electromechanical transduction and subsequent ion-channel entrainment.\\n3. Conduct *in vivo* comparative models to assess regenerative efficacy against gold-standard substrates.\\n4. Rigorously validate to exclude pathological fibrosis and power-density toxicity.",
115
- "path": ["seed", "bio", "card", "alt1", "trial"]
116
- },
117
- {
118
- "hypothesis": "Deployment of dynamic peptide networks that self-assemble post-infarction to orchestrate local immunological responses and guide substrate regeneration.",
119
- "metrics": {"Novelty": 88, "Mechanistic clarity": 82, "Experimental tractability": 86, "Cross-domain distance": 85},
120
- "outline": "1. Formulate peptide sequences programmed for triggered *in situ* self-assembly within the myocardial infarct zone.\\n2. Quantify macrophage polarization and local immune choreography post-deployment.\\n3. Map the temporospatial degradation profile against *de novo* tissue formation.\\n4. Falsify against off-target aggregation and delayed clearance risks.",
121
- "path": ["seed", "nano", "selfasm", "alt2", "trial"]
122
- },
123
- {
124
- "hypothesis": "Integration of conductive hydrogels with immunomodulatory properties to simultaneously bridge electrical uncoupling and mitigate adverse fibrotic scarring.",
125
- "metrics": {"Novelty": 85, "Mechanistic clarity": 90, "Experimental tractability": 88, "Cross-domain distance": 79},
126
- "outline": "1. Fabricate biocompatible hydrogels featuring precisely tuned electrical conductivity and immunomodulatory motifs.\\n2. Monitor electrophysiological synchronization across the scaffold-tissue interface.\\n3. Assess macrophage state transitions and suppression of adverse fibrotic remodelling.\\n4. Validate long-term persistence, hemocompatibility, and mechanical integration.",
127
- "path": ["seed", "bio", "card", "immune", "trial"]
128
- }
129
- ]
130
- JOURNALS = [
131
- {"name": "Nature", "url": "https://www.nature.com/search", "desc": "Flagship multidisciplinary research journal."},
132
- {"name": "Science", "url": "https://www.science.org/search", "desc": "High-impact science journal and family."},
133
- {"name": "Cell", "url": "https://www.cell.com/search", "desc": "Life sciences and translational biology."},
134
- {"name": "The Lancet", "url": "https://www.thelancet.com/search", "desc": "Clinical and medical research."},
135
- {"name": "IEEE Xplore","url": "https://ieeexplore.ieee.org/search/searchresult.jsp", "desc": "Engineering, AI, signal processing, and systems."},
136
- ]
137
- SEMANTIC_SCHOLAR_API_KEY = os.getenv("SEMANTIC_SCHOLAR_API_KEY", "")
138
- GROBID_URL = os.getenv("GROBID_URL", "").strip()
139
- REQUEST_TIMEOUT = 25
140
- # ── Utility helpers ──────────────────────────────────────────────────────────
141
- def safe_text(x, default: str = "") -> str:
142
- return html.escape(str(x if x is not None else default))
143
- def norm_text(x: Optional[str]) -> str:
144
- return re.sub(r"\\s+", " ", (x or "")).strip()
145
- def detect_query_type(query: str) -> str:
146
- q = (query or "").strip()
147
- if re.match(r"^10\\.\\d{4,9}/[-._;()/:A-Z0-9]+$", q, flags=re.I):
148
- return "doi"
149
- if q.startswith("http://") or q.startswith("https://"):
150
- return "link"
151
- return "topic"
152
- def ensure_list(x):
153
- return x if isinstance(x, list) else []
154
- # ── HTML builders ─────────────────────────────────────────────────────────────
155
- def build_connectome_html(path_ids: List[str]) -> str:
156
- active = set(path_ids)
157
- node_map = {n["id"]: n for n in NODES}
158
- path_pairs = {
159
- pair
160
- for i in range(len(path_ids) - 1)
161
- for pair in [(path_ids[i], path_ids[i + 1]), (path_ids[i + 1], path_ids[i])]
162
- }
163
- base_lines, active_lines, circles, labels = [], [], [], []
164
- for a, b in EDGES:
165
- na, nb = node_map[a], node_map[b]
166
- x1, y1 = na["x"] * 8 + 80, na["y"] * 6 + 280
167
- x2, y2 = nb["x"] * 8 + 80, nb["y"] * 6 + 280
168
- base_lines.append(f\'<line class="edge" x1="{x1:.1f}" y1="{y1:.1f}" x2="{x2:.1f}" y2="{y2:.1f}" />\')
169
- if (a, b) in path_pairs:
170
- active_lines.append(f\'<line class="edge active" x1="{x1:.1f}" y1="{y1:.1f}" x2="{x2:.1f}" y2="{y2:.1f}" />\')
171
- for n in NODES:
172
- cx, cy = n["x"] * 8 + 80, n["y"] * 6 + 280
173
- is_active = n["id"] in active
174
- state = "chosen" if is_active else "idle"
175
- halo_cls = "halo active" if is_active else "halo"
176
- lbl_cls = "label active" if is_active else "label"
177
- radius = 18 if is_active else 13
178
- halo_r = 30 if is_active else 0
179
- circles.append(
180
- f\'<g class="node-wrap">\'
181
- f\'<circle class="{halo_cls}" cx="{cx:.1f}" cy="{cy:.1f}" r="{halo_r}" />\'
182
- f\'<circle class="node {n["group"]} {state}" cx="{cx:.1f}" cy="{cy:.1f}" r="{radius}" />\'
183
- f\'</g>\'
184
- )
185
- labels.append(f\'<text class="{lbl_cls}" x="{cx + 18:.1f}" y="{cy - 16:.1f}">{safe_text(n["label"])}</text>\')
186
- return f"""
187
- <div class="panel brain-shell">
188
- <div class="brain-header">
189
- <div>
190
- <p class="eyebrow">Connectome</p>
191
- <h3>3D Connectome</h3>
192
- </div>
193
- <div class="brain-legend">
194
- <span><i class="dot dot-live"></i> lit path</span>
195
- <span><i class="dot dot-chosen"></i> chosen node</span>
196
- <span><i class="dot dot-idle"></i> available node</span>
197
- </div>
198
- </div>
199
- <div class="brain-stage">
200
- <svg viewBox="0 0 780 560" class="brain-svg" role="img" aria-label="DVNC 3D connectome visualisation">
201
- {"".join(base_lines)}
202
- {"".join(active_lines)}
203
- {"".join(circles)}
204
- {"".join(labels)}
205
- </svg>
206
- </div>
207
- </div>
208
- """
209
- def build_cards_html(cards: List[Dict]) -> str:
210
- items = []
211
- for i, c in enumerate(cards):
212
- items.append(f"""
213
- <article class="candidate-card" tabindex="0">
214
- <div class="candidate-card-inner">
215
- <div class="candidate-face candidate-front">
216
- <div class="candidate-top">
217
- <span class="chip">{safe_text(c["agent"])}</span>
218
- <span class="score">{safe_text(c["score"])}</span>
219
- </div>
220
- <h4>{safe_text(c["title"])}</h4>
221
- <p>{safe_text(c["front"])}</p>
222
- <div class="meta-row"><span>Novelty</span><strong>{safe_text(c["novelty"])}</strong></div>
223
- <button class="mini" type="button" onclick="triggerRouteSwap('{i}')">Use as main insight</button>
224
- </div>
225
- <div class="candidate-face candidate-back">
226
- <div class="candidate-top">
227
- <span class="chip alt">Alternative path</span>
228
- <span class="score">{safe_text(c["score"])}</span>
229
- </div>
230
- <h4>{safe_text(c["title"])}</h4>
231
- <p>{safe_text(c["back"])}</p>
232
- <div class="meta-row"><span>Swap into route</span><strong>Enabled</strong></div>
233
- <button class="mini" type="button" onclick="triggerRouteSwap('{i}')">Use as main insight</button>
234
- </div>
235
- </div>
236
- </article>""")
237
- return \'<div class="panel" style="padding:20px;"><div class="candidate-grid">\' + "".join(items) + "</div></div>"
238
- def build_agent_timeline(reasoning: List[Dict]) -> str:
239
- rows = []
240
- for r in reasoning:
241
- rows.append(f"""
242
- <details class="agent-step" {"open" if r["step"] == 1 else ""}>
243
- <summary class="agent-summary">
244
- <div class="agent-index">{safe_text(r["step"])}</div>
245
- <div class="agent-head">
246
- <h4>{safe_text(r["agent"])}</h4>
247
- <span>{safe_text(r["tag"])}</span>
248
- </div>
249
- </summary>
250
- <div class="agent-copy">
251
- <p>{safe_text(r["summary"])}</p>
252
- </div>
253
- </details>""")
254
- return \'<div class="panel" style="padding:18px;"><div class="timeline">\' + "".join(rows) + "</div></div>"
255
- def build_chat_html(query: str, result: Dict) -> str:
256
- return f"""
257
- <div class="panel chat-panel">
258
- <div class="chat-thread">
259
- <div class="bubble bubble-user">
260
- <span class="role">You</span>
261
- <p>{safe_text(query)}</p>
262
- </div>
263
- <div class="bubble bubble-ai">
264
- <span class="role">DVNC Sovereign</span>
265
- <p>{safe_text(result["summary"])}</p>
266
- </div>
267
- <div class="bubble bubble-system">
268
- <span class="role">Discovery Signal</span>
269
- <p><strong>Primary hypothesis:</strong> {safe_text(result["primary_hypothesis"])}</p>
270
- </div>
271
- </div>
272
- </div>
273
- """
274
- def build_models_html(selected: str) -> str:
275
- items = []
276
- for m in MODELS:
277
- active = "active" if m["name"] == selected else ""
278
- items.append(f"""
279
- <div class="model-pill {active}">
280
- <span class="model-name">{safe_text(m["name"])}</span>
281
- <span class="model-tag">{safe_text(m["tag"])}</span>
282
- <small>{safe_text(m["desc"])}</small>
283
- </div>""")
284
- return \'<div class="panel" style="padding:18px;"><div class="model-switcher">\' + "".join(items) + "</div></div>"
285
- # ── Discovery logic ───────────────────────────────────────────────────────────
286
- def run_discovery(query: str, model_name: str):
287
- """
288
- Runs the 7-agent discovery pipeline.
289
- """
290
- random.seed(len(query) + len(model_name))
291
- if "curie" in query.lower() or "einstein" in query.lower():
292
- primary = "Map the anomaly first, then force a distant analogy before composing the experimental programme."
293
- path = ["seed", "bio", "card", "immune", "trial"]
294
- else:
295
- primary = "Utilization of a self-assembling conductive scaffold to transduce mechanical strain into localized regenerative signalling pathways."
296
- path = DEFAULT_PATH
297
- summaries = [
298
- "Normalises the user prompt into a graph-searchable seed and isolates the tension inside the question.",
299
- "Finds remote conceptual bridges instead of staying near the starting domain cluster.",
300
- "Pulls evidence packets and conflict signals required for grounded hypothesis formation.",
301
- "Generates cross-domain analogies with a bias toward mechanism transfer rather than keyword similarity.",
302
- "Composes the lead hypothesis and two structurally different variants.",
303
- "Attacks weak assumptions, hidden confounders, and feasibility gaps.",
304
- "Produces a staged validation plan with measurable falsification criteria.",
305
- ]
306
- tags = ["input", "graph", "evidence", "analogy", "compose", "critique", "experiment"]
307
- reasoning = [
308
- {"step": i + 1, "agent": AGENTS[i], "tag": tags[i], "summary": summaries[i]}
309
- for i in range(7)
310
- ]
311
- result = {
312
- "summary": "A deeper route was chosen through the connectome, with live alternatives preserved as swappable cards so the reasoning path can be inspected rather than hidden.",
313
- "primary_hypothesis": primary,
314
- "reasoning": reasoning,
315
- "cards": CANDIDATES,
316
- "path": path,
317
- "metrics": {
318
- "Novelty": 93,
319
- "Mechanistic clarity": 89,
320
- "Experimental tractability": 82,
321
- "Cross-domain distance": 91,
322
- },
323
- }
324
- chat_html = build_chat_html(query, result)
325
- connectome_html = build_connectome_html(path)
326
- timeline_html = build_agent_route_cards_html(reasoning)
327
- metrics_md = "\\n".join(f"- {k}: {v}/100" for k, v in result["metrics"].items())
328
- hypothesis_md = (
329
- "# Discovery Output\\n\\n"
330
- f"**Model:** {model_name}\\n\\n"
331
- f"**Primary hypothesis:** {result['primary_hypothesis']}\\n\\n"
332
- "## Scoring\\n"
333
- f"{metrics_md}\\n\\n"
334
- "## Experimental outline\\n"
335
- "1. Construct the candidate material or protocol.\\n"
336
- "2. Test mechanistic signal expression under controlled conditions.\\n"
337
- "3. Compare against baseline and nearest-neighbour alternatives.\\n"
338
- "4. Falsify using the adversarial risk criteria surfaced in the reasoning path.\\n"
339
- )
340
- cards_html = build_cards_html(CANDIDATES)
341
- route_state = get_default_route_state()
342
- return chat_html, connectome_html, timeline_html, cards_html, hypothesis_md, build_models_html(model_name), route_state
343
- def apply_route_swap(query: str, model_name: str, route_swap_payload: str, route_state):
344
- """
345
- Called when a user clicks 'Use as main insight' on a candidate card.
346
- Sanitizes the output, adopts academic rigor, updates the connectome and discovery output.
347
- """
348
- try:
349
- idx = int(route_swap_payload)
350
- except ValueError:
351
- idx = 0
352
- if not (0 <= idx < len(ACADEMIC_INSIGHTS)):
353
- idx = 0
354
- academic = ACADEMIC_INSIGHTS[idx]
355
-
356
- # Update Connectome
357
- connectome_html = build_connectome_html(academic["path"])
358
-
359
- # Update Chat Feedback
360
- result = {
361
- "summary": "Main insight formally adopted. The connectome pathway and validation protocol have been realigned to the selected candidate methodology.",
362
- "primary_hypothesis": academic["hypothesis"]
363
- }
364
- chat_html = build_chat_html(query, result)
365
- # Format Oxford-tier markdown output
366
- metrics_md = "\\n".join(f"- {k}: {v}/100" for k, v in academic["metrics"].items())
367
-
368
- hypothesis_md = (
369
- "# Discovery Output\\n\\n"
370
- f"**Model:** {model_name}\\n\\n"
371
- f"**Primary hypothesis:** {academic['hypothesis']}\\n\\n"
372
- "## Scoring\\n"
373
- f"{metrics_md}\\n\\n"
374
- "## Experimental outline\\n"
375
- f"{academic['outline']}\\n"
376
- )
377
- # We return the new chat, new connectome, leave timeline alone (gr.update()), new output, new state
378
- return chat_html, connectome_html, gr.update(), hypothesis_md, route_state
379
- # ── Example loaders ────────���──────────────────────────────────────────────────
380
- def load_example() -> str:
381
- return "How could a self-assembling conductive biomaterial improve cardiac tissue regeneration by converting mechanical strain into repair signalling?"
382
- def load_paper_topic() -> str:
383
- return "self-assembling conductive biomaterials for cardiac repair"
384
- # ── CSS / HEAD ────────────────────────────────────────────────────────────────
385
- BASE_CSS = r"""
386
- :root {
387
- --bg: #ffffff; --panel: #ffffff; --line: rgba(0,0,0,.12);
388
- --text: #111111; --muted: #5b5b5b; --soft: rgba(0,0,0,.62);
389
- --gold: #ff6600; --teal: #17b8a6; --blue: #628dff;
390
- --chosen: #ff7a1a; --idle: #b8d8ff; --idle-stroke: #5e8fe6;
391
- --query-node: #ffd8b3; --paper-node: #d7f6f2; --upload-node: #e7defe;
392
- --shadow: 0 16px 40px rgba(0,0,0,.12);
393
- }
394
- html,body,.gradio-container { background:#ffffff !important; font-family:Inter,ui-sans-serif,system-ui,sans-serif; }
395
- .gradio-container { max-width:1640px !important; padding:20px !important; }
396
- #dvnc-shell { border:1px solid var(--line); border-radius:28px; overflow:hidden; background:#ffffff; box-shadow:var(--shadow); padding:20px 22px 22px; }
397
- .hero-bar { display:flex; justify-content:space-between; align-items:center; gap:16px; padding-bottom:12px; border-bottom:1px solid rgba(0,0,0,.06); margin-bottom:16px; }
398
- .brand { display:flex; align-items:center; gap:14px; }
399
- .logo { width:42px; height:42px; border-radius:14px; display:grid; place-items:center; color:var(--gold); background:linear-gradient(135deg,rgba(255,122,26,.12),rgba(23,184,166,.10)); border:1px solid rgba(0,0,0,.08); }
400
- .logo svg { width:24px; height:24px; }
401
- .brand h1 { font-size:1.05rem; margin:0; font-weight:700; letter-spacing:.12em; text-transform:uppercase; }
402
- .brand p { margin:3px 0 0; color:var(--muted); font-size:.84rem; }
403
- .status { display:flex; gap:10px; align-items:center; color:var(--soft); font-size:.85rem; }
404
- .status-dot { width:10px; height:10px; border-radius:50%; background:var(--teal); box-shadow:0 0 0 6px rgba(23,184,166,.10),0 0 14px rgba(23,184,166,.25); }
405
- .panel { background:#ffffff; border:1px solid var(--line); border-radius:22px; box-shadow:inset 0 1px 0 rgba(255,255,255,.8); }
406
- .querybox textarea,.querybox input { background:transparent !important; color:var(--text) !important; }
407
- .querybox,.querybox>div { background:#ffffff !important; border-radius:18px !important; border-color:var(--line) !important; }
408
- .chat-panel { padding:18px; min-height:280px; }
409
- .chat-thread { display:flex; flex-direction:column; gap:14px; }
410
- .bubble { max-width:88%; padding:16px 18px; border-radius:22px; border:1px solid var(--line); }
411
- .bubble p { margin:8px 0 0; line-height:1.6; font-size:.96rem; color:var(--text); }
412
- .bubble .role { font-size:.72rem; letter-spacing:.12em; text-transform:uppercase; color:var(--muted); }
413
- .bubble-user { align-self:flex-end; background:linear-gradient(135deg,rgba(98,141,255,.16),rgba(98,141,255,.08)); }
414
- .bubble-ai { align-self:flex-start; background:#ffffff; }
415
- .bubble-system { align-self:flex-start; background:linear-gradient(135deg,rgba(255,122,26,.10),rgba(255,122,26,.04)); }
416
- .model-switcher { display:grid; grid-template-columns:repeat(3,1fr); gap:12px; }
417
- .model-pill { padding:14px; border:1px solid var(--line); border-radius:18px; display:flex; flex-direction:column; gap:4px; min-height:98px; background:#ffffff; }
418
- .model-pill.active { border-color:rgba(255,122,26,.40); background:linear-gradient(135deg,rgba(255,122,26,.10),rgba(255,255,255,.96)); }
419
- .model-name { font-weight:650; color:var(--text); }
420
- .model-tag { font-size:.76rem; text-transform:uppercase; letter-spacing:.12em; color:var(--gold); }
421
- .model-pill small { color:var(--muted); line-height:1.45; }
422
- .brain-shell { padding:18px; }
423
- .brain-header { display:flex; justify-content:space-between; align-items:flex-end; gap:16px; margin-bottom:10px; }
424
- .eyebrow { font-size:.72rem; letter-spacing:.16em; text-transform:uppercase; color:var(--gold); margin:0 0 4px; }
425
- .brain-header h3 { margin:0; font-size:1.12rem; color:var(--text); }
426
- .brain-legend { display:flex; gap:14px; color:var(--muted); font-size:.8rem; flex-wrap:wrap; }
427
- .dot { width:10px; height:10px; display:inline-block; border-radius:50%; margin-right:6px; }
428
- .dot-live { background:var(--chosen); box-shadow:0 0 10px rgba(255,122,26,.35); }
429
- .dot-chosen { background:var(--chosen); }
430
- .dot-idle { background:var(--idle); border:1px solid var(--idle-stroke); }
431
- .dot-query { background:var(--query-node); border:1px solid #de9e58; }
432
- .dot-paper { background:var(--paper-node); border:1px solid #4fb3a5; }
433
- .dot-upload { background:var(--upload-node); border:1px solid #8f73d9; }
434
- .brain-stage { position:relative; min-height:420px; overflow:hidden; background:linear-gradient(180deg,rgba(250,250,250,1),rgba(255,255,255,1)); border:1px solid rgba(0,0,0,.05); border-radius:20px; }
435
- .brain-svg { width:100%; height:520px; display:block; }
436
- .edge { stroke:rgba(0,0,0,.12); stroke-width:2.4; }
437
- .edge.active { stroke:var(--chosen); stroke-width:4.2; stroke-linecap:round; filter:drop-shadow(0 0 6px rgba(255,122,26,.45)); stroke-dasharray:8 12; animation:pulseEdge 1.5s linear infinite; }
438
- .node { stroke-width:2.2; transition:all .25s ease; }
439
- .node.idle { fill:var(--idle); stroke:var(--idle-stroke); }
440
- .node.chosen { fill:var(--chosen); stroke:#ffb16d; }
441
- .halo { fill:none; }
442
- .halo.active { stroke:rgba(255,122,26,.18); stroke-width:12; }
443
- .label { fill:#2c2c2c; font-size:13px; font-weight:500; letter-spacing:.01em; }
444
- .label.active { fill:#111111; font-weight:700; }
445
- .learn-edge { stroke:rgba(0,0,0,.18); stroke-width:2.2; stroke-linecap:round; }
446
- .learn-node { stroke-width:2.2; }
447
- .learn-node.query { fill:var(--query-node); stroke:#de9e58; }
448
- .learn-node.paper { fill:var(--paper-node); stroke:#36a091; }
449
- .learn-node.upload { fill:var(--upload-node); stroke:#7e63cb; }
450
- .learn-label { fill:#1e1e1e; font-size:12px; font-weight:600; }
451
- .learning-empty { display:grid; place-items:center; }
452
- .empty-graph-copy { text-align:center; max-width:440px; padding:40px 20px; }
453
- .empty-graph-copy h4 { margin:0 0 10px; font-size:1.05rem; }
454
- .empty-graph-copy p { margin:0; color:var(--muted); line-height:1.6; }
455
- .timeline { display:flex; flex-direction:column; gap:10px; }
456
- .agent-step { border:1px solid var(--line); border-radius:18px; background:#ffffff; overflow:hidden; }
457
- .agent-summary { list-style:none; display:grid; grid-template-columns:42px 1fr; gap:12px; align-items:center; padding:12px; cursor:pointer; }
458
- .agent-summary::-webkit-details-marker { display:none; }
459
- .agent-index { width:42px; height:42px; border-radius:14px; display:grid; place-items:center; font-weight:700; color:var(--gold); background:rgba(255,122,26,.08); border:1px solid rgba(255,122,26,.18); }
460
- .agent-head { display:flex; justify-content:space-between; gap:12px; align-items:center; }
461
- .agent-head h4 { margin:0; font-size:.98rem; color:var(--text); }
462
- .agent-head span { font-size:.72rem; letter-spacing:.12em; text-transform:uppercase; color:var(--muted); }
463
- .agent-copy { padding:0 14px 16px 66px; }
464
- .agent-copy p { margin:0; color:#2d2d2d; font-size:.93rem; line-height:1.6; }
465
- .candidate-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:18px; }
466
- .candidate-card { background:none; perspective:1400px; min-height:330px; }
467
- .candidate-card-inner { position:relative; width:100%; min-height:330px; transition:transform .8s cubic-bezier(.2,.7,.1,1); transform-style:preserve-3d; }
468
- .candidate-card:hover .candidate-card-inner,.candidate-card:focus .candidate-card-inner,.candidate-card:focus-within .candidate-card-inner { transform:rotateY(180deg); }
469
- .candidate-face { position:absolute; inset:0; padding:20px; border-radius:22px; border:1px solid var(--line); background:#ffffff; color:var(--text); backface-visibility:hidden; box-shadow:0 12px 24px rgba(0,0,0,.06); display:flex; flex-direction:column; gap:14px; }
470
- .candidate-back { transform:rotateY(180deg); }
471
- .candidate-top { display:flex; justify-content:space-between; align-items:center; gap:8px; }
472
- .chip { font-size:.72rem; text-transform:uppercase; letter-spacing:.12em; color:#0b6f66; padding:7px 10px; border-radius:999px; background:rgba(23,184,166,.08); border:1px solid rgba(23,184,166,.18); }
473
- .chip.alt { color:var(--gold); background:rgba(255,122,26,.08); border-color:rgba(255,122,26,.18); }
474
- .score { font-weight:700; color:var(--gold); }
475
- .candidate-face h4 { margin:0; font-size:1.08rem; line-height:1.35; }
476
- .candidate-face p { margin:0; color:#1e1e1e; line-height:1.65; font-size:.96rem; overflow-wrap:anywhere; }
477
- .meta-row { margin-top:auto; display:flex; justify-content:space-between; color:var(--muted); font-size:.88rem; gap:14px; }
478
- .mini { cursor:pointer; margin-top:8px; align-self:flex-start; color:var(--text); padding:10px 12px; border-radius:14px; border:1px solid var(--line); background:#ffffff; transition:all 0.2s; }
479
- .mini:hover { background: #f5f5f5; border-color: var(--chosen); }
480
- .papers-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:14px; }
481
- .paper-card { border:1px solid var(--line); border-radius:18px; padding:16px; background:#ffffff; }
482
- .paper-topline { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:10px; }
483
- .paper-badge { font-size:.72rem; padding:6px 10px; border-radius:999px; background:rgba(98,141,255,.08); color:#3456b5; border:1px solid rgba(98,141,255,.18); }
484
- .paper-badge.alt { background:rgba(0,0,0,.04); color:#444; border-color:rgba(0,0,0,.08); }
485
- .doi-badge { background:rgba(255,122,26,.08); color:#8a4105; border-color:rgba(255,122,26,.18); }
486
- .paper-card h4 { margin:0 0 10px; line-height:1.35; font-size:1rem; }
487
- .paper-card p { margin:0 0 12px; line-height:1.6; color:#222; }
488
- .paper-links { display:flex; gap:12px; flex-wrap:wrap; }
489
- .paper-meta-stack { display:flex; flex-direction:column; gap:6px; color:#444; margin-bottom:12px; font-size:.9rem; }
490
- .paper-links a,.journal-card,.upload-note a { color:#0b63ce; text-decoration:none; }
491
- .journal-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:14px; }
492
- .journal-card { border:1px solid var(--line); border-radius:18px; padding:16px; display:flex; justify-content:space-between; gap:14px; align-items:center; background:#ffffff; }
493
- .journal-card h4 { margin:0 0 6px; }
494
- .journal-card p { margin:0; color:var(--muted); line-height:1.5; }
495
- .upload-note { border:1px dashed rgba(0,0,0,.16); border-radius:18px; padding:16px; background:rgba(0,0,0,.015); color:#1f1f1f; line-height:1.6; }
496
- .prosebox { padding:18px; white-space:pre-wrap; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; line-height:1.55; color:#1b1b1b; }
497
- .gr-button-primary { background:linear-gradient(135deg,rgba(255,122,26,.92),rgba(240,108,22,.92)) !important; color:#ffffff !important; border:none !important; }
498
- .gr-button-secondary { background:#ffffff !important; color:var(--text) !important; border:1px solid var(--line) !important; }
499
- .ref-list { margin:0; padding-left:18px; }
500
- .ref-list li { margin-bottom:8px; line-height:1.5; }
501
- .parse-grid { display:grid; grid-template-columns:1.2fr 1fr; gap:14px; }
502
- .parse-card { border:1px solid var(--line); border-radius:18px; padding:16px; background:#ffffff; }
503
- .selection-panel { padding:18px; }
504
- footer { display:none !important; }
505
- @keyframes pulseEdge { to { stroke-dashoffset:-40; } }
506
- @media (max-width:1180px) {
507
- .model-switcher,.candidate-grid,.papers-grid,.journal-grid,.parse-grid { grid-template-columns:1fr; }
508
- .brain-svg { height:460px; }
509
- }
510
- """
511
- CSS = BASE_CSS + "\\n" + get_dvnc_layout_css()
512
- HEAD = """
513
- <link rel="preconnect" href="https://fonts.googleapis.com">
514
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
515
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
516
- <script>
517
- function triggerRouteSwap(idx) {
518
- const container = document.getElementById('route_swap_payload');
519
- if(!container) return;
520
- const input = container.querySelector('textarea') || container.querySelector('input');
521
- if(input) {
522
- input.value = idx.toString();
523
- input.dispatchEvent(new Event('input', { bubbles: true }));
524
- setTimeout(() => {
525
- const btn = document.getElementById('route_swap_apply');
526
- if(btn) btn.click();
527
- }, 150);
528
- }
529
- }
530
- </script>
531
- """
532
- # ── Gradio layout ─────────────────────────────────────────────────────────────
533
- with gr.Blocks(css=CSS, head=HEAD, theme=gr.themes.Base(), fill_height=True) as demo:
534
- # ── Shared state ──────────────────────────────────────────────────────────
535
- papers_state = gr.State([])
536
- parsed_pdf_state = gr.State({})
537
- ingest_payload_state = gr.State({})
538
- route_state = gr.State(get_default_route_state())
539
- # ── Header ────────────────────────────────────────────────────────────────
540
- gr.HTML("""
541
- <div id="dvnc-shell">
542
- <div class="hero-bar">
543
- <div class="brand">
544
- <div class="logo" aria-hidden="true">
545
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7">
546
- <path d="M5 17L12 4l7 13"/>
547
- <path d="M8.5 12.5h7"/>
548
- <circle cx="12" cy="12" r="1.8" fill="currentColor" stroke="none"/>
549
- </svg>
550
- </div>
551
- <div>
552
- <h1>DVNC.AI</h1>
553
- <p>Sovereign discovery instrument · connectome-native reasoning</p>
554
- </div>
555
- </div>
556
- <div class="status"><span class="status-dot"></span><span>Live orchestration</span></div>
557
- </div>
558
- </div>
559
- """)
560
- with gr.Tabs():
561
- # ── Tab 1 · Discovery Engine ──────────────────────────────────────────
562
- with gr.Tab("Discovery Engine"):
563
- model_html = gr.HTML(build_models_html("DVNC Sovereign"))
564
- with gr.Row():
565
- with gr.Column(scale=2):
566
- model = gr.Dropdown(
567
- choices=[m["name"] for m in MODELS],
568
- value="DVNC Sovereign",
569
- label="Model tier",
570
- )
571
- query = gr.Textbox(
572
- label="Discovery query",
573
- elem_classes=["querybox"],
574
- placeholder="Enter a scientific question, anomaly, or breakthrough direction…",
575
- lines=4,
576
- )
577
- with gr.Row():
578
- run_btn = gr.Button("Run discovery", variant="primary")
579
- example_btn = gr.Button("Load example", variant="secondary")
580
- chat = gr.HTML("""
581
- <div class="panel chat-panel">
582
- <div class="chat-thread">
583
- <div class="bubble bubble-ai">
584
- <span class="role">DVNC</span>
585
- <p>Enter a query to activate the 7-agent discovery stack and illuminate the chosen path through the 3D connectome.</p>
586
- </div>
587
- </div>
588
- </div>
589
- """)
590
- with gr.Column(scale=3):
591
- connectome = gr.HTML(build_connectome_html(DEFAULT_PATH))
592
- cards = gr.HTML("")
593
- output = gr.Markdown("# Discovery Output\\n\\nAwaiting query.")
594
- timeline = gr.HTML(get_initial_discovery_timeline_html())
595
- route_swap_payload = gr.Textbox(value="", visible=False, elem_id="route_swap_payload")
596
- route_swap_apply = gr.Button("Apply route swap", visible=False, elem_id="route_swap_apply")
597
- # ── Tab 2 · Self-Learning Graph ───────────────────────────────────────
598
- with gr.Tab("Self-Learning Graph"):
599
- with gr.Row():
600
- with gr.Column(scale=2):
601
- paper_query = gr.Textbox(
602
- label="Research topic / title / DOI / link",
603
- elem_classes=["querybox"],
604
- placeholder="e.g. self-assembling conductive biomaterials for cardiac repair",
605
- lines=3,
606
- )
607
- search_mode = gr.Dropdown(
608
- choices=SEARCH_MODES,
609
- value="topic",
610
- label="Search mode",
611
- )
612
- source_selector = gr.CheckboxGroup(
613
- choices=SOURCE_OPTIONS,
614
- value=DEFAULT_SOURCES,
615
- label="Sources",
616
- )
617
- pdf_upload = gr.File(label="Upload PDF papers", file_types=[".pdf"], file_count="single")
618
- with gr.Row():
619
- learn_btn = gr.Button("Discover papers", variant="primary")
620
- load_topic_btn = gr.Button("Load example topic", variant="secondary")
621
- upload_status = gr.Markdown("No PDF uploaded yet.")
622
- discovery_status = gr.Markdown("### No discovery results yet.")
623
- journal_panel = gr.HTML(build_journal_html("biomaterials cardiac repair"))
624
- gr.HTML(\'<div class="panel selection-panel"><h3 style="margin:0 0 12px;">Select papers to ingest</h3></div>\')
625
- selection_box = gr.CheckboxGroup(choices=[], value=[], label="Candidate papers")
626
- parser_order = gr.CheckboxGroup(
627
- choices=["grobid", "docling", "pymupdf"],
628
- value=["grobid", "docling", "pymupdf"],
629
- label="Parser routing order",
630
- )
631
- with gr.Row():
632
- parse_btn = gr.Button("Parse uploaded PDF", variant="secondary")
633
- ingest_btn = gr.Button("Ingest selected into graph", variant="primary")
634
- with gr.Column(scale=3):
635
- learning_graph = gr.HTML(build_learning_graph_html([], []))
636
- papers_panel = gr.HTML(\'<div class="panel papers-panel" style="padding:18px"><p>Search by topic, title, DOI, or link, then select papers before graph ingestion.</p></div>\')
637
- parse_summary = gr.Markdown("### PDF parse status\\n\\nAwaiting upload.")
638
- parse_panel = gr.HTML(\'<div class="panel" style="padding:18px"><p>No parsed document yet.</p></div>\')
639
- ingest_summary = gr.Markdown("### Graph ingest status\\n\\nAwaiting paper selection.")
640
- ingest_payload = gr.JSON(label="Graph ingest payload", value={"status": "empty", "nodes": [], "edges": []})
641
- # ── Event wiring ──────────────────────────────────────────────────────────
642
- example_btn.click(fn=load_example, outputs=query)
643
- run_btn.click(
644
- fn=run_discovery,
645
- inputs=[query, model],
646
- outputs=[chat, connectome, timeline, cards, output, model_html, route_state],
647
- )
648
- route_swap_apply.click(
649
- fn=apply_route_swap,
650
- inputs=[query, model, route_swap_payload, route_state],
651
- outputs=[chat, connectome, timeline, output, route_state],
652
- )
653
- load_topic_btn.click(fn=load_paper_topic, outputs=paper_query)
654
- learn_btn.click(
655
- fn=run_paper_discovery,
656
- inputs=[paper_query, search_mode, source_selector, pdf_upload],
657
- outputs=[learning_graph, papers_panel, journal_panel, upload_status, selection_box, papers_state, discovery_status],
658
- )
659
- parse_btn.click(
660
- fn=parse_uploaded_pdf,
661
- inputs=[pdf_upload, parser_order],
662
- outputs=[parse_summary, parsed_pdf_state],
663
- ).then(
664
- fn=render_parse_result,
665
- inputs=[parsed_pdf_state],
666
- outputs=[parse_panel],
667
- )
668
- ingest_btn.click(
669
- fn=ingest_selected_papers,
670
- inputs=[paper_query, selection_box, papers_state, pdf_upload, parsed_pdf_state],
671
- outputs=[learning_graph, ingest_summary, ingest_payload],
672
- )
673
- if __name__ == "__main__":
674
- demo.launch()
675
-
676
- with open("app.py", "w") as f:
677
- f.write(app_py)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dvnc_ai_v2_hf/deprecated/self_learning_graph_old.py DELETED
@@ -1,1001 +0,0 @@
1
- import html
2
- import os
3
- import re
4
- import urllib.parse
5
- import xml.etree.ElementTree as ET
6
- from pathlib import Path
7
- from typing import Dict, List, Optional
8
- from urllib.parse import quote
9
-
10
- import gradio as gr
11
- import requests
12
-
13
- try:
14
- import fitz # PyMuPDF
15
- except Exception:
16
- fitz = None
17
-
18
- try:
19
- from bs4 import BeautifulSoup
20
- except Exception:
21
- BeautifulSoup = None
22
-
23
-
24
- JOURNALS = [
25
- {"name": "Nature", "url": "https://www.nature.com/search", "desc": "Flagship multidisciplinary research journal."},
26
- {"name": "Science", "url": "https://www.science.org/search", "desc": "High-impact science journal and family."},
27
- {"name": "Cell", "url": "https://www.cell.com/search", "desc": "Life sciences and translational biology."},
28
- {"name": "The Lancet", "url": "https://www.thelancet.com/search", "desc": "Clinical and medical research."},
29
- {"name": "IEEE Xplore", "url": "https://ieeexplore.ieee.org/search/searchresult.jsp", "desc": "Engineering, AI, signal processing, and systems."},
30
- ]
31
-
32
- SEARCH_MODES = ["topic", "title", "doi", "link", "paper_name", "autonomous_web"]
33
- SOURCE_OPTIONS = ["arxiv", "crossref", "openalex", "semantic_scholar", "europe_pmc"]
34
- DEFAULT_SOURCES = ["arxiv", "openalex", "crossref", "semantic_scholar", "europe_pmc"]
35
-
36
- SEMANTIC_SCHOLAR_API_KEY = os.getenv("SEMANTIC_SCHOLAR_API_KEY", "")
37
- GROBID_URL = os.getenv("GROBID_URL", "").strip()
38
- REQUEST_TIMEOUT = 25
39
-
40
-
41
- def safe_text(x, default=""):
42
- return html.escape(str(x if x is not None else default))
43
-
44
-
45
- def norm_text(x: Optional[str]) -> str:
46
- return re.sub(r"\s+", " ", (x or "")).strip()
47
-
48
-
49
- def detect_query_type(query: str) -> str:
50
- q = (query or "").strip()
51
- doi_pattern = r"^10\.\d{4,9}/[-._;()/:A-Z0-9]+$"
52
- if re.match(doi_pattern, q, flags=re.I):
53
- return "doi"
54
- if q.startswith("http://") or q.startswith("https://"):
55
- return "link"
56
- return "topic"
57
-
58
-
59
- def ensure_list(x):
60
- return x if isinstance(x, list) else []
61
-
62
-
63
- def build_learning_graph_html(nodes, edges, title="Self-Learning Knowledge Graph"):
64
- if not nodes:
65
- return """
66
- <div class="panel brain-shell">
67
- <div class="brain-header">
68
- <div>
69
- <p class="eyebrow">Learning Graph</p>
70
- <h3>Self-Learning Knowledge Graph</h3>
71
- </div>
72
- </div>
73
- <div class="brain-stage learning-empty">
74
- <div class="empty-graph-copy">
75
- <h4>No papers mapped yet</h4>
76
- <p>Search papers, pick a topic, select candidates, or upload a PDF to grow the graph in real time.</p>
77
- </div>
78
- </div>
79
- </div>
80
- """
81
-
82
- node_items = []
83
- label_items = []
84
- edge_items = []
85
- coords = [(110, 110), (320, 80), (540, 130), (660, 270), (550, 410), (300, 450), (110, 340), (380, 260)]
86
-
87
- graph_nodes = [dict(n) for n in nodes[:8]]
88
- for i, node in enumerate(graph_nodes):
89
- x, y = coords[i]
90
- node["sx"] = x
91
- node["sy"] = y
92
-
93
- node_map = {n["id"]: n for n in graph_nodes}
94
- for a, b in edges:
95
- if a in node_map and b in node_map:
96
- na = node_map[a]
97
- nb = node_map[b]
98
- edge_items.append(
99
- f'<line class="learn-edge" x1="{na["sx"]}" y1="{na["sy"]}" x2="{nb["sx"]}" y2="{nb["sy"]}" />'
100
- )
101
-
102
- for node in graph_nodes:
103
- kind = node.get("kind", "paper")
104
- klass = f'learn-node {kind}'
105
- radius = 24 if kind == "query" else 20
106
- node_items.append(
107
- f'<circle class="{klass}" cx="{node["sx"]}" cy="{node["sy"]}" r="{radius}" />'
108
- )
109
- label_items.append(
110
- f'<text class="learn-label" x="{node["sx"] + 28}" y="{node["sy"] - 10}">{safe_text(node["label"][:44])}</text>'
111
- )
112
-
113
- return f"""
114
- <div class="panel brain-shell">
115
- <div class="brain-header">
116
- <div>
117
- <p class="eyebrow">Learning Graph</p>
118
- <h3>{safe_text(title)}</h3>
119
- </div>
120
- <div class="brain-legend">
121
- <span><i class="dot dot-query"></i> query</span>
122
- <span><i class="dot dot-paper"></i> paper</span>
123
- <span><i class="dot dot-upload"></i> uploaded PDF</span>
124
- </div>
125
- </div>
126
- <div class="brain-stage">
127
- <svg viewBox="0 0 760 520" class="brain-svg" role="img" aria-label="Self-learning knowledge graph">
128
- {''.join(edge_items)}
129
- {''.join(node_items)}
130
- {''.join(label_items)}
131
- </svg>
132
- </div>
133
- </div>
134
- """
135
-
136
-
137
- def build_journal_html(query):
138
- q = urllib.parse.quote_plus(query or "biomaterials cardiac repair")
139
- rows = []
140
- for journal in JOURNALS:
141
- url = f"{journal['url']}?q={q}" if "?" not in journal["url"] else f"{journal['url']}&q={q}"
142
- if "ieeexplore" in journal["url"]:
143
- url = f"https://ieeexplore.ieee.org/search/searchresult.jsp?queryText={q}"
144
- rows.append(
145
- f"""
146
- <a class="journal-card" href="{safe_text(url)}" target="_blank" rel="noopener noreferrer">
147
- <div>
148
- <h4>{safe_text(journal['name'])}</h4>
149
- <p>{safe_text(journal['desc'])}</p>
150
- </div>
151
- <span>Open</span>
152
- </a>
153
- """
154
- )
155
- return '<div class="journal-grid">' + ''.join(rows) + '</div>'
156
-
157
-
158
- def search_arxiv(query, max_results=8):
159
- encoded = urllib.parse.quote(query)
160
- url = (
161
- "http://export.arxiv.org/api/query?search_query=all:"
162
- f"{encoded}&start=0&max_results={max_results}&sortBy=relevance&sortOrder=descending"
163
- )
164
- response = requests.get(url, timeout=REQUEST_TIMEOUT)
165
- response.raise_for_status()
166
- root = ET.fromstring(response.text)
167
- ns = {"atom": "http://www.w3.org/2005/Atom"}
168
- papers = []
169
- for entry in root.findall("atom:entry", ns):
170
- title = " ".join((entry.findtext("atom:title", default="", namespaces=ns) or "").split())
171
- summary = " ".join((entry.findtext("atom:summary", default="", namespaces=ns) or "").split())
172
- published = entry.findtext("atom:published", default="", namespaces=ns)
173
- paper_id = entry.findtext("atom:id", default="", namespaces=ns)
174
- authors = [a.findtext("atom:name", default="", namespaces=ns) for a in entry.findall("atom:author", ns)]
175
- pdf_url = ""
176
- for link in entry.findall("atom:link", ns):
177
- if link.attrib.get("title") == "pdf":
178
- pdf_url = link.attrib.get("href", "")
179
- break
180
- papers.append(
181
- {
182
- "id": paper_id or title,
183
- "title": title,
184
- "summary": summary,
185
- "abstract": summary,
186
- "published": published[:10],
187
- "authors": [a for a in authors[:8] if a],
188
- "authors_text": ", ".join([a for a in authors[:4] if a]),
189
- "url": paper_id,
190
- "pdf": pdf_url,
191
- "doi": "",
192
- "venue": "arXiv",
193
- "year": published[:4] if published else "",
194
- "source": "arxiv",
195
- "score": 0.76,
196
- "open_access": True,
197
- "external_ids": {"arxiv": (paper_id or "").split("/")[-1]},
198
- }
199
- )
200
- return papers
201
-
202
-
203
- def search_crossref(query, mode="topic", max_results=8):
204
- headers = {"User-Agent": "dvnc-ai-space/0.1"}
205
- if mode == "doi":
206
- url = f"https://api.crossref.org/works/{quote(query)}"
207
- response = requests.get(url, headers=headers, timeout=REQUEST_TIMEOUT)
208
- if response.status_code != 200:
209
- return []
210
- items = [response.json().get("message", {})]
211
- else:
212
- params = {"rows": max_results}
213
- if mode in ("title", "paper_name"):
214
- params["query.title"] = query
215
- else:
216
- params["query.bibliographic"] = query
217
- response = requests.get("https://api.crossref.org/works", params=params, headers=headers, timeout=REQUEST_TIMEOUT)
218
- response.raise_for_status()
219
- items = response.json().get("message", {}).get("items", [])
220
-
221
- out = []
222
- for item in items:
223
- authors = []
224
- for a in item.get("author", []) or []:
225
- name = " ".join(filter(None, [a.get("given"), a.get("family")])).strip()
226
- if name:
227
- authors.append(name)
228
- title = (item.get("title") or ["Untitled"])[0]
229
- year = ""
230
- for key in ["published-print", "published-online", "created"]:
231
- if item.get(key, {}).get("date-parts"):
232
- year = str(item[key]["date-parts"][0][0])
233
- break
234
- abstract = item.get("abstract") or ""
235
- abstract = re.sub("<.*?>", "", abstract)
236
- doi = item.get("DOI", "")
237
- out.append({
238
- "id": doi or title,
239
- "title": norm_text(title),
240
- "summary": norm_text(abstract)[:400],
241
- "abstract": norm_text(abstract),
242
- "published": year,
243
- "authors": authors,
244
- "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
245
- "url": item.get("URL", ""),
246
- "pdf": "",
247
- "doi": doi,
248
- "venue": (item.get("container-title") or [""])[0],
249
- "year": year,
250
- "source": "crossref",
251
- "score": 0.72,
252
- "open_access": None,
253
- "external_ids": {"crossref": doi} if doi else {},
254
- })
255
- return out
256
-
257
-
258
- def search_openalex(query, mode="topic", max_results=8):
259
- params = {"per-page": max_results}
260
- if mode == "doi":
261
- doi = query.lower().replace("https://doi.org/", "").replace("http://doi.org/", "")
262
- params["filter"] = f"doi:https://doi.org/{doi}"
263
- else:
264
- params["search"] = query
265
- response = requests.get("https://api.openalex.org/works", params=params, timeout=REQUEST_TIMEOUT)
266
- response.raise_for_status()
267
- items = response.json().get("results", [])
268
- out = []
269
- for item in items:
270
- authors = []
271
- for auth in item.get("authorships", [])[:8]:
272
- author = auth.get("author") or {}
273
- if author.get("display_name"):
274
- authors.append(author["display_name"])
275
- oa = item.get("open_access") or {}
276
- doi = (item.get("doi") or "").replace("https://doi.org/", "")
277
- out.append({
278
- "id": item.get("id") or doi or item.get("title"),
279
- "title": norm_text(item.get("title")),
280
- "summary": "",
281
- "abstract": "",
282
- "published": str(item.get("publication_year") or ""),
283
- "authors": authors,
284
- "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
285
- "url": (item.get("primary_location") or {}).get("landing_page_url") or item.get("id") or "",
286
- "pdf": oa.get("oa_url") or "",
287
- "doi": doi,
288
- "venue": ((item.get("primary_location") or {}).get("source") or {}).get("display_name") or "",
289
- "year": str(item.get("publication_year") or ""),
290
- "source": "openalex",
291
- "score": 0.80,
292
- "open_access": oa.get("is_oa"),
293
- "external_ids": item.get("ids") or {},
294
- })
295
- return out
296
-
297
-
298
- def search_semantic_scholar(query, mode="topic", max_results=8):
299
- headers = {}
300
- if SEMANTIC_SCHOLAR_API_KEY:
301
- headers["x-api-key"] = SEMANTIC_SCHOLAR_API_KEY
302
- fields = "title,authors,year,abstract,venue,externalIds,url,openAccessPdf"
303
- if mode == "doi":
304
- doi = query.lower().replace("https://doi.org/", "").replace("http://doi.org/", "")
305
- url = f"https://api.semanticscholar.org/graph/v1/paper/DOI:{quote(doi)}"
306
- response = requests.get(url, params={"fields": fields}, headers=headers, timeout=REQUEST_TIMEOUT)
307
- if response.status_code != 200:
308
- return []
309
- items = [response.json()]
310
- else:
311
- response = requests.get(
312
- "https://api.semanticscholar.org/graph/v1/paper/search",
313
- params={"query": query, "limit": max_results, "fields": fields},
314
- headers=headers,
315
- timeout=REQUEST_TIMEOUT,
316
- )
317
- if response.status_code != 200:
318
- return []
319
- items = response.json().get("data", [])
320
-
321
- out = []
322
- for item in items:
323
- external = item.get("externalIds") or {}
324
- authors = [a.get("name") for a in item.get("authors", []) if a.get("name")]
325
- out.append({
326
- "id": external.get("CorpusId") or external.get("DOI") or item.get("title"),
327
- "title": norm_text(item.get("title")),
328
- "summary": norm_text(item.get("abstract", ""))[:400],
329
- "abstract": norm_text(item.get("abstract", "")),
330
- "published": str(item.get("year") or ""),
331
- "authors": authors,
332
- "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
333
- "url": item.get("url") or "",
334
- "pdf": (item.get("openAccessPdf") or {}).get("url") or "",
335
- "doi": external.get("DOI", ""),
336
- "venue": item.get("venue") or "",
337
- "year": str(item.get("year") or ""),
338
- "source": "semantic_scholar",
339
- "score": 0.84,
340
- "open_access": bool((item.get("openAccessPdf") or {}).get("url")),
341
- "external_ids": external,
342
- })
343
- return out
344
-
345
-
346
- def search_europe_pmc(query, mode="topic", max_results=8):
347
- epmc_query = f'DOI:"{query}"' if mode == "doi" else query
348
- params = {
349
- "query": epmc_query,
350
- "format": "json",
351
- "pageSize": max_results,
352
- "resultType": "core",
353
- }
354
- response = requests.get("https://www.ebi.ac.uk/europepmc/webservices/rest/search", params=params, timeout=REQUEST_TIMEOUT)
355
- if response.status_code != 200:
356
- return []
357
- items = response.json().get("resultList", {}).get("result", [])
358
- out = []
359
- for item in items:
360
- author_string = item.get("authorString", "")
361
- authors = [x.strip() for x in author_string.split(",")[:8] if x.strip()]
362
- pmcid = item.get("pmcid", "")
363
- pdf_url = f"https://europepmc.org/articles/{pmcid}?pdf=render" if pmcid else ""
364
- landing_url = f"https://europepmc.org/article/PMC/{pmcid}" if pmcid else ""
365
- out.append({
366
- "id": item.get("id") or item.get("doi") or item.get("title"),
367
- "title": norm_text(item.get("title")),
368
- "summary": norm_text(item.get("abstractText", ""))[:400],
369
- "abstract": norm_text(item.get("abstractText", "")),
370
- "published": str(item.get("pubYear") or ""),
371
- "authors": authors,
372
- "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
373
- "url": landing_url,
374
- "pdf": pdf_url,
375
- "doi": item.get("doi", ""),
376
- "venue": item.get("journalTitle", ""),
377
- "year": str(item.get("pubYear") or ""),
378
- "source": "europe_pmc",
379
- "score": 0.78,
380
- "open_access": bool(pmcid),
381
- "external_ids": {"pmid": item.get("pmid"), "pmcid": pmcid},
382
- })
383
- return out
384
-
385
-
386
- def resolve_link(query):
387
- url = (query or "").strip()
388
- if not url:
389
- return []
390
- try:
391
- response = requests.get(
392
- url,
393
- timeout=REQUEST_TIMEOUT,
394
- allow_redirects=True,
395
- headers={"User-Agent": "dvnc-ai-space/0.1"},
396
- )
397
- content_type = response.headers.get("content-type", "")
398
- if "pdf" in content_type or url.lower().endswith(".pdf"):
399
- name = Path(url.split("?")[0]).name or "linked-paper.pdf"
400
- return [{
401
- "id": url,
402
- "title": name,
403
- "summary": "Direct PDF link detected.",
404
- "abstract": "",
405
- "published": "",
406
- "authors": [],
407
- "authors_text": "Unknown authors",
408
- "url": url,
409
- "pdf": url,
410
- "doi": "",
411
- "venue": "Direct PDF",
412
- "year": "",
413
- "source": "link",
414
- "score": 0.66,
415
- "open_access": True,
416
- "external_ids": {},
417
- }]
418
-
419
- if BeautifulSoup is None:
420
- return [{
421
- "id": url,
422
- "title": url,
423
- "summary": "Web page resolved. Install beautifulsoup4 for DOI extraction.",
424
- "abstract": "",
425
- "published": "",
426
- "authors": [],
427
- "authors_text": "Unknown authors",
428
- "url": url,
429
- "pdf": "",
430
- "doi": "",
431
- "venue": "Web Link",
432
- "year": "",
433
- "source": "link",
434
- "score": 0.48,
435
- "open_access": None,
436
- "external_ids": {},
437
- }]
438
-
439
- soup = BeautifulSoup(response.text, "html.parser")
440
- doi = ""
441
- for meta_name in ["citation_doi", "dc.identifier", "dc.Identifier"]:
442
- tag = soup.find("meta", attrs={"name": meta_name})
443
- if tag and tag.get("content"):
444
- doi = tag["content"].strip()
445
- break
446
-
447
- title = soup.title.text.strip() if soup.title else url
448
- pdf_link = ""
449
- for a in soup.find_all("a", href=True):
450
- href = a["href"]
451
- if ".pdf" in href.lower():
452
- pdf_link = href if href.startswith("http") else ""
453
- break
454
-
455
- if doi:
456
- results = search_crossref(doi, mode="doi", max_results=1)
457
- if results:
458
- if pdf_link and not results[0].get("pdf"):
459
- results[0]["pdf"] = pdf_link
460
- if url and not results[0].get("url"):
461
- results[0]["url"] = url
462
- return results
463
-
464
- return [{
465
- "id": url,
466
- "title": title,
467
- "summary": "Landing page resolved from direct link.",
468
- "abstract": "",
469
- "published": "",
470
- "authors": [],
471
- "authors_text": "Unknown authors",
472
- "url": url,
473
- "pdf": pdf_link,
474
- "doi": doi,
475
- "venue": "Web Link",
476
- "year": "",
477
- "source": "link",
478
- "score": 0.54,
479
- "open_access": bool(pdf_link),
480
- "external_ids": {},
481
- }]
482
- except Exception as e:
483
- return [{
484
- "id": url,
485
- "title": "Link resolution error",
486
- "summary": str(e),
487
- "abstract": "",
488
- "published": "",
489
- "authors": [],
490
- "authors_text": "Unknown authors",
491
- "url": url,
492
- "pdf": "",
493
- "doi": "",
494
- "venue": "Link",
495
- "year": "",
496
- "source": "link",
497
- "score": 0.20,
498
- "open_access": None,
499
- "external_ids": {},
500
- }]
501
-
502
-
503
- def dedupe_papers(items: List[Dict]) -> List[Dict]:
504
- seen = {}
505
- for item in items:
506
- key = (
507
- (item.get("doi") or "").lower().strip()
508
- or (item.get("external_ids") or {}).get("arxiv")
509
- or norm_text(item.get("title", "")).lower()
510
- or item.get("id")
511
- )
512
- if not key:
513
- key = f"{item.get('source')}::{item.get('title')}"
514
- if key not in seen or float(item.get("score", 0)) > float(seen[key].get("score", 0)):
515
- seen[key] = item
516
- return sorted(seen.values(), key=lambda x: float(x.get("score", 0)), reverse=True)
517
-
518
-
519
- def discover_papers(query, mode, sources, max_results=10):
520
- query = (query or "").strip()
521
- if not query:
522
- return []
523
-
524
- mode = detect_query_type(query) if mode == "autonomous_web" else mode
525
- selected_sources = ensure_list(sources) or DEFAULT_SOURCES
526
- results = []
527
-
528
- if mode == "link":
529
- return dedupe_papers(resolve_link(query))
530
-
531
- try:
532
- if "arxiv" in selected_sources and mode != "doi":
533
- results.extend(search_arxiv(query, max_results=max_results))
534
- except Exception:
535
- pass
536
- try:
537
- if "crossref" in selected_sources:
538
- results.extend(search_crossref(query, mode=mode, max_results=max_results))
539
- except Exception:
540
- pass
541
- try:
542
- if "openalex" in selected_sources:
543
- results.extend(search_openalex(query, mode=mode, max_results=max_results))
544
- except Exception:
545
- pass
546
- try:
547
- if "semantic_scholar" in selected_sources:
548
- results.extend(search_semantic_scholar(query, mode=mode, max_results=max_results))
549
- except Exception:
550
- pass
551
- try:
552
- if "europe_pmc" in selected_sources:
553
- results.extend(search_europe_pmc(query, mode=mode, max_results=max_results))
554
- except Exception:
555
- pass
556
-
557
- return dedupe_papers(results)
558
-
559
-
560
- def format_papers_html(papers):
561
- if not papers:
562
- return '<div class="panel papers-panel" style="padding:18px"><p>No papers found yet.</p></div>'
563
-
564
- items = []
565
- for i, paper in enumerate(papers, start=1):
566
- summary = safe_text((paper.get("summary") or "")[:280])
567
- doi_line = f'<span class="paper-badge doi-badge">{safe_text(paper.get("doi"))}</span>' if paper.get("doi") else ""
568
- pdf_link = paper.get("pdf") or "#"
569
- abs_link = paper.get("url") or "#"
570
- items.append(
571
- f"""
572
- <article class="paper-card">
573
- <div class="paper-topline">
574
- <span class="paper-badge">{safe_text(paper.get('source', 'paper'))}</span>
575
- <span class="paper-badge alt">{safe_text(paper.get('published', '') or 'Paper')}</span>
576
- {doi_line}
577
- </div>
578
- <h4>{i}. {safe_text(paper.get('title', 'Untitled'))}</h4>
579
- <p>{summary or 'No abstract snippet available.'}</p>
580
- <div class="paper-meta-stack">
581
- <div><strong>Authors:</strong> {safe_text(paper.get('authors_text', 'Unknown authors'))}</div>
582
- <div><strong>Venue:</strong> {safe_text(paper.get('venue', 'Unknown venue'))}</div>
583
- <div><strong>Score:</strong> {safe_text(round(float(paper.get('score', 0)), 3))}</div>
584
- </div>
585
- <div class="paper-links">
586
- <a href="{safe_text(abs_link)}" target="_blank" rel="noopener noreferrer">Abstract</a>
587
- <a href="{safe_text(pdf_link)}" target="_blank" rel="noopener noreferrer">PDF</a>
588
- </div>
589
- </article>
590
- """
591
- )
592
- return '<div class="papers-grid">' + ''.join(items) + '</div>'
593
-
594
-
595
- def format_selection_choices(papers):
596
- choices = []
597
- for i, paper in enumerate(papers):
598
- label = f"[{paper.get('source', 'src')}] {paper.get('title', 'Untitled')} — {paper.get('authors_text', 'Unknown authors')[:80]}"
599
- choices.append((label, str(i)))
600
- return choices
601
-
602
-
603
- def uploaded_pdf_summary(file_obj):
604
- if not file_obj:
605
- return "No PDF uploaded yet."
606
- path = getattr(file_obj, "name", None) or str(file_obj)
607
- p = Path(path)
608
- return f"Uploaded PDF ready for ingestion: {p.name}. Use Parse uploaded PDF to extract title, abstract, sections, and references."
609
-
610
-
611
- def build_learning_graph_state(query, papers, uploaded_name=None):
612
- nodes = [{"id": "query", "label": query or "Research topic", "kind": "query"}]
613
- edges = []
614
- for i, paper in enumerate(papers[:5], start=1):
615
- pid = f"paper_{i}"
616
- nodes.append({"id": pid, "label": paper["title"], "kind": "paper"})
617
- edges.append(("query", pid))
618
- if uploaded_name:
619
- nodes.append({"id": "upload", "label": uploaded_name, "kind": "upload"})
620
- edges.append(("query", "upload"))
621
- if len(nodes) > 2:
622
- edges.append(("upload", "paper_1"))
623
- return nodes, edges
624
-
625
-
626
- def graph_from_selected(query, selected_papers, uploaded_name=None):
627
- nodes = [{"id": "query", "label": query or "Research topic", "kind": "query"}]
628
- edges = []
629
- for i, paper in enumerate(selected_papers[:6], start=1):
630
- pid = f"paper_{i}"
631
- nodes.append({"id": pid, "label": paper["title"], "kind": "paper"})
632
- edges.append(("query", pid))
633
- if paper.get("doi"):
634
- doi_id = f"doi_{i}"
635
- nodes.append({"id": doi_id, "label": f"DOI {paper['doi']}", "kind": "paper"})
636
- edges.append((pid, doi_id))
637
- if uploaded_name:
638
- nodes.append({"id": "upload", "label": uploaded_name, "kind": "upload"})
639
- edges.append(("query", "upload"))
640
- if len(selected_papers) > 0:
641
- edges.append(("upload", "paper_1"))
642
- return nodes, edges
643
-
644
-
645
- def parse_pdf_with_grobid(pdf_path):
646
- if not GROBID_URL:
647
- raise RuntimeError("GROBID_URL is not set")
648
-
649
- with open(pdf_path, "rb") as f:
650
- files = {"input": (Path(pdf_path).name, f, "application/pdf")}
651
- response = requests.post(
652
- f"{GROBID_URL.rstrip('/')}/api/processFulltextDocument",
653
- files=files,
654
- data={"includeRawAffiliations": "1", "segmentSentences": "1"},
655
- timeout=120,
656
- )
657
-
658
- response.raise_for_status()
659
- tei_xml = response.text
660
-
661
- if BeautifulSoup is None:
662
- return {
663
- "parser": "grobid",
664
- "title": Path(pdf_path).name,
665
- "abstract": "",
666
- "authors": [],
667
- "sections": [],
668
- "references": [],
669
- "raw_text": "",
670
- "tei_xml": tei_xml[:30000],
671
- }
672
-
673
- soup = BeautifulSoup(tei_xml, "xml")
674
- title_stmt = soup.find("titleStmt")
675
- title_tag = title_stmt.find("title") if title_stmt else soup.find("title")
676
- abstract_tag = soup.find("abstract")
677
-
678
- authors = []
679
- for author in soup.find_all("author"):
680
- pers = author.find("persName")
681
- if pers:
682
- name = " ".join(
683
- x.get_text(" ", strip=True)
684
- for x in pers.find_all(["forename", "surname"])
685
- ).strip()
686
- if name:
687
- authors.append(name)
688
-
689
- sections = []
690
- for div in soup.find_all("div"):
691
- head = div.find("head")
692
- paras = [p.get_text(" ", strip=True) for p in div.find_all("p")]
693
- text = "\n".join([p for p in paras if p.strip()])
694
- if head and text.strip():
695
- sections.append({"heading": head.get_text(" ", strip=True), "text": text[:4000]})
696
-
697
- references = []
698
- for bibl in soup.find_all("biblStruct")[:30]:
699
- ref_title = ""
700
- ref_doi = ""
701
- title_node = bibl.find("title")
702
- if title_node:
703
- ref_title = title_node.get_text(" ", strip=True)
704
- doi_node = bibl.find("idno", attrs={"type": "DOI"})
705
- if doi_node:
706
- ref_doi = doi_node.get_text(" ", strip=True)
707
- references.append({"title": ref_title, "doi": ref_doi})
708
-
709
- return {
710
- "parser": "grobid",
711
- "title": title_tag.get_text(" ", strip=True) if title_tag else Path(pdf_path).name,
712
- "abstract": abstract_tag.get_text(" ", strip=True) if abstract_tag else "",
713
- "authors": authors,
714
- "sections": sections,
715
- "references": references,
716
- "raw_text": "",
717
- "tei_xml": tei_xml[:60000],
718
- }
719
-
720
-
721
- def parse_pdf_with_pymupdf(pdf_path):
722
- if fitz is None:
723
- raise RuntimeError("PyMuPDF not installed")
724
-
725
- doc = fitz.open(pdf_path)
726
- pages = [page.get_text("text") for page in doc]
727
- raw_text = "\n".join(pages).strip()
728
- first_page = raw_text[:4000]
729
- lines = [x.strip() for x in first_page.splitlines() if x.strip()]
730
- title = lines[0][:300] if lines else Path(pdf_path).name
731
-
732
- abstract = ""
733
- match = re.search(r"abstract\s*(.+?)(?:\n\s*\n|\n1[\.\s]|introduction)", raw_text, re.I | re.S)
734
- if match:
735
- abstract = norm_text(match.group(1))[:2000]
736
-
737
- return {
738
- "parser": "pymupdf",
739
- "title": title,
740
- "abstract": abstract,
741
- "authors": [],
742
- "sections": [{"heading": "Full Text", "text": raw_text[:12000]}] if raw_text else [],
743
- "references": [],
744
- "raw_text": raw_text[:50000],
745
- "tei_xml": "",
746
- }
747
-
748
-
749
- def parse_pdf_with_docling(pdf_path):
750
- try:
751
- from docling.document_converter import DocumentConverter
752
- except Exception as e:
753
- raise RuntimeError(f"Docling import failed: {e}")
754
-
755
- converter = DocumentConverter()
756
- result = converter.convert(pdf_path)
757
- doc = result.document
758
- markdown = doc.export_to_markdown()
759
-
760
- title = Path(pdf_path).name
761
- first_nonempty = next((line.strip("# ").strip() for line in markdown.splitlines() if line.strip()), "")
762
- if first_nonempty:
763
- title = first_nonempty[:300]
764
-
765
- return {
766
- "parser": "docling",
767
- "title": title,
768
- "abstract": "",
769
- "authors": [],
770
- "sections": [{"heading": "Document", "text": markdown[:12000]}] if markdown else [],
771
- "references": [],
772
- "raw_text": markdown[:50000],
773
- "tei_xml": "",
774
- }
775
-
776
-
777
- def parse_uploaded_pdf(file_obj, parser_order):
778
- if not file_obj:
779
- return "No PDF uploaded yet.", {}
780
-
781
- path = getattr(file_obj, "name", None) or str(file_obj)
782
- parser_order = ensure_list(parser_order) or ["grobid", "docling", "pymupdf"]
783
- errors = []
784
-
785
- for parser_name in parser_order:
786
- try:
787
- if parser_name == "grobid":
788
- result = parse_pdf_with_grobid(path)
789
- elif parser_name == "docling":
790
- result = parse_pdf_with_docling(path)
791
- elif parser_name == "pymupdf":
792
- result = parse_pdf_with_pymupdf(path)
793
- else:
794
- continue
795
-
796
- summary = (
797
- f"### Parsed PDF\n\n"
798
- f"- Parser used: {result['parser']}\n"
799
- f"- Title: {result.get('title') or 'Unknown'}\n"
800
- f"- Authors: {', '.join(result.get('authors')[:6]) if result.get('authors') else 'Unknown'}\n"
801
- f"- Abstract found: {'Yes' if result.get('abstract') else 'No'}\n"
802
- f"- Sections extracted: {len(result.get('sections') or [])}\n"
803
- f"- References extracted: {len(result.get('references') or [])}\n"
804
- )
805
- return summary, result
806
- except Exception as e:
807
- errors.append(f"{parser_name}: {e}")
808
-
809
- fail_summary = "### PDF parsing failed\n\n" + "\n".join([f"- {x}" for x in errors])
810
- return fail_summary, {"parser": None, "errors": errors}
811
-
812
-
813
- def render_parse_result(parsed):
814
- if not parsed:
815
- return '<div class="panel" style="padding:18px"><p>No parsed document yet.</p></div>'
816
-
817
- sections_html = []
818
- for section in parsed.get("sections", [])[:6]:
819
- sections_html.append(
820
- f"""
821
- <details class="agent-step">
822
- <summary class="agent-summary">
823
- <div class="agent-index">§</div>
824
- <div class="agent-head">
825
- <h4>{safe_text(section.get('heading', 'Section'))}</h4>
826
- <span>section</span>
827
- </div>
828
- </summary>
829
- <div class="agent-copy">
830
- <p>{safe_text(section.get('text', '')[:1800])}</p>
831
- </div>
832
- </details>
833
- """
834
- )
835
-
836
- refs = parsed.get("references", [])[:12]
837
- refs_html = "".join(
838
- f"<li>{safe_text(r.get('title') or 'Untitled')} {'· DOI ' + safe_text(r.get('doi')) if r.get('doi') else ''}</li>"
839
- for r in refs
840
- ) or "<li>No references extracted.</li>"
841
-
842
- title = safe_text(parsed.get("title") or "Parsed document")
843
- abstract = safe_text((parsed.get("abstract") or "")[:2400]) or "No abstract extracted."
844
- parser_name = safe_text(parsed.get("parser") or "unknown")
845
-
846
- return f"""
847
- <div class="panel" style="padding:18px">
848
- <div class="brain-header">
849
- <div>
850
- <p class="eyebrow">PDF Parse</p>
851
- <h3>{title}</h3>
852
- </div>
853
- <div class="brain-legend"><span><i class="dot dot-upload"></i> {parser_name}</span></div>
854
- </div>
855
- <div class="parse-grid">
856
- <div class="parse-card">
857
- <h4>Abstract</h4>
858
- <p>{abstract}</p>
859
- </div>
860
- <div class="parse-card">
861
- <h4>References</h4>
862
- <ul class="ref-list">{refs_html}</ul>
863
- </div>
864
- </div>
865
- <div class="timeline" style="margin-top:14px;">
866
- {''.join(sections_html) if sections_html else '<div class="panel" style="padding:16px;"><p>No sections extracted.</p></div>'}
867
- </div>
868
- </div>
869
- """
870
-
871
-
872
- def build_ingest_payload(query, selected_papers, parsed_pdf=None):
873
- nodes = [{"id": "topic:query", "type": "Topic", "label": query or "Research topic"}]
874
- edges = []
875
-
876
- for i, p in enumerate(selected_papers, start=1):
877
- paper_id = p.get("doi") or (p.get("external_ids") or {}).get("arxiv") or f"paper:{i}"
878
- nodes.append({
879
- "id": paper_id,
880
- "type": "Paper",
881
- "title": p.get("title"),
882
- "year": p.get("year"),
883
- "venue": p.get("venue"),
884
- "doi": p.get("doi"),
885
- "source": p.get("source"),
886
- "url": p.get("url"),
887
- "pdf": p.get("pdf"),
888
- })
889
- edges.append({"source": "topic:query", "target": paper_id, "type": "ABOUT", "weight": p.get("score", 0)})
890
-
891
- for author in p.get("authors", [])[:8]:
892
- author_id = f"author:{author.lower()}"
893
- nodes.append({"id": author_id, "type": "Author", "label": author})
894
- edges.append({"source": paper_id, "target": author_id, "type": "WRITTEN_BY"})
895
-
896
- if parsed_pdf and parsed_pdf.get("title"):
897
- doc_id = "upload:pdf"
898
- nodes.append({
899
- "id": doc_id,
900
- "type": "UploadedPDF",
901
- "title": parsed_pdf.get("title"),
902
- "parser": parsed_pdf.get("parser"),
903
- })
904
- edges.append({"source": "topic:query", "target": doc_id, "type": "UPLOADED_SOURCE"})
905
- for idx, section in enumerate(parsed_pdf.get("sections", [])[:8], start=1):
906
- sec_id = f"{doc_id}:section:{idx}"
907
- nodes.append({"id": sec_id, "type": "Section", "label": section.get("heading") or f"Section {idx}"})
908
- edges.append({"source": doc_id, "target": sec_id, "type": "HAS_SECTION"})
909
- for idx, ref in enumerate(parsed_pdf.get("references", [])[:12], start=1):
910
- ref_id = f"{doc_id}:ref:{idx}"
911
- nodes.append({"id": ref_id, "type": "Reference", "label": ref.get("title") or f"Reference {idx}", "doi": ref.get("doi")})
912
- edges.append({"source": doc_id, "target": ref_id, "type": "CITES"})
913
-
914
- return {"status": "ok", "nodes": nodes, "edges": edges}
915
-
916
-
917
- def run_paper_discovery(query, search_mode, sources, pdf_file):
918
- query_text = (query or "").strip()
919
-
920
- if not query_text and not pdf_file:
921
- empty_graph = build_learning_graph_html([], [], "Self-Learning Knowledge Graph")
922
- return (
923
- empty_graph,
924
- '<div class="panel papers-panel" style="padding:18px"><p>Enter a topic, title, DOI, link, or upload a PDF to start learning.</p></div>',
925
- build_journal_html("biomaterials cardiac repair"),
926
- "No PDF uploaded yet.",
927
- gr.update(choices=[], value=[]),
928
- [],
929
- "### No discovery results yet.",
930
- )
931
-
932
- papers = []
933
- if query_text:
934
- try:
935
- papers = discover_papers(query_text, search_mode, sources, max_results=10)
936
- except Exception as e:
937
- graph_nodes, graph_edges = build_learning_graph_state(
938
- query_text,
939
- [],
940
- Path(getattr(pdf_file, "name", "uploaded.pdf")).name if pdf_file else None,
941
- )
942
- error_html = f'<div class="panel papers-panel" style="padding:18px"><p>Paper search failed: {safe_text(str(e))}</p></div>'
943
- return (
944
- build_learning_graph_html(graph_nodes, graph_edges),
945
- error_html,
946
- build_journal_html(query_text or "biomaterials cardiac repair"),
947
- uploaded_pdf_summary(pdf_file),
948
- gr.update(choices=[], value=[]),
949
- [],
950
- f"### Discovery failed.\n\n- Error: {safe_text(str(e))}",
951
- )
952
-
953
- uploaded_name = Path(getattr(pdf_file, "name", str(pdf_file))).name if pdf_file else None
954
- graph_nodes, graph_edges = build_learning_graph_state(query_text, papers[:5], uploaded_name)
955
- graph_html = build_learning_graph_html(graph_nodes, graph_edges)
956
- papers_html = format_papers_html(papers)
957
- journals_html = build_journal_html(query_text or "biomaterials cardiac repair")
958
- pdf_summary = uploaded_pdf_summary(pdf_file)
959
- choices = format_selection_choices(papers)
960
-
961
- status_md = (
962
- f"### Discovery results\n\n"
963
- f"- Search mode: {search_mode}\n"
964
- f"- Sources: {', '.join(ensure_list(sources) or DEFAULT_SOURCES)}\n"
965
- f"- Candidates found: {len(papers)}\n"
966
- f"- Select papers below, then click **Ingest selected into graph**.\n"
967
- )
968
- return graph_html, papers_html, journals_html, pdf_summary, gr.update(choices=choices, value=[]), papers, status_md
969
-
970
-
971
- def ingest_selected_papers(query, selected_indices, papers_state, pdf_file, parsed_state):
972
- papers = ensure_list(papers_state)
973
- selected_indices = ensure_list(selected_indices)
974
-
975
- selected = []
976
- for idx in selected_indices:
977
- try:
978
- selected.append(papers[int(idx)])
979
- except Exception:
980
- pass
981
-
982
- uploaded_name = Path(getattr(pdf_file, "name", str(pdf_file))).name if pdf_file else None
983
-
984
- if not selected and not parsed_state:
985
- graph_html = build_learning_graph_html([], [], "Self-Learning Knowledge Graph")
986
- return graph_html, "### Nothing ingested yet.\n\nSelect papers or parse an uploaded PDF first.", {"status": "empty", "nodes": [], "edges": []}
987
-
988
- graph_nodes, graph_edges = graph_from_selected(query, selected, uploaded_name)
989
- graph_html = build_learning_graph_html(graph_nodes, graph_edges, "Selected Research Graph")
990
- payload = build_ingest_payload(query, selected, parsed_state if isinstance(parsed_state, dict) else None)
991
-
992
- summary_lines = [
993
- "### Graph ingest ready",
994
- "",
995
- f"- Topic: {query or 'Research topic'}",
996
- f"- Selected papers: {len(selected)}",
997
- f"- Uploaded PDF parsed: {'Yes' if parsed_state and isinstance(parsed_state, dict) and parsed_state.get('title') else 'No'}",
998
- f"- Nodes created: {len(payload['nodes'])}",
999
- f"- Edges created: {len(payload['edges'])}",
1000
- ]
1001
- return graph_html, "\n".join(summary_lines), payload
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dvnc_ai_v2_hf/deprecated/self_learning_graph_old3.py DELETED
@@ -1,1626 +0,0 @@
1
- import html
2
- import math
3
- import os
4
- import re
5
- import time
6
- import urllib.parse
7
- import xml.etree.ElementTree as ET
8
- from collections import Counter, defaultdict
9
- from pathlib import Path
10
- from typing import Dict, List, Optional, Tuple
11
- from urllib.parse import quote
12
-
13
- import gradio as gr
14
- import requests
15
-
16
- try:
17
- import fitz # PyMuPDF
18
- except Exception:
19
- fitz = None
20
-
21
- try:
22
- from bs4 import BeautifulSoup
23
- except Exception:
24
- BeautifulSoup = None
25
-
26
-
27
- JOURNALS = [
28
- {"name": "Nature", "url": "https://www.nature.com/search", "desc": "Flagship multidisciplinary research journal."},
29
- {"name": "Science", "url": "https://www.science.org/search", "desc": "High-impact science journal and family."},
30
- {"name": "Cell", "url": "https://www.cell.com/search", "desc": "Life sciences and translational biology."},
31
- {"name": "The Lancet", "url": "https://www.thelancet.com/search", "desc": "Clinical and medical research."},
32
- {"name": "IEEE Xplore", "url": "https://ieeexplore.ieee.org/search/searchresult.jsp", "desc": "Engineering, AI, signal processing, and systems."},
33
- ]
34
-
35
- SEARCH_MODES = ["topic", "title", "doi", "link", "paper_name", "autonomous_web"]
36
- SOURCE_OPTIONS = ["arxiv", "crossref", "openalex", "semantic_scholar", "europe_pmc"]
37
- DEFAULT_SOURCES = ["arxiv", "openalex", "crossref", "semantic_scholar", "europe_pmc"]
38
-
39
- SEMANTIC_SCHOLAR_API_KEY = os.getenv("SEMANTIC_SCHOLAR_API_KEY", "")
40
- GROBID_URL = os.getenv("GROBID_URL", "").strip()
41
- REQUEST_TIMEOUT = int(os.getenv("REQUEST_TIMEOUT", "25"))
42
- GRAPH_MAX_ROUNDS = int(os.getenv("GRAPH_MAX_ROUNDS", "2"))
43
- GRAPH_MAX_RESULTS_PER_SOURCE = int(os.getenv("GRAPH_MAX_RESULTS_PER_SOURCE", "8"))
44
- GRAPH_MAX_FRONTIER = int(os.getenv("GRAPH_MAX_FRONTIER", "5"))
45
- GRAPH_MAX_CONCEPTS = int(os.getenv("GRAPH_MAX_CONCEPTS", "12"))
46
- GRAPH_MAX_CLAIMS = int(os.getenv("GRAPH_MAX_CLAIMS", "6"))
47
- GRAPH_MAX_GRAPH_NODES = int(os.getenv("GRAPH_MAX_GRAPH_NODES", "18"))
48
-
49
- STOPWORDS = {
50
- "a", "an", "and", "are", "as", "at", "be", "been", "being", "by", "can", "could", "did", "do", "does",
51
- "for", "from", "had", "has", "have", "if", "in", "into", "is", "it", "its", "may", "might", "of", "on",
52
- "or", "our", "such", "that", "the", "their", "there", "these", "this", "those", "to", "using", "use",
53
- "used", "via", "was", "were", "will", "with", "within", "without", "we", "they", "you", "your", "study",
54
- "paper", "research", "results", "method", "methods", "analysis", "approach", "toward", "towards",
55
- "based", "new", "novel", "effect", "effects", "model", "models", "system", "systems",
56
- }
57
-
58
- GRAPH_MEMORY = {
59
- "queries": [],
60
- "papers": {},
61
- "nodes": {},
62
- "edges": [],
63
- "events": [],
64
- "concept_counts": Counter(),
65
- "claim_counts": Counter(),
66
- "seen_queries": set(),
67
- "seen_dois": set(),
68
- "seen_titles": set(),
69
- }
70
-
71
-
72
- def safe_text(x, default=""):
73
- return html.escape(str(x if x is not None else default))
74
-
75
-
76
- def norm_text(x: Optional[str]) -> str:
77
- return re.sub(r"\s+", " ", (x or "")).strip()
78
-
79
-
80
- def slugify(text: str) -> str:
81
- return re.sub(r"[^a-z0-9]+", "-", (text or "").lower()).strip("-")
82
-
83
-
84
- def ensure_list(x):
85
- return x if isinstance(x, list) else []
86
-
87
-
88
- def unique_keep_order(items):
89
- seen = set()
90
- out = []
91
- for item in items:
92
- key = norm_text(str(item)).lower()
93
- if key and key not in seen:
94
- seen.add(key)
95
- out.append(item)
96
- return out
97
-
98
-
99
- def normalize_doi(text: str) -> str:
100
- text = (text or "").strip()
101
- text = text.replace("https://doi.org/", "").replace("http://doi.org/", "")
102
- return text.strip()
103
-
104
-
105
- def detect_query_type(query: str) -> str:
106
- q = (query or "").strip()
107
- doi_pattern = r"^10\.\d{4,9}/[-._;()/:A-Z0-9]+$"
108
- if re.match(doi_pattern, q, flags=re.I):
109
- return "doi"
110
- if q.startswith("http://") or q.startswith("https://"):
111
- return "link"
112
- return "topic"
113
-
114
-
115
- def tokenize(text: str) -> List[str]:
116
- return [t for t in re.findall(r"[a-zA-Z][a-zA-Z0-9\-]{2,}", (text or "").lower()) if t not in STOPWORDS]
117
-
118
-
119
- def text_overlap_score(a: str, b: str) -> float:
120
- sa = set(tokenize(a))
121
- sb = set(tokenize(b))
122
- if not sa or not sb:
123
- return 0.0
124
- return len(sa & sb) / max(len(sa | sb), 1)
125
-
126
-
127
- def compute_recency_bonus(year: str) -> float:
128
- try:
129
- y = int(str(year)[:4])
130
- except Exception:
131
- return 0.0
132
- current = time.gmtime().tm_year
133
- age = max(current - y, 0)
134
- return max(0.0, 0.12 - age * 0.015)
135
-
136
-
137
- def extract_candidate_phrases(text: str, max_terms: int = 30) -> List[str]:
138
- text = norm_text(text)
139
- if not text:
140
- return []
141
-
142
- phrases = []
143
- tokens = re.findall(r"[A-Za-z][A-Za-z0-9\-]{2,}", text)
144
- for n in (3, 2, 1):
145
- for i in range(len(tokens) - n + 1):
146
- phrase = " ".join(tokens[i:i + n]).strip().lower()
147
- if len(phrase) < 4:
148
- continue
149
- parts = phrase.split()
150
- if any(p in STOPWORDS for p in parts):
151
- continue
152
- if all(len(p) <= 2 for p in parts):
153
- continue
154
- phrases.append(phrase)
155
-
156
- counts = Counter(phrases)
157
- ranked = [p for p, _ in counts.most_common(max_terms * 3)]
158
- filtered = []
159
- for phrase in ranked:
160
- if phrase in filtered:
161
- continue
162
- if any(phrase != other and phrase in other for other in filtered):
163
- continue
164
- filtered.append(phrase)
165
- if len(filtered) >= max_terms:
166
- break
167
- return filtered
168
-
169
-
170
- def extract_concepts_from_text(text: str, max_terms: int = GRAPH_MAX_CONCEPTS) -> List[str]:
171
- return extract_candidate_phrases(text, max_terms=max_terms)
172
-
173
-
174
- def extract_claim_like_sentences(text: str, max_items: int = GRAPH_MAX_CLAIMS) -> List[str]:
175
- text = norm_text(text)
176
- if not text:
177
- return []
178
- parts = re.split(r"(?<=[\.\!\?])\s+", text)
179
- scored = []
180
- for s in parts:
181
- ss = norm_text(s)
182
- if len(ss) < 40 or len(ss) > 280:
183
- continue
184
- score = 0
185
- lower = ss.lower()
186
- if any(k in lower for k in ["improves", "reduces", "increases", "suggests", "demonstrates", "shows", "reveals", "predicts", "achieves", "outperforms"]):
187
- score += 2
188
- if any(k in lower for k in ["significant", "associated", "correlated", "effective", "robust", "accurate", "validated"]):
189
- score += 1
190
- score += min(len(tokenize(ss)) / 15.0, 2.0)
191
- scored.append((score, ss))
192
- return [s for _, s in sorted(scored, key=lambda x: x[0], reverse=True)[:max_items]]
193
-
194
-
195
- def parse_openalex_abstract(inverted_index) -> str:
196
- if not inverted_index or not isinstance(inverted_index, dict):
197
- return ""
198
- pos_to_word = {}
199
- for word, positions in inverted_index.items():
200
- for p in positions:
201
- pos_to_word[p] = word
202
- if not pos_to_word:
203
- return ""
204
- ordered = [pos_to_word[i] for i in sorted(pos_to_word)]
205
- return " ".join(ordered)
206
-
207
-
208
- def journal_query_links(query: str):
209
- q = urllib.parse.quote_plus(query or "biomaterials cardiac repair")
210
- rows = []
211
- for journal in JOURNALS:
212
- url = f"{journal['url']}?q={q}" if "?" not in journal["url"] else f"{journal['url']}&q={q}"
213
- if "ieeexplore" in journal["url"]:
214
- url = f"https://ieeexplore.ieee.org/search/searchresult.jsp?queryText={q}"
215
- rows.append({
216
- "name": journal["name"],
217
- "desc": journal["desc"],
218
- "url": url,
219
- })
220
- return rows
221
-
222
-
223
- def build_journal_html(query):
224
- rows = []
225
- for journal in journal_query_links(query):
226
- rows.append(
227
- f"""
228
- <a class="journal-card" href="{safe_text(journal['url'])}" target="_blank" rel="noopener noreferrer">
229
- <div>
230
- <h4>{safe_text(journal['name'])}</h4>
231
- <p>{safe_text(journal['desc'])}</p>
232
- </div>
233
- <span>Open</span>
234
- </a>
235
- """
236
- )
237
- return '<div class="journal-grid">' + ''.join(rows) + '</div>'
238
-
239
-
240
- def search_arxiv(query, max_results=8):
241
- encoded = urllib.parse.quote(query)
242
- url = (
243
- "http://export.arxiv.org/api/query?search_query=all:"
244
- f"{encoded}&start=0&max_results={max_results}&sortBy=relevance&sortOrder=descending"
245
- )
246
- response = requests.get(url, timeout=REQUEST_TIMEOUT)
247
- response.raise_for_status()
248
- root = ET.fromstring(response.text)
249
- ns = {"atom": "http://www.w3.org/2005/Atom"}
250
- papers = []
251
- for entry in root.findall("atom:entry", ns):
252
- title = " ".join((entry.findtext("atom:title", default="", namespaces=ns) or "").split())
253
- summary = " ".join((entry.findtext("atom:summary", default="", namespaces=ns) or "").split())
254
- published = entry.findtext("atom:published", default="", namespaces=ns)
255
- paper_id = entry.findtext("atom:id", default="", namespaces=ns)
256
- authors = [a.findtext("atom:name", default="", namespaces=ns) for a in entry.findall("atom:author", ns)]
257
- pdf_url = ""
258
- for link in entry.findall("atom:link", ns):
259
- if link.attrib.get("title") == "pdf":
260
- pdf_url = link.attrib.get("href", "")
261
- break
262
- papers.append(
263
- {
264
- "id": paper_id or title,
265
- "title": title,
266
- "summary": summary,
267
- "abstract": summary,
268
- "published": published[:10],
269
- "authors": [a for a in authors[:8] if a],
270
- "authors_text": ", ".join([a for a in authors[:4] if a]),
271
- "url": paper_id,
272
- "pdf": pdf_url,
273
- "doi": "",
274
- "venue": "arXiv",
275
- "year": published[:4] if published else "",
276
- "source": "arxiv",
277
- "score": 0.76,
278
- "open_access": True,
279
- "external_ids": {"arxiv": (paper_id or "").split("/")[-1]},
280
- }
281
- )
282
- return papers
283
-
284
-
285
- def search_crossref(query, mode="topic", max_results=8):
286
- headers = {"User-Agent": "dvnc-ai-space/0.2"}
287
- if mode == "doi":
288
- url = f"https://api.crossref.org/works/{quote(query)}"
289
- response = requests.get(url, headers=headers, timeout=REQUEST_TIMEOUT)
290
- if response.status_code != 200:
291
- return []
292
- items = [response.json().get("message", {})]
293
- else:
294
- params = {"rows": max_results}
295
- if mode in ("title", "paper_name"):
296
- params["query.title"] = query
297
- else:
298
- params["query.bibliographic"] = query
299
- response = requests.get("https://api.crossref.org/works", params=params, headers=headers, timeout=REQUEST_TIMEOUT)
300
- response.raise_for_status()
301
- items = response.json().get("message", {}).get("items", [])
302
-
303
- out = []
304
- for item in items:
305
- authors = []
306
- for a in item.get("author", []) or []:
307
- name = " ".join(filter(None, [a.get("given"), a.get("family")])).strip()
308
- if name:
309
- authors.append(name)
310
-
311
- title = (item.get("title") or ["Untitled"])[0]
312
- year = ""
313
- for key in ["published-print", "published-online", "created"]:
314
- if item.get(key, {}).get("date-parts"):
315
- year = str(item[key]["date-parts"][0][0])
316
- break
317
-
318
- abstract = item.get("abstract") or ""
319
- abstract = re.sub("<.*?>", "", abstract)
320
- doi = normalize_doi(item.get("DOI", ""))
321
- out.append({
322
- "id": doi or title,
323
- "title": norm_text(title),
324
- "summary": norm_text(abstract)[:500],
325
- "abstract": norm_text(abstract),
326
- "published": year,
327
- "authors": authors,
328
- "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
329
- "url": item.get("URL", ""),
330
- "pdf": "",
331
- "doi": doi,
332
- "venue": (item.get("container-title") or [""])[0],
333
- "year": year,
334
- "source": "crossref",
335
- "score": 0.72,
336
- "open_access": None,
337
- "external_ids": {"crossref": doi} if doi else {},
338
- })
339
- return out
340
-
341
-
342
- def search_openalex(query, mode="topic", max_results=8):
343
- params = {"per-page": max_results}
344
- if mode == "doi":
345
- doi = normalize_doi(query)
346
- params["filter"] = f"doi:https://doi.org/{doi}"
347
- else:
348
- params["search"] = query
349
-
350
- response = requests.get("https://api.openalex.org/works", params=params, timeout=REQUEST_TIMEOUT)
351
- response.raise_for_status()
352
- items = response.json().get("results", [])
353
- out = []
354
- for item in items:
355
- authors = []
356
- for auth in item.get("authorships", [])[:8]:
357
- author = auth.get("author") or {}
358
- if author.get("display_name"):
359
- authors.append(author["display_name"])
360
-
361
- oa = item.get("open_access") or {}
362
- doi = normalize_doi(item.get("doi") or "")
363
- abstract = parse_openalex_abstract(item.get("abstract_inverted_index"))
364
- out.append({
365
- "id": item.get("id") or doi or item.get("title"),
366
- "title": norm_text(item.get("title")),
367
- "summary": norm_text(abstract)[:500],
368
- "abstract": norm_text(abstract),
369
- "published": str(item.get("publication_year") or ""),
370
- "authors": authors,
371
- "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
372
- "url": (item.get("primary_location") or {}).get("landing_page_url") or item.get("id") or "",
373
- "pdf": oa.get("oa_url") or "",
374
- "doi": doi,
375
- "venue": ((item.get("primary_location") or {}).get("source") or {}).get("display_name") or "",
376
- "year": str(item.get("publication_year") or ""),
377
- "source": "openalex",
378
- "score": 0.80,
379
- "open_access": oa.get("is_oa"),
380
- "external_ids": item.get("ids") or {},
381
- })
382
- return out
383
-
384
-
385
- def search_semantic_scholar(query, mode="topic", max_results=8):
386
- headers = {}
387
- if SEMANTIC_SCHOLAR_API_KEY:
388
- headers["x-api-key"] = SEMANTIC_SCHOLAR_API_KEY
389
-
390
- fields = "title,authors,year,abstract,venue,externalIds,url,openAccessPdf"
391
- if mode == "doi":
392
- doi = normalize_doi(query)
393
- url = f"https://api.semanticscholar.org/graph/v1/paper/DOI:{quote(doi)}"
394
- response = requests.get(url, params={"fields": fields}, headers=headers, timeout=REQUEST_TIMEOUT)
395
- if response.status_code != 200:
396
- return []
397
- items = [response.json()]
398
- else:
399
- response = requests.get(
400
- "https://api.semanticscholar.org/graph/v1/paper/search",
401
- params={"query": query, "limit": max_results, "fields": fields},
402
- headers=headers,
403
- timeout=REQUEST_TIMEOUT,
404
- )
405
- if response.status_code != 200:
406
- return []
407
- items = response.json().get("data", [])
408
-
409
- out = []
410
- for item in items:
411
- external = item.get("externalIds") or {}
412
- authors = [a.get("name") for a in item.get("authors", []) if a.get("name")]
413
- out.append({
414
- "id": external.get("CorpusId") or external.get("DOI") or item.get("title"),
415
- "title": norm_text(item.get("title")),
416
- "summary": norm_text(item.get("abstract", ""))[:500],
417
- "abstract": norm_text(item.get("abstract", "")),
418
- "published": str(item.get("year") or ""),
419
- "authors": authors,
420
- "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
421
- "url": item.get("url") or "",
422
- "pdf": (item.get("openAccessPdf") or {}).get("url") or "",
423
- "doi": normalize_doi(external.get("DOI", "")),
424
- "venue": item.get("venue") or "",
425
- "year": str(item.get("year") or ""),
426
- "source": "semantic_scholar",
427
- "score": 0.84,
428
- "open_access": bool((item.get("openAccessPdf") or {}).get("url")),
429
- "external_ids": external,
430
- })
431
- return out
432
-
433
-
434
- def search_europe_pmc(query, mode="topic", max_results=8):
435
- epmc_query = f'DOI:"{query}"' if mode == "doi" else query
436
- params = {
437
- "query": epmc_query,
438
- "format": "json",
439
- "pageSize": max_results,
440
- "resultType": "core",
441
- }
442
- response = requests.get("https://www.ebi.ac.uk/europepmc/webservices/rest/search", params=params, timeout=REQUEST_TIMEOUT)
443
- if response.status_code != 200:
444
- return []
445
- items = response.json().get("resultList", {}).get("result", [])
446
- out = []
447
- for item in items:
448
- author_string = item.get("authorString", "")
449
- authors = [x.strip() for x in author_string.split(",")[:8] if x.strip()]
450
- pmcid = item.get("pmcid", "")
451
- pdf_url = f"https://europepmc.org/articles/{pmcid}?pdf=render" if pmcid else ""
452
- landing_url = f"https://europepmc.org/article/PMC/{pmcid}" if pmcid else ""
453
- out.append({
454
- "id": item.get("id") or item.get("doi") or item.get("title"),
455
- "title": norm_text(item.get("title")),
456
- "summary": norm_text(item.get("abstractText", ""))[:500],
457
- "abstract": norm_text(item.get("abstractText", "")),
458
- "published": str(item.get("pubYear") or ""),
459
- "authors": authors,
460
- "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
461
- "url": landing_url,
462
- "pdf": pdf_url,
463
- "doi": normalize_doi(item.get("doi", "")),
464
- "venue": item.get("journalTitle", ""),
465
- "year": str(item.get("pubYear") or ""),
466
- "source": "europe_pmc",
467
- "score": 0.78,
468
- "open_access": bool(pmcid),
469
- "external_ids": {"pmid": item.get("pmid"), "pmcid": pmcid},
470
- })
471
- return out
472
-
473
-
474
- def resolve_link(query):
475
- url = (query or "").strip()
476
- if not url:
477
- return []
478
- try:
479
- response = requests.get(
480
- url,
481
- timeout=REQUEST_TIMEOUT,
482
- allow_redirects=True,
483
- headers={"User-Agent": "dvnc-ai-space/0.2"},
484
- )
485
- content_type = response.headers.get("content-type", "")
486
- if "pdf" in content_type or url.lower().endswith(".pdf"):
487
- name = Path(url.split("?")[0]).name or "linked-paper.pdf"
488
- return [{
489
- "id": url,
490
- "title": name,
491
- "summary": "Direct PDF link detected.",
492
- "abstract": "",
493
- "published": "",
494
- "authors": [],
495
- "authors_text": "Unknown authors",
496
- "url": url,
497
- "pdf": url,
498
- "doi": "",
499
- "venue": "Direct PDF",
500
- "year": "",
501
- "source": "link",
502
- "score": 0.66,
503
- "open_access": True,
504
- "external_ids": {},
505
- }]
506
-
507
- if BeautifulSoup is None:
508
- return [{
509
- "id": url,
510
- "title": url,
511
- "summary": "Web page resolved. Install beautifulsoup4 for DOI extraction.",
512
- "abstract": "",
513
- "published": "",
514
- "authors": [],
515
- "authors_text": "Unknown authors",
516
- "url": url,
517
- "pdf": "",
518
- "doi": "",
519
- "venue": "Web Link",
520
- "year": "",
521
- "source": "link",
522
- "score": 0.48,
523
- "open_access": None,
524
- "external_ids": {},
525
- }]
526
-
527
- soup = BeautifulSoup(response.text, "html.parser")
528
- doi = ""
529
- for meta_name in ["citation_doi", "dc.identifier", "dc.Identifier"]:
530
- tag = soup.find("meta", attrs={"name": meta_name})
531
- if tag and tag.get("content"):
532
- doi = normalize_doi(tag["content"].strip())
533
- break
534
-
535
- title = soup.title.text.strip() if soup.title else url
536
- pdf_link = ""
537
- for a in soup.find_all("a", href=True):
538
- href = a["href"]
539
- if ".pdf" in href.lower():
540
- pdf_link = href if href.startswith("http") else ""
541
- break
542
-
543
- if doi:
544
- results = search_crossref(doi, mode="doi", max_results=1)
545
- if results:
546
- if pdf_link and not results[0].get("pdf"):
547
- results[0]["pdf"] = pdf_link
548
- if url and not results[0].get("url"):
549
- results[0]["url"] = url
550
- return results
551
-
552
- return [{
553
- "id": url,
554
- "title": title,
555
- "summary": "Landing page resolved from direct link.",
556
- "abstract": "",
557
- "published": "",
558
- "authors": [],
559
- "authors_text": "Unknown authors",
560
- "url": url,
561
- "pdf": pdf_link,
562
- "doi": doi,
563
- "venue": "Web Link",
564
- "year": "",
565
- "source": "link",
566
- "score": 0.54,
567
- "open_access": bool(pdf_link),
568
- "external_ids": {},
569
- }]
570
- except Exception as e:
571
- return [{
572
- "id": url,
573
- "title": "Link resolution error",
574
- "summary": str(e),
575
- "abstract": "",
576
- "published": "",
577
- "authors": [],
578
- "authors_text": "Unknown authors",
579
- "url": url,
580
- "pdf": "",
581
- "doi": "",
582
- "venue": "Link",
583
- "year": "",
584
- "source": "link",
585
- "score": 0.20,
586
- "open_access": None,
587
- "external_ids": {},
588
- }]
589
-
590
-
591
- def dedupe_papers(items: List[Dict]) -> List[Dict]:
592
- seen = {}
593
- for item in items:
594
- key = (
595
- normalize_doi(item.get("doi") or "")
596
- or (item.get("external_ids") or {}).get("arxiv")
597
- or norm_text(item.get("title", "")).lower()
598
- or str(item.get("id"))
599
- )
600
- if not key:
601
- key = f"{item.get('source')}::{item.get('title')}"
602
- if key not in seen or float(item.get("score", 0)) > float(seen[key].get("score", 0)):
603
- seen[key] = item
604
- return sorted(seen.values(), key=lambda x: float(x.get("score", 0)), reverse=True)
605
-
606
-
607
- def enrich_paper_semantics(query: str, paper: Dict) -> Dict:
608
- paper = dict(paper)
609
- title = paper.get("title", "")
610
- abstract = paper.get("abstract", "") or paper.get("summary", "")
611
- venue = paper.get("venue", "")
612
- base_text = " ".join([title, abstract, venue]).strip()
613
-
614
- concepts = extract_concepts_from_text(base_text, max_terms=GRAPH_MAX_CONCEPTS)
615
- claims = extract_claim_like_sentences(abstract, max_items=GRAPH_MAX_CLAIMS)
616
-
617
- rel = text_overlap_score(query, f"{title} {abstract}")
618
- recency = compute_recency_bonus(paper.get("year"))
619
- citation_hints = 0.02 if paper.get("doi") else 0.0
620
- oa_bonus = 0.03 if paper.get("open_access") else 0.0
621
- semantic_bonus = min(len(concepts), 8) * 0.01
622
- learned_score = float(paper.get("score", 0)) + rel * 0.5 + recency + citation_hints + oa_bonus + semantic_bonus
623
-
624
- paper["concepts"] = concepts[:GRAPH_MAX_CONCEPTS]
625
- paper["claims"] = claims[:GRAPH_MAX_CLAIMS]
626
- paper["relevance"] = round(rel, 4)
627
- paper["learned_score"] = round(learned_score, 4)
628
- paper["semantic_summary"] = "; ".join(concepts[:5]) if concepts else ""
629
- return paper
630
-
631
-
632
- def discover_papers(query, mode, sources, max_results=10):
633
- query = (query or "").strip()
634
- if not query:
635
- return []
636
-
637
- mode = detect_query_type(query) if mode == "autonomous_web" else mode
638
- selected_sources = ensure_list(sources) or DEFAULT_SOURCES
639
- results = []
640
-
641
- if mode == "link":
642
- return dedupe_papers(resolve_link(query))
643
-
644
- try:
645
- if "arxiv" in selected_sources and mode != "doi":
646
- results.extend(search_arxiv(query, max_results=min(max_results, GRAPH_MAX_RESULTS_PER_SOURCE)))
647
- except Exception:
648
- pass
649
- try:
650
- if "crossref" in selected_sources:
651
- results.extend(search_crossref(query, mode=mode, max_results=min(max_results, GRAPH_MAX_RESULTS_PER_SOURCE)))
652
- except Exception:
653
- pass
654
- try:
655
- if "openalex" in selected_sources:
656
- results.extend(search_openalex(query, mode=mode, max_results=min(max_results, GRAPH_MAX_RESULTS_PER_SOURCE)))
657
- except Exception:
658
- pass
659
- try:
660
- if "semantic_scholar" in selected_sources:
661
- results.extend(search_semantic_scholar(query, mode=mode, max_results=min(max_results, GRAPH_MAX_RESULTS_PER_SOURCE)))
662
- except Exception:
663
- pass
664
- try:
665
- if "europe_pmc" in selected_sources:
666
- results.extend(search_europe_pmc(query, mode=mode, max_results=min(max_results, GRAPH_MAX_RESULTS_PER_SOURCE)))
667
- except Exception:
668
- pass
669
-
670
- ranked = [enrich_paper_semantics(query, p) for p in dedupe_papers(results)]
671
- ranked = sorted(ranked, key=lambda x: float(x.get("learned_score", x.get("score", 0))), reverse=True)
672
- return ranked
673
-
674
-
675
- def extract_reference_queries_from_parsed(parsed_pdf: Optional[Dict], max_items: int = 8) -> List[str]:
676
- if not parsed_pdf or not isinstance(parsed_pdf, dict):
677
- return []
678
- out = []
679
- for ref in (parsed_pdf.get("references") or [])[:max_items]:
680
- doi = normalize_doi(ref.get("doi") or "")
681
- title = norm_text(ref.get("title") or "")
682
- if doi:
683
- out.append(doi)
684
- elif title:
685
- out.append(title)
686
- return unique_keep_order(out)
687
-
688
-
689
- def propose_expansion_queries(seed_query: str, papers: List[Dict], parsed_pdf: Optional[Dict] = None, max_items: int = 10) -> List[str]:
690
- counter = Counter()
691
- seed_tokens = set(tokenize(seed_query))
692
-
693
- for paper in papers[:10]:
694
- title = paper.get("title", "")
695
- abstract = paper.get("abstract", "") or paper.get("summary", "")
696
- concepts = paper.get("concepts") or extract_concepts_from_text(f"{title} {abstract}")
697
- for concept in concepts[:8]:
698
- score = 1.0
699
- ctoks = set(tokenize(concept))
700
- if seed_tokens & ctoks:
701
- score += 1.0
702
- if len(concept.split()) >= 2:
703
- score += 0.5
704
- counter[concept] += score
705
-
706
- title_query = norm_text(title)
707
- if title_query:
708
- counter[title_query] += 0.4
709
-
710
- for rq in extract_reference_queries_from_parsed(parsed_pdf, max_items=6):
711
- counter[rq] += 1.8
712
-
713
- expansions = []
714
- for concept, _ in counter.most_common(max_items * 3):
715
- c = norm_text(concept)
716
- if not c:
717
- continue
718
- if c.lower() == seed_query.lower():
719
- continue
720
- if len(c) < 4:
721
- continue
722
- expansions.append(c)
723
-
724
- expansions = unique_keep_order(expansions)
725
- return expansions[:max_items]
726
-
727
-
728
- def autonomous_expand_graph(query: str, sources: List[str], parsed_pdf: Optional[Dict] = None,
729
- initial_mode: str = "autonomous_web", max_rounds: int = GRAPH_MAX_ROUNDS,
730
- frontier_limit: int = GRAPH_MAX_FRONTIER, max_results: int = 8) -> Dict:
731
- visited_queries = []
732
- query_frontier = [query]
733
- all_papers = []
734
- round_summaries = []
735
-
736
- for round_idx in range(max_rounds):
737
- this_round_queries = []
738
- this_round_papers = []
739
-
740
- for q in query_frontier[:frontier_limit]:
741
- qn = norm_text(q)
742
- if not qn or qn.lower() in GRAPH_MEMORY["seen_queries"]:
743
- continue
744
- GRAPH_MEMORY["seen_queries"].add(qn.lower())
745
- visited_queries.append(qn)
746
- this_round_queries.append(qn)
747
-
748
- mode = detect_query_type(qn) if initial_mode == "autonomous_web" else initial_mode
749
- discovered = discover_papers(qn, mode, sources, max_results=max_results)
750
- this_round_papers.extend(discovered)
751
-
752
- all_papers.extend(this_round_papers)
753
- all_papers = dedupe_papers(all_papers)
754
- all_papers = [enrich_paper_semantics(query, p) for p in all_papers]
755
- all_papers = sorted(all_papers, key=lambda x: float(x.get("learned_score", x.get("score", 0))), reverse=True)
756
-
757
- top_context = all_papers[:12]
758
- proposed = propose_expansion_queries(query, top_context, parsed_pdf=parsed_pdf, max_items=frontier_limit * 2)
759
- next_frontier = [q for q in proposed if q.lower() not in GRAPH_MEMORY["seen_queries"]][:frontier_limit]
760
-
761
- round_summaries.append({
762
- "round": round_idx + 1,
763
- "queries": this_round_queries,
764
- "papers_found": len(this_round_papers),
765
- "frontier_next": next_frontier,
766
- })
767
-
768
- if not next_frontier:
769
- break
770
- query_frontier = next_frontier
771
-
772
- graph_snapshot = build_autonomous_payload(query, all_papers[:15], parsed_pdf, visited_queries, round_summaries)
773
- return {
774
- "query": query,
775
- "papers": all_papers,
776
- "visited_queries": visited_queries,
777
- "rounds": round_summaries,
778
- "payload": graph_snapshot,
779
- }
780
-
781
-
782
- def format_papers_html(papers):
783
- if not papers:
784
- return '<div class="panel papers-panel" style="padding:18px"><p>No papers found yet.</p></div>'
785
-
786
- items = []
787
- for i, paper in enumerate(papers, start=1):
788
- summary = safe_text((paper.get("summary") or paper.get("abstract") or "")[:280])
789
- doi_line = f'<span class="paper-badge doi-badge">{safe_text(paper.get("doi"))}</span>' if paper.get("doi") else ""
790
- pdf_link = paper.get("pdf") or "#"
791
- abs_link = paper.get("url") or "#"
792
- concept_line = ", ".join((paper.get("concepts") or [])[:4])
793
- items.append(
794
- f"""
795
- <article class="paper-card">
796
- <div class="paper-topline">
797
- <span class="paper-badge">{safe_text(paper.get('source', 'paper'))}</span>
798
- <span class="paper-badge alt">{safe_text(paper.get('published', '') or 'Paper')}</span>
799
- {doi_line}
800
- </div>
801
- <h4>{i}. {safe_text(paper.get('title', 'Untitled'))}</h4>
802
- <p>{summary or 'No abstract snippet available.'}</p>
803
- <div class="paper-meta-stack">
804
- <div><strong>Authors:</strong> {safe_text(paper.get('authors_text', 'Unknown authors'))}</div>
805
- <div><strong>Venue:</strong> {safe_text(paper.get('venue', 'Unknown venue'))}</div>
806
- <div><strong>Base score:</strong> {safe_text(round(float(paper.get('score', 0)), 3))}</div>
807
- <div><strong>Learned score:</strong> {safe_text(round(float(paper.get('learned_score', paper.get('score', 0)), 3)))}</div>
808
- <div><strong>Concepts:</strong> {safe_text(concept_line or 'None extracted')}</div>
809
- </div>
810
- <div class="paper-links">
811
- <a href="{safe_text(abs_link)}" target="_blank" rel="noopener noreferrer">Abstract</a>
812
- <a href="{safe_text(pdf_link)}" target="_blank" rel="noopener noreferrer">PDF</a>
813
- </div>
814
- </article>
815
- """
816
- )
817
- return '<div class="papers-grid">' + ''.join(items) + '</div>'
818
-
819
-
820
- def format_selection_choices(papers):
821
- choices = []
822
- for i, paper in enumerate(papers):
823
- learned = paper.get("learned_score", paper.get("score", 0))
824
- label = f"[{paper.get('source', 'src')}] {paper.get('title', 'Untitled')} — {paper.get('authors_text', 'Unknown authors')[:80]} — score {round(float(learned), 3)}"
825
- choices.append((label, str(i)))
826
- return choices
827
-
828
-
829
- def uploaded_pdf_summary(file_obj):
830
- if not file_obj:
831
- return "No PDF uploaded yet."
832
- path = getattr(file_obj, "name", None) or str(file_obj)
833
- p = Path(path)
834
- return f"Uploaded PDF ready for ingestion: {p.name}. Use Parse uploaded PDF to extract title, abstract, sections, references, concepts, and graph edges."
835
-
836
-
837
- def build_learning_graph_html(nodes, edges, title="Self-Learning Knowledge Graph"):
838
- if not nodes:
839
- return """
840
- <div class="panel brain-shell">
841
- <div class="brain-header">
842
- <div>
843
- <p class="eyebrow">Learning Graph</p>
844
- <h3>Self-Learning Knowledge Graph</h3>
845
- </div>
846
- </div>
847
- <div class="brain-stage learning-empty">
848
- <div class="empty-graph-copy">
849
- <h4>No papers mapped yet</h4>
850
- <p>Search papers, pick a topic, select candidates, or upload a PDF to grow the graph in real time.</p>
851
- </div>
852
- </div>
853
- </div>
854
- """
855
-
856
- node_items = []
857
- label_items = []
858
- edge_items = []
859
-
860
- coords = [
861
- (100, 90), (250, 60), (420, 75), (590, 115), (690, 250), (620, 395),
862
- (455, 455), (280, 455), (110, 395), (60, 250), (215, 250), (365, 245),
863
- (525, 250), (300, 145), (480, 340), (180, 340), (545, 175), (130, 170)
864
- ]
865
-
866
- graph_nodes = [dict(n) for n in nodes[:GRAPH_MAX_GRAPH_NODES]]
867
- for i, node in enumerate(graph_nodes):
868
- x, y = coords[i % len(coords)]
869
- node["sx"] = x
870
- node["sy"] = y
871
-
872
- node_map = {n["id"]: n for n in graph_nodes}
873
- for edge in edges[:60]:
874
- if isinstance(edge, dict):
875
- a = edge.get("source")
876
- b = edge.get("target")
877
- etype = edge.get("type", "")
878
- else:
879
- a, b = edge
880
- etype = ""
881
- if a in node_map and b in node_map:
882
- na = node_map[a]
883
- nb = node_map[b]
884
- edge_items.append(
885
- f'<line class="learn-edge edge-{safe_text(etype.lower())}" x1="{na["sx"]}" y1="{na["sy"]}" x2="{nb["sx"]}" y2="{nb["sy"]}" />'
886
- )
887
-
888
- for node in graph_nodes:
889
- kind = node.get("kind", node.get("type", "paper")).lower()
890
- if kind == "topic":
891
- kind = "query"
892
- if kind == "uploadedpdf":
893
- kind = "upload"
894
- radius = 25 if kind == "query" else 18 if kind in {"concept", "author", "claim", "reference"} else 20
895
- klass = f'learn-node {kind}'
896
- node_items.append(
897
- f'<circle class="{klass}" cx="{node["sx"]}" cy="{node["sy"]}" r="{radius}" />'
898
- )
899
- label = node.get("label") or node.get("title") or node.get("id")
900
- label_items.append(
901
- f'<text class="learn-label" x="{node["sx"] + 26}" y="{node["sy"] - 8}">{safe_text(str(label)[:46])}</text>'
902
- )
903
-
904
- return f"""
905
- <div class="panel brain-shell">
906
- <div class="brain-header">
907
- <div>
908
- <p class="eyebrow">Learning Graph</p>
909
- <h3>{safe_text(title)}</h3>
910
- </div>
911
- <div class="brain-legend">
912
- <span><i class="dot dot-query"></i> topic</span>
913
- <span><i class="dot dot-paper"></i> paper</span>
914
- <span><i class="dot dot-upload"></i> uploaded PDF</span>
915
- <span><i class="dot dot-concept"></i> concept</span>
916
- <span><i class="dot dot-author"></i> author</span>
917
- <span><i class="dot dot-ref"></i> reference</span>
918
- </div>
919
- </div>
920
- <div class="brain-stage">
921
- <svg viewBox="0 0 760 520" class="brain-svg" role="img" aria-label="Self-learning knowledge graph">
922
- {''.join(edge_items)}
923
- {''.join(node_items)}
924
- {''.join(label_items)}
925
- </svg>
926
- </div>
927
- </div>
928
- """
929
-
930
-
931
- def build_learning_graph_state(query, papers, uploaded_name=None):
932
- nodes = [{"id": "query", "label": query or "Research topic", "kind": "query"}]
933
- edges = []
934
-
935
- for i, paper in enumerate(papers[:5], start=1):
936
- pid = f"paper_{i}"
937
- nodes.append({"id": pid, "label": paper["title"], "kind": "paper"})
938
- edges.append({"source": "query", "target": pid, "type": "ABOUT"})
939
- for concept in (paper.get("concepts") or [])[:2]:
940
- cid = f"concept_{i}_{slugify(concept)[:20]}"
941
- nodes.append({"id": cid, "label": concept, "kind": "concept"})
942
- edges.append({"source": pid, "target": cid, "type": "MENTIONS"})
943
-
944
- if uploaded_name:
945
- nodes.append({"id": "upload", "label": uploaded_name, "kind": "upload"})
946
- edges.append({"source": "query", "target": "upload", "type": "UPLOADED_SOURCE"})
947
- if len(nodes) > 2:
948
- edges.append({"source": "upload", "target": "paper_1", "type": "RELATED_TO"})
949
-
950
- return nodes, edges
951
-
952
-
953
- def graph_from_selected(query, selected_papers, uploaded_name=None):
954
- nodes = [{"id": "query", "label": query or "Research topic", "kind": "query"}]
955
- edges = []
956
-
957
- for i, paper in enumerate(selected_papers[:8], start=1):
958
- pid = f"paper_{i}"
959
- nodes.append({"id": pid, "label": paper["title"], "kind": "paper"})
960
- edges.append({"source": "query", "target": pid, "type": "ABOUT"})
961
-
962
- if paper.get("doi"):
963
- doi_id = f"doi_{i}"
964
- nodes.append({"id": doi_id, "label": f"DOI {paper['doi']}", "kind": "reference"})
965
- edges.append({"source": pid, "target": doi_id, "type": "HAS_ID"})
966
-
967
- for author in paper.get("authors", [])[:2]:
968
- aid = f"author_{slugify(author)[:24]}_{i}"
969
- nodes.append({"id": aid, "label": author, "kind": "author"})
970
- edges.append({"source": pid, "target": aid, "type": "WRITTEN_BY"})
971
-
972
- for concept in (paper.get("concepts") or [])[:2]:
973
- cid = f"concept_{slugify(concept)[:20]}_{i}"
974
- nodes.append({"id": cid, "label": concept, "kind": "concept"})
975
- edges.append({"source": pid, "target": cid, "type": "MENTIONS"})
976
-
977
- if uploaded_name:
978
- nodes.append({"id": "upload", "label": uploaded_name, "kind": "upload"})
979
- edges.append({"source": "query", "target": "upload", "type": "UPLOADED_SOURCE"})
980
- if len(selected_papers) > 0:
981
- edges.append({"source": "upload", "target": "paper_1", "type": "RELATED_TO"})
982
-
983
- return nodes, edges
984
-
985
-
986
- def parse_pdf_with_grobid(pdf_path):
987
- if not GROBID_URL:
988
- raise RuntimeError("GROBID_URL is not set")
989
-
990
- with open(pdf_path, "rb") as f:
991
- files = {"input": (Path(pdf_path).name, f, "application/pdf")}
992
- response = requests.post(
993
- f"{GROBID_URL.rstrip('/')}/api/processFulltextDocument",
994
- files=files,
995
- data={"includeRawAffiliations": "1", "segmentSentences": "1"},
996
- timeout=120,
997
- )
998
-
999
- response.raise_for_status()
1000
- tei_xml = response.text
1001
-
1002
- if BeautifulSoup is None:
1003
- return {
1004
- "parser": "grobid",
1005
- "title": Path(pdf_path).name,
1006
- "abstract": "",
1007
- "authors": [],
1008
- "sections": [],
1009
- "references": [],
1010
- "claims": [],
1011
- "concepts": [],
1012
- "raw_text": "",
1013
- "tei_xml": tei_xml[:30000],
1014
- }
1015
-
1016
- soup = BeautifulSoup(tei_xml, "xml")
1017
- title_stmt = soup.find("titleStmt")
1018
- title_tag = title_stmt.find("title") if title_stmt else soup.find("title")
1019
- abstract_tag = soup.find("abstract")
1020
-
1021
- authors = []
1022
- for author in soup.find_all("author"):
1023
- pers = author.find("persName")
1024
- if pers:
1025
- name = " ".join(
1026
- x.get_text(" ", strip=True)
1027
- for x in pers.find_all(["forename", "surname"])
1028
- ).strip()
1029
- if name:
1030
- authors.append(name)
1031
-
1032
- sections = []
1033
- section_texts = []
1034
- for div in soup.find_all("div"):
1035
- head = div.find("head")
1036
- paras = [p.get_text(" ", strip=True) for p in div.find_all("p")]
1037
- text = "\n".join([p for p in paras if p.strip()])
1038
- if head and text.strip():
1039
- sections.append({"heading": head.get_text(" ", strip=True), "text": text[:4000]})
1040
- section_texts.append(text)
1041
-
1042
- references = []
1043
- for bibl in soup.find_all("biblStruct")[:40]:
1044
- ref_title = ""
1045
- ref_doi = ""
1046
- title_node = bibl.find("title")
1047
- if title_node:
1048
- ref_title = title_node.get_text(" ", strip=True)
1049
- doi_node = bibl.find("idno", attrs={"type": "DOI"})
1050
- if doi_node:
1051
- ref_doi = doi_node.get_text(" ", strip=True)
1052
- references.append({"title": ref_title, "doi": ref_doi})
1053
-
1054
- abstract = abstract_tag.get_text(" ", strip=True) if abstract_tag else ""
1055
- full_text_for_semantics = " ".join([abstract] + section_texts[:4])
1056
- return {
1057
- "parser": "grobid",
1058
- "title": title_tag.get_text(" ", strip=True) if title_tag else Path(pdf_path).name,
1059
- "abstract": abstract,
1060
- "authors": authors,
1061
- "sections": sections,
1062
- "references": references,
1063
- "claims": extract_claim_like_sentences(full_text_for_semantics, max_items=GRAPH_MAX_CLAIMS),
1064
- "concepts": extract_concepts_from_text(full_text_for_semantics, max_terms=GRAPH_MAX_CONCEPTS),
1065
- "raw_text": "",
1066
- "tei_xml": tei_xml[:60000],
1067
- }
1068
-
1069
-
1070
- def parse_pdf_with_pymupdf(pdf_path):
1071
- if fitz is None:
1072
- raise RuntimeError("PyMuPDF not installed")
1073
-
1074
- doc = fitz.open(pdf_path)
1075
- pages = [page.get_text("text") for page in doc]
1076
- raw_text = "\n".join(pages).strip()
1077
- first_page = raw_text[:4000]
1078
- lines = [x.strip() for x in first_page.splitlines() if x.strip()]
1079
- title = lines[0][:300] if lines else Path(pdf_path).name
1080
-
1081
- abstract = ""
1082
- match = re.search(r"abstract\s*(.+?)(?:\n\s*\n|\n1[\.\s]|introduction)", raw_text, re.I | re.S)
1083
- if match:
1084
- abstract = norm_text(match.group(1))[:2500]
1085
-
1086
- return {
1087
- "parser": "pymupdf",
1088
- "title": title,
1089
- "abstract": abstract,
1090
- "authors": [],
1091
- "sections": [{"heading": "Full Text", "text": raw_text[:12000]}] if raw_text else [],
1092
- "references": [],
1093
- "claims": extract_claim_like_sentences(raw_text, max_items=GRAPH_MAX_CLAIMS),
1094
- "concepts": extract_concepts_from_text(raw_text, max_terms=GRAPH_MAX_CONCEPTS),
1095
- "raw_text": raw_text[:50000],
1096
- "tei_xml": "",
1097
- }
1098
-
1099
-
1100
- def parse_pdf_with_docling(pdf_path):
1101
- try:
1102
- from docling.document_converter import DocumentConverter
1103
- except Exception as e:
1104
- raise RuntimeError(f"Docling import failed: {e}")
1105
-
1106
- converter = DocumentConverter()
1107
- result = converter.convert(pdf_path)
1108
- doc = result.document
1109
- markdown = doc.export_to_markdown()
1110
-
1111
- title = Path(pdf_path).name
1112
- first_nonempty = next((line.strip("# ").strip() for line in markdown.splitlines() if line.strip()), "")
1113
- if first_nonempty:
1114
- title = first_nonempty[:300]
1115
-
1116
- return {
1117
- "parser": "docling",
1118
- "title": title,
1119
- "abstract": "",
1120
- "authors": [],
1121
- "sections": [{"heading": "Document", "text": markdown[:12000]}] if markdown else [],
1122
- "references": [],
1123
- "claims": extract_claim_like_sentences(markdown, max_items=GRAPH_MAX_CLAIMS),
1124
- "concepts": extract_concepts_from_text(markdown, max_terms=GRAPH_MAX_CONCEPTS),
1125
- "raw_text": markdown[:50000],
1126
- "tei_xml": "",
1127
- }
1128
-
1129
-
1130
- def parse_uploaded_pdf(file_obj, parser_order):
1131
- if not file_obj:
1132
- return "No PDF uploaded yet.", {}
1133
-
1134
- path = getattr(file_obj, "name", None) or str(file_obj)
1135
- parser_order = ensure_list(parser_order) or ["grobid", "docling", "pymupdf"]
1136
- errors = []
1137
-
1138
- for parser_name in parser_order:
1139
- try:
1140
- if parser_name == "grobid":
1141
- result = parse_pdf_with_grobid(path)
1142
- elif parser_name == "docling":
1143
- result = parse_pdf_with_docling(path)
1144
- elif parser_name == "pymupdf":
1145
- result = parse_pdf_with_pymupdf(path)
1146
- else:
1147
- continue
1148
-
1149
- summary = (
1150
- f"### Parsed PDF\n\n"
1151
- f"- Parser used: {result['parser']}\n"
1152
- f"- Title: {result.get('title') or 'Unknown'}\n"
1153
- f"- Authors: {', '.join(result.get('authors')[:6]) if result.get('authors') else 'Unknown'}\n"
1154
- f"- Abstract found: {'Yes' if result.get('abstract') else 'No'}\n"
1155
- f"- Sections extracted: {len(result.get('sections') or [])}\n"
1156
- f"- References extracted: {len(result.get('references') or [])}\n"
1157
- f"- Concepts extracted: {len(result.get('concepts') or [])}\n"
1158
- f"- Claims extracted: {len(result.get('claims') or [])}\n"
1159
- )
1160
- return summary, result
1161
- except Exception as e:
1162
- errors.append(f"{parser_name}: {e}")
1163
-
1164
- fail_summary = "### PDF parsing failed\n\n" + "\n".join([f"- {x}" for x in errors])
1165
- return fail_summary, {"parser": None, "errors": errors}
1166
-
1167
-
1168
- def render_parse_result(parsed):
1169
- if not parsed:
1170
- return '<div class="panel" style="padding:18px"><p>No parsed document yet.</p></div>'
1171
-
1172
- sections_html = []
1173
- for section in parsed.get("sections", [])[:6]:
1174
- sections_html.append(
1175
- f"""
1176
- <details class="agent-step">
1177
- <summary class="agent-summary">
1178
- <div class="agent-index">§</div>
1179
- <div class="agent-head">
1180
- <h4>{safe_text(section.get('heading', 'Section'))}</h4>
1181
- <span>section</span>
1182
- </div>
1183
- </summary>
1184
- <div class="agent-copy">
1185
- <p>{safe_text(section.get('text', '')[:1800])}</p>
1186
- </div>
1187
- </details>
1188
- """
1189
- )
1190
-
1191
- refs = parsed.get("references", [])[:12]
1192
- refs_html = "".join(
1193
- f"<li>{safe_text(r.get('title') or 'Untitled')} {'· DOI ' + safe_text(r.get('doi')) if r.get('doi') else ''}</li>"
1194
- for r in refs
1195
- ) or "<li>No references extracted.</li>"
1196
-
1197
- concepts = parsed.get("concepts", [])[:10]
1198
- claims = parsed.get("claims", [])[:6]
1199
- concepts_html = "".join(f"<li>{safe_text(x)}</li>" for x in concepts) or "<li>No concepts extracted.</li>"
1200
- claims_html = "".join(f"<li>{safe_text(x)}</li>" for x in claims) or "<li>No claims extracted.</li>"
1201
-
1202
- title = safe_text(parsed.get("title") or "Parsed document")
1203
- abstract = safe_text((parsed.get("abstract") or "")[:2400]) or "No abstract extracted."
1204
- parser_name = safe_text(parsed.get("parser") or "unknown")
1205
-
1206
- return f"""
1207
- <div class="panel" style="padding:18px">
1208
- <div class="brain-header">
1209
- <div>
1210
- <p class="eyebrow">PDF Parse</p>
1211
- <h3>{title}</h3>
1212
- </div>
1213
- <div class="brain-legend"><span><i class="dot dot-upload"></i> {parser_name}</span></div>
1214
- </div>
1215
- <div class="parse-grid">
1216
- <div class="parse-card">
1217
- <h4>Abstract</h4>
1218
- <p>{abstract}</p>
1219
- </div>
1220
- <div class="parse-card">
1221
- <h4>References</h4>
1222
- <ul class="ref-list">{refs_html}</ul>
1223
- </div>
1224
- <div class="parse-card">
1225
- <h4>Concepts</h4>
1226
- <ul class="ref-list">{concepts_html}</ul>
1227
- </div>
1228
- <div class="parse-card">
1229
- <h4>Claims</h4>
1230
- <ul class="ref-list">{claims_html}</ul>
1231
- </div>
1232
- </div>
1233
- <div class="timeline" style="margin-top:14px;">
1234
- {''.join(sections_html) if sections_html else '<div class="panel" style="padding:16px;"><p>No sections extracted.</p></div>'}
1235
- </div>
1236
- </div>
1237
- """
1238
-
1239
-
1240
- def add_node(nodes_by_id: Dict[str, Dict], node_id: str, node_type: str, label: str = "", **attrs):
1241
- if not node_id:
1242
- return
1243
- existing = nodes_by_id.get(node_id, {})
1244
- merged = {"id": node_id, "type": node_type, "label": label or existing.get("label", node_id)}
1245
- merged.update(existing)
1246
- merged.update({k: v for k, v in attrs.items() if v not in [None, ""]})
1247
- nodes_by_id[node_id] = merged
1248
-
1249
-
1250
- def add_edge(edges: List[Dict], source: str, target: str, edge_type: str, **attrs):
1251
- if not source or not target or source == target:
1252
- return
1253
- edge = {"source": source, "target": target, "type": edge_type}
1254
- edge.update({k: v for k, v in attrs.items() if v not in [None, ""]})
1255
- edges.append(edge)
1256
-
1257
-
1258
- def build_ingest_payload(query, selected_papers, parsed_pdf=None):
1259
- nodes_by_id = {}
1260
- edges = []
1261
-
1262
- topic_id = "topic:query"
1263
- add_node(nodes_by_id, topic_id, "Topic", label=query or "Research topic", query=query or "")
1264
-
1265
- for i, p in enumerate(selected_papers, start=1):
1266
- paper_id = normalize_doi(p.get("doi")) or (p.get("external_ids") or {}).get("arxiv") or f"paper:{i}:{slugify(p.get('title', 'paper'))[:32]}"
1267
- add_node(
1268
- nodes_by_id,
1269
- paper_id,
1270
- "Paper",
1271
- label=p.get("title") or f"Paper {i}",
1272
- title=p.get("title"),
1273
- year=p.get("year"),
1274
- venue=p.get("venue"),
1275
- doi=normalize_doi(p.get("doi")),
1276
- source=p.get("source"),
1277
- url=p.get("url"),
1278
- pdf=p.get("pdf"),
1279
- score=p.get("score"),
1280
- learned_score=p.get("learned_score", p.get("score")),
1281
- open_access=p.get("open_access"),
1282
- )
1283
- add_edge(edges, topic_id, paper_id, "ABOUT", weight=p.get("learned_score", p.get("score", 0)))
1284
-
1285
- for author in p.get("authors", [])[:8]:
1286
- author_id = f"author:{slugify(author)[:64]}"
1287
- add_node(nodes_by_id, author_id, "Author", label=author, name=author)
1288
- add_edge(edges, paper_id, author_id, "WRITTEN_BY")
1289
-
1290
- for concept in (p.get("concepts") or [])[:8]:
1291
- concept_id = f"concept:{slugify(concept)[:72]}"
1292
- add_node(nodes_by_id, concept_id, "Concept", label=concept, name=concept)
1293
- add_edge(edges, paper_id, concept_id, "MENTIONS")
1294
-
1295
- for claim in (p.get("claims") or [])[:4]:
1296
- claim_id = f"claim:{slugify(claim)[:72]}"
1297
- add_node(nodes_by_id, claim_id, "Claim", label=claim[:120], text=claim)
1298
- add_edge(edges, paper_id, claim_id, "ASSERTS")
1299
-
1300
- if p.get("venue"):
1301
- venue_id = f"venue:{slugify(p['venue'])[:72]}"
1302
- add_node(nodes_by_id, venue_id, "Venue", label=p["venue"], name=p["venue"])
1303
- add_edge(edges, paper_id, venue_id, "PUBLISHED_IN")
1304
-
1305
- if parsed_pdf and parsed_pdf.get("title"):
1306
- doc_id = "upload:pdf"
1307
- add_node(
1308
- nodes_by_id,
1309
- doc_id,
1310
- "UploadedPDF",
1311
- label=parsed_pdf.get("title"),
1312
- title=parsed_pdf.get("title"),
1313
- parser=parsed_pdf.get("parser"),
1314
- )
1315
- add_edge(edges, topic_id, doc_id, "UPLOADED_SOURCE")
1316
-
1317
- for idx, author in enumerate(parsed_pdf.get("authors", [])[:8], start=1):
1318
- author_id = f"author:upload:{slugify(author)[:64]}"
1319
- add_node(nodes_by_id, author_id, "Author", label=author, name=author)
1320
- add_edge(edges, doc_id, author_id, "WRITTEN_BY")
1321
-
1322
- for idx, section in enumerate(parsed_pdf.get("sections", [])[:8], start=1):
1323
- sec_id = f"{doc_id}:section:{idx}"
1324
- add_node(nodes_by_id, sec_id, "Section", label=section.get("heading") or f"Section {idx}", text=section.get("text", ""))
1325
- add_edge(edges, doc_id, sec_id, "HAS_SECTION")
1326
-
1327
- for idx, ref in enumerate(parsed_pdf.get("references", [])[:12], start=1):
1328
- ref_label = ref.get("title") or f"Reference {idx}"
1329
- ref_doi = normalize_doi(ref.get("doi") or "")
1330
- ref_id = ref_doi or f"{doc_id}:ref:{idx}:{slugify(ref_label)[:32]}"
1331
- add_node(nodes_by_id, ref_id, "Reference", label=ref_label, title=ref_label, doi=ref_doi)
1332
- add_edge(edges, doc_id, ref_id, "CITES")
1333
-
1334
- for concept in (parsed_pdf.get("concepts") or [])[:8]:
1335
- concept_id = f"concept:{slugify(concept)[:72]}"
1336
- add_node(nodes_by_id, concept_id, "Concept", label=concept, name=concept)
1337
- add_edge(edges, doc_id, concept_id, "MENTIONS")
1338
-
1339
- for claim in (parsed_pdf.get("claims") or [])[:6]:
1340
- claim_id = f"claim:{slugify(claim)[:72]}"
1341
- add_node(nodes_by_id, claim_id, "Claim", label=claim[:120], text=claim)
1342
- add_edge(edges, doc_id, claim_id, "ASSERTS")
1343
-
1344
- payload = {"status": "ok", "nodes": list(nodes_by_id.values()), "edges": edges}
1345
- return payload
1346
-
1347
-
1348
- def build_autonomous_payload(query: str, papers: List[Dict], parsed_pdf: Optional[Dict], visited_queries: List[str], rounds: List[Dict]) -> Dict:
1349
- base_payload = build_ingest_payload(query, papers, parsed_pdf=parsed_pdf)
1350
- nodes_by_id = {n["id"]: dict(n) for n in base_payload["nodes"]}
1351
- edges = list(base_payload["edges"])
1352
-
1353
- for q in visited_queries[:12]:
1354
- qid = f"query:{slugify(q)[:72]}"
1355
- add_node(nodes_by_id, qid, "Query", label=q, query=q)
1356
- add_edge(edges, "topic:query", qid, "EXPANDS_TO")
1357
-
1358
- for r in rounds:
1359
- rid = f"round:{r['round']}"
1360
- add_node(nodes_by_id, rid, "Round", label=f"Round {r['round']}", round=r["round"])
1361
- add_edge(edges, "topic:query", rid, "HAS_ROUND")
1362
- for q in r.get("queries", [])[:5]:
1363
- qid = f"query:{slugify(q)[:72]}"
1364
- add_node(nodes_by_id, qid, "Query", label=q, query=q)
1365
- add_edge(edges, rid, qid, "EXECUTED_QUERY")
1366
- for nq in r.get("frontier_next", [])[:5]:
1367
- qid = f"query:{slugify(nq)[:72]}"
1368
- add_node(nodes_by_id, qid, "Query", label=nq, query=nq)
1369
- add_edge(edges, rid, qid, "PROPOSED_QUERY")
1370
-
1371
- return {"status": "ok", "nodes": list(nodes_by_id.values()), "edges": edges}
1372
-
1373
-
1374
- def learn_from_payload(payload: Dict, query: str = "") -> Dict:
1375
- if not payload:
1376
- return GRAPH_MEMORY
1377
-
1378
- GRAPH_MEMORY["queries"].append(query or "")
1379
- GRAPH_MEMORY["events"].append({
1380
- "ts": time.time(),
1381
- "query": query or "",
1382
- "nodes": len(payload.get("nodes", [])),
1383
- "edges": len(payload.get("edges", [])),
1384
- })
1385
-
1386
- for node in payload.get("nodes", []):
1387
- nid = node.get("id")
1388
- if not nid:
1389
- continue
1390
- GRAPH_MEMORY["nodes"][nid] = node
1391
- ntype = (node.get("type") or "").lower()
1392
- if ntype == "paper":
1393
- key = normalize_doi(node.get("doi") or "") or nid
1394
- GRAPH_MEMORY["papers"][key] = node
1395
- if node.get("doi"):
1396
- GRAPH_MEMORY["seen_dois"].add(normalize_doi(node["doi"]))
1397
- if node.get("title"):
1398
- GRAPH_MEMORY["seen_titles"].add(norm_text(node["title"]).lower())
1399
- if ntype == "concept" and node.get("label"):
1400
- GRAPH_MEMORY["concept_counts"][node["label"].lower()] += 1
1401
- if ntype == "claim" and node.get("label"):
1402
- GRAPH_MEMORY["claim_counts"][node["label"].lower()] += 1
1403
-
1404
- GRAPH_MEMORY["edges"].extend(payload.get("edges", []))
1405
- return GRAPH_MEMORY
1406
-
1407
-
1408
- def summarize_learning_state(learning_result: Dict) -> str:
1409
- if not learning_result:
1410
- return "### No learning results yet."
1411
-
1412
- papers = learning_result.get("papers") or []
1413
- visited_queries = learning_result.get("visited_queries") or []
1414
- rounds = learning_result.get("rounds") or []
1415
-
1416
- top_concepts = []
1417
- for p in papers[:8]:
1418
- top_concepts.extend((p.get("concepts") or [])[:3])
1419
- concept_counts = Counter([c.lower() for c in top_concepts if c])
1420
-
1421
- lines = [
1422
- "### Self-learning graph update",
1423
- "",
1424
- f"- Seed query: {learning_result.get('query') or 'Research topic'}",
1425
- f"- Frontier queries executed: {len(visited_queries)}",
1426
- f"- Expansion rounds: {len(rounds)}",
1427
- f"- Unique papers discovered: {len(papers)}",
1428
- f"- Top learned concepts: {', '.join([c for c, _ in concept_counts.most_common(6)]) if concept_counts else 'None'}",
1429
- ]
1430
-
1431
- for r in rounds[:3]:
1432
- lines.append(f"- Round {r['round']}: {len(r.get('queries', []))} queries, {r.get('papers_found', 0)} papers, next frontier {len(r.get('frontier_next', []))}")
1433
-
1434
- return "\n".join(lines)
1435
-
1436
-
1437
- def run_paper_discovery(query, search_mode, sources, pdf_file):
1438
- query_text = (query or "").strip()
1439
-
1440
- if not query_text and not pdf_file:
1441
- empty_graph = build_learning_graph_html([], [], "Self-Learning Knowledge Graph")
1442
- return (
1443
- empty_graph,
1444
- '<div class="panel papers-panel" style="padding:18px"><p>Enter a topic, title, DOI, link, or upload a PDF to start learning.</p></div>',
1445
- build_journal_html("biomaterials cardiac repair"),
1446
- "No PDF uploaded yet.",
1447
- gr.update(choices=[], value=[]),
1448
- [],
1449
- "### No discovery results yet.",
1450
- )
1451
-
1452
- parsed_pdf = None
1453
- uploaded_name = Path(getattr(pdf_file, "name", str(pdf_file))).name if pdf_file else None
1454
-
1455
- papers = []
1456
- try:
1457
- if search_mode == "autonomous_web" and query_text:
1458
- learning = autonomous_expand_graph(
1459
- query=query_text,
1460
- sources=ensure_list(sources) or DEFAULT_SOURCES,
1461
- parsed_pdf=parsed_pdf,
1462
- initial_mode="autonomous_web",
1463
- max_rounds=GRAPH_MAX_ROUNDS,
1464
- frontier_limit=GRAPH_MAX_FRONTIER,
1465
- max_results=GRAPH_MAX_RESULTS_PER_SOURCE,
1466
- )
1467
- papers = learning["papers"][:15]
1468
- payload = learning["payload"]
1469
- graph_html = build_learning_graph_html(payload["nodes"], payload["edges"], "Autonomous Self-Learning Graph")
1470
- status_md = summarize_learning_state(learning)
1471
- else:
1472
- if query_text:
1473
- papers = discover_papers(query_text, search_mode, sources, max_results=10)
1474
- graph_nodes, graph_edges = build_learning_graph_state(query_text, papers[:6], uploaded_name)
1475
- graph_html = build_learning_graph_html(graph_nodes, graph_edges)
1476
- status_md = (
1477
- f"### Discovery results\n\n"
1478
- f"- Search mode: {search_mode}\n"
1479
- f"- Sources: {', '.join(ensure_list(sources) or DEFAULT_SOURCES)}\n"
1480
- f"- Candidates found: {len(papers)}\n"
1481
- f"- Select papers below, then click **Ingest selected into graph**.\n"
1482
- )
1483
-
1484
- except Exception as e:
1485
- graph_nodes, graph_edges = build_learning_graph_state(
1486
- query_text,
1487
- [],
1488
- Path(getattr(pdf_file, "name", "uploaded.pdf")).name if pdf_file else None,
1489
- )
1490
- error_html = f'<div class="panel papers-panel" style="padding:18px"><p>Paper search failed: {safe_text(str(e))}</p></div>'
1491
- return (
1492
- build_learning_graph_html(graph_nodes, graph_edges),
1493
- error_html,
1494
- build_journal_html(query_text or "biomaterials cardiac repair"),
1495
- uploaded_pdf_summary(pdf_file),
1496
- gr.update(choices=[], value=[]),
1497
- [],
1498
- f"### Discovery failed.\n\n- Error: {safe_text(str(e))}",
1499
- )
1500
-
1501
- papers_html = format_papers_html(papers)
1502
- journals_html = build_journal_html(query_text or "biomaterials cardiac repair")
1503
- pdf_summary = uploaded_pdf_summary(pdf_file)
1504
- choices = format_selection_choices(papers)
1505
- return graph_html, papers_html, journals_html, pdf_summary, gr.update(choices=choices, value=[]), papers, status_md
1506
-
1507
-
1508
- def ingest_selected_papers(query, selected_indices, papers_state, pdf_file, parsed_state):
1509
- papers = ensure_list(papers_state)
1510
- selected_indices = ensure_list(selected_indices)
1511
-
1512
- selected = []
1513
- for idx in selected_indices:
1514
- try:
1515
- selected.append(papers[int(idx)])
1516
- except Exception:
1517
- pass
1518
-
1519
- uploaded_name = Path(getattr(pdf_file, "name", str(pdf_file))).name if pdf_file else None
1520
-
1521
- if not selected and parsed_state and isinstance(parsed_state, dict) and papers:
1522
- selected = papers[:3]
1523
-
1524
- if not selected and not parsed_state:
1525
- graph_html = build_learning_graph_html([], [], "Self-Learning Knowledge Graph")
1526
- return graph_html, "### Nothing ingested yet.\n\nSelect papers or parse an uploaded PDF first.", {"status": "empty", "nodes": [], "edges": []}
1527
-
1528
- query_text = query or (parsed_state.get("title") if isinstance(parsed_state, dict) else "") or "Research topic"
1529
-
1530
- for i, p in enumerate(selected):
1531
- selected[i] = enrich_paper_semantics(query_text, p)
1532
-
1533
- graph_nodes, graph_edges = graph_from_selected(query_text, selected, uploaded_name)
1534
- graph_html = build_learning_graph_html(graph_nodes, graph_edges, "Selected Research Graph")
1535
-
1536
- payload = build_ingest_payload(query_text, selected, parsed_state if isinstance(parsed_state, dict) else None)
1537
- learn_from_payload(payload, query=query_text)
1538
-
1539
- top_concepts = []
1540
- for p in selected:
1541
- top_concepts.extend((p.get("concepts") or [])[:3])
1542
- if isinstance(parsed_state, dict):
1543
- top_concepts.extend((parsed_state.get("concepts") or [])[:3])
1544
-
1545
- summary_lines = [
1546
- "### Graph ingest ready",
1547
- "",
1548
- f"- Topic: {query_text}",
1549
- f"- Selected papers: {len(selected)}",
1550
- f"- Uploaded PDF parsed: {'Yes' if parsed_state and isinstance(parsed_state, dict) and parsed_state.get('title') else 'No'}",
1551
- f"- Nodes created: {len(payload['nodes'])}",
1552
- f"- Edges created: {len(payload['edges'])}",
1553
- f"- Learned concepts: {', '.join(unique_keep_order(top_concepts)[:8]) if top_concepts else 'None'}",
1554
- f"- Memory papers stored: {len(GRAPH_MEMORY['papers'])}",
1555
- f"- Memory concepts stored: {len(GRAPH_MEMORY['concept_counts'])}",
1556
- ]
1557
- return graph_html, "\n".join(summary_lines), payload
1558
-
1559
-
1560
- def run_self_learning_cycle(query, search_mode, sources, pdf_file, parser_order, selected_indices, papers_state, parsed_state):
1561
- query_text = (query or "").strip()
1562
-
1563
- if pdf_file and (not parsed_state or not isinstance(parsed_state, dict) or not parsed_state.get("title")):
1564
- _, parsed_state = parse_uploaded_pdf(pdf_file, parser_order)
1565
-
1566
- learning = autonomous_expand_graph(
1567
- query=query_text or (parsed_state.get("title") if isinstance(parsed_state, dict) else "Research topic"),
1568
- sources=ensure_list(sources) or DEFAULT_SOURCES,
1569
- parsed_pdf=parsed_state if isinstance(parsed_state, dict) else None,
1570
- initial_mode="autonomous_web" if search_mode == "autonomous_web" else search_mode,
1571
- max_rounds=GRAPH_MAX_ROUNDS,
1572
- frontier_limit=GRAPH_MAX_FRONTIER,
1573
- max_results=GRAPH_MAX_RESULTS_PER_SOURCE,
1574
- )
1575
-
1576
- papers = learning["papers"][:15]
1577
-
1578
- selected = []
1579
- for idx in ensure_list(selected_indices):
1580
- try:
1581
- selected.append(papers[int(idx)])
1582
- except Exception:
1583
- pass
1584
- if not selected:
1585
- selected = papers[:5]
1586
-
1587
- payload = build_autonomous_payload(
1588
- learning["query"],
1589
- selected,
1590
- parsed_pdf=parsed_state if isinstance(parsed_state, dict) else None,
1591
- visited_queries=learning.get("visited_queries", []),
1592
- rounds=learning.get("rounds", []),
1593
- )
1594
- learn_from_payload(payload, query=learning["query"])
1595
-
1596
- graph_html = build_learning_graph_html(payload["nodes"], payload["edges"], "Self-Learning Graph Cycle")
1597
- papers_html = format_papers_html(papers)
1598
- status_md = summarize_learning_state(learning)
1599
-
1600
- return graph_html, papers_html, gr.update(choices=format_selection_choices(papers), value=[]), papers, status_md, payload
1601
-
1602
-
1603
- def get_graph_memory_snapshot():
1604
- return {
1605
- "queries": list(GRAPH_MEMORY["queries"]),
1606
- "papers": list(GRAPH_MEMORY["papers"].values()),
1607
- "nodes": list(GRAPH_MEMORY["nodes"].values()),
1608
- "edges": list(GRAPH_MEMORY["edges"]),
1609
- "concept_counts": dict(GRAPH_MEMORY["concept_counts"]),
1610
- "claim_counts": dict(GRAPH_MEMORY["claim_counts"]),
1611
- "events": list(GRAPH_MEMORY["events"]),
1612
- }
1613
-
1614
-
1615
- def reset_graph_memory():
1616
- GRAPH_MEMORY["queries"] = []
1617
- GRAPH_MEMORY["papers"] = {}
1618
- GRAPH_MEMORY["nodes"] = {}
1619
- GRAPH_MEMORY["edges"] = []
1620
- GRAPH_MEMORY["events"] = []
1621
- GRAPH_MEMORY["concept_counts"] = Counter()
1622
- GRAPH_MEMORY["claim_counts"] = Counter()
1623
- GRAPH_MEMORY["seen_queries"] = set()
1624
- GRAPH_MEMORY["seen_dois"] = set()
1625
- GRAPH_MEMORY["seen_titles"] = set()
1626
- return "### Graph memory reset.", get_graph_memory_snapshot()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dvnc_ai_v2_hf/deprecated/self_learning_graph_old4.py DELETED
@@ -1,1430 +0,0 @@
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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dvnc_ai_v2_hf/deprecated/self_learning_graph_old5.py DELETED
@@ -1,1490 +0,0 @@
1
- import html
2
- import json
3
- import os
4
- import re
5
- import time
6
- import urllib.parse
7
- import xml.etree.ElementTree as ET
8
- from collections import Counter
9
- from pathlib import Path
10
- from typing import Any, Dict, List, Optional, Tuple
11
-
12
- import gradio as gr
13
- import requests
14
-
15
- try:
16
- import fitz # PyMuPDF
17
- except Exception:
18
- fitz = None
19
-
20
- try:
21
- from bs4 import BeautifulSoup
22
- except Exception:
23
- BeautifulSoup = None
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
- OPENALEX_EMAIL = os.getenv("OPENALEX_EMAIL", "").strip()
40
- CROSSREF_MAILTO = os.getenv("CROSSREF_MAILTO", "").strip()
41
- REQUEST_TIMEOUT = int(os.getenv("REQUEST_TIMEOUT", "25"))
42
- GRAPH_MAX_CONCEPTS = int(os.getenv("GRAPH_MAX_CONCEPTS", "12"))
43
- GRAPH_MAX_CLAIMS = int(os.getenv("GRAPH_MAX_CLAIMS", "8"))
44
- GRAPH_MAX_RESULTS = int(os.getenv("GRAPH_MAX_RESULTS", "12"))
45
- GRAPH_MAX_EXPANSIONS = int(os.getenv("GRAPH_MAX_EXPANSIONS", "6"))
46
- GRAPH_MAX_NODES = int(os.getenv("GRAPH_MAX_NODES", "400"))
47
- GRAPH_MAX_EDGES = int(os.getenv("GRAPH_MAX_EDGES", "1200"))
48
- MAX_ABSTRACT_CHARS = int(os.getenv("MAX_ABSTRACT_CHARS", "4000"))
49
- MAX_RAW_TEXT_CHARS = int(os.getenv("MAX_RAW_TEXT_CHARS", "70000"))
50
-
51
- STOPWORDS = {
52
- "a", "an", "and", "are", "as", "at", "be", "been", "being", "by", "can", "could", "did", "do", "does",
53
- "for", "from", "had", "has", "have", "if", "in", "into", "is", "it", "its", "may", "might", "of", "on",
54
- "or", "our", "such", "that", "the", "their", "there", "these", "this", "those", "to", "using", "use",
55
- "used", "via", "was", "were", "will", "with", "within", "without", "we", "they", "you", "your", "study",
56
- "paper", "research", "results", "method", "methods", "analysis", "approach", "toward", "towards",
57
- "based", "new", "novel", "effect", "effects", "model", "models", "system", "systems", "show", "shows",
58
- "introduction", "conclusion", "discussion", "figure", "table", "supplementary", "material", "materials",
59
- }
60
-
61
- GRAPH_MEMORY = {
62
- "papers": {},
63
- "nodes": {},
64
- "edges": [],
65
- "concept_counts": Counter(),
66
- "claim_counts": Counter(),
67
- "queries": [],
68
- "events": [],
69
- "frontier": [],
70
- "payloads": [],
71
- }
72
-
73
-
74
- class ScholarlyClient:
75
- def __init__(self):
76
- self.session = requests.Session()
77
- ua = "dvnc-ai-self-learning-graph/1.0"
78
- if CROSSREF_MAILTO:
79
- ua += f" (mailto:{CROSSREF_MAILTO})"
80
- self.session.headers.update({"User-Agent": ua, "Accept": "application/json, text/xml, */*"})
81
- self.session_timeout = REQUEST_TIMEOUT
82
-
83
- def get(self, url: str, **kwargs):
84
- timeout = kwargs.pop("timeout", self.session_timeout)
85
- return self.session.get(url, timeout=timeout, **kwargs)
86
-
87
- def post(self, url: str, **kwargs):
88
- timeout = kwargs.pop("timeout", max(self.session_timeout, 120))
89
- return self.session.post(url, timeout=timeout, **kwargs)
90
-
91
-
92
- HTTP = ScholarlyClient()
93
-
94
-
95
- def safe_text(x, default=""):
96
- return html.escape(str(x if x is not None else default))
97
-
98
-
99
- def norm_text(x: Optional[str]) -> str:
100
- return re.sub(r"\s+", " ", (x or "")).strip()
101
-
102
-
103
- def slugify(text: str) -> str:
104
- return re.sub(r"[^a-z0-9]+", "-", (text or "").lower()).strip("-")
105
-
106
-
107
- def ensure_list(x):
108
- return x if isinstance(x, list) else []
109
-
110
-
111
- def truncate_text(text: str, limit: int) -> str:
112
- text = norm_text(text)
113
- return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
114
-
115
-
116
- def normalize_doi(text: str) -> str:
117
- text = (text or "").strip()
118
- text = re.sub(r"^https?://(dx\.)?doi\.org/", "", text, flags=re.I)
119
- return text.strip().rstrip("/")
120
-
121
-
122
- def detect_query_type(query: str) -> str:
123
- q = (query or "").strip()
124
- doi_pattern = r"^10\.\d{4,9}/[-._;()/:A-Z0-9]+$"
125
- if re.match(doi_pattern, q, flags=re.I):
126
- return "doi"
127
- if q.startswith("http://") or q.startswith("https://"):
128
- return "link"
129
- return "topic"
130
-
131
-
132
- def tokenize(text: str) -> List[str]:
133
- return [t for t in re.findall(r"[a-zA-Z][a-zA-Z0-9\-]{2,}", (text or "").lower()) if t not in STOPWORDS]
134
-
135
-
136
- def unique_keep_order(items: List[str]) -> List[str]:
137
- seen = set()
138
- out = []
139
- for item in items:
140
- key = norm_text(item).lower()
141
- if key and key not in seen:
142
- seen.add(key)
143
- out.append(norm_text(item))
144
- return out
145
-
146
-
147
- def text_overlap_score(a: str, b: str) -> float:
148
- sa = set(tokenize(a))
149
- sb = set(tokenize(b))
150
- if not sa or not sb:
151
- return 0.0
152
- return len(sa & sb) / max(1, len(sa | sb))
153
-
154
-
155
- def compute_recency_bonus(year: str) -> float:
156
- try:
157
- y = int(str(year)[:4])
158
- except Exception:
159
- return 0.0
160
- current = time.gmtime().tm_year
161
- age = max(current - y, 0)
162
- return max(0.0, 0.14 - age * 0.015)
163
-
164
-
165
- def extract_candidate_phrases(text: str, max_terms: int = 20) -> List[str]:
166
- text = norm_text(text)
167
- if not text:
168
- return []
169
- tokens = re.findall(r"[A-Za-z][A-Za-z0-9\-]{2,}", text)
170
- phrases = []
171
- for n in (3, 2, 1):
172
- for i in range(len(tokens) - n + 1):
173
- phrase = " ".join(tokens[i:i + n]).strip().lower()
174
- if len(phrase) < 4:
175
- continue
176
- parts = phrase.split()
177
- if any(p in STOPWORDS for p in parts):
178
- continue
179
- if all(len(p) <= 2 for p in parts):
180
- continue
181
- phrases.append(phrase)
182
- counts = Counter(phrases)
183
- ranked = [p for p, _ in counts.most_common(max_terms * 4)]
184
- filtered = []
185
- for phrase in ranked:
186
- if phrase in filtered:
187
- continue
188
- if any(phrase != other and phrase in other for other in filtered):
189
- continue
190
- filtered.append(phrase)
191
- if len(filtered) >= max_terms:
192
- break
193
- return filtered
194
-
195
-
196
- def extract_concepts_from_text(text: str, max_terms: int = GRAPH_MAX_CONCEPTS) -> List[str]:
197
- return extract_candidate_phrases(text, max_terms=max_terms)
198
-
199
-
200
- def extract_claim_like_sentences(text: str, max_items: int = GRAPH_MAX_CLAIMS) -> List[str]:
201
- text = norm_text(text)
202
- if not text:
203
- return []
204
- parts = re.split(r"(?<=[\.\!\?])\s+", text)
205
- scored = []
206
- for sentence in parts:
207
- s = norm_text(sentence)
208
- if len(s) < 40 or len(s) > 320:
209
- continue
210
- lower = s.lower()
211
- score = 0.0
212
- if any(k in lower for k in ["improves", "reduces", "increases", "suggests", "demonstrates", "shows", "reveals", "predicts", "achieves", "outperforms", "enables", "supports"]):
213
- score += 2.0
214
- if any(k in lower for k in ["significant", "associated", "correlated", "effective", "robust", "accurate", "validated", "statistically"]):
215
- score += 1.0
216
- if any(k in lower for k in ["compared", "versus", "baseline", "state-of-the-art", "sota"]):
217
- score += 1.0
218
- score += min(len(tokenize(s)) / 15.0, 2.0)
219
- scored.append((score, s))
220
- return [s for _, s in sorted(scored, key=lambda x: x[0], reverse=True)[:max_items]]
221
-
222
-
223
- def parse_openalex_abstract(inverted_index) -> str:
224
- if not inverted_index or not isinstance(inverted_index, dict):
225
- return ""
226
- pos_to_word = {}
227
- for word, positions in inverted_index.items():
228
- for pos in positions:
229
- pos_to_word[pos] = word
230
- if not pos_to_word:
231
- return ""
232
- return " ".join(pos_to_word[i] for i in sorted(pos_to_word))
233
-
234
-
235
- def score_frontier_candidate(query: str, seed_concepts: List[str], paper: Dict) -> Dict:
236
- title = paper.get("title", "")
237
- abstract = paper.get("abstract", "") or paper.get("summary", "")
238
- venue = paper.get("venue", "")
239
- base_text = " ".join([title, abstract, venue])
240
- rel = text_overlap_score(query, base_text)
241
- concept_overlap = 0.0
242
- if seed_concepts:
243
- concept_overlap = text_overlap_score(" ".join(seed_concepts), " ".join(paper.get("concepts") or []))
244
- recency = compute_recency_bonus(paper.get("year"))
245
- doi_bonus = 0.02 if paper.get("doi") else 0.0
246
- oa_bonus = 0.03 if paper.get("open_access") else 0.0
247
- score = float(paper.get("score", 0)) + rel * 0.45 + concept_overlap * 0.2 + recency + doi_bonus + oa_bonus
248
- paper["frontier_score"] = round(score, 4)
249
- paper["frontier_relevance"] = round(rel, 4)
250
- paper["frontier_concept_overlap"] = round(concept_overlap, 4)
251
- return paper
252
-
253
-
254
- def enrich_paper_semantics(query: str, paper: Dict) -> Dict:
255
- paper = dict(paper)
256
- title = paper.get("title", "")
257
- abstract = paper.get("abstract", "") or paper.get("summary", "")
258
- venue = paper.get("venue", "")
259
- base_text = " ".join([title, abstract, venue]).strip()
260
- concepts = extract_concepts_from_text(base_text, max_terms=GRAPH_MAX_CONCEPTS)
261
- claims = extract_claim_like_sentences(abstract, max_items=GRAPH_MAX_CLAIMS)
262
- rel = text_overlap_score(query, f"{title} {abstract}")
263
- recency = compute_recency_bonus(paper.get("year"))
264
- doi_bonus = 0.02 if paper.get("doi") else 0.0
265
- oa_bonus = 0.03 if paper.get("open_access") else 0.0
266
- concept_bonus = min(len(concepts), 8) * 0.01
267
- learned_score = float(paper.get("score", 0)) + rel * 0.5 + recency + doi_bonus + oa_bonus + concept_bonus
268
- paper["concepts"] = concepts[:GRAPH_MAX_CONCEPTS]
269
- paper["claims"] = claims[:GRAPH_MAX_CLAIMS]
270
- paper["relevance"] = round(rel, 4)
271
- paper["learned_score"] = round(learned_score, 4)
272
- return paper
273
-
274
-
275
- def paper_identity_key(paper: Dict) -> str:
276
- return (
277
- normalize_doi(paper.get("doi") or "")
278
- or (paper.get("external_ids") or {}).get("arxiv")
279
- or (paper.get("external_ids") or {}).get("pmcid")
280
- or norm_text(paper.get("title", "")).lower()
281
- or str(paper.get("id"))
282
- )
283
-
284
-
285
- def journal_query_links(query: str):
286
- q = urllib.parse.quote_plus(query or "biomaterials cardiac repair")
287
- rows = []
288
- for journal in JOURNALS:
289
- url = f"{journal['url']}?q={q}" if "?" not in journal["url"] else f"{journal['url']}&q={q}"
290
- if "ieeexplore" in journal["url"]:
291
- url = f"https://ieeexplore.ieee.org/search/searchresult.jsp?queryText={q}"
292
- rows.append({"name": journal["name"], "desc": journal["desc"], "url": url})
293
- return rows
294
-
295
-
296
- def build_journal_html(query):
297
- rows = []
298
- for journal in journal_query_links(query):
299
- rows.append(
300
- f"""
301
- <a class="journal-card" href="{safe_text(journal['url'])}" target="_blank" rel="noopener noreferrer">
302
- <div>
303
- <h4>{safe_text(journal['name'])}</h4>
304
- <p>{safe_text(journal['desc'])}</p>
305
- </div>
306
- <span>Open</span>
307
- </a>
308
- """
309
- )
310
- return '<div class="journal-grid">' + ''.join(rows) + '</div>'
311
-
312
-
313
- def search_arxiv(query, max_results=8):
314
- encoded = urllib.parse.quote(query)
315
- url = (
316
- "http://export.arxiv.org/api/query?search_query=all:"
317
- f"{encoded}&start=0&max_results={max_results}&sortBy=relevance&sortOrder=descending"
318
- )
319
- response = HTTP.get(url)
320
- response.raise_for_status()
321
- root = ET.fromstring(response.text)
322
- ns = {"atom": "http://www.w3.org/2005/Atom"}
323
- papers = []
324
- for entry in root.findall("atom:entry", ns):
325
- title = " ".join((entry.findtext("atom:title", default="", namespaces=ns) or "").split())
326
- summary = truncate_text(" ".join((entry.findtext("atom:summary", default="", namespaces=ns) or "").split()), MAX_ABSTRACT_CHARS)
327
- published = entry.findtext("atom:published", default="", namespaces=ns)
328
- paper_id = entry.findtext("atom:id", default="", namespaces=ns)
329
- authors = [a.findtext("atom:name", default="", namespaces=ns) for a in entry.findall("atom:author", ns)]
330
- pdf_url = ""
331
- for link in entry.findall("atom:link", ns):
332
- if link.attrib.get("title") == "pdf":
333
- pdf_url = link.attrib.get("href", "")
334
- break
335
- papers.append({
336
- "id": paper_id or title,
337
- "title": title,
338
- "summary": summary,
339
- "abstract": summary,
340
- "published": published[:10],
341
- "authors": [a for a in authors[:8] if a],
342
- "authors_text": ", ".join([a for a in authors[:4] if a]) or "Unknown authors",
343
- "url": paper_id,
344
- "pdf": pdf_url,
345
- "doi": "",
346
- "venue": "arXiv",
347
- "year": published[:4] if published else "",
348
- "source": "arxiv",
349
- "score": 0.76,
350
- "open_access": True,
351
- "external_ids": {"arxiv": (paper_id or "").split("/")[-1]},
352
- })
353
- return papers
354
-
355
-
356
- def search_crossref(query, mode="topic", max_results=8):
357
- params = {}
358
- if CROSSREF_MAILTO:
359
- params["mailto"] = CROSSREF_MAILTO
360
- if mode == "doi":
361
- url = f"https://api.crossref.org/works/{urllib.parse.quote(query)}"
362
- response = HTTP.get(url, params=params)
363
- if response.status_code != 200:
364
- return []
365
- items = [response.json().get("message", {})]
366
- else:
367
- params["rows"] = max_results
368
- if mode in ("title", "paper_name"):
369
- params["query.title"] = query
370
- else:
371
- params["query.bibliographic"] = query
372
- response = HTTP.get("https://api.crossref.org/works", params=params)
373
- response.raise_for_status()
374
- items = response.json().get("message", {}).get("items", [])
375
- out = []
376
- for item in items:
377
- authors = []
378
- for a in item.get("author", []) or []:
379
- name = " ".join(filter(None, [a.get("given"), a.get("family")])).strip()
380
- if name:
381
- authors.append(name)
382
- title = (item.get("title") or ["Untitled"])[0]
383
- year = ""
384
- for key in ["published-print", "published-online", "created"]:
385
- if item.get(key, {}).get("date-parts"):
386
- year = str(item[key]["date-parts"][0][0])
387
- break
388
- abstract = truncate_text(re.sub("<.*?>", "", item.get("abstract") or ""), MAX_ABSTRACT_CHARS)
389
- doi = normalize_doi(item.get("DOI", ""))
390
- out.append({
391
- "id": doi or title,
392
- "title": norm_text(title),
393
- "summary": abstract[:500],
394
- "abstract": abstract,
395
- "published": year,
396
- "authors": authors,
397
- "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
398
- "url": item.get("URL", ""),
399
- "pdf": "",
400
- "doi": doi,
401
- "venue": (item.get("container-title") or [""])[0],
402
- "year": year,
403
- "source": "crossref",
404
- "score": 0.72,
405
- "open_access": None,
406
- "external_ids": {"crossref": doi} if doi else {},
407
- })
408
- return out
409
-
410
-
411
- def search_openalex(query, mode="topic", max_results=8):
412
- params = {"per-page": max_results}
413
- if OPENALEX_EMAIL:
414
- params["mailto"] = OPENALEX_EMAIL
415
- if mode == "doi":
416
- doi = normalize_doi(query)
417
- params["filter"] = f"doi:https://doi.org/{doi}"
418
- else:
419
- params["search"] = query
420
- response = HTTP.get("https://api.openalex.org/works", params=params)
421
- response.raise_for_status()
422
- items = response.json().get("results", [])
423
- out = []
424
- for item in items:
425
- authors = []
426
- for auth in item.get("authorships", [])[:8]:
427
- author = auth.get("author") or {}
428
- if author.get("display_name"):
429
- authors.append(author["display_name"])
430
- oa = item.get("open_access") or {}
431
- doi = normalize_doi(item.get("doi") or "")
432
- abstract = truncate_text(parse_openalex_abstract(item.get("abstract_inverted_index")), MAX_ABSTRACT_CHARS)
433
- out.append({
434
- "id": item.get("id") or doi or item.get("title"),
435
- "title": norm_text(item.get("title")),
436
- "summary": abstract[:500],
437
- "abstract": abstract,
438
- "published": str(item.get("publication_year") or ""),
439
- "authors": authors,
440
- "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
441
- "url": (item.get("primary_location") or {}).get("landing_page_url") or item.get("id") or "",
442
- "pdf": oa.get("oa_url") or "",
443
- "doi": doi,
444
- "venue": ((item.get("primary_location") or {}).get("source") or {}).get("display_name") or "",
445
- "year": str(item.get("publication_year") or ""),
446
- "source": "openalex",
447
- "score": 0.80,
448
- "open_access": oa.get("is_oa"),
449
- "external_ids": item.get("ids") or {},
450
- })
451
- return out
452
-
453
-
454
- def search_semantic_scholar(query, mode="topic", max_results=8):
455
- headers = {}
456
- if SEMANTIC_SCHOLAR_API_KEY:
457
- headers["x-api-key"] = SEMANTIC_SCHOLAR_API_KEY
458
- fields = "title,authors,year,abstract,venue,externalIds,url,openAccessPdf"
459
- if mode == "doi":
460
- doi = normalize_doi(query)
461
- url = f"https://api.semanticscholar.org/graph/v1/paper/DOI:{urllib.parse.quote(doi)}"
462
- response = HTTP.get(url, params={"fields": fields}, headers=headers)
463
- if response.status_code != 200:
464
- return []
465
- items = [response.json()]
466
- else:
467
- response = HTTP.get(
468
- "https://api.semanticscholar.org/graph/v1/paper/search",
469
- params={"query": query, "limit": max_results, "fields": fields},
470
- headers=headers,
471
- )
472
- if response.status_code != 200:
473
- return []
474
- items = response.json().get("data", [])
475
- out = []
476
- for item in items:
477
- external = item.get("externalIds") or {}
478
- authors = [a.get("name") for a in item.get("authors", []) if a.get("name")]
479
- abstract = truncate_text(norm_text(item.get("abstract", "")), MAX_ABSTRACT_CHARS)
480
- out.append({
481
- "id": external.get("CorpusId") or external.get("DOI") or item.get("title"),
482
- "title": norm_text(item.get("title")),
483
- "summary": abstract[:500],
484
- "abstract": abstract,
485
- "published": str(item.get("year") or ""),
486
- "authors": authors,
487
- "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
488
- "url": item.get("url") or "",
489
- "pdf": (item.get("openAccessPdf") or {}).get("url") or "",
490
- "doi": normalize_doi(external.get("DOI", "")),
491
- "venue": item.get("venue") or "",
492
- "year": str(item.get("year") or ""),
493
- "source": "semantic_scholar",
494
- "score": 0.84,
495
- "open_access": bool((item.get("openAccessPdf") or {}).get("url")),
496
- "external_ids": external,
497
- })
498
- return out
499
-
500
-
501
- def search_europe_pmc(query, mode="topic", max_results=8):
502
- epmc_query = f'DOI:"{query}"' if mode == "doi" else query
503
- params = {"query": epmc_query, "format": "json", "pageSize": max_results, "resultType": "core"}
504
- response = HTTP.get("https://www.ebi.ac.uk/europepmc/webservices/rest/search", params=params)
505
- if response.status_code != 200:
506
- return []
507
- items = response.json().get("resultList", {}).get("result", [])
508
- out = []
509
- for item in items:
510
- author_string = item.get("authorString", "")
511
- authors = [x.strip() for x in author_string.split(",")[:8] if x.strip()]
512
- pmcid = item.get("pmcid", "")
513
- pdf_url = f"https://europepmc.org/articles/{pmcid}?pdf=render" if pmcid else ""
514
- landing_url = f"https://europepmc.org/article/PMC/{pmcid}" if pmcid else ""
515
- abstract = truncate_text(norm_text(item.get("abstractText", "")), MAX_ABSTRACT_CHARS)
516
- out.append({
517
- "id": item.get("id") or item.get("doi") or item.get("title"),
518
- "title": norm_text(item.get("title")),
519
- "summary": abstract[:500],
520
- "abstract": abstract,
521
- "published": str(item.get("pubYear") or ""),
522
- "authors": authors,
523
- "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
524
- "url": landing_url,
525
- "pdf": pdf_url,
526
- "doi": normalize_doi(item.get("doi", "")),
527
- "venue": item.get("journalTitle", ""),
528
- "year": str(item.get("pubYear") or ""),
529
- "source": "europe_pmc",
530
- "score": 0.78,
531
- "open_access": bool(pmcid),
532
- "external_ids": {"pmid": item.get("pmid"), "pmcid": pmcid},
533
- })
534
- return out
535
-
536
-
537
- def resolve_link(query):
538
- url = (query or "").strip()
539
- if not url:
540
- return []
541
- try:
542
- response = HTTP.get(url, allow_redirects=True, headers={"User-Agent": "dvnc-ai-space/1.0"})
543
- content_type = response.headers.get("content-type", "")
544
- if "pdf" in content_type or url.lower().endswith(".pdf"):
545
- name = Path(url.split("?")[0]).name or "linked-paper.pdf"
546
- return [{
547
- "id": url,
548
- "title": name,
549
- "summary": "Direct PDF link detected.",
550
- "abstract": "",
551
- "published": "",
552
- "authors": [],
553
- "authors_text": "Unknown authors",
554
- "url": url,
555
- "pdf": url,
556
- "doi": "",
557
- "venue": "Direct PDF",
558
- "year": "",
559
- "source": "link",
560
- "score": 0.66,
561
- "open_access": True,
562
- "external_ids": {},
563
- }]
564
- doi = ""
565
- title = url
566
- pdf_link = ""
567
- if BeautifulSoup is not None:
568
- soup = BeautifulSoup(response.text, "html.parser")
569
- title = soup.title.text.strip() if soup.title else url
570
- for meta_name in ["citation_doi", "dc.identifier", "dc.Identifier"]:
571
- tag = soup.find("meta", attrs={"name": meta_name})
572
- if tag and tag.get("content"):
573
- doi = normalize_doi(tag["content"].strip())
574
- break
575
- for a in soup.find_all("a", href=True):
576
- href = a["href"]
577
- if ".pdf" in href.lower():
578
- pdf_link = href if href.startswith("http") else urllib.parse.urljoin(url, href)
579
- break
580
- if doi:
581
- results = search_crossref(doi, mode="doi", max_results=1)
582
- if results:
583
- if pdf_link and not results[0].get("pdf"):
584
- results[0]["pdf"] = pdf_link
585
- if url and not results[0].get("url"):
586
- results[0]["url"] = url
587
- return results
588
- return [{
589
- "id": url,
590
- "title": title,
591
- "summary": "Landing page resolved from direct link.",
592
- "abstract": "",
593
- "published": "",
594
- "authors": [],
595
- "authors_text": "Unknown authors",
596
- "url": url,
597
- "pdf": pdf_link,
598
- "doi": doi,
599
- "venue": "Web Link",
600
- "year": "",
601
- "source": "link",
602
- "score": 0.54,
603
- "open_access": bool(pdf_link),
604
- "external_ids": {},
605
- }]
606
- except Exception as e:
607
- return [{
608
- "id": url,
609
- "title": "Link resolution error",
610
- "summary": str(e),
611
- "abstract": "",
612
- "published": "",
613
- "authors": [],
614
- "authors_text": "Unknown authors",
615
- "url": url,
616
- "pdf": "",
617
- "doi": "",
618
- "venue": "Link",
619
- "year": "",
620
- "source": "link",
621
- "score": 0.20,
622
- "open_access": None,
623
- "external_ids": {},
624
- }]
625
-
626
-
627
- def dedupe_papers(items: List[Dict]) -> List[Dict]:
628
- seen = {}
629
- for item in items:
630
- key = paper_identity_key(item) or f"{item.get('source', 'src')}::{item.get('title', 'paper')}"
631
- current = seen.get(key)
632
- candidate_score = float(item.get("learned_score", item.get("score", 0)))
633
- current_score = float(current.get("learned_score", current.get("score", 0))) if current else -1
634
- if current is None or candidate_score > current_score:
635
- seen[key] = item
636
- return sorted(seen.values(), key=lambda x: float(x.get("learned_score", x.get("score", 0))), reverse=True)
637
-
638
-
639
- def discover_papers(query, mode, sources, max_results=10):
640
- query = (query or "").strip()
641
- if not query:
642
- return []
643
- mode = detect_query_type(query) if mode == "autonomous_web" else mode
644
- selected_sources = ensure_list(sources) or DEFAULT_SOURCES
645
- results = []
646
- if mode == "link":
647
- return dedupe_papers(resolve_link(query))
648
- if "arxiv" in selected_sources and mode != "doi":
649
- try:
650
- results.extend(search_arxiv(query, max_results=min(max_results, GRAPH_MAX_RESULTS)))
651
- except Exception:
652
- pass
653
- if "crossref" in selected_sources:
654
- try:
655
- results.extend(search_crossref(query, mode=mode, max_results=min(max_results, GRAPH_MAX_RESULTS)))
656
- except Exception:
657
- pass
658
- if "openalex" in selected_sources:
659
- try:
660
- results.extend(search_openalex(query, mode=mode, max_results=min(max_results, GRAPH_MAX_RESULTS)))
661
- except Exception:
662
- pass
663
- if "semantic_scholar" in selected_sources:
664
- try:
665
- results.extend(search_semantic_scholar(query, mode=mode, max_results=min(max_results, GRAPH_MAX_RESULTS)))
666
- except Exception:
667
- pass
668
- if "europe_pmc" in selected_sources:
669
- try:
670
- results.extend(search_europe_pmc(query, mode=mode, max_results=min(max_results, GRAPH_MAX_RESULTS)))
671
- except Exception:
672
- pass
673
- papers = [enrich_paper_semantics(query, p) for p in dedupe_papers(results)]
674
- papers = sorted(papers, key=lambda x: float(x.get("learned_score", x.get("score", 0))), reverse=True)
675
- return papers[:max_results]
676
-
677
-
678
- def propose_expansion_queries(query: str, papers: List[Dict], parsed_state: Optional[Dict] = None, limit: int = GRAPH_MAX_EXPANSIONS) -> List[str]:
679
- concept_pool = []
680
- venue_pool = []
681
- for paper in papers[:8]:
682
- concept_pool.extend((paper.get("concepts") or [])[:4])
683
- if paper.get("venue"):
684
- venue_pool.append(paper["venue"])
685
- if parsed_state and isinstance(parsed_state, dict):
686
- concept_pool.extend((parsed_state.get("concepts") or [])[:6])
687
- ranked_concepts = [c for c, _ in Counter([norm_text(c).lower() for c in concept_pool if c]).most_common(limit * 2)]
688
- expansions = [norm_text(query)] if query else []
689
- for concept in ranked_concepts:
690
- if not concept:
691
- continue
692
- if query:
693
- expansions.append(f"{query} {concept}")
694
- else:
695
- expansions.append(concept)
696
- for venue in unique_keep_order(venue_pool)[:2]:
697
- if query and venue:
698
- expansions.append(f"{query} {venue}")
699
- return unique_keep_order(expansions)[:limit]
700
-
701
-
702
- def frontier_expand(query: str, sources: List[str], selected_papers: List[Dict], parsed_state: Optional[Dict] = None, per_query: int = 4) -> List[Dict]:
703
- seed_concepts = []
704
- for p in selected_papers[:6]:
705
- seed_concepts.extend((p.get("concepts") or [])[:4])
706
- if parsed_state and isinstance(parsed_state, dict):
707
- seed_concepts.extend((parsed_state.get("concepts") or [])[:6])
708
- expansion_queries = propose_expansion_queries(query, selected_papers, parsed_state=parsed_state, limit=GRAPH_MAX_EXPANSIONS)
709
- frontier = []
710
- for eq in expansion_queries:
711
- try:
712
- items = discover_papers(eq, "topic", sources, max_results=per_query)
713
- for item in items:
714
- frontier.append(score_frontier_candidate(query or eq, seed_concepts, item))
715
- except Exception:
716
- continue
717
- frontier = dedupe_papers(frontier)
718
- frontier.sort(key=lambda x: float(x.get("frontier_score", x.get("learned_score", x.get("score", 0))),), reverse=True)
719
- GRAPH_MEMORY["frontier"] = frontier[: GRAPH_MAX_EXPANSIONS * per_query]
720
- return GRAPH_MEMORY["frontier"]
721
-
722
-
723
- def paper_choice_value(index: int, paper: Dict) -> str:
724
- doi = normalize_doi(paper.get("doi") or "")
725
- title_slug = slugify(paper.get("title", ""))[:40]
726
- return f"{index}|{doi}|{title_slug}"
727
-
728
-
729
- def paper_choice_label(index: int, paper: Dict) -> str:
730
- score = round(float(paper.get("learned_score", paper.get("score", 0))), 3)
731
- title = paper.get("title", "Untitled")
732
- authors_text = paper.get("authors_text", "Unknown authors")[:80]
733
- source = paper.get("source", "src")
734
- return f"[{source}] {title} — {authors_text} — score {score}"
735
-
736
-
737
- def format_selection_choices(papers):
738
- return [(paper_choice_label(i, paper), paper_choice_value(i, paper)) for i, paper in enumerate(papers)]
739
-
740
-
741
- def format_papers_html(papers):
742
- if not papers:
743
- return '<div class="panel papers-panel" style="padding:18px"><p>No papers found yet.</p></div>'
744
- items = []
745
- for i, paper in enumerate(papers, start=1):
746
- summary = safe_text((paper.get("summary") or paper.get("abstract") or "")[:280])
747
- doi_line = f'<span class="paper-badge doi-badge">{safe_text(paper.get("doi"))}</span>' if paper.get("doi") else ""
748
- pdf_link = paper.get("pdf") or "#"
749
- abs_link = paper.get("url") or "#"
750
- concepts_text = ", ".join((paper.get("concepts") or [])[:4])
751
- items.append(
752
- f"""
753
- <article class="paper-card">
754
- <div class="paper-topline">
755
- <span class="paper-badge">{safe_text(paper.get('source', 'paper'))}</span>
756
- <span class="paper-badge alt">{safe_text(paper.get('published', '') or 'Paper')}</span>
757
- {doi_line}
758
- </div>
759
- <h4>{i}. {safe_text(paper.get('title', 'Untitled'))}</h4>
760
- <p>{summary or 'No abstract snippet available.'}</p>
761
- <div class="paper-meta-stack">
762
- <div><strong>Authors:</strong> {safe_text(paper.get('authors_text', 'Unknown authors'))}</div>
763
- <div><strong>Venue:</strong> {safe_text(paper.get('venue', 'Unknown venue'))}</div>
764
- <div><strong>Learned score:</strong> {safe_text(round(float(paper.get('learned_score', paper.get('score', 0))), 3))}</div>
765
- <div><strong>Concepts:</strong> {safe_text(concepts_text or 'None extracted')}</div>
766
- </div>
767
- <div class="paper-links">
768
- <a href="{safe_text(abs_link)}" target="_blank" rel="noopener noreferrer">Abstract</a>
769
- <a href="{safe_text(pdf_link)}" target="_blank" rel="noopener noreferrer">PDF</a>
770
- </div>
771
- </article>
772
- """
773
- )
774
- return '<div class="papers-grid">' + ''.join(items) + '</div>'
775
-
776
-
777
- def format_frontier_html(frontier):
778
- if not frontier:
779
- return '<div class="panel papers-panel" style="padding:18px"><p>No autonomous expansion candidates yet.</p></div>'
780
- cards = []
781
- for i, paper in enumerate(frontier[:12], start=1):
782
- cards.append(
783
- f"""
784
- <article class="paper-card frontier-card">
785
- <div class="paper-topline">
786
- <span class="paper-badge">frontier</span>
787
- <span class="paper-badge alt">{safe_text(paper.get('source', 'paper'))}</span>
788
- </div>
789
- <h4>{i}. {safe_text(paper.get('title', 'Untitled'))}</h4>
790
- <p>{safe_text((paper.get('summary') or paper.get('abstract') or '')[:260])}</p>
791
- <div class="paper-meta-stack">
792
- <div><strong>Frontier score:</strong> {safe_text(paper.get('frontier_score', paper.get('learned_score', paper.get('score', 0))))}</div>
793
- <div><strong>Concept overlap:</strong> {safe_text(paper.get('frontier_concept_overlap', 0))}</div>
794
- <div><strong>Authors:</strong> {safe_text(paper.get('authors_text', 'Unknown authors'))}</div>
795
- </div>
796
- </article>
797
- """
798
- )
799
- return '<div class="papers-grid">' + ''.join(cards) + '</div>'
800
-
801
-
802
- def uploaded_pdf_summary(file_obj):
803
- if not file_obj:
804
- return "No PDF uploaded yet."
805
- path = getattr(file_obj, "name", None) or str(file_obj)
806
- p = Path(path)
807
- return f"Uploaded PDF ready for ingestion: {p.name}. Use Parse uploaded PDF to extract title, abstract, sections, references, concepts, and claims."
808
-
809
-
810
- def build_learning_graph_html(nodes, edges, title="Self-Learning Knowledge Graph"):
811
- if not nodes:
812
- return """
813
- <div class="panel brain-shell">
814
- <div class="brain-header">
815
- <div>
816
- <p class="eyebrow">Learning Graph</p>
817
- <h3>Self-Learning Knowledge Graph</h3>
818
- </div>
819
- </div>
820
- <div class="brain-stage learning-empty">
821
- <div class="empty-graph-copy">
822
- <h4>No papers mapped yet</h4>
823
- <p>Search papers, pick a topic, select candidates, or upload a PDF to grow the graph in real time.</p>
824
- </div>
825
- </div>
826
- </div>
827
- """
828
- coords = [
829
- (100, 90), (250, 60), (420, 75), (590, 115), (690, 250), (620, 395),
830
- (455, 455), (280, 455), (110, 395), (60, 250), (215, 250), (365, 245),
831
- (525, 250), (300, 145), (480, 340), (180, 340), (545, 175), (130, 170)
832
- ]
833
- graph_nodes = [dict(n) for n in nodes[:18]]
834
- for i, node in enumerate(graph_nodes):
835
- x, y = coords[i % len(coords)]
836
- node["sx"] = x
837
- node["sy"] = y
838
- node_map = {n["id"]: n for n in graph_nodes}
839
- edge_items, node_items, label_items = [], [], []
840
- for edge in edges[:80]:
841
- source = edge.get("source")
842
- target = edge.get("target")
843
- edge_type = edge.get("type", "")
844
- if source in node_map and target in node_map:
845
- a = node_map[source]
846
- b = node_map[target]
847
- edge_items.append(
848
- f'<line class="learn-edge edge-{safe_text(edge_type.lower())}" x1="{a["sx"]}" y1="{a["sy"]}" x2="{b["sx"]}" y2="{b["sy"]}" />'
849
- )
850
- for node in graph_nodes:
851
- kind = (node.get("kind") or node.get("type") or "paper").lower()
852
- if kind == "topic":
853
- kind = "query"
854
- if kind == "uploadedpdf":
855
- kind = "upload"
856
- radius = 25 if kind == "query" else 18 if kind in {"concept", "author", "claim", "reference"} else 20
857
- css_class = f"learn-node {kind}"
858
- node_items.append(f'<circle class="{css_class}" cx="{node["sx"]}" cy="{node["sy"]}" r="{radius}" />')
859
- label = node.get("label") or node.get("title") or node.get("id")
860
- label_items.append(f'<text class="learn-label" x="{node["sx"] + 26}" y="{node["sy"] - 8}">{safe_text(str(label)[:46])}</text>')
861
- return f"""
862
- <div class="panel brain-shell">
863
- <div class="brain-header">
864
- <div>
865
- <p class="eyebrow">Learning Graph</p>
866
- <h3>{safe_text(title)}</h3>
867
- </div>
868
- <div class="brain-legend">
869
- <span><i class="dot dot-query"></i> topic</span>
870
- <span><i class="dot dot-paper"></i> paper</span>
871
- <span><i class="dot dot-upload"></i> uploaded PDF</span>
872
- <span><i class="dot dot-concept"></i> concept</span>
873
- <span><i class="dot dot-author"></i> author</span>
874
- <span><i class="dot dot-ref"></i> reference</span>
875
- </div>
876
- </div>
877
- <div class="brain-stage">
878
- <svg viewBox="0 0 760 520" class="brain-svg" role="img" aria-label="Self-learning knowledge graph">
879
- {''.join(edge_items)}
880
- {''.join(node_items)}
881
- {''.join(label_items)}
882
- </svg>
883
- </div>
884
- </div>
885
- """
886
-
887
-
888
- def build_learning_graph_state(query, papers, uploaded_name=None):
889
- nodes = [{"id": "query", "label": query or "Research topic", "kind": "query"}]
890
- edges = []
891
- for i, paper in enumerate(papers[:5], start=1):
892
- pid = f"paper_{i}"
893
- nodes.append({"id": pid, "label": paper.get("title", f"Paper {i}"), "kind": "paper"})
894
- edges.append({"source": "query", "target": pid, "type": "ABOUT"})
895
- for concept in (paper.get("concepts") or [])[:2]:
896
- cid = f"concept_{i}_{slugify(concept)[:20]}"
897
- nodes.append({"id": cid, "label": concept, "kind": "concept"})
898
- edges.append({"source": pid, "target": cid, "type": "MENTIONS"})
899
- if uploaded_name:
900
- nodes.append({"id": "upload", "label": uploaded_name, "kind": "upload"})
901
- edges.append({"source": "query", "target": "upload", "type": "UPLOADED_SOURCE"})
902
- return nodes, edges
903
-
904
-
905
- def graph_from_selected(query, selected_papers, uploaded_name=None, parsed_state=None, frontier=None):
906
- nodes = [{"id": "query", "label": query or "Research topic", "kind": "query"}]
907
- edges = []
908
- for i, paper in enumerate(selected_papers[:6], start=1):
909
- pid = f"paper_{i}"
910
- nodes.append({"id": pid, "label": paper.get("title", f"Paper {i}"), "kind": "paper"})
911
- edges.append({"source": "query", "target": pid, "type": "ABOUT"})
912
- for author in paper.get("authors", [])[:2]:
913
- aid = f"author_{i}_{slugify(author)[:24]}"
914
- nodes.append({"id": aid, "label": author, "kind": "author"})
915
- edges.append({"source": pid, "target": aid, "type": "WRITTEN_BY"})
916
- for concept in (paper.get("concepts") or [])[:2]:
917
- cid = f"concept_{i}_{slugify(concept)[:24]}"
918
- nodes.append({"id": cid, "label": concept, "kind": "concept"})
919
- edges.append({"source": pid, "target": cid, "type": "MENTIONS"})
920
- for claim in (paper.get("claims") or [])[:1]:
921
- cid = f"claim_{i}_{slugify(claim)[:24]}"
922
- nodes.append({"id": cid, "label": claim[:42], "kind": "claim"})
923
- edges.append({"source": pid, "target": cid, "type": "ASSERTS"})
924
- if uploaded_name:
925
- nodes.append({"id": "upload", "label": uploaded_name, "kind": "upload"})
926
- edges.append({"source": "query", "target": "upload", "type": "UPLOADED_SOURCE"})
927
- if parsed_state and isinstance(parsed_state, dict):
928
- for concept in (parsed_state.get("concepts") or [])[:3]:
929
- cid = f"upload_concept_{slugify(concept)[:24]}"
930
- nodes.append({"id": cid, "label": concept, "kind": "concept"})
931
- edges.append({"source": "upload", "target": cid, "type": "MENTIONS"})
932
- for j, fp in enumerate(ensure_list(frontier)[:3], start=1):
933
- fid = f"frontier_{j}"
934
- nodes.append({"id": fid, "label": fp.get("title", f"Frontier {j}"), "kind": "reference"})
935
- edges.append({"source": "query", "target": fid, "type": "FRONTIER"})
936
- return nodes, edges
937
-
938
-
939
- def parse_pdf_with_grobid(pdf_path):
940
- if not GROBID_URL:
941
- raise RuntimeError("GROBID_URL is not set")
942
- with open(pdf_path, "rb") as f:
943
- files = {"input": (Path(pdf_path).name, f, "application/pdf")}
944
- response = HTTP.post(
945
- f"{GROBID_URL.rstrip('/')}/api/processFulltextDocument",
946
- files=files,
947
- data={"includeRawAffiliations": "1", "segmentSentences": "1"},
948
- timeout=180,
949
- )
950
- response.raise_for_status()
951
- tei_xml = response.text
952
- root = ET.fromstring(tei_xml)
953
- ns = {"tei": "http://www.tei-c.org/ns/1.0"}
954
- title = root.findtext(".//tei:titleStmt/tei:title", default="", namespaces=ns) or Path(pdf_path).name
955
- abstract_parts = []
956
- for p in root.findall(".//tei:profileDesc/tei:abstract//tei:p", ns):
957
- abstract_parts.append(" ".join(list(p.itertext())))
958
- abstract = truncate_text(" ".join(abstract_parts), MAX_ABSTRACT_CHARS)
959
- authors = []
960
- for author in root.findall(".//tei:sourceDesc//tei:author", ns):
961
- parts = []
962
- forename = author.findall(".//tei:forename", ns)
963
- surname = author.findall(".//tei:surname", ns)
964
- parts.extend([norm_text(" ".join(x.itertext())) for x in forename])
965
- parts.extend([norm_text(" ".join(x.itertext())) for x in surname])
966
- name = norm_text(" ".join(parts))
967
- if name:
968
- authors.append(name)
969
- sections = []
970
- text_pool = []
971
- for div in root.findall(".//tei:text//tei:body//tei:div", ns):
972
- head = div.findtext("./tei:head", default="", namespaces=ns)
973
- paras = []
974
- for p in div.findall(".//tei:p", ns):
975
- para_text = norm_text(" ".join(list(p.itertext())))
976
- if para_text:
977
- paras.append(para_text)
978
- joined = "\n".join(paras)
979
- if head or joined:
980
- sections.append({"heading": head or "Section", "text": truncate_text(joined, 4000)})
981
- if joined:
982
- text_pool.append(joined)
983
- references = []
984
- for bibl in root.findall(".//tei:listBibl//tei:biblStruct", ns)[:60]:
985
- ref_title = bibl.findtext(".//tei:title", default="", namespaces=ns)
986
- ref_doi = ""
987
- for idno in bibl.findall(".//tei:idno", ns):
988
- if (idno.attrib.get("type") or "").lower() == "doi":
989
- ref_doi = norm_text(" ".join(idno.itertext()))
990
- break
991
- references.append({"title": norm_text(ref_title), "doi": normalize_doi(ref_doi)})
992
- semantic_text = truncate_text(" ".join([abstract] + text_pool[:5]), MAX_RAW_TEXT_CHARS)
993
- return {
994
- "parser": "grobid",
995
- "title": norm_text(title),
996
- "abstract": abstract,
997
- "authors": authors[:12],
998
- "sections": sections[:14],
999
- "references": references[:60],
1000
- "claims": extract_claim_like_sentences(semantic_text, max_items=GRAPH_MAX_CLAIMS),
1001
- "concepts": extract_concepts_from_text(semantic_text, max_terms=GRAPH_MAX_CONCEPTS),
1002
- "raw_text": "",
1003
- "parser_quality": "scholarly-structured",
1004
- }
1005
-
1006
-
1007
- def parse_pdf_with_pymupdf(pdf_path):
1008
- if fitz is None:
1009
- raise RuntimeError("PyMuPDF not installed")
1010
- doc = fitz.open(pdf_path)
1011
- raw_text = truncate_text("\n".join(page.get_text("text") for page in doc).strip(), MAX_RAW_TEXT_CHARS)
1012
- first_page = raw_text[:4000]
1013
- lines = [x.strip() for x in first_page.splitlines() if x.strip()]
1014
- title = lines[0][:300] if lines else Path(pdf_path).name
1015
- abstract = ""
1016
- match = re.search(r"abstract\s*(.+?)(?:\n\s*\n|\n1[\.\s]|introduction)", raw_text, re.I | re.S)
1017
- if match:
1018
- abstract = truncate_text(match.group(1), 2500)
1019
- return {
1020
- "parser": "pymupdf",
1021
- "title": title,
1022
- "abstract": abstract,
1023
- "authors": [],
1024
- "sections": [{"heading": "Full Text", "text": raw_text[:12000]}] if raw_text else [],
1025
- "references": [],
1026
- "claims": extract_claim_like_sentences(raw_text, max_items=GRAPH_MAX_CLAIMS),
1027
- "concepts": extract_concepts_from_text(raw_text, max_terms=GRAPH_MAX_CONCEPTS),
1028
- "raw_text": raw_text,
1029
- "parser_quality": "text-fallback",
1030
- }
1031
-
1032
-
1033
- def parse_pdf_with_docling(pdf_path):
1034
- try:
1035
- from docling.document_converter import DocumentConverter
1036
- except Exception as e:
1037
- raise RuntimeError(f"Docling import failed: {e}")
1038
- converter = DocumentConverter()
1039
- result = converter.convert(pdf_path)
1040
- doc = result.document
1041
- markdown = truncate_text(doc.export_to_markdown(), MAX_RAW_TEXT_CHARS)
1042
- title = Path(pdf_path).name
1043
- first_nonempty = next((line.strip("# ").strip() for line in markdown.splitlines() if line.strip()), "")
1044
- if first_nonempty:
1045
- title = first_nonempty[:300]
1046
- return {
1047
- "parser": "docling",
1048
- "title": title,
1049
- "abstract": "",
1050
- "authors": [],
1051
- "sections": [{"heading": "Document", "text": markdown[:12000]}] if markdown else [],
1052
- "references": [],
1053
- "claims": extract_claim_like_sentences(markdown, max_items=GRAPH_MAX_CLAIMS),
1054
- "concepts": extract_concepts_from_text(markdown, max_terms=GRAPH_MAX_CONCEPTS),
1055
- "raw_text": markdown,
1056
- "parser_quality": "layout-aware",
1057
- }
1058
-
1059
-
1060
- def parse_uploaded_pdf(file_obj, parser_order):
1061
- if not file_obj:
1062
- return "### PDF parse status\n\nNo PDF uploaded yet.", {}
1063
- path = getattr(file_obj, "name", None) or str(file_obj)
1064
- parser_order = ensure_list(parser_order) or ["grobid", "docling", "pymupdf"]
1065
- errors = []
1066
- for parser_name in parser_order:
1067
- try:
1068
- if parser_name == "grobid":
1069
- result = parse_pdf_with_grobid(path)
1070
- elif parser_name == "docling":
1071
- result = parse_pdf_with_docling(path)
1072
- elif parser_name == "pymupdf":
1073
- result = parse_pdf_with_pymupdf(path)
1074
- else:
1075
- continue
1076
- summary = (
1077
- f"### PDF parse status\n\n"
1078
- f"- Parser used: {result['parser']}\n"
1079
- f"- Parser quality: {result.get('parser_quality', 'unknown')}\n"
1080
- f"- Title: {result.get('title') or 'Unknown'}\n"
1081
- f"- Authors: {', '.join(result.get('authors')[:6]) if result.get('authors') else 'Unknown'}\n"
1082
- f"- Abstract found: {'Yes' if result.get('abstract') else 'No'}\n"
1083
- f"- Sections extracted: {len(result.get('sections') or [])}\n"
1084
- f"- References extracted: {len(result.get('references') or [])}\n"
1085
- f"- Concepts extracted: {len(result.get('concepts') or [])}\n"
1086
- f"- Claims extracted: {len(result.get('claims') or [])}\n"
1087
- )
1088
- return summary, result
1089
- except Exception as e:
1090
- errors.append(f"{parser_name}: {e}")
1091
- fail_summary = "### PDF parse status\n\n" + "\n".join([f"- {x}" for x in errors])
1092
- return fail_summary, {"parser": None, "errors": errors}
1093
-
1094
-
1095
- def render_parse_result(parsed):
1096
- if not parsed or not isinstance(parsed, dict) or (not parsed.get("title") and not parsed.get("sections")):
1097
- return '<div class="panel" style="padding:18px"><p>No parsed document yet.</p></div>'
1098
- sections_html = []
1099
- for section in parsed.get("sections", [])[:6]:
1100
- sections_html.append(
1101
- f"""
1102
- <details class="agent-step">
1103
- <summary class="agent-summary">
1104
- <div class="agent-index">§</div>
1105
- <div class="agent-head">
1106
- <h4>{safe_text(section.get('heading', 'Section'))}</h4>
1107
- <span>section</span>
1108
- </div>
1109
- </summary>
1110
- <div class="agent-copy">
1111
- <p>{safe_text(section.get('text', '')[:1800])}</p>
1112
- </div>
1113
- </details>
1114
- """
1115
- )
1116
- refs = parsed.get("references", [])[:12]
1117
- refs_html = "".join(
1118
- f"<li>{safe_text(r.get('title') or 'Untitled')} {'· DOI ' + safe_text(r.get('doi')) if r.get('doi') else ''}</li>"
1119
- for r in refs
1120
- ) or "<li>No references extracted.</li>"
1121
- concepts = parsed.get("concepts", [])[:10]
1122
- claims = parsed.get("claims", [])[:6]
1123
- concepts_html = "".join(f"<li>{safe_text(x)}</li>" for x in concepts) or "<li>No concepts extracted.</li>"
1124
- claims_html = "".join(f"<li>{safe_text(x)}</li>" for x in claims) or "<li>No claims extracted.</li>"
1125
- title = safe_text(parsed.get("title") or "Parsed document")
1126
- abstract = safe_text((parsed.get("abstract") or "")[:2400]) or "No abstract extracted."
1127
- parser_name = safe_text(parsed.get("parser") or "unknown")
1128
- parser_quality = safe_text(parsed.get("parser_quality") or "unknown")
1129
- return f"""
1130
- <div class="panel" style="padding:18px">
1131
- <div class="brain-header">
1132
- <div>
1133
- <p class="eyebrow">PDF Parse</p>
1134
- <h3>{title}</h3>
1135
- </div>
1136
- <div class="brain-legend"><span><i class="dot dot-upload"></i> {parser_name} · {parser_quality}</span></div>
1137
- </div>
1138
- <div class="parse-grid">
1139
- <div class="parse-card">
1140
- <h4>Abstract</h4>
1141
- <p>{abstract}</p>
1142
- </div>
1143
- <div class="parse-card">
1144
- <h4>References</h4>
1145
- <ul class="ref-list">{refs_html}</ul>
1146
- </div>
1147
- <div class="parse-card">
1148
- <h4>Concepts</h4>
1149
- <ul class="ref-list">{concepts_html}</ul>
1150
- </div>
1151
- <div class="parse-card">
1152
- <h4>Claims</h4>
1153
- <ul class="ref-list">{claims_html}</ul>
1154
- </div>
1155
- </div>
1156
- <div class="timeline" style="margin-top:14px;">
1157
- {''.join(sections_html) if sections_html else '<div class="panel" style="padding:16px;"><p>No sections extracted.</p></div>'}
1158
- </div>
1159
- </div>
1160
- """
1161
-
1162
-
1163
- def add_node(nodes_by_id: Dict[str, Dict], node_id: str, node_type: str, label: str = "", **attrs):
1164
- if not node_id:
1165
- return
1166
- current = nodes_by_id.get(node_id, {})
1167
- merged = {"id": node_id, "type": node_type, "label": label or current.get("label", node_id)}
1168
- merged.update(current)
1169
- for key, value in attrs.items():
1170
- if value not in [None, ""]:
1171
- merged[key] = value
1172
- nodes_by_id[node_id] = merged
1173
-
1174
-
1175
- def add_edge(edges: List[Dict], source: str, target: str, edge_type: str, **attrs):
1176
- if not source or not target or source == target:
1177
- return
1178
- edge = {"source": source, "target": target, "type": edge_type}
1179
- for key, value in attrs.items():
1180
- if value not in [None, ""]:
1181
- edge[key] = value
1182
- edges.append(edge)
1183
-
1184
-
1185
- def build_ingest_payload(query, selected_papers, parsed_pdf=None, frontier=None):
1186
- nodes_by_id = {}
1187
- edges = []
1188
- topic_id = "topic:query"
1189
- add_node(nodes_by_id, topic_id, "Topic", label=query or "Research topic", query=query or "")
1190
- for i, paper in enumerate(selected_papers, start=1):
1191
- 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]}"
1192
- add_node(
1193
- nodes_by_id,
1194
- paper_id,
1195
- "Paper",
1196
- label=paper.get("title") or f"Paper {i}",
1197
- title=paper.get("title"),
1198
- year=paper.get("year"),
1199
- venue=paper.get("venue"),
1200
- doi=normalize_doi(paper.get("doi")),
1201
- source=paper.get("source"),
1202
- url=paper.get("url"),
1203
- pdf=paper.get("pdf"),
1204
- score=paper.get("score"),
1205
- learned_score=paper.get("learned_score", paper.get("score")),
1206
- open_access=paper.get("open_access"),
1207
- authors_text=paper.get("authors_text"),
1208
- )
1209
- add_edge(edges, topic_id, paper_id, "ABOUT", weight=paper.get("learned_score", paper.get("score", 0)))
1210
- for author in paper.get("authors", [])[:6]:
1211
- author_id = f"author:{slugify(author)[:64]}"
1212
- add_node(nodes_by_id, author_id, "Author", label=author, name=author)
1213
- add_edge(edges, paper_id, author_id, "WRITTEN_BY")
1214
- for concept in (paper.get("concepts") or [])[:6]:
1215
- concept_id = f"concept:{slugify(concept)[:72]}"
1216
- add_node(nodes_by_id, concept_id, "Concept", label=concept, name=concept)
1217
- add_edge(edges, paper_id, concept_id, "MENTIONS")
1218
- for claim in (paper.get("claims") or [])[:3]:
1219
- claim_id = f"claim:{slugify(claim)[:72]}"
1220
- add_node(nodes_by_id, claim_id, "Claim", label=claim[:120], text=claim)
1221
- add_edge(edges, paper_id, claim_id, "ASSERTS")
1222
- if parsed_pdf and isinstance(parsed_pdf, dict) and parsed_pdf.get("title"):
1223
- doc_id = "upload:pdf"
1224
- add_node(nodes_by_id, doc_id, "UploadedPDF", label=parsed_pdf.get("title"), title=parsed_pdf.get("title"), parser=parsed_pdf.get("parser"))
1225
- add_edge(edges, topic_id, doc_id, "UPLOADED_SOURCE")
1226
- for concept in (parsed_pdf.get("concepts") or [])[:6]:
1227
- concept_id = f"concept:{slugify(concept)[:72]}"
1228
- add_node(nodes_by_id, concept_id, "Concept", label=concept, name=concept)
1229
- add_edge(edges, doc_id, concept_id, "MENTIONS")
1230
- for claim in (parsed_pdf.get("claims") or [])[:4]:
1231
- claim_id = f"claim:{slugify(claim)[:72]}"
1232
- add_node(nodes_by_id, claim_id, "Claim", label=claim[:120], text=claim)
1233
- add_edge(edges, doc_id, claim_id, "ASSERTS")
1234
- for idx, ref in enumerate(parsed_pdf.get("references", [])[:12], start=1):
1235
- ref_title = ref.get("title") or f"Reference {idx}"
1236
- ref_doi = normalize_doi(ref.get("doi") or "")
1237
- ref_id = ref_doi or f"ref:{idx}:{slugify(ref_title)[:32]}"
1238
- add_node(nodes_by_id, ref_id, "Reference", label=ref_title, title=ref_title, doi=ref_doi)
1239
- add_edge(edges, doc_id, ref_id, "CITES")
1240
- for idx, item in enumerate(ensure_list(frontier)[:12], start=1):
1241
- fid = normalize_doi(item.get("doi")) or f"frontier:{idx}:{slugify(item.get('title', 'paper'))[:32]}"
1242
- add_node(nodes_by_id, fid, "FrontierPaper", label=item.get("title") or f"Frontier {idx}", title=item.get("title"), frontier_score=item.get("frontier_score"), url=item.get("url"))
1243
- add_edge(edges, topic_id, fid, "FRONTIER_CANDIDATE", weight=item.get("frontier_score", item.get("learned_score", item.get("score", 0))))
1244
- return {"status": "ok", "nodes": list(nodes_by_id.values())[:GRAPH_MAX_NODES], "edges": edges[:GRAPH_MAX_EDGES]}
1245
-
1246
-
1247
- def learn_from_payload(payload: Dict, query: str = "") -> Dict:
1248
- if not payload:
1249
- return GRAPH_MEMORY
1250
- GRAPH_MEMORY["queries"].append(query or "")
1251
- GRAPH_MEMORY["events"].append({
1252
- "ts": time.time(),
1253
- "query": query or "",
1254
- "nodes": len(payload.get("nodes", [])),
1255
- "edges": len(payload.get("edges", [])),
1256
- })
1257
- GRAPH_MEMORY["payloads"].append(payload)
1258
- for node in payload.get("nodes", []):
1259
- node_id = node.get("id")
1260
- if not node_id:
1261
- continue
1262
- GRAPH_MEMORY["nodes"][node_id] = node
1263
- node_type = (node.get("type") or "").lower()
1264
- if node_type in {"paper", "frontierpaper"}:
1265
- GRAPH_MEMORY["papers"][node_id] = node
1266
- if node_type == "concept" and node.get("label"):
1267
- GRAPH_MEMORY["concept_counts"][node["label"].lower()] += 1
1268
- if node_type == "claim" and node.get("label"):
1269
- GRAPH_MEMORY["claim_counts"][node["label"].lower()] += 1
1270
- GRAPH_MEMORY["edges"].extend(payload.get("edges", []))
1271
- GRAPH_MEMORY["edges"] = GRAPH_MEMORY["edges"][:GRAPH_MAX_EDGES]
1272
- return GRAPH_MEMORY
1273
-
1274
-
1275
- def export_learning_state() -> str:
1276
- snapshot = {
1277
- "papers": list(GRAPH_MEMORY["papers"].values())[:50],
1278
- "nodes": list(GRAPH_MEMORY["nodes"].values())[:200],
1279
- "edges": GRAPH_MEMORY["edges"][:400],
1280
- "top_concepts": GRAPH_MEMORY["concept_counts"].most_common(20),
1281
- "top_claims": GRAPH_MEMORY["claim_counts"].most_common(20),
1282
- "queries": GRAPH_MEMORY["queries"][-20:],
1283
- "events": GRAPH_MEMORY["events"][-20:],
1284
- "frontier": GRAPH_MEMORY["frontier"][:20],
1285
- }
1286
- return json.dumps(snapshot, indent=2, ensure_ascii=False)
1287
-
1288
-
1289
- def resolve_selected_papers(selected_indices, papers_state):
1290
- papers = ensure_list(papers_state)
1291
- selected_indices = ensure_list(selected_indices)
1292
- selected = []
1293
- if not selected_indices:
1294
- return selected
1295
- value_map = {paper_choice_value(i, paper): paper for i, paper in enumerate(papers)}
1296
- label_map = {paper_choice_label(i, paper): paper for i, paper in enumerate(papers)}
1297
- for idx in selected_indices:
1298
- try:
1299
- if isinstance(idx, int):
1300
- if 0 <= idx < len(papers):
1301
- selected.append(papers[idx])
1302
- continue
1303
- idx_str = str(idx)
1304
- if idx_str in value_map:
1305
- selected.append(value_map[idx_str])
1306
- continue
1307
- if idx_str.isdigit():
1308
- num = int(idx_str)
1309
- if 0 <= num < len(papers):
1310
- selected.append(papers[num])
1311
- continue
1312
- if "|" in idx_str:
1313
- left = idx_str.split("|", 1)[0]
1314
- if left.isdigit():
1315
- num = int(left)
1316
- if 0 <= num < len(papers):
1317
- selected.append(papers[num])
1318
- continue
1319
- if idx_str in label_map:
1320
- selected.append(label_map[idx_str])
1321
- continue
1322
- except Exception:
1323
- continue
1324
- out = []
1325
- seen = set()
1326
- for paper in selected:
1327
- key = paper_identity_key(paper)
1328
- if key not in seen:
1329
- seen.add(key)
1330
- out.append(paper)
1331
- return out
1332
-
1333
-
1334
- def summarize_learning_state(query_text, papers, selected_sources):
1335
- concept_pool = []
1336
- for paper in papers[:8]:
1337
- concept_pool.extend((paper.get("concepts") or [])[:3])
1338
- top_concepts = [c for c, _ in Counter([c.lower() for c in concept_pool]).most_common(6)]
1339
- return (
1340
- "### Discovery results\n\n"
1341
- f"- Query: {query_text}\n"
1342
- f"- Sources: {', '.join(selected_sources)}\n"
1343
- f"- Candidates found: {len(papers)}\n"
1344
- f"- Top learned concepts: {', '.join(top_concepts) if top_concepts else 'None'}\n"
1345
- "- Select papers below, then click **Ingest selected into graph**.\n"
1346
- )
1347
-
1348
-
1349
- def run_paper_discovery(query, search_mode, sources, pdf_file):
1350
- query_text = norm_text(query or "")
1351
- selected_sources = ensure_list(sources) or DEFAULT_SOURCES
1352
- if not query_text and not pdf_file:
1353
- empty_graph = build_learning_graph_html([], [], "Self-Learning Knowledge Graph")
1354
- return (
1355
- empty_graph,
1356
- '<div class="panel papers-panel" style="padding:18px"><p>Enter a topic, title, DOI, link, or upload a PDF to start learning.</p></div>',
1357
- build_journal_html("biomaterials cardiac repair"),
1358
- "No PDF uploaded yet.",
1359
- gr.update(choices=[], value=[]),
1360
- [],
1361
- "### No discovery results yet.",
1362
- )
1363
- if not query_text and pdf_file:
1364
- uploaded_name = Path(getattr(pdf_file, "name", str(pdf_file))).name
1365
- graph_nodes, graph_edges = build_learning_graph_state("", [], uploaded_name)
1366
- return (
1367
- build_learning_graph_html(graph_nodes, graph_edges, "Uploaded PDF Waiting for Parse"),
1368
- '<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>',
1369
- build_journal_html("biomaterials cardiac repair"),
1370
- uploaded_pdf_summary(pdf_file),
1371
- gr.update(choices=[], value=[]),
1372
- [],
1373
- "### Upload detected.\n\n- Parse the PDF to extract structure.\n- Or enter a topic to start discovery.",
1374
- )
1375
- uploaded_name = Path(getattr(pdf_file, "name", str(pdf_file))).name if pdf_file else None
1376
- try:
1377
- papers = discover_papers(query_text, search_mode, selected_sources, max_results=GRAPH_MAX_RESULTS)
1378
- graph_nodes, graph_edges = build_learning_graph_state(query_text, papers[:6], uploaded_name)
1379
- graph_html = build_learning_graph_html(graph_nodes, graph_edges, "Self-Learning Knowledge Graph")
1380
- papers_html = format_papers_html(papers)
1381
- journals_html = build_journal_html(query_text or "biomaterials cardiac repair")
1382
- pdf_summary = uploaded_pdf_summary(pdf_file)
1383
- choices = format_selection_choices(papers)
1384
- status_md = summarize_learning_state(query_text, papers, selected_sources)
1385
- return (
1386
- graph_html,
1387
- papers_html,
1388
- journals_html,
1389
- pdf_summary,
1390
- gr.update(choices=choices, value=[]),
1391
- papers,
1392
- status_md,
1393
- )
1394
- except Exception as e:
1395
- graph_nodes, graph_edges = build_learning_graph_state(query_text, [], uploaded_name)
1396
- error_html = f'<div class="panel papers-panel" style="padding:18px"><p>Paper search failed: {safe_text(str(e))}</p></div>'
1397
- return (
1398
- build_learning_graph_html(graph_nodes, graph_edges),
1399
- error_html,
1400
- build_journal_html(query_text or "biomaterials cardiac repair"),
1401
- uploaded_pdf_summary(pdf_file),
1402
- gr.update(choices=[], value=[]),
1403
- [],
1404
- f"### Discovery failed.\n\n- Error: {safe_text(str(e))}",
1405
- )
1406
-
1407
-
1408
- def ingest_selected_papers(query, selected_indices, papers_state, pdf_file, parsed_state):
1409
- papers = ensure_list(papers_state)
1410
- selected = resolve_selected_papers(selected_indices, papers)
1411
- uploaded_name = Path(getattr(pdf_file, "name", str(pdf_file))).name if pdf_file else None
1412
- if not selected and parsed_state and isinstance(parsed_state, dict) and parsed_state.get("title") and papers:
1413
- selected = papers[:3]
1414
- if not selected and not (parsed_state and isinstance(parsed_state, dict) and parsed_state.get("title")):
1415
- graph_html = build_learning_graph_html([], [], "Self-Learning Knowledge Graph")
1416
- return (
1417
- graph_html,
1418
- "### Graph ingest status\n\nSelect papers or parse an uploaded PDF first.",
1419
- {"status": "empty", "nodes": [], "edges": []},
1420
- )
1421
- query_text = norm_text(query or "")
1422
- if not query_text and isinstance(parsed_state, dict):
1423
- query_text = parsed_state.get("title") or "Research topic"
1424
- if not query_text:
1425
- query_text = "Research topic"
1426
- selected = [enrich_paper_semantics(query_text, paper) for paper in selected]
1427
- frontier = frontier_expand(query_text, DEFAULT_SOURCES, selected, parsed_state=parsed_state if isinstance(parsed_state, dict) else None, per_query=3)
1428
- graph_nodes, graph_edges = graph_from_selected(query_text, selected, uploaded_name, parsed_state if isinstance(parsed_state, dict) else None, frontier=frontier)
1429
- graph_html = build_learning_graph_html(graph_nodes, graph_edges, "Selected Research Graph")
1430
- payload = build_ingest_payload(query_text, selected, parsed_state if isinstance(parsed_state, dict) else None, frontier=frontier)
1431
- learn_from_payload(payload, query=query_text)
1432
- top_concepts = []
1433
- for paper in selected:
1434
- top_concepts.extend((paper.get("concepts") or [])[:3])
1435
- if isinstance(parsed_state, dict):
1436
- top_concepts.extend((parsed_state.get("concepts") or [])[:3])
1437
- summary_lines = [
1438
- "### Graph ingest status",
1439
- "",
1440
- f"- Topic: {query_text}",
1441
- f"- Selected papers: {len(selected)}",
1442
- f"- Uploaded PDF parsed: {'Yes' if parsed_state and isinstance(parsed_state, dict) and parsed_state.get('title') else 'No'}",
1443
- f"- Frontier candidates proposed: {len(frontier)}",
1444
- f"- Nodes created: {len(payload['nodes'])}",
1445
- f"- Edges created: {len(payload['edges'])}",
1446
- f"- Learned concepts: {', '.join(unique_keep_order(top_concepts)[:8]) if top_concepts else 'None'}",
1447
- f"- Memory papers stored: {len(GRAPH_MEMORY['papers'])}",
1448
- f"- Memory concepts stored: {len(GRAPH_MEMORY['concept_counts'])}",
1449
- ]
1450
- return graph_html, "\n".join(summary_lines), payload
1451
-
1452
-
1453
- def autonomous_expand_into_markdown(query, payload, parsed_state=None):
1454
- frontier = GRAPH_MEMORY.get("frontier") or []
1455
- lines = [
1456
- "### Autonomous expansion plan",
1457
- "",
1458
- f"- Seed query: {query or 'Research topic'}",
1459
- f"- Current nodes: {len(payload.get('nodes', [])) if isinstance(payload, dict) else 0}",
1460
- f"- Current edges: {len(payload.get('edges', [])) if isinstance(payload, dict) else 0}",
1461
- f"- Frontier candidates: {len(frontier)}",
1462
- ]
1463
- proposed = propose_expansion_queries(query or "", list(GRAPH_MEMORY.get("papers", {}).values())[:8], parsed_state=parsed_state, limit=GRAPH_MAX_EXPANSIONS)
1464
- if proposed:
1465
- lines.extend(["", "#### Proposed next queries", ""])
1466
- lines.extend([f"- {q}" for q in proposed])
1467
- if frontier:
1468
- lines.extend(["", "#### Top frontier papers", ""])
1469
- for item in frontier[:8]:
1470
- lines.append(f"- {item.get('title', 'Untitled')} ({item.get('source', 'unknown')}) — frontier score {item.get('frontier_score', item.get('learned_score', item.get('score', 0)))}")
1471
- return "\n".join(lines)
1472
-
1473
-
1474
- __all__ = [
1475
- "SEARCH_MODES",
1476
- "SOURCE_OPTIONS",
1477
- "DEFAULT_SOURCES",
1478
- "GRAPH_MEMORY",
1479
- "discover_papers",
1480
- "run_paper_discovery",
1481
- "parse_uploaded_pdf",
1482
- "render_parse_result",
1483
- "ingest_selected_papers",
1484
- "build_ingest_payload",
1485
- "learn_from_payload",
1486
- "frontier_expand",
1487
- "autonomous_expand_into_markdown",
1488
- "export_learning_state",
1489
- "format_frontier_html",
1490
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dvnc_ai_v2_hf/deprecated/self_learning_graph_old6.py DELETED
@@ -1,1490 +0,0 @@
1
- import html
2
- import json
3
- import os
4
- import re
5
- import time
6
- import urllib.parse
7
- import xml.etree.ElementTree as ET
8
- from collections import Counter
9
- from pathlib import Path
10
- from typing import Any, Dict, List, Optional, Tuple
11
-
12
- import gradio as gr
13
- import requests
14
-
15
- try:
16
- import fitz # PyMuPDF
17
- except Exception:
18
- fitz = None
19
-
20
- try:
21
- from bs4 import BeautifulSoup
22
- except Exception:
23
- BeautifulSoup = None
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
- OPENALEX_EMAIL = os.getenv("OPENALEX_EMAIL", "").strip()
40
- CROSSREF_MAILTO = os.getenv("CROSSREF_MAILTO", "").strip()
41
- REQUEST_TIMEOUT = int(os.getenv("REQUEST_TIMEOUT", "25"))
42
- GRAPH_MAX_CONCEPTS = int(os.getenv("GRAPH_MAX_CONCEPTS", "12"))
43
- GRAPH_MAX_CLAIMS = int(os.getenv("GRAPH_MAX_CLAIMS", "8"))
44
- GRAPH_MAX_RESULTS = int(os.getenv("GRAPH_MAX_RESULTS", "12"))
45
- GRAPH_MAX_EXPANSIONS = int(os.getenv("GRAPH_MAX_EXPANSIONS", "6"))
46
- GRAPH_MAX_NODES = int(os.getenv("GRAPH_MAX_NODES", "400"))
47
- GRAPH_MAX_EDGES = int(os.getenv("GRAPH_MAX_EDGES", "1200"))
48
- MAX_ABSTRACT_CHARS = int(os.getenv("MAX_ABSTRACT_CHARS", "4000"))
49
- MAX_RAW_TEXT_CHARS = int(os.getenv("MAX_RAW_TEXT_CHARS", "70000"))
50
-
51
- STOPWORDS = {
52
- "a", "an", "and", "are", "as", "at", "be", "been", "being", "by", "can", "could", "did", "do", "does",
53
- "for", "from", "had", "has", "have", "if", "in", "into", "is", "it", "its", "may", "might", "of", "on",
54
- "or", "our", "such", "that", "the", "their", "there", "these", "this", "those", "to", "using", "use",
55
- "used", "via", "was", "were", "will", "with", "within", "without", "we", "they", "you", "your", "study",
56
- "paper", "research", "results", "method", "methods", "analysis", "approach", "toward", "towards",
57
- "based", "new", "novel", "effect", "effects", "model", "models", "system", "systems", "show", "shows",
58
- "introduction", "conclusion", "discussion", "figure", "table", "supplementary", "material", "materials",
59
- }
60
-
61
- GRAPH_MEMORY = {
62
- "papers": {},
63
- "nodes": {},
64
- "edges": [],
65
- "concept_counts": Counter(),
66
- "claim_counts": Counter(),
67
- "queries": [],
68
- "events": [],
69
- "frontier": [],
70
- "payloads": [],
71
- }
72
-
73
-
74
- class ScholarlyClient:
75
- def __init__(self):
76
- self.session = requests.Session()
77
- ua = "dvnc-ai-self-learning-graph/1.0"
78
- if CROSSREF_MAILTO:
79
- ua += f" (mailto:{CROSSREF_MAILTO})"
80
- self.session.headers.update({"User-Agent": ua, "Accept": "application/json, text/xml, */*"})
81
- self.session_timeout = REQUEST_TIMEOUT
82
-
83
- def get(self, url: str, **kwargs):
84
- timeout = kwargs.pop("timeout", self.session_timeout)
85
- return self.session.get(url, timeout=timeout, **kwargs)
86
-
87
- def post(self, url: str, **kwargs):
88
- timeout = kwargs.pop("timeout", max(self.session_timeout, 120))
89
- return self.session.post(url, timeout=timeout, **kwargs)
90
-
91
-
92
- HTTP = ScholarlyClient()
93
-
94
-
95
- def safe_text(x, default=""):
96
- return html.escape(str(x if x is not None else default))
97
-
98
-
99
- def norm_text(x: Optional[str]) -> str:
100
- return re.sub(r"\s+", " ", (x or "")).strip()
101
-
102
-
103
- def slugify(text: str) -> str:
104
- return re.sub(r"[^a-z0-9]+", "-", (text or "").lower()).strip("-")
105
-
106
-
107
- def ensure_list(x):
108
- return x if isinstance(x, list) else []
109
-
110
-
111
- def truncate_text(text: str, limit: int) -> str:
112
- text = norm_text(text)
113
- return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
114
-
115
-
116
- def normalize_doi(text: str) -> str:
117
- text = (text or "").strip()
118
- text = re.sub(r"^https?://(dx\.)?doi\.org/", "", text, flags=re.I)
119
- return text.strip().rstrip("/")
120
-
121
-
122
- def detect_query_type(query: str) -> str:
123
- q = (query or "").strip()
124
- doi_pattern = r"^10\.\d{4,9}/[-._;()/:A-Z0-9]+$"
125
- if re.match(doi_pattern, q, flags=re.I):
126
- return "doi"
127
- if q.startswith("http://") or q.startswith("https://"):
128
- return "link"
129
- return "topic"
130
-
131
-
132
- def tokenize(text: str) -> List[str]:
133
- return [t for t in re.findall(r"[a-zA-Z][a-zA-Z0-9\-]{2,}", (text or "").lower()) if t not in STOPWORDS]
134
-
135
-
136
- def unique_keep_order(items: List[str]) -> List[str]:
137
- seen = set()
138
- out = []
139
- for item in items:
140
- key = norm_text(item).lower()
141
- if key and key not in seen:
142
- seen.add(key)
143
- out.append(norm_text(item))
144
- return out
145
-
146
-
147
- def text_overlap_score(a: str, b: str) -> float:
148
- sa = set(tokenize(a))
149
- sb = set(tokenize(b))
150
- if not sa or not sb:
151
- return 0.0
152
- return len(sa & sb) / max(1, len(sa | sb))
153
-
154
-
155
- def compute_recency_bonus(year: str) -> float:
156
- try:
157
- y = int(str(year)[:4])
158
- except Exception:
159
- return 0.0
160
- current = time.gmtime().tm_year
161
- age = max(current - y, 0)
162
- return max(0.0, 0.14 - age * 0.015)
163
-
164
-
165
- def extract_candidate_phrases(text: str, max_terms: int = 20) -> List[str]:
166
- text = norm_text(text)
167
- if not text:
168
- return []
169
- tokens = re.findall(r"[A-Za-z][A-Za-z0-9\-]{2,}", text)
170
- phrases = []
171
- for n in (3, 2, 1):
172
- for i in range(len(tokens) - n + 1):
173
- phrase = " ".join(tokens[i:i + n]).strip().lower()
174
- if len(phrase) < 4:
175
- continue
176
- parts = phrase.split()
177
- if any(p in STOPWORDS for p in parts):
178
- continue
179
- if all(len(p) <= 2 for p in parts):
180
- continue
181
- phrases.append(phrase)
182
- counts = Counter(phrases)
183
- ranked = [p for p, _ in counts.most_common(max_terms * 4)]
184
- filtered = []
185
- for phrase in ranked:
186
- if phrase in filtered:
187
- continue
188
- if any(phrase != other and phrase in other for other in filtered):
189
- continue
190
- filtered.append(phrase)
191
- if len(filtered) >= max_terms:
192
- break
193
- return filtered
194
-
195
-
196
- def extract_concepts_from_text(text: str, max_terms: int = GRAPH_MAX_CONCEPTS) -> List[str]:
197
- return extract_candidate_phrases(text, max_terms=max_terms)
198
-
199
-
200
- def extract_claim_like_sentences(text: str, max_items: int = GRAPH_MAX_CLAIMS) -> List[str]:
201
- text = norm_text(text)
202
- if not text:
203
- return []
204
- parts = re.split(r"(?<=[\.\!\?])\s+", text)
205
- scored = []
206
- for sentence in parts:
207
- s = norm_text(sentence)
208
- if len(s) < 40 or len(s) > 320:
209
- continue
210
- lower = s.lower()
211
- score = 0.0
212
- if any(k in lower for k in ["improves", "reduces", "increases", "suggests", "demonstrates", "shows", "reveals", "predicts", "achieves", "outperforms", "enables", "supports"]):
213
- score += 2.0
214
- if any(k in lower for k in ["significant", "associated", "correlated", "effective", "robust", "accurate", "validated", "statistically"]):
215
- score += 1.0
216
- if any(k in lower for k in ["compared", "versus", "baseline", "state-of-the-art", "sota"]):
217
- score += 1.0
218
- score += min(len(tokenize(s)) / 15.0, 2.0)
219
- scored.append((score, s))
220
- return [s for _, s in sorted(scored, key=lambda x: x[0], reverse=True)[:max_items]]
221
-
222
-
223
- def parse_openalex_abstract(inverted_index) -> str:
224
- if not inverted_index or not isinstance(inverted_index, dict):
225
- return ""
226
- pos_to_word = {}
227
- for word, positions in inverted_index.items():
228
- for pos in positions:
229
- pos_to_word[pos] = word
230
- if not pos_to_word:
231
- return ""
232
- return " ".join(pos_to_word[i] for i in sorted(pos_to_word))
233
-
234
-
235
- def score_frontier_candidate(query: str, seed_concepts: List[str], paper: Dict) -> Dict:
236
- title = paper.get("title", "")
237
- abstract = paper.get("abstract", "") or paper.get("summary", "")
238
- venue = paper.get("venue", "")
239
- base_text = " ".join([title, abstract, venue])
240
- rel = text_overlap_score(query, base_text)
241
- concept_overlap = 0.0
242
- if seed_concepts:
243
- concept_overlap = text_overlap_score(" ".join(seed_concepts), " ".join(paper.get("concepts") or []))
244
- recency = compute_recency_bonus(paper.get("year"))
245
- doi_bonus = 0.02 if paper.get("doi") else 0.0
246
- oa_bonus = 0.03 if paper.get("open_access") else 0.0
247
- score = float(paper.get("score", 0)) + rel * 0.45 + concept_overlap * 0.2 + recency + doi_bonus + oa_bonus
248
- paper["frontier_score"] = round(score, 4)
249
- paper["frontier_relevance"] = round(rel, 4)
250
- paper["frontier_concept_overlap"] = round(concept_overlap, 4)
251
- return paper
252
-
253
-
254
- def enrich_paper_semantics(query: str, paper: Dict) -> Dict:
255
- paper = dict(paper)
256
- title = paper.get("title", "")
257
- abstract = paper.get("abstract", "") or paper.get("summary", "")
258
- venue = paper.get("venue", "")
259
- base_text = " ".join([title, abstract, venue]).strip()
260
- concepts = extract_concepts_from_text(base_text, max_terms=GRAPH_MAX_CONCEPTS)
261
- claims = extract_claim_like_sentences(abstract, max_items=GRAPH_MAX_CLAIMS)
262
- rel = text_overlap_score(query, f"{title} {abstract}")
263
- recency = compute_recency_bonus(paper.get("year"))
264
- doi_bonus = 0.02 if paper.get("doi") else 0.0
265
- oa_bonus = 0.03 if paper.get("open_access") else 0.0
266
- concept_bonus = min(len(concepts), 8) * 0.01
267
- learned_score = float(paper.get("score", 0)) + rel * 0.5 + recency + doi_bonus + oa_bonus + concept_bonus
268
- paper["concepts"] = concepts[:GRAPH_MAX_CONCEPTS]
269
- paper["claims"] = claims[:GRAPH_MAX_CLAIMS]
270
- paper["relevance"] = round(rel, 4)
271
- paper["learned_score"] = round(learned_score, 4)
272
- return paper
273
-
274
-
275
- def paper_identity_key(paper: Dict) -> str:
276
- return (
277
- normalize_doi(paper.get("doi") or "")
278
- or (paper.get("external_ids") or {}).get("arxiv")
279
- or (paper.get("external_ids") or {}).get("pmcid")
280
- or norm_text(paper.get("title", "")).lower()
281
- or str(paper.get("id"))
282
- )
283
-
284
-
285
- def journal_query_links(query: str):
286
- q = urllib.parse.quote_plus(query or "biomaterials cardiac repair")
287
- rows = []
288
- for journal in JOURNALS:
289
- url = f"{journal['url']}?q={q}" if "?" not in journal["url"] else f"{journal['url']}&q={q}"
290
- if "ieeexplore" in journal["url"]:
291
- url = f"https://ieeexplore.ieee.org/search/searchresult.jsp?queryText={q}"
292
- rows.append({"name": journal["name"], "desc": journal["desc"], "url": url})
293
- return rows
294
-
295
-
296
- def build_journal_html(query):
297
- rows = []
298
- for journal in journal_query_links(query):
299
- rows.append(
300
- f"""
301
- <a class="journal-card" href="{safe_text(journal['url'])}" target="_blank" rel="noopener noreferrer">
302
- <div>
303
- <h4>{safe_text(journal['name'])}</h4>
304
- <p>{safe_text(journal['desc'])}</p>
305
- </div>
306
- <span>Open</span>
307
- </a>
308
- """
309
- )
310
- return '<div class="journal-grid">' + ''.join(rows) + '</div>'
311
-
312
-
313
- def search_arxiv(query, max_results=8):
314
- encoded = urllib.parse.quote(query)
315
- url = (
316
- "http://export.arxiv.org/api/query?search_query=all:"
317
- f"{encoded}&start=0&max_results={max_results}&sortBy=relevance&sortOrder=descending"
318
- )
319
- response = HTTP.get(url)
320
- response.raise_for_status()
321
- root = ET.fromstring(response.text)
322
- ns = {"atom": "http://www.w3.org/2005/Atom"}
323
- papers = []
324
- for entry in root.findall("atom:entry", ns):
325
- title = " ".join((entry.findtext("atom:title", default="", namespaces=ns) or "").split())
326
- summary = truncate_text(" ".join((entry.findtext("atom:summary", default="", namespaces=ns) or "").split()), MAX_ABSTRACT_CHARS)
327
- published = entry.findtext("atom:published", default="", namespaces=ns)
328
- paper_id = entry.findtext("atom:id", default="", namespaces=ns)
329
- authors = [a.findtext("atom:name", default="", namespaces=ns) for a in entry.findall("atom:author", ns)]
330
- pdf_url = ""
331
- for link in entry.findall("atom:link", ns):
332
- if link.attrib.get("title") == "pdf":
333
- pdf_url = link.attrib.get("href", "")
334
- break
335
- papers.append({
336
- "id": paper_id or title,
337
- "title": title,
338
- "summary": summary,
339
- "abstract": summary,
340
- "published": published[:10],
341
- "authors": [a for a in authors[:8] if a],
342
- "authors_text": ", ".join([a for a in authors[:4] if a]) or "Unknown authors",
343
- "url": paper_id,
344
- "pdf": pdf_url,
345
- "doi": "",
346
- "venue": "arXiv",
347
- "year": published[:4] if published else "",
348
- "source": "arxiv",
349
- "score": 0.76,
350
- "open_access": True,
351
- "external_ids": {"arxiv": (paper_id or "").split("/")[-1]},
352
- })
353
- return papers
354
-
355
-
356
- def search_crossref(query, mode="topic", max_results=8):
357
- params = {}
358
- if CROSSREF_MAILTO:
359
- params["mailto"] = CROSSREF_MAILTO
360
- if mode == "doi":
361
- url = f"https://api.crossref.org/works/{urllib.parse.quote(query)}"
362
- response = HTTP.get(url, params=params)
363
- if response.status_code != 200:
364
- return []
365
- items = [response.json().get("message", {})]
366
- else:
367
- params["rows"] = max_results
368
- if mode in ("title", "paper_name"):
369
- params["query.title"] = query
370
- else:
371
- params["query.bibliographic"] = query
372
- response = HTTP.get("https://api.crossref.org/works", params=params)
373
- response.raise_for_status()
374
- items = response.json().get("message", {}).get("items", [])
375
- out = []
376
- for item in items:
377
- authors = []
378
- for a in item.get("author", []) or []:
379
- name = " ".join(filter(None, [a.get("given"), a.get("family")])).strip()
380
- if name:
381
- authors.append(name)
382
- title = (item.get("title") or ["Untitled"])[0]
383
- year = ""
384
- for key in ["published-print", "published-online", "created"]:
385
- if item.get(key, {}).get("date-parts"):
386
- year = str(item[key]["date-parts"][0][0])
387
- break
388
- abstract = truncate_text(re.sub("<.*?>", "", item.get("abstract") or ""), MAX_ABSTRACT_CHARS)
389
- doi = normalize_doi(item.get("DOI", ""))
390
- out.append({
391
- "id": doi or title,
392
- "title": norm_text(title),
393
- "summary": abstract[:500],
394
- "abstract": abstract,
395
- "published": year,
396
- "authors": authors,
397
- "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
398
- "url": item.get("URL", ""),
399
- "pdf": "",
400
- "doi": doi,
401
- "venue": (item.get("container-title") or [""])[0],
402
- "year": year,
403
- "source": "crossref",
404
- "score": 0.72,
405
- "open_access": None,
406
- "external_ids": {"crossref": doi} if doi else {},
407
- })
408
- return out
409
-
410
-
411
- def search_openalex(query, mode="topic", max_results=8):
412
- params = {"per-page": max_results}
413
- if OPENALEX_EMAIL:
414
- params["mailto"] = OPENALEX_EMAIL
415
- if mode == "doi":
416
- doi = normalize_doi(query)
417
- params["filter"] = f"doi:https://doi.org/{doi}"
418
- else:
419
- params["search"] = query
420
- response = HTTP.get("https://api.openalex.org/works", params=params)
421
- response.raise_for_status()
422
- items = response.json().get("results", [])
423
- out = []
424
- for item in items:
425
- authors = []
426
- for auth in item.get("authorships", [])[:8]:
427
- author = auth.get("author") or {}
428
- if author.get("display_name"):
429
- authors.append(author["display_name"])
430
- oa = item.get("open_access") or {}
431
- doi = normalize_doi(item.get("doi") or "")
432
- abstract = truncate_text(parse_openalex_abstract(item.get("abstract_inverted_index")), MAX_ABSTRACT_CHARS)
433
- out.append({
434
- "id": item.get("id") or doi or item.get("title"),
435
- "title": norm_text(item.get("title")),
436
- "summary": abstract[:500],
437
- "abstract": abstract,
438
- "published": str(item.get("publication_year") or ""),
439
- "authors": authors,
440
- "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
441
- "url": (item.get("primary_location") or {}).get("landing_page_url") or item.get("id") or "",
442
- "pdf": oa.get("oa_url") or "",
443
- "doi": doi,
444
- "venue": ((item.get("primary_location") or {}).get("source") or {}).get("display_name") or "",
445
- "year": str(item.get("publication_year") or ""),
446
- "source": "openalex",
447
- "score": 0.80,
448
- "open_access": oa.get("is_oa"),
449
- "external_ids": item.get("ids") or {},
450
- })
451
- return out
452
-
453
-
454
- def search_semantic_scholar(query, mode="topic", max_results=8):
455
- headers = {}
456
- if SEMANTIC_SCHOLAR_API_KEY:
457
- headers["x-api-key"] = SEMANTIC_SCHOLAR_API_KEY
458
- fields = "title,authors,year,abstract,venue,externalIds,url,openAccessPdf"
459
- if mode == "doi":
460
- doi = normalize_doi(query)
461
- url = f"https://api.semanticscholar.org/graph/v1/paper/DOI:{urllib.parse.quote(doi)}"
462
- response = HTTP.get(url, params={"fields": fields}, headers=headers)
463
- if response.status_code != 200:
464
- return []
465
- items = [response.json()]
466
- else:
467
- response = HTTP.get(
468
- "https://api.semanticscholar.org/graph/v1/paper/search",
469
- params={"query": query, "limit": max_results, "fields": fields},
470
- headers=headers,
471
- )
472
- if response.status_code != 200:
473
- return []
474
- items = response.json().get("data", [])
475
- out = []
476
- for item in items:
477
- external = item.get("externalIds") or {}
478
- authors = [a.get("name") for a in item.get("authors", []) if a.get("name")]
479
- abstract = truncate_text(norm_text(item.get("abstract", "")), MAX_ABSTRACT_CHARS)
480
- out.append({
481
- "id": external.get("CorpusId") or external.get("DOI") or item.get("title"),
482
- "title": norm_text(item.get("title")),
483
- "summary": abstract[:500],
484
- "abstract": abstract,
485
- "published": str(item.get("year") or ""),
486
- "authors": authors,
487
- "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
488
- "url": item.get("url") or "",
489
- "pdf": (item.get("openAccessPdf") or {}).get("url") or "",
490
- "doi": normalize_doi(external.get("DOI", "")),
491
- "venue": item.get("venue") or "",
492
- "year": str(item.get("year") or ""),
493
- "source": "semantic_scholar",
494
- "score": 0.84,
495
- "open_access": bool((item.get("openAccessPdf") or {}).get("url")),
496
- "external_ids": external,
497
- })
498
- return out
499
-
500
-
501
- def search_europe_pmc(query, mode="topic", max_results=8):
502
- epmc_query = f'DOI:"{query}"' if mode == "doi" else query
503
- params = {"query": epmc_query, "format": "json", "pageSize": max_results, "resultType": "core"}
504
- response = HTTP.get("https://www.ebi.ac.uk/europepmc/webservices/rest/search", params=params)
505
- if response.status_code != 200:
506
- return []
507
- items = response.json().get("resultList", {}).get("result", [])
508
- out = []
509
- for item in items:
510
- author_string = item.get("authorString", "")
511
- authors = [x.strip() for x in author_string.split(",")[:8] if x.strip()]
512
- pmcid = item.get("pmcid", "")
513
- pdf_url = f"https://europepmc.org/articles/{pmcid}?pdf=render" if pmcid else ""
514
- landing_url = f"https://europepmc.org/article/PMC/{pmcid}" if pmcid else ""
515
- abstract = truncate_text(norm_text(item.get("abstractText", "")), MAX_ABSTRACT_CHARS)
516
- out.append({
517
- "id": item.get("id") or item.get("doi") or item.get("title"),
518
- "title": norm_text(item.get("title")),
519
- "summary": abstract[:500],
520
- "abstract": abstract,
521
- "published": str(item.get("pubYear") or ""),
522
- "authors": authors,
523
- "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
524
- "url": landing_url,
525
- "pdf": pdf_url,
526
- "doi": normalize_doi(item.get("doi", "")),
527
- "venue": item.get("journalTitle", ""),
528
- "year": str(item.get("pubYear") or ""),
529
- "source": "europe_pmc",
530
- "score": 0.78,
531
- "open_access": bool(pmcid),
532
- "external_ids": {"pmid": item.get("pmid"), "pmcid": pmcid},
533
- })
534
- return out
535
-
536
-
537
- def resolve_link(query):
538
- url = (query or "").strip()
539
- if not url:
540
- return []
541
- try:
542
- response = HTTP.get(url, allow_redirects=True, headers={"User-Agent": "dvnc-ai-space/1.0"})
543
- content_type = response.headers.get("content-type", "")
544
- if "pdf" in content_type or url.lower().endswith(".pdf"):
545
- name = Path(url.split("?")[0]).name or "linked-paper.pdf"
546
- return [{
547
- "id": url,
548
- "title": name,
549
- "summary": "Direct PDF link detected.",
550
- "abstract": "",
551
- "published": "",
552
- "authors": [],
553
- "authors_text": "Unknown authors",
554
- "url": url,
555
- "pdf": url,
556
- "doi": "",
557
- "venue": "Direct PDF",
558
- "year": "",
559
- "source": "link",
560
- "score": 0.66,
561
- "open_access": True,
562
- "external_ids": {},
563
- }]
564
- doi = ""
565
- title = url
566
- pdf_link = ""
567
- if BeautifulSoup is not None:
568
- soup = BeautifulSoup(response.text, "html.parser")
569
- title = soup.title.text.strip() if soup.title else url
570
- for meta_name in ["citation_doi", "dc.identifier", "dc.Identifier"]:
571
- tag = soup.find("meta", attrs={"name": meta_name})
572
- if tag and tag.get("content"):
573
- doi = normalize_doi(tag["content"].strip())
574
- break
575
- for a in soup.find_all("a", href=True):
576
- href = a["href"]
577
- if ".pdf" in href.lower():
578
- pdf_link = href if href.startswith("http") else urllib.parse.urljoin(url, href)
579
- break
580
- if doi:
581
- results = search_crossref(doi, mode="doi", max_results=1)
582
- if results:
583
- if pdf_link and not results[0].get("pdf"):
584
- results[0]["pdf"] = pdf_link
585
- if url and not results[0].get("url"):
586
- results[0]["url"] = url
587
- return results
588
- return [{
589
- "id": url,
590
- "title": title,
591
- "summary": "Landing page resolved from direct link.",
592
- "abstract": "",
593
- "published": "",
594
- "authors": [],
595
- "authors_text": "Unknown authors",
596
- "url": url,
597
- "pdf": pdf_link,
598
- "doi": doi,
599
- "venue": "Web Link",
600
- "year": "",
601
- "source": "link",
602
- "score": 0.54,
603
- "open_access": bool(pdf_link),
604
- "external_ids": {},
605
- }]
606
- except Exception as e:
607
- return [{
608
- "id": url,
609
- "title": "Link resolution error",
610
- "summary": str(e),
611
- "abstract": "",
612
- "published": "",
613
- "authors": [],
614
- "authors_text": "Unknown authors",
615
- "url": url,
616
- "pdf": "",
617
- "doi": "",
618
- "venue": "Link",
619
- "year": "",
620
- "source": "link",
621
- "score": 0.20,
622
- "open_access": None,
623
- "external_ids": {},
624
- }]
625
-
626
-
627
- def dedupe_papers(items: List[Dict]) -> List[Dict]:
628
- seen = {}
629
- for item in items:
630
- key = paper_identity_key(item) or f"{item.get('source', 'src')}::{item.get('title', 'paper')}"
631
- current = seen.get(key)
632
- candidate_score = float(item.get("learned_score", item.get("score", 0)))
633
- current_score = float(current.get("learned_score", current.get("score", 0))) if current else -1
634
- if current is None or candidate_score > current_score:
635
- seen[key] = item
636
- return sorted(seen.values(), key=lambda x: float(x.get("learned_score", x.get("score", 0))), reverse=True)
637
-
638
-
639
- def discover_papers(query, mode, sources, max_results=10):
640
- query = (query or "").strip()
641
- if not query:
642
- return []
643
- mode = detect_query_type(query) if mode == "autonomous_web" else mode
644
- selected_sources = ensure_list(sources) or DEFAULT_SOURCES
645
- results = []
646
- if mode == "link":
647
- return dedupe_papers(resolve_link(query))
648
- if "arxiv" in selected_sources and mode != "doi":
649
- try:
650
- results.extend(search_arxiv(query, max_results=min(max_results, GRAPH_MAX_RESULTS)))
651
- except Exception:
652
- pass
653
- if "crossref" in selected_sources:
654
- try:
655
- results.extend(search_crossref(query, mode=mode, max_results=min(max_results, GRAPH_MAX_RESULTS)))
656
- except Exception:
657
- pass
658
- if "openalex" in selected_sources:
659
- try:
660
- results.extend(search_openalex(query, mode=mode, max_results=min(max_results, GRAPH_MAX_RESULTS)))
661
- except Exception:
662
- pass
663
- if "semantic_scholar" in selected_sources:
664
- try:
665
- results.extend(search_semantic_scholar(query, mode=mode, max_results=min(max_results, GRAPH_MAX_RESULTS)))
666
- except Exception:
667
- pass
668
- if "europe_pmc" in selected_sources:
669
- try:
670
- results.extend(search_europe_pmc(query, mode=mode, max_results=min(max_results, GRAPH_MAX_RESULTS)))
671
- except Exception:
672
- pass
673
- papers = [enrich_paper_semantics(query, p) for p in dedupe_papers(results)]
674
- papers = sorted(papers, key=lambda x: float(x.get("learned_score", x.get("score", 0))), reverse=True)
675
- return papers[:max_results]
676
-
677
-
678
- def propose_expansion_queries(query: str, papers: List[Dict], parsed_state: Optional[Dict] = None, limit: int = GRAPH_MAX_EXPANSIONS) -> List[str]:
679
- concept_pool = []
680
- venue_pool = []
681
- for paper in papers[:8]:
682
- concept_pool.extend((paper.get("concepts") or [])[:4])
683
- if paper.get("venue"):
684
- venue_pool.append(paper["venue"])
685
- if parsed_state and isinstance(parsed_state, dict):
686
- concept_pool.extend((parsed_state.get("concepts") or [])[:6])
687
- ranked_concepts = [c for c, _ in Counter([norm_text(c).lower() for c in concept_pool if c]).most_common(limit * 2)]
688
- expansions = [norm_text(query)] if query else []
689
- for concept in ranked_concepts:
690
- if not concept:
691
- continue
692
- if query:
693
- expansions.append(f"{query} {concept}")
694
- else:
695
- expansions.append(concept)
696
- for venue in unique_keep_order(venue_pool)[:2]:
697
- if query and venue:
698
- expansions.append(f"{query} {venue}")
699
- return unique_keep_order(expansions)[:limit]
700
-
701
-
702
- def frontier_expand(query: str, sources: List[str], selected_papers: List[Dict], parsed_state: Optional[Dict] = None, per_query: int = 4) -> List[Dict]:
703
- seed_concepts = []
704
- for p in selected_papers[:6]:
705
- seed_concepts.extend((p.get("concepts") or [])[:4])
706
- if parsed_state and isinstance(parsed_state, dict):
707
- seed_concepts.extend((parsed_state.get("concepts") or [])[:6])
708
- expansion_queries = propose_expansion_queries(query, selected_papers, parsed_state=parsed_state, limit=GRAPH_MAX_EXPANSIONS)
709
- frontier = []
710
- for eq in expansion_queries:
711
- try:
712
- items = discover_papers(eq, "topic", sources, max_results=per_query)
713
- for item in items:
714
- frontier.append(score_frontier_candidate(query or eq, seed_concepts, item))
715
- except Exception:
716
- continue
717
- frontier = dedupe_papers(frontier)
718
- frontier.sort(key=lambda x: float(x.get("frontier_score", x.get("learned_score", x.get("score", 0))),), reverse=True)
719
- GRAPH_MEMORY["frontier"] = frontier[: GRAPH_MAX_EXPANSIONS * per_query]
720
- return GRAPH_MEMORY["frontier"]
721
-
722
-
723
- def paper_choice_value(index: int, paper: Dict) -> str:
724
- doi = normalize_doi(paper.get("doi") or "")
725
- title_slug = slugify(paper.get("title", ""))[:40]
726
- return f"{index}|{doi}|{title_slug}"
727
-
728
-
729
- def paper_choice_label(index: int, paper: Dict) -> str:
730
- score = round(float(paper.get("learned_score", paper.get("score", 0))), 3)
731
- title = paper.get("title", "Untitled")
732
- authors_text = paper.get("authors_text", "Unknown authors")[:80]
733
- source = paper.get("source", "src")
734
- return f"[{source}] {title} — {authors_text} — score {score}"
735
-
736
-
737
- def format_selection_choices(papers):
738
- return [(paper_choice_label(i, paper), paper_choice_value(i, paper)) for i, paper in enumerate(papers)]
739
-
740
-
741
- def format_papers_html(papers):
742
- if not papers:
743
- return '<div class="panel papers-panel" style="padding:18px"><p>No papers found yet.</p></div>'
744
- items = []
745
- for i, paper in enumerate(papers, start=1):
746
- summary = safe_text((paper.get("summary") or paper.get("abstract") or "")[:280])
747
- doi_line = f'<span class="paper-badge doi-badge">{safe_text(paper.get("doi"))}</span>' if paper.get("doi") else ""
748
- pdf_link = paper.get("pdf") or "#"
749
- abs_link = paper.get("url") or "#"
750
- concepts_text = ", ".join((paper.get("concepts") or [])[:4])
751
- items.append(
752
- f"""
753
- <article class="paper-card">
754
- <div class="paper-topline">
755
- <span class="paper-badge">{safe_text(paper.get('source', 'paper'))}</span>
756
- <span class="paper-badge alt">{safe_text(paper.get('published', '') or 'Paper')}</span>
757
- {doi_line}
758
- </div>
759
- <h4>{i}. {safe_text(paper.get('title', 'Untitled'))}</h4>
760
- <p>{summary or 'No abstract snippet available.'}</p>
761
- <div class="paper-meta-stack">
762
- <div><strong>Authors:</strong> {safe_text(paper.get('authors_text', 'Unknown authors'))}</div>
763
- <div><strong>Venue:</strong> {safe_text(paper.get('venue', 'Unknown venue'))}</div>
764
- <div><strong>Learned score:</strong> {safe_text(round(float(paper.get('learned_score', paper.get('score', 0))), 3))}</div>
765
- <div><strong>Concepts:</strong> {safe_text(concepts_text or 'None extracted')}</div>
766
- </div>
767
- <div class="paper-links">
768
- <a href="{safe_text(abs_link)}" target="_blank" rel="noopener noreferrer">Abstract</a>
769
- <a href="{safe_text(pdf_link)}" target="_blank" rel="noopener noreferrer">PDF</a>
770
- </div>
771
- </article>
772
- """
773
- )
774
- return '<div class="papers-grid">' + ''.join(items) + '</div>'
775
-
776
-
777
- def format_frontier_html(frontier):
778
- if not frontier:
779
- return '<div class="panel papers-panel" style="padding:18px"><p>No autonomous expansion candidates yet.</p></div>'
780
- cards = []
781
- for i, paper in enumerate(frontier[:12], start=1):
782
- cards.append(
783
- f"""
784
- <article class="paper-card frontier-card">
785
- <div class="paper-topline">
786
- <span class="paper-badge">frontier</span>
787
- <span class="paper-badge alt">{safe_text(paper.get('source', 'paper'))}</span>
788
- </div>
789
- <h4>{i}. {safe_text(paper.get('title', 'Untitled'))}</h4>
790
- <p>{safe_text((paper.get('summary') or paper.get('abstract') or '')[:260])}</p>
791
- <div class="paper-meta-stack">
792
- <div><strong>Frontier score:</strong> {safe_text(paper.get('frontier_score', paper.get('learned_score', paper.get('score', 0))))}</div>
793
- <div><strong>Concept overlap:</strong> {safe_text(paper.get('frontier_concept_overlap', 0))}</div>
794
- <div><strong>Authors:</strong> {safe_text(paper.get('authors_text', 'Unknown authors'))}</div>
795
- </div>
796
- </article>
797
- """
798
- )
799
- return '<div class="papers-grid">' + ''.join(cards) + '</div>'
800
-
801
-
802
- def uploaded_pdf_summary(file_obj):
803
- if not file_obj:
804
- return "No PDF uploaded yet."
805
- path = getattr(file_obj, "name", None) or str(file_obj)
806
- p = Path(path)
807
- return f"Uploaded PDF ready for ingestion: {p.name}. Use Parse uploaded PDF to extract title, abstract, sections, references, concepts, and claims."
808
-
809
-
810
- def build_learning_graph_html(nodes, edges, title="Self-Learning Knowledge Graph"):
811
- if not nodes:
812
- return """
813
- <div class="panel brain-shell">
814
- <div class="brain-header">
815
- <div>
816
- <p class="eyebrow">Learning Graph</p>
817
- <h3>Self-Learning Knowledge Graph</h3>
818
- </div>
819
- </div>
820
- <div class="brain-stage learning-empty">
821
- <div class="empty-graph-copy">
822
- <h4>No papers mapped yet</h4>
823
- <p>Search papers, pick a topic, select candidates, or upload a PDF to grow the graph in real time.</p>
824
- </div>
825
- </div>
826
- </div>
827
- """
828
- coords = [
829
- (100, 90), (250, 60), (420, 75), (590, 115), (690, 250), (620, 395),
830
- (455, 455), (280, 455), (110, 395), (60, 250), (215, 250), (365, 245),
831
- (525, 250), (300, 145), (480, 340), (180, 340), (545, 175), (130, 170)
832
- ]
833
- graph_nodes = [dict(n) for n in nodes[:18]]
834
- for i, node in enumerate(graph_nodes):
835
- x, y = coords[i % len(coords)]
836
- node["sx"] = x
837
- node["sy"] = y
838
- node_map = {n["id"]: n for n in graph_nodes}
839
- edge_items, node_items, label_items = [], [], []
840
- for edge in edges[:80]:
841
- source = edge.get("source")
842
- target = edge.get("target")
843
- edge_type = edge.get("type", "")
844
- if source in node_map and target in node_map:
845
- a = node_map[source]
846
- b = node_map[target]
847
- edge_items.append(
848
- f'<line class="learn-edge edge-{safe_text(edge_type.lower())}" x1="{a["sx"]}" y1="{a["sy"]}" x2="{b["sx"]}" y2="{b["sy"]}" />'
849
- )
850
- for node in graph_nodes:
851
- kind = (node.get("kind") or node.get("type") or "paper").lower()
852
- if kind == "topic":
853
- kind = "query"
854
- if kind == "uploadedpdf":
855
- kind = "upload"
856
- radius = 25 if kind == "query" else 18 if kind in {"concept", "author", "claim", "reference"} else 20
857
- css_class = f"learn-node {kind}"
858
- node_items.append(f'<circle class="{css_class}" cx="{node["sx"]}" cy="{node["sy"]}" r="{radius}" />')
859
- label = node.get("label") or node.get("title") or node.get("id")
860
- label_items.append(f'<text class="learn-label" x="{node["sx"] + 26}" y="{node["sy"] - 8}">{safe_text(str(label)[:46])}</text>')
861
- return f"""
862
- <div class="panel brain-shell">
863
- <div class="brain-header">
864
- <div>
865
- <p class="eyebrow">Learning Graph</p>
866
- <h3>{safe_text(title)}</h3>
867
- </div>
868
- <div class="brain-legend">
869
- <span><i class="dot dot-query"></i> topic</span>
870
- <span><i class="dot dot-paper"></i> paper</span>
871
- <span><i class="dot dot-upload"></i> uploaded PDF</span>
872
- <span><i class="dot dot-concept"></i> concept</span>
873
- <span><i class="dot dot-author"></i> author</span>
874
- <span><i class="dot dot-ref"></i> reference</span>
875
- </div>
876
- </div>
877
- <div class="brain-stage">
878
- <svg viewBox="0 0 760 520" class="brain-svg" role="img" aria-label="Self-learning knowledge graph">
879
- {''.join(edge_items)}
880
- {''.join(node_items)}
881
- {''.join(label_items)}
882
- </svg>
883
- </div>
884
- </div>
885
- """
886
-
887
-
888
- def build_learning_graph_state(query, papers, uploaded_name=None):
889
- nodes = [{"id": "query", "label": query or "Research topic", "kind": "query"}]
890
- edges = []
891
- for i, paper in enumerate(papers[:5], start=1):
892
- pid = f"paper_{i}"
893
- nodes.append({"id": pid, "label": paper.get("title", f"Paper {i}"), "kind": "paper"})
894
- edges.append({"source": "query", "target": pid, "type": "ABOUT"})
895
- for concept in (paper.get("concepts") or [])[:2]:
896
- cid = f"concept_{i}_{slugify(concept)[:20]}"
897
- nodes.append({"id": cid, "label": concept, "kind": "concept"})
898
- edges.append({"source": pid, "target": cid, "type": "MENTIONS"})
899
- if uploaded_name:
900
- nodes.append({"id": "upload", "label": uploaded_name, "kind": "upload"})
901
- edges.append({"source": "query", "target": "upload", "type": "UPLOADED_SOURCE"})
902
- return nodes, edges
903
-
904
-
905
- def graph_from_selected(query, selected_papers, uploaded_name=None, parsed_state=None, frontier=None):
906
- nodes = [{"id": "query", "label": query or "Research topic", "kind": "query"}]
907
- edges = []
908
- for i, paper in enumerate(selected_papers[:6], start=1):
909
- pid = f"paper_{i}"
910
- nodes.append({"id": pid, "label": paper.get("title", f"Paper {i}"), "kind": "paper"})
911
- edges.append({"source": "query", "target": pid, "type": "ABOUT"})
912
- for author in paper.get("authors", [])[:2]:
913
- aid = f"author_{i}_{slugify(author)[:24]}"
914
- nodes.append({"id": aid, "label": author, "kind": "author"})
915
- edges.append({"source": pid, "target": aid, "type": "WRITTEN_BY"})
916
- for concept in (paper.get("concepts") or [])[:2]:
917
- cid = f"concept_{i}_{slugify(concept)[:24]}"
918
- nodes.append({"id": cid, "label": concept, "kind": "concept"})
919
- edges.append({"source": pid, "target": cid, "type": "MENTIONS"})
920
- for claim in (paper.get("claims") or [])[:1]:
921
- cid = f"claim_{i}_{slugify(claim)[:24]}"
922
- nodes.append({"id": cid, "label": claim[:42], "kind": "claim"})
923
- edges.append({"source": pid, "target": cid, "type": "ASSERTS"})
924
- if uploaded_name:
925
- nodes.append({"id": "upload", "label": uploaded_name, "kind": "upload"})
926
- edges.append({"source": "query", "target": "upload", "type": "UPLOADED_SOURCE"})
927
- if parsed_state and isinstance(parsed_state, dict):
928
- for concept in (parsed_state.get("concepts") or [])[:3]:
929
- cid = f"upload_concept_{slugify(concept)[:24]}"
930
- nodes.append({"id": cid, "label": concept, "kind": "concept"})
931
- edges.append({"source": "upload", "target": cid, "type": "MENTIONS"})
932
- for j, fp in enumerate(ensure_list(frontier)[:3], start=1):
933
- fid = f"frontier_{j}"
934
- nodes.append({"id": fid, "label": fp.get("title", f"Frontier {j}"), "kind": "reference"})
935
- edges.append({"source": "query", "target": fid, "type": "FRONTIER"})
936
- return nodes, edges
937
-
938
-
939
- def parse_pdf_with_grobid(pdf_path):
940
- if not GROBID_URL:
941
- raise RuntimeError("GROBID_URL is not set")
942
- with open(pdf_path, "rb") as f:
943
- files = {"input": (Path(pdf_path).name, f, "application/pdf")}
944
- response = HTTP.post(
945
- f"{GROBID_URL.rstrip('/')}/api/processFulltextDocument",
946
- files=files,
947
- data={"includeRawAffiliations": "1", "segmentSentences": "1"},
948
- timeout=180,
949
- )
950
- response.raise_for_status()
951
- tei_xml = response.text
952
- root = ET.fromstring(tei_xml)
953
- ns = {"tei": "http://www.tei-c.org/ns/1.0"}
954
- title = root.findtext(".//tei:titleStmt/tei:title", default="", namespaces=ns) or Path(pdf_path).name
955
- abstract_parts = []
956
- for p in root.findall(".//tei:profileDesc/tei:abstract//tei:p", ns):
957
- abstract_parts.append(" ".join(list(p.itertext())))
958
- abstract = truncate_text(" ".join(abstract_parts), MAX_ABSTRACT_CHARS)
959
- authors = []
960
- for author in root.findall(".//tei:sourceDesc//tei:author", ns):
961
- parts = []
962
- forename = author.findall(".//tei:forename", ns)
963
- surname = author.findall(".//tei:surname", ns)
964
- parts.extend([norm_text(" ".join(x.itertext())) for x in forename])
965
- parts.extend([norm_text(" ".join(x.itertext())) for x in surname])
966
- name = norm_text(" ".join(parts))
967
- if name:
968
- authors.append(name)
969
- sections = []
970
- text_pool = []
971
- for div in root.findall(".//tei:text//tei:body//tei:div", ns):
972
- head = div.findtext("./tei:head", default="", namespaces=ns)
973
- paras = []
974
- for p in div.findall(".//tei:p", ns):
975
- para_text = norm_text(" ".join(list(p.itertext())))
976
- if para_text:
977
- paras.append(para_text)
978
- joined = "\n".join(paras)
979
- if head or joined:
980
- sections.append({"heading": head or "Section", "text": truncate_text(joined, 4000)})
981
- if joined:
982
- text_pool.append(joined)
983
- references = []
984
- for bibl in root.findall(".//tei:listBibl//tei:biblStruct", ns)[:60]:
985
- ref_title = bibl.findtext(".//tei:title", default="", namespaces=ns)
986
- ref_doi = ""
987
- for idno in bibl.findall(".//tei:idno", ns):
988
- if (idno.attrib.get("type") or "").lower() == "doi":
989
- ref_doi = norm_text(" ".join(idno.itertext()))
990
- break
991
- references.append({"title": norm_text(ref_title), "doi": normalize_doi(ref_doi)})
992
- semantic_text = truncate_text(" ".join([abstract] + text_pool[:5]), MAX_RAW_TEXT_CHARS)
993
- return {
994
- "parser": "grobid",
995
- "title": norm_text(title),
996
- "abstract": abstract,
997
- "authors": authors[:12],
998
- "sections": sections[:14],
999
- "references": references[:60],
1000
- "claims": extract_claim_like_sentences(semantic_text, max_items=GRAPH_MAX_CLAIMS),
1001
- "concepts": extract_concepts_from_text(semantic_text, max_terms=GRAPH_MAX_CONCEPTS),
1002
- "raw_text": "",
1003
- "parser_quality": "scholarly-structured",
1004
- }
1005
-
1006
-
1007
- def parse_pdf_with_pymupdf(pdf_path):
1008
- if fitz is None:
1009
- raise RuntimeError("PyMuPDF not installed")
1010
- doc = fitz.open(pdf_path)
1011
- raw_text = truncate_text("\n".join(page.get_text("text") for page in doc).strip(), MAX_RAW_TEXT_CHARS)
1012
- first_page = raw_text[:4000]
1013
- lines = [x.strip() for x in first_page.splitlines() if x.strip()]
1014
- title = lines[0][:300] if lines else Path(pdf_path).name
1015
- abstract = ""
1016
- match = re.search(r"abstract\s*(.+?)(?:\n\s*\n|\n1[\.\s]|introduction)", raw_text, re.I | re.S)
1017
- if match:
1018
- abstract = truncate_text(match.group(1), 2500)
1019
- return {
1020
- "parser": "pymupdf",
1021
- "title": title,
1022
- "abstract": abstract,
1023
- "authors": [],
1024
- "sections": [{"heading": "Full Text", "text": raw_text[:12000]}] if raw_text else [],
1025
- "references": [],
1026
- "claims": extract_claim_like_sentences(raw_text, max_items=GRAPH_MAX_CLAIMS),
1027
- "concepts": extract_concepts_from_text(raw_text, max_terms=GRAPH_MAX_CONCEPTS),
1028
- "raw_text": raw_text,
1029
- "parser_quality": "text-fallback",
1030
- }
1031
-
1032
-
1033
- def parse_pdf_with_docling(pdf_path):
1034
- try:
1035
- from docling.document_converter import DocumentConverter
1036
- except Exception as e:
1037
- raise RuntimeError(f"Docling import failed: {e}")
1038
- converter = DocumentConverter()
1039
- result = converter.convert(pdf_path)
1040
- doc = result.document
1041
- markdown = truncate_text(doc.export_to_markdown(), MAX_RAW_TEXT_CHARS)
1042
- title = Path(pdf_path).name
1043
- first_nonempty = next((line.strip("# ").strip() for line in markdown.splitlines() if line.strip()), "")
1044
- if first_nonempty:
1045
- title = first_nonempty[:300]
1046
- return {
1047
- "parser": "docling",
1048
- "title": title,
1049
- "abstract": "",
1050
- "authors": [],
1051
- "sections": [{"heading": "Document", "text": markdown[:12000]}] if markdown else [],
1052
- "references": [],
1053
- "claims": extract_claim_like_sentences(markdown, max_items=GRAPH_MAX_CLAIMS),
1054
- "concepts": extract_concepts_from_text(markdown, max_terms=GRAPH_MAX_CONCEPTS),
1055
- "raw_text": markdown,
1056
- "parser_quality": "layout-aware",
1057
- }
1058
-
1059
-
1060
- def parse_uploaded_pdf(file_obj, parser_order):
1061
- if not file_obj:
1062
- return "### PDF parse status\n\nNo PDF uploaded yet.", {}
1063
- path = getattr(file_obj, "name", None) or str(file_obj)
1064
- parser_order = ensure_list(parser_order) or ["grobid", "docling", "pymupdf"]
1065
- errors = []
1066
- for parser_name in parser_order:
1067
- try:
1068
- if parser_name == "grobid":
1069
- result = parse_pdf_with_grobid(path)
1070
- elif parser_name == "docling":
1071
- result = parse_pdf_with_docling(path)
1072
- elif parser_name == "pymupdf":
1073
- result = parse_pdf_with_pymupdf(path)
1074
- else:
1075
- continue
1076
- summary = (
1077
- f"### PDF parse status\n\n"
1078
- f"- Parser used: {result['parser']}\n"
1079
- f"- Parser quality: {result.get('parser_quality', 'unknown')}\n"
1080
- f"- Title: {result.get('title') or 'Unknown'}\n"
1081
- f"- Authors: {', '.join(result.get('authors')[:6]) if result.get('authors') else 'Unknown'}\n"
1082
- f"- Abstract found: {'Yes' if result.get('abstract') else 'No'}\n"
1083
- f"- Sections extracted: {len(result.get('sections') or [])}\n"
1084
- f"- References extracted: {len(result.get('references') or [])}\n"
1085
- f"- Concepts extracted: {len(result.get('concepts') or [])}\n"
1086
- f"- Claims extracted: {len(result.get('claims') or [])}\n"
1087
- )
1088
- return summary, result
1089
- except Exception as e:
1090
- errors.append(f"{parser_name}: {e}")
1091
- fail_summary = "### PDF parse status\n\n" + "\n".join([f"- {x}" for x in errors])
1092
- return fail_summary, {"parser": None, "errors": errors}
1093
-
1094
-
1095
- def render_parse_result(parsed):
1096
- if not parsed or not isinstance(parsed, dict) or (not parsed.get("title") and not parsed.get("sections")):
1097
- return '<div class="panel" style="padding:18px"><p>No parsed document yet.</p></div>'
1098
- sections_html = []
1099
- for section in parsed.get("sections", [])[:6]:
1100
- sections_html.append(
1101
- f"""
1102
- <details class="agent-step">
1103
- <summary class="agent-summary">
1104
- <div class="agent-index">§</div>
1105
- <div class="agent-head">
1106
- <h4>{safe_text(section.get('heading', 'Section'))}</h4>
1107
- <span>section</span>
1108
- </div>
1109
- </summary>
1110
- <div class="agent-copy">
1111
- <p>{safe_text(section.get('text', '')[:1800])}</p>
1112
- </div>
1113
- </details>
1114
- """
1115
- )
1116
- refs = parsed.get("references", [])[:12]
1117
- refs_html = "".join(
1118
- f"<li>{safe_text(r.get('title') or 'Untitled')} {'· DOI ' + safe_text(r.get('doi')) if r.get('doi') else ''}</li>"
1119
- for r in refs
1120
- ) or "<li>No references extracted.</li>"
1121
- concepts = parsed.get("concepts", [])[:10]
1122
- claims = parsed.get("claims", [])[:6]
1123
- concepts_html = "".join(f"<li>{safe_text(x)}</li>" for x in concepts) or "<li>No concepts extracted.</li>"
1124
- claims_html = "".join(f"<li>{safe_text(x)}</li>" for x in claims) or "<li>No claims extracted.</li>"
1125
- title = safe_text(parsed.get("title") or "Parsed document")
1126
- abstract = safe_text((parsed.get("abstract") or "")[:2400]) or "No abstract extracted."
1127
- parser_name = safe_text(parsed.get("parser") or "unknown")
1128
- parser_quality = safe_text(parsed.get("parser_quality") or "unknown")
1129
- return f"""
1130
- <div class="panel" style="padding:18px">
1131
- <div class="brain-header">
1132
- <div>
1133
- <p class="eyebrow">PDF Parse</p>
1134
- <h3>{title}</h3>
1135
- </div>
1136
- <div class="brain-legend"><span><i class="dot dot-upload"></i> {parser_name} · {parser_quality}</span></div>
1137
- </div>
1138
- <div class="parse-grid">
1139
- <div class="parse-card">
1140
- <h4>Abstract</h4>
1141
- <p>{abstract}</p>
1142
- </div>
1143
- <div class="parse-card">
1144
- <h4>References</h4>
1145
- <ul class="ref-list">{refs_html}</ul>
1146
- </div>
1147
- <div class="parse-card">
1148
- <h4>Concepts</h4>
1149
- <ul class="ref-list">{concepts_html}</ul>
1150
- </div>
1151
- <div class="parse-card">
1152
- <h4>Claims</h4>
1153
- <ul class="ref-list">{claims_html}</ul>
1154
- </div>
1155
- </div>
1156
- <div class="timeline" style="margin-top:14px;">
1157
- {''.join(sections_html) if sections_html else '<div class="panel" style="padding:16px;"><p>No sections extracted.</p></div>'}
1158
- </div>
1159
- </div>
1160
- """
1161
-
1162
-
1163
- def add_node(nodes_by_id: Dict[str, Dict], node_id: str, node_type: str, label: str = "", **attrs):
1164
- if not node_id:
1165
- return
1166
- current = nodes_by_id.get(node_id, {})
1167
- merged = {"id": node_id, "type": node_type, "label": label or current.get("label", node_id)}
1168
- merged.update(current)
1169
- for key, value in attrs.items():
1170
- if value not in [None, ""]:
1171
- merged[key] = value
1172
- nodes_by_id[node_id] = merged
1173
-
1174
-
1175
- def add_edge(edges: List[Dict], source: str, target: str, edge_type: str, **attrs):
1176
- if not source or not target or source == target:
1177
- return
1178
- edge = {"source": source, "target": target, "type": edge_type}
1179
- for key, value in attrs.items():
1180
- if value not in [None, ""]:
1181
- edge[key] = value
1182
- edges.append(edge)
1183
-
1184
-
1185
- def build_ingest_payload(query, selected_papers, parsed_pdf=None, frontier=None):
1186
- nodes_by_id = {}
1187
- edges = []
1188
- topic_id = "topic:query"
1189
- add_node(nodes_by_id, topic_id, "Topic", label=query or "Research topic", query=query or "")
1190
- for i, paper in enumerate(selected_papers, start=1):
1191
- 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]}"
1192
- add_node(
1193
- nodes_by_id,
1194
- paper_id,
1195
- "Paper",
1196
- label=paper.get("title") or f"Paper {i}",
1197
- title=paper.get("title"),
1198
- year=paper.get("year"),
1199
- venue=paper.get("venue"),
1200
- doi=normalize_doi(paper.get("doi")),
1201
- source=paper.get("source"),
1202
- url=paper.get("url"),
1203
- pdf=paper.get("pdf"),
1204
- score=paper.get("score"),
1205
- learned_score=paper.get("learned_score", paper.get("score")),
1206
- open_access=paper.get("open_access"),
1207
- authors_text=paper.get("authors_text"),
1208
- )
1209
- add_edge(edges, topic_id, paper_id, "ABOUT", weight=paper.get("learned_score", paper.get("score", 0)))
1210
- for author in paper.get("authors", [])[:6]:
1211
- author_id = f"author:{slugify(author)[:64]}"
1212
- add_node(nodes_by_id, author_id, "Author", label=author, name=author)
1213
- add_edge(edges, paper_id, author_id, "WRITTEN_BY")
1214
- for concept in (paper.get("concepts") or [])[:6]:
1215
- concept_id = f"concept:{slugify(concept)[:72]}"
1216
- add_node(nodes_by_id, concept_id, "Concept", label=concept, name=concept)
1217
- add_edge(edges, paper_id, concept_id, "MENTIONS")
1218
- for claim in (paper.get("claims") or [])[:3]:
1219
- claim_id = f"claim:{slugify(claim)[:72]}"
1220
- add_node(nodes_by_id, claim_id, "Claim", label=claim[:120], text=claim)
1221
- add_edge(edges, paper_id, claim_id, "ASSERTS")
1222
- if parsed_pdf and isinstance(parsed_pdf, dict) and parsed_pdf.get("title"):
1223
- doc_id = "upload:pdf"
1224
- add_node(nodes_by_id, doc_id, "UploadedPDF", label=parsed_pdf.get("title"), title=parsed_pdf.get("title"), parser=parsed_pdf.get("parser"))
1225
- add_edge(edges, topic_id, doc_id, "UPLOADED_SOURCE")
1226
- for concept in (parsed_pdf.get("concepts") or [])[:6]:
1227
- concept_id = f"concept:{slugify(concept)[:72]}"
1228
- add_node(nodes_by_id, concept_id, "Concept", label=concept, name=concept)
1229
- add_edge(edges, doc_id, concept_id, "MENTIONS")
1230
- for claim in (parsed_pdf.get("claims") or [])[:4]:
1231
- claim_id = f"claim:{slugify(claim)[:72]}"
1232
- add_node(nodes_by_id, claim_id, "Claim", label=claim[:120], text=claim)
1233
- add_edge(edges, doc_id, claim_id, "ASSERTS")
1234
- for idx, ref in enumerate(parsed_pdf.get("references", [])[:12], start=1):
1235
- ref_title = ref.get("title") or f"Reference {idx}"
1236
- ref_doi = normalize_doi(ref.get("doi") or "")
1237
- ref_id = ref_doi or f"ref:{idx}:{slugify(ref_title)[:32]}"
1238
- add_node(nodes_by_id, ref_id, "Reference", label=ref_title, title=ref_title, doi=ref_doi)
1239
- add_edge(edges, doc_id, ref_id, "CITES")
1240
- for idx, item in enumerate(ensure_list(frontier)[:12], start=1):
1241
- fid = normalize_doi(item.get("doi")) or f"frontier:{idx}:{slugify(item.get('title', 'paper'))[:32]}"
1242
- add_node(nodes_by_id, fid, "FrontierPaper", label=item.get("title") or f"Frontier {idx}", title=item.get("title"), frontier_score=item.get("frontier_score"), url=item.get("url"))
1243
- add_edge(edges, topic_id, fid, "FRONTIER_CANDIDATE", weight=item.get("frontier_score", item.get("learned_score", item.get("score", 0))))
1244
- return {"status": "ok", "nodes": list(nodes_by_id.values())[:GRAPH_MAX_NODES], "edges": edges[:GRAPH_MAX_EDGES]}
1245
-
1246
-
1247
- def learn_from_payload(payload: Dict, query: str = "") -> Dict:
1248
- if not payload:
1249
- return GRAPH_MEMORY
1250
- GRAPH_MEMORY["queries"].append(query or "")
1251
- GRAPH_MEMORY["events"].append({
1252
- "ts": time.time(),
1253
- "query": query or "",
1254
- "nodes": len(payload.get("nodes", [])),
1255
- "edges": len(payload.get("edges", [])),
1256
- })
1257
- GRAPH_MEMORY["payloads"].append(payload)
1258
- for node in payload.get("nodes", []):
1259
- node_id = node.get("id")
1260
- if not node_id:
1261
- continue
1262
- GRAPH_MEMORY["nodes"][node_id] = node
1263
- node_type = (node.get("type") or "").lower()
1264
- if node_type in {"paper", "frontierpaper"}:
1265
- GRAPH_MEMORY["papers"][node_id] = node
1266
- if node_type == "concept" and node.get("label"):
1267
- GRAPH_MEMORY["concept_counts"][node["label"].lower()] += 1
1268
- if node_type == "claim" and node.get("label"):
1269
- GRAPH_MEMORY["claim_counts"][node["label"].lower()] += 1
1270
- GRAPH_MEMORY["edges"].extend(payload.get("edges", []))
1271
- GRAPH_MEMORY["edges"] = GRAPH_MEMORY["edges"][:GRAPH_MAX_EDGES]
1272
- return GRAPH_MEMORY
1273
-
1274
-
1275
- def export_learning_state() -> str:
1276
- snapshot = {
1277
- "papers": list(GRAPH_MEMORY["papers"].values())[:50],
1278
- "nodes": list(GRAPH_MEMORY["nodes"].values())[:200],
1279
- "edges": GRAPH_MEMORY["edges"][:400],
1280
- "top_concepts": GRAPH_MEMORY["concept_counts"].most_common(20),
1281
- "top_claims": GRAPH_MEMORY["claim_counts"].most_common(20),
1282
- "queries": GRAPH_MEMORY["queries"][-20:],
1283
- "events": GRAPH_MEMORY["events"][-20:],
1284
- "frontier": GRAPH_MEMORY["frontier"][:20],
1285
- }
1286
- return json.dumps(snapshot, indent=2, ensure_ascii=False)
1287
-
1288
-
1289
- def resolve_selected_papers(selected_indices, papers_state):
1290
- papers = ensure_list(papers_state)
1291
- selected_indices = ensure_list(selected_indices)
1292
- selected = []
1293
- if not selected_indices:
1294
- return selected
1295
- value_map = {paper_choice_value(i, paper): paper for i, paper in enumerate(papers)}
1296
- label_map = {paper_choice_label(i, paper): paper for i, paper in enumerate(papers)}
1297
- for idx in selected_indices:
1298
- try:
1299
- if isinstance(idx, int):
1300
- if 0 <= idx < len(papers):
1301
- selected.append(papers[idx])
1302
- continue
1303
- idx_str = str(idx)
1304
- if idx_str in value_map:
1305
- selected.append(value_map[idx_str])
1306
- continue
1307
- if idx_str.isdigit():
1308
- num = int(idx_str)
1309
- if 0 <= num < len(papers):
1310
- selected.append(papers[num])
1311
- continue
1312
- if "|" in idx_str:
1313
- left = idx_str.split("|", 1)[0]
1314
- if left.isdigit():
1315
- num = int(left)
1316
- if 0 <= num < len(papers):
1317
- selected.append(papers[num])
1318
- continue
1319
- if idx_str in label_map:
1320
- selected.append(label_map[idx_str])
1321
- continue
1322
- except Exception:
1323
- continue
1324
- out = []
1325
- seen = set()
1326
- for paper in selected:
1327
- key = paper_identity_key(paper)
1328
- if key not in seen:
1329
- seen.add(key)
1330
- out.append(paper)
1331
- return out
1332
-
1333
-
1334
- def summarize_learning_state(query_text, papers, selected_sources):
1335
- concept_pool = []
1336
- for paper in papers[:8]:
1337
- concept_pool.extend((paper.get("concepts") or [])[:3])
1338
- top_concepts = [c for c, _ in Counter([c.lower() for c in concept_pool]).most_common(6)]
1339
- return (
1340
- "### Discovery results\n\n"
1341
- f"- Query: {query_text}\n"
1342
- f"- Sources: {', '.join(selected_sources)}\n"
1343
- f"- Candidates found: {len(papers)}\n"
1344
- f"- Top learned concepts: {', '.join(top_concepts) if top_concepts else 'None'}\n"
1345
- "- Select papers below, then click **Ingest selected into graph**.\n"
1346
- )
1347
-
1348
-
1349
- def run_paper_discovery(query, search_mode, sources, pdf_file):
1350
- query_text = norm_text(query or "")
1351
- selected_sources = ensure_list(sources) or DEFAULT_SOURCES
1352
- if not query_text and not pdf_file:
1353
- empty_graph = build_learning_graph_html([], [], "Self-Learning Knowledge Graph")
1354
- return (
1355
- empty_graph,
1356
- '<div class="panel papers-panel" style="padding:18px"><p>Enter a topic, title, DOI, link, or upload a PDF to start learning.</p></div>',
1357
- build_journal_html("biomaterials cardiac repair"),
1358
- "No PDF uploaded yet.",
1359
- gr.update(choices=[], value=[]),
1360
- [],
1361
- "### No discovery results yet.",
1362
- )
1363
- if not query_text and pdf_file:
1364
- uploaded_name = Path(getattr(pdf_file, "name", str(pdf_file))).name
1365
- graph_nodes, graph_edges = build_learning_graph_state("", [], uploaded_name)
1366
- return (
1367
- build_learning_graph_html(graph_nodes, graph_edges, "Uploaded PDF Waiting for Parse"),
1368
- '<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>',
1369
- build_journal_html("biomaterials cardiac repair"),
1370
- uploaded_pdf_summary(pdf_file),
1371
- gr.update(choices=[], value=[]),
1372
- [],
1373
- "### Upload detected.\n\n- Parse the PDF to extract structure.\n- Or enter a topic to start discovery.",
1374
- )
1375
- uploaded_name = Path(getattr(pdf_file, "name", str(pdf_file))).name if pdf_file else None
1376
- try:
1377
- papers = discover_papers(query_text, search_mode, selected_sources, max_results=GRAPH_MAX_RESULTS)
1378
- graph_nodes, graph_edges = build_learning_graph_state(query_text, papers[:6], uploaded_name)
1379
- graph_html = build_learning_graph_html(graph_nodes, graph_edges, "Self-Learning Knowledge Graph")
1380
- papers_html = format_papers_html(papers)
1381
- journals_html = build_journal_html(query_text or "biomaterials cardiac repair")
1382
- pdf_summary = uploaded_pdf_summary(pdf_file)
1383
- choices = format_selection_choices(papers)
1384
- status_md = summarize_learning_state(query_text, papers, selected_sources)
1385
- return (
1386
- graph_html,
1387
- papers_html,
1388
- journals_html,
1389
- pdf_summary,
1390
- gr.update(choices=choices, value=[]),
1391
- papers,
1392
- status_md,
1393
- )
1394
- except Exception as e:
1395
- graph_nodes, graph_edges = build_learning_graph_state(query_text, [], uploaded_name)
1396
- error_html = f'<div class="panel papers-panel" style="padding:18px"><p>Paper search failed: {safe_text(str(e))}</p></div>'
1397
- return (
1398
- build_learning_graph_html(graph_nodes, graph_edges),
1399
- error_html,
1400
- build_journal_html(query_text or "biomaterials cardiac repair"),
1401
- uploaded_pdf_summary(pdf_file),
1402
- gr.update(choices=[], value=[]),
1403
- [],
1404
- f"### Discovery failed.\n\n- Error: {safe_text(str(e))}",
1405
- )
1406
-
1407
-
1408
- def ingest_selected_papers(query, selected_indices, papers_state, pdf_file, parsed_state):
1409
- papers = ensure_list(papers_state)
1410
- selected = resolve_selected_papers(selected_indices, papers)
1411
- uploaded_name = Path(getattr(pdf_file, "name", str(pdf_file))).name if pdf_file else None
1412
- if not selected and parsed_state and isinstance(parsed_state, dict) and parsed_state.get("title") and papers:
1413
- selected = papers[:3]
1414
- if not selected and not (parsed_state and isinstance(parsed_state, dict) and parsed_state.get("title")):
1415
- graph_html = build_learning_graph_html([], [], "Self-Learning Knowledge Graph")
1416
- return (
1417
- graph_html,
1418
- "### Graph ingest status\n\nSelect papers or parse an uploaded PDF first.",
1419
- {"status": "empty", "nodes": [], "edges": []},
1420
- )
1421
- query_text = norm_text(query or "")
1422
- if not query_text and isinstance(parsed_state, dict):
1423
- query_text = parsed_state.get("title") or "Research topic"
1424
- if not query_text:
1425
- query_text = "Research topic"
1426
- selected = [enrich_paper_semantics(query_text, paper) for paper in selected]
1427
- frontier = frontier_expand(query_text, DEFAULT_SOURCES, selected, parsed_state=parsed_state if isinstance(parsed_state, dict) else None, per_query=3)
1428
- graph_nodes, graph_edges = graph_from_selected(query_text, selected, uploaded_name, parsed_state if isinstance(parsed_state, dict) else None, frontier=frontier)
1429
- graph_html = build_learning_graph_html(graph_nodes, graph_edges, "Selected Research Graph")
1430
- payload = build_ingest_payload(query_text, selected, parsed_state if isinstance(parsed_state, dict) else None, frontier=frontier)
1431
- learn_from_payload(payload, query=query_text)
1432
- top_concepts = []
1433
- for paper in selected:
1434
- top_concepts.extend((paper.get("concepts") or [])[:3])
1435
- if isinstance(parsed_state, dict):
1436
- top_concepts.extend((parsed_state.get("concepts") or [])[:3])
1437
- summary_lines = [
1438
- "### Graph ingest status",
1439
- "",
1440
- f"- Topic: {query_text}",
1441
- f"- Selected papers: {len(selected)}",
1442
- f"- Uploaded PDF parsed: {'Yes' if parsed_state and isinstance(parsed_state, dict) and parsed_state.get('title') else 'No'}",
1443
- f"- Frontier candidates proposed: {len(frontier)}",
1444
- f"- Nodes created: {len(payload['nodes'])}",
1445
- f"- Edges created: {len(payload['edges'])}",
1446
- f"- Learned concepts: {', '.join(unique_keep_order(top_concepts)[:8]) if top_concepts else 'None'}",
1447
- f"- Memory papers stored: {len(GRAPH_MEMORY['papers'])}",
1448
- f"- Memory concepts stored: {len(GRAPH_MEMORY['concept_counts'])}",
1449
- ]
1450
- return graph_html, "\n".join(summary_lines), payload
1451
-
1452
-
1453
- def autonomous_expand_into_markdown(query, payload, parsed_state=None):
1454
- frontier = GRAPH_MEMORY.get("frontier") or []
1455
- lines = [
1456
- "### Autonomous expansion plan",
1457
- "",
1458
- f"- Seed query: {query or 'Research topic'}",
1459
- f"- Current nodes: {len(payload.get('nodes', [])) if isinstance(payload, dict) else 0}",
1460
- f"- Current edges: {len(payload.get('edges', [])) if isinstance(payload, dict) else 0}",
1461
- f"- Frontier candidates: {len(frontier)}",
1462
- ]
1463
- proposed = propose_expansion_queries(query or "", list(GRAPH_MEMORY.get("papers", {}).values())[:8], parsed_state=parsed_state, limit=GRAPH_MAX_EXPANSIONS)
1464
- if proposed:
1465
- lines.extend(["", "#### Proposed next queries", ""])
1466
- lines.extend([f"- {q}" for q in proposed])
1467
- if frontier:
1468
- lines.extend(["", "#### Top frontier papers", ""])
1469
- for item in frontier[:8]:
1470
- lines.append(f"- {item.get('title', 'Untitled')} ({item.get('source', 'unknown')}) — frontier score {item.get('frontier_score', item.get('learned_score', item.get('score', 0)))}")
1471
- return "\n".join(lines)
1472
-
1473
-
1474
- __all__ = [
1475
- "SEARCH_MODES",
1476
- "SOURCE_OPTIONS",
1477
- "DEFAULT_SOURCES",
1478
- "GRAPH_MEMORY",
1479
- "discover_papers",
1480
- "run_paper_discovery",
1481
- "parse_uploaded_pdf",
1482
- "render_parse_result",
1483
- "ingest_selected_papers",
1484
- "build_ingest_payload",
1485
- "learn_from_payload",
1486
- "frontier_expand",
1487
- "autonomous_expand_into_markdown",
1488
- "export_learning_state",
1489
- "format_frontier_html",
1490
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dvnc_ai_v2_hf/deprecated/self_learning_graph_old_2.py DELETED
@@ -1,73 +0,0 @@
1
- Core constants and state
2
- - JOURNALS
3
- - SEARCH_MODES
4
- - SOURCE_OPTIONS
5
- - DEFAULT_SOURCES
6
- - GRAPH_MEMORY
7
-
8
- Text and identity helpers
9
- - safe_text(x, default="")
10
- - norm_text(x)
11
- - slugify(text)
12
- - ensure_list(x)
13
- - normalize_doi(text)
14
- - detect_query_type(query)
15
- - tokenize(text)
16
- - paper_identity_key(paper)
17
-
18
- Semantic helpers
19
- - extract_candidate_phrases(text, max_terms=...)
20
- - extract_concepts_from_text(text, max_terms=...)
21
- - extract_claim_like_sentences(text, max_items=...)
22
- - text_overlap_score(a, b)
23
- - compute_recency_bonus(year)
24
- - parse_openalex_abstract(inverted_index)
25
- - enrich_paper_semantics(query, paper)
26
-
27
- Retrieval layer
28
- - search_arxiv(query, max_results=8)
29
- - search_crossref(query, mode="topic", max_results=8)
30
- - search_openalex(query, mode="topic", max_results=8)
31
- - search_semantic_scholar(query, mode="topic", max_results=8)
32
- - search_europe_pmc(query, mode="topic", max_results=8)
33
- - resolve_link(query)
34
- - dedupe_papers(items)
35
- - discover_papers(query, mode, sources, max_results=10)
36
-
37
- Self-learning layer
38
- - extract_reference_queries_from_parsed(parsed_pdf, max_items=8)
39
- - propose_expansion_queries(seed_query, papers, parsed_pdf=None, max_items=8)
40
- - autonomous_expand_graph(query, sources, parsed_pdf=None, max_rounds=2, max_results=8)
41
-
42
- UI formatting
43
- - journal_query_links(query)
44
- - build_journal_html(query)
45
- - paper_choice_value(index, paper)
46
- - paper_choice_label(index, paper)
47
- - format_papers_html(papers)
48
- - format_selection_choices(papers)
49
- - uploaded_pdf_summary(file_obj)
50
- - build_learning_graph_html(nodes, edges, title=...)
51
- - build_learning_graph_state(query, papers, uploaded_name=None)
52
- - graph_from_selected(query, selected_papers, uploaded_name=None)
53
-
54
- PDF parsing
55
- - parse_pdf_with_grobid(pdf_path)
56
- - parse_pdf_with_pymupdf(pdf_path)
57
- - parse_pdf_with_docling(pdf_path)
58
- - parse_uploaded_pdf(file_obj, parser_order)
59
- - render_parse_result(parsed)
60
-
61
- Graph payload and memory
62
- - add_node(nodes_by_id, node_id, node_type, label="", **attrs)
63
- - add_edge(edges, source, target, edge_type, **attrs)
64
- - build_ingest_payload(query, selected_papers, parsed_pdf=None)
65
- - build_autonomous_payload(query, papers, parsed_pdf, visited_queries, rounds)
66
- - learn_from_payload(payload, query="")
67
- - get_graph_memory_snapshot()
68
- - reset_graph_memory()
69
-
70
- Main Gradio callbacks
71
- - run_paper_discovery(query, search_mode, sources, pdf_file)
72
- - ingest_selected_papers(query, selected_indices, papers_state, pdf_file, parsed_state)
73
- - run_self_learning_cycle(query, search_mode, sources, pdf_file, parser_order, selected_indices, papers_state, parsed_state)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dvnc_ai_v2_hf/deprecated/self_learning_graph_old_8.py DELETED
@@ -1,2070 +0,0 @@
1
- import html
2
- import json
3
- import os
4
- import re
5
- import time
6
- import urllib.parse
7
- import xml.etree.ElementTree as ET
8
- from collections import Counter
9
- from pathlib import Path
10
- from typing import Any, Dict, List, Optional
11
-
12
- import gradio as gr
13
- import requests
14
-
15
- try:
16
- import fitz # PyMuPDF
17
- except Exception:
18
- fitz = None
19
-
20
- try:
21
- from bs4 import BeautifulSoup
22
- except Exception:
23
- BeautifulSoup = None
24
-
25
-
26
- JOURNALS = [
27
- {"name": "Nature", "url": "https://www.nature.com/search", "desc": "Flagship multidisciplinary research journal."},
28
- {"name": "Science", "url": "https://www.science.org/search", "desc": "High-impact science journal and family."},
29
- {"name": "Cell", "url": "https://www.cell.com/search", "desc": "Life sciences and translational biology."},
30
- {"name": "The Lancet", "url": "https://www.thelancet.com/search", "desc": "Clinical and medical research."},
31
- {"name": "IEEE Xplore", "url": "https://ieeexplore.ieee.org/search/searchresult.jsp", "desc": "Engineering, AI, signal processing, and systems."},
32
- ]
33
-
34
- SEARCH_MODES = ["topic", "title", "doi", "link", "paper_name", "autonomous_web"]
35
- SOURCE_OPTIONS = ["arxiv", "crossref", "openalex", "semantic_scholar", "europe_pmc"]
36
- DEFAULT_SOURCES = ["arxiv", "crossref", "openalex", "semantic_scholar", "europe_pmc"]
37
- PDF_PARSERS = ["grobid", "docling", "pymupdf"]
38
-
39
- SEMANTIC_SCHOLAR_API_KEY = os.getenv("SEMANTIC_SCHOLAR_API_KEY", "").strip()
40
- GROBID_URL = os.getenv("GROBID_URL", "").strip()
41
- OPENALEX_EMAIL = os.getenv("OPENALEX_EMAIL", "").strip()
42
- CROSSREF_MAILTO = os.getenv("CROSSREF_MAILTO", "").strip()
43
- REQUEST_TIMEOUT = int(os.getenv("REQUEST_TIMEOUT", "25"))
44
- GRAPH_MAX_CONCEPTS = int(os.getenv("GRAPH_MAX_CONCEPTS", "14"))
45
- GRAPH_MAX_CLAIMS = int(os.getenv("GRAPH_MAX_CLAIMS", "8"))
46
- GRAPH_MAX_RESULTS = int(os.getenv("GRAPH_MAX_RESULTS", "12"))
47
- GRAPH_MAX_EXPANSIONS = int(os.getenv("GRAPH_MAX_EXPANSIONS", "6"))
48
- GRAPH_MAX_NODES = int(os.getenv("GRAPH_MAX_NODES", "500"))
49
- GRAPH_MAX_EDGES = int(os.getenv("GRAPH_MAX_EDGES", "1600"))
50
- MAX_ABSTRACT_CHARS = int(os.getenv("MAX_ABSTRACT_CHARS", "4500"))
51
- MAX_RAW_TEXT_CHARS = int(os.getenv("MAX_RAW_TEXT_CHARS", "90000"))
52
- GRAPH_IFRAME_HEIGHT = int(os.getenv("GRAPH_IFRAME_HEIGHT", "760"))
53
-
54
- STOPWORDS = {
55
- "a", "an", "and", "are", "as", "at", "be", "been", "being", "by", "can", "could", "did", "do", "does",
56
- "for", "from", "had", "has", "have", "if", "in", "into", "is", "it", "its", "may", "might", "of", "on",
57
- "or", "our", "such", "that", "the", "their", "there", "these", "this", "those", "to", "using", "use",
58
- "used", "via", "was", "were", "will", "with", "within", "without", "we", "they", "you", "your", "study",
59
- "paper", "research", "results", "result", "method", "methods", "analysis", "approach", "toward", "towards",
60
- "based", "new", "novel", "effect", "effects", "model", "models", "system", "systems", "show", "shows",
61
- "shown", "introduction", "conclusion", "discussion", "figure", "table", "supplementary", "material",
62
- "materials", "however", "therefore", "furthermore", "among", "across", "between", "also", "than",
63
- "both", "et", "al",
64
- }
65
-
66
- COMMON_SECTION_HEADINGS = {
67
- "abstract", "introduction", "background", "methods", "materials", "results", "discussion", "conclusion",
68
- "references", "acknowledgements", "acknowledgments", "keywords", "supplementary", "appendix"
69
- }
70
-
71
- GRAPH_MEMORY = {
72
- "papers": {},
73
- "nodes": {},
74
- "edges": [],
75
- "concept_counts": Counter(),
76
- "claim_counts": Counter(),
77
- "queries": [],
78
- "events": [],
79
- "frontier": [],
80
- "payloads": [],
81
- }
82
-
83
-
84
- class ScholarlyClient:
85
- def __init__(self):
86
- self.session = requests.Session()
87
- ua = "dvnc-ai-self-learning-graph/2.0"
88
- if CROSSREF_MAILTO:
89
- ua += f" (mailto:{CROSSREF_MAILTO})"
90
- self.session.headers.update({"User-Agent": ua, "Accept": "application/json, text/xml, */*"})
91
- self.session_timeout = REQUEST_TIMEOUT
92
-
93
- def get(self, url: str, **kwargs):
94
- timeout = kwargs.pop("timeout", self.session_timeout)
95
- return self.session.get(url, timeout=timeout, **kwargs)
96
-
97
- def post(self, url: str, **kwargs):
98
- timeout = kwargs.pop("timeout", max(self.session_timeout, 120))
99
- return self.session.post(url, timeout=timeout, **kwargs)
100
-
101
-
102
- HTTP = ScholarlyClient()
103
-
104
-
105
- def safe_text(x, default=""):
106
- return html.escape(str(x if x is not None else default))
107
-
108
-
109
- def norm_text(x: Optional[str]) -> str:
110
- return re.sub(r"\s+", " ", (x or "")).strip()
111
-
112
-
113
- def slugify(text: str) -> str:
114
- return re.sub(r"[^a-z0-9]+", "-", (text or "").lower()).strip("-")
115
-
116
-
117
- def ensure_list(x):
118
- return x if isinstance(x, list) else []
119
-
120
-
121
- def truncate_text(text: str, limit: int) -> str:
122
- text = norm_text(text)
123
- return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
124
-
125
-
126
- def normalize_doi(text: str) -> str:
127
- text = (text or "").strip()
128
- text = re.sub(r"^https?://(dx\.)?doi\.org/", "", text, flags=re.I)
129
- return text.strip().rstrip("/")
130
-
131
-
132
- def detect_query_type(query: str) -> str:
133
- q = (query or "").strip()
134
- doi_pattern = r"^10\.\d{4,9}/[-._;()/:A-Z0-9]+$"
135
- if re.match(doi_pattern, q, flags=re.I):
136
- return "doi"
137
- if q.startswith("http://") or q.startswith("https://"):
138
- return "link"
139
- return "topic"
140
-
141
-
142
- def unique_keep_order(items: List[str]) -> List[str]:
143
- seen = set()
144
- out = []
145
- for item in items:
146
- key = norm_text(item).lower()
147
- if key and key not in seen:
148
- seen.add(key)
149
- out.append(norm_text(item))
150
- return out
151
-
152
-
153
- def tokenize(text: str) -> List[str]:
154
- return [t for t in re.findall(r"[A-Za-z][A-Za-z0-9\-/+]{2,}", (text or "")) if t.lower() not in STOPWORDS]
155
-
156
-
157
- def text_overlap_score(a: str, b: str) -> float:
158
- sa = {x.lower() for x in tokenize(a)}
159
- sb = {x.lower() for x in tokenize(b)}
160
- if not sa or not sb:
161
- return 0.0
162
- return len(sa & sb) / max(1, len(sa | sb))
163
-
164
-
165
- def compute_recency_bonus(year: str) -> float:
166
- try:
167
- y = int(str(year)[:4])
168
- except Exception:
169
- return 0.0
170
- current = time.gmtime().tm_year
171
- age = max(current - y, 0)
172
- return max(0.0, 0.16 - age * 0.018)
173
-
174
-
175
- def dehyphenate_text(text: str) -> str:
176
- text = re.sub(r"(\w)-\s*\n\s*(\w)", r"\1\2", text)
177
- text = re.sub(r"([a-z])\n([a-z])", r"\1 \2", text)
178
- return text
179
-
180
-
181
- def clean_extracted_text(text: str) -> str:
182
- text = text or ""
183
- replacements = {
184
- "\u00ad": "",
185
- "\ufb01": "fi",
186
- "\ufb02": "fl",
187
- "\u2010": "-",
188
- "\u2011": "-",
189
- "\u2012": "-",
190
- "\u2013": "-",
191
- "\u2014": "-",
192
- "\u2212": "-",
193
- "\u00a0": " ",
194
- }
195
- for old, new in replacements.items():
196
- text = text.replace(old, new)
197
-
198
- text = dehyphenate_text(text)
199
- text = re.sub(r"[ \t]+", " ", text)
200
- text = re.sub(r"\n{3,}", "\n\n", text)
201
-
202
- text = re.sub(r"(?i)\b([a-z]{4,})\s+([a-z]?\1)\b", r"\1", text)
203
- text = re.sub(r"(?i)\b([a-z])([A-Z][a-z]+)\b", r"\1 \2", text)
204
- text = re.sub(
205
- r"(?i)(\b[a-z]{1})(hydrogel|conductive|responsive|injectable|biomaterial|scaffold|tissue|cardiac|patch|repair)\b",
206
- r"\2",
207
- text,
208
- )
209
- text = re.sub(r"(?i)\b([a-z]{3,})\s+([a-z]\1)\b", r"\1", text)
210
-
211
- return text.strip()
212
-
213
-
214
- def line_quality_score(line: str) -> float:
215
- line = norm_text(line)
216
- if not line:
217
- return -10.0
218
- lower = line.lower().strip(":")
219
- if lower in COMMON_SECTION_HEADINGS:
220
- return -5.0
221
- words = line.split()
222
- if len(words) < 3:
223
- return -2.0
224
- score = 0.0
225
- score += min(len(words), 18) * 0.2
226
- score += sum(1 for w in words if w[:1].isupper()) * 0.15
227
- if len(line) > 180:
228
- score -= 2.0
229
- if re.search(r"doi|http|www\.|@", lower):
230
- score -= 1.5
231
- if re.search(r"^[0-9\.\-\s]+$", line):
232
- score -= 3.0
233
- return score
234
-
235
-
236
- def extract_title_from_text(raw_text: str, fallback: str = "Uploaded PDF") -> str:
237
- raw_text = clean_extracted_text(raw_text or "")
238
- lines = [norm_text(x) for x in raw_text.splitlines() if norm_text(x)]
239
- head = lines[:18]
240
- if not head:
241
- return fallback
242
- best = sorted(head, key=line_quality_score, reverse=True)[0]
243
- best = re.sub(r"\s{2,}", " ", best).strip(" -:;")
244
- return truncate_text(best or fallback, 260)
245
-
246
-
247
- def sentence_split(text: str) -> List[str]:
248
- text = clean_extracted_text(text)
249
- return [norm_text(s) for s in re.split(r"(?<=[\.\!\?])\s+", text) if norm_text(s)]
250
-
251
-
252
- def looks_like_bad_phrase(phrase: str) -> bool:
253
- phrase = norm_text(phrase)
254
- if not phrase:
255
- return True
256
- if len(phrase) < 4 or len(phrase) > 90:
257
- return True
258
- tokens_ = phrase.split()
259
- if len(tokens_) > 6:
260
- return True
261
- for tok in tokens_:
262
- t = tok.strip("-.,;:()[]{}")
263
- if not t:
264
- return True
265
- if len(t) == 1 and t.lower() not in {"p", "h"}:
266
- return True
267
- if re.search(r"[^A-Za-z0-9\-/+]", t):
268
- return True
269
- if re.search(r"(.)\1\1\1", t.lower()):
270
- return True
271
- if re.match(r"^[bcdfghjklmnpqrstvwxyz]{5,}$", t.lower()):
272
- return True
273
- return False
274
-
275
-
276
- def normalize_concept_label(phrase: str) -> str:
277
- phrase = norm_text(phrase)
278
- mapping = {"ph": "pH", "ecg": "ECG", "mri": "MRI", "ai": "AI", "ml": "ML", "3d": "3D"}
279
- parts = []
280
- for part in phrase.split():
281
- low = part.lower()
282
- parts.append(mapping.get(low, part))
283
- return " ".join(parts)
284
-
285
-
286
- def dedupe_similar_strings(items: List[str], max_items: int) -> List[str]:
287
- result = []
288
- seen = set()
289
- for item in items:
290
- cleaned = normalize_concept_label(item)
291
- low = cleaned.lower()
292
- if low in seen:
293
- continue
294
- if any(low != s and (low in s or s in low) and abs(len(low) - len(s)) <= 2 for s in seen):
295
- continue
296
- seen.add(low)
297
- result.append(cleaned)
298
- if len(result) >= max_items:
299
- break
300
- return result
301
-
302
-
303
- def extract_candidate_phrases(text: str, max_terms: int = 20) -> List[str]:
304
- text = clean_extracted_text(text)
305
- words = re.findall(r"[A-Za-z][A-Za-z0-9\-/+]{2,}", text)
306
- phrases = []
307
-
308
- for n in (3, 2, 1):
309
- for i in range(len(words) - n + 1):
310
- phrase = " ".join(words[i:i + n])
311
- low = phrase.lower()
312
- parts = low.split()
313
- if any(p in STOPWORDS for p in parts):
314
- continue
315
- if looks_like_bad_phrase(phrase):
316
- continue
317
- if len(parts) == 1 and len(parts[0]) < 5:
318
- continue
319
- phrases.append(low)
320
-
321
- counts = Counter(phrases)
322
- ranked = []
323
- for phrase, count in counts.most_common(max_terms * 8):
324
- score = float(count)
325
- token_count = len(phrase.split())
326
- score += 0.25 * token_count
327
- if any(x in phrase for x in ["hydrogel", "scaffold", "conductive", "regeneration", "learning", "graph", "neural", "cardiac", "biomaterial"]):
328
- score += 0.5
329
- ranked.append((score, phrase))
330
-
331
- ranked_phrases = [p for _, p in sorted(ranked, key=lambda x: x[0], reverse=True)]
332
- return dedupe_similar_strings(ranked_phrases, max_terms)
333
-
334
-
335
- def extract_concepts_from_text(text: str, max_terms: int = GRAPH_MAX_CONCEPTS) -> List[str]:
336
- return extract_candidate_phrases(text, max_terms=max_terms)
337
-
338
-
339
- def extract_claim_like_sentences(text: str, max_items: int = GRAPH_MAX_CLAIMS) -> List[str]:
340
- scored = []
341
- for sentence in sentence_split(text):
342
- if len(sentence) < 45 or len(sentence) > 320:
343
- continue
344
- lower = sentence.lower()
345
- score = 0.0
346
- if any(k in lower for k in ["improves", "reduces", "increases", "demonstrates", "shows", "reveals", "predicts", "achieves", "outperforms", "enables", "supports"]):
347
- score += 2.0
348
- if any(k in lower for k in ["significant", "associated", "correlated", "effective", "robust", "accurate", "validated", "statistically"]):
349
- score += 1.0
350
- if any(k in lower for k in ["compared", "versus", "baseline", "state-of-the-art", "sota"]):
351
- score += 1.0
352
- score += min(len(tokenize(sentence)) / 18.0, 2.0)
353
- scored.append((score, sentence))
354
- best = [s for _, s in sorted(scored, key=lambda x: x[0], reverse=True)]
355
- return dedupe_similar_strings(best, max_items)
356
-
357
-
358
- def parse_openalex_abstract(inverted_index) -> str:
359
- if not inverted_index or not isinstance(inverted_index, dict):
360
- return ""
361
- pos_to_word = {}
362
- for word, positions in inverted_index.items():
363
- for pos in positions:
364
- pos_to_word[pos] = word
365
- if not pos_to_word:
366
- return ""
367
- return " ".join(pos_to_word[i] for i in sorted(pos_to_word))
368
-
369
-
370
- def paper_identity_key(paper: Dict) -> str:
371
- return (
372
- normalize_doi(paper.get("doi") or "")
373
- or (paper.get("external_ids") or {}).get("arxiv")
374
- or (paper.get("external_ids") or {}).get("pmcid")
375
- or norm_text(paper.get("title", "")).lower()
376
- or str(paper.get("id"))
377
- )
378
-
379
-
380
- def score_frontier_candidate(query: str, seed_concepts: List[str], paper: Dict) -> Dict:
381
- title = paper.get("title", "")
382
- abstract = paper.get("abstract", "") or paper.get("summary", "")
383
- venue = paper.get("venue", "")
384
- base_text = " ".join([title, abstract, venue])
385
- rel = text_overlap_score(query, base_text)
386
- concept_overlap = 0.0
387
- if seed_concepts:
388
- concept_overlap = text_overlap_score(" ".join(seed_concepts), " ".join(paper.get("concepts") or []))
389
- recency = compute_recency_bonus(paper.get("year"))
390
- doi_bonus = 0.02 if paper.get("doi") else 0.0
391
- oa_bonus = 0.03 if paper.get("open_access") else 0.0
392
- score = float(paper.get("learned_score", paper.get("score", 0))) + rel * 0.45 + concept_overlap * 0.22 + recency + doi_bonus + oa_bonus
393
- paper["frontier_score"] = round(score, 4)
394
- paper["frontier_relevance"] = round(rel, 4)
395
- paper["frontier_concept_overlap"] = round(concept_overlap, 4)
396
- return paper
397
-
398
-
399
- def enrich_paper_semantics(query: str, paper: Dict) -> Dict:
400
- paper = dict(paper)
401
- title = clean_extracted_text(paper.get("title", ""))
402
- abstract = clean_extracted_text(paper.get("abstract", "") or paper.get("summary", ""))
403
- venue = clean_extracted_text(paper.get("venue", ""))
404
- base_text = " ".join([title, abstract, venue]).strip()
405
-
406
- concepts = extract_concepts_from_text(base_text, max_terms=GRAPH_MAX_CONCEPTS)
407
- claims = extract_claim_like_sentences(abstract, max_items=GRAPH_MAX_CLAIMS)
408
-
409
- rel = text_overlap_score(query, f"{title} {abstract}")
410
- recency = compute_recency_bonus(paper.get("year"))
411
- doi_bonus = 0.02 if paper.get("doi") else 0.0
412
- oa_bonus = 0.03 if paper.get("open_access") else 0.0
413
- concept_bonus = min(len(concepts), 8) * 0.012
414
-
415
- learned_score = float(paper.get("score", 0)) + rel * 0.52 + recency + doi_bonus + oa_bonus + concept_bonus
416
-
417
- paper["title"] = title or paper.get("title", "Untitled")
418
- paper["abstract"] = abstract
419
- paper["summary"] = truncate_text(abstract or paper.get("summary", ""), 520)
420
- paper["venue"] = venue
421
- paper["concepts"] = concepts[:GRAPH_MAX_CONCEPTS]
422
- paper["claims"] = claims[:GRAPH_MAX_CLAIMS]
423
- paper["relevance"] = round(rel, 4)
424
- paper["learned_score"] = round(learned_score, 4)
425
- return paper
426
-
427
-
428
- def journal_query_links(query: str):
429
- q = urllib.parse.quote_plus(query or "biomaterials cardiac repair")
430
- rows = []
431
- for journal in JOURNALS:
432
- url = f"{journal['url']}?q={q}" if "?" not in journal["url"] else f"{journal['url']}&q={q}"
433
- if "ieeexplore" in journal["url"]:
434
- url = f"https://ieeexplore.ieee.org/search/searchresult.jsp?queryText={q}"
435
- rows.append({"name": journal["name"], "desc": journal["desc"], "url": url})
436
- return rows
437
-
438
-
439
- def build_journal_html(query):
440
- rows = []
441
- for journal in journal_query_links(query):
442
- rows.append(
443
- f"""
444
- <a class="journal-card" href="{safe_text(journal['url'])}" target="_blank" rel="noopener noreferrer">
445
- <div>
446
- <h4>{safe_text(journal['name'])}</h4>
447
- <p>{safe_text(journal['desc'])}</p>
448
- </div>
449
- <span>Open</span>
450
- </a>
451
- """
452
- )
453
- return '<div class="journal-grid">' + ''.join(rows) + '</div>'
454
-
455
-
456
- def search_arxiv(query, max_results=8):
457
- encoded = urllib.parse.quote(query)
458
- url = (
459
- "http://export.arxiv.org/api/query?search_query=all:"
460
- f"{encoded}&start=0&max_results={max_results}&sortBy=relevance&sortOrder=descending"
461
- )
462
- response = HTTP.get(url)
463
- response.raise_for_status()
464
- root = ET.fromstring(response.text)
465
- ns = {"atom": "http://www.w3.org/2005/Atom"}
466
- papers = []
467
- for entry in root.findall("atom:entry", ns):
468
- title = clean_extracted_text(entry.findtext("atom:title", default="", namespaces=ns) or "")
469
- summary = truncate_text(clean_extracted_text(entry.findtext("atom:summary", default="", namespaces=ns) or ""), MAX_ABSTRACT_CHARS)
470
- published = entry.findtext("atom:published", default="", namespaces=ns)
471
- paper_id = entry.findtext("atom:id", default="", namespaces=ns)
472
- authors = [clean_extracted_text(a.findtext("atom:name", default="", namespaces=ns)) for a in entry.findall("atom:author", ns)]
473
- pdf_url = ""
474
- for link in entry.findall("atom:link", ns):
475
- if link.attrib.get("title") == "pdf":
476
- pdf_url = link.attrib.get("href", "")
477
- break
478
- papers.append({
479
- "id": paper_id or title,
480
- "title": title,
481
- "summary": summary,
482
- "abstract": summary,
483
- "published": published[:10],
484
- "authors": [a for a in authors[:8] if a],
485
- "authors_text": ", ".join([a for a in authors[:4] if a]) or "Unknown authors",
486
- "url": paper_id,
487
- "pdf": pdf_url,
488
- "doi": "",
489
- "venue": "arXiv",
490
- "year": published[:4] if published else "",
491
- "source": "arxiv",
492
- "score": 0.76,
493
- "open_access": True,
494
- "external_ids": {"arxiv": (paper_id or "").split("/")[-1]},
495
- })
496
- return papers
497
-
498
-
499
- def search_crossref(query, mode="topic", max_results=8):
500
- params = {}
501
- if CROSSREF_MAILTO:
502
- params["mailto"] = CROSSREF_MAILTO
503
- if mode == "doi":
504
- url = f"https://api.crossref.org/works/{urllib.parse.quote(query)}"
505
- response = HTTP.get(url, params=params)
506
- if response.status_code != 200:
507
- return []
508
- items = [response.json().get("message", {})]
509
- else:
510
- params["rows"] = max_results
511
- if mode in ("title", "paper_name"):
512
- params["query.title"] = query
513
- else:
514
- params["query.bibliographic"] = query
515
- response = HTTP.get("https://api.crossref.org/works", params=params)
516
- response.raise_for_status()
517
- items = response.json().get("message", {}).get("items", [])
518
- out = []
519
- for item in items:
520
- authors = []
521
- for a in item.get("author", []) or []:
522
- name = " ".join(filter(None, [a.get("given"), a.get("family")])).strip()
523
- if name:
524
- authors.append(clean_extracted_text(name))
525
- title = clean_extracted_text((item.get("title") or ["Untitled"])[0])
526
- year = ""
527
- for key in ["published-print", "published-online", "created"]:
528
- if item.get(key, {}).get("date-parts"):
529
- year = str(item[key]["date-parts"][0][0])
530
- break
531
- abstract = truncate_text(clean_extracted_text(re.sub("<.*?>", " ", item.get("abstract") or "")), MAX_ABSTRACT_CHARS)
532
- doi = normalize_doi(item.get("DOI", ""))
533
- out.append({
534
- "id": doi or title,
535
- "title": title,
536
- "summary": abstract[:500],
537
- "abstract": abstract,
538
- "published": year,
539
- "authors": authors,
540
- "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
541
- "url": item.get("URL", ""),
542
- "pdf": "",
543
- "doi": doi,
544
- "venue": clean_extracted_text((item.get("container-title") or [""])[0]),
545
- "year": year,
546
- "source": "crossref",
547
- "score": 0.72,
548
- "open_access": None,
549
- "external_ids": {"crossref": doi} if doi else {},
550
- })
551
- return out
552
-
553
-
554
- def search_openalex(query, mode="topic", max_results=8):
555
- params = {"per-page": max_results}
556
- if OPENALEX_EMAIL:
557
- params["mailto"] = OPENALEX_EMAIL
558
- if mode == "doi":
559
- doi = normalize_doi(query)
560
- params["filter"] = f"doi:https://doi.org/{doi}"
561
- else:
562
- params["search"] = query
563
- response = HTTP.get("https://api.openalex.org/works", params=params)
564
- response.raise_for_status()
565
- items = response.json().get("results", [])
566
- out = []
567
- for item in items:
568
- authors = []
569
- for auth in item.get("authorships", [])[:8]:
570
- author = auth.get("author") or {}
571
- if author.get("display_name"):
572
- authors.append(clean_extracted_text(author["display_name"]))
573
- oa = item.get("open_access") or {}
574
- doi = normalize_doi(item.get("doi") or "")
575
- abstract = truncate_text(clean_extracted_text(parse_openalex_abstract(item.get("abstract_inverted_index"))), MAX_ABSTRACT_CHARS)
576
- out.append({
577
- "id": item.get("id") or doi or item.get("title"),
578
- "title": clean_extracted_text(item.get("title") or ""),
579
- "summary": abstract[:500],
580
- "abstract": abstract,
581
- "published": str(item.get("publication_year") or ""),
582
- "authors": authors,
583
- "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
584
- "url": (item.get("primary_location") or {}).get("landing_page_url") or item.get("id") or "",
585
- "pdf": oa.get("oa_url") or "",
586
- "doi": doi,
587
- "venue": clean_extracted_text(((item.get("primary_location") or {}).get("source") or {}).get("display_name") or ""),
588
- "year": str(item.get("publication_year") or ""),
589
- "source": "openalex",
590
- "score": 0.80,
591
- "open_access": oa.get("is_oa"),
592
- "external_ids": item.get("ids") or {},
593
- })
594
- return out
595
-
596
-
597
- def search_semantic_scholar(query, mode="topic", max_results=8):
598
- headers = {}
599
- if SEMANTIC_SCHOLAR_API_KEY:
600
- headers["x-api-key"] = SEMANTIC_SCHOLAR_API_KEY
601
- fields = "title,authors,year,abstract,venue,externalIds,url,openAccessPdf"
602
- if mode == "doi":
603
- doi = normalize_doi(query)
604
- url = f"https://api.semanticscholar.org/graph/v1/paper/DOI:{urllib.parse.quote(doi)}"
605
- response = HTTP.get(url, params={"fields": fields}, headers=headers)
606
- if response.status_code != 200:
607
- return []
608
- items = [response.json()]
609
- else:
610
- response = HTTP.get(
611
- "https://api.semanticscholar.org/graph/v1/paper/search",
612
- params={"query": query, "limit": max_results, "fields": fields},
613
- headers=headers,
614
- )
615
- if response.status_code != 200:
616
- return []
617
- items = response.json().get("data", [])
618
- out = []
619
- for item in items:
620
- external = item.get("externalIds") or {}
621
- authors = [clean_extracted_text(a.get("name")) for a in item.get("authors", []) if a.get("name")]
622
- abstract = truncate_text(clean_extracted_text(item.get("abstract", "")), MAX_ABSTRACT_CHARS)
623
- out.append({
624
- "id": external.get("CorpusId") or external.get("DOI") or item.get("title"),
625
- "title": clean_extracted_text(item.get("title") or ""),
626
- "summary": abstract[:500],
627
- "abstract": abstract,
628
- "published": str(item.get("year") or ""),
629
- "authors": authors,
630
- "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
631
- "url": item.get("url") or "",
632
- "pdf": (item.get("openAccessPdf") or {}).get("url") or "",
633
- "doi": normalize_doi(external.get("DOI", "")),
634
- "venue": clean_extracted_text(item.get("venue") or ""),
635
- "year": str(item.get("year") or ""),
636
- "source": "semantic_scholar",
637
- "score": 0.84,
638
- "open_access": bool((item.get("openAccessPdf") or {}).get("url")),
639
- "external_ids": external,
640
- })
641
- return out
642
-
643
-
644
- def search_europe_pmc(query, mode="topic", max_results=8):
645
- epmc_query = f'DOI:"{query}"' if mode == "doi" else query
646
- params = {"query": epmc_query, "format": "json", "pageSize": max_results, "resultType": "core"}
647
- response = HTTP.get("https://www.ebi.ac.uk/europepmc/webservices/rest/search", params=params)
648
- if response.status_code != 200:
649
- return []
650
- items = response.json().get("resultList", {}).get("result", [])
651
- out = []
652
- for item in items:
653
- author_string = item.get("authorString", "")
654
- authors = [clean_extracted_text(x) for x in author_string.split(",")[:8] if norm_text(x)]
655
- pmcid = item.get("pmcid", "")
656
- pdf_url = f"https://europepmc.org/articles/{pmcid}?pdf=render" if pmcid else ""
657
- landing_url = f"https://europepmc.org/article/PMC/{pmcid}" if pmcid else ""
658
- abstract = truncate_text(clean_extracted_text(item.get("abstractText", "")), MAX_ABSTRACT_CHARS)
659
- out.append({
660
- "id": item.get("id") or item.get("doi") or item.get("title"),
661
- "title": clean_extracted_text(item.get("title") or ""),
662
- "summary": abstract[:500],
663
- "abstract": abstract,
664
- "published": str(item.get("pubYear") or ""),
665
- "authors": authors,
666
- "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
667
- "url": landing_url,
668
- "pdf": pdf_url,
669
- "doi": normalize_doi(item.get("doi", "")),
670
- "venue": clean_extracted_text(item.get("journalTitle", "")),
671
- "year": str(item.get("pubYear") or ""),
672
- "source": "europe_pmc",
673
- "score": 0.78,
674
- "open_access": bool(pmcid),
675
- "external_ids": {"pmid": item.get("pmid"), "pmcid": pmcid},
676
- })
677
- return out
678
-
679
-
680
- def resolve_link(query):
681
- url = (query or "").strip()
682
- if not url:
683
- return []
684
- try:
685
- response = HTTP.get(url, allow_redirects=True, headers={"User-Agent": "dvnc-ai-space/2.0"})
686
- content_type = response.headers.get("content-type", "")
687
- if "pdf" in content_type or url.lower().endswith(".pdf"):
688
- name = Path(url.split("?")[0]).name or "linked-paper.pdf"
689
- return [{
690
- "id": url,
691
- "title": name,
692
- "summary": "Direct PDF link detected.",
693
- "abstract": "",
694
- "published": "",
695
- "authors": [],
696
- "authors_text": "Unknown authors",
697
- "url": url,
698
- "pdf": url,
699
- "doi": "",
700
- "venue": "Direct PDF",
701
- "year": "",
702
- "source": "link",
703
- "score": 0.66,
704
- "open_access": True,
705
- "external_ids": {},
706
- }]
707
- doi = ""
708
- title = url
709
- pdf_link = ""
710
- if BeautifulSoup is not None:
711
- soup = BeautifulSoup(response.text, "html.parser")
712
- title = clean_extracted_text(soup.title.text.strip()) if soup.title else url
713
- for meta_name in ["citation_doi", "dc.identifier", "dc.Identifier"]:
714
- tag = soup.find("meta", attrs={"name": meta_name})
715
- if tag and tag.get("content"):
716
- doi = normalize_doi(tag["content"].strip())
717
- break
718
- for a in soup.find_all("a", href=True):
719
- href = a["href"]
720
- if ".pdf" in href.lower():
721
- pdf_link = href if href.startswith("http") else urllib.parse.urljoin(url, href)
722
- break
723
- if doi:
724
- results = search_crossref(doi, mode="doi", max_results=1)
725
- if results:
726
- if pdf_link and not results[0].get("pdf"):
727
- results[0]["pdf"] = pdf_link
728
- if url and not results[0].get("url"):
729
- results[0]["url"] = url
730
- return results
731
- return [{
732
- "id": url,
733
- "title": title,
734
- "summary": "Landing page resolved from direct link.",
735
- "abstract": "",
736
- "published": "",
737
- "authors": [],
738
- "authors_text": "Unknown authors",
739
- "url": url,
740
- "pdf": pdf_link,
741
- "doi": doi,
742
- "venue": "Web Link",
743
- "year": "",
744
- "source": "link",
745
- "score": 0.54,
746
- "open_access": bool(pdf_link),
747
- "external_ids": {},
748
- }]
749
- except Exception as e:
750
- return [{
751
- "id": url,
752
- "title": "Link resolution error",
753
- "summary": str(e),
754
- "abstract": "",
755
- "published": "",
756
- "authors": [],
757
- "authors_text": "Unknown authors",
758
- "url": url,
759
- "pdf": "",
760
- "doi": "",
761
- "venue": "Link",
762
- "year": "",
763
- "source": "link",
764
- "score": 0.20,
765
- "open_access": None,
766
- "external_ids": {},
767
- }]
768
-
769
-
770
- def dedupe_papers(items: List[Dict]) -> List[Dict]:
771
- seen = {}
772
- for item in items:
773
- key = paper_identity_key(item) or f"{item.get('source', 'src')}::{item.get('title', 'paper')}"
774
- current = seen.get(key)
775
- candidate_score = float(item.get("learned_score", item.get("score", 0)))
776
- current_score = float(current.get("learned_score", current.get("score", 0))) if current else -1
777
- if current is None or candidate_score > current_score:
778
- seen[key] = item
779
- return sorted(seen.values(), key=lambda x: float(x.get("learned_score", x.get("score", 0))), reverse=True)
780
-
781
-
782
- def discover_papers(query, mode, sources, max_results=10):
783
- query = (query or "").strip()
784
- if not query:
785
- return []
786
- mode = detect_query_type(query) if mode == "autonomous_web" else mode
787
- selected_sources = ensure_list(sources) or DEFAULT_SOURCES
788
- results = []
789
-
790
- if mode == "link":
791
- return dedupe_papers(resolve_link(query))
792
-
793
- if "arxiv" in selected_sources and mode != "doi":
794
- try:
795
- results.extend(search_arxiv(query, max_results=min(max_results, GRAPH_MAX_RESULTS)))
796
- except Exception:
797
- pass
798
- if "crossref" in selected_sources:
799
- try:
800
- results.extend(search_crossref(query, mode=mode, max_results=min(max_results, GRAPH_MAX_RESULTS)))
801
- except Exception:
802
- pass
803
- if "openalex" in selected_sources:
804
- try:
805
- results.extend(search_openalex(query, mode=mode, max_results=min(max_results, GRAPH_MAX_RESULTS)))
806
- except Exception:
807
- pass
808
- if "semantic_scholar" in selected_sources:
809
- try:
810
- results.extend(search_semantic_scholar(query, mode=mode, max_results=min(max_results, GRAPH_MAX_RESULTS)))
811
- except Exception:
812
- pass
813
- if "europe_pmc" in selected_sources:
814
- try:
815
- results.extend(search_europe_pmc(query, mode=mode, max_results=min(max_results, GRAPH_MAX_RESULTS)))
816
- except Exception:
817
- pass
818
-
819
- papers = [enrich_paper_semantics(query, p) for p in dedupe_papers(results)]
820
- papers = sorted(papers, key=lambda x: float(x.get("learned_score", x.get("score", 0))), reverse=True)
821
- return papers[:max_results]
822
-
823
-
824
- def propose_expansion_queries(query: str, papers: List[Dict], parsed_state: Optional[Dict] = None, limit: int = GRAPH_MAX_EXPANSIONS) -> List[str]:
825
- concept_pool = []
826
- venue_pool = []
827
- for paper in papers[:8]:
828
- concept_pool.extend((paper.get("concepts") or [])[:4])
829
- if paper.get("venue"):
830
- venue_pool.append(paper["venue"])
831
- if parsed_state and isinstance(parsed_state, dict):
832
- concept_pool.extend((parsed_state.get("concepts") or [])[:6])
833
-
834
- ranked_concepts = [c for c, _ in Counter([norm_text(c).lower() for c in concept_pool if c]).most_common(limit * 2)]
835
- expansions = [norm_text(query)] if query else []
836
- for concept in ranked_concepts:
837
- if concept:
838
- expansions.append(f"{query} {concept}".strip())
839
- for venue in unique_keep_order(venue_pool)[:2]:
840
- if venue:
841
- expansions.append(f"{query} {venue}".strip())
842
- return unique_keep_order(expansions)[:limit]
843
-
844
-
845
- def frontier_expand(query: str, sources: List[str], selected_papers: List[Dict], parsed_state: Optional[Dict] = None, per_query: int = 4) -> List[Dict]:
846
- seed_concepts = []
847
- for p in selected_papers[:6]:
848
- seed_concepts.extend((p.get("concepts") or [])[:4])
849
- if parsed_state and isinstance(parsed_state, dict):
850
- seed_concepts.extend((parsed_state.get("concepts") or [])[:6])
851
-
852
- expansion_queries = propose_expansion_queries(query, selected_papers, parsed_state=parsed_state, limit=GRAPH_MAX_EXPANSIONS)
853
- frontier = []
854
- for eq in expansion_queries:
855
- try:
856
- items = discover_papers(eq, "topic", sources, max_results=per_query)
857
- for item in items:
858
- frontier.append(score_frontier_candidate(query or eq, seed_concepts, item))
859
- except Exception:
860
- continue
861
-
862
- frontier = dedupe_papers(frontier)
863
- frontier.sort(key=lambda x: float(x.get("frontier_score", x.get("learned_score", x.get("score", 0)))), reverse=True)
864
- GRAPH_MEMORY["frontier"] = frontier[: GRAPH_MAX_EXPANSIONS * per_query]
865
- return GRAPH_MEMORY["frontier"]
866
-
867
-
868
- def paper_choice_value(index: int, paper: Dict) -> str:
869
- doi = normalize_doi(paper.get("doi") or "")
870
- title_slug = slugify(paper.get("title", ""))[:40]
871
- return f"{index}|{doi}|{title_slug}"
872
-
873
-
874
- def paper_choice_label(index: int, paper: Dict) -> str:
875
- score = round(float(paper.get("learned_score", paper.get("score", 0))), 3)
876
- title = paper.get("title", "Untitled")
877
- authors_text = paper.get("authors_text", "Unknown authors")[:90]
878
- source = paper.get("source", "src")
879
- return f"[{source}] {title} — {authors_text} — score {score}"
880
-
881
-
882
- def format_selection_choices(papers):
883
- return [(paper_choice_label(i, paper), paper_choice_value(i, paper)) for i, paper in enumerate(papers)]
884
-
885
-
886
- def format_papers_html(papers):
887
- if not papers:
888
- return '<div class="panel papers-panel" style="padding:18px"><p>No papers found yet.</p></div>'
889
-
890
- items = []
891
- for i, paper in enumerate(papers, start=1):
892
- summary = safe_text((paper.get("summary") or paper.get("abstract") or "")[:320])
893
- doi_line = f'<span class="paper-badge doi-badge">{safe_text(paper.get("doi"))}</span>' if paper.get("doi") else ""
894
- pdf_link = paper.get("pdf") or "#"
895
- abs_link = paper.get("url") or "#"
896
- concepts_text = ", ".join((paper.get("concepts") or [])[:5])
897
-
898
- items.append(
899
- f"""
900
- <article class="paper-card">
901
- <div class="paper-topline">
902
- <span class="paper-badge">{safe_text(paper.get('source', 'paper'))}</span>
903
- <span class="paper-badge alt">{safe_text(paper.get('published', '') or 'Paper')}</span>
904
- {doi_line}
905
- </div>
906
- <h4>{i}. {safe_text(paper.get('title', 'Untitled'))}</h4>
907
- <p>{summary or 'No abstract snippet available.'}</p>
908
- <div class="paper-meta-stack">
909
- <div><strong>Authors:</strong> {safe_text(paper.get('authors_text', 'Unknown authors'))}</div>
910
- <div><strong>Venue:</strong> {safe_text(paper.get('venue', 'Unknown venue'))}</div>
911
- <div><strong>Learned score:</strong> {safe_text(round(float(paper.get('learned_score', paper.get('score', 0))), 3))}</div>
912
- <div><strong>Concepts:</strong> {safe_text(concepts_text or 'None extracted')}</div>
913
- </div>
914
- <div class="paper-links">
915
- <a href="{safe_text(abs_link)}" target="_blank" rel="noopener noreferrer">Abstract</a>
916
- <a href="{safe_text(pdf_link)}" target="_blank" rel="noopener noreferrer">PDF</a>
917
- </div>
918
- </article>
919
- """
920
- )
921
- return '<div class="papers-grid">' + ''.join(items) + '</div>'
922
-
923
-
924
- def format_frontier_html(frontier):
925
- if not frontier:
926
- return '<div class="panel papers-panel" style="padding:18px"><p>No autonomous expansion candidates yet.</p></div>'
927
- cards = []
928
- for i, paper in enumerate(frontier[:12], start=1):
929
- cards.append(
930
- f"""
931
- <article class="paper-card frontier-card">
932
- <div class="paper-topline">
933
- <span class="paper-badge">frontier</span>
934
- <span class="paper-badge alt">{safe_text(paper.get('source', 'paper'))}</span>
935
- </div>
936
- <h4>{i}. {safe_text(paper.get('title', 'Untitled'))}</h4>
937
- <p>{safe_text((paper.get('summary') or paper.get('abstract') or '')[:280])}</p>
938
- <div class="paper-meta-stack">
939
- <div><strong>Frontier score:</strong> {safe_text(paper.get('frontier_score', paper.get('learned_score', paper.get('score', 0))))}</div>
940
- <div><strong>Concept overlap:</strong> {safe_text(paper.get('frontier_concept_overlap', 0))}</div>
941
- <div><strong>Authors:</strong> {safe_text(paper.get('authors_text', 'Unknown authors'))}</div>
942
- </div>
943
- </article>
944
- """
945
- )
946
- return '<div class="papers-grid">' + ''.join(cards) + '</div>'
947
-
948
-
949
- def uploaded_pdf_summary(file_obj):
950
- if not file_obj:
951
- return "No PDF uploaded yet."
952
- path = getattr(file_obj, "name", None) or str(file_obj)
953
- p = Path(path)
954
- return f"Uploaded PDF ready for ingestion: {p.name}. Use Parse uploaded PDF to extract title, abstract, sections, references, concepts, and claims."
955
-
956
-
957
- def graph_kind_style(kind: str) -> Dict[str, Any]:
958
- palette = {
959
- "query": {"color": "#1f8ef1", "size": 14, "label": "Research topic"},
960
- "paper": {"color": "#00c49a", "size": 10, "label": "Paper"},
961
- "upload": {"color": "#ff9f43", "size": 11, "label": "Uploaded PDF"},
962
- "concept": {"color": "#a66cff", "size": 8, "label": "Concept"},
963
- "author": {"color": "#f368e0", "size": 7, "label": "Author"},
964
- "claim": {"color": "#ff6b6b", "size": 8, "label": "Claim"},
965
- "reference": {"color": "#6c757d", "size": 7, "label": "Reference"},
966
- "frontier": {"color": "#ffd166", "size": 8, "label": "Frontier candidate"},
967
- }
968
- return palette.get(kind, {"color": "#9aa0a6", "size": 7, "label": kind.title()})
969
-
970
-
971
- def summarize_graph(nodes: List[Dict], edges: List[Dict]) -> Dict[str, Any]:
972
- counts = Counter((n.get("kind") or n.get("type") or "unknown").lower() for n in nodes)
973
- return {"nodes": len(nodes), "edges": len(edges), "counts": dict(counts)}
974
-
975
-
976
- def _prepare_3d_graph_data(nodes: List[Dict], edges: List[Dict], title: str) -> Dict[str, Any]:
977
- node_out = []
978
- for node in nodes:
979
- kind = (node.get("kind") or node.get("type") or "paper").lower()
980
- if kind == "topic":
981
- kind = "query"
982
- if kind == "uploadedpdf":
983
- kind = "upload"
984
- if kind == "frontierpaper":
985
- kind = "frontier"
986
- style = graph_kind_style(kind)
987
- node_out.append({
988
- "id": node.get("id"),
989
- "label": truncate_text(node.get("label") or node.get("title") or node.get("id") or "node", 120),
990
- "kind": kind,
991
- "color": style["color"],
992
- "val": style["size"],
993
- "detail": {
994
- "kind": style["label"],
995
- "title": node.get("title") or node.get("label") or node.get("id"),
996
- "venue": node.get("venue") or "",
997
- "year": node.get("year") or "",
998
- "doi": node.get("doi") or "",
999
- "source": node.get("source") or "",
1000
- "authors_text": node.get("authors_text") or "",
1001
- "text": node.get("text") or "",
1002
- },
1003
- })
1004
- edge_out = []
1005
- for edge in edges:
1006
- edge_out.append({
1007
- "source": edge.get("source"),
1008
- "target": edge.get("target"),
1009
- "type": edge.get("type") or "RELATES_TO",
1010
- "label": edge.get("type") or "RELATES_TO",
1011
- })
1012
- return {"title": title, "nodes": node_out, "links": edge_out, "summary": summarize_graph(nodes, edges)}
1013
-
1014
-
1015
- def build_learning_graph_html(nodes, edges, title="Self-Learning Knowledge Graph"):
1016
- if not nodes:
1017
- return """
1018
- <div class="panel brain-shell" style="overflow:auto; max-width:100%;">
1019
- <div class="brain-header">
1020
- <div>
1021
- <p class="eyebrow">Learning Graph</p>
1022
- <h3>Self-Learning Knowledge Graph</h3>
1023
- </div>
1024
- </div>
1025
- <div class="brain-stage learning-empty" style="min-height:420px; overflow:auto;">
1026
- <div class="empty-graph-copy">
1027
- <h4>No papers mapped yet</h4>
1028
- <p>Search papers, select candidates, or upload a PDF to grow the graph in an interactive 3D view.</p>
1029
- </div>
1030
- </div>
1031
- </div>
1032
- """
1033
-
1034
- graph_data = _prepare_3d_graph_data(nodes, edges, title)
1035
- payload_json = json.dumps(graph_data, ensure_ascii=False)
1036
-
1037
- iframe_html = f"""
1038
- <!doctype html>
1039
- <html>
1040
- <head>
1041
- <meta charset="utf-8" />
1042
- <meta name="viewport" content="width=device-width, initial-scale=1" />
1043
- <style>
1044
- html, body {{ margin:0; height:100%; background:#0b1020; color:#eef2ff; font-family: Inter, ui-sans-serif, system-ui, sans-serif; overflow:hidden; }}
1045
- #wrap {{ position:relative; width:100%; height:100%; background:radial-gradient(circle at top, #18213c 0%, #0b1020 60%, #060910 100%); }}
1046
- #graph {{ position:absolute; inset:0; }}
1047
- .overlay {{
1048
- position:absolute; left:16px; top:16px; z-index:10; max-width:min(460px, calc(100% - 32px));
1049
- padding:14px 16px; border:1px solid rgba(255,255,255,.12); border-radius:16px;
1050
- background:rgba(10,14,28,.72); backdrop-filter: blur(14px); box-shadow:0 12px 28px rgba(0,0,0,.28);
1051
- }}
1052
- .overlay h3 {{ margin:0 0 6px; font-size:18px; line-height:1.2; }}
1053
- .overlay p {{ margin:0; font-size:13px; color:#cbd5e1; line-height:1.5; }}
1054
- .legend {{ display:flex; flex-wrap:wrap; gap:8px 10px; margin-top:10px; }}
1055
- .chip {{
1056
- display:inline-flex; align-items:center; gap:8px; padding:6px 10px; border-radius:999px; font-size:12px;
1057
- color:#e2e8f0; background:rgba(255,255,255,.06); border:1px solid rgba(255,255,255,.08);
1058
- }}
1059
- .dot {{ width:10px; height:10px; border-radius:999px; display:inline-block; }}
1060
- .panel {{
1061
- position:absolute; right:16px; top:16px; z-index:10; width:min(360px, calc(100% - 32px));
1062
- max-height:calc(100% - 32px); overflow:auto; padding:14px 16px; border:1px solid rgba(255,255,255,.12);
1063
- border-radius:16px; background:rgba(10,14,28,.72); backdrop-filter: blur(14px); box-shadow:0 12px 28px rgba(0,0,0,.28);
1064
- }}
1065
- .panel h4 {{ margin:0 0 8px; font-size:14px; color:#f8fafc; }}
1066
- .panel p {{ margin:0; font-size:12px; color:#cbd5e1; line-height:1.5; }}
1067
- .panel dl {{ margin:12px 0 0; display:grid; grid-template-columns:auto 1fr; gap:6px 10px; font-size:12px; }}
1068
- .panel dt {{ color:#93c5fd; }}
1069
- .panel dd {{ margin:0; color:#e2e8f0; word-break:break-word; }}
1070
- .stats {{ margin-top:10px; display:grid; grid-template-columns:repeat(3,1fr); gap:8px; }}
1071
- .stat {{ padding:8px 10px; border-radius:12px; background:rgba(255,255,255,.06); border:1px solid rgba(255,255,255,.08); }}
1072
- .stat strong {{ display:block; font-size:15px; color:#fff; }}
1073
- .stat span {{ font-size:11px; color:#cbd5e1; }}
1074
- .hint {{
1075
- position:absolute; left:16px; bottom:16px; z-index:10; padding:10px 12px; border-radius:12px;
1076
- font-size:12px; color:#dbeafe; background:rgba(15,23,42,.75); border:1px solid rgba(255,255,255,.08);
1077
- }}
1078
- </style>
1079
- <script src="https://unpkg.com/three@0.160.0/build/three.min.js"></script>
1080
- <script src="https://unpkg.com/3d-force-graph"></script>
1081
- </head>
1082
- <body>
1083
- <div id="wrap">
1084
- <div id="graph"></div>
1085
- <div class="overlay">
1086
- <h3></h3>
1087
- <p>Drag the background to orbit, scroll to zoom, right-drag to pan, and drag a node to move or pin it in 3D space.</p>
1088
- <div class="legend" id="legend"></div>
1089
- <div class="stats" id="stats"></div>
1090
- </div>
1091
- <div class="panel" id="panel">
1092
- <h4>Node details</h4>
1093
- <p>Click any node to inspect its label, type, venue, DOI, year, and source.</p>
1094
- </div>
1095
- <div class="hint">Interactive 3D graph: orbit, zoom, pan, drag nodes.</div>
1096
- </div>
1097
- <script>
1098
- const payload = {payload_json};
1099
- document.querySelector('.overlay h3').textContent = payload.title || 'Self-Learning Knowledge Graph';
1100
-
1101
- const legendEl = document.getElementById('legend');
1102
- const statEl = document.getElementById('stats');
1103
- const panelEl = document.getElementById('panel');
1104
-
1105
- const kindMap = {{
1106
- query: ['Research topic', '#1f8ef1'],
1107
- paper: ['Paper', '#00c49a'],
1108
- upload: ['Uploaded PDF', '#ff9f43'],
1109
- concept: ['Concept', '#a66cff'],
1110
- author: ['Author', '#f368e0'],
1111
- claim: ['Claim', '#ff6b6b'],
1112
- reference: ['Reference', '#6c757d'],
1113
- frontier: ['Frontier candidate', '#ffd166']
1114
- }};
1115
-
1116
- Object.entries(kindMap).forEach(([key, value]) => {{
1117
- const chip = document.createElement('div');
1118
- chip.className = 'chip';
1119
- chip.innerHTML = `<span class="dot" style="background:${{value[1]}}"></span>${{value[0]}}`;
1120
- legendEl.appendChild(chip);
1121
- }});
1122
-
1123
- const summary = payload.summary || {{nodes: payload.nodes.length, edges: payload.links.length, counts: {{}}}};
1124
- [['Nodes', summary.nodes], ['Edges', summary.edges], ['Types', Object.keys(summary.counts || {{}}).length]].forEach(item => {{
1125
- const box = document.createElement('div');
1126
- box.className = 'stat';
1127
- box.innerHTML = `<strong>${{item[1]}}</strong><span>${{item[0]}}</span>`;
1128
- statEl.appendChild(box);
1129
- }});
1130
-
1131
- function roundRect(ctx, x, y, width, height, radius) {{
1132
- ctx.beginPath();
1133
- ctx.moveTo(x + radius, y);
1134
- ctx.lineTo(x + width - radius, y);
1135
- ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
1136
- ctx.lineTo(x + width, y + height - radius);
1137
- ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
1138
- ctx.lineTo(x + radius, y + height);
1139
- ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
1140
- ctx.lineTo(x, y + radius);
1141
- ctx.quadraticCurveTo(x, y, x + radius, y);
1142
- ctx.closePath();
1143
- }}
1144
-
1145
- function makeTextSprite(text) {{
1146
- const canvas = document.createElement('canvas');
1147
- const ctx = canvas.getContext('2d');
1148
- const fontSize = 28;
1149
- ctx.font = `600 ${{fontSize}}px Inter, Arial, sans-serif`;
1150
- const width = Math.min(900, Math.max(180, ctx.measureText(text).width + 28));
1151
- canvas.width = width;
1152
- canvas.height = 52;
1153
- ctx.font = `600 ${{fontSize}}px Inter, Arial, sans-serif`;
1154
- ctx.fillStyle = 'rgba(7,12,24,0.78)';
1155
- roundRect(ctx, 0, 0, width, 52, 18);
1156
- ctx.fill();
1157
- ctx.fillStyle = '#f8fafc';
1158
- ctx.textBaseline = 'middle';
1159
- ctx.fillText(text.slice(0, 64), 14, 26);
1160
- const texture = new THREE.CanvasTexture(canvas);
1161
- const material = new THREE.SpriteMaterial({{ map: texture, transparent: true }});
1162
- const sprite = new THREE.Sprite(material);
1163
- sprite.scale.set(width / 10, 5.2, 1);
1164
- sprite.position.set(0, 10, 0);
1165
- return sprite;
1166
- }}
1167
-
1168
- function nodeObject(node) {{
1169
- const group = new THREE.Group();
1170
- const geo = new THREE.SphereGeometry(Math.max(2.8, (node.val || 6) * 0.5), 18, 18);
1171
- const mat = new THREE.MeshStandardMaterial({{ color: node.color || '#94a3b8', metalness: 0.15, roughness: 0.45 }});
1172
- const sphere = new THREE.Mesh(geo, mat);
1173
- group.add(sphere);
1174
- group.add(makeTextSprite(node.label || node.id));
1175
- return group;
1176
- }}
1177
-
1178
- function renderPanel(node, pinned) {{
1179
- const d = node.detail || {{}};
1180
- panelEl.innerHTML = `
1181
- <h4>Node details</h4>
1182
- <dl>
1183
- <dt>Label</dt><dd>${{node.label || ''}}</dd>
1184
- <dt>Type</dt><dd>${{d.kind || node.kind || ''}}</dd>
1185
- <dt>Venue</dt><dd>${{d.venue || '—'}}</dd>
1186
- <dt>Year</dt><dd>${{d.year || '—'}}</dd>
1187
- <dt>DOI</dt><dd>${{d.doi || '—'}}</dd>
1188
- <dt>Source</dt><dd>${{d.source || '—'}}</dd>
1189
- <dt>Authors</dt><dd>${{d.authors_text || '—'}}</dd>
1190
- <dt>Text</dt><dd>${{d.text || '—'}}</dd>
1191
- <dt>Pinned</dt><dd>${{pinned ? 'Yes' : (node.fx != null ? 'Yes' : 'No')}}</dd>
1192
- </dl>`;
1193
- }}
1194
-
1195
- const elem = document.getElementById('graph');
1196
- const Graph = ForceGraph3D()(elem)
1197
- .backgroundColor('#00000000')
1198
- .graphData(payload)
1199
- .nodeRelSize(6)
1200
- .nodeOpacity(1)
1201
- .nodeLabel(node => `<div style="padding:6px 8px"><strong>${{node.label}}</strong><br/>${{(node.detail || {{}}).kind || node.kind}}</div>`)
1202
- .nodeThreeObject(nodeObject)
1203
- .linkColor(link => {{
1204
- const m = {{
1205
- ABOUT:'#4dabf7',
1206
- MENTIONS:'#b197fc',
1207
- WRITTEN_BY:'#f783ac',
1208
- ASSERTS:'#ff8787',
1209
- CITES:'#adb5bd',
1210
- FRONTIER:'#ffe066',
1211
- FRONTIER_CANDIDATE:'#ffe066',
1212
- UPLOADED_SOURCE:'#ffa94d'
1213
- }};
1214
- return m[link.type] || 'rgba(226,232,240,0.42)';
1215
- }})
1216
- .linkWidth(link => ['ABOUT','UPLOADED_SOURCE','FRONTIER','FRONTIER_CANDIDATE'].includes(link.type) ? 2.6 : 1.35)
1217
- .linkDirectionalParticles(link => ['ABOUT','FRONTIER','FRONTIER_CANDIDATE'].includes(link.type) ? 2 : 0)
1218
- .linkDirectionalParticleWidth(2.2)
1219
- .cooldownTicks(140)
1220
- .d3VelocityDecay(0.24)
1221
- .d3Force('charge').strength(-180)
1222
- .onNodeClick(node => {{
1223
- const distance = 90;
1224
- const distRatio = 1 + distance / Math.hypot(node.x || 1, node.y || 1, node.z || 1);
1225
- Graph.cameraPosition(
1226
- {{ x: (node.x || 0) * distRatio, y: (node.y || 0) * distRatio, z: (node.z || 0) * distRatio }},
1227
- node,
1228
- 900
1229
- );
1230
- renderPanel(node);
1231
- }})
1232
- .onNodeDragEnd(node => {{
1233
- node.fx = node.x;
1234
- node.fy = node.y;
1235
- node.fz = node.z;
1236
- renderPanel(node, true);
1237
- }});
1238
-
1239
- Graph.scene().add(new THREE.AmbientLight(0xffffff, 1.1));
1240
- const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
1241
- dirLight.position.set(120, 120, 120);
1242
- Graph.scene().add(dirLight);
1243
- Graph.cameraPosition({{ z: 210 }});
1244
-
1245
- window.addEventListener('resize', () => {{
1246
- Graph.width(elem.clientWidth);
1247
- Graph.height(elem.clientHeight);
1248
- }});
1249
- </script>
1250
- </body>
1251
- </html>
1252
- """
1253
- return f"""
1254
- <div class="panel brain-shell" style="overflow:auto; max-width:100%;">
1255
- <iframe
1256
- title="{safe_text(title)}"
1257
- style="width:100%; height:{GRAPH_IFRAME_HEIGHT}px; border:0; border-radius:18px; overflow:auto; background:#0b1020;"
1258
- sandbox="allow-scripts allow-same-origin"
1259
- srcdoc="{html.escape(iframe_html, quote=True)}"
1260
- ></iframe>
1261
- </div>
1262
- """
1263
-
1264
-
1265
- def build_learning_graph_state(query, papers, uploaded_name=None):
1266
- nodes = [{"id": "query", "label": query or "Research topic", "kind": "query"}]
1267
- edges = []
1268
-
1269
- for i, paper in enumerate(papers[:6], start=1):
1270
- pid = f"paper_{i}"
1271
- nodes.append({
1272
- "id": pid,
1273
- "label": paper.get("title", f"Paper {i}"),
1274
- "kind": "paper",
1275
- "title": paper.get("title"),
1276
- "venue": paper.get("venue"),
1277
- "year": paper.get("year"),
1278
- "source": paper.get("source"),
1279
- "authors_text": paper.get("authors_text"),
1280
- })
1281
- edges.append({"source": "query", "target": pid, "type": "ABOUT"})
1282
- for concept in (paper.get("concepts") or [])[:3]:
1283
- cid = f"concept_{i}_{slugify(concept)[:30]}"
1284
- nodes.append({"id": cid, "label": concept, "kind": "concept"})
1285
- edges.append({"source": pid, "target": cid, "type": "MENTIONS"})
1286
-
1287
- if uploaded_name:
1288
- nodes.append({"id": "upload", "label": uploaded_name, "kind": "upload"})
1289
- edges.append({"source": "query", "target": "upload", "type": "UPLOADED_SOURCE"})
1290
- return nodes, edges
1291
-
1292
-
1293
- def graph_from_selected(query, selected_papers, uploaded_name=None, parsed_state=None, frontier=None):
1294
- nodes = [{"id": "query", "label": query or "Research topic", "kind": "query"}]
1295
- edges = []
1296
-
1297
- for i, paper in enumerate(selected_papers[:8], start=1):
1298
- pid = f"paper_{i}"
1299
- nodes.append({
1300
- "id": pid,
1301
- "label": paper.get("title", f"Paper {i}"),
1302
- "kind": "paper",
1303
- "title": paper.get("title"),
1304
- "venue": paper.get("venue"),
1305
- "year": paper.get("year"),
1306
- "doi": paper.get("doi"),
1307
- "source": paper.get("source"),
1308
- "authors_text": paper.get("authors_text"),
1309
- })
1310
- edges.append({"source": "query", "target": pid, "type": "ABOUT"})
1311
-
1312
- for author in paper.get("authors", [])[:3]:
1313
- aid = f"author_{i}_{slugify(author)[:30]}"
1314
- nodes.append({"id": aid, "label": author, "kind": "author"})
1315
- edges.append({"source": pid, "target": aid, "type": "WRITTEN_BY"})
1316
-
1317
- for concept in (paper.get("concepts") or [])[:4]:
1318
- cid = f"concept_{i}_{slugify(concept)[:30]}"
1319
- nodes.append({"id": cid, "label": concept, "kind": "concept"})
1320
- edges.append({"source": pid, "target": cid, "type": "MENTIONS"})
1321
-
1322
- for claim in (paper.get("claims") or [])[:2]:
1323
- cid = f"claim_{i}_{slugify(claim)[:30]}"
1324
- nodes.append({"id": cid, "label": truncate_text(claim, 82), "kind": "claim", "text": claim})
1325
- edges.append({"source": pid, "target": cid, "type": "ASSERTS"})
1326
-
1327
- if uploaded_name:
1328
- nodes.append({"id": "upload", "label": uploaded_name, "kind": "upload", "title": uploaded_name})
1329
- edges.append({"source": "query", "target": "upload", "type": "UPLOADED_SOURCE"})
1330
- if parsed_state and isinstance(parsed_state, dict):
1331
- for concept in (parsed_state.get("concepts") or [])[:4]:
1332
- cid = f"upload_concept_{slugify(concept)[:30]}"
1333
- nodes.append({"id": cid, "label": concept, "kind": "concept"})
1334
- edges.append({"source": "upload", "target": cid, "type": "MENTIONS"})
1335
- for claim in (parsed_state.get("claims") or [])[:3]:
1336
- cid = f"upload_claim_{slugify(claim)[:30]}"
1337
- nodes.append({"id": cid, "label": truncate_text(claim, 82), "kind": "claim", "text": claim})
1338
- edges.append({"source": "upload", "target": cid, "type": "ASSERTS"})
1339
- for ref in (parsed_state.get("references") or [])[:6]:
1340
- if ref.get("title"):
1341
- rid = f"ref_{slugify(ref.get('title'))[:30]}"
1342
- nodes.append({"id": rid, "label": truncate_text(ref.get("title"), 82), "kind": "reference", "doi": ref.get("doi", "")})
1343
- edges.append({"source": "upload", "target": rid, "type": "CITES"})
1344
-
1345
- for j, fp in enumerate(ensure_list(frontier)[:6], start=1):
1346
- fid = f"frontier_{j}"
1347
- nodes.append({
1348
- "id": fid,
1349
- "label": fp.get("title", f"Frontier {j}"),
1350
- "kind": "frontier",
1351
- "title": fp.get("title"),
1352
- "source": fp.get("source"),
1353
- "authors_text": fp.get("authors_text"),
1354
- "year": fp.get("year"),
1355
- "doi": fp.get("doi"),
1356
- })
1357
- edges.append({"source": "query", "target": fid, "type": "FRONTIER_CANDIDATE"})
1358
-
1359
- dedup_nodes = []
1360
- seen = set()
1361
- for node in nodes:
1362
- if node["id"] not in seen:
1363
- seen.add(node["id"])
1364
- dedup_nodes.append(node)
1365
- return dedup_nodes, edges
1366
-
1367
-
1368
- def extract_references_from_text(text: str) -> List[Dict[str, str]]:
1369
- refs = []
1370
- seen = set()
1371
- lines = [norm_text(x) for x in clean_extracted_text(text).splitlines() if norm_text(x)]
1372
- ref_lines = lines[-250:]
1373
- doi_re = re.compile(r"10\.\d{4,9}/[-._;()/:A-Z0-9]+", re.I)
1374
-
1375
- for line in ref_lines:
1376
- doi_match = doi_re.search(line)
1377
- title = line
1378
- doi = doi_match.group(0) if doi_match else ""
1379
- if doi:
1380
- title = line.replace(doi, "").strip(" .;,-")
1381
- if len(title) < 12:
1382
- continue
1383
- key = (title.lower(), doi.lower())
1384
- if key in seen:
1385
- continue
1386
- seen.add(key)
1387
- refs.append({"title": truncate_text(title, 240), "doi": normalize_doi(doi)})
1388
- if len(refs) >= 40:
1389
- break
1390
- return refs
1391
-
1392
-
1393
- def parse_pdf_with_grobid(pdf_path):
1394
- if not GROBID_URL:
1395
- raise RuntimeError("GROBID_URL is not set")
1396
-
1397
- with open(pdf_path, "rb") as f:
1398
- files = {"input": (Path(pdf_path).name, f, "application/pdf")}
1399
- response = HTTP.post(
1400
- f"{GROBID_URL.rstrip('/')}/api/processFulltextDocument",
1401
- files=files,
1402
- data={"includeRawAffiliations": "1", "segmentSentences": "1"},
1403
- timeout=180,
1404
- )
1405
- response.raise_for_status()
1406
-
1407
- root = ET.fromstring(response.text)
1408
- ns = {"tei": "http://www.tei-c.org/ns/1.0"}
1409
-
1410
- title = clean_extracted_text(root.findtext(".//tei:titleStmt/tei:title", default="", namespaces=ns) or Path(pdf_path).name)
1411
-
1412
- abstract_parts = []
1413
- for p in root.findall(".//tei:profileDesc/tei:abstract//tei:p", ns):
1414
- abstract_parts.append(clean_extracted_text(" ".join(list(p.itertext()))))
1415
- abstract = truncate_text(" ".join(abstract_parts), MAX_ABSTRACT_CHARS)
1416
-
1417
- authors = []
1418
- for author in root.findall(".//tei:sourceDesc//tei:author", ns):
1419
- parts = []
1420
- for forename in author.findall(".//tei:forename", ns):
1421
- parts.append(norm_text(" ".join(forename.itertext())))
1422
- for surname in author.findall(".//tei:surname", ns):
1423
- parts.append(norm_text(" ".join(surname.itertext())))
1424
- name = clean_extracted_text(" ".join(parts))
1425
- if name:
1426
- authors.append(name)
1427
-
1428
- sections = []
1429
- text_pool = []
1430
- for div in root.findall(".//tei:text//tei:body//tei:div", ns):
1431
- head = clean_extracted_text(div.findtext("./tei:head", default="", namespaces=ns) or "Section")
1432
- paras = []
1433
- for p in div.findall(".//tei:p", ns):
1434
- para_text = clean_extracted_text(" ".join(list(p.itertext())))
1435
- if para_text:
1436
- paras.append(para_text)
1437
- joined = "\n".join(paras)
1438
- if head or joined:
1439
- sections.append({"heading": head or "Section", "text": truncate_text(joined, 5000)})
1440
- if joined:
1441
- text_pool.append(joined)
1442
-
1443
- references = []
1444
- for bibl in root.findall(".//tei:listBibl//tei:biblStruct", ns)[:80]:
1445
- ref_title = clean_extracted_text(bibl.findtext(".//tei:title", default="", namespaces=ns) or "")
1446
- ref_doi = ""
1447
- for idno in bibl.findall(".//tei:idno", ns):
1448
- if (idno.attrib.get("type") or "").lower() == "doi":
1449
- ref_doi = norm_text(" ".join(idno.itertext()))
1450
- break
1451
- if ref_title:
1452
- references.append({"title": ref_title, "doi": normalize_doi(ref_doi)})
1453
-
1454
- semantic_text = truncate_text(" ".join([title, abstract] + text_pool[:6]), MAX_RAW_TEXT_CHARS)
1455
- return {
1456
- "parser": "grobid",
1457
- "title": title,
1458
- "abstract": abstract,
1459
- "authors": authors[:12],
1460
- "sections": sections[:16],
1461
- "references": references[:60],
1462
- "claims": extract_claim_like_sentences(semantic_text, max_items=GRAPH_MAX_CLAIMS),
1463
- "concepts": extract_concepts_from_text(semantic_text, max_terms=GRAPH_MAX_CONCEPTS),
1464
- "raw_text": "",
1465
- "parser_quality": "scholarly-structured",
1466
- }
1467
-
1468
-
1469
- def parse_pdf_with_pymupdf(pdf_path):
1470
- if fitz is None:
1471
- raise RuntimeError("PyMuPDF not installed")
1472
-
1473
- doc = fitz.open(pdf_path)
1474
- page_texts = [page.get_text("text") for page in doc]
1475
- raw_text = truncate_text(clean_extracted_text("\n".join(page_texts).strip()), MAX_RAW_TEXT_CHARS)
1476
- first_page = clean_extracted_text("\n".join(page_texts[:2]))[:5000]
1477
-
1478
- title = extract_title_from_text(first_page, fallback=Path(pdf_path).name)
1479
-
1480
- abstract = ""
1481
- match = re.search(r"abstract\s*(.+?)(?:\n\s*\n|\n(?:1|i)[\.\s]|\nintroduction)", raw_text, re.I | re.S)
1482
- if match:
1483
- abstract = truncate_text(clean_extracted_text(match.group(1)), 2600)
1484
-
1485
- sections = []
1486
- blocks = re.split(r"\n(?=[A-Z][A-Za-z\s]{2,40}\n)", raw_text)
1487
- for block in blocks[:10]:
1488
- lines = [norm_text(x) for x in block.splitlines() if norm_text(x)]
1489
- if not lines:
1490
- continue
1491
- heading = lines[0] if len(lines[0]) < 60 else "Section"
1492
- body = " ".join(lines[1:] if len(lines) > 1 else lines)
1493
- if len(body) > 80:
1494
- sections.append({"heading": clean_extracted_text(heading), "text": truncate_text(body, 4200)})
1495
-
1496
- return {
1497
- "parser": "pymupdf",
1498
- "title": title,
1499
- "abstract": abstract,
1500
- "authors": [],
1501
- "sections": sections[:12] or ([{"heading": "Full Text", "text": raw_text[:12000]}] if raw_text else []),
1502
- "references": extract_references_from_text(raw_text),
1503
- "claims": extract_claim_like_sentences(raw_text, max_items=GRAPH_MAX_CLAIMS),
1504
- "concepts": extract_concepts_from_text(" ".join([title, abstract, raw_text[:18000]]), max_terms=GRAPH_MAX_CONCEPTS),
1505
- "raw_text": raw_text,
1506
- "parser_quality": "text-fallback-cleaned",
1507
- }
1508
-
1509
-
1510
- def parse_pdf_with_docling(pdf_path):
1511
- try:
1512
- from docling.document_converter import DocumentConverter
1513
- except Exception as e:
1514
- raise RuntimeError(f"Docling import failed: {e}")
1515
-
1516
- converter = DocumentConverter()
1517
- result = converter.convert(pdf_path)
1518
- doc = result.document
1519
- markdown = truncate_text(clean_extracted_text(doc.export_to_markdown()), MAX_RAW_TEXT_CHARS)
1520
- title = extract_title_from_text(markdown, fallback=Path(pdf_path).name)
1521
-
1522
- sections = []
1523
- chunks = re.split(r"\n(?=# )", markdown)
1524
- for chunk in chunks[:12]:
1525
- lines = [norm_text(x.lstrip('#')) for x in chunk.splitlines() if norm_text(x)]
1526
- if not lines:
1527
- continue
1528
- heading = lines[0][:80]
1529
- body = " ".join(lines[1:])
1530
- if body:
1531
- sections.append({"heading": heading, "text": truncate_text(body, 4200)})
1532
-
1533
- return {
1534
- "parser": "docling",
1535
- "title": title,
1536
- "abstract": "",
1537
- "authors": [],
1538
- "sections": sections[:12] or ([{"heading": "Document", "text": markdown[:12000]}] if markdown else []),
1539
- "references": extract_references_from_text(markdown),
1540
- "claims": extract_claim_like_sentences(markdown, max_items=GRAPH_MAX_CLAIMS),
1541
- "concepts": extract_concepts_from_text(markdown, max_terms=GRAPH_MAX_CONCEPTS),
1542
- "raw_text": markdown,
1543
- "parser_quality": "layout-aware-cleaned",
1544
- }
1545
-
1546
-
1547
- def parse_uploaded_pdf(file_obj, parser_order):
1548
- if not file_obj:
1549
- return "### PDF parse status\n\nNo PDF uploaded yet.", {}
1550
-
1551
- path = getattr(file_obj, "name", None) or str(file_obj)
1552
- parser_order = ensure_list(parser_order) or PDF_PARSERS
1553
- errors = []
1554
-
1555
- for parser_name in parser_order:
1556
- try:
1557
- if parser_name == "grobid":
1558
- result = parse_pdf_with_grobid(path)
1559
- elif parser_name == "docling":
1560
- result = parse_pdf_with_docling(path)
1561
- elif parser_name == "pymupdf":
1562
- result = parse_pdf_with_pymupdf(path)
1563
- else:
1564
- continue
1565
-
1566
- summary = (
1567
- f"### PDF parse status\n\n"
1568
- f"- Parser used: {result['parser']}\n"
1569
- f"- Parser quality: {result.get('parser_quality', 'unknown')}\n"
1570
- f"- Title: {result.get('title') or 'Unknown'}\n"
1571
- f"- Authors: {', '.join(result.get('authors')[:6]) if result.get('authors') else 'Unknown'}\n"
1572
- f"- Abstract found: {'Yes' if result.get('abstract') else 'No'}\n"
1573
- f"- Sections extracted: {len(result.get('sections') or [])}\n"
1574
- f"- References extracted: {len(result.get('references') or [])}\n"
1575
- f"- Concepts extracted: {len(result.get('concepts') or [])}\n"
1576
- f"- Claims extracted: {len(result.get('claims') or [])}\n"
1577
- )
1578
- return summary, result
1579
- except Exception as e:
1580
- errors.append(f"{parser_name}: {e}")
1581
-
1582
- fail_summary = "### PDF parse status\n\n" + "\n".join([f"- {x}" for x in errors])
1583
- return fail_summary, {"parser": None, "errors": errors}
1584
-
1585
-
1586
- def render_parse_result(parsed):
1587
- if not parsed or not isinstance(parsed, dict) or (not parsed.get("title") and not parsed.get("sections")):
1588
- return '<div class="panel" style="padding:18px"><p>No parsed document yet.</p></div>'
1589
-
1590
- sections_html = []
1591
- for section in parsed.get("sections", [])[:8]:
1592
- sections_html.append(
1593
- f"""
1594
- <details class="agent-step">
1595
- <summary class="agent-summary">
1596
- <div class="agent-index">§</div>
1597
- <div class="agent-head">
1598
- <h4>{safe_text(section.get('heading', 'Section'))}</h4>
1599
- <span>section</span>
1600
- </div>
1601
- </summary>
1602
- <div class="agent-copy">
1603
- <p>{safe_text(section.get('text', '')[:2200])}</p>
1604
- </div>
1605
- </details>
1606
- """
1607
- )
1608
-
1609
- refs = parsed.get("references", [])[:14]
1610
- refs_html = "".join(
1611
- f"<li>{safe_text(r.get('title') or 'Untitled')} {'· DOI ' + safe_text(r.get('doi')) if r.get('doi') else ''}</li>"
1612
- for r in refs
1613
- ) or "<li>No references extracted.</li>"
1614
-
1615
- concepts = parsed.get("concepts", [])[:12]
1616
- claims = parsed.get("claims", [])[:8]
1617
- concepts_html = "".join(f"<li>{safe_text(x)}</li>" for x in concepts) or "<li>No concepts extracted.</li>"
1618
- claims_html = "".join(f"<li>{safe_text(x)}</li>" for x in claims) or "<li>No claims extracted.</li>"
1619
-
1620
- title = safe_text(parsed.get("title") or "Parsed document")
1621
- abstract = safe_text((parsed.get("abstract") or "")[:2600]) or "No abstract extracted."
1622
- parser_name = safe_text(parsed.get("parser") or "unknown")
1623
- parser_quality = safe_text(parsed.get("parser_quality") or "unknown")
1624
-
1625
- return f"""
1626
- <div class="panel" style="padding:18px">
1627
- <div class="brain-header">
1628
- <div>
1629
- <p class="eyebrow">PDF Parse</p>
1630
- <h3>{title}</h3>
1631
- </div>
1632
- <div class="brain-legend"><span><i class="dot dot-upload"></i> {parser_name} · {parser_quality}</span></div>
1633
- </div>
1634
- <div class="parse-grid">
1635
- <div class="parse-card">
1636
- <h4>Abstract</h4>
1637
- <p>{abstract}</p>
1638
- </div>
1639
- <div class="parse-card">
1640
- <h4>References</h4>
1641
- <ul class="ref-list">{refs_html}</ul>
1642
- </div>
1643
- <div class="parse-card">
1644
- <h4>Concepts</h4>
1645
- <ul class="ref-list">{concepts_html}</ul>
1646
- </div>
1647
- <div class="parse-card">
1648
- <h4>Claims</h4>
1649
- <ul class="ref-list">{claims_html}</ul>
1650
- </div>
1651
- </div>
1652
- <div class="timeline" style="margin-top:14px; max-height:560px; overflow:auto;">
1653
- {''.join(sections_html) if sections_html else '<div class="panel" style="padding:16px;"><p>No sections extracted.</p></div>'}
1654
- </div>
1655
- </div>
1656
- """
1657
-
1658
-
1659
- def add_node(nodes_by_id: Dict[str, Dict], node_id: str, node_type: str, label: str = "", **attrs):
1660
- if not node_id:
1661
- return
1662
- current = nodes_by_id.get(node_id, {})
1663
- merged = {"id": node_id, "type": node_type, "label": label or current.get("label", node_id)}
1664
- merged.update(current)
1665
- for key, value in attrs.items():
1666
- if value not in [None, ""]:
1667
- merged[key] = value
1668
- nodes_by_id[node_id] = merged
1669
-
1670
-
1671
- def add_edge(edges: List[Dict], source: str, target: str, edge_type: str, **attrs):
1672
- if not source or not target or source == target:
1673
- return
1674
- edge = {"source": source, "target": target, "type": edge_type}
1675
- for key, value in attrs.items():
1676
- if value not in [None, ""]:
1677
- edge[key] = value
1678
- edges.append(edge)
1679
-
1680
-
1681
- def build_ingest_payload(query, selected_papers, parsed_pdf=None, frontier=None):
1682
- nodes_by_id = {}
1683
- edges = []
1684
-
1685
- topic_id = "topic:query"
1686
- add_node(nodes_by_id, topic_id, "Topic", label=query or "Research topic", query=query or "")
1687
-
1688
- for i, paper in enumerate(selected_papers, start=1):
1689
- paper_id = normalize_doi(paper.get("doi")) or (paper.get("external_ids") or {}).get("arxiv") or f"paper:{i}:{slugify(paper.get('title', 'paper'))[:40]}"
1690
- add_node(
1691
- nodes_by_id,
1692
- paper_id,
1693
- "Paper",
1694
- label=paper.get("title") or f"Paper {i}",
1695
- title=paper.get("title"),
1696
- year=paper.get("year"),
1697
- venue=paper.get("venue"),
1698
- doi=normalize_doi(paper.get("doi")),
1699
- source=paper.get("source"),
1700
- url=paper.get("url"),
1701
- pdf=paper.get("pdf"),
1702
- score=paper.get("score"),
1703
- learned_score=paper.get("learned_score", paper.get("score")),
1704
- open_access=paper.get("open_access"),
1705
- authors_text=paper.get("authors_text"),
1706
- )
1707
- add_edge(edges, topic_id, paper_id, "ABOUT", weight=paper.get("learned_score", paper.get("score", 0)))
1708
-
1709
- for author in paper.get("authors", [])[:6]:
1710
- author_id = f"author:{slugify(author)[:64]}"
1711
- add_node(nodes_by_id, author_id, "Author", label=author, name=author)
1712
- add_edge(edges, paper_id, author_id, "WRITTEN_BY")
1713
-
1714
- for concept in (paper.get("concepts") or [])[:8]:
1715
- concept_id = f"concept:{slugify(concept)[:72]}"
1716
- add_node(nodes_by_id, concept_id, "Concept", label=concept, name=concept)
1717
- add_edge(edges, paper_id, concept_id, "MENTIONS")
1718
-
1719
- for claim in (paper.get("claims") or [])[:4]:
1720
- claim_id = f"claim:{slugify(claim)[:72]}"
1721
- add_node(nodes_by_id, claim_id, "Claim", label=claim[:140], text=claim)
1722
- add_edge(edges, paper_id, claim_id, "ASSERTS")
1723
-
1724
- if parsed_pdf and isinstance(parsed_pdf, dict) and parsed_pdf.get("title"):
1725
- doc_id = "upload:pdf"
1726
- add_node(nodes_by_id, doc_id, "UploadedPDF", label=parsed_pdf.get("title"), title=parsed_pdf.get("title"), parser=parsed_pdf.get("parser"))
1727
- add_edge(edges, topic_id, doc_id, "UPLOADED_SOURCE")
1728
-
1729
- for concept in (parsed_pdf.get("concepts") or [])[:8]:
1730
- concept_id = f"concept:{slugify(concept)[:72]}"
1731
- add_node(nodes_by_id, concept_id, "Concept", label=concept, name=concept)
1732
- add_edge(edges, doc_id, concept_id, "MENTIONS")
1733
-
1734
- for claim in (parsed_pdf.get("claims") or [])[:6]:
1735
- claim_id = f"claim:{slugify(claim)[:72]}"
1736
- add_node(nodes_by_id, claim_id, "Claim", label=claim[:140], text=claim)
1737
- add_edge(edges, doc_id, claim_id, "ASSERTS")
1738
-
1739
- for idx, ref in enumerate(parsed_pdf.get("references", [])[:20], start=1):
1740
- ref_title = ref.get("title") or f"Reference {idx}"
1741
- ref_doi = normalize_doi(ref.get("doi") or "")
1742
- ref_id = ref_doi or f"ref:{idx}:{slugify(ref_title)[:40]}"
1743
- add_node(nodes_by_id, ref_id, "Reference", label=ref_title, title=ref_title, doi=ref_doi)
1744
- add_edge(edges, doc_id, ref_id, "CITES")
1745
-
1746
- for idx, item in enumerate(ensure_list(frontier)[:18], start=1):
1747
- fid = normalize_doi(item.get("doi")) or f"frontier:{idx}:{slugify(item.get('title', 'paper'))[:40]}"
1748
- add_node(
1749
- nodes_by_id,
1750
- fid,
1751
- "FrontierPaper",
1752
- label=item.get("title") or f"Frontier {idx}",
1753
- title=item.get("title"),
1754
- frontier_score=item.get("frontier_score"),
1755
- url=item.get("url"),
1756
- source=item.get("source"),
1757
- authors_text=item.get("authors_text"),
1758
- year=item.get("year"),
1759
- doi=item.get("doi"),
1760
- )
1761
- add_edge(edges, topic_id, fid, "FRONTIER_CANDIDATE", weight=item.get("frontier_score", item.get("learned_score", item.get("score", 0))))
1762
-
1763
- return {"status": "ok", "nodes": list(nodes_by_id.values())[:GRAPH_MAX_NODES], "edges": edges[:GRAPH_MAX_EDGES]}
1764
-
1765
-
1766
- def learn_from_payload(payload: Dict, query: str = "") -> Dict:
1767
- if not payload:
1768
- return GRAPH_MEMORY
1769
-
1770
- GRAPH_MEMORY["queries"].append(query or "")
1771
- GRAPH_MEMORY["events"].append({
1772
- "ts": time.time(),
1773
- "query": query or "",
1774
- "nodes": len(payload.get("nodes", [])),
1775
- "edges": len(payload.get("edges", [])),
1776
- })
1777
- GRAPH_MEMORY["payloads"].append(payload)
1778
-
1779
- for node in payload.get("nodes", []):
1780
- node_id = node.get("id")
1781
- if not node_id:
1782
- continue
1783
- GRAPH_MEMORY["nodes"][node_id] = node
1784
- node_type = (node.get("type") or "").lower()
1785
- if node_type in {"paper", "frontierpaper"}:
1786
- GRAPH_MEMORY["papers"][node_id] = node
1787
- if node_type == "concept" and node.get("label"):
1788
- GRAPH_MEMORY["concept_counts"][node["label"].lower()] += 1
1789
- if node_type == "claim" and node.get("label"):
1790
- GRAPH_MEMORY["claim_counts"][node["label"].lower()] += 1
1791
-
1792
- GRAPH_MEMORY["edges"].extend(payload.get("edges", []))
1793
- GRAPH_MEMORY["edges"] = GRAPH_MEMORY["edges"][:GRAPH_MAX_EDGES]
1794
- return GRAPH_MEMORY
1795
-
1796
-
1797
- def export_learning_state() -> str:
1798
- snapshot = {
1799
- "papers": list(GRAPH_MEMORY["papers"].values())[:60],
1800
- "nodes": list(GRAPH_MEMORY["nodes"].values())[:250],
1801
- "edges": GRAPH_MEMORY["edges"][:500],
1802
- "top_concepts": GRAPH_MEMORY["concept_counts"].most_common(24),
1803
- "top_claims": GRAPH_MEMORY["claim_counts"].most_common(24),
1804
- "queries": GRAPH_MEMORY["queries"][-20:],
1805
- "events": GRAPH_MEMORY["events"][-20:],
1806
- "frontier": GRAPH_MEMORY["frontier"][:24],
1807
- }
1808
- return json.dumps(snapshot, indent=2, ensure_ascii=False)
1809
-
1810
-
1811
- def resolve_selected_papers(selected_indices, papers_state):
1812
- papers = ensure_list(papers_state)
1813
- selected_indices = ensure_list(selected_indices)
1814
- selected = []
1815
- if not selected_indices:
1816
- return selected
1817
-
1818
- value_map = {paper_choice_value(i, paper): paper for i, paper in enumerate(papers)}
1819
- label_map = {paper_choice_label(i, paper): paper for i, paper in enumerate(papers)}
1820
-
1821
- for idx in selected_indices:
1822
- try:
1823
- if isinstance(idx, int):
1824
- if 0 <= idx < len(papers):
1825
- selected.append(papers[idx])
1826
- continue
1827
- idx_str = str(idx)
1828
- if idx_str in value_map:
1829
- selected.append(value_map[idx_str])
1830
- continue
1831
- if idx_str.isdigit():
1832
- num = int(idx_str)
1833
- if 0 <= num < len(papers):
1834
- selected.append(papers[num])
1835
- continue
1836
- if "|" in idx_str:
1837
- left = idx_str.split("|", 1)[0]
1838
- if left.isdigit():
1839
- num = int(left)
1840
- if 0 <= num < len(papers):
1841
- selected.append(papers[num])
1842
- continue
1843
- if idx_str in label_map:
1844
- selected.append(label_map[idx_str])
1845
- continue
1846
- except Exception:
1847
- continue
1848
-
1849
- out = []
1850
- seen = set()
1851
- for paper in selected:
1852
- key = paper_identity_key(paper)
1853
- if key not in seen:
1854
- seen.add(key)
1855
- out.append(paper)
1856
- return out
1857
-
1858
-
1859
- def summarize_learning_state(query_text, papers, selected_sources):
1860
- concept_pool = []
1861
- for paper in papers[:8]:
1862
- concept_pool.extend((paper.get("concepts") or [])[:4])
1863
- top_concepts = [c for c, _ in Counter([c.lower() for c in concept_pool]).most_common(8)]
1864
- return (
1865
- "### Discovery results\n\n"
1866
- f"- Query: {query_text}\n"
1867
- f"- Sources: {', '.join(selected_sources)}\n"
1868
- f"- Candidates found: {len(papers)}\n"
1869
- f"- Top learned concepts: {', '.join(top_concepts) if top_concepts else 'None'}\n"
1870
- "- Select papers below, or leave selection empty to auto-ingest the top papers.\n"
1871
- )
1872
-
1873
-
1874
- def build_ingest_status_markdown(query_text: str, payload: Dict, selected: List[Dict], parsed_state: Optional[Dict], frontier: List[Dict]) -> str:
1875
- payload = payload or {"nodes": [], "edges": []}
1876
- nodes = payload.get("nodes", [])
1877
- edges = payload.get("edges", [])
1878
-
1879
- counts = Counter((node.get("type") or "Unknown") for node in nodes)
1880
- node_lines = []
1881
- for idx, node in enumerate(nodes[:24], start=1):
1882
- label = node.get("label") or node.get("title") or node.get("id")
1883
- node_lines.append(f"- {idx}. [{node.get('type', 'Node')}] {label}")
1884
-
1885
- edge_lines = []
1886
- for idx, edge in enumerate(edges[:30], start=1):
1887
- edge_lines.append(f"- {idx}. {edge.get('source')} --{edge.get('type', 'RELATES_TO')}--> {edge.get('target')}")
1888
-
1889
- return "\n".join([
1890
- "### Graph ingest status",
1891
- "",
1892
- f"- Topic: {query_text}",
1893
- f"- Selected papers ingested: {len(selected)}",
1894
- f"- Uploaded PDF parsed: {'Yes' if parsed_state and isinstance(parsed_state, dict) and parsed_state.get('title') else 'No'}",
1895
- f"- Frontier candidates added: {len(frontier)}",
1896
- f"- Total nodes created: {len(nodes)}",
1897
- f"- Total edges created: {len(edges)}",
1898
- f"- Node breakdown: {', '.join([f'{k}={v}' for k, v in counts.items()]) if counts else 'None'}",
1899
- "",
1900
- "### Nodes",
1901
- *(node_lines or ["- None"]),
1902
- "",
1903
- "### Edges",
1904
- *(edge_lines or ["- None"]),
1905
- ])
1906
-
1907
-
1908
- def run_paper_discovery(query, search_mode, sources, pdf_file):
1909
- query_text = norm_text(query or "")
1910
- selected_sources = ensure_list(sources) or DEFAULT_SOURCES
1911
-
1912
- if not query_text and not pdf_file:
1913
- empty_graph = build_learning_graph_html([], [], "Self-Learning Knowledge Graph")
1914
- return (
1915
- empty_graph,
1916
- '<div class="panel papers-panel" style="padding:18px"><p>Enter a topic, title, DOI, link, or upload a PDF to start learning.</p></div>',
1917
- build_journal_html("biomaterials cardiac repair"),
1918
- "No PDF uploaded yet.",
1919
- gr.update(choices=[], value=[]),
1920
- [],
1921
- "### No discovery results yet.",
1922
- )
1923
-
1924
- if not query_text and pdf_file:
1925
- uploaded_name = Path(getattr(pdf_file, "name", str(pdf_file))).name
1926
- graph_nodes, graph_edges = build_learning_graph_state("", [], uploaded_name)
1927
- return (
1928
- build_learning_graph_html(graph_nodes, graph_edges, "Uploaded PDF Waiting for Parse"),
1929
- '<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>',
1930
- build_journal_html("biomaterials cardiac repair"),
1931
- uploaded_pdf_summary(pdf_file),
1932
- gr.update(choices=[], value=[]),
1933
- [],
1934
- "### Upload detected.\n\n- Parse the PDF to extract structure.\n- Or enter a topic to start discovery.",
1935
- )
1936
-
1937
- uploaded_name = Path(getattr(pdf_file, "name", str(pdf_file))).name if pdf_file else None
1938
-
1939
- try:
1940
- papers = discover_papers(query_text, search_mode, selected_sources, max_results=GRAPH_MAX_RESULTS)
1941
- graph_nodes, graph_edges = build_learning_graph_state(query_text, papers[:6], uploaded_name)
1942
- graph_html = build_learning_graph_html(graph_nodes, graph_edges, "Self-Learning Knowledge Graph")
1943
- papers_html = format_papers_html(papers)
1944
- journals_html = build_journal_html(query_text or "biomaterials cardiac repair")
1945
- pdf_summary = uploaded_pdf_summary(pdf_file)
1946
- choices = format_selection_choices(papers)
1947
- status_md = summarize_learning_state(query_text, papers, selected_sources)
1948
- return (
1949
- graph_html,
1950
- papers_html,
1951
- journals_html,
1952
- pdf_summary,
1953
- gr.update(choices=choices, value=[]),
1954
- papers,
1955
- status_md,
1956
- )
1957
- except Exception as e:
1958
- graph_nodes, graph_edges = build_learning_graph_state(query_text, [], uploaded_name)
1959
- error_html = f'<div class="panel papers-panel" style="padding:18px"><p>Paper search failed: {safe_text(str(e))}</p></div>'
1960
- return (
1961
- build_learning_graph_html(graph_nodes, graph_edges),
1962
- error_html,
1963
- build_journal_html(query_text or "biomaterials cardiac repair"),
1964
- uploaded_pdf_summary(pdf_file),
1965
- gr.update(choices=[], value=[]),
1966
- [],
1967
- f"### Discovery failed.\n\n- Error: {safe_text(str(e))}",
1968
- )
1969
-
1970
-
1971
- def ingest_selected_papers(query, selected_indices, papers_state, pdf_file, parsed_state):
1972
- papers = ensure_list(papers_state)
1973
- selected = resolve_selected_papers(selected_indices, papers)
1974
- uploaded_name = Path(getattr(pdf_file, "name", str(pdf_file))).name if pdf_file else None
1975
-
1976
- if not selected and papers:
1977
- selected = papers[:3]
1978
-
1979
- if not selected and parsed_state and isinstance(parsed_state, dict) and parsed_state.get("title"):
1980
- selected = []
1981
-
1982
- if not selected and not (parsed_state and isinstance(parsed_state, dict) and parsed_state.get("title")):
1983
- graph_html = build_learning_graph_html([], [], "Self-Learning Knowledge Graph")
1984
- empty_payload = {"status": "empty", "nodes": [], "edges": []}
1985
- return (
1986
- graph_html,
1987
- "### Graph ingest status\n\n- No papers were selected and no parsed PDF is available.\n- Select papers or parse an uploaded PDF first.",
1988
- empty_payload,
1989
- )
1990
-
1991
- query_text = norm_text(query or "")
1992
- if not query_text and isinstance(parsed_state, dict):
1993
- query_text = parsed_state.get("title") or "Research topic"
1994
- if not query_text:
1995
- query_text = "Research topic"
1996
-
1997
- selected = [enrich_paper_semantics(query_text, paper) for paper in selected]
1998
- frontier = frontier_expand(query_text, DEFAULT_SOURCES, selected or papers[:3], parsed_state=parsed_state if isinstance(parsed_state, dict) else None, per_query=3)
1999
-
2000
- graph_nodes, graph_edges = graph_from_selected(
2001
- query_text,
2002
- selected,
2003
- uploaded_name,
2004
- parsed_state if isinstance(parsed_state, dict) else None,
2005
- frontier=frontier,
2006
- )
2007
- graph_html = build_learning_graph_html(graph_nodes, graph_edges, "Selected Research Graph")
2008
-
2009
- payload = build_ingest_payload(
2010
- query_text,
2011
- selected,
2012
- parsed_state if isinstance(parsed_state, dict) else None,
2013
- frontier=frontier,
2014
- )
2015
- learn_from_payload(payload, query=query_text)
2016
-
2017
- status_md = build_ingest_status_markdown(
2018
- query_text,
2019
- payload,
2020
- selected,
2021
- parsed_state if isinstance(parsed_state, dict) else None,
2022
- frontier,
2023
- )
2024
-
2025
- return graph_html, status_md, payload
2026
-
2027
-
2028
- def autonomous_expand_into_markdown(query, payload, parsed_state=None):
2029
- frontier = GRAPH_MEMORY.get("frontier") or []
2030
- lines = [
2031
- "### Autonomous expansion plan",
2032
- "",
2033
- f"- Seed query: {query or 'Research topic'}",
2034
- f"- Current nodes: {len(payload.get('nodes', [])) if isinstance(payload, dict) else 0}",
2035
- f"- Current edges: {len(payload.get('edges', [])) if isinstance(payload, dict) else 0}",
2036
- f"- Frontier candidates: {len(frontier)}",
2037
- ]
2038
-
2039
- proposed = propose_expansion_queries(query or "", list(GRAPH_MEMORY.get("papers", {}).values())[:8], parsed_state=parsed_state, limit=GRAPH_MAX_EXPANSIONS)
2040
- if proposed:
2041
- lines.extend(["", "#### Proposed next queries", ""])
2042
- lines.extend([f"- {q}" for q in proposed])
2043
-
2044
- if frontier:
2045
- lines.extend(["", "#### Top frontier papers", ""])
2046
- for item in frontier[:8]:
2047
- lines.append(
2048
- f"- {item.get('title', 'Untitled')} ({item.get('source', 'unknown')}) — frontier score {item.get('frontier_score', item.get('learned_score', item.get('score', 0)))}"
2049
- )
2050
- return "\n".join(lines)
2051
-
2052
-
2053
- __all__ = [
2054
- "SEARCH_MODES",
2055
- "SOURCE_OPTIONS",
2056
- "DEFAULT_SOURCES",
2057
- "PDF_PARSERS",
2058
- "GRAPH_MEMORY",
2059
- "discover_papers",
2060
- "run_paper_discovery",
2061
- "parse_uploaded_pdf",
2062
- "render_parse_result",
2063
- "ingest_selected_papers",
2064
- "build_ingest_payload",
2065
- "learn_from_payload",
2066
- "frontier_expand",
2067
- "autonomous_expand_into_markdown",
2068
- "export_learning_state",
2069
- "format_frontier_html",
2070
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dvnc_ai_v2_hf/graph_canvas_patch.py DELETED
@@ -1,977 +0,0 @@
1
- import html
2
- import json
3
- import re
4
- from typing import Any, Dict, List
5
-
6
-
7
- TYPE_STYLES = {
8
- "topic": {"color": "#38bdf8", "size": 12, "label": "Topic"},
9
- "query": {"color": "#38bdf8", "size": 12, "label": "Topic"},
10
- "paper": {"color": "#34d399", "size": 9, "label": "Paper"},
11
- "uploadedpdf": {"color": "#f59e0b", "size": 10, "label": "Uploaded PDF"},
12
- "upload": {"color": "#f59e0b", "size": 10, "label": "Uploaded PDF"},
13
- "concept": {"color": "#a78bfa", "size": 7, "label": "Concept"},
14
- "author": {"color": "#f472b6", "size": 6, "label": "Author"},
15
- "reference": {"color": "#94a3b8", "size": 6, "label": "Reference"},
16
- "claim": {"color": "#fb7185", "size": 7, "label": "Claim"},
17
- "frontierpaper": {"color": "#fde047", "size": 8, "label": "Frontier Paper"},
18
- "frontier": {"color": "#fde047", "size": 8, "label": "Frontier Paper"},
19
- }
20
-
21
-
22
- def _safe_text(x: Any, default: str = "") -> str:
23
- return html.escape(str(x if x is not None else default))
24
-
25
-
26
- def _norm_text(x: Any) -> str:
27
- return re.sub(r"\s+", " ", str(x or "")).strip()
28
-
29
-
30
- def _truncate(text: str, limit: int = 120) -> str:
31
- text = _norm_text(text)
32
- return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
33
-
34
-
35
- def _as_dict(payload: Any) -> Dict[str, Any]:
36
- if payload is None:
37
- return {"status": "empty", "nodes": [], "edges": []}
38
- if isinstance(payload, dict):
39
- return payload
40
- if isinstance(payload, str):
41
- try:
42
- parsed = json.loads(payload)
43
- if isinstance(parsed, dict):
44
- return parsed
45
- except Exception:
46
- pass
47
- return {"status": "empty", "nodes": [], "edges": []}
48
-
49
-
50
- def _style_for(kind: str) -> Dict[str, Any]:
51
- key = _norm_text(kind).lower().replace(" ", "")
52
- return TYPE_STYLES.get(key, {"color": "#cbd5e1", "size": 6, "label": _norm_text(kind or "Node") or "Node"})
53
-
54
-
55
- def _normalize_payload(payload: Any, title: str) -> Dict[str, Any]:
56
- raw = _as_dict(payload)
57
- raw_nodes = raw.get("nodes", []) or []
58
- raw_edges = raw.get("edges", raw.get("links", [])) or []
59
-
60
- nodes: List[Dict[str, Any]] = []
61
- seen = set()
62
-
63
- for i, node in enumerate(raw_nodes):
64
- if not isinstance(node, dict):
65
- continue
66
- node_id = _norm_text(node.get("id") or f"node_{i}")
67
- if not node_id or node_id in seen:
68
- continue
69
- seen.add(node_id)
70
-
71
- kind = _norm_text(node.get("kind") or node.get("type") or "paper")
72
- style = _style_for(kind)
73
- label = _truncate(node.get("label") or node.get("title") or node_id, 120)
74
-
75
- nodes.append({
76
- "id": node_id,
77
- "label": label,
78
- "kind": kind or "paper",
79
- "color": style["color"],
80
- "size": float(node.get("size") or style["size"]),
81
- "detail": {
82
- "type": style["label"],
83
- "label": label,
84
- "title": _norm_text(node.get("title") or node.get("label") or node_id),
85
- "venue": _norm_text(node.get("venue")),
86
- "year": _norm_text(node.get("year")),
87
- "doi": _norm_text(node.get("doi")),
88
- "source": _norm_text(node.get("source")),
89
- "authors_text": _norm_text(node.get("authors_text")),
90
- "text": _norm_text(node.get("text")),
91
- },
92
- })
93
-
94
- links: List[Dict[str, Any]] = []
95
- for edge in raw_edges:
96
- if not isinstance(edge, dict):
97
- continue
98
- src = _norm_text(edge.get("source"))
99
- tgt = _norm_text(edge.get("target"))
100
- if not src or not tgt or src == tgt:
101
- continue
102
- links.append({
103
- "source": src,
104
- "target": tgt,
105
- "type": _norm_text(edge.get("type") or "RELATES_TO"),
106
- })
107
-
108
- counts: Dict[str, int] = {}
109
- for n in nodes:
110
- label = _style_for(n["kind"])["label"]
111
- counts[label] = counts.get(label, 0) + 1
112
-
113
- return {
114
- "title": _norm_text(title) or "Self-Learning Knowledge Graph",
115
- "nodes": nodes,
116
- "links": links,
117
- "counts": counts,
118
- }
119
-
120
-
121
- def render_graph_canvas_html(payload: Any, title: str = "Self-Learning Knowledge Graph", height: int = 780) -> str:
122
- data = _normalize_payload(payload, title=title)
123
- payload_json = json.dumps(data, ensure_ascii=False).replace("</script>", "<\\/script>")
124
-
125
- html_doc = r"""
126
- <!doctype html>
127
- <html>
128
- <head>
129
- <meta charset="utf-8"/>
130
- <meta name="viewport" content="width=device-width, initial-scale=1"/>
131
- <style>
132
- :root {
133
- --bg0: #030712;
134
- --bg1: #0b1220;
135
- --bg2: #111827;
136
- --panel: rgba(8, 15, 29, 0.72);
137
- --panel-border: rgba(255,255,255,0.10);
138
- --text: #e5eefc;
139
- --muted: #a5b4cc;
140
- --accent: #38bdf8;
141
- }
142
- * { box-sizing: border-box; }
143
- html, body {
144
- margin: 0;
145
- width: 100%;
146
- height: 100%;
147
- overflow: hidden;
148
- background:
149
- radial-gradient(circle at 20% 20%, rgba(56,189,248,0.16), transparent 28%),
150
- radial-gradient(circle at 80% 18%, rgba(167,139,250,0.12), transparent 24%),
151
- linear-gradient(180deg, var(--bg2) 0%, var(--bg1) 45%, var(--bg0) 100%);
152
- color: var(--text);
153
- font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
154
- touch-action: none;
155
- }
156
- #app {
157
- position: relative;
158
- width: 100%;
159
- height: 100%;
160
- overflow: hidden;
161
- }
162
- #canvas {
163
- position: absolute;
164
- inset: 0;
165
- width: 100%;
166
- height: 100%;
167
- display: block;
168
- cursor: grab;
169
- }
170
- #canvas.dragging { cursor: grabbing; }
171
-
172
- .hud {
173
- position: absolute;
174
- z-index: 10;
175
- backdrop-filter: blur(10px);
176
- background: var(--panel);
177
- border: 1px solid var(--panel-border);
178
- box-shadow: 0 10px 30px rgba(0,0,0,0.28);
179
- }
180
- .topbar {
181
- top: 16px;
182
- left: 16px;
183
- width: min(420px, calc(100% - 32px));
184
- border-radius: 16px;
185
- padding: 14px 16px;
186
- }
187
- .topbar h3 {
188
- margin: 0 0 6px;
189
- font-size: 18px;
190
- line-height: 1.2;
191
- color: #f8fbff;
192
- }
193
- .topbar p {
194
- margin: 0;
195
- font-size: 12px;
196
- line-height: 1.45;
197
- color: var(--muted);
198
- }
199
- .legend {
200
- display: flex;
201
- flex-wrap: wrap;
202
- gap: 8px;
203
- margin-top: 10px;
204
- }
205
- .chip {
206
- display: inline-flex;
207
- align-items: center;
208
- gap: 8px;
209
- padding: 6px 10px;
210
- border-radius: 999px;
211
- font-size: 11px;
212
- line-height: 1;
213
- color: #dbeafe;
214
- background: rgba(255,255,255,0.04);
215
- border: 1px solid rgba(255,255,255,0.06);
216
- }
217
- .dot {
218
- width: 10px;
219
- height: 10px;
220
- border-radius: 999px;
221
- flex: 0 0 10px;
222
- }
223
-
224
- .details {
225
- right: 16px;
226
- bottom: 16px;
227
- width: min(360px, calc(100% - 32px));
228
- max-height: min(48%, 360px);
229
- overflow: auto;
230
- border-radius: 16px;
231
- padding: 14px 16px;
232
- }
233
- .details h4 {
234
- margin: 0 0 8px;
235
- font-size: 14px;
236
- color: #f8fbff;
237
- }
238
- .details p {
239
- margin: 0;
240
- font-size: 12px;
241
- line-height: 1.5;
242
- color: var(--muted);
243
- }
244
- .details dl {
245
- margin: 12px 0 0;
246
- display: grid;
247
- grid-template-columns: 78px 1fr;
248
- gap: 8px 10px;
249
- font-size: 12px;
250
- }
251
- .details dt {
252
- color: #93c5fd;
253
- font-weight: 600;
254
- }
255
- .details dd {
256
- margin: 0;
257
- color: #e5eefc;
258
- word-break: break-word;
259
- }
260
-
261
- .statusbar {
262
- position: absolute;
263
- left: 16px;
264
- bottom: 16px;
265
- z-index: 10;
266
- display: flex;
267
- gap: 8px;
268
- flex-wrap: wrap;
269
- }
270
- .status {
271
- padding: 8px 10px;
272
- border-radius: 12px;
273
- font-size: 11px;
274
- color: #dbeafe;
275
- background: rgba(8, 15, 29, 0.66);
276
- border: 1px solid rgba(255,255,255,0.08);
277
- backdrop-filter: blur(8px);
278
- }
279
-
280
- .empty {
281
- position: absolute;
282
- inset: 0;
283
- display: none;
284
- align-items: center;
285
- justify-content: center;
286
- z-index: 5;
287
- pointer-events: none;
288
- }
289
- .empty.show { display: flex; }
290
- .empty-card {
291
- width: min(520px, calc(100% - 32px));
292
- text-align: center;
293
- padding: 24px 28px;
294
- border-radius: 20px;
295
- background: rgba(8, 15, 29, 0.66);
296
- border: 1px solid rgba(255,255,255,0.08);
297
- backdrop-filter: blur(10px);
298
- box-shadow: 0 16px 40px rgba(0,0,0,0.34);
299
- }
300
- .empty-card h3 {
301
- margin: 0 0 10px;
302
- font-size: 22px;
303
- }
304
- .empty-card p {
305
- margin: 0;
306
- color: var(--muted);
307
- line-height: 1.55;
308
- }
309
-
310
- @media (max-width: 700px) {
311
- .topbar {
312
- width: calc(100% - 20px);
313
- left: 10px;
314
- top: 10px;
315
- padding: 12px;
316
- }
317
- .details {
318
- width: calc(100% - 20px);
319
- right: 10px;
320
- bottom: 10px;
321
- max-height: 34%;
322
- padding: 12px;
323
- }
324
- .statusbar {
325
- left: 10px;
326
- bottom: calc(34% + 22px);
327
- right: 10px;
328
- }
329
- }
330
- </style>
331
- </head>
332
- <body>
333
- <div id="app">
334
- <canvas id="canvas"></canvas>
335
-
336
- <div class="hud topbar">
337
- <h3 id="title"></h3>
338
- <p>Drag background to rotate, mouse wheel or trackpad pinch to zoom, two-finger pinch on touch screens, Shift-drag or right-drag to pan, and drag a node to move it in 3D space.</p>
339
- <div class="legend" id="legend"></div>
340
- </div>
341
-
342
- <div class="hud details" id="details">
343
- <h4>Node details</h4>
344
- <p>Tap or click a node to inspect it.</p>
345
- </div>
346
-
347
- <div class="statusbar" id="statusbar"></div>
348
-
349
- <div class="empty" id="empty">
350
- <div class="empty-card">
351
- <h3>No graph data yet</h3>
352
- <p>Ingest papers or pass a payload containing nodes and edges. This renderer accepts the existing graph payload shape directly.</p>
353
- </div>
354
- </div>
355
- </div>
356
-
357
- <script>
358
- const DATA = __DATA__;
359
-
360
- const canvas = document.getElementById("canvas");
361
- const ctx = canvas.getContext("2d", { alpha: true });
362
- const titleEl = document.getElementById("title");
363
- const detailsEl = document.getElementById("details");
364
- const legendEl = document.getElementById("legend");
365
- const statusbarEl = document.getElementById("statusbar");
366
- const emptyEl = document.getElementById("empty");
367
-
368
- titleEl.textContent = DATA.title || "Self-Learning Knowledge Graph";
369
-
370
- const legendMap = [
371
- ["Topic", "#38bdf8"],
372
- ["Paper", "#34d399"],
373
- ["Uploaded PDF", "#f59e0b"],
374
- ["Concept", "#a78bfa"],
375
- ["Author", "#f472b6"],
376
- ["Reference", "#94a3b8"],
377
- ["Claim", "#fb7185"],
378
- ["Frontier Paper", "#fde047"]
379
- ];
380
-
381
- legendMap.forEach(([label, color]) => {
382
- const chip = document.createElement("div");
383
- chip.className = "chip";
384
- chip.innerHTML = `<span class="dot" style="background:${color}"></span>${label}`;
385
- legendEl.appendChild(chip);
386
- });
387
-
388
- function addStatus(text) {
389
- const el = document.createElement("div");
390
- el.className = "status";
391
- el.textContent = text;
392
- statusbarEl.appendChild(el);
393
- }
394
-
395
- addStatus(`Nodes: ${DATA.nodes.length}`);
396
- addStatus(`Edges: ${DATA.links.length}`);
397
-
398
- Object.entries(DATA.counts || {}).forEach(([k, v]) => addStatus(`${k}: ${v}`));
399
-
400
- if (!DATA.nodes.length) {
401
- emptyEl.classList.add("show");
402
- }
403
-
404
- function hashCode(str) {
405
- let h = 0;
406
- for (let i = 0; i < str.length; i++) {
407
- h = ((h << 5) - h) + str.charCodeAt(i);
408
- h |= 0;
409
- }
410
- return Math.abs(h);
411
- }
412
-
413
- function seededFloat(key, salt) {
414
- const h = hashCode(key + "|" + salt);
415
- return (h % 10000) / 10000;
416
- }
417
-
418
- const nodes = DATA.nodes.map((n, i) => {
419
- const r1 = seededFloat(n.id, "x");
420
- const r2 = seededFloat(n.id, "y");
421
- const r3 = seededFloat(n.id, "z");
422
- const radius = 120 + (i % 7) * 18 + (n.size || 6) * 2;
423
- const theta = r1 * Math.PI * 2;
424
- const phi = (r2 - 0.5) * Math.PI;
425
-
426
- return {
427
- ...n,
428
- x: Math.cos(theta) * Math.cos(phi) * radius,
429
- y: Math.sin(phi) * radius * 0.72,
430
- z: Math.sin(theta) * Math.cos(phi) * radius,
431
- vx: 0,
432
- vy: 0,
433
- vz: 0,
434
- fx: null,
435
- fy: null,
436
- fz: null,
437
- screenX: 0,
438
- screenY: 0,
439
- screenR: 0,
440
- depth: 0
441
- };
442
- });
443
-
444
- const nodeById = new Map(nodes.map(n => [n.id, n]));
445
-
446
- const links = DATA.links
447
- .map(l => ({
448
- ...l,
449
- sourceRef: nodeById.get(l.source),
450
- targetRef: nodeById.get(l.target)
451
- }))
452
- .filter(l => l.sourceRef && l.targetRef);
453
-
454
- const state = {
455
- width: 0,
456
- height: 0,
457
- dpr: Math.max(1, Math.min(2, window.devicePixelRatio || 1)),
458
- cx: 0,
459
- cy: 0,
460
- yaw: 0.65,
461
- pitch: 0.25,
462
- zoom: 1.0,
463
- panX: 0,
464
- panY: 0,
465
- perspective: 940,
466
- cameraZ: 860,
467
- hovered: null,
468
- selected: null,
469
- draggingNode: null,
470
- draggingBackground: false,
471
- panMode: false,
472
- lastX: 0,
473
- lastY: 0,
474
- raf: null,
475
- lastTouchDist: 0,
476
- lastTouchCenter: null
477
- };
478
-
479
- function resize() {
480
- const rect = canvas.getBoundingClientRect();
481
- state.width = rect.width;
482
- state.height = rect.height;
483
- state.cx = rect.width / 2;
484
- state.cy = rect.height / 2;
485
- canvas.width = Math.floor(rect.width * state.dpr);
486
- canvas.height = Math.floor(rect.height * state.dpr);
487
- ctx.setTransform(state.dpr, 0, 0, state.dpr, 0, 0);
488
- }
489
- resize();
490
- window.addEventListener("resize", resize);
491
-
492
- function clamp(v, lo, hi) {
493
- return Math.max(lo, Math.min(hi, v));
494
- }
495
-
496
- function rotatePoint(x, y, z) {
497
- const cy = Math.cos(state.yaw);
498
- const sy = Math.sin(state.yaw);
499
- const cp = Math.cos(state.pitch);
500
- const sp = Math.sin(state.pitch);
501
-
502
- const x1 = x * cy - z * sy;
503
- const z1 = x * sy + z * cy;
504
-
505
- const y2 = y * cp - z1 * sp;
506
- const z2 = y * sp + z1 * cp;
507
-
508
- return { x: x1, y: y2, z: z2 };
509
- }
510
-
511
- function projectNode(n) {
512
- const p = rotatePoint(n.x, n.y, n.z);
513
- const depth = state.cameraZ - p.z;
514
- const scale = (state.perspective / Math.max(120, depth)) * state.zoom;
515
- n.depth = p.z;
516
- n.screenX = state.cx + state.panX + p.x * scale;
517
- n.screenY = state.cy + state.panY + p.y * scale;
518
- n.screenR = clamp((n.size || 6) * scale * 0.85, 4, 22);
519
- n._scale = scale;
520
- }
521
-
522
- function setDetails(n) {
523
- if (!n) {
524
- detailsEl.innerHTML = `
525
- <h4>Node details</h4>
526
- <p>Tap or click a node to inspect it.</p>
527
- `;
528
- return;
529
- }
530
- const d = n.detail || {};
531
- detailsEl.innerHTML = `
532
- <h4>Node details</h4>
533
- <dl>
534
- <dt>Label</dt><dd>${escapeHtml(n.label || "")}</dd>
535
- <dt>Type</dt><dd>${escapeHtml(d.type || n.kind || "")}</dd>
536
- <dt>Title</dt><dd>${escapeHtml(d.title || "—")}</dd>
537
- <dt>Venue</dt><dd>${escapeHtml(d.venue || "—")}</dd>
538
- <dt>Year</dt><dd>${escapeHtml(d.year || "—")}</dd>
539
- <dt>DOI</dt><dd>${escapeHtml(d.doi || "—")}</dd>
540
- <dt>Source</dt><dd>${escapeHtml(d.source || "—")}</dd>
541
- <dt>Authors</dt><dd>${escapeHtml(d.authors_text || "—")}</dd>
542
- <dt>Text</dt><dd>${escapeHtml(d.text || "—")}</dd>
543
- </dl>
544
- `;
545
- }
546
-
547
- function escapeHtml(str) {
548
- return String(str || "")
549
- .replaceAll("&", "&amp;")
550
- .replaceAll("<", "&lt;")
551
- .replaceAll(">", "&gt;")
552
- .replaceAll('"', "&quot;")
553
- .replaceAll("'", "&#039;");
554
- }
555
-
556
- function findNodeAt(x, y) {
557
- const ordered = [...nodes].sort((a, b) => b.depth - a.depth);
558
- for (const n of ordered) {
559
- const dx = x - n.screenX;
560
- const dy = y - n.screenY;
561
- const rr = n.screenR + 6;
562
- if (dx * dx + dy * dy <= rr * rr) {
563
- return n;
564
- }
565
- }
566
- return null;
567
- }
568
-
569
- function worldDeltaFromScreen(dx, dy, scale) {
570
- const localX = dx / Math.max(0.2, scale);
571
- const localY = dy / Math.max(0.2, scale);
572
-
573
- const cp = Math.cos(-state.pitch);
574
- const sp = Math.sin(-state.pitch);
575
- const cy = Math.cos(-state.yaw);
576
- const sy = Math.sin(-state.yaw);
577
-
578
- let x1 = localX;
579
- let y1 = localY * cp;
580
- let z1 = localY * sp;
581
-
582
- const wx = x1 * cy - z1 * sy;
583
- const wz = x1 * sy + z1 * cy;
584
-
585
- return { x: wx, y: y1, z: wz };
586
- }
587
-
588
- function stepPhysics() {
589
- if (!nodes.length) return;
590
-
591
- const nCount = nodes.length;
592
- const pairStride = nCount > 120 ? 2 : 1;
593
- const repulsion = 15000;
594
- const spring = 0.0035;
595
- const centering = 0.0008;
596
- const damping = 0.90;
597
-
598
- for (const n of nodes) {
599
- n.ax = 0;
600
- n.ay = 0;
601
- n.az = 0;
602
- }
603
-
604
- for (let i = 0; i < nCount; i += pairStride) {
605
- const a = nodes[i];
606
- for (let j = i + 1; j < nCount; j += pairStride) {
607
- const b = nodes[j];
608
- const dx = a.x - b.x;
609
- const dy = a.y - b.y;
610
- const dz = a.z - b.z;
611
- const d2 = dx * dx + dy * dy + dz * dz + 0.01;
612
- const f = repulsion / d2;
613
- const inv = 1 / Math.sqrt(d2);
614
- const fx = dx * inv * f;
615
- const fy = dy * inv * f;
616
- const fz = dz * inv * f;
617
- a.ax += fx; a.ay += fy; a.az += fz;
618
- b.ax -= fx; b.ay -= fy; b.az -= fz;
619
- }
620
- }
621
-
622
- for (const l of links) {
623
- const a = l.sourceRef;
624
- const b = l.targetRef;
625
- const dx = b.x - a.x;
626
- const dy = b.y - a.y;
627
- const dz = b.z - a.z;
628
- const dist = Math.sqrt(dx * dx + dy * dy + dz * dz) || 1;
629
- const target = 80 + ((a.size || 6) + (b.size || 6)) * 3;
630
- const f = (dist - target) * spring;
631
- const fx = (dx / dist) * f;
632
- const fy = (dy / dist) * f;
633
- const fz = (dz / dist) * f;
634
- a.ax += fx; a.ay += fy; a.az += fz;
635
- b.ax -= fx; b.ay -= fy; b.az -= fz;
636
- }
637
-
638
- for (const n of nodes) {
639
- if (state.draggingNode === n) {
640
- n.vx = 0; n.vy = 0; n.vz = 0;
641
- continue;
642
- }
643
- n.ax += -n.x * centering;
644
- n.ay += -n.y * centering;
645
- n.az += -n.z * centering;
646
-
647
- n.vx = (n.vx + n.ax) * damping;
648
- n.vy = (n.vy + n.ay) * damping;
649
- n.vz = (n.vz + n.az) * damping;
650
-
651
- n.x += n.vx;
652
- n.y += n.vy;
653
- n.z += n.vz;
654
- }
655
- }
656
-
657
- function drawBackground() {
658
- ctx.clearRect(0, 0, state.width, state.height);
659
-
660
- const g = ctx.createRadialGradient(state.width * 0.5, state.height * 0.46, 40, state.width * 0.5, state.height * 0.46, state.width * 0.72);
661
- g.addColorStop(0, "rgba(56,189,248,0.05)");
662
- g.addColorStop(0.45, "rgba(99,102,241,0.03)");
663
- g.addColorStop(1, "rgba(0,0,0,0)");
664
- ctx.fillStyle = g;
665
- ctx.fillRect(0, 0, state.width, state.height);
666
- }
667
-
668
- function drawEdge(a, b, type) {
669
- const depthAlpha = clamp((a._scale + b._scale) * 0.22, 0.10, 0.45);
670
- const typeColor = {
671
- "ABOUT": "rgba(56,189,248,ALPHA)",
672
- "MENTIONS": "rgba(167,139,250,ALPHA)",
673
- "WRITTEN_BY": "rgba(244,114,182,ALPHA)",
674
- "CITES": "rgba(148,163,184,ALPHA)",
675
- "ASSERTS": "rgba(251,113,133,ALPHA)",
676
- "FRONTIER": "rgba(253,224,71,ALPHA)",
677
- "FRONTIER_CANDIDATE": "rgba(253,224,71,ALPHA)",
678
- "UPLOADED_SOURCE": "rgba(245,158,11,ALPHA)"
679
- };
680
- const c = (typeColor[type] || "rgba(203,213,225,ALPHA)").replace("ALPHA", depthAlpha.toFixed(3));
681
-
682
- ctx.beginPath();
683
- ctx.moveTo(a.screenX, a.screenY);
684
- ctx.lineTo(b.screenX, b.screenY);
685
- ctx.lineWidth = type === "ABOUT" || type === "UPLOADED_SOURCE" ? 2 : 1.2;
686
- ctx.strokeStyle = c;
687
- ctx.stroke();
688
- }
689
-
690
- function drawNode(n, selected, hovered) {
691
- const r = n.screenR;
692
- ctx.beginPath();
693
- ctx.arc(n.screenX, n.screenY, r + 8, 0, Math.PI * 2);
694
- const glow = ctx.createRadialGradient(n.screenX, n.screenY, 0, n.screenX, n.screenY, r + 8);
695
- glow.addColorStop(0, hexToRgba(n.color, hovered || selected ? 0.28 : 0.18));
696
- glow.addColorStop(1, hexToRgba(n.color, 0));
697
- ctx.fillStyle = glow;
698
- ctx.fill();
699
-
700
- ctx.beginPath();
701
- ctx.arc(n.screenX, n.screenY, r, 0, Math.PI * 2);
702
- ctx.fillStyle = n.color;
703
- ctx.fill();
704
-
705
- ctx.lineWidth = selected ? 2.6 : hovered ? 2.0 : 1.0;
706
- ctx.strokeStyle = selected ? "rgba(255,255,255,0.95)" : hovered ? "rgba(255,255,255,0.75)" : "rgba(255,255,255,0.22)";
707
- ctx.stroke();
708
-
709
- if (selected || hovered || r > 10) {
710
- drawLabel(n, selected || hovered);
711
- }
712
- }
713
-
714
- function drawLabel(n, strong) {
715
- const text = n.label || n.id;
716
- ctx.font = `${strong ? 700 : 600} 12px Inter, sans-serif`;
717
- const padX = 8;
718
- const h = 24;
719
- const w = Math.min(260, ctx.measureText(text).width + padX * 2);
720
- const x = n.screenX + 10;
721
- const y = n.screenY - 10 - h;
722
-
723
- roundRect(x, y, w, h, 10);
724
- ctx.fillStyle = strong ? "rgba(8,15,29,0.92)" : "rgba(8,15,29,0.72)";
725
- ctx.fill();
726
-
727
- ctx.lineWidth = 1;
728
- ctx.strokeStyle = "rgba(255,255,255,0.08)";
729
- ctx.stroke();
730
-
731
- ctx.fillStyle = "#f8fbff";
732
- ctx.fillText(text.length > 32 ? text.slice(0, 31) + "…" : text, x + padX, y + 16);
733
- }
734
-
735
- function roundRect(x, y, w, h, r) {
736
- ctx.beginPath();
737
- ctx.moveTo(x + r, y);
738
- ctx.arcTo(x + w, y, x + w, y + h, r);
739
- ctx.arcTo(x + w, y + h, x, y + h, r);
740
- ctx.arcTo(x, y + h, x, y, r);
741
- ctx.arcTo(x, y, x + w, y, r);
742
- ctx.closePath();
743
- }
744
-
745
- function hexToRgba(hex, alpha) {
746
- const h = hex.replace("#", "");
747
- const bigint = parseInt(h.length === 3 ? h.split("").map(c => c + c).join("") : h, 16);
748
- const r = (bigint >> 16) & 255;
749
- const g = (bigint >> 8) & 255;
750
- const b = bigint & 255;
751
- return `rgba(${r}, ${g}, ${b}, ${alpha})`;
752
- }
753
-
754
- function render() {
755
- drawBackground();
756
-
757
- for (const n of nodes) {
758
- projectNode(n);
759
- }
760
-
761
- const orderedLinks = [...links].sort((a, b) => {
762
- const da = (a.sourceRef.depth + a.targetRef.depth) * 0.5;
763
- const db = (b.sourceRef.depth + b.targetRef.depth) * 0.5;
764
- return da - db;
765
- });
766
-
767
- for (const l of orderedLinks) {
768
- drawEdge(l.sourceRef, l.targetRef, l.type);
769
- }
770
-
771
- const orderedNodes = [...nodes].sort((a, b) => a.depth - b.depth);
772
- for (const n of orderedNodes) {
773
- drawNode(n, state.selected === n, state.hovered === n);
774
- }
775
- }
776
-
777
- function animate() {
778
- stepPhysics();
779
- render();
780
- state.raf = requestAnimationFrame(animate);
781
- }
782
- animate();
783
-
784
- function pointerPos(evt) {
785
- const rect = canvas.getBoundingClientRect();
786
- return { x: evt.clientX - rect.left, y: evt.clientY - rect.top };
787
- }
788
-
789
- canvas.addEventListener("contextmenu", (e) => e.preventDefault());
790
-
791
- canvas.addEventListener("mousedown", (e) => {
792
- const p = pointerPos(e);
793
- state.lastX = p.x;
794
- state.lastY = p.y;
795
- state.hovered = findNodeAt(p.x, p.y);
796
- state.panMode = e.button === 2 || e.shiftKey;
797
-
798
- if (state.hovered && e.button !== 2) {
799
- state.draggingNode = state.hovered;
800
- state.selected = state.hovered;
801
- setDetails(state.selected);
802
- } else {
803
- state.draggingBackground = true;
804
- }
805
-
806
- canvas.classList.add("dragging");
807
- });
808
-
809
- window.addEventListener("mousemove", (e) => {
810
- const rect = canvas.getBoundingClientRect();
811
- const inside = e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
812
- if (!inside && !state.draggingNode && !state.draggingBackground) return;
813
-
814
- const p = pointerPos(e);
815
- const dx = p.x - state.lastX;
816
- const dy = p.y - state.lastY;
817
- state.lastX = p.x;
818
- state.lastY = p.y;
819
-
820
- if (state.draggingNode) {
821
- const delta = worldDeltaFromScreen(dx, dy, state.draggingNode._scale || 1);
822
- state.draggingNode.x += delta.x * 1.18;
823
- state.draggingNode.y += delta.y * 1.18;
824
- state.draggingNode.z += delta.z * 1.18;
825
- state.draggingNode.vx = 0;
826
- state.draggingNode.vy = 0;
827
- state.draggingNode.vz = 0;
828
- return;
829
- }
830
-
831
- if (state.draggingBackground) {
832
- if (state.panMode) {
833
- state.panX += dx;
834
- state.panY += dy;
835
- } else {
836
- state.yaw += dx * 0.0085;
837
- state.pitch = clamp(state.pitch + dy * 0.0065, -1.25, 1.25);
838
- }
839
- return;
840
- }
841
-
842
- state.hovered = findNodeAt(p.x, p.y);
843
- });
844
-
845
- window.addEventListener("mouseup", () => {
846
- state.draggingNode = null;
847
- state.draggingBackground = false;
848
- state.panMode = false;
849
- canvas.classList.remove("dragging");
850
- });
851
-
852
- canvas.addEventListener("wheel", (e) => {
853
- e.preventDefault();
854
- const delta = e.deltaY > 0 ? 0.92 : 1.09;
855
- state.zoom = clamp(state.zoom * delta, 0.35, 4.0);
856
- }, { passive: false });
857
-
858
- canvas.addEventListener("click", (e) => {
859
- const p = pointerPos(e);
860
- const hit = findNodeAt(p.x, p.y);
861
- if (hit) {
862
- state.selected = hit;
863
- setDetails(hit);
864
- }
865
- });
866
-
867
- canvas.addEventListener("touchstart", (e) => {
868
- e.preventDefault();
869
- if (e.touches.length === 1) {
870
- const t = e.touches[0];
871
- const p = pointerPos(t);
872
- state.lastX = p.x;
873
- state.lastY = p.y;
874
- state.hovered = findNodeAt(p.x, p.y);
875
- if (state.hovered) {
876
- state.draggingNode = state.hovered;
877
- state.selected = state.hovered;
878
- setDetails(state.selected);
879
- } else {
880
- state.draggingBackground = true;
881
- }
882
- } else if (e.touches.length === 2) {
883
- state.draggingNode = null;
884
- state.draggingBackground = false;
885
- const a = e.touches[0];
886
- const b = e.touches[1];
887
- state.lastTouchDist = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
888
- state.lastTouchCenter = {
889
- x: (a.clientX + b.clientX) * 0.5,
890
- y: (a.clientY + b.clientY) * 0.5
891
- };
892
- }
893
- }, { passive: false });
894
-
895
- canvas.addEventListener("touchmove", (e) => {
896
- e.preventDefault();
897
- if (e.touches.length === 1) {
898
- const t = e.touches[0];
899
- const p = pointerPos(t);
900
- const dx = p.x - state.lastX;
901
- const dy = p.y - state.lastY;
902
- state.lastX = p.x;
903
- state.lastY = p.y;
904
-
905
- if (state.draggingNode) {
906
- const delta = worldDeltaFromScreen(dx, dy, state.draggingNode._scale || 1);
907
- state.draggingNode.x += delta.x * 1.18;
908
- state.draggingNode.y += delta.y * 1.18;
909
- state.draggingNode.z += delta.z * 1.18;
910
- state.draggingNode.vx = 0;
911
- state.draggingNode.vy = 0;
912
- state.draggingNode.vz = 0;
913
- } else if (state.draggingBackground) {
914
- state.yaw += dx * 0.0085;
915
- state.pitch = clamp(state.pitch + dy * 0.0065, -1.25, 1.25);
916
- }
917
- } else if (e.touches.length === 2) {
918
- const a = e.touches[0];
919
- const b = e.touches[1];
920
- const dist = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
921
- const center = {
922
- x: (a.clientX + b.clientX) * 0.5,
923
- y: (a.clientY + b.clientY) * 0.5
924
- };
925
-
926
- if (state.lastTouchDist) {
927
- const ratio = dist / state.lastTouchDist;
928
- state.zoom = clamp(state.zoom * ratio, 0.35, 4.0);
929
- }
930
-
931
- if (state.lastTouchCenter) {
932
- state.panX += center.x - state.lastTouchCenter.x;
933
- state.panY += center.y - state.lastTouchCenter.y;
934
- }
935
-
936
- state.lastTouchDist = dist;
937
- state.lastTouchCenter = center;
938
- }
939
- }, { passive: false });
940
-
941
- canvas.addEventListener("touchend", (e) => {
942
- e.preventDefault();
943
- if (e.touches.length === 0) {
944
- state.draggingNode = null;
945
- state.draggingBackground = false;
946
- state.lastTouchDist = 0;
947
- state.lastTouchCenter = null;
948
- }
949
- }, { passive: false });
950
-
951
- setDetails(null);
952
- </script>
953
- </body>
954
- </html>
955
- """
956
-
957
- html_doc = html_doc.replace("__DATA__", payload_json)
958
- return (
959
- f'<iframe title="{_safe_text(title)}" '
960
- f'style="width:100%; height:{int(height)}px; border:0; border-radius:18px; overflow:hidden; background:#030712;" '
961
- f'sandbox="allow-scripts allow-same-origin" '
962
- f'srcdoc="{html.escape(html_doc, quote=True)}"></iframe>'
963
- )
964
-
965
-
966
- def render_graph_canvas_component(payload: Any, title: str = "Self-Learning Knowledge Graph", height: int = 780):
967
- try:
968
- import gradio as gr
969
- except Exception as e:
970
- raise RuntimeError(f"gradio is required for render_graph_canvas_component: {e}")
971
- return gr.HTML(render_graph_canvas_html(payload, title=title, height=height))
972
-
973
-
974
- __all__ = [
975
- "render_graph_canvas_html",
976
- "render_graph_canvas_component",
977
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dvnc_ai_v2_hf/self_learning_graph.py CHANGED
@@ -1,12 +1,11 @@
1
  import html
2
- import json
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 Any, Dict, List, Optional
 
10
 
11
  import gradio as gr
12
  import requests
@@ -16,31 +15,11 @@ try:
16
  except Exception:
17
  fitz = None
18
 
19
- from dvnc_ai_v2_hf.graph_canvas_patch import render_graph_canvas_html
20
-
21
- def build_learning_graph_html(nodes, edges, title="Self-Learning Knowledge Graph"):
22
- payload = {
23
- "status": "ok" if (nodes or edges) else "empty",
24
- "nodes": nodes or [],
25
- "edges": edges or [],
26
- }
27
- return render_graph_canvas_html(payload, title=title, height=780)
28
-
29
- SEARCH_MODES = ["topic", "title", "doi", "link", "paper_name", "autonomous_web"]
30
- SOURCE_OPTIONS = ["arxiv", "crossref", "openalex", "semantic_scholar", "europe_pmc"]
31
- DEFAULT_SOURCES = ["arxiv", "crossref", "openalex", "semantic_scholar", "europe_pmc"]
32
- PDF_PARSERS = ["pymupdf"]
33
 
34
- REQUEST_TIMEOUT = 25
35
- GRAPH_MAX_RESULTS = 12
36
- GRAPH_MAX_CONCEPTS = 12
37
- GRAPH_MAX_CLAIMS = 8
38
- GRAPH_MAX_EXPANSIONS = 6
39
- GRAPH_MAX_NODES = 500
40
- GRAPH_MAX_EDGES = 1600
41
- GRAPH_IFRAME_HEIGHT = 760
42
- MAX_ABSTRACT_CHARS = 4000
43
- MAX_RAW_TEXT_CHARS = 60000
44
 
45
  JOURNALS = [
46
  {"name": "Nature", "url": "https://www.nature.com/search", "desc": "Flagship multidisciplinary research journal."},
@@ -50,34 +29,14 @@ JOURNALS = [
50
  {"name": "IEEE Xplore", "url": "https://ieeexplore.ieee.org/search/searchresult.jsp", "desc": "Engineering, AI, signal processing, and systems."},
51
  ]
52
 
53
- STOPWORDS = {
54
- "a", "an", "and", "are", "as", "at", "be", "been", "being", "by", "can", "could",
55
- "did", "do", "does", "for", "from", "had", "has", "have", "if", "in", "into", "is",
56
- "it", "its", "may", "might", "of", "on", "or", "our", "such", "that", "the", "their",
57
- "there", "these", "this", "those", "to", "using", "use", "used", "via", "was", "were",
58
- "will", "with", "within", "without", "we", "they", "you", "your", "study", "paper",
59
- "research", "results", "result", "method", "methods", "analysis", "approach", "based",
60
- "new", "novel", "effect", "effects", "model", "models", "system", "systems", "show",
61
- "shows", "shown", "however", "therefore", "also", "between", "across", "among", "et", "al",
62
- "introduction", "discussion", "conclusion", "references", "figure", "table",
63
- }
64
-
65
- GRAPH_MEMORY: Dict[str, Any] = {
66
- "papers": {},
67
- "nodes": {},
68
- "edges": [],
69
- "queries": [],
70
- "events": [],
71
- "frontier": [],
72
- "payloads": [],
73
- "concept_counts": Counter(),
74
- "claim_counts": Counter(),
75
- }
76
-
77
-
78
- # ----------------------------
79
- # Utility
80
- # ----------------------------
81
 
82
  def safe_text(x, default=""):
83
  return html.escape(str(x if x is not None else default))
@@ -87,25 +46,6 @@ def norm_text(x: Optional[str]) -> str:
87
  return re.sub(r"\s+", " ", (x or "")).strip()
88
 
89
 
90
- def ensure_list(x):
91
- return x if isinstance(x, list) else []
92
-
93
-
94
- def truncate_text(text: str, limit: int) -> str:
95
- text = norm_text(text)
96
- return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
97
-
98
-
99
- def slugify(text: str) -> str:
100
- return re.sub(r"[^a-z0-9]+", "-", (text or "").lower()).strip("-")
101
-
102
-
103
- def normalize_doi(text: str) -> str:
104
- text = (text or "").strip()
105
- text = re.sub(r"^https?://(dx\.)?doi\.org/", "", text, flags=re.I)
106
- return text.strip().rstrip("/")
107
-
108
-
109
  def detect_query_type(query: str) -> str:
110
  q = (query or "").strip()
111
  doi_pattern = r"^10\.\d{4,9}/[-._;()/:A-Z0-9]+$"
@@ -116,262 +56,106 @@ def detect_query_type(query: str) -> str:
116
  return "topic"
117
 
118
 
119
- def tokenize(text: str) -> List[str]:
120
- return [
121
- t for t in re.findall(r"[A-Za-z][A-Za-z0-9\-/+]{2,}", (text or ""))
122
- if t.lower() not in STOPWORDS
123
- ]
124
-
125
-
126
- def text_overlap_score(a: str, b: str) -> float:
127
- sa = {x.lower() for x in tokenize(a)}
128
- sb = {x.lower() for x in tokenize(b)}
129
- if not sa or not sb:
130
- return 0.0
131
- return len(sa & sb) / max(1, len(sa | sb))
132
-
133
-
134
- def compute_recency_bonus(year: str) -> float:
135
- try:
136
- y = int(str(year)[:4])
137
- except Exception:
138
- return 0.0
139
- current = time.gmtime().tm_year
140
- age = max(current - y, 0)
141
- return max(0.0, 0.16 - age * 0.018)
142
-
143
-
144
- def clean_extracted_text(text: str) -> str:
145
- text = text or ""
146
- replacements = {
147
- "\u00ad": "",
148
- "\ufb01": "fi",
149
- "\ufb02": "fl",
150
- "\u2010": "-",
151
- "\u2011": "-",
152
- "\u2012": "-",
153
- "\u2013": "-",
154
- "\u2014": "-",
155
- "\u2212": "-",
156
- "\u00a0": " ",
157
- }
158
- for old, new in replacements.items():
159
- text = text.replace(old, new)
160
-
161
- text = re.sub(r"(\w)-\s*\n\s*(\w)", r"\1\2", text)
162
- text = re.sub(r"([a-z])\n([a-z])", r"\1 \2", text)
163
- text = re.sub(r"\bnjectable\b", "injectable", text, flags=re.I)
164
- text = re.sub(r"\bfhydrogel\b", "hydrogel", text, flags=re.I)
165
- text = re.sub(
166
- r"(?i)\b([a-z])?(hydrogel|conductive|responsive|injectable|biomaterial|scaffold|cardiac|patch|repair)\b",
167
- lambda m: m.group(2),
168
- text,
169
- )
170
- text = re.sub(r"[ \t]+", " ", text)
171
- text = re.sub(r"\n{3,}", "\n\n", text)
172
- return text.strip()
173
-
174
-
175
- def paper_identity_key(paper: Dict[str, Any]) -> str:
176
- return (
177
- normalize_doi(paper.get("doi") or "")
178
- or ((paper.get("external_ids") or {}).get("arxiv") or "")
179
- or norm_text(paper.get("title", "")).lower()
180
- or str(paper.get("id", ""))
181
- )
182
-
183
-
184
- def unique_keep_order(items: List[str]) -> List[str]:
185
- seen = set()
186
- out = []
187
- for item in items:
188
- key = norm_text(item).lower()
189
- if key and key not in seen:
190
- seen.add(key)
191
- out.append(norm_text(item))
192
- return out
193
-
194
-
195
- # ----------------------------
196
- # Concept / claim extraction
197
- # ----------------------------
198
-
199
- def normalize_concept_label(phrase: str) -> str:
200
- phrase = norm_text(phrase)
201
- mapping = {"ph": "pH", "ai": "AI", "ml": "ML", "3d": "3D", "ecg": "ECG", "mri": "MRI"}
202
- return " ".join(mapping.get(p.lower(), p) for p in phrase.split())
203
-
204
-
205
- def looks_like_bad_phrase(phrase: str) -> bool:
206
- phrase = norm_text(phrase)
207
- if not phrase or len(phrase) < 4 or len(phrase) > 90:
208
- return True
209
- parts = phrase.split()
210
- if len(parts) > 6:
211
- return True
212
- for p in parts:
213
- t = p.strip("-.,;:()[]{}")
214
- if not t:
215
- return True
216
- if len(t) == 1 and t.lower() not in {"p", "h"}:
217
- return True
218
- if re.match(r"^[bcdfghjklmnpqrstvwxyz]{5,}$", t.lower()):
219
- return True
220
- return False
221
-
222
-
223
- def extract_concepts_from_text(text: str, max_terms: int = GRAPH_MAX_CONCEPTS) -> List[str]:
224
- text = clean_extracted_text(text)
225
- words = re.findall(r"[A-Za-z][A-Za-z0-9\-/+]{2,}", text)
226
- phrases = []
227
-
228
- for n in (3, 2, 1):
229
- for i in range(len(words) - n + 1):
230
- phrase = " ".join(words[i:i + n])
231
- low = phrase.lower()
232
- if any(tok in STOPWORDS for tok in low.split()):
233
- continue
234
- if looks_like_bad_phrase(low):
235
- continue
236
- phrases.append(low)
237
 
238
- counts = Counter(phrases)
239
- ranked = []
240
- for phrase, count in counts.most_common(max_terms * 8):
241
- score = float(count)
242
- score += 0.25 * len(phrase.split())
243
- if any(x in phrase for x in ["hydrogel", "conductive", "responsive", "injectable", "graph", "neural", "cardiac", "biomaterial"]):
244
- score += 0.5
245
- ranked.append((score, phrase))
246
 
247
- out = []
248
- seen = set()
249
- for _, phrase in sorted(ranked, key=lambda x: x[0], reverse=True):
250
- clean = normalize_concept_label(phrase)
251
- low = clean.lower()
252
- if low in seen:
253
- continue
254
- if any((low in s or s in low) and abs(len(low) - len(s)) <= 2 for s in seen):
255
- continue
256
- seen.add(low)
257
- out.append(clean)
258
- if len(out) >= max_terms:
259
- break
260
- return out
 
 
 
 
261
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
262
 
263
- def extract_claim_like_sentences(text: str, max_items: int = GRAPH_MAX_CLAIMS) -> List[str]:
264
- text = clean_extracted_text(text)
265
- sentences = [norm_text(s) for s in re.split(r"(?<=[\.\!\?])\s+", text) if norm_text(s)]
266
- scored = []
267
- for sentence in sentences:
268
- if len(sentence) < 45 or len(sentence) > 320:
269
- continue
270
- lower = sentence.lower()
271
- score = 0.0
272
- if any(k in lower for k in ["improves", "reduces", "increases", "demonstrates", "shows", "reveals", "predicts", "achieves", "outperforms", "enables", "supports"]):
273
- score += 2.0
274
- if any(k in lower for k in ["significant", "associated", "correlated", "effective", "robust", "accurate", "validated", "statistically"]):
275
- score += 1.0
276
- score += min(len(tokenize(sentence)) / 18.0, 2.0)
277
- scored.append((score, sentence))
278
 
279
- out = []
280
- seen = set()
281
- for _, sentence in sorted(scored, key=lambda x: x[0], reverse=True):
282
- key = sentence.lower()
283
- if key not in seen:
284
- seen.add(key)
285
- out.append(sentence)
286
- if len(out) >= max_items:
287
- break
288
- return out
 
 
 
 
 
 
 
 
 
 
 
 
289
 
290
 
291
- def extract_title_from_text(raw_text: str, fallback: str = "Uploaded PDF") -> str:
292
- raw_text = clean_extracted_text(raw_text or "")
293
- lines = [norm_text(x) for x in raw_text.splitlines() if norm_text(x)]
294
- for line in lines[:18]:
295
- if 8 <= len(line) <= 240 and not re.search(r"doi|http|www\.|@", line, flags=re.I):
296
- return truncate_text(line.strip(" -:;"), 240)
297
- return fallback
298
-
299
-
300
- def extract_references_from_text(text: str) -> List[Dict[str, str]]:
301
- refs = []
302
- seen = set()
303
- doi_re = re.compile(r"10\.\d{4,9}/[-._;()/:A-Z0-9]+", re.I)
304
-
305
- for line in [norm_text(x) for x in clean_extracted_text(text).splitlines() if norm_text(x)][-250:]:
306
- doi_match = doi_re.search(line)
307
- doi = normalize_doi(doi_match.group(0)) if doi_match else ""
308
- title = line.replace(doi_match.group(0), "").strip(" .;,-") if doi_match else line
309
- if len(title) < 12:
310
- continue
311
- key = (title.lower(), doi.lower())
312
- if key in seen:
313
- continue
314
- seen.add(key)
315
- refs.append({"title": truncate_text(title, 220), "doi": doi})
316
- if len(refs) >= 40:
317
- break
318
- return refs
319
-
320
-
321
- # ----------------------------
322
- # Search sources
323
- # ----------------------------
324
-
325
- def parse_openalex_abstract(inverted_index) -> str:
326
- if not inverted_index or not isinstance(inverted_index, dict):
327
- return ""
328
- pos_to_word = {}
329
- for word, positions in inverted_index.items():
330
- for pos in positions:
331
- pos_to_word[pos] = word
332
- return " ".join(pos_to_word[i] for i in sorted(pos_to_word)) if pos_to_word else ""
333
-
334
-
335
- def enrich_paper_semantics(query: str, paper: Dict[str, Any]) -> Dict[str, Any]:
336
- paper = dict(paper)
337
- title = clean_extracted_text(paper.get("title", ""))
338
- abstract = clean_extracted_text(paper.get("abstract", "") or paper.get("summary", ""))
339
- venue = clean_extracted_text(paper.get("venue", ""))
340
-
341
- concepts = extract_concepts_from_text(" ".join([title, abstract, venue]), max_terms=GRAPH_MAX_CONCEPTS)
342
- claims = extract_claim_like_sentences(abstract, max_items=GRAPH_MAX_CLAIMS)
343
- rel = text_overlap_score(query, f"{title} {abstract}")
344
- recency = compute_recency_bonus(paper.get("year"))
345
- doi_bonus = 0.02 if paper.get("doi") else 0.0
346
- oa_bonus = 0.03 if paper.get("open_access") else 0.0
347
- learned_score = float(paper.get("score", 0)) + rel * 0.52 + recency + doi_bonus + oa_bonus + min(len(concepts), 8) * 0.012
348
-
349
- paper["title"] = title or paper.get("title", "Untitled")
350
- paper["abstract"] = abstract
351
- paper["summary"] = truncate_text(abstract or paper.get("summary", ""), 520)
352
- paper["venue"] = venue
353
- paper["concepts"] = concepts[:GRAPH_MAX_CONCEPTS]
354
- paper["claims"] = claims[:GRAPH_MAX_CLAIMS]
355
- paper["relevance"] = round(rel, 4)
356
- paper["learned_score"] = round(learned_score, 4)
357
- return paper
358
-
359
-
360
- def dedupe_papers(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
361
- seen = {}
362
- for item in items:
363
- key = paper_identity_key(item) or f"{item.get('source', 'src')}::{item.get('title', 'paper')}"
364
- cur = seen.get(key)
365
- cur_score = float(cur.get("learned_score", cur.get("score", 0))) if cur else -1
366
- new_score = float(item.get("learned_score", item.get("score", 0)))
367
- if cur is None or new_score > cur_score:
368
- seen[key] = item
369
- out = list(seen.values())
370
- out.sort(key=lambda x: float(x.get("learned_score", x.get("score", 0))), reverse=True)
371
- return out
372
 
373
 
374
- def search_arxiv(query: str, max_results: int = 8) -> List[Dict[str, Any]]:
375
  encoded = urllib.parse.quote(query)
376
  url = (
377
  "http://export.arxiv.org/api/query?search_query=all:"
@@ -381,45 +165,45 @@ def search_arxiv(query: str, max_results: int = 8) -> List[Dict[str, Any]]:
381
  response.raise_for_status()
382
  root = ET.fromstring(response.text)
383
  ns = {"atom": "http://www.w3.org/2005/Atom"}
384
-
385
- out = []
386
  for entry in root.findall("atom:entry", ns):
387
- title = clean_extracted_text(entry.findtext("atom:title", default="", namespaces=ns) or "")
388
- summary = truncate_text(clean_extracted_text(entry.findtext("atom:summary", default="", namespaces=ns) or ""), MAX_ABSTRACT_CHARS)
389
- published = entry.findtext("atom:published", default="", namespaces=ns) or ""
390
- paper_id = entry.findtext("atom:id", default="", namespaces=ns) or ""
391
- authors = [clean_extracted_text(a.findtext("atom:name", default="", namespaces=ns) or "") for a in entry.findall("atom:author", ns)]
392
  pdf_url = ""
393
  for link in entry.findall("atom:link", ns):
394
  if link.attrib.get("title") == "pdf":
395
  pdf_url = link.attrib.get("href", "")
396
  break
397
-
398
- out.append({
399
- "id": paper_id or title,
400
- "title": title,
401
- "summary": summary,
402
- "abstract": summary,
403
- "published": published[:10],
404
- "authors": [a for a in authors[:8] if a],
405
- "authors_text": ", ".join([a for a in authors[:4] if a]) or "Unknown authors",
406
- "url": paper_id,
407
- "pdf": pdf_url,
408
- "doi": "",
409
- "venue": "arXiv",
410
- "year": published[:4] if published else "",
411
- "source": "arxiv",
412
- "score": 0.76,
413
- "open_access": True,
414
- "external_ids": {"arxiv": (paper_id or "").split("/")[-1]},
415
- })
416
- return out
 
417
 
418
 
419
- def search_crossref(query: str, mode: str = "topic", max_results: int = 8) -> List[Dict[str, Any]]:
420
- headers = {"User-Agent": "dvnc-ai-space/1.0"}
421
  if mode == "doi":
422
- url = f"https://api.crossref.org/works/{urllib.parse.quote(normalize_doi(query))}"
423
  response = requests.get(url, headers=headers, timeout=REQUEST_TIMEOUT)
424
  if response.status_code != 200:
425
  return []
@@ -440,29 +224,28 @@ def search_crossref(query: str, mode: str = "topic", max_results: int = 8) -> Li
440
  for a in item.get("author", []) or []:
441
  name = " ".join(filter(None, [a.get("given"), a.get("family")])).strip()
442
  if name:
443
- authors.append(clean_extracted_text(name))
444
-
445
- title = clean_extracted_text((item.get("title") or ["Untitled"])[0])
446
  year = ""
447
  for key in ["published-print", "published-online", "created"]:
448
  if item.get(key, {}).get("date-parts"):
449
  year = str(item[key]["date-parts"][0][0])
450
  break
451
- abstract = truncate_text(clean_extracted_text(re.sub("<.*?>", " ", item.get("abstract") or "")), MAX_ABSTRACT_CHARS)
452
- doi = normalize_doi(item.get("DOI", ""))
453
-
454
  out.append({
455
  "id": doi or title,
456
- "title": title,
457
- "summary": truncate_text(abstract, 500),
458
- "abstract": abstract,
459
  "published": year,
460
  "authors": authors,
461
  "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
462
  "url": item.get("URL", ""),
463
  "pdf": "",
464
  "doi": doi,
465
- "venue": clean_extracted_text((item.get("container-title") or [""])[0]),
466
  "year": year,
467
  "source": "crossref",
468
  "score": 0.72,
@@ -472,42 +255,37 @@ def search_crossref(query: str, mode: str = "topic", max_results: int = 8) -> Li
472
  return out
473
 
474
 
475
- def search_openalex(query: str, mode: str = "topic", max_results: int = 8) -> List[Dict[str, Any]]:
476
  params = {"per-page": max_results}
477
  if mode == "doi":
478
- doi = normalize_doi(query)
479
  params["filter"] = f"doi:https://doi.org/{doi}"
480
  else:
481
  params["search"] = query
482
-
483
  response = requests.get("https://api.openalex.org/works", params=params, timeout=REQUEST_TIMEOUT)
484
- if response.status_code != 200:
485
- return []
486
  items = response.json().get("results", [])
487
-
488
  out = []
489
  for item in items:
490
  authors = []
491
  for auth in item.get("authorships", [])[:8]:
492
  author = auth.get("author") or {}
493
  if author.get("display_name"):
494
- authors.append(clean_extracted_text(author["display_name"]))
495
  oa = item.get("open_access") or {}
496
- doi = normalize_doi(item.get("doi") or "")
497
- abstract = truncate_text(clean_extracted_text(parse_openalex_abstract(item.get("abstract_inverted_index"))), MAX_ABSTRACT_CHARS)
498
-
499
  out.append({
500
  "id": item.get("id") or doi or item.get("title"),
501
- "title": clean_extracted_text(item.get("title") or ""),
502
- "summary": truncate_text(abstract, 500),
503
- "abstract": abstract,
504
  "published": str(item.get("publication_year") or ""),
505
  "authors": authors,
506
  "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
507
- "url": ((item.get("primary_location") or {}).get("landing_page_url") or item.get("id") or ""),
508
  "pdf": oa.get("oa_url") or "",
509
  "doi": doi,
510
- "venue": clean_extracted_text((((item.get("primary_location") or {}).get("source") or {}).get("display_name") or "")),
511
  "year": str(item.get("publication_year") or ""),
512
  "source": "openalex",
513
  "score": 0.80,
@@ -517,12 +295,15 @@ def search_openalex(query: str, mode: str = "topic", max_results: int = 8) -> Li
517
  return out
518
 
519
 
520
- def search_semantic_scholar(query: str, mode: str = "topic", max_results: int = 8) -> List[Dict[str, Any]]:
 
 
 
521
  fields = "title,authors,year,abstract,venue,externalIds,url,openAccessPdf"
522
  if mode == "doi":
523
- doi = normalize_doi(query)
524
- url = f"https://api.semanticscholar.org/graph/v1/paper/DOI:{urllib.parse.quote(doi)}"
525
- response = requests.get(url, params={"fields": fields}, timeout=REQUEST_TIMEOUT)
526
  if response.status_code != 200:
527
  return []
528
  items = [response.json()]
@@ -530,6 +311,7 @@ def search_semantic_scholar(query: str, mode: str = "topic", max_results: int =
530
  response = requests.get(
531
  "https://api.semanticscholar.org/graph/v1/paper/search",
532
  params={"query": query, "limit": max_results, "fields": fields},
 
533
  timeout=REQUEST_TIMEOUT,
534
  )
535
  if response.status_code != 200:
@@ -539,20 +321,19 @@ def search_semantic_scholar(query: str, mode: str = "topic", max_results: int =
539
  out = []
540
  for item in items:
541
  external = item.get("externalIds") or {}
542
- authors = [clean_extracted_text(a.get("name")) for a in item.get("authors", []) if a.get("name")]
543
- abstract = truncate_text(clean_extracted_text(item.get("abstract", "")), MAX_ABSTRACT_CHARS)
544
  out.append({
545
  "id": external.get("CorpusId") or external.get("DOI") or item.get("title"),
546
- "title": clean_extracted_text(item.get("title") or ""),
547
- "summary": truncate_text(abstract, 500),
548
- "abstract": abstract,
549
  "published": str(item.get("year") or ""),
550
  "authors": authors,
551
  "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
552
  "url": item.get("url") or "",
553
- "pdf": ((item.get("openAccessPdf") or {}).get("url") or ""),
554
- "doi": normalize_doi(external.get("DOI", "")),
555
- "venue": clean_extracted_text(item.get("venue") or ""),
556
  "year": str(item.get("year") or ""),
557
  "source": "semantic_scholar",
558
  "score": 0.84,
@@ -562,34 +343,37 @@ def search_semantic_scholar(query: str, mode: str = "topic", max_results: int =
562
  return out
563
 
564
 
565
- def search_europe_pmc(query: str, mode: str = "topic", max_results: int = 8) -> List[Dict[str, Any]]:
566
  epmc_query = f'DOI:"{query}"' if mode == "doi" else query
567
- params = {"query": epmc_query, "format": "json", "pageSize": max_results, "resultType": "core"}
 
 
 
 
 
568
  response = requests.get("https://www.ebi.ac.uk/europepmc/webservices/rest/search", params=params, timeout=REQUEST_TIMEOUT)
569
  if response.status_code != 200:
570
  return []
571
  items = response.json().get("resultList", {}).get("result", [])
572
-
573
  out = []
574
  for item in items:
575
  author_string = item.get("authorString", "")
576
- authors = [clean_extracted_text(x) for x in author_string.split(",")[:8] if norm_text(x)]
577
  pmcid = item.get("pmcid", "")
578
  pdf_url = f"https://europepmc.org/articles/{pmcid}?pdf=render" if pmcid else ""
579
  landing_url = f"https://europepmc.org/article/PMC/{pmcid}" if pmcid else ""
580
- abstract = truncate_text(clean_extracted_text(item.get("abstractText", "")), MAX_ABSTRACT_CHARS)
581
  out.append({
582
  "id": item.get("id") or item.get("doi") or item.get("title"),
583
- "title": clean_extracted_text(item.get("title") or ""),
584
- "summary": truncate_text(abstract, 500),
585
- "abstract": abstract,
586
  "published": str(item.get("pubYear") or ""),
587
  "authors": authors,
588
  "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
589
  "url": landing_url,
590
  "pdf": pdf_url,
591
- "doi": normalize_doi(item.get("doi", "")),
592
- "venue": clean_extracted_text(item.get("journalTitle", "")),
593
  "year": str(item.get("pubYear") or ""),
594
  "source": "europe_pmc",
595
  "score": 0.78,
@@ -599,31 +383,140 @@ def search_europe_pmc(query: str, mode: str = "topic", max_results: int = 8) ->
599
  return out
600
 
601
 
602
- def resolve_link(query: str) -> List[Dict[str, Any]]:
603
  url = (query or "").strip()
604
  if not url:
605
  return []
606
- return [{
607
- "id": url,
608
- "title": Path(url.split("?")[0]).name or url,
609
- "summary": "Direct link supplied.",
610
- "abstract": "",
611
- "published": "",
612
- "authors": [],
613
- "authors_text": "Unknown authors",
614
- "url": url,
615
- "pdf": url if url.lower().endswith(".pdf") else "",
616
- "doi": "",
617
- "venue": "Web Link",
618
- "year": "",
619
- "source": "link",
620
- "score": 0.4,
621
- "open_access": None,
622
- "external_ids": {},
623
- }]
624
-
625
-
626
- def discover_papers(query: str, mode: str, sources: List[str], max_results: int = 10) -> List[Dict[str, Any]]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
627
  query = (query or "").strip()
628
  if not query:
629
  return []
@@ -635,93 +528,33 @@ def discover_papers(query: str, mode: str, sources: List[str], max_results: int
635
  if mode == "link":
636
  return dedupe_papers(resolve_link(query))
637
 
638
- if "arxiv" in selected_sources and mode != "doi":
639
- try:
640
- results.extend(search_arxiv(query, max_results=min(max_results, 6)))
641
- except Exception:
642
- pass
643
- if "crossref" in selected_sources:
644
- try:
645
- results.extend(search_crossref(query, mode=mode, max_results=min(max_results, 8)))
646
- except Exception:
647
- pass
648
- if "openalex" in selected_sources:
649
- try:
650
- results.extend(search_openalex(query, mode=mode, max_results=min(max_results, 8)))
651
- except Exception:
652
- pass
653
- if "semantic_scholar" in selected_sources:
654
- try:
655
- results.extend(search_semantic_scholar(query, mode=mode, max_results=min(max_results, 8)))
656
- except Exception:
657
- pass
658
- if "europe_pmc" in selected_sources:
659
- try:
660
- results.extend(search_europe_pmc(query, mode=mode, max_results=min(max_results, 8)))
661
- except Exception:
662
- pass
663
-
664
- papers = [enrich_paper_semantics(query, p) for p in dedupe_papers(results)]
665
- papers.sort(key=lambda x: float(x.get("learned_score", x.get("score", 0))), reverse=True)
666
- return papers[:max_results]
667
-
668
-
669
- # ----------------------------
670
- # Journal / paper HTML
671
- # ----------------------------
672
-
673
- def journal_query_links(query: str):
674
- q = urllib.parse.quote_plus(query or "biomaterials cardiac repair")
675
- rows = []
676
- for journal in JOURNALS:
677
- url = f"{journal['url']}?q={q}" if "?" not in journal["url"] else f"{journal['url']}&q={q}"
678
- if "ieeexplore" in journal["url"]:
679
- url = f"https://ieeexplore.ieee.org/search/searchresult.jsp?queryText={q}"
680
- rows.append({"name": journal["name"], "desc": journal["desc"], "url": url})
681
- return rows
682
-
683
-
684
- def build_journal_html(query):
685
- rows = []
686
- for journal in journal_query_links(query):
687
- rows.append(
688
- f"""
689
- <a class="journal-card" href="{safe_text(journal['url'])}" target="_blank" rel="noopener noreferrer">
690
- <div>
691
- <h4>{safe_text(journal['name'])}</h4>
692
- <p>{safe_text(journal['desc'])}</p>
693
- </div>
694
- <span>Open</span>
695
- </a>
696
- """
697
- )
698
- return """
699
- <style>
700
- .journal-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px}
701
- .journal-card{display:flex;justify-content:space-between;gap:12px;padding:14px 16px;border-radius:14px;border:1px solid #dbe3f0;background:#f8fbff;text-decoration:none;color:#0f172a}
702
- .journal-card h4{margin:0 0 4px;font-size:15px}
703
- .journal-card p{margin:0;color:#475569;font-size:13px;line-height:1.45}
704
- .journal-card span{align-self:center;font-size:12px;font-weight:700;color:#2563eb}
705
- </style>
706
- <div class="journal-grid">""" + "".join(rows) + "</div>"
707
-
708
-
709
- def paper_choice_value(index: int, paper: Dict[str, Any]) -> str:
710
- doi = normalize_doi(paper.get("doi") or "")
711
- title_slug = slugify(paper.get("title", ""))[:40]
712
- return f"{index}|{doi}|{title_slug}"
713
-
714
-
715
- def paper_choice_label(index: int, paper: Dict[str, Any]) -> str:
716
- score = round(float(paper.get("learned_score", paper.get("score", 0))), 3)
717
- title = paper.get("title", "Untitled")
718
- authors_text = paper.get("authors_text", "Unknown authors")[:90]
719
- source = paper.get("source", "src")
720
- return f"[{source}] {title} — {authors_text} — score {score}"
721
-
722
 
723
- def format_selection_choices(papers):
724
- return [(paper_choice_label(i, paper), paper_choice_value(i, paper)) for i, paper in enumerate(papers)]
725
 
726
 
727
  def format_papers_html(papers):
@@ -730,12 +563,10 @@ def format_papers_html(papers):
730
 
731
  items = []
732
  for i, paper in enumerate(papers, start=1):
733
- summary = safe_text((paper.get("summary") or paper.get("abstract") or "")[:320])
734
  doi_line = f'<span class="paper-badge doi-badge">{safe_text(paper.get("doi"))}</span>' if paper.get("doi") else ""
735
  pdf_link = paper.get("pdf") or "#"
736
  abs_link = paper.get("url") or "#"
737
- concepts_text = ", ".join((paper.get("concepts") or [])[:5])
738
-
739
  items.append(
740
  f"""
741
  <article class="paper-card">
@@ -749,8 +580,7 @@ def format_papers_html(papers):
749
  <div class="paper-meta-stack">
750
  <div><strong>Authors:</strong> {safe_text(paper.get('authors_text', 'Unknown authors'))}</div>
751
  <div><strong>Venue:</strong> {safe_text(paper.get('venue', 'Unknown venue'))}</div>
752
- <div><strong>Learned score:</strong> {safe_text(round(float(paper.get('learned_score', paper.get('score', 0))), 3))}</div>
753
- <div><strong>Concepts:</strong> {safe_text(concepts_text or 'None extracted')}</div>
754
  </div>
755
  <div class="paper-links">
756
  <a href="{safe_text(abs_link)}" target="_blank" rel="noopener noreferrer">Abstract</a>
@@ -762,29 +592,12 @@ def format_papers_html(papers):
762
  return '<div class="papers-grid">' + ''.join(items) + '</div>'
763
 
764
 
765
- def format_frontier_html(frontier):
766
- if not frontier:
767
- return '<div class="panel papers-panel" style="padding:18px"><p>No autonomous expansion candidates yet.</p></div>'
768
- cards = []
769
- for i, paper in enumerate(frontier[:12], start=1):
770
- cards.append(
771
- f"""
772
- <article class="paper-card frontier-card">
773
- <div class="paper-topline">
774
- <span class="paper-badge">frontier</span>
775
- <span class="paper-badge alt">{safe_text(paper.get('source', 'paper'))}</span>
776
- </div>
777
- <h4>{i}. {safe_text(paper.get('title', 'Untitled'))}</h4>
778
- <p>{safe_text((paper.get('summary') or paper.get('abstract') or '')[:280])}</p>
779
- <div class="paper-meta-stack">
780
- <div><strong>Frontier score:</strong> {safe_text(paper.get('frontier_score', paper.get('learned_score', paper.get('score', 0))))}</div>
781
- <div><strong>Concept overlap:</strong> {safe_text(paper.get('frontier_concept_overlap', 0))}</div>
782
- <div><strong>Authors:</strong> {safe_text(paper.get('authors_text', 'Unknown authors'))}</div>
783
- </div>
784
- </article>
785
- """
786
- )
787
- return '<div class="papers-grid">' + ''.join(cards) + '</div>'
788
 
789
 
790
  def uploaded_pdf_summary(file_obj):
@@ -792,391 +605,217 @@ def uploaded_pdf_summary(file_obj):
792
  return "No PDF uploaded yet."
793
  path = getattr(file_obj, "name", None) or str(file_obj)
794
  p = Path(path)
795
- return f"Uploaded PDF ready for ingestion: {p.name}. Use Parse uploaded PDF to extract title, abstract, sections, references, concepts, and claims."
796
-
797
-
798
- # ----------------------------
799
- # 3D graph
800
- # ----------------------------
801
-
802
- def graph_kind_style(kind: str) -> Dict[str, Any]:
803
- palette = {
804
- "query": {"color": "#1f8ef1", "size": 14, "label": "Research topic"},
805
- "paper": {"color": "#00c49a", "size": 10, "label": "Paper"},
806
- "upload": {"color": "#ff9f43", "size": 11, "label": "Uploaded PDF"},
807
- "concept": {"color": "#a66cff", "size": 8, "label": "Concept"},
808
- "author": {"color": "#f368e0", "size": 7, "label": "Author"},
809
- "claim": {"color": "#ff6b6b", "size": 8, "label": "Claim"},
810
- "reference": {"color": "#6c757d", "size": 7, "label": "Reference"},
811
- "frontier": {"color": "#ffd166", "size": 8, "label": "Frontier candidate"},
812
- }
813
- return palette.get(kind, {"color": "#9aa0a6", "size": 7, "label": kind.title()})
814
-
815
-
816
- def summarize_graph(nodes: List[Dict], edges: List[Dict]) -> Dict[str, Any]:
817
- counts = Counter((n.get("kind") or n.get("type") or "unknown").lower() for n in nodes)
818
- return {"nodes": len(nodes), "edges": len(edges), "counts": dict(counts)}
819
-
820
-
821
- def _prepare_3d_graph_data(nodes: List[Dict], edges: List[Dict], title: str) -> Dict[str, Any]:
822
- node_out = []
823
- for node in nodes:
824
- kind = (node.get("kind") or node.get("type") or "paper").lower()
825
- if kind == "topic":
826
- kind = "query"
827
- if kind == "uploadedpdf":
828
- kind = "upload"
829
- if kind == "frontierpaper":
830
- kind = "frontier"
831
- style = graph_kind_style(kind)
832
- node_out.append({
833
- "id": node.get("id"),
834
- "label": truncate_text(node.get("label") or node.get("title") or node.get("id") or "node", 120),
835
- "kind": kind,
836
- "color": style["color"],
837
- "val": style["size"],
838
- "detail": {
839
- "kind": style["label"],
840
- "title": node.get("title") or node.get("label") or node.get("id"),
841
- "venue": node.get("venue") or "",
842
- "year": node.get("year") or "",
843
- "doi": node.get("doi") or "",
844
- "source": node.get("source") or "",
845
- "authors_text": node.get("authors_text") or "",
846
- "text": node.get("text") or "",
847
- },
848
- })
849
- edge_out = []
850
- for edge in edges:
851
- edge_out.append({
852
- "source": edge.get("source"),
853
- "target": edge.get("target"),
854
- "type": edge.get("type") or "RELATES_TO",
855
- "label": edge.get("type") or "RELATES_TO",
856
- })
857
- return {"title": title, "nodes": node_out, "links": edge_out, "summary": summarize_graph(nodes, edges)}
858
-
859
-
860
- def build_learning_graph_html(nodes, edges, title="Self-Learning Knowledge Graph"):
861
- if not nodes:
862
- return """
863
- <div class="panel brain-shell" style="overflow:auto; max-width:100%;">
864
- <div class="brain-header">
865
- <div>
866
- <p class="eyebrow">Learning Graph</p>
867
- <h3>Self-Learning Knowledge Graph</h3>
868
- </div>
869
- </div>
870
- <div class="brain-stage learning-empty" style="min-height:420px; overflow:auto;">
871
- <div class="empty-graph-copy">
872
- <h4>No papers mapped yet</h4>
873
- <p>Search papers, select candidates, or upload a PDF to grow the graph in an interactive 3D view.</p>
874
- </div>
875
- </div>
876
- </div>
877
- """
878
-
879
- graph_data = _prepare_3d_graph_data(nodes, edges, title)
880
- payload_json = json.dumps(graph_data, ensure_ascii=False)
881
-
882
- iframe_html = f"""
883
- <!doctype html>
884
- <html>
885
- <head>
886
- <meta charset="utf-8" />
887
- <meta name="viewport" content="width=device-width, initial-scale=1" />
888
- <style>
889
- html, body {{ margin:0; height:100%; background:#0b1020; color:#eef2ff; font-family: Inter, ui-sans-serif, system-ui, sans-serif; overflow:hidden; }}
890
- #wrap {{ position:relative; width:100%; height:100%; background:radial-gradient(circle at top, #18213c 0%, #0b1020 60%, #060910 100%); }}
891
- #graph {{ position:absolute; inset:0; }}
892
- .overlay {{
893
- position:absolute; left:16px; top:16px; z-index:10; max-width:min(460px, calc(100% - 32px));
894
- padding:14px 16px; border:1px solid rgba(255,255,255,.12); border-radius:16px;
895
- background:rgba(10,14,28,.72); backdrop-filter: blur(14px); box-shadow:0 12px 28px rgba(0,0,0,.28);
896
- }}
897
- .overlay h3 {{ margin:0 0 6px; font-size:18px; line-height:1.2; }}
898
- .overlay p {{ margin:0; font-size:13px; color:#cbd5e1; line-height:1.5; }}
899
- .panel {{
900
- position:absolute; right:16px; top:16px; z-index:10; width:min(360px, calc(100% - 32px));
901
- max-height:calc(100% - 32px); overflow:auto; padding:14px 16px; border:1px solid rgba(255,255,255,.12);
902
- border-radius:16px; background:rgba(10,14,28,.72); backdrop-filter: blur(14px); box-shadow:0 12px 28px rgba(0,0,0,.28);
903
- }}
904
- .panel h4 {{ margin:0 0 8px; font-size:14px; color:#f8fafc; }}
905
- .panel p {{ margin:0; font-size:12px; color:#cbd5e1; line-height:1.5; }}
906
- .panel dl {{ margin:12px 0 0; display:grid; grid-template-columns:auto 1fr; gap:6px 10px; font-size:12px; }}
907
- .panel dt {{ color:#93c5fd; }}
908
- .panel dd {{ margin:0; color:#e2e8f0; word-break:break-word; }}
909
- .hint {{
910
- position:absolute; left:16px; bottom:16px; z-index:10; padding:10px 12px; border-radius:12px;
911
- font-size:12px; color:#dbeafe; background:rgba(15,23,42,.75); border:1px solid rgba(255,255,255,.08);
912
- }}
913
- </style>
914
- <script src="https://unpkg.com/three@0.160.0/build/three.min.js"></script>
915
- <script src="https://unpkg.com/3d-force-graph"></script>
916
- </head>
917
- <body>
918
- <div id="wrap">
919
- <div id="graph"></div>
920
- <div class="overlay">
921
- <h3></h3>
922
- <p>Drag the background to orbit, scroll to zoom, right-drag to pan, and drag a node to move or pin it in 3D space.</p>
923
- </div>
924
- <div class="panel" id="panel">
925
- <h4>Node details</h4>
926
- <p>Click any node to inspect its label, type, venue, DOI, year, and source.</p>
927
- </div>
928
- <div class="hint">Interactive 3D graph: orbit, zoom, pan, drag nodes.</div>
929
- </div>
930
- <script>
931
- const payload = {payload_json};
932
- document.querySelector('.overlay h3').textContent = payload.title || 'Self-Learning Knowledge Graph';
933
- const panelEl = document.getElementById('panel');
934
-
935
- const Graph = ForceGraph3D()(document.getElementById('graph'))
936
- .backgroundColor('#00000000')
937
- .graphData(payload)
938
- .nodeRelSize(6)
939
- .nodeOpacity(1)
940
- .nodeLabel(node => `<div style="padding:6px 8px"><strong>${{node.label}}</strong><br/>${{(node.detail || {{}}).kind || node.kind}}</div>`)
941
- .linkWidth(link => ['ABOUT','UPLOADED_SOURCE','FRONTIER_CANDIDATE'].includes(link.type) ? 2.6 : 1.35)
942
- .linkDirectionalParticles(link => ['ABOUT','FRONTIER_CANDIDATE'].includes(link.type) ? 2 : 0)
943
- .linkDirectionalParticleWidth(2.2)
944
- .cooldownTicks(140)
945
- .d3VelocityDecay(0.24)
946
- .d3Force('charge').strength(-180)
947
- .nodeColor(node => node.color)
948
- .nodeVal(node => node.val)
949
- .onNodeClick(node => {{
950
- const d = node.detail || {{}};
951
- panelEl.innerHTML = `
952
- <h4>Node details</h4>
953
- <dl>
954
- <dt>Label</dt><dd>${{node.label || ''}}</dd>
955
- <dt>Type</dt><dd>${{d.kind || node.kind || ''}}</dd>
956
- <dt>Venue</dt><dd>${{d.venue || '—'}}</dd>
957
- <dt>Year</dt><dd>${{d.year || '—'}}</dd>
958
- <dt>DOI</dt><dd>${{d.doi || '—'}}</dd>
959
- <dt>Source</dt><dd>${{d.source || '—'}}</dd>
960
- <dt>Authors</dt><dd>${{d.authors_text || '—'}}</dd>
961
- <dt>Text</dt><dd>${{d.text || '—'}}</dd>
962
- </dl>`;
963
- }})
964
- .onNodeDragEnd(node => {{
965
- node.fx = node.x;
966
- node.fy = node.y;
967
- node.fz = node.z;
968
- }});
969
-
970
- Graph.scene().add(new THREE.AmbientLight(0xffffff, 1.1));
971
- const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
972
- dirLight.position.set(120, 120, 120);
973
- Graph.scene().add(dirLight);
974
- Graph.cameraPosition({{ z: 210 }});
975
- </script>
976
- </body>
977
- </html>
978
- """
979
- return f"""
980
- <div class="panel brain-shell" style="overflow:auto; max-width:100%;">
981
- <iframe
982
- title="{safe_text(title)}"
983
- style="width:100%; height:{GRAPH_IFRAME_HEIGHT}px; border:0; border-radius:18px; overflow:auto; background:#0b1020;"
984
- sandbox="allow-scripts allow-same-origin"
985
- srcdoc="{html.escape(iframe_html, quote=True)}"
986
- ></iframe>
987
- </div>
988
- """
989
 
990
 
991
  def build_learning_graph_state(query, papers, uploaded_name=None):
992
  nodes = [{"id": "query", "label": query or "Research topic", "kind": "query"}]
993
  edges = []
994
-
995
- for i, paper in enumerate(papers[:6], start=1):
996
  pid = f"paper_{i}"
997
- nodes.append({
998
- "id": pid,
999
- "label": paper.get("title", f"Paper {i}"),
1000
- "kind": "paper",
1001
- "title": paper.get("title"),
1002
- "venue": paper.get("venue"),
1003
- "year": paper.get("year"),
1004
- "source": paper.get("source"),
1005
- "authors_text": paper.get("authors_text"),
1006
- })
1007
- edges.append({"source": "query", "target": pid, "type": "ABOUT"})
1008
- for concept in (paper.get("concepts") or [])[:3]:
1009
- cid = f"concept_{i}_{slugify(concept)[:30]}"
1010
- nodes.append({"id": cid, "label": concept, "kind": "concept"})
1011
- edges.append({"source": pid, "target": cid, "type": "MENTIONS"})
1012
-
1013
  if uploaded_name:
1014
  nodes.append({"id": "upload", "label": uploaded_name, "kind": "upload"})
1015
- edges.append({"source": "query", "target": "upload", "type": "UPLOADED_SOURCE"})
 
 
1016
  return nodes, edges
1017
 
1018
 
1019
- def graph_from_selected(query, selected_papers, uploaded_name=None, parsed_state=None, frontier=None):
1020
  nodes = [{"id": "query", "label": query or "Research topic", "kind": "query"}]
1021
  edges = []
1022
-
1023
- for i, paper in enumerate(selected_papers[:8], start=1):
1024
  pid = f"paper_{i}"
1025
- nodes.append({
1026
- "id": pid,
1027
- "label": paper.get("title", f"Paper {i}"),
1028
- "kind": "paper",
1029
- "title": paper.get("title"),
1030
- "venue": paper.get("venue"),
1031
- "year": paper.get("year"),
1032
- "doi": paper.get("doi"),
1033
- "source": paper.get("source"),
1034
- "authors_text": paper.get("authors_text"),
1035
- })
1036
- edges.append({"source": "query", "target": pid, "type": "ABOUT"})
1037
 
1038
- for author in paper.get("authors", [])[:3]:
1039
- aid = f"author_{i}_{slugify(author)[:30]}"
1040
- nodes.append({"id": aid, "label": author, "kind": "author"})
1041
- edges.append({"source": pid, "target": aid, "type": "WRITTEN_BY"})
1042
 
1043
- for concept in (paper.get("concepts") or [])[:4]:
1044
- cid = f"concept_{i}_{slugify(concept)[:30]}"
1045
- nodes.append({"id": cid, "label": concept, "kind": "concept"})
1046
- edges.append({"source": pid, "target": cid, "type": "MENTIONS"})
1047
 
1048
- for claim in (paper.get("claims") or [])[:2]:
1049
- cid = f"claim_{i}_{slugify(claim)[:30]}"
1050
- nodes.append({"id": cid, "label": truncate_text(claim, 82), "kind": "claim", "text": claim})
1051
- edges.append({"source": pid, "target": cid, "type": "ASSERTS"})
 
 
 
 
1052
 
1053
- if uploaded_name:
1054
- nodes.append({"id": "upload", "label": uploaded_name, "kind": "upload", "title": uploaded_name})
1055
- edges.append({"source": "query", "target": "upload", "type": "UPLOADED_SOURCE"})
1056
- if parsed_state and isinstance(parsed_state, dict):
1057
- for concept in (parsed_state.get("concepts") or [])[:4]:
1058
- cid = f"upload_concept_{slugify(concept)[:30]}"
1059
- nodes.append({"id": cid, "label": concept, "kind": "concept"})
1060
- edges.append({"source": "upload", "target": cid, "type": "MENTIONS"})
1061
- for claim in (parsed_state.get("claims") or [])[:3]:
1062
- cid = f"upload_claim_{slugify(claim)[:30]}"
1063
- nodes.append({"id": cid, "label": truncate_text(claim, 82), "kind": "claim", "text": claim})
1064
- edges.append({"source": "upload", "target": cid, "type": "ASSERTS"})
1065
- for ref in (parsed_state.get("references") or [])[:6]:
1066
- if ref.get("title"):
1067
- rid = f"ref_{slugify(ref.get('title'))[:30]}"
1068
- nodes.append({"id": rid, "label": truncate_text(ref.get("title"), 82), "kind": "reference", "doi": ref.get("doi", "")})
1069
- edges.append({"source": "upload", "target": rid, "type": "CITES"})
1070
-
1071
- for j, fp in enumerate(ensure_list(frontier)[:6], start=1):
1072
- fid = f"frontier_{j}"
1073
- nodes.append({
1074
- "id": fid,
1075
- "label": fp.get("title", f"Frontier {j}"),
1076
- "kind": "frontier",
1077
- "title": fp.get("title"),
1078
- "source": fp.get("source"),
1079
- "authors_text": fp.get("authors_text"),
1080
- "year": fp.get("year"),
1081
- "doi": fp.get("doi"),
1082
- })
1083
- edges.append({"source": "query", "target": fid, "type": "FRONTIER_CANDIDATE"})
1084
 
1085
- dedup_nodes = []
1086
- seen = set()
1087
- for node in nodes:
1088
- if node["id"] not in seen:
1089
- seen.add(node["id"])
1090
- dedup_nodes.append(node)
1091
- return dedup_nodes, edges
 
 
 
 
 
 
 
 
 
 
 
 
1092
 
 
 
 
 
 
 
 
 
 
 
1093
 
1094
- # ----------------------------
1095
- # PDF parsing
1096
- # ----------------------------
1097
 
1098
- def parse_pdf_with_pymupdf(pdf_path: str) -> Dict[str, Any]:
1099
  if fitz is None:
1100
  raise RuntimeError("PyMuPDF not installed")
1101
 
1102
  doc = fitz.open(pdf_path)
1103
- page_texts = [page.get_text("text") for page in doc[: min(len(doc), 10)]]
1104
- raw_text = truncate_text(clean_extracted_text("\n".join(page_texts).strip()), MAX_RAW_TEXT_CHARS)
1105
- first_page = clean_extracted_text("\n".join(page_texts[:2]))[:5000]
1106
-
1107
- title = extract_title_from_text(first_page, fallback=Path(pdf_path).name)
1108
 
1109
  abstract = ""
1110
- match = re.search(r"abstract\s*(.+?)(?:\n\s*\n|\n(?:1|i)[\.\s]|\nintroduction)", raw_text, re.I | re.S)
1111
  if match:
1112
- abstract = truncate_text(clean_extracted_text(match.group(1)), 2600)
1113
-
1114
- sections = []
1115
- blocks = re.split(r"\n(?=[A-Z][A-Za-z\s]{2,40}\n)", raw_text)
1116
- for block in blocks[:10]:
1117
- lines = [norm_text(x) for x in block.splitlines() if norm_text(x)]
1118
- if not lines:
1119
- continue
1120
- heading = lines[0] if len(lines[0]) < 60 else "Section"
1121
- body = " ".join(lines[1:] if len(lines) > 1 else lines)
1122
- if len(body) > 80:
1123
- sections.append({"heading": clean_extracted_text(heading), "text": truncate_text(body, 4200)})
1124
 
1125
  return {
1126
  "parser": "pymupdf",
1127
  "title": title,
1128
  "abstract": abstract,
1129
  "authors": [],
1130
- "sections": sections[:12] or ([{"heading": "Full Text", "text": raw_text[:12000]}] if raw_text else []),
1131
- "references": extract_references_from_text(raw_text),
1132
- "claims": extract_claim_like_sentences(raw_text, max_items=GRAPH_MAX_CLAIMS),
1133
- "concepts": extract_concepts_from_text(" ".join([title, abstract, raw_text[:18000]]), max_terms=GRAPH_MAX_CONCEPTS),
1134
- "raw_text": raw_text,
1135
- "parser_quality": "text-fallback-cleaned",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1136
  }
1137
 
1138
 
1139
- def parse_uploaded_pdf(file_obj, parser_order=None):
1140
  if not file_obj:
1141
- return "### PDF parse status\n\nNo PDF uploaded yet.", {}
1142
 
1143
  path = getattr(file_obj, "name", None) or str(file_obj)
1144
- parser_order = ensure_list(parser_order) or PDF_PARSERS
1145
  errors = []
1146
 
1147
  for parser_name in parser_order:
1148
  try:
1149
- if parser_name == "pymupdf":
 
 
 
 
1150
  result = parse_pdf_with_pymupdf(path)
1151
  else:
1152
  continue
1153
 
1154
  summary = (
1155
- f"### PDF parse status\n\n"
1156
  f"- Parser used: {result['parser']}\n"
1157
- f"- Parser quality: {result.get('parser_quality', 'unknown')}\n"
1158
  f"- Title: {result.get('title') or 'Unknown'}\n"
1159
  f"- Authors: {', '.join(result.get('authors')[:6]) if result.get('authors') else 'Unknown'}\n"
1160
  f"- Abstract found: {'Yes' if result.get('abstract') else 'No'}\n"
1161
  f"- Sections extracted: {len(result.get('sections') or [])}\n"
1162
  f"- References extracted: {len(result.get('references') or [])}\n"
1163
- f"- Concepts extracted: {len(result.get('concepts') or [])}\n"
1164
- f"- Claims extracted: {len(result.get('claims') or [])}\n"
1165
  )
1166
  return summary, result
1167
  except Exception as e:
1168
  errors.append(f"{parser_name}: {e}")
1169
 
1170
- fail_summary = "### PDF parse status\n\n" + "\n".join([f"- {x}" for x in errors])
1171
  return fail_summary, {"parser": None, "errors": errors}
1172
 
1173
 
1174
  def render_parse_result(parsed):
1175
- if not parsed or not isinstance(parsed, dict) or (not parsed.get("title") and not parsed.get("sections")):
1176
  return '<div class="panel" style="padding:18px"><p>No parsed document yet.</p></div>'
1177
 
1178
  sections_html = []
1179
- for section in parsed.get("sections", [])[:8]:
1180
  sections_html.append(
1181
  f"""
1182
  <details class="agent-step">
@@ -1188,27 +827,21 @@ def render_parse_result(parsed):
1188
  </div>
1189
  </summary>
1190
  <div class="agent-copy">
1191
- <p>{safe_text(section.get('text', '')[:2200])}</p>
1192
  </div>
1193
  </details>
1194
  """
1195
  )
1196
 
1197
- refs = parsed.get("references", [])[:14]
1198
  refs_html = "".join(
1199
  f"<li>{safe_text(r.get('title') or 'Untitled')} {'· DOI ' + safe_text(r.get('doi')) if r.get('doi') else ''}</li>"
1200
  for r in refs
1201
  ) or "<li>No references extracted.</li>"
1202
 
1203
- concepts = parsed.get("concepts", [])[:12]
1204
- claims = parsed.get("claims", [])[:8]
1205
- concepts_html = "".join(f"<li>{safe_text(x)}</li>" for x in concepts) or "<li>No concepts extracted.</li>"
1206
- claims_html = "".join(f"<li>{safe_text(x)}</li>" for x in claims) or "<li>No claims extracted.</li>"
1207
-
1208
  title = safe_text(parsed.get("title") or "Parsed document")
1209
- abstract = safe_text((parsed.get("abstract") or "")[:2600]) or "No abstract extracted."
1210
  parser_name = safe_text(parsed.get("parser") or "unknown")
1211
- parser_quality = safe_text(parsed.get("parser_quality") or "unknown")
1212
 
1213
  return f"""
1214
  <div class="panel" style="padding:18px">
@@ -1217,7 +850,7 @@ def render_parse_result(parsed):
1217
  <p class="eyebrow">PDF Parse</p>
1218
  <h3>{title}</h3>
1219
  </div>
1220
- <div class="brain-legend"><span><i class="dot dot-upload"></i> {parser_name} · {parser_quality}</span></div>
1221
  </div>
1222
  <div class="parse-grid">
1223
  <div class="parse-card">
@@ -1228,433 +861,61 @@ def render_parse_result(parsed):
1228
  <h4>References</h4>
1229
  <ul class="ref-list">{refs_html}</ul>
1230
  </div>
1231
- <div class="parse-card">
1232
- <h4>Concepts</h4>
1233
- <ul class="ref-list">{concepts_html}</ul>
1234
- </div>
1235
- <div class="parse-card">
1236
- <h4>Claims</h4>
1237
- <ul class="ref-list">{claims_html}</ul>
1238
- </div>
1239
  </div>
1240
- <div class="timeline" style="margin-top:14px; max-height:560px; overflow:auto;">
1241
  {''.join(sections_html) if sections_html else '<div class="panel" style="padding:16px;"><p>No sections extracted.</p></div>'}
1242
  </div>
1243
  </div>
1244
  """
1245
 
1246
 
1247
- # ----------------------------
1248
- # Ingest payload / learning
1249
- # ----------------------------
1250
-
1251
- def add_node(nodes_by_id: Dict[str, Dict], node_id: str, node_type: str, label: str = "", **attrs):
1252
- if not node_id:
1253
- return
1254
- current = nodes_by_id.get(node_id, {})
1255
- merged = {"id": node_id, "type": node_type, "label": label or current.get("label", node_id)}
1256
- merged.update(current)
1257
- for key, value in attrs.items():
1258
- if value not in [None, ""]:
1259
- merged[key] = value
1260
- nodes_by_id[node_id] = merged
1261
-
1262
-
1263
- def add_edge(edges: List[Dict], source: str, target: str, edge_type: str, **attrs):
1264
- if not source or not target or source == target:
1265
- return
1266
- edge = {"source": source, "target": target, "type": edge_type}
1267
- for key, value in attrs.items():
1268
- if value not in [None, ""]:
1269
- edge[key] = value
1270
- edges.append(edge)
1271
-
1272
-
1273
- def build_ingest_payload(query, selected_papers, parsed_pdf=None, frontier=None):
1274
- nodes_by_id = {}
1275
  edges = []
1276
 
1277
- topic_id = "topic:query"
1278
- add_node(nodes_by_id, topic_id, "Topic", label=query or "Research topic", query=query or "")
1279
-
1280
- for i, paper in enumerate(selected_papers, start=1):
1281
- paper_id = normalize_doi(paper.get("doi")) or ((paper.get("external_ids") or {}).get("arxiv")) or f"paper:{i}:{slugify(paper.get('title', 'paper'))[:40]}"
1282
- add_node(
1283
- nodes_by_id,
1284
- paper_id,
1285
- "Paper",
1286
- label=paper.get("title") or f"Paper {i}",
1287
- title=paper.get("title"),
1288
- year=paper.get("year"),
1289
- venue=paper.get("venue"),
1290
- doi=normalize_doi(paper.get("doi")),
1291
- source=paper.get("source"),
1292
- url=paper.get("url"),
1293
- pdf=paper.get("pdf"),
1294
- score=paper.get("score"),
1295
- learned_score=paper.get("learned_score", paper.get("score")),
1296
- open_access=paper.get("open_access"),
1297
- authors_text=paper.get("authors_text"),
1298
- )
1299
- add_edge(edges, topic_id, paper_id, "ABOUT", weight=paper.get("learned_score", paper.get("score", 0)))
1300
-
1301
- for author in paper.get("authors", [])[:6]:
1302
- author_id = f"author:{slugify(author)[:64]}"
1303
- add_node(nodes_by_id, author_id, "Author", label=author, name=author)
1304
- add_edge(edges, paper_id, author_id, "WRITTEN_BY")
1305
-
1306
- for concept in (paper.get("concepts") or [])[:8]:
1307
- concept_id = f"concept:{slugify(concept)[:72]}"
1308
- add_node(nodes_by_id, concept_id, "Concept", label=concept, name=concept)
1309
- add_edge(edges, paper_id, concept_id, "MENTIONS")
1310
 
1311
- for claim in (paper.get("claims") or [])[:4]:
1312
- claim_id = f"claim:{slugify(claim)[:72]}"
1313
- add_node(nodes_by_id, claim_id, "Claim", label=claim[:140], text=claim)
1314
- add_edge(edges, paper_id, claim_id, "ASSERTS")
1315
 
1316
- if parsed_pdf and isinstance(parsed_pdf, dict) and parsed_pdf.get("title"):
1317
  doc_id = "upload:pdf"
1318
- add_node(nodes_by_id, doc_id, "UploadedPDF", label=parsed_pdf.get("title"), title=parsed_pdf.get("title"), parser=parsed_pdf.get("parser"))
1319
- add_edge(edges, topic_id, doc_id, "UPLOADED_SOURCE")
1320
-
1321
- for concept in (parsed_pdf.get("concepts") or [])[:8]:
1322
- concept_id = f"concept:{slugify(concept)[:72]}"
1323
- add_node(nodes_by_id, concept_id, "Concept", label=concept, name=concept)
1324
- add_edge(edges, doc_id, concept_id, "MENTIONS")
1325
-
1326
- for claim in (parsed_pdf.get("claims") or [])[:6]:
1327
- claim_id = f"claim:{slugify(claim)[:72]}"
1328
- add_node(nodes_by_id, claim_id, "Claim", label=claim[:140], text=claim)
1329
- add_edge(edges, doc_id, claim_id, "ASSERTS")
1330
-
1331
- for idx, ref in enumerate(parsed_pdf.get("references", [])[:20], start=1):
1332
- ref_title = ref.get("title") or f"Reference {idx}"
1333
- ref_doi = normalize_doi(ref.get("doi") or "")
1334
- ref_id = ref_doi or f"ref:{idx}:{slugify(ref_title)[:40]}"
1335
- add_node(nodes_by_id, ref_id, "Reference", label=ref_title, title=ref_title, doi=ref_doi)
1336
- add_edge(edges, doc_id, ref_id, "CITES")
1337
-
1338
- for idx, item in enumerate(ensure_list(frontier)[:18], start=1):
1339
- fid = normalize_doi(item.get("doi")) or f"frontier:{idx}:{slugify(item.get('title', 'paper'))[:40]}"
1340
- add_node(
1341
- nodes_by_id,
1342
- fid,
1343
- "FrontierPaper",
1344
- label=item.get("title") or f"Frontier {idx}",
1345
- title=item.get("title"),
1346
- frontier_score=item.get("frontier_score"),
1347
- url=item.get("url"),
1348
- source=item.get("source"),
1349
- authors_text=item.get("authors_text"),
1350
- year=item.get("year"),
1351
- doi=item.get("doi"),
1352
- )
1353
- add_edge(edges, topic_id, fid, "FRONTIER_CANDIDATE", weight=item.get("frontier_score", item.get("learned_score", item.get("score", 0))))
1354
-
1355
- return {"status": "ok", "nodes": list(nodes_by_id.values())[:GRAPH_MAX_NODES], "edges": edges[:GRAPH_MAX_EDGES]}
1356
-
1357
-
1358
- def learn_from_payload(payload: Dict, query: str = "") -> Dict:
1359
- if not payload:
1360
- return GRAPH_MEMORY
1361
-
1362
- GRAPH_MEMORY["queries"].append(query or "")
1363
- GRAPH_MEMORY["events"].append({
1364
- "ts": time.time(),
1365
- "query": query or "",
1366
- "nodes": len(payload.get("nodes", [])),
1367
- "edges": len(payload.get("edges", [])),
1368
- })
1369
- GRAPH_MEMORY["payloads"].append(payload)
1370
-
1371
- for node in payload.get("nodes", []):
1372
- node_id = node.get("id")
1373
- if not node_id:
1374
- continue
1375
- GRAPH_MEMORY["nodes"][node_id] = node
1376
- node_type = (node.get("type") or "").lower()
1377
- if node_type in {"paper", "frontierpaper"}:
1378
- GRAPH_MEMORY["papers"][node_id] = node
1379
- if node_type == "concept" and node.get("label"):
1380
- GRAPH_MEMORY["concept_counts"][node["label"].lower()] += 1
1381
- if node_type == "claim" and node.get("label"):
1382
- GRAPH_MEMORY["claim_counts"][node["label"].lower()] += 1
1383
-
1384
- GRAPH_MEMORY["edges"].extend(payload.get("edges", []))
1385
- GRAPH_MEMORY["edges"] = GRAPH_MEMORY["edges"][:GRAPH_MAX_EDGES]
1386
- return GRAPH_MEMORY
1387
-
1388
-
1389
- def export_learning_state() -> str:
1390
- snapshot = {
1391
- "papers": list(GRAPH_MEMORY["papers"].values())[:60],
1392
- "nodes": list(GRAPH_MEMORY["nodes"].values())[:250],
1393
- "edges": GRAPH_MEMORY["edges"][:500],
1394
- "top_concepts": GRAPH_MEMORY["concept_counts"].most_common(24),
1395
- "top_claims": GRAPH_MEMORY["claim_counts"].most_common(24),
1396
- "queries": GRAPH_MEMORY["queries"][-20:],
1397
- "events": GRAPH_MEMORY["events"][-20:],
1398
- "frontier": GRAPH_MEMORY["frontier"][:24],
1399
- }
1400
- return json.dumps(snapshot, indent=2, ensure_ascii=False)
1401
-
1402
-
1403
- # ----------------------------
1404
- # Frontier expansion
1405
- # ----------------------------
1406
-
1407
- def score_frontier_candidate(query: str, seed_concepts: List[str], paper: Dict[str, Any]) -> Dict[str, Any]:
1408
- title = paper.get("title", "")
1409
- abstract = paper.get("abstract", "") or paper.get("summary", "")
1410
- venue = paper.get("venue", "")
1411
- base_text = " ".join([title, abstract, venue])
1412
- rel = text_overlap_score(query, base_text)
1413
- concept_overlap = text_overlap_score(" ".join(seed_concepts), " ".join(paper.get("concepts") or [])) if seed_concepts else 0.0
1414
- recency = compute_recency_bonus(paper.get("year"))
1415
- doi_bonus = 0.02 if paper.get("doi") else 0.0
1416
- oa_bonus = 0.03 if paper.get("open_access") else 0.0
1417
- score = float(paper.get("learned_score", paper.get("score", 0))) + rel * 0.45 + concept_overlap * 0.22 + recency + doi_bonus + oa_bonus
1418
- paper["frontier_score"] = round(score, 4)
1419
- paper["frontier_relevance"] = round(rel, 4)
1420
- paper["frontier_concept_overlap"] = round(concept_overlap, 4)
1421
- return paper
1422
-
1423
-
1424
- def propose_expansion_queries(query: str, papers: List[Dict], parsed_state: Optional[Dict] = None, limit: int = GRAPH_MAX_EXPANSIONS) -> List[str]:
1425
- concept_pool = []
1426
- venue_pool = []
1427
- for paper in papers[:8]:
1428
- concept_pool.extend((paper.get("concepts") or [])[:4])
1429
- if paper.get("venue"):
1430
- venue_pool.append(paper["venue"])
1431
- if parsed_state and isinstance(parsed_state, dict):
1432
- concept_pool.extend((parsed_state.get("concepts") or [])[:6])
1433
-
1434
- ranked_concepts = [c for c, _ in Counter([norm_text(c).lower() for c in concept_pool if c]).most_common(limit * 2)]
1435
- expansions = [norm_text(query)] if query else []
1436
- for concept in ranked_concepts:
1437
- if concept:
1438
- expansions.append(f"{query} {concept}".strip())
1439
- for venue in unique_keep_order(venue_pool)[:2]:
1440
- if venue:
1441
- expansions.append(f"{query} {venue}".strip())
1442
- return unique_keep_order(expansions)[:limit]
1443
-
1444
-
1445
- def frontier_expand(query: str, sources: List[str], selected_papers: List[Dict], parsed_state: Optional[Dict] = None, per_query: int = 4) -> List[Dict]:
1446
- seed_concepts = []
1447
- for p in selected_papers[:6]:
1448
- seed_concepts.extend((p.get("concepts") or [])[:4])
1449
- if parsed_state and isinstance(parsed_state, dict):
1450
- seed_concepts.extend((parsed_state.get("concepts") or [])[:6])
1451
-
1452
- expansion_queries = propose_expansion_queries(query, selected_papers, parsed_state=parsed_state, limit=GRAPH_MAX_EXPANSIONS)
1453
- frontier = []
1454
- for eq in expansion_queries:
1455
- try:
1456
- items = discover_papers(eq, "topic", sources, max_results=per_query)
1457
- for item in items:
1458
- frontier.append(score_frontier_candidate(query or eq, seed_concepts, item))
1459
- except Exception:
1460
- continue
1461
-
1462
- frontier = dedupe_papers(frontier)
1463
- frontier.sort(key=lambda x: float(x.get("frontier_score", x.get("learned_score", x.get("score", 0)))), reverse=True)
1464
- GRAPH_MEMORY["frontier"] = frontier[: GRAPH_MAX_EXPANSIONS * per_query]
1465
- return GRAPH_MEMORY["frontier"]
1466
-
1467
-
1468
- def autonomous_expand_into_markdown(query, payload, parsed_state=None):
1469
- frontier = GRAPH_MEMORY.get("frontier") or []
1470
- lines = [
1471
- "### Autonomous expansion plan",
1472
- "",
1473
- f"- Seed query: {query or 'Research topic'}",
1474
- f"- Current nodes: {len(payload.get('nodes', [])) if isinstance(payload, dict) else 0}",
1475
- f"- Current edges: {len(payload.get('edges', [])) if isinstance(payload, dict) else 0}",
1476
- f"- Frontier candidates: {len(frontier)}",
1477
- ]
1478
-
1479
- proposed = propose_expansion_queries(query or "", list(GRAPH_MEMORY.get("papers", {}).values())[:8], parsed_state=parsed_state, limit=GRAPH_MAX_EXPANSIONS)
1480
- if proposed:
1481
- lines.extend(["", "#### Proposed next queries", ""])
1482
- lines.extend([f"- {q}" for q in proposed])
1483
-
1484
- if frontier:
1485
- lines.extend(["", "#### Top frontier papers", ""])
1486
- for item in frontier[:8]:
1487
- lines.append(
1488
- f"- {item.get('title', 'Untitled')} ({item.get('source', 'unknown')}) — frontier score {item.get('frontier_score', item.get('learned_score', item.get('score', 0)))}"
1489
- )
1490
- return "\n".join(lines)
1491
-
1492
-
1493
- # ----------------------------
1494
- # Selection / ingest
1495
- # ----------------------------
1496
-
1497
- def resolve_selected_papers(selected_indices, papers_state):
1498
- papers = ensure_list(papers_state)
1499
- selected_indices = ensure_list(selected_indices)
1500
- selected = []
1501
- if not selected_indices:
1502
- return selected
1503
-
1504
- value_map = {paper_choice_value(i, paper): paper for i, paper in enumerate(papers)}
1505
- label_map = {paper_choice_label(i, paper): paper for i, paper in enumerate(papers)}
1506
-
1507
- for idx in selected_indices:
1508
- try:
1509
- if isinstance(idx, int):
1510
- if 0 <= idx < len(papers):
1511
- selected.append(papers[idx])
1512
- continue
1513
- idx_str = str(idx)
1514
- if idx_str in value_map:
1515
- selected.append(value_map[idx_str])
1516
- continue
1517
- if idx_str.isdigit():
1518
- num = int(idx_str)
1519
- if 0 <= num < len(papers):
1520
- selected.append(papers[num])
1521
- continue
1522
- if "|" in idx_str:
1523
- left = idx_str.split("|", 1)[0]
1524
- if left.isdigit():
1525
- num = int(left)
1526
- if 0 <= num < len(papers):
1527
- selected.append(papers[num])
1528
- continue
1529
- if idx_str in label_map:
1530
- selected.append(label_map[idx_str])
1531
- continue
1532
- except Exception:
1533
- continue
1534
-
1535
- out = []
1536
- seen = set()
1537
- for paper in selected:
1538
- key = paper_identity_key(paper)
1539
- if key not in seen:
1540
- seen.add(key)
1541
- out.append(paper)
1542
- return out
1543
-
1544
-
1545
- def build_ingest_status_markdown(query_text: str, payload: Dict, selected: List[Dict], parsed_state: Optional[Dict], frontier: List[Dict]) -> str:
1546
- payload = payload or {"nodes": [], "edges": []}
1547
- nodes = payload.get("nodes", [])
1548
- edges = payload.get("edges", [])
1549
- counts = Counter((node.get("type") or "Unknown") for node in nodes)
1550
-
1551
- node_lines = []
1552
- for idx, node in enumerate(nodes[:24], start=1):
1553
- label = node.get("label") or node.get("title") or node.get("id")
1554
- node_lines.append(f"- {idx}. [{node.get('type', 'Node')}] {label}")
1555
-
1556
- edge_lines = []
1557
- for idx, edge in enumerate(edges[:30], start=1):
1558
- edge_lines.append(f"- {idx}. {edge.get('source')} --{edge.get('type', 'RELATES_TO')}--> {edge.get('target')}")
1559
-
1560
- return "\n".join([
1561
- "### Graph ingest status",
1562
- "",
1563
- f"- Topic: {query_text}",
1564
- f"- Selected papers ingested: {len(selected)}",
1565
- f"- Uploaded PDF parsed: {'Yes' if parsed_state and isinstance(parsed_state, dict) and parsed_state.get('title') else 'No'}",
1566
- f"- Frontier candidates added: {len(frontier)}",
1567
- f"- Total nodes created: {len(nodes)}",
1568
- f"- Total edges created: {len(edges)}",
1569
- f"- Node breakdown: {', '.join([f'{k}={v}' for k, v in counts.items()]) if counts else 'None'}",
1570
- "",
1571
- "### Nodes",
1572
- *(node_lines or ["- None"]),
1573
- "",
1574
- "### Edges",
1575
- *(edge_lines or ["- None"]),
1576
- ])
1577
-
1578
-
1579
- def ingest_selected_papers(query, selected_indices, papers_state, pdf_file, parsed_state):
1580
- papers = ensure_list(papers_state)
1581
- selected = resolve_selected_papers(selected_indices, papers)
1582
- uploaded_name = Path(getattr(pdf_file, "name", str(pdf_file))).name if pdf_file else None
1583
-
1584
- if not selected and papers:
1585
- selected = papers[:3]
1586
-
1587
- if not selected and parsed_state and isinstance(parsed_state, dict) and parsed_state.get("title"):
1588
- selected = []
1589
-
1590
- if not selected and not (parsed_state and isinstance(parsed_state, dict) and parsed_state.get("title")):
1591
- graph_html = build_learning_graph_html([], [], "Self-Learning Knowledge Graph")
1592
- empty_payload = {"status": "empty", "nodes": [], "edges": []}
1593
- return (
1594
- graph_html,
1595
- "### Graph ingest status\n\n- No papers were selected and no parsed PDF is available.\n- Select papers or parse an uploaded PDF first.",
1596
- empty_payload,
1597
- )
1598
-
1599
- query_text = norm_text(query or "")
1600
- if not query_text and isinstance(parsed_state, dict):
1601
- query_text = parsed_state.get("title") or "Research topic"
1602
- if not query_text:
1603
- query_text = "Research topic"
1604
-
1605
- selected = [enrich_paper_semantics(query_text, paper) for paper in selected]
1606
- frontier = frontier_expand(query_text, DEFAULT_SOURCES, selected or papers[:3], parsed_state=parsed_state if isinstance(parsed_state, dict) else None, per_query=3)
1607
-
1608
- graph_nodes, graph_edges = graph_from_selected(
1609
- query_text,
1610
- selected,
1611
- uploaded_name,
1612
- parsed_state if isinstance(parsed_state, dict) else None,
1613
- frontier=frontier,
1614
- )
1615
- graph_html = build_learning_graph_html(graph_nodes, graph_edges, "Selected Research Graph")
1616
-
1617
- payload = build_ingest_payload(
1618
- query_text,
1619
- selected,
1620
- parsed_state if isinstance(parsed_state, dict) else None,
1621
- frontier=frontier,
1622
- )
1623
- learn_from_payload(payload, query=query_text)
1624
-
1625
- status_md = build_ingest_status_markdown(
1626
- query_text,
1627
- payload,
1628
- selected,
1629
- parsed_state if isinstance(parsed_state, dict) else None,
1630
- frontier,
1631
- )
1632
-
1633
- return graph_html, status_md, payload
1634
-
1635
-
1636
- # ----------------------------
1637
- # Discovery
1638
- # ----------------------------
1639
 
1640
- def summarize_learning_state(query_text, papers, selected_sources):
1641
- concept_pool = []
1642
- for paper in papers[:8]:
1643
- concept_pool.extend((paper.get("concepts") or [])[:4])
1644
- top_concepts = [c for c, _ in Counter([c.lower() for c in concept_pool]).most_common(8)]
1645
- return (
1646
- "### Discovery results\n\n"
1647
- f"- Query: {query_text}\n"
1648
- f"- Sources: {', '.join(selected_sources)}\n"
1649
- f"- Candidates found: {len(papers)}\n"
1650
- f"- Top learned concepts: {', '.join(top_concepts) if top_concepts else 'None'}\n"
1651
- "- Select papers below, or leave selection empty to auto-ingest the top papers.\n"
1652
- )
1653
 
1654
 
1655
  def run_paper_discovery(query, search_mode, sources, pdf_file):
1656
- query_text = norm_text(query or "")
1657
- selected_sources = ensure_list(sources) or DEFAULT_SOURCES
1658
 
1659
  if not query_text and not pdf_file:
1660
  empty_graph = build_learning_graph_html([], [], "Self-Learning Knowledge Graph")
@@ -1668,71 +929,73 @@ def run_paper_discovery(query, search_mode, sources, pdf_file):
1668
  "### No discovery results yet.",
1669
  )
1670
 
1671
- if not query_text and pdf_file:
1672
- uploaded_name = Path(getattr(pdf_file, "name", str(pdf_file))).name
1673
- graph_nodes, graph_edges = build_learning_graph_state("", [], uploaded_name)
1674
- return (
1675
- build_learning_graph_html(graph_nodes, graph_edges, "Uploaded PDF Waiting for Parse"),
1676
- '<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>',
1677
- build_journal_html("biomaterials cardiac repair"),
1678
- uploaded_pdf_summary(pdf_file),
1679
- gr.update(choices=[], value=[]),
1680
- [],
1681
- "### Upload detected.\n\n- Parse the PDF to extract structure.\n- Or enter a topic to start discovery.",
1682
- )
 
 
 
 
 
 
 
 
1683
 
1684
  uploaded_name = Path(getattr(pdf_file, "name", str(pdf_file))).name if pdf_file else None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1685
 
1686
- try:
1687
- papers = discover_papers(query_text, search_mode, selected_sources, max_results=GRAPH_MAX_RESULTS)
1688
- graph_nodes, graph_edges = build_learning_graph_state(query_text, papers[:6], uploaded_name)
1689
- graph_html = build_learning_graph_html(graph_nodes, graph_edges, "Self-Learning Knowledge Graph")
1690
- papers_html = format_papers_html(papers)
1691
- journals_html = build_journal_html(query_text or "biomaterials cardiac repair")
1692
- pdf_summary = uploaded_pdf_summary(pdf_file)
1693
- choices = format_selection_choices(papers)
1694
- status_md = summarize_learning_state(query_text, papers, selected_sources)
1695
- return (
1696
- graph_html,
1697
- papers_html,
1698
- journals_html,
1699
- pdf_summary,
1700
- gr.update(choices=choices, value=[]),
1701
- papers,
1702
- status_md,
1703
- )
1704
- except Exception as e:
1705
- graph_nodes, graph_edges = build_learning_graph_state(query_text, [], uploaded_name)
1706
- error_html = f'<div class="panel papers-panel" style="padding:18px"><p>Paper search failed: {safe_text(str(e))}</p></div>'
1707
- return (
1708
- build_learning_graph_html(graph_nodes, graph_edges),
1709
- error_html,
1710
- build_journal_html(query_text or "biomaterials cardiac repair"),
1711
- uploaded_pdf_summary(pdf_file),
1712
- gr.update(choices=[], value=[]),
1713
- [],
1714
- f"### Discovery failed.\n\n- Error: {safe_text(str(e))}",
1715
- )
1716
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1717
 
1718
- __all__ = [
1719
- "SEARCH_MODES",
1720
- "SOURCE_OPTIONS",
1721
- "DEFAULT_SOURCES",
1722
- "PDF_PARSERS",
1723
- "GRAPH_MEMORY",
1724
- "discover_papers",
1725
- "run_paper_discovery",
1726
- "parse_uploaded_pdf",
1727
- "render_parse_result",
1728
- "ingest_selected_papers",
1729
- "build_ingest_payload",
1730
- "learn_from_payload",
1731
- "frontier_expand",
1732
- "autonomous_expand_into_markdown",
1733
- "export_learning_state",
1734
- "format_frontier_html",
1735
- "build_learning_graph_html",
1736
- "build_journal_html",
1737
- "safe_text",
1738
- ]
 
1
  import html
2
+ import os
3
  import re
 
4
  import urllib.parse
5
  import xml.etree.ElementTree as ET
 
6
  from pathlib import Path
7
+ from typing import Dict, List, Optional
8
+ from urllib.parse import quote
9
 
10
  import gradio as gr
11
  import requests
 
15
  except Exception:
16
  fitz = None
17
 
18
+ try:
19
+ from bs4 import BeautifulSoup
20
+ except Exception:
21
+ BeautifulSoup = None
 
 
 
 
 
 
 
 
 
 
22
 
 
 
 
 
 
 
 
 
 
 
23
 
24
  JOURNALS = [
25
  {"name": "Nature", "url": "https://www.nature.com/search", "desc": "Flagship multidisciplinary research journal."},
 
29
  {"name": "IEEE Xplore", "url": "https://ieeexplore.ieee.org/search/searchresult.jsp", "desc": "Engineering, AI, signal processing, and systems."},
30
  ]
31
 
32
+ SEARCH_MODES = ["topic", "title", "doi", "link", "paper_name", "autonomous_web"]
33
+ SOURCE_OPTIONS = ["arxiv", "crossref", "openalex", "semantic_scholar", "europe_pmc"]
34
+ DEFAULT_SOURCES = ["arxiv", "openalex", "crossref", "semantic_scholar", "europe_pmc"]
35
+
36
+ SEMANTIC_SCHOLAR_API_KEY = os.getenv("SEMANTIC_SCHOLAR_API_KEY", "")
37
+ GROBID_URL = os.getenv("GROBID_URL", "").strip()
38
+ REQUEST_TIMEOUT = 25
39
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
  def safe_text(x, default=""):
42
  return html.escape(str(x if x is not None else default))
 
46
  return re.sub(r"\s+", " ", (x or "")).strip()
47
 
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  def detect_query_type(query: str) -> str:
50
  q = (query or "").strip()
51
  doi_pattern = r"^10\.\d{4,9}/[-._;()/:A-Z0-9]+$"
 
56
  return "topic"
57
 
58
 
59
+ def ensure_list(x):
60
+ return x if isinstance(x, list) else []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
 
 
 
 
 
 
 
 
 
62
 
63
+ def build_learning_graph_html(nodes, edges, title="Self-Learning Knowledge Graph"):
64
+ if not nodes:
65
+ return """
66
+ <div class="panel brain-shell">
67
+ <div class="brain-header">
68
+ <div>
69
+ <p class="eyebrow">Learning Graph</p>
70
+ <h3>Self-Learning Knowledge Graph</h3>
71
+ </div>
72
+ </div>
73
+ <div class="brain-stage learning-empty">
74
+ <div class="empty-graph-copy">
75
+ <h4>No papers mapped yet</h4>
76
+ <p>Search papers, pick a topic, select candidates, or upload a PDF to grow the graph in real time.</p>
77
+ </div>
78
+ </div>
79
+ </div>
80
+ """
81
 
82
+ node_items = []
83
+ label_items = []
84
+ edge_items = []
85
+ coords = [(110, 110), (320, 80), (540, 130), (660, 270), (550, 410), (300, 450), (110, 340), (380, 260)]
86
+
87
+ graph_nodes = [dict(n) for n in nodes[:8]]
88
+ for i, node in enumerate(graph_nodes):
89
+ x, y = coords[i]
90
+ node["sx"] = x
91
+ node["sy"] = y
92
+
93
+ node_map = {n["id"]: n for n in graph_nodes}
94
+ for a, b in edges:
95
+ if a in node_map and b in node_map:
96
+ na = node_map[a]
97
+ nb = node_map[b]
98
+ edge_items.append(
99
+ f'<line class="learn-edge" x1="{na["sx"]}" y1="{na["sy"]}" x2="{nb["sx"]}" y2="{nb["sy"]}" />'
100
+ )
101
 
102
+ for node in graph_nodes:
103
+ kind = node.get("kind", "paper")
104
+ klass = f'learn-node {kind}'
105
+ radius = 24 if kind == "query" else 20
106
+ node_items.append(
107
+ f'<circle class="{klass}" cx="{node["sx"]}" cy="{node["sy"]}" r="{radius}" />'
108
+ )
109
+ label_items.append(
110
+ f'<text class="learn-label" x="{node["sx"] + 28}" y="{node["sy"] - 10}">{safe_text(node["label"][:44])}</text>'
111
+ )
 
 
 
 
 
112
 
113
+ return f"""
114
+ <div class="panel brain-shell">
115
+ <div class="brain-header">
116
+ <div>
117
+ <p class="eyebrow">Learning Graph</p>
118
+ <h3>{safe_text(title)}</h3>
119
+ </div>
120
+ <div class="brain-legend">
121
+ <span><i class="dot dot-query"></i> query</span>
122
+ <span><i class="dot dot-paper"></i> paper</span>
123
+ <span><i class="dot dot-upload"></i> uploaded PDF</span>
124
+ </div>
125
+ </div>
126
+ <div class="brain-stage">
127
+ <svg viewBox="0 0 760 520" class="brain-svg" role="img" aria-label="Self-learning knowledge graph">
128
+ {''.join(edge_items)}
129
+ {''.join(node_items)}
130
+ {''.join(label_items)}
131
+ </svg>
132
+ </div>
133
+ </div>
134
+ """
135
 
136
 
137
+ def build_journal_html(query):
138
+ q = urllib.parse.quote_plus(query or "biomaterials cardiac repair")
139
+ rows = []
140
+ for journal in JOURNALS:
141
+ url = f"{journal['url']}?q={q}" if "?" not in journal["url"] else f"{journal['url']}&q={q}"
142
+ if "ieeexplore" in journal["url"]:
143
+ url = f"https://ieeexplore.ieee.org/search/searchresult.jsp?queryText={q}"
144
+ rows.append(
145
+ f"""
146
+ <a class="journal-card" href="{safe_text(url)}" target="_blank" rel="noopener noreferrer">
147
+ <div>
148
+ <h4>{safe_text(journal['name'])}</h4>
149
+ <p>{safe_text(journal['desc'])}</p>
150
+ </div>
151
+ <span>Open</span>
152
+ </a>
153
+ """
154
+ )
155
+ return '<div class="journal-grid">' + ''.join(rows) + '</div>'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
 
157
 
158
+ def search_arxiv(query, max_results=8):
159
  encoded = urllib.parse.quote(query)
160
  url = (
161
  "http://export.arxiv.org/api/query?search_query=all:"
 
165
  response.raise_for_status()
166
  root = ET.fromstring(response.text)
167
  ns = {"atom": "http://www.w3.org/2005/Atom"}
168
+ papers = []
 
169
  for entry in root.findall("atom:entry", ns):
170
+ title = " ".join((entry.findtext("atom:title", default="", namespaces=ns) or "").split())
171
+ summary = " ".join((entry.findtext("atom:summary", default="", namespaces=ns) or "").split())
172
+ published = entry.findtext("atom:published", default="", namespaces=ns)
173
+ paper_id = entry.findtext("atom:id", default="", namespaces=ns)
174
+ authors = [a.findtext("atom:name", default="", namespaces=ns) for a in entry.findall("atom:author", ns)]
175
  pdf_url = ""
176
  for link in entry.findall("atom:link", ns):
177
  if link.attrib.get("title") == "pdf":
178
  pdf_url = link.attrib.get("href", "")
179
  break
180
+ papers.append(
181
+ {
182
+ "id": paper_id or title,
183
+ "title": title,
184
+ "summary": summary,
185
+ "abstract": summary,
186
+ "published": published[:10],
187
+ "authors": [a for a in authors[:8] if a],
188
+ "authors_text": ", ".join([a for a in authors[:4] if a]),
189
+ "url": paper_id,
190
+ "pdf": pdf_url,
191
+ "doi": "",
192
+ "venue": "arXiv",
193
+ "year": published[:4] if published else "",
194
+ "source": "arxiv",
195
+ "score": 0.76,
196
+ "open_access": True,
197
+ "external_ids": {"arxiv": (paper_id or "").split("/")[-1]},
198
+ }
199
+ )
200
+ return papers
201
 
202
 
203
+ def search_crossref(query, mode="topic", max_results=8):
204
+ headers = {"User-Agent": "dvnc-ai-space/0.1"}
205
  if mode == "doi":
206
+ url = f"https://api.crossref.org/works/{quote(query)}"
207
  response = requests.get(url, headers=headers, timeout=REQUEST_TIMEOUT)
208
  if response.status_code != 200:
209
  return []
 
224
  for a in item.get("author", []) or []:
225
  name = " ".join(filter(None, [a.get("given"), a.get("family")])).strip()
226
  if name:
227
+ authors.append(name)
228
+ title = (item.get("title") or ["Untitled"])[0]
 
229
  year = ""
230
  for key in ["published-print", "published-online", "created"]:
231
  if item.get(key, {}).get("date-parts"):
232
  year = str(item[key]["date-parts"][0][0])
233
  break
234
+ abstract = item.get("abstract") or ""
235
+ abstract = re.sub("<.*?>", "", abstract)
236
+ doi = item.get("DOI", "")
237
  out.append({
238
  "id": doi or title,
239
+ "title": norm_text(title),
240
+ "summary": norm_text(abstract)[:400],
241
+ "abstract": norm_text(abstract),
242
  "published": year,
243
  "authors": authors,
244
  "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
245
  "url": item.get("URL", ""),
246
  "pdf": "",
247
  "doi": doi,
248
+ "venue": (item.get("container-title") or [""])[0],
249
  "year": year,
250
  "source": "crossref",
251
  "score": 0.72,
 
255
  return out
256
 
257
 
258
+ def search_openalex(query, mode="topic", max_results=8):
259
  params = {"per-page": max_results}
260
  if mode == "doi":
261
+ doi = query.lower().replace("https://doi.org/", "").replace("http://doi.org/", "")
262
  params["filter"] = f"doi:https://doi.org/{doi}"
263
  else:
264
  params["search"] = query
 
265
  response = requests.get("https://api.openalex.org/works", params=params, timeout=REQUEST_TIMEOUT)
266
+ response.raise_for_status()
 
267
  items = response.json().get("results", [])
 
268
  out = []
269
  for item in items:
270
  authors = []
271
  for auth in item.get("authorships", [])[:8]:
272
  author = auth.get("author") or {}
273
  if author.get("display_name"):
274
+ authors.append(author["display_name"])
275
  oa = item.get("open_access") or {}
276
+ doi = (item.get("doi") or "").replace("https://doi.org/", "")
 
 
277
  out.append({
278
  "id": item.get("id") or doi or item.get("title"),
279
+ "title": norm_text(item.get("title")),
280
+ "summary": "",
281
+ "abstract": "",
282
  "published": str(item.get("publication_year") or ""),
283
  "authors": authors,
284
  "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
285
+ "url": (item.get("primary_location") or {}).get("landing_page_url") or item.get("id") or "",
286
  "pdf": oa.get("oa_url") or "",
287
  "doi": doi,
288
+ "venue": ((item.get("primary_location") or {}).get("source") or {}).get("display_name") or "",
289
  "year": str(item.get("publication_year") or ""),
290
  "source": "openalex",
291
  "score": 0.80,
 
295
  return out
296
 
297
 
298
+ def search_semantic_scholar(query, mode="topic", max_results=8):
299
+ headers = {}
300
+ if SEMANTIC_SCHOLAR_API_KEY:
301
+ headers["x-api-key"] = SEMANTIC_SCHOLAR_API_KEY
302
  fields = "title,authors,year,abstract,venue,externalIds,url,openAccessPdf"
303
  if mode == "doi":
304
+ doi = query.lower().replace("https://doi.org/", "").replace("http://doi.org/", "")
305
+ url = f"https://api.semanticscholar.org/graph/v1/paper/DOI:{quote(doi)}"
306
+ response = requests.get(url, params={"fields": fields}, headers=headers, timeout=REQUEST_TIMEOUT)
307
  if response.status_code != 200:
308
  return []
309
  items = [response.json()]
 
311
  response = requests.get(
312
  "https://api.semanticscholar.org/graph/v1/paper/search",
313
  params={"query": query, "limit": max_results, "fields": fields},
314
+ headers=headers,
315
  timeout=REQUEST_TIMEOUT,
316
  )
317
  if response.status_code != 200:
 
321
  out = []
322
  for item in items:
323
  external = item.get("externalIds") or {}
324
+ authors = [a.get("name") for a in item.get("authors", []) if a.get("name")]
 
325
  out.append({
326
  "id": external.get("CorpusId") or external.get("DOI") or item.get("title"),
327
+ "title": norm_text(item.get("title")),
328
+ "summary": norm_text(item.get("abstract", ""))[:400],
329
+ "abstract": norm_text(item.get("abstract", "")),
330
  "published": str(item.get("year") or ""),
331
  "authors": authors,
332
  "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
333
  "url": item.get("url") or "",
334
+ "pdf": (item.get("openAccessPdf") or {}).get("url") or "",
335
+ "doi": external.get("DOI", ""),
336
+ "venue": item.get("venue") or "",
337
  "year": str(item.get("year") or ""),
338
  "source": "semantic_scholar",
339
  "score": 0.84,
 
343
  return out
344
 
345
 
346
+ def search_europe_pmc(query, mode="topic", max_results=8):
347
  epmc_query = f'DOI:"{query}"' if mode == "doi" else query
348
+ params = {
349
+ "query": epmc_query,
350
+ "format": "json",
351
+ "pageSize": max_results,
352
+ "resultType": "core",
353
+ }
354
  response = requests.get("https://www.ebi.ac.uk/europepmc/webservices/rest/search", params=params, timeout=REQUEST_TIMEOUT)
355
  if response.status_code != 200:
356
  return []
357
  items = response.json().get("resultList", {}).get("result", [])
 
358
  out = []
359
  for item in items:
360
  author_string = item.get("authorString", "")
361
+ authors = [x.strip() for x in author_string.split(",")[:8] if x.strip()]
362
  pmcid = item.get("pmcid", "")
363
  pdf_url = f"https://europepmc.org/articles/{pmcid}?pdf=render" if pmcid else ""
364
  landing_url = f"https://europepmc.org/article/PMC/{pmcid}" if pmcid else ""
 
365
  out.append({
366
  "id": item.get("id") or item.get("doi") or item.get("title"),
367
+ "title": norm_text(item.get("title")),
368
+ "summary": norm_text(item.get("abstractText", ""))[:400],
369
+ "abstract": norm_text(item.get("abstractText", "")),
370
  "published": str(item.get("pubYear") or ""),
371
  "authors": authors,
372
  "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
373
  "url": landing_url,
374
  "pdf": pdf_url,
375
+ "doi": item.get("doi", ""),
376
+ "venue": item.get("journalTitle", ""),
377
  "year": str(item.get("pubYear") or ""),
378
  "source": "europe_pmc",
379
  "score": 0.78,
 
383
  return out
384
 
385
 
386
+ def resolve_link(query):
387
  url = (query or "").strip()
388
  if not url:
389
  return []
390
+ try:
391
+ response = requests.get(
392
+ url,
393
+ timeout=REQUEST_TIMEOUT,
394
+ allow_redirects=True,
395
+ headers={"User-Agent": "dvnc-ai-space/0.1"},
396
+ )
397
+ content_type = response.headers.get("content-type", "")
398
+ if "pdf" in content_type or url.lower().endswith(".pdf"):
399
+ name = Path(url.split("?")[0]).name or "linked-paper.pdf"
400
+ return [{
401
+ "id": url,
402
+ "title": name,
403
+ "summary": "Direct PDF link detected.",
404
+ "abstract": "",
405
+ "published": "",
406
+ "authors": [],
407
+ "authors_text": "Unknown authors",
408
+ "url": url,
409
+ "pdf": url,
410
+ "doi": "",
411
+ "venue": "Direct PDF",
412
+ "year": "",
413
+ "source": "link",
414
+ "score": 0.66,
415
+ "open_access": True,
416
+ "external_ids": {},
417
+ }]
418
+
419
+ if BeautifulSoup is None:
420
+ return [{
421
+ "id": url,
422
+ "title": url,
423
+ "summary": "Web page resolved. Install beautifulsoup4 for DOI extraction.",
424
+ "abstract": "",
425
+ "published": "",
426
+ "authors": [],
427
+ "authors_text": "Unknown authors",
428
+ "url": url,
429
+ "pdf": "",
430
+ "doi": "",
431
+ "venue": "Web Link",
432
+ "year": "",
433
+ "source": "link",
434
+ "score": 0.48,
435
+ "open_access": None,
436
+ "external_ids": {},
437
+ }]
438
+
439
+ soup = BeautifulSoup(response.text, "html.parser")
440
+ doi = ""
441
+ for meta_name in ["citation_doi", "dc.identifier", "dc.Identifier"]:
442
+ tag = soup.find("meta", attrs={"name": meta_name})
443
+ if tag and tag.get("content"):
444
+ doi = tag["content"].strip()
445
+ break
446
+
447
+ title = soup.title.text.strip() if soup.title else url
448
+ pdf_link = ""
449
+ for a in soup.find_all("a", href=True):
450
+ href = a["href"]
451
+ if ".pdf" in href.lower():
452
+ pdf_link = href if href.startswith("http") else ""
453
+ break
454
+
455
+ if doi:
456
+ results = search_crossref(doi, mode="doi", max_results=1)
457
+ if results:
458
+ if pdf_link and not results[0].get("pdf"):
459
+ results[0]["pdf"] = pdf_link
460
+ if url and not results[0].get("url"):
461
+ results[0]["url"] = url
462
+ return results
463
+
464
+ return [{
465
+ "id": url,
466
+ "title": title,
467
+ "summary": "Landing page resolved from direct link.",
468
+ "abstract": "",
469
+ "published": "",
470
+ "authors": [],
471
+ "authors_text": "Unknown authors",
472
+ "url": url,
473
+ "pdf": pdf_link,
474
+ "doi": doi,
475
+ "venue": "Web Link",
476
+ "year": "",
477
+ "source": "link",
478
+ "score": 0.54,
479
+ "open_access": bool(pdf_link),
480
+ "external_ids": {},
481
+ }]
482
+ except Exception as e:
483
+ return [{
484
+ "id": url,
485
+ "title": "Link resolution error",
486
+ "summary": str(e),
487
+ "abstract": "",
488
+ "published": "",
489
+ "authors": [],
490
+ "authors_text": "Unknown authors",
491
+ "url": url,
492
+ "pdf": "",
493
+ "doi": "",
494
+ "venue": "Link",
495
+ "year": "",
496
+ "source": "link",
497
+ "score": 0.20,
498
+ "open_access": None,
499
+ "external_ids": {},
500
+ }]
501
+
502
+
503
+ def dedupe_papers(items: List[Dict]) -> List[Dict]:
504
+ seen = {}
505
+ for item in items:
506
+ key = (
507
+ (item.get("doi") or "").lower().strip()
508
+ or (item.get("external_ids") or {}).get("arxiv")
509
+ or norm_text(item.get("title", "")).lower()
510
+ or item.get("id")
511
+ )
512
+ if not key:
513
+ key = f"{item.get('source')}::{item.get('title')}"
514
+ if key not in seen or float(item.get("score", 0)) > float(seen[key].get("score", 0)):
515
+ seen[key] = item
516
+ return sorted(seen.values(), key=lambda x: float(x.get("score", 0)), reverse=True)
517
+
518
+
519
+ def discover_papers(query, mode, sources, max_results=10):
520
  query = (query or "").strip()
521
  if not query:
522
  return []
 
528
  if mode == "link":
529
  return dedupe_papers(resolve_link(query))
530
 
531
+ try:
532
+ if "arxiv" in selected_sources and mode != "doi":
533
+ results.extend(search_arxiv(query, max_results=max_results))
534
+ except Exception:
535
+ pass
536
+ try:
537
+ if "crossref" in selected_sources:
538
+ results.extend(search_crossref(query, mode=mode, max_results=max_results))
539
+ except Exception:
540
+ pass
541
+ try:
542
+ if "openalex" in selected_sources:
543
+ results.extend(search_openalex(query, mode=mode, max_results=max_results))
544
+ except Exception:
545
+ pass
546
+ try:
547
+ if "semantic_scholar" in selected_sources:
548
+ results.extend(search_semantic_scholar(query, mode=mode, max_results=max_results))
549
+ except Exception:
550
+ pass
551
+ try:
552
+ if "europe_pmc" in selected_sources:
553
+ results.extend(search_europe_pmc(query, mode=mode, max_results=max_results))
554
+ except Exception:
555
+ pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
556
 
557
+ return dedupe_papers(results)
 
558
 
559
 
560
  def format_papers_html(papers):
 
563
 
564
  items = []
565
  for i, paper in enumerate(papers, start=1):
566
+ summary = safe_text((paper.get("summary") or "")[:280])
567
  doi_line = f'<span class="paper-badge doi-badge">{safe_text(paper.get("doi"))}</span>' if paper.get("doi") else ""
568
  pdf_link = paper.get("pdf") or "#"
569
  abs_link = paper.get("url") or "#"
 
 
570
  items.append(
571
  f"""
572
  <article class="paper-card">
 
580
  <div class="paper-meta-stack">
581
  <div><strong>Authors:</strong> {safe_text(paper.get('authors_text', 'Unknown authors'))}</div>
582
  <div><strong>Venue:</strong> {safe_text(paper.get('venue', 'Unknown venue'))}</div>
583
+ <div><strong>Score:</strong> {safe_text(round(float(paper.get('score', 0)), 3))}</div>
 
584
  </div>
585
  <div class="paper-links">
586
  <a href="{safe_text(abs_link)}" target="_blank" rel="noopener noreferrer">Abstract</a>
 
592
  return '<div class="papers-grid">' + ''.join(items) + '</div>'
593
 
594
 
595
+ def format_selection_choices(papers):
596
+ choices = []
597
+ for i, paper in enumerate(papers):
598
+ label = f"[{paper.get('source', 'src')}] {paper.get('title', 'Untitled')} — {paper.get('authors_text', 'Unknown authors')[:80]}"
599
+ choices.append((label, str(i)))
600
+ return choices
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
601
 
602
 
603
  def uploaded_pdf_summary(file_obj):
 
605
  return "No PDF uploaded yet."
606
  path = getattr(file_obj, "name", None) or str(file_obj)
607
  p = Path(path)
608
+ return f"Uploaded PDF ready for ingestion: {p.name}. Use Parse uploaded PDF to extract title, abstract, sections, and references."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
609
 
610
 
611
  def build_learning_graph_state(query, papers, uploaded_name=None):
612
  nodes = [{"id": "query", "label": query or "Research topic", "kind": "query"}]
613
  edges = []
614
+ for i, paper in enumerate(papers[:5], start=1):
 
615
  pid = f"paper_{i}"
616
+ nodes.append({"id": pid, "label": paper["title"], "kind": "paper"})
617
+ edges.append(("query", pid))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
618
  if uploaded_name:
619
  nodes.append({"id": "upload", "label": uploaded_name, "kind": "upload"})
620
+ edges.append(("query", "upload"))
621
+ if len(nodes) > 2:
622
+ edges.append(("upload", "paper_1"))
623
  return nodes, edges
624
 
625
 
626
+ def graph_from_selected(query, selected_papers, uploaded_name=None):
627
  nodes = [{"id": "query", "label": query or "Research topic", "kind": "query"}]
628
  edges = []
629
+ for i, paper in enumerate(selected_papers[:6], start=1):
 
630
  pid = f"paper_{i}"
631
+ nodes.append({"id": pid, "label": paper["title"], "kind": "paper"})
632
+ edges.append(("query", pid))
633
+ if paper.get("doi"):
634
+ doi_id = f"doi_{i}"
635
+ nodes.append({"id": doi_id, "label": f"DOI {paper['doi']}", "kind": "paper"})
636
+ edges.append((pid, doi_id))
637
+ if uploaded_name:
638
+ nodes.append({"id": "upload", "label": uploaded_name, "kind": "upload"})
639
+ edges.append(("query", "upload"))
640
+ if len(selected_papers) > 0:
641
+ edges.append(("upload", "paper_1"))
642
+ return nodes, edges
643
 
 
 
 
 
644
 
645
+ def parse_pdf_with_grobid(pdf_path):
646
+ if not GROBID_URL:
647
+ raise RuntimeError("GROBID_URL is not set")
 
648
 
649
+ with open(pdf_path, "rb") as f:
650
+ files = {"input": (Path(pdf_path).name, f, "application/pdf")}
651
+ response = requests.post(
652
+ f"{GROBID_URL.rstrip('/')}/api/processFulltextDocument",
653
+ files=files,
654
+ data={"includeRawAffiliations": "1", "segmentSentences": "1"},
655
+ timeout=120,
656
+ )
657
 
658
+ response.raise_for_status()
659
+ tei_xml = response.text
660
+
661
+ if BeautifulSoup is None:
662
+ return {
663
+ "parser": "grobid",
664
+ "title": Path(pdf_path).name,
665
+ "abstract": "",
666
+ "authors": [],
667
+ "sections": [],
668
+ "references": [],
669
+ "raw_text": "",
670
+ "tei_xml": tei_xml[:30000],
671
+ }
672
+
673
+ soup = BeautifulSoup(tei_xml, "xml")
674
+ title_stmt = soup.find("titleStmt")
675
+ title_tag = title_stmt.find("title") if title_stmt else soup.find("title")
676
+ abstract_tag = soup.find("abstract")
677
+
678
+ authors = []
679
+ for author in soup.find_all("author"):
680
+ pers = author.find("persName")
681
+ if pers:
682
+ name = " ".join(
683
+ x.get_text(" ", strip=True)
684
+ for x in pers.find_all(["forename", "surname"])
685
+ ).strip()
686
+ if name:
687
+ authors.append(name)
 
688
 
689
+ sections = []
690
+ for div in soup.find_all("div"):
691
+ head = div.find("head")
692
+ paras = [p.get_text(" ", strip=True) for p in div.find_all("p")]
693
+ text = "\n".join([p for p in paras if p.strip()])
694
+ if head and text.strip():
695
+ sections.append({"heading": head.get_text(" ", strip=True), "text": text[:4000]})
696
+
697
+ references = []
698
+ for bibl in soup.find_all("biblStruct")[:30]:
699
+ ref_title = ""
700
+ ref_doi = ""
701
+ title_node = bibl.find("title")
702
+ if title_node:
703
+ ref_title = title_node.get_text(" ", strip=True)
704
+ doi_node = bibl.find("idno", attrs={"type": "DOI"})
705
+ if doi_node:
706
+ ref_doi = doi_node.get_text(" ", strip=True)
707
+ references.append({"title": ref_title, "doi": ref_doi})
708
 
709
+ return {
710
+ "parser": "grobid",
711
+ "title": title_tag.get_text(" ", strip=True) if title_tag else Path(pdf_path).name,
712
+ "abstract": abstract_tag.get_text(" ", strip=True) if abstract_tag else "",
713
+ "authors": authors,
714
+ "sections": sections,
715
+ "references": references,
716
+ "raw_text": "",
717
+ "tei_xml": tei_xml[:60000],
718
+ }
719
 
 
 
 
720
 
721
+ def parse_pdf_with_pymupdf(pdf_path):
722
  if fitz is None:
723
  raise RuntimeError("PyMuPDF not installed")
724
 
725
  doc = fitz.open(pdf_path)
726
+ pages = [page.get_text("text") for page in doc]
727
+ raw_text = "\n".join(pages).strip()
728
+ first_page = raw_text[:4000]
729
+ lines = [x.strip() for x in first_page.splitlines() if x.strip()]
730
+ title = lines[0][:300] if lines else Path(pdf_path).name
731
 
732
  abstract = ""
733
+ match = re.search(r"abstract\s*(.+?)(?:\n\s*\n|\n1[\.\s]|introduction)", raw_text, re.I | re.S)
734
  if match:
735
+ abstract = norm_text(match.group(1))[:2000]
 
 
 
 
 
 
 
 
 
 
 
736
 
737
  return {
738
  "parser": "pymupdf",
739
  "title": title,
740
  "abstract": abstract,
741
  "authors": [],
742
+ "sections": [{"heading": "Full Text", "text": raw_text[:12000]}] if raw_text else [],
743
+ "references": [],
744
+ "raw_text": raw_text[:50000],
745
+ "tei_xml": "",
746
+ }
747
+
748
+
749
+ def parse_pdf_with_docling(pdf_path):
750
+ try:
751
+ from docling.document_converter import DocumentConverter
752
+ except Exception as e:
753
+ raise RuntimeError(f"Docling import failed: {e}")
754
+
755
+ converter = DocumentConverter()
756
+ result = converter.convert(pdf_path)
757
+ doc = result.document
758
+ markdown = doc.export_to_markdown()
759
+
760
+ title = Path(pdf_path).name
761
+ first_nonempty = next((line.strip("# ").strip() for line in markdown.splitlines() if line.strip()), "")
762
+ if first_nonempty:
763
+ title = first_nonempty[:300]
764
+
765
+ return {
766
+ "parser": "docling",
767
+ "title": title,
768
+ "abstract": "",
769
+ "authors": [],
770
+ "sections": [{"heading": "Document", "text": markdown[:12000]}] if markdown else [],
771
+ "references": [],
772
+ "raw_text": markdown[:50000],
773
+ "tei_xml": "",
774
  }
775
 
776
 
777
+ def parse_uploaded_pdf(file_obj, parser_order):
778
  if not file_obj:
779
+ return "No PDF uploaded yet.", {}
780
 
781
  path = getattr(file_obj, "name", None) or str(file_obj)
782
+ parser_order = ensure_list(parser_order) or ["grobid", "docling", "pymupdf"]
783
  errors = []
784
 
785
  for parser_name in parser_order:
786
  try:
787
+ if parser_name == "grobid":
788
+ result = parse_pdf_with_grobid(path)
789
+ elif parser_name == "docling":
790
+ result = parse_pdf_with_docling(path)
791
+ elif parser_name == "pymupdf":
792
  result = parse_pdf_with_pymupdf(path)
793
  else:
794
  continue
795
 
796
  summary = (
797
+ f"### Parsed PDF\n\n"
798
  f"- Parser used: {result['parser']}\n"
 
799
  f"- Title: {result.get('title') or 'Unknown'}\n"
800
  f"- Authors: {', '.join(result.get('authors')[:6]) if result.get('authors') else 'Unknown'}\n"
801
  f"- Abstract found: {'Yes' if result.get('abstract') else 'No'}\n"
802
  f"- Sections extracted: {len(result.get('sections') or [])}\n"
803
  f"- References extracted: {len(result.get('references') or [])}\n"
 
 
804
  )
805
  return summary, result
806
  except Exception as e:
807
  errors.append(f"{parser_name}: {e}")
808
 
809
+ fail_summary = "### PDF parsing failed\n\n" + "\n".join([f"- {x}" for x in errors])
810
  return fail_summary, {"parser": None, "errors": errors}
811
 
812
 
813
  def render_parse_result(parsed):
814
+ if not parsed:
815
  return '<div class="panel" style="padding:18px"><p>No parsed document yet.</p></div>'
816
 
817
  sections_html = []
818
+ for section in parsed.get("sections", [])[:6]:
819
  sections_html.append(
820
  f"""
821
  <details class="agent-step">
 
827
  </div>
828
  </summary>
829
  <div class="agent-copy">
830
+ <p>{safe_text(section.get('text', '')[:1800])}</p>
831
  </div>
832
  </details>
833
  """
834
  )
835
 
836
+ refs = parsed.get("references", [])[:12]
837
  refs_html = "".join(
838
  f"<li>{safe_text(r.get('title') or 'Untitled')} {'· DOI ' + safe_text(r.get('doi')) if r.get('doi') else ''}</li>"
839
  for r in refs
840
  ) or "<li>No references extracted.</li>"
841
 
 
 
 
 
 
842
  title = safe_text(parsed.get("title") or "Parsed document")
843
+ abstract = safe_text((parsed.get("abstract") or "")[:2400]) or "No abstract extracted."
844
  parser_name = safe_text(parsed.get("parser") or "unknown")
 
845
 
846
  return f"""
847
  <div class="panel" style="padding:18px">
 
850
  <p class="eyebrow">PDF Parse</p>
851
  <h3>{title}</h3>
852
  </div>
853
+ <div class="brain-legend"><span><i class="dot dot-upload"></i> {parser_name}</span></div>
854
  </div>
855
  <div class="parse-grid">
856
  <div class="parse-card">
 
861
  <h4>References</h4>
862
  <ul class="ref-list">{refs_html}</ul>
863
  </div>
 
 
 
 
 
 
 
 
864
  </div>
865
+ <div class="timeline" style="margin-top:14px;">
866
  {''.join(sections_html) if sections_html else '<div class="panel" style="padding:16px;"><p>No sections extracted.</p></div>'}
867
  </div>
868
  </div>
869
  """
870
 
871
 
872
+ def build_ingest_payload(query, selected_papers, parsed_pdf=None):
873
+ nodes = [{"id": "topic:query", "type": "Topic", "label": query or "Research topic"}]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
874
  edges = []
875
 
876
+ for i, p in enumerate(selected_papers, start=1):
877
+ paper_id = p.get("doi") or (p.get("external_ids") or {}).get("arxiv") or f"paper:{i}"
878
+ nodes.append({
879
+ "id": paper_id,
880
+ "type": "Paper",
881
+ "title": p.get("title"),
882
+ "year": p.get("year"),
883
+ "venue": p.get("venue"),
884
+ "doi": p.get("doi"),
885
+ "source": p.get("source"),
886
+ "url": p.get("url"),
887
+ "pdf": p.get("pdf"),
888
+ })
889
+ edges.append({"source": "topic:query", "target": paper_id, "type": "ABOUT", "weight": p.get("score", 0)})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
890
 
891
+ for author in p.get("authors", [])[:8]:
892
+ author_id = f"author:{author.lower()}"
893
+ nodes.append({"id": author_id, "type": "Author", "label": author})
894
+ edges.append({"source": paper_id, "target": author_id, "type": "WRITTEN_BY"})
895
 
896
+ if parsed_pdf and parsed_pdf.get("title"):
897
  doc_id = "upload:pdf"
898
+ nodes.append({
899
+ "id": doc_id,
900
+ "type": "UploadedPDF",
901
+ "title": parsed_pdf.get("title"),
902
+ "parser": parsed_pdf.get("parser"),
903
+ })
904
+ edges.append({"source": "topic:query", "target": doc_id, "type": "UPLOADED_SOURCE"})
905
+ for idx, section in enumerate(parsed_pdf.get("sections", [])[:8], start=1):
906
+ sec_id = f"{doc_id}:section:{idx}"
907
+ nodes.append({"id": sec_id, "type": "Section", "label": section.get("heading") or f"Section {idx}"})
908
+ edges.append({"source": doc_id, "target": sec_id, "type": "HAS_SECTION"})
909
+ for idx, ref in enumerate(parsed_pdf.get("references", [])[:12], start=1):
910
+ ref_id = f"{doc_id}:ref:{idx}"
911
+ nodes.append({"id": ref_id, "type": "Reference", "label": ref.get("title") or f"Reference {idx}", "doi": ref.get("doi")})
912
+ edges.append({"source": doc_id, "target": ref_id, "type": "CITES"})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
913
 
914
+ return {"status": "ok", "nodes": nodes, "edges": edges}
 
 
 
 
 
 
 
 
 
 
 
 
915
 
916
 
917
  def run_paper_discovery(query, search_mode, sources, pdf_file):
918
+ query_text = (query or "").strip()
 
919
 
920
  if not query_text and not pdf_file:
921
  empty_graph = build_learning_graph_html([], [], "Self-Learning Knowledge Graph")
 
929
  "### No discovery results yet.",
930
  )
931
 
932
+ papers = []
933
+ if query_text:
934
+ try:
935
+ papers = discover_papers(query_text, search_mode, sources, max_results=10)
936
+ except Exception as e:
937
+ graph_nodes, graph_edges = build_learning_graph_state(
938
+ query_text,
939
+ [],
940
+ Path(getattr(pdf_file, "name", "uploaded.pdf")).name if pdf_file else None,
941
+ )
942
+ error_html = f'<div class="panel papers-panel" style="padding:18px"><p>Paper search failed: {safe_text(str(e))}</p></div>'
943
+ return (
944
+ build_learning_graph_html(graph_nodes, graph_edges),
945
+ error_html,
946
+ build_journal_html(query_text or "biomaterials cardiac repair"),
947
+ uploaded_pdf_summary(pdf_file),
948
+ gr.update(choices=[], value=[]),
949
+ [],
950
+ f"### Discovery failed.\n\n- Error: {safe_text(str(e))}",
951
+ )
952
 
953
  uploaded_name = Path(getattr(pdf_file, "name", str(pdf_file))).name if pdf_file else None
954
+ graph_nodes, graph_edges = build_learning_graph_state(query_text, papers[:5], uploaded_name)
955
+ graph_html = build_learning_graph_html(graph_nodes, graph_edges)
956
+ papers_html = format_papers_html(papers)
957
+ journals_html = build_journal_html(query_text or "biomaterials cardiac repair")
958
+ pdf_summary = uploaded_pdf_summary(pdf_file)
959
+ choices = format_selection_choices(papers)
960
+
961
+ status_md = (
962
+ f"### Discovery results\n\n"
963
+ f"- Search mode: {search_mode}\n"
964
+ f"- Sources: {', '.join(ensure_list(sources) or DEFAULT_SOURCES)}\n"
965
+ f"- Candidates found: {len(papers)}\n"
966
+ f"- Select papers below, then click **Ingest selected into graph**.\n"
967
+ )
968
+ return graph_html, papers_html, journals_html, pdf_summary, gr.update(choices=choices, value=[]), papers, status_md
969
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
970
 
971
+ def ingest_selected_papers(query, selected_indices, papers_state, pdf_file, parsed_state):
972
+ papers = ensure_list(papers_state)
973
+ selected_indices = ensure_list(selected_indices)
974
+
975
+ selected = []
976
+ for idx in selected_indices:
977
+ try:
978
+ selected.append(papers[int(idx)])
979
+ except Exception:
980
+ pass
981
+
982
+ uploaded_name = Path(getattr(pdf_file, "name", str(pdf_file))).name if pdf_file else None
983
+
984
+ if not selected and not parsed_state:
985
+ graph_html = build_learning_graph_html([], [], "Self-Learning Knowledge Graph")
986
+ return graph_html, "### Nothing ingested yet.\n\nSelect papers or parse an uploaded PDF first.", {"status": "empty", "nodes": [], "edges": []}
987
+
988
+ graph_nodes, graph_edges = graph_from_selected(query, selected, uploaded_name)
989
+ graph_html = build_learning_graph_html(graph_nodes, graph_edges, "Selected Research Graph")
990
+ payload = build_ingest_payload(query, selected, parsed_state if isinstance(parsed_state, dict) else None)
991
 
992
+ summary_lines = [
993
+ "### Graph ingest ready",
994
+ "",
995
+ f"- Topic: {query or 'Research topic'}",
996
+ f"- Selected papers: {len(selected)}",
997
+ f"- Uploaded PDF parsed: {'Yes' if parsed_state and isinstance(parsed_state, dict) and parsed_state.get('title') else 'No'}",
998
+ f"- Nodes created: {len(payload['nodes'])}",
999
+ f"- Edges created: {len(payload['edges'])}",
1000
+ ]
1001
+ return graph_html, "\n".join(summary_lines), payload
 
 
 
 
 
 
 
 
 
 
 
dvnc_flip_insight_patch.py DELETED
@@ -1,203 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import re
4
- from typing import Any
5
-
6
-
7
- PATCH_SCRIPT = r"""
8
- <script>
9
- (function () {
10
- if (window.__dvncFlipPatchLoaded) return;
11
- window.__dvncFlipPatchLoaded = true;
12
-
13
- function findByFragment(fragment) {
14
- return document.getElementById(fragment) || document.querySelector('[id*="' + fragment + '"]');
15
- }
16
-
17
- function getInteractiveNode(root) {
18
- if (!root) return null;
19
- return root.matches?.('textarea,input,button') ? root : root.querySelector?.('textarea,input,button');
20
- }
21
-
22
- function triggerRouteSwapPatched(idx) {
23
- try {
24
- const payloadRoot = findByFragment('route_swap_payload');
25
- const payload = getInteractiveNode(payloadRoot);
26
- if (!payload) {
27
- console.warn('[DVNC patch] route_swap_payload not found');
28
- return;
29
- }
30
-
31
- payload.focus?.();
32
- payload.value = String(idx);
33
-
34
- ['input', 'change'].forEach(function (name) {
35
- payload.dispatchEvent(new Event(name, { bubbles: true }));
36
- });
37
-
38
- window.setTimeout(function () {
39
- const applyRoot = findByFragment('route_swap_apply');
40
- const applyBtn = getInteractiveNode(applyRoot) || applyRoot;
41
- if (!applyBtn) {
42
- console.warn('[DVNC patch] route_swap_apply not found');
43
- return;
44
- }
45
- applyBtn.click?.();
46
- }, 180);
47
- } catch (err) {
48
- console.error('[DVNC patch] triggerRouteSwap failed', err);
49
- }
50
- }
51
-
52
- window.triggerRouteSwap = triggerRouteSwapPatched;
53
-
54
- document.addEventListener('click', function (e) {
55
- const mini = e.target.closest('.candidate-back .mini');
56
- if (mini) return;
57
-
58
- const card = e.target.closest('.candidate-card');
59
- if (!card) return;
60
-
61
- card.classList.toggle('flipped');
62
- }, true);
63
-
64
- document.addEventListener('keydown', function (e) {
65
- const target = e.target;
66
- if (!target || !target.closest) return;
67
-
68
- const card = target.closest('.candidate-card');
69
- if (!card) return;
70
-
71
- if (e.key === 'Enter' || e.key === ' ') {
72
- if (target.closest('.candidate-back .mini')) return;
73
- e.preventDefault();
74
- card.classList.toggle('flipped');
75
- }
76
- }, true);
77
- })();
78
- </script>
79
- """
80
-
81
- PATCH_STYLE = r"""
82
- <style>
83
- .candidate-card { cursor: pointer; }
84
- .candidate-card.flipped .candidate-card-inner { transform: rotateY(180deg) !important; }
85
- .candidate-card:hover .candidate-card-inner,
86
- .candidate-card:focus .candidate-card-inner,
87
- .candidate-card:focus-within .candidate-card-inner { transform: none !important; }
88
- .candidate-back .mini { position: relative; z-index: 5; }
89
- </style>
90
- """
91
-
92
-
93
- def _inject_assets(head: str) -> str:
94
- if "__dvncFlipPatchLoaded" in head:
95
- return head
96
- return head + "\n" + PATCH_STYLE + "\n" + PATCH_SCRIPT
97
-
98
-
99
- def _patch_head(module: Any) -> None:
100
- for attr in ("HEAD", "head", "CUSTOM_HEAD"):
101
- if hasattr(module, attr):
102
- value = getattr(module, attr)
103
- if isinstance(value, str):
104
- setattr(module, attr, _inject_assets(value))
105
- return
106
-
107
-
108
- def _patch_cards_builder(module: Any) -> None:
109
- if not hasattr(module, "build_cards_html"):
110
- return
111
-
112
- original = module.build_cards_html
113
-
114
- def wrapped(*args, **kwargs):
115
- out = original(*args, **kwargs)
116
- if not isinstance(out, str):
117
- return out
118
- if "candidate-card" not in out:
119
- return out
120
-
121
- out = re.sub(
122
- r'(<div\s+class="candidate-card"\b)',
123
- r'\1 tabindex="0" role="button" aria-label="Flip insight card"',
124
- out,
125
- )
126
- return out
127
-
128
- module.build_cards_html = wrapped
129
-
130
-
131
- def _build_timeline_from_state(module: Any, route_state: Any):
132
- if route_state is None:
133
- return None
134
-
135
- builder = getattr(module, "build_agent_route_cards_html", None)
136
- if not callable(builder):
137
- return None
138
-
139
- try:
140
- variants = route_state.get("variants") if isinstance(route_state, dict) else None
141
- active_idx = route_state.get("active_variant", 0) if isinstance(route_state, dict) else 0
142
- if not variants or active_idx >= len(variants):
143
- return None
144
-
145
- variant = variants[active_idx] or {}
146
- steps = variant.get("steps") or variant.get("route") or []
147
- if not steps:
148
- return None
149
-
150
- normalized = []
151
- for i, step in enumerate(steps):
152
- if isinstance(step, dict):
153
- normalized.append({
154
- "step": step.get("step", i + 1),
155
- "agent": step.get("agent", f"Step {i+1}"),
156
- "tag": step.get("tag", step.get("mode", "")),
157
- "summary": step.get("summary", step.get("description", "")),
158
- })
159
- else:
160
- normalized.append({
161
- "step": i + 1,
162
- "agent": f"Step {i+1}",
163
- "tag": "",
164
- "summary": str(step),
165
- })
166
-
167
- return builder(normalized)
168
- except Exception:
169
- return None
170
-
171
-
172
- def _patch_apply_route_swap(module: Any) -> None:
173
- if not hasattr(module, "apply_route_swap"):
174
- return
175
-
176
- original = module.apply_route_swap
177
-
178
- def wrapped(*args, **kwargs):
179
- result = original(*args, **kwargs)
180
- if not isinstance(result, tuple):
181
- return result
182
-
183
- if len(result) == 5:
184
- chat_html, connectome_html, timeline_value, hypothesis_md, route_state = result
185
- rebuilt = _build_timeline_from_state(module, route_state)
186
- if rebuilt is not None:
187
- gr = getattr(module, "gr", None)
188
- if gr is not None and hasattr(gr, "update"):
189
- timeline_value = gr.update(value=rebuilt)
190
- else:
191
- timeline_value = rebuilt
192
- return chat_html, connectome_html, timeline_value, hypothesis_md, route_state
193
-
194
- return result
195
-
196
- module.apply_route_swap = wrapped
197
-
198
-
199
- def apply_patch(module: Any) -> Any:
200
- _patch_head(module)
201
- _patch_cards_builder(module)
202
- _patch_apply_route_swap(module)
203
- return module
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dvnc_flip_insight_patch_old.py DELETED
@@ -1,227 +0,0 @@
1
- """
2
- Standalone non-destructive patch for DVNC.AI Hugging Face Space.
3
-
4
- Purpose
5
- - Fix Bug 1: hover/focus flip interaction makes the Flip Insight button unreliable.
6
- - Fix Bug 2: brittle Gradio element ID lookup for route swap payload/apply controls.
7
- - Fix Bug 3: route swap leaves timeline stale/blank by rebuilding/preserving the timeline output.
8
-
9
- Design
10
- - Leaves the existing codebase untouched.
11
- - Intended to be imported and applied from a separate runner or notebook.
12
- - Uses monkey-patching where possible.
13
-
14
- Usage example
15
- import app as dvnc_app
16
- import dvnc_flip_insight_patch as patch
17
- patch.apply_patch(dvnc_app)
18
-
19
- If you want to keep the current app.py completely untouched, create a tiny launcher file
20
- that imports the original app module and then calls apply_patch(dvnc_app).
21
- """
22
-
23
- from __future__ import annotations
24
-
25
- import re
26
- from typing import Any, Callable
27
-
28
-
29
- PATCH_SCRIPT = r"""
30
- <script>
31
- (function () {
32
- if (window.__dvncFlipPatchLoaded) return;
33
- window.__dvncFlipPatchLoaded = true;
34
-
35
- function findByFragment(fragment) {
36
- return document.getElementById(fragment) || document.querySelector('[id*="' + fragment + '"]');
37
- }
38
-
39
- function getInteractiveNode(root) {
40
- if (!root) return null;
41
- return root.matches?.('textarea,input,button') ? root : root.querySelector?.('textarea,input,button');
42
- }
43
-
44
- function triggerRouteSwapPatched(idx) {
45
- try {
46
- const payloadRoot = findByFragment('route_swap_payload');
47
- const payload = getInteractiveNode(payloadRoot);
48
- if (!payload) {
49
- console.warn('[DVNC patch] route_swap_payload not found');
50
- return;
51
- }
52
- payload.focus?.();
53
- payload.value = String(idx);
54
- ['input', 'change'].forEach(function (name) {
55
- payload.dispatchEvent(new Event(name, { bubbles: true }));
56
- });
57
-
58
- window.setTimeout(function () {
59
- const applyRoot = findByFragment('route_swap_apply');
60
- const applyBtn = getInteractiveNode(applyRoot) || applyRoot;
61
- if (!applyBtn) {
62
- console.warn('[DVNC patch] route_swap_apply not found');
63
- return;
64
- }
65
- applyBtn.click?.();
66
- }, 180);
67
- } catch (err) {
68
- console.error('[DVNC patch] triggerRouteSwap failed', err);
69
- }
70
- }
71
-
72
- window.triggerRouteSwap = triggerRouteSwapPatched;
73
-
74
- document.addEventListener('click', function (e) {
75
- const mini = e.target.closest('.candidate-back .mini');
76
- if (mini) return;
77
-
78
- const card = e.target.closest('.candidate-card');
79
- if (!card) return;
80
-
81
- card.classList.toggle('flipped');
82
- }, true);
83
-
84
- document.addEventListener('keydown', function (e) {
85
- const card = e.target.closest && e.target.closest('.candidate-card');
86
- if (!card) return;
87
- if (e.key === 'Enter' || e.key === ' ') {
88
- if (e.target.closest('.candidate-back .mini')) return;
89
- e.preventDefault();
90
- card.classList.toggle('flipped');
91
- }
92
- }, true);
93
- })();
94
- </script>
95
- """
96
-
97
- PATCH_STYLE = r"""
98
- <style>
99
- .candidate-card { cursor: pointer; }
100
- .candidate-card.flipped .candidate-card-inner { transform: rotateY(180deg) !important; }
101
- .candidate-card:hover .candidate-card-inner,
102
- .candidate-card:focus .candidate-card-inner,
103
- .candidate-card:focus-within .candidate-card-inner { transform: none !important; }
104
- .candidate-back .mini { position: relative; z-index: 5; }
105
- </style>
106
- """
107
-
108
-
109
- def _inject_assets(head: str) -> str:
110
- if "__dvncFlipPatchLoaded" in head:
111
- return head
112
- return head + "\n" + PATCH_STYLE + "\n" + PATCH_SCRIPT
113
-
114
-
115
- def _patch_head(module: Any) -> None:
116
- for attr in ("HEAD", "head", "CUSTOM_HEAD"):
117
- if hasattr(module, attr):
118
- value = getattr(module, attr)
119
- if isinstance(value, str):
120
- setattr(module, attr, _inject_assets(value))
121
- return
122
-
123
-
124
- def _patch_cards_builder(module: Any) -> None:
125
- if not hasattr(module, "build_cards_html"):
126
- return
127
- original = module.build_cards_html
128
-
129
- def wrapped(*args, **kwargs):
130
- out = original(*args, **kwargs)
131
- if not isinstance(out, str):
132
- return out
133
- if "candidate-card" not in out:
134
- return out
135
-
136
- out = re.sub(
137
- r'(<div\s+class="candidate-card"\b)',
138
- r'\1 tabindex="0" role="button" aria-label="Flip insight card"',
139
- out,
140
- count=0,
141
- )
142
- return out
143
-
144
- module.build_cards_html = wrapped
145
-
146
-
147
- def _build_timeline_from_state(module: Any, route_state: Any) -> Any:
148
- if route_state is None:
149
- return None
150
-
151
- builder = getattr(module, "build_agent_route_cards_html", None)
152
- if not callable(builder):
153
- return None
154
-
155
- try:
156
- variants = route_state.get("variants") if isinstance(route_state, dict) else None
157
- active_idx = route_state.get("active_variant", 0) if isinstance(route_state, dict) else 0
158
- if not variants or active_idx >= len(variants):
159
- return None
160
- variant = variants[active_idx] or {}
161
- steps = variant.get("steps") or variant.get("route") or []
162
- if not steps:
163
- return None
164
-
165
- normalized = []
166
- for i, step in enumerate(steps):
167
- if isinstance(step, dict):
168
- normalized.append(
169
- {
170
- "step": step.get("step", i + 1),
171
- "agent": step.get("agent", f"Step {i+1}"),
172
- "tag": step.get("tag", step.get("mode", "")),
173
- "summary": step.get("summary", step.get("description", "")),
174
- }
175
- )
176
- else:
177
- normalized.append(
178
- {
179
- "step": i + 1,
180
- "agent": f"Step {i+1}",
181
- "tag": "",
182
- "summary": str(step),
183
- }
184
- )
185
- return builder(normalized)
186
- except Exception:
187
- return None
188
-
189
-
190
- def _patch_apply_route_swap(module: Any) -> None:
191
- if not hasattr(module, "apply_route_swap"):
192
- return
193
-
194
- original = module.apply_route_swap
195
-
196
- def wrapped(*args, **kwargs):
197
- result = original(*args, **kwargs)
198
- if not isinstance(result, tuple):
199
- return result
200
-
201
- if len(result) == 5:
202
- chat_html, connectome_html, timeline_value, hypothesis_md, route_state = result
203
- timeline_rebuilt = _build_timeline_from_state(module, route_state)
204
- if timeline_rebuilt is not None:
205
- gr = getattr(module, "gr", None)
206
- if gr is not None and hasattr(gr, "update"):
207
- timeline_value = gr.update(value=timeline_rebuilt)
208
- else:
209
- timeline_value = timeline_rebuilt
210
- return chat_html, connectome_html, timeline_value, hypothesis_md, route_state
211
- return result
212
-
213
- module.apply_route_swap = wrapped
214
-
215
-
216
- def _patch_rendered_html(module: Any) -> None:
217
- for attr in ("DISCOVERY_CARD_CSS",):
218
- if hasattr(module, attr) and isinstance(getattr(module, attr), str):
219
- setattr(module, attr, getattr(module, attr) + "\n" + PATCH_STYLE)
220
-
221
-
222
- def apply_patch(module: Any) -> Any:
223
- _patch_head(module)
224
- _patch_rendered_html(module)
225
- _patch_cards_builder(module)
226
- _patch_apply_route_swap(module)
227
- return module
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dvnc_patch_loader_example.py DELETED
@@ -1,27 +0,0 @@
1
- """
2
- Example launcher that keeps the original code untouched.
3
-
4
- Place this file alongside the existing app module and adjust the import path if needed.
5
- Run this launcher instead of the original entry point.
6
- """
7
-
8
- import importlib.util
9
- import sys
10
- from pathlib import Path
11
-
12
- ORIGINAL_APP_PATH = Path("dvnc_ai_v2_hf/app.py")
13
- PATCH_PATH = Path("dvnc_flip_insight_patch.py")
14
-
15
- spec = importlib.util.spec_from_file_location("dvnc_original_app", ORIGINAL_APP_PATH)
16
- mod = importlib.util.module_from_spec(spec)
17
- sys.modules["dvnc_original_app"] = mod
18
- spec.loader.exec_module(mod)
19
-
20
- patch_spec = importlib.util.spec_from_file_location("dvnc_flip_insight_patch", PATCH_PATH)
21
- patch = importlib.util.module_from_spec(patch_spec)
22
- sys.modules["dvnc_flip_insight_patch"] = patch
23
- patch_spec.loader.exec_module(patch)
24
- patch.apply_patch(mod)
25
-
26
- # If the original app exposes demo/launch objects, they remain on mod.
27
- # Example: mod.demo.launch()