Spaces:
Sleeping
Sleeping
ShamaDaVinci
#2
by ShamaRahman - opened
- app.py +745 -19
- app_old2.py +0 -770
- app_old3.py +0 -782
- app_old4.py +0 -412
- app_old5.py +0 -52
- dvnc_ai_v2_hf/app.py +386 -1155
- dvnc_ai_v2_hf/{deprecated/app_old11.py → app_old1.py} +0 -0
- dvnc_ai_v2_hf/deprecated/app_old10.py +0 -677
- dvnc_ai_v2_hf/deprecated/self_learning_graph_old.py +0 -1001
- dvnc_ai_v2_hf/deprecated/self_learning_graph_old3.py +0 -1626
- dvnc_ai_v2_hf/deprecated/self_learning_graph_old4.py +0 -1430
- dvnc_ai_v2_hf/deprecated/self_learning_graph_old5.py +0 -1490
- dvnc_ai_v2_hf/deprecated/self_learning_graph_old6.py +0 -1490
- dvnc_ai_v2_hf/deprecated/self_learning_graph_old_2.py +0 -73
- dvnc_ai_v2_hf/deprecated/self_learning_graph_old_8.py +0 -2070
- dvnc_ai_v2_hf/graph_canvas_patch.py +0 -977
- dvnc_ai_v2_hf/self_learning_graph.py +599 -1336
- dvnc_flip_insight_patch.py +0 -203
- dvnc_flip_insight_patch_old.py +0 -227
- dvnc_patch_loader_example.py +0 -27
app.py
CHANGED
|
@@ -1,29 +1,755 @@
|
|
| 1 |
-
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
sys.modules[module_name] = module
|
| 15 |
-
spec.loader.exec_module(module)
|
| 16 |
-
return module
|
| 17 |
|
|
|
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
if __name__ == "__main__":
|
| 28 |
-
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 41 |
-
|
| 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",
|
| 68 |
-
{"id": "bio",
|
| 69 |
-
{"id": "card",
|
| 70 |
-
{"id": "nano",
|
| 71 |
-
{"id": "selfasm", "label": "Self-Assembly",
|
| 72 |
-
{"id": "electro", "label": "Electro-signalling",
|
| 73 |
-
{"id": "immune",
|
| 74 |
-
{"id": "trial",
|
| 75 |
-
{"id": "alt1",
|
| 76 |
-
{"id": "alt2",
|
| 77 |
]
|
| 78 |
-
|
| 79 |
EDGES = [
|
| 80 |
-
("seed",
|
| 81 |
-
("
|
| 82 |
-
("
|
| 83 |
-
("
|
| 84 |
-
("selfasm",
|
| 85 |
-
("
|
| 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":
|
| 99 |
-
"front":
|
| 100 |
-
"back":
|
| 101 |
-
"score":
|
| 102 |
"novelty": "High",
|
| 103 |
-
"agent":
|
| 104 |
},
|
| 105 |
{
|
| 106 |
-
"title":
|
| 107 |
-
"front":
|
| 108 |
-
"back":
|
| 109 |
-
"score":
|
| 110 |
"novelty": "High",
|
| 111 |
-
"agent":
|
| 112 |
},
|
| 113 |
{
|
| 114 |
-
"title":
|
| 115 |
-
"front":
|
| 116 |
-
"back":
|
| 117 |
-
"score":
|
| 118 |
"novelty": "Medium-High",
|
| 119 |
-
"agent":
|
| 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 |
-
|
| 128 |
-
|
| 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 |
-
|
| 144 |
-
|
| 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 |
-
|
| 160 |
-
|
| 161 |
-
|
| 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 |
-
|
| 176 |
-
|
| 177 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
def norm_text(x: Optional[str]) -> str:
|
| 179 |
-
return re.sub(r"\s+", " ", (x or "")).strip()
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
|
|
|
|
|
|
|
|
|
| 187 |
def build_connectome_html(path_ids: List[str]) -> str:
|
| 188 |
-
active
|
| 189 |
-
node_map
|
| 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
|
| 213 |
is_active = n["id"] in active
|
| 214 |
-
state
|
| 215 |
halo_cls = "halo active" if is_active else "halo"
|
| 216 |
-
lbl_cls
|
| 217 |
-
radius
|
| 218 |
-
halo_r
|
| 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
|
| 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 |
-
{
|
| 246 |
-
{
|
| 247 |
-
{
|
| 248 |
-
{
|
| 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 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 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 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 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 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
random.seed(len(
|
| 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
|
| 336 |
else:
|
| 337 |
-
primary =
|
| 338 |
-
|
| 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":
|
| 366 |
-
"cards":
|
| 367 |
-
"path":
|
| 368 |
"metrics": {
|
| 369 |
-
"Novelty":
|
| 370 |
-
"Mechanistic clarity":
|
| 371 |
"Experimental tractability": 82,
|
| 372 |
-
"Cross-domain distance":
|
| 373 |
},
|
| 374 |
}
|
| 375 |
-
|
| 376 |
-
chat_html = build_chat_html(query_text, result)
|
| 377 |
connectome_html = build_connectome_html(path)
|
| 378 |
-
timeline_html
|
| 379 |
-
metrics_md
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
"#
|
| 385 |
-
f"
|
| 386 |
-
|
| 387 |
-
"
|
| 388 |
-
|
| 389 |
-
"
|
| 390 |
-
"
|
| 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(
|
| 410 |
-
except
|
| 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 |
-
|
| 422 |
-
"have been realigned to the selected candidate methodology."
|
| 423 |
-
),
|
| 424 |
-
"primary_hypothesis": academic["hypothesis"],
|
| 425 |
}
|
| 426 |
-
chat_html = build_chat_html(
|
| 427 |
-
|
| 428 |
-
|
|
|
|
| 429 |
hypothesis_md = (
|
| 430 |
-
"# Discovery Output\n\n"
|
| 431 |
-
f"**Model:** {
|
| 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 |
-
--
|
| 459 |
-
--
|
| 460 |
-
--
|
| 461 |
-
--
|
| 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 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
}
|
| 479 |
-
|
| 480 |
-
.
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
}
|
| 484 |
-
|
| 485 |
-
#
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
}
|
| 493 |
-
|
| 494 |
-
.
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
}
|
| 503 |
-
|
| 504 |
-
.
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
}
|
| 509 |
-
|
| 510 |
-
.
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
}
|
| 520 |
-
|
| 521 |
-
.
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
}
|
| 525 |
-
|
| 526 |
-
.
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
}
|
| 533 |
-
|
| 534 |
-
.
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
}
|
| 539 |
-
|
| 540 |
-
.
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
}
|
| 547 |
-
|
| 548 |
-
.
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
}
|
| 555 |
-
|
| 556 |
-
.
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
}
|
| 562 |
-
|
| 563 |
-
.
|
| 564 |
-
.
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
}
|
| 568 |
-
|
| 569 |
-
.
|
| 570 |
-
.
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
}
|
| 575 |
-
|
| 576 |
-
.
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
}
|
| 580 |
-
|
| 581 |
-
.
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
}
|
| 586 |
-
|
| 587 |
-
.
|
| 588 |
-
|
| 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 |
-
|
| 1225 |
-
|
| 1226 |
-
|
| 1227 |
-
|
| 1228 |
-
|
| 1229 |
-
|
| 1230 |
-
|
| 1231 |
-
|
| 1232 |
-
|
| 1233 |
-
|
| 1234 |
-
|
| 1235 |
-
if (btn) btn.click();
|
| 1236 |
-
}, 120);
|
| 1237 |
}
|
| 1238 |
</script>
|
| 1239 |
"""
|
| 1240 |
-
|
| 1241 |
-
|
| 1242 |
-
# ──
|
| 1243 |
-
|
| 1244 |
-
|
| 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
|
| 1255 |
-
|
| 1256 |
-
|
| 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
|
| 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
|
| 1300 |
-
example_btn = gr.Button("Load example",
|
| 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
|
| 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
|
| 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
|
| 1353 |
-
load_topic_btn = gr.Button("Load example topic",
|
| 1354 |
-
|
| 1355 |
-
upload_status = gr.Markdown("No PDF uploaded yet.")
|
| 1356 |
discovery_status = gr.Markdown("### No discovery results yet.")
|
| 1357 |
-
journal_panel
|
| 1358 |
-
|
| 1359 |
-
gr.
|
| 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=
|
| 1371 |
-
value=
|
| 1372 |
label="Parser routing order",
|
| 1373 |
)
|
| 1374 |
-
|
| 1375 |
with gr.Row():
|
| 1376 |
-
parse_btn
|
| 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
|
| 1382 |
-
|
| 1383 |
-
)
|
| 1384 |
-
|
| 1385 |
-
|
| 1386 |
-
|
| 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("&", "&")
|
| 550 |
-
.replaceAll("<", "<")
|
| 551 |
-
.replaceAll(">", ">")
|
| 552 |
-
.replaceAll('"', """)
|
| 553 |
-
.replaceAll("'", "'");
|
| 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
|
| 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
|
|
|
|
| 10 |
|
| 11 |
import gradio as gr
|
| 12 |
import requests
|
|
@@ -16,31 +15,11 @@ try:
|
|
| 16 |
except Exception:
|
| 17 |
fitz = None
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 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 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 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
|
| 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 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 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 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 289 |
|
| 290 |
|
| 291 |
-
def
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
for
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 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
|
| 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 =
|
| 388 |
-
summary =
|
| 389 |
-
published = entry.findtext("atom:published", default="", namespaces=ns)
|
| 390 |
-
paper_id = entry.findtext("atom:id", default="", namespaces=ns)
|
| 391 |
-
authors = [
|
| 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 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
|
|
|
| 417 |
|
| 418 |
|
| 419 |
-
def search_crossref(query
|
| 420 |
-
headers = {"User-Agent": "dvnc-ai-space/
|
| 421 |
if mode == "doi":
|
| 422 |
-
url = f"https://api.crossref.org/works/{
|
| 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(
|
| 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 =
|
| 452 |
-
|
| 453 |
-
|
| 454 |
out.append({
|
| 455 |
"id": doi or title,
|
| 456 |
-
"title": title,
|
| 457 |
-
"summary":
|
| 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":
|
| 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
|
| 476 |
params = {"per-page": max_results}
|
| 477 |
if mode == "doi":
|
| 478 |
-
doi =
|
| 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 |
-
|
| 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(
|
| 495 |
oa = item.get("open_access") or {}
|
| 496 |
-
doi =
|
| 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":
|
| 502 |
-
"summary":
|
| 503 |
-
"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": (
|
| 508 |
"pdf": oa.get("oa_url") or "",
|
| 509 |
"doi": doi,
|
| 510 |
-
"venue":
|
| 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
|
|
|
|
|
|
|
|
|
|
| 521 |
fields = "title,authors,year,abstract,venue,externalIds,url,openAccessPdf"
|
| 522 |
if mode == "doi":
|
| 523 |
-
doi =
|
| 524 |
-
url = f"https://api.semanticscholar.org/graph/v1/paper/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 = [
|
| 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":
|
| 547 |
-
"summary":
|
| 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": (
|
| 554 |
-
"doi":
|
| 555 |
-
"venue":
|
| 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
|
| 566 |
epmc_query = f'DOI:"{query}"' if mode == "doi" else query
|
| 567 |
-
params = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = [
|
| 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":
|
| 584 |
-
"summary":
|
| 585 |
-
"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":
|
| 592 |
-
"venue":
|
| 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
|
| 603 |
url = (query or "").strip()
|
| 604 |
if not url:
|
| 605 |
return []
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
"
|
| 614 |
-
"
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 639 |
-
|
| 640 |
-
results.extend(search_arxiv(query, max_results=
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
results.extend(search_crossref(query, mode=mode, max_results=
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
results.extend(search_openalex(query, mode=mode, max_results=
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
results.extend(search_semantic_scholar(query, mode=mode, max_results=
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
results.extend(search_europe_pmc(query, mode=mode, max_results=
|
| 661 |
-
|
| 662 |
-
|
| 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 |
-
|
| 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
|
| 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>
|
| 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
|
| 766 |
-
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
|
| 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,
|
| 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 |
-
|
| 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(
|
|
|
|
|
|
|
| 1016 |
return nodes, edges
|
| 1017 |
|
| 1018 |
|
| 1019 |
-
def graph_from_selected(query, selected_papers, uploaded_name=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 |
-
|
| 1027 |
-
|
| 1028 |
-
|
| 1029 |
-
"
|
| 1030 |
-
|
| 1031 |
-
|
| 1032 |
-
|
| 1033 |
-
|
| 1034 |
-
|
| 1035 |
-
|
| 1036 |
-
|
| 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 |
-
|
| 1044 |
-
|
| 1045 |
-
|
| 1046 |
-
edges.append({"source": pid, "target": cid, "type": "MENTIONS"})
|
| 1047 |
|
| 1048 |
-
|
| 1049 |
-
|
| 1050 |
-
|
| 1051 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1052 |
|
| 1053 |
-
|
| 1054 |
-
|
| 1055 |
-
|
| 1056 |
-
|
| 1057 |
-
|
| 1058 |
-
|
| 1059 |
-
|
| 1060 |
-
|
| 1061 |
-
|
| 1062 |
-
|
| 1063 |
-
|
| 1064 |
-
|
| 1065 |
-
|
| 1066 |
-
|
| 1067 |
-
|
| 1068 |
-
|
| 1069 |
-
|
| 1070 |
-
|
| 1071 |
-
|
| 1072 |
-
|
| 1073 |
-
|
| 1074 |
-
|
| 1075 |
-
|
| 1076 |
-
|
| 1077 |
-
"
|
| 1078 |
-
|
| 1079 |
-
|
| 1080 |
-
|
| 1081 |
-
|
| 1082 |
-
|
| 1083 |
-
edges.append({"source": "query", "target": fid, "type": "FRONTIER_CANDIDATE"})
|
| 1084 |
|
| 1085 |
-
|
| 1086 |
-
|
| 1087 |
-
|
| 1088 |
-
|
| 1089 |
-
|
| 1090 |
-
|
| 1091 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1092 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1093 |
|
| 1094 |
-
# ----------------------------
|
| 1095 |
-
# PDF parsing
|
| 1096 |
-
# ----------------------------
|
| 1097 |
|
| 1098 |
-
def parse_pdf_with_pymupdf(pdf_path
|
| 1099 |
if fitz is None:
|
| 1100 |
raise RuntimeError("PyMuPDF not installed")
|
| 1101 |
|
| 1102 |
doc = fitz.open(pdf_path)
|
| 1103 |
-
|
| 1104 |
-
raw_text =
|
| 1105 |
-
first_page =
|
| 1106 |
-
|
| 1107 |
-
title =
|
| 1108 |
|
| 1109 |
abstract = ""
|
| 1110 |
-
match = re.search(r"abstract\s*(.+?)(?:\n\s*\n|\
|
| 1111 |
if match:
|
| 1112 |
-
abstract =
|
| 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":
|
| 1131 |
-
"references":
|
| 1132 |
-
"
|
| 1133 |
-
"
|
| 1134 |
-
|
| 1135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1136 |
}
|
| 1137 |
|
| 1138 |
|
| 1139 |
-
def parse_uploaded_pdf(file_obj, parser_order
|
| 1140 |
if not file_obj:
|
| 1141 |
-
return "
|
| 1142 |
|
| 1143 |
path = getattr(file_obj, "name", None) or str(file_obj)
|
| 1144 |
-
parser_order = ensure_list(parser_order) or
|
| 1145 |
errors = []
|
| 1146 |
|
| 1147 |
for parser_name in parser_order:
|
| 1148 |
try:
|
| 1149 |
-
if parser_name == "
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1150 |
result = parse_pdf_with_pymupdf(path)
|
| 1151 |
else:
|
| 1152 |
continue
|
| 1153 |
|
| 1154 |
summary = (
|
| 1155 |
-
f"###
|
| 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
|
| 1171 |
return fail_summary, {"parser": None, "errors": errors}
|
| 1172 |
|
| 1173 |
|
| 1174 |
def render_parse_result(parsed):
|
| 1175 |
-
if not parsed
|
| 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", [])[:
|
| 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', '')[:
|
| 1192 |
</div>
|
| 1193 |
</details>
|
| 1194 |
"""
|
| 1195 |
)
|
| 1196 |
|
| 1197 |
-
refs = parsed.get("references", [])[:
|
| 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 "")[:
|
| 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}
|
| 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;
|
| 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 |
-
|
| 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 |
-
|
| 1278 |
-
|
| 1279 |
-
|
| 1280 |
-
|
| 1281 |
-
|
| 1282 |
-
|
| 1283 |
-
|
| 1284 |
-
|
| 1285 |
-
"
|
| 1286 |
-
|
| 1287 |
-
|
| 1288 |
-
|
| 1289 |
-
|
| 1290 |
-
|
| 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
|
| 1312 |
-
|
| 1313 |
-
|
| 1314 |
-
|
| 1315 |
|
| 1316 |
-
if parsed_pdf and
|
| 1317 |
doc_id = "upload:pdf"
|
| 1318 |
-
|
| 1319 |
-
|
| 1320 |
-
|
| 1321 |
-
|
| 1322 |
-
|
| 1323 |
-
|
| 1324 |
-
|
| 1325 |
-
|
| 1326 |
-
|
| 1327 |
-
|
| 1328 |
-
|
| 1329 |
-
|
| 1330 |
-
|
| 1331 |
-
|
| 1332 |
-
|
| 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 |
-
|
| 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 =
|
| 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 |
-
|
| 1672 |
-
|
| 1673 |
-
|
| 1674 |
-
|
| 1675 |
-
|
| 1676 |
-
|
| 1677 |
-
|
| 1678 |
-
|
| 1679 |
-
|
| 1680 |
-
|
| 1681 |
-
|
| 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 |
-
|
| 1719 |
-
|
| 1720 |
-
|
| 1721 |
-
|
| 1722 |
-
|
| 1723 |
-
|
| 1724 |
-
|
| 1725 |
-
|
| 1726 |
-
|
| 1727 |
-
"
|
| 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()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|