Files changed (37) hide show
  1. README.md +9 -195
  2. app.py +2 -27
  3. app_old2.py +0 -770
  4. app_old3.py +0 -782
  5. app_old4.py +0 -412
  6. app_old5.py +0 -52
  7. dvnc_ai_v2_hf/agent_route_cards.py +0 -655
  8. dvnc_ai_v2_hf/app.py +242 -1320
  9. dvnc_ai_v2_hf/{deprecated/app_old.py → app_old.py} +0 -0
  10. dvnc_ai_v2_hf/{deprecated/app_old2.py → app_old2.py} +0 -0
  11. dvnc_ai_v2_hf/{deprecated/app_old3.py → app_old3.py} +0 -0
  12. dvnc_ai_v2_hf/{deprecated/app_old4.py → app_old4.py} +0 -0
  13. dvnc_ai_v2_hf/deprecated/agent_route_cards_old.py +0 -622
  14. dvnc_ai_v2_hf/deprecated/app_old10.py +0 -677
  15. dvnc_ai_v2_hf/deprecated/app_old11.py +0 -696
  16. dvnc_ai_v2_hf/deprecated/app_old5.py +0 -343
  17. dvnc_ai_v2_hf/deprecated/app_old6.py +0 -935
  18. dvnc_ai_v2_hf/deprecated/app_old7.py +0 -907
  19. dvnc_ai_v2_hf/deprecated/app_old8.py +0 -0
  20. dvnc_ai_v2_hf/deprecated/app_old9.py +0 -1832
  21. dvnc_ai_v2_hf/deprecated/notes.md +0 -1
  22. dvnc_ai_v2_hf/deprecated/self_learning_graph_old.py +0 -1001
  23. dvnc_ai_v2_hf/deprecated/self_learning_graph_old3.py +0 -1626
  24. dvnc_ai_v2_hf/deprecated/self_learning_graph_old4.py +0 -1430
  25. dvnc_ai_v2_hf/deprecated/self_learning_graph_old5.py +0 -1490
  26. dvnc_ai_v2_hf/deprecated/self_learning_graph_old6.py +0 -1490
  27. dvnc_ai_v2_hf/deprecated/self_learning_graph_old_2.py +0 -73
  28. dvnc_ai_v2_hf/deprecated/self_learning_graph_old_8.py +0 -2070
  29. dvnc_ai_v2_hf/discovery_app_bridge.py +0 -81
  30. dvnc_ai_v2_hf/discovery_route_runtime.py +0 -756
  31. dvnc_ai_v2_hf/dvnc_ui_layout.py +0 -209
  32. dvnc_ai_v2_hf/graph_canvas_patch.py +0 -977
  33. dvnc_ai_v2_hf/self_learning_graph.py +0 -1738
  34. dvnc_flip_insight_patch.py +0 -203
  35. dvnc_flip_insight_patch_old.py +0 -227
  36. dvnc_patch_loader_example.py +0 -27
  37. requirements.txt +0 -16
README.md CHANGED
@@ -1,209 +1,23 @@
1
  ---
2
  title: DVNC.AI
3
  emoji: 🧠
4
- colorFrom: indigo
5
- colorTo: blue
6
  sdk: gradio
7
- sdk_version: 5.29.0
8
  app_file: app.py
9
  pinned: false
10
- license: mit
11
  short_description: Connectome-native scientific discovery workspace
12
  ---
13
 
14
  # DVNC.AI
15
 
16
- DVNC.AI is a connectome-native scientific discovery workspace designed to search, expand, and structure research topics into an interactive knowledge graph. The application supports topic-led discovery, paper lookup, document upload, graph expansion, and AI-assisted reasoning over research artifacts.
17
 
18
- This repository currently runs as a **Gradio Space** with a root-level launcher file required by Hugging Face Spaces. The production UI lives in the nested application package, and the root `app.py` exists so the Space can boot correctly in the default Gradio runtime.
 
 
19
 
20
- ## Current repository layout
21
 
22
- ```text
23
- .
24
- ├── app.py # Root Hugging Face launcher
25
- ├── app_old.py # Legacy root app
26
- ├── requirements.txt # Python dependencies for the Space
27
- ├── README.md
28
- ├── dvnc_ai_hf/ # Earlier app package
29
- └── dvnc_ai_v2_hf/ # Current primary app package
30
- ```
31
-
32
- ## How the current Space starts
33
-
34
- The Space is configured as a **Gradio Space**, which means Hugging Face expects a root `app.py` and installs dependencies from `requirements.txt`. The root launcher simply imports the active Gradio demo from `dvnc_ai_v2_hf.app` and starts it.
35
-
36
- That pattern is intentional and should remain in place unless the Space is migrated to Docker.
37
-
38
- ## Supported architecture options
39
-
40
- Two deployment patterns are supported for the next phase of development.
41
-
42
- ### Option 1: Gradio Space + external parser services
43
-
44
- This is the simplest path and is the recommended option if the goal is to keep the current Space lightweight.
45
-
46
- #### How it works
47
-
48
- - Hugging Face runs the Gradio app using the root `app.py`.
49
- - The main UI and orchestration logic live in `dvnc_ai_v2_hf/`.
50
- - External scholarly/document parsing services are called over HTTP.
51
- - PDF parsing can use a layered fallback:
52
- 1. External GROBID endpoint for scholarly TEI extraction.
53
- 2. Local Docling-based conversion for layout-aware parsing.
54
- 3. Local PyMuPDF fallback for raw text extraction.
55
-
56
- #### Recommended environment variables
57
-
58
- - `ANTHROPIC_API_KEY` — required for Claude-powered reasoning.
59
- - `GROBID_URL` — optional external GROBID server URL.
60
- - `SEMANTIC_SCHOLAR_API_KEY` — optional, improves Semantic Scholar API access.
61
- - `OPENALEX_EMAIL` — optional polite-pool identity for OpenAlex-style requests.
62
- - `CROSSREF_MAILTO` — optional polite contact for metadata requests.
63
-
64
- #### Recommended use cases
65
-
66
- Use this mode if:
67
- - the Space should remain a standard Gradio Space;
68
- - the parser stack can live outside the Space;
69
- - fast iteration is more important than bundling every service into one runtime.
70
-
71
- ### Option 2: Docker Space + bundled parsing stack
72
-
73
- This is the recommended option if the application needs a first-class parsing service bundled with the app runtime.
74
-
75
- #### How it works
76
-
77
- - The Space is converted from `sdk: gradio` to `sdk: docker`.
78
- - A custom `Dockerfile` starts the web app and any required background services.
79
- - GROBID can run inside the same container or through an internal companion service.
80
- - The app can expose a single user-facing web interface while running a richer backend.
81
-
82
- #### Recommended use cases
83
-
84
- Use this mode if:
85
- - the Space should include GROBID directly;
86
- - system packages or custom services are required;
87
- - document parsing quality is a core product feature;
88
- - the app needs more control over startup, ports, or service orchestration.
89
-
90
- #### YAML for Docker migration
91
-
92
- If the Space is migrated to Docker, replace the YAML block at the top of this README with:
93
-
94
- ```yaml
95
- ***
96
- title: DVNC.AI
97
- emoji: 🧠
98
- colorFrom: indigo
99
- colorTo: blue
100
- sdk: docker
101
- pinned: false
102
- license: mit
103
- short_description: Connectome-native scientific discovery workspace with bundled parsing and graph expansion services.
104
- ***
105
- ```
106
-
107
- In Docker mode, `app_file` is no longer used because startup is controlled by the `Dockerfile`.
108
-
109
- ## Product direction
110
-
111
- The target application architecture supports:
112
-
113
- - **Research topic discovery** — search papers by topic or concept.
114
- - **Paper lookup** — search by title, DOI, paper name, or direct link.
115
- - **Autonomous discovery** — retrieve candidates from multiple scholarly sources.
116
- - **User selection** — show candidate papers and let the user choose which ones enter the graph.
117
- - **PDF upload** — allow users to upload papers directly.
118
- - **Structured parsing** — extract title, abstract, sections, references, and metadata from documents.
119
- - **Graph expansion** — turn selected or parsed documents into graph nodes and edges for the self-learning graph.
120
-
121
- ## Planned source connectors
122
-
123
- The next implementation phase is designed to support a multi-source retrieval layer such as:
124
-
125
- - Crossref for DOI and bibliographic metadata.
126
- - OpenAlex for topic/title discovery and scholarly metadata enrichment.
127
- - Semantic Scholar for academic graph enrichment and relevance ranking.
128
- - arXiv for preprints and open metadata.
129
- - Europe PMC for biomedical and life-science literature.
130
- - Direct URL ingestion from paper landing pages and PDFs.
131
-
132
- ## Parser strategy
133
-
134
- Document parsing should use a priority-based parser stack:
135
-
136
- 1. **GROBID** for scholarly PDF parsing into structured TEI/XML.
137
- 2. **Docling** for layout-aware extraction, tables, and document conversion.
138
- 3. **PyMuPDF** for fast native PDF text extraction fallback.
139
-
140
- This approach keeps the PDF uploader in the product while improving document understanding significantly over plain text extraction alone.
141
-
142
- ## Running locally
143
-
144
- ### Gradio mode
145
-
146
- Install dependencies:
147
-
148
- ```bash
149
- pip install -r requirements.txt
150
- ```
151
-
152
- Start the Space locally:
153
-
154
- ```bash
155
- python app.py
156
- ```
157
-
158
- If an external parser is used, export the parser endpoint first:
159
-
160
- ```bash
161
- export GROBID_URL=http://localhost:8070
162
- export ANTHROPIC_API_KEY=your_key_here
163
- python app.py
164
- ```
165
-
166
- ### Docker mode
167
-
168
- Once a `Dockerfile` is added, run locally with:
169
-
170
- ```bash
171
- docker build -t dvnc-ai .
172
- docker run -p 7860:7860 dvnc-ai
173
- ```
174
-
175
- ## Deployment notes
176
-
177
- ### For Gradio Spaces
178
-
179
- Keep these files at the repository root:
180
-
181
- - `README.md`
182
- - `app.py`
183
- - `requirements.txt`
184
-
185
- Hugging Face Spaces expects the root application entrypoint for Gradio deployments.
186
-
187
- ### For Docker Spaces
188
-
189
- Add:
190
- - `Dockerfile`
191
- - any startup scripts or service config files
192
-
193
- Then switch the README YAML to `sdk: docker`.
194
-
195
- ## Secrets and configuration
196
-
197
- At minimum, set:
198
-
199
- - `ANTHROPIC_API_KEY`
200
-
201
- Depending on the selected architecture, also set:
202
-
203
- - `GROBID_URL`
204
- - `SEMANTIC_SCHOLAR_API_KEY`
205
- - source-specific API credentials if needed
206
-
207
- ## Development note
208
-
209
- At present, `dvnc_ai_v2_hf/` should be treated as the primary active application package. The `dvnc_ai_hf/` and `app_old.py` files appear to represent earlier iterations and should be retained only if they are still needed for rollback or reference.
 
1
  ---
2
  title: DVNC.AI
3
  emoji: 🧠
4
+ colorFrom: gray
5
+ colorTo: indigo
6
  sdk: gradio
 
7
  app_file: app.py
8
  pinned: false
 
9
  short_description: Connectome-native scientific discovery workspace
10
  ---
11
 
12
  # DVNC.AI
13
 
14
+ This root patch lets the existing nested `dvnc_ai_v2_hf/` folder run on Hugging Face Spaces by providing a root-level `app.py` entrypoint, which Hugging Face requires for Gradio Spaces.[cite:166][cite:163]
15
 
16
+ ## Required existing folder
17
+ This patch assumes the repository already contains the uploaded folder:
18
+ - `dvnc_ai_v2_hf/`
19
 
20
+ The bridge file imports the `demo` object from `dvnc_ai_v2_hf.app` so the existing implementation can remain in place while the Space launches from the repository root.[cite:166][cite:323]
21
 
22
+ ## Required secret
23
+ Set `ANTHROPIC_API_KEY` in the Space secrets for live Claude calls.[cite:294][cite:300]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app.py CHANGED
@@ -1,29 +1,4 @@
1
- import importlib.util
2
- import sys
3
- from pathlib import Path
4
-
5
- ORIGINAL_APP_PATH = Path("dvnc_ai_v2_hf/app.py")
6
- PATCH_PATH = Path("dvnc_flip_insight_patch.py")
7
-
8
-
9
- def load_module(module_name: str, file_path: Path):
10
- spec = importlib.util.spec_from_file_location(module_name, file_path)
11
- if spec is None or spec.loader is None:
12
- raise RuntimeError(f"Could not load module {module_name} from {file_path}")
13
- module = importlib.util.module_from_spec(spec)
14
- sys.modules[module_name] = module
15
- spec.loader.exec_module(module)
16
- return module
17
-
18
-
19
- original_app = load_module("dvnc_original_app", ORIGINAL_APP_PATH)
20
- patch_module = load_module("dvnc_flip_insight_patch", PATCH_PATH)
21
-
22
- patch_module.apply_patch(original_app)
23
-
24
- demo = getattr(original_app, "demo", None)
25
- app = getattr(original_app, "app", None)
26
 
27
  if __name__ == "__main__":
28
- if demo is not None:
29
- demo.launch()
 
1
+ from dvnc_ai_v2_hf.app import demo
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  if __name__ == "__main__":
4
+ 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/agent_route_cards.py DELETED
@@ -1,655 +0,0 @@
1
- import html
2
- import json
3
- import re
4
- from typing import Dict, List, Optional
5
-
6
-
7
- AGENT_STAGE_DEFAULTS = {
8
- "Query Interpreter": [
9
- {
10
- "key": "mechanistic",
11
- "title": "Mechanistic decomposition",
12
- "front": "Resolve the prompt into substrate, perturbation, state variable, measurable readout, and timescale.",
13
- "back": "Swap into route when the question needs hard causal framing before graph expansion.",
14
- "badge": "input",
15
- "score": 91,
16
- "novelty": "High clarity",
17
- },
18
- {
19
- "key": "multiscale",
20
- "title": "Multi-scale framing",
21
- "front": "Split the problem across molecular, cellular, tissue, and systems levels before branching.",
22
- "back": "Swap into route when bridge variables across scales matter more than a single mechanism.",
23
- "badge": "input",
24
- "score": 86,
25
- "novelty": "Broad scope",
26
- },
27
- {
28
- "key": "constraint-led",
29
- "title": "Constraint-led framing",
30
- "front": "Start from assay, delivery, model-system, and validation constraints instead of pure theory.",
31
- "back": "Swap into route when experimental tractability should dominate from the first step.",
32
- "badge": "input",
33
- "score": 84,
34
- "novelty": "Practical",
35
- },
36
- ],
37
- "Graph Divergence Mapper": [
38
- {
39
- "key": "local-causal",
40
- "title": "Local causal neighbourhood",
41
- "front": "Expand first through nearest mechanistic neighbours to preserve interpretability.",
42
- "back": "Swap into route when you want a conservative branch around known causal structure.",
43
- "badge": "graph",
44
- "score": 88,
45
- "novelty": "Stable",
46
- },
47
- {
48
- "key": "cross-scale",
49
- "title": "Cross-scale bridge mapping",
50
- "front": "Search for hidden intermediates that convert local events into tissue or system outcomes.",
51
- "back": "Swap into route when the graph must discover bridge variables across levels.",
52
- "badge": "graph",
53
- "score": 93,
54
- "novelty": "High novelty",
55
- },
56
- {
57
- "key": "counterfactual",
58
- "title": "Counterfactual branches",
59
- "front": "Generate explicit rival graph routes that could explain the same observed effect.",
60
- "back": "Swap into route when you want disagreement and adversarial structure early.",
61
- "badge": "graph",
62
- "score": 89,
63
- "novelty": "Adversarial",
64
- },
65
- ],
66
- "Evidence Harvester": [
67
- {
68
- "key": "supportive",
69
- "title": "Supportive evidence packet",
70
- "front": "Collect papers that directly strengthen the active route and mechanism stack.",
71
- "back": "Swap into route when the composer needs the strongest positive case first.",
72
- "badge": "evidence",
73
- "score": 87,
74
- "novelty": "Direct",
75
- },
76
- {
77
- "key": "contradiction",
78
- "title": "Contradiction scan",
79
- "front": "Prioritise limiting conditions, null findings, and papers that invert the result.",
80
- "back": "Swap into route when context dependence matters more than consensus.",
81
- "badge": "evidence",
82
- "score": 92,
83
- "novelty": "Scientist-grade",
84
- },
85
- {
86
- "key": "assay-gap",
87
- "title": "Assay bias scan",
88
- "front": "Look for missing readouts and weak proxies that may distort the literature signal.",
89
- "back": "Swap into route when measurement weakness is likely driving false confidence.",
90
- "badge": "evidence",
91
- "score": 85,
92
- "novelty": "Methods-aware",
93
- },
94
- ],
95
- "Analogy Engine": [
96
- {
97
- "key": "near-transfer",
98
- "title": "Near-domain transfer",
99
- "front": "Borrow mechanisms from adjacent domains where variables and constraints still align.",
100
- "back": "Swap into route when novelty should stay close to interpretable biology or materials science.",
101
- "badge": "analogy",
102
- "score": 86,
103
- "novelty": "Controlled",
104
- },
105
- {
106
- "key": "distant-transfer",
107
- "title": "Distant mechanism transfer",
108
- "front": "Import remote control logic from another field with conserved dynamics or topology.",
109
- "back": "Swap into route when you want maximal novelty without relying on keyword similarity.",
110
- "badge": "analogy",
111
- "score": 94,
112
- "novelty": "Very high",
113
- },
114
- {
115
- "key": "material-analogy",
116
- "title": "Material behaviour analogy",
117
- "front": "Recast the system through mechanics, conductivity, self-assembly, or feedback material behaviour.",
118
- "back": "Swap into route when physical behaviour is a stronger bridge than biological naming.",
119
- "badge": "analogy",
120
- "score": 90,
121
- "novelty": "Bridge-heavy",
122
- },
123
- ],
124
- "Hypothesis Composer": [
125
- {
126
- "key": "single-lead",
127
- "title": "Single lead hypothesis",
128
- "front": "Compose one sharp mechanism-led thesis with strong causal sequencing.",
129
- "back": "Swap into route when the system should commit early to the strongest line of attack.",
130
- "badge": "compose",
131
- "score": 89,
132
- "novelty": "Focused",
133
- },
134
- {
135
- "key": "triad",
136
- "title": "Three-route composition",
137
- "front": "Produce a lead hypothesis plus two structurally different alternatives side by side.",
138
- "back": "Swap into route when route comparison is part of the product experience.",
139
- "badge": "compose",
140
- "score": 93,
141
- "novelty": "Portfolio",
142
- },
143
- {
144
- "key": "claim-evidence",
145
- "title": "Claim-evidence assembly",
146
- "front": "Bind each major claim to evidence packets, expected signals, and failure modes.",
147
- "back": "Swap into route when graph-ready structured output matters more than prose elegance.",
148
- "badge": "compose",
149
- "score": 91,
150
- "novelty": "Structured",
151
- },
152
- ],
153
- "Adversarial Critic": [
154
- {
155
- "key": "confounders",
156
- "title": "Confounder attack",
157
- "front": "Stress test hidden variables, proxy leakage, and spurious causal chains.",
158
- "back": "Swap into route when false positives and mistaken mechanisms are the main risk.",
159
- "badge": "critique",
160
- "score": 90,
161
- "novelty": "Critical",
162
- },
163
- {
164
- "key": "boundary",
165
- "title": "Boundary-condition attack",
166
- "front": "Probe dose, timescale, cell type, species, and substrate conditions that may invert the result.",
167
- "back": "Swap into route when the hypothesis is probably true only in a narrow regime.",
168
- "badge": "critique",
169
- "score": 92,
170
- "novelty": "Boundary-aware",
171
- },
172
- {
173
- "key": "feasibility",
174
- "title": "Feasibility attack",
175
- "front": "Target fabrication, delivery, stability, toxicity, and validation bottlenecks.",
176
- "back": "Swap into route when the science is elegant but the build path may fail.",
177
- "badge": "critique",
178
- "score": 87,
179
- "novelty": "Reality check",
180
- },
181
- ],
182
- "Experimental Program Designer": [
183
- {
184
- "key": "fast-falsify",
185
- "title": "Fast falsification plan",
186
- "front": "Design the smallest decisive experiments that can kill the route quickly.",
187
- "back": "Swap into route when you want rapid learning with minimal lab spend.",
188
- "badge": "experiment",
189
- "score": 88,
190
- "novelty": "Lean",
191
- },
192
- {
193
- "key": "staged-program",
194
- "title": "Staged validation program",
195
- "front": "Move from mechanistic assay to comparative benchmark to translational test in sequence.",
196
- "back": "Swap into route when the output should look like a full scientific program.",
197
- "badge": "experiment",
198
- "score": 94,
199
- "novelty": "Complete",
200
- },
201
- {
202
- "key": "orthogonal-readouts",
203
- "title": "Orthogonal readout design",
204
- "front": "Use multiple independent readouts so the route does not depend on one proxy measurement.",
205
- "back": "Swap into route when evidence quality matters more than sheer speed.",
206
- "badge": "experiment",
207
- "score": 91,
208
- "novelty": "Robust",
209
- },
210
- ],
211
- }
212
-
213
-
214
- def _safe(value: object) -> str:
215
- return html.escape(str(value if value is not None else ""))
216
-
217
-
218
- def _slug(value: str) -> str:
219
- return re.sub(r"[^a-z0-9]+", "-", (value or "").lower()).strip("-") or "stage"
220
-
221
-
222
- def get_default_stage_cards(agent_name: str) -> List[Dict]:
223
- return [dict(card) for card in AGENT_STAGE_DEFAULTS.get(agent_name, [])]
224
-
225
-
226
- def build_agent_route_cards_html(
227
- reasoning: List[Dict],
228
- stage_cards: Optional[Dict[str, List[Dict]]] = None,
229
- selected_keys: Optional[Dict[str, str]] = None,
230
- component_id: str = "dvnc-agent-route-cards",
231
- ) -> str:
232
- stage_cards = stage_cards or {}
233
- selected_keys = selected_keys or {}
234
-
235
- rows: List[str] = []
236
- selected_map: Dict[str, str] = {}
237
-
238
- for item in reasoning or []:
239
- agent = item.get("agent", "Agent")
240
- agent_slug = _slug(agent)
241
- cards = [dict(c) for c in (stage_cards.get(agent) or get_default_stage_cards(agent))][:3]
242
- if not cards:
243
- continue
244
-
245
- selected_key = selected_keys.get(agent) or cards[0].get("key") or ""
246
- selected_map[agent] = selected_key
247
- card_html: List[str] = []
248
-
249
- for idx, card in enumerate(cards):
250
- key = card.get("key") or f"option-{idx + 1}"
251
- active = key == selected_key
252
- active_cls = " is-active" if active else ""
253
- status_text = "Enabled" if active else "Available"
254
-
255
- card_html.append(
256
- f'''<article class="dvnc-route-card{active_cls}" data-card-key="{_safe(key)}" data-agent="{_safe(agent)}" data-selected="{"1" if active else "0"}">
257
- <div class="dvnc-route-card-inner">
258
- <div class="dvnc-route-face dvnc-route-front">
259
- <div class="dvnc-route-top">
260
- <span class="dvnc-route-badge">{_safe(card.get("badge", item.get("tag", "route")))}</span>
261
- <span class="dvnc-route-score">{_safe(card.get("score", ""))}</span>
262
- </div>
263
- <h4>{_safe(card.get("title", "Route option"))}</h4>
264
- <p>{_safe(card.get("front", ""))}</p>
265
- <div class="dvnc-route-meta">
266
- <span>Novelty</span>
267
- <strong class="dvnc-route-novelty">{_safe(card.get("novelty", "Adaptive"))}</strong>
268
- </div>
269
- <button type="button" class="dvnc-route-btn dvnc-flip-btn" data-action="flip">Flip insight</button>
270
- </div>
271
- <div class="dvnc-route-face dvnc-route-back">
272
- <div class="dvnc-route-top">
273
- <span class="dvnc-route-badge alt">alternative path</span>
274
- <span class="dvnc-route-score">{_safe(card.get("score", ""))}</span>
275
- </div>
276
- <h4>{_safe(card.get("title", "Route option"))}</h4>
277
- <p>{_safe(card.get("back", ""))}</p>
278
- <div class="dvnc-route-meta">
279
- <span>Swap into route</span>
280
- <strong class="dvnc-route-status">{status_text}</strong>
281
- </div>
282
- <div class="dvnc-route-actions">
283
- <button
284
- type="button"
285
- class="dvnc-route-btn dvnc-swap-btn"
286
- data-action="swap"
287
- data-agent="{_safe(agent)}"
288
- data-key="{_safe(key)}"
289
- aria-pressed="{"true" if active else "false"}"
290
- >Swap into route</button>
291
- <button type="button" class="dvnc-route-btn ghost dvnc-flip-btn" data-action="flip">Return</button>
292
- </div>
293
- </div>
294
- </div>
295
- </article>'''
296
- )
297
-
298
- rows.append(
299
- f'''<section class="dvnc-agent-row" data-agent="{_safe(agent)}" data-agent-slug="{_safe(agent_slug)}">
300
- <div class="dvnc-agent-summary-panel">
301
- <div class="dvnc-agent-step">{_safe(item.get("step", ""))}</div>
302
- <div class="dvnc-agent-copy">
303
- <div class="dvnc-agent-topline">
304
- <h3>{_safe(agent)}</h3>
305
- <span>{_safe(item.get("tag", "stage"))}</span>
306
- </div>
307
- <p>{_safe(item.get("summary", ""))}</p>
308
- </div>
309
- </div>
310
- <div class="dvnc-agent-cards">{"".join(card_html)}</div>
311
- </section>'''
312
- )
313
-
314
- selected_json = _safe(json.dumps(selected_map))
315
-
316
- style = f'''
317
- <style>
318
- #{component_id} {{
319
- --dvnc-line: rgba(0, 0, 0, 0.10);
320
- --dvnc-text: #111111;
321
- --dvnc-muted: #5b5b5b;
322
- --dvnc-gold: #ff6600;
323
- --dvnc-panel: #ffffff;
324
- --dvnc-shadow: 0 18px 38px rgba(0, 0, 0, 0.08);
325
- }}
326
- #{component_id}.dvnc-agent-route-shell {{
327
- display: flex;
328
- flex-direction: column;
329
- gap: 22px;
330
- width: min(100%, 1520px);
331
- margin: 0 auto;
332
- padding: 8px 18px 10px;
333
- box-sizing: border-box;
334
- }}
335
- #{component_id} .dvnc-route-state {{
336
- display: none;
337
- }}
338
- #{component_id} .dvnc-agent-route-grid {{
339
- display: flex;
340
- flex-direction: column;
341
- gap: 22px;
342
- width: 100%;
343
- }}
344
- #{component_id} .dvnc-agent-row {{
345
- display: grid;
346
- grid-template-columns: minmax(300px, 360px) minmax(0, 1fr);
347
- gap: 22px;
348
- align-items: stretch;
349
- width: 100%;
350
- }}
351
- #{component_id} .dvnc-agent-summary-panel {{
352
- background: var(--dvnc-panel);
353
- border: 1px solid var(--dvnc-line);
354
- border-radius: 22px;
355
- padding: 20px;
356
- display: grid;
357
- grid-template-columns: 56px 1fr;
358
- gap: 16px;
359
- min-height: 320px;
360
- }}
361
- #{component_id} .dvnc-agent-step {{
362
- width: 56px;
363
- height: 56px;
364
- border-radius: 18px;
365
- display: grid;
366
- place-items: center;
367
- font-weight: 800;
368
- font-size: 1rem;
369
- color: var(--dvnc-gold);
370
- background: rgba(255, 102, 0, 0.08);
371
- border: 1px solid rgba(255, 102, 0, 0.18);
372
- }}
373
- #{component_id} .dvnc-agent-copy {{
374
- display: flex;
375
- flex-direction: column;
376
- justify-content: flex-start;
377
- }}
378
- #{component_id} .dvnc-agent-topline {{
379
- display: flex;
380
- justify-content: space-between;
381
- gap: 12px;
382
- align-items: center;
383
- flex-wrap: wrap;
384
- }}
385
- #{component_id} .dvnc-agent-topline h3 {{
386
- margin: 0;
387
- font-size: 1.02rem;
388
- color: var(--dvnc-text);
389
- }}
390
- #{component_id} .dvnc-agent-topline span {{
391
- font-size: 0.72rem;
392
- letter-spacing: 0.12em;
393
- text-transform: uppercase;
394
- color: var(--dvnc-muted);
395
- }}
396
- #{component_id} .dvnc-agent-copy p {{
397
- margin: 10px 0 0;
398
- color: #252525;
399
- line-height: 1.68;
400
- font-size: 0.95rem;
401
- max-width: 34ch;
402
- }}
403
- #{component_id} .dvnc-agent-cards {{
404
- display: grid;
405
- grid-template-columns: repeat(3, minmax(0, 1fr));
406
- gap: 16px;
407
- align-items: stretch;
408
- width: 100%;
409
- }}
410
- #{component_id} .dvnc-route-card {{
411
- background: none;
412
- perspective: 1400px;
413
- min-height: 320px;
414
- }}
415
- #{component_id} .dvnc-route-card-inner {{
416
- position: relative;
417
- width: 100%;
418
- min-height: 320px;
419
- transform-style: preserve-3d;
420
- transition: transform 0.75s cubic-bezier(0.2, 0.7, 0.1, 1);
421
- }}
422
- #{component_id} .dvnc-route-card.is-flipped .dvnc-route-card-inner {{
423
- transform: rotateY(180deg);
424
- }}
425
- #{component_id} .dvnc-route-face {{
426
- position: absolute;
427
- inset: 0;
428
- backface-visibility: hidden;
429
- background: #ffffff;
430
- border: 1px solid var(--dvnc-line);
431
- border-radius: 22px;
432
- padding: 20px;
433
- display: flex;
434
- flex-direction: column;
435
- gap: 14px;
436
- box-shadow: var(--dvnc-shadow);
437
- }}
438
- #{component_id} .dvnc-route-back {{
439
- transform: rotateY(180deg);
440
- }}
441
- #{component_id} .dvnc-route-card.is-active .dvnc-route-face {{
442
- border-color: rgba(255, 102, 0, 0.28);
443
- box-shadow: 0 18px 40px rgba(255, 102, 0, 0.10);
444
- }}
445
- #{component_id} .dvnc-route-top {{
446
- display: flex;
447
- justify-content: space-between;
448
- gap: 10px;
449
- align-items: center;
450
- }}
451
- #{component_id} .dvnc-route-badge {{
452
- font-size: 0.72rem;
453
- text-transform: uppercase;
454
- letter-spacing: 0.12em;
455
- color: #0b6f66;
456
- padding: 7px 10px;
457
- border-radius: 999px;
458
- background: rgba(23, 184, 166, 0.08);
459
- border: 1px solid rgba(23, 184, 166, 0.18);
460
- }}
461
- #{component_id} .dvnc-route-badge.alt {{
462
- color: var(--dvnc-gold);
463
- background: rgba(255, 102, 0, 0.08);
464
- border-color: rgba(255, 102, 0, 0.18);
465
- }}
466
- #{component_id} .dvnc-route-score {{
467
- font-weight: 800;
468
- color: var(--dvnc-gold);
469
- }}
470
- #{component_id} .dvnc-route-face h4 {{
471
- margin: 0;
472
- font-size: 1.03rem;
473
- line-height: 1.38;
474
- color: var(--dvnc-text);
475
- }}
476
- #{component_id} .dvnc-route-face p {{
477
- margin: 0;
478
- color: #202020;
479
- line-height: 1.62;
480
- font-size: 0.95rem;
481
- }}
482
- #{component_id} .dvnc-route-meta {{
483
- margin-top: auto;
484
- display: flex;
485
- justify-content: space-between;
486
- gap: 12px;
487
- color: var(--dvnc-muted);
488
- font-size: 0.87rem;
489
- }}
490
- #{component_id} .dvnc-route-actions {{
491
- display: flex;
492
- gap: 10px;
493
- flex-wrap: wrap;
494
- }}
495
- #{component_id} .dvnc-route-btn {{
496
- border: 1px solid var(--dvnc-line);
497
- background: #fff;
498
- color: var(--dvnc-text);
499
- padding: 10px 12px;
500
- border-radius: 14px;
501
- cursor: pointer;
502
- font: inherit;
503
- transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
504
- }}
505
- #{component_id} .dvnc-route-btn:hover {{
506
- border-color: rgba(0, 0, 0, 0.18);
507
- }}
508
- #{component_id} .dvnc-route-btn:focus-visible {{
509
- outline: none;
510
- box-shadow: 0 0 0 3px rgba(255, 102, 0, 0.18);
511
- }}
512
- #{component_id} .dvnc-swap-btn {{
513
- background: linear-gradient(135deg, rgba(255, 102, 0, 0.95), rgba(240, 108, 22, 0.95));
514
- color: #fff;
515
- border: none;
516
- }}
517
- #{component_id} .dvnc-route-btn.ghost {{
518
- background: #fff;
519
- }}
520
- @media (max-width: 1400px) {{
521
- #{component_id}.dvnc-agent-route-shell {{
522
- width: min(100%, 1320px);
523
- padding: 8px 12px 10px;
524
- }}
525
- #{component_id} .dvnc-agent-row {{
526
- grid-template-columns: minmax(280px, 330px) minmax(0, 1fr);
527
- }}
528
- #{component_id} .dvnc-agent-cards {{
529
- grid-template-columns: repeat(2, minmax(0, 1fr));
530
- }}
531
- }}
532
- @media (max-width: 1100px) {{
533
- #{component_id} .dvnc-agent-row {{
534
- grid-template-columns: 1fr;
535
- }}
536
- #{component_id} .dvnc-agent-summary-panel {{
537
- min-height: auto;
538
- }}
539
- #{component_id} .dvnc-agent-cards {{
540
- grid-template-columns: 1fr;
541
- }}
542
- }}
543
- @media (prefers-reduced-motion: reduce) {{
544
- #{component_id} .dvnc-route-card-inner,
545
- #{component_id} .dvnc-route-btn {{
546
- transition: none;
547
- }}
548
- }}
549
- </style>
550
- '''
551
-
552
- script = f'''
553
- <script>
554
- (function() {{
555
- const root = document.getElementById({json.dumps(component_id)});
556
- if (!root || root.dataset.bound === "1") return;
557
- root.dataset.bound = "1";
558
-
559
- const stateField = root.querySelector(".dvnc-route-state");
560
-
561
- function readSelectedMap() {{
562
- try {{
563
- return JSON.parse(root.dataset.selectedMap || "{{}}");
564
- }} catch (error) {{
565
- return {{}};
566
- }}
567
- }}
568
-
569
- function writeSelectedMap(selectedMap) {{
570
- const payload = JSON.stringify(selectedMap || {{}});
571
- root.dataset.selectedMap = payload;
572
-
573
- if (stateField) {{
574
- stateField.value = payload;
575
- stateField.dispatchEvent(new Event("input", {{ bubbles: true }}));
576
- stateField.dispatchEvent(new Event("change", {{ bubbles: true }}));
577
- }}
578
-
579
- root.dispatchEvent(
580
- new CustomEvent("dvnc-route-change", {{
581
- bubbles: true,
582
- detail: {{ selectedMap: selectedMap || {{}} }},
583
- }})
584
- );
585
- }}
586
-
587
- function syncSelection(agent, key) {{
588
- root.querySelectorAll(".dvnc-agent-row").forEach((row) => {{
589
- if (row.dataset.agent !== agent) return;
590
-
591
- row.querySelectorAll(".dvnc-route-card").forEach((card) => {{
592
- const active = card.dataset.cardKey === key;
593
- card.classList.toggle("is-active", active);
594
- card.dataset.selected = active ? "1" : "0";
595
-
596
- const statusEl = card.querySelector(".dvnc-route-status");
597
- if (statusEl) {{
598
- statusEl.textContent = active ? "Enabled" : "Available";
599
- }}
600
-
601
- const swapBtn = card.querySelector(".dvnc-swap-btn");
602
- if (swapBtn) {{
603
- swapBtn.setAttribute("aria-pressed", active ? "true" : "false");
604
- }}
605
- }});
606
- }});
607
-
608
- const selectedMap = readSelectedMap();
609
- selectedMap[agent] = key;
610
- writeSelectedMap(selectedMap);
611
- }}
612
-
613
- root.addEventListener("click", (event) => {{
614
- const flipBtn = event.target.closest(".dvnc-flip-btn");
615
- if (flipBtn) {{
616
- const card = flipBtn.closest(".dvnc-route-card");
617
- if (card) {{
618
- card.classList.toggle("is-flipped");
619
- }}
620
- return;
621
- }}
622
-
623
- const swapBtn = event.target.closest(".dvnc-swap-btn");
624
- if (swapBtn) {{
625
- const agent = swapBtn.dataset.agent;
626
- const key = swapBtn.dataset.key;
627
- syncSelection(agent, key);
628
-
629
- const card = swapBtn.closest(".dvnc-route-card");
630
- if (card) {{
631
- card.classList.remove("is-flipped");
632
- }}
633
- }}
634
- }});
635
-
636
- writeSelectedMap(readSelectedMap());
637
- }})();
638
- </script>
639
- '''
640
-
641
- return (
642
- f'<div class="dvnc-agent-route-shell" id="{_safe(component_id)}" data-selected-map="{selected_json}">'
643
- + style
644
- + f'<input type="hidden" class="dvnc-route-state" value="{selected_json}" />'
645
- + f'<div class="dvnc-agent-route-grid">{"".join(rows)}</div>'
646
- + script
647
- + "</div>"
648
- )
649
-
650
-
651
- __all__ = [
652
- "AGENT_STAGE_DEFAULTS",
653
- "get_default_stage_cards",
654
- "build_agent_route_cards_html",
655
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dvnc_ai_v2_hf/app.py CHANGED
@@ -1,56 +1,12 @@
1
- """
2
- DVNC.AI — app.py
3
- Production Gradio entrypoint for the DVNC discovery workspace.
4
- Aligned to the current dvnc_ai_v2_hf.self_learning_graph interface.
5
- """
6
-
7
- # ── Standard library ────────────────────────────────────────────────────────
8
  import random
9
- import re
10
- from typing import Dict, List, Optional
11
-
12
- # ── Third-party ─────────────────────────────────────────────────────────────
13
  import gradio as gr
14
 
15
- # ── Internal modules ────────────────────────────────────────────────────────
16
- from dvnc_ai_v2_hf.agent_route_cards import build_agent_route_cards_html
17
- from dvnc_ai_v2_hf.discovery_app_bridge import (
18
- get_default_route_state,
19
- get_discovery_css,
20
- get_initial_discovery_timeline_html,
21
- )
22
- from dvnc_ai_v2_hf.dvnc_ui_layout import get_dvnc_layout_css
23
- from dvnc_ai_v2_hf import self_learning_graph as slg
24
- from dvnc_ai_v2_hf.self_learning_graph import (
25
- DEFAULT_SOURCES,
26
- SEARCH_MODES,
27
- SOURCE_OPTIONS,
28
- build_journal_html,
29
- build_learning_graph_html,
30
- ingest_selected_papers,
31
- parse_uploaded_pdf,
32
- render_parse_result,
33
- run_paper_discovery,
34
- safe_text,
35
- )
36
-
37
- # ── Constants ───────────────────────────────────────────────────────────────
38
  MODELS = [
39
- {
40
- "name": "DVNC Sovereign",
41
- "tag": "flagship",
42
- "desc": "Maximum depth orchestration for frontier discovery",
43
- },
44
- {
45
- "name": "DVNC Atlas",
46
- "tag": "research",
47
- "desc": "Balanced reasoning, graph traversal, and synthesis",
48
- },
49
- {
50
- "name": "DVNC Curie",
51
- "tag": "lab",
52
- "desc": "Experimental hypothesis generation for anomalous signals",
53
- },
54
  ]
55
 
56
  AGENTS = [
@@ -64,31 +20,22 @@ AGENTS = [
64
  ]
65
 
66
  NODES = [
67
- {"id": "seed", "label": "Seed Query", "group": "core", "x": 10, "y": 0, "z": 0},
68
- {"id": "bio", "label": "Biomaterials", "group": "domain", "x": 24, "y": 12, "z": -8},
69
- {"id": "card", "label": "Cardiac Repair", "group": "domain", "x": 38, "y": 3, "z": 14},
70
- {"id": "nano", "label": "Nanostructure", "group": "bridge", "x": 24, "y": -18, "z": 16},
71
- {"id": "selfasm", "label": "Self-Assembly", "group": "bridge", "x": 40, "y": -16, "z": -16},
72
- {"id": "electro", "label": "Electro-signalling", "group": "mechanism", "x": 58, "y": 10, "z": -10},
73
- {"id": "immune", "label": "Immune Modulation", "group": "mechanism", "x": 64, "y": -8, "z": 10},
74
- {"id": "trial", "label": "Validation Path", "group": "outcome", "x": 80, "y": 0, "z": 0},
75
- {"id": "alt1", "label": "Piezoelectric Scaffold", "group": "candidate", "x": 56, "y": 26, "z": 14},
76
- {"id": "alt2", "label": "Peptide Mesh", "group": "candidate", "x": 54, "y": -27, "z": -14},
77
  ]
78
 
79
  EDGES = [
80
- ("seed", "bio"),
81
- ("seed", "nano"),
82
- ("bio", "card"),
83
- ("nano", "selfasm"),
84
- ("selfasm", "electro"),
85
- ("card", "immune"),
86
- ("electro", "trial"),
87
- ("immune", "trial"),
88
- ("card", "alt1"),
89
- ("selfasm", "alt2"),
90
- ("alt1", "trial"),
91
- ("alt2", "trial"),
92
  ]
93
 
94
  DEFAULT_PATH = ["seed", "nano", "selfasm", "electro", "trial"]
@@ -100,7 +47,7 @@ CANDIDATES = [
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",
@@ -108,7 +55,7 @@ CANDIDATES = [
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",
@@ -116,230 +63,132 @@ CANDIDATES = [
116
  "back": "Discovery path: inflammation mismatch -> conductive medium -> macrophage polarization -> synchronized healing. Risk: persistence and biocompatibility.",
117
  "score": 85,
118
  "novelty": "Medium-High",
119
- "agent": "Adversarial Critic",
120
- },
121
- ]
122
-
123
- ACADEMIC_INSIGHTS = [
124
- {
125
- "hypothesis": "Implementation of mechano-electric scaffolds to transduce cardiac strain into localized micro-current signalling for myocardial regeneration.",
126
- "metrics": {
127
- "Novelty": 92,
128
- "Mechanistic clarity": 85,
129
- "Experimental tractability": 78,
130
- "Cross-domain distance": 94,
131
- },
132
- "outline": (
133
- "1. Synthesize candidate piezoelectric biomaterial scaffolds with tunable strain-electric coupling.\n"
134
- "2. Evaluate in vitro electromechanical transduction and subsequent ion-channel entrainment.\n"
135
- "3. Conduct in vivo comparative models to assess regenerative efficacy against gold-standard substrates.\n"
136
- "4. Rigorously validate to exclude pathological fibrosis and power-density toxicity."
137
- ),
138
- "path": ["seed", "bio", "card", "alt1", "trial"],
139
- },
140
- {
141
- "hypothesis": "Deployment of dynamic peptide networks that self-assemble post-infarction to orchestrate local immunological responses and guide substrate regeneration.",
142
- "metrics": {
143
- "Novelty": 88,
144
- "Mechanistic clarity": 82,
145
- "Experimental tractability": 86,
146
- "Cross-domain distance": 85,
147
- },
148
- "outline": (
149
- "1. Formulate peptide sequences programmed for triggered in situ self-assembly within the myocardial infarct zone.\n"
150
- "2. Quantify macrophage polarization and local immune choreography post-deployment.\n"
151
- "3. Map the temporospatial degradation profile against de novo tissue formation.\n"
152
- "4. Falsify against off-target aggregation and delayed clearance risks."
153
- ),
154
- "path": ["seed", "nano", "selfasm", "alt2", "trial"],
155
- },
156
- {
157
- "hypothesis": "Integration of conductive hydrogels with immunomodulatory properties to simultaneously bridge electrical uncoupling and mitigate adverse fibrotic scarring.",
158
- "metrics": {
159
- "Novelty": 85,
160
- "Mechanistic clarity": 90,
161
- "Experimental tractability": 88,
162
- "Cross-domain distance": 79,
163
- },
164
- "outline": (
165
- "1. Fabricate biocompatible hydrogels featuring precisely tuned electrical conductivity and immunomodulatory motifs.\n"
166
- "2. Monitor electrophysiological synchronization across the scaffold-tissue interface.\n"
167
- "3. Assess macrophage state transitions and suppression of adverse fibrotic remodelling.\n"
168
- "4. Validate long-term persistence, hemocompatibility, and mechanical integration."
169
- ),
170
- "path": ["seed", "bio", "card", "immune", "trial"],
171
- },
172
  ]
173
 
174
- PDF_PARSER_OPTIONS = getattr(slg, "PDF_PARSERS", ["pymupdf"])
175
-
176
-
177
- # ── Utility helpers ─────────────────────────────────────────────────────────
178
- def norm_text(x: Optional[str]) -> str:
179
- return re.sub(r"\s+", " ", (x or "")).strip()
180
-
181
-
182
- def build_metrics_markdown(metrics: Dict[str, int]) -> str:
183
- return "\n".join(f"- {key}: {value}/100" for key, value in metrics.items())
184
 
185
-
186
- # ── HTML builders ────────────────────────────────────────────────────────────
187
- def build_connectome_html(path_ids: List[str]) -> str:
188
  active = set(path_ids)
189
- node_map = {n["id"]: n for n in NODES}
190
-
191
- path_pairs = {
192
- pair
193
- for i in range(len(path_ids) - 1)
194
- for pair in [(path_ids[i], path_ids[i + 1]), (path_ids[i + 1], path_ids[i])]
195
- }
196
-
197
- base_lines, active_lines, circles, labels = [], [], [], []
198
-
199
  for a, b in EDGES:
200
  na, nb = node_map[a], node_map[b]
201
- x1, y1 = na["x"] * 8 + 80, na["y"] * 6 + 280
202
- x2, y2 = nb["x"] * 8 + 80, nb["y"] * 6 + 280
203
- base_lines.append(
204
- f'<line class="edge" x1="{x1:.1f}" y1="{y1:.1f}" x2="{x2:.1f}" y2="{y2:.1f}" />'
205
- )
206
- if (a, b) in path_pairs:
207
- active_lines.append(
208
- f'<line class="edge active" x1="{x1:.1f}" y1="{y1:.1f}" x2="{x2:.1f}" y2="{y2:.1f}" />'
209
- )
210
-
211
  for n in NODES:
212
- cx, cy = n["x"] * 8 + 80, n["y"] * 6 + 280
213
- is_active = n["id"] in active
214
- state = "chosen" if is_active else "idle"
215
- halo_cls = "halo active" if is_active else "halo"
216
- lbl_cls = "label active" if is_active else "label"
217
- radius = 18 if is_active else 13
218
- halo_r = 30 if is_active else 0
219
-
220
- circles.append(
221
- f'<g class="node-wrap">'
222
- f'<circle class="{halo_cls}" cx="{cx:.1f}" cy="{cy:.1f}" r="{halo_r}" />'
223
- f'<circle class="node {n["group"]} {state}" cx="{cx:.1f}" cy="{cy:.1f}" r="{radius}" />'
224
- f"</g>"
225
- )
226
- labels.append(
227
- f'<text class="{lbl_cls}" x="{cx + 18:.1f}" y="{cy - 16:.1f}">{safe_text(n["label"])}</text>'
228
- )
229
-
230
- return f"""
231
- <div class="panel brain-shell">
232
  <div class="brain-header">
233
  <div>
234
  <p class="eyebrow">Connectome</p>
235
- <h3>3D Connectome</h3>
236
  </div>
237
  <div class="brain-legend">
238
- <span><i class="dot dot-live"></i> lit path</span>
239
- <span><i class="dot dot-chosen"></i> chosen node</span>
240
- <span><i class="dot dot-idle"></i> available node</span>
241
  </div>
242
  </div>
243
  <div class="brain-stage">
244
- <svg viewBox="0 0 780 560" class="brain-svg" role="img" aria-label="DVNC 3D connectome visualisation">
245
- {''.join(base_lines)}
246
- {''.join(active_lines)}
 
 
 
247
  {''.join(circles)}
248
  {''.join(labels)}
249
  </svg>
250
  </div>
251
  </div>
252
- """
253
 
254
 
255
- def build_cards_html(cards: List[Dict]) -> str:
256
  items = []
257
  for i, c in enumerate(cards):
258
- items.append(
259
- f"""
260
- <article class="candidate-card" tabindex="0">
261
- <div class="candidate-card-inner">
262
- <div class="candidate-face candidate-front">
263
- <div class="candidate-top">
264
- <span class="chip">{safe_text(c["agent"])}</span>
265
- <span class="score">{safe_text(c["score"])}</span>
266
- </div>
267
- <h4>{safe_text(c["title"])}</h4>
268
- <p>{safe_text(c["front"])}</p>
269
- <div class="meta-row"><span>Novelty</span><strong>{safe_text(c["novelty"])}</strong></div>
270
- <button class="mini" type="button" onclick="triggerRouteSwap('{i}')">Use as main insight</button>
271
- </div>
272
- <div class="candidate-face candidate-back">
273
- <div class="candidate-top">
274
- <span class="chip alt">Alternative path</span>
275
- <span class="score">{safe_text(c["score"])}</span>
276
- </div>
277
- <h4>{safe_text(c["title"])}</h4>
278
- <p>{safe_text(c["back"])}</p>
279
- <div class="meta-row"><span>Swap into route</span><strong>Enabled</strong></div>
280
- <button class="mini" type="button" onclick="triggerRouteSwap('{i}')">Use as main insight</button>
281
- </div>
282
- </div>
283
- </article>
284
- """
285
- )
286
- return '<div class="panel" style="padding:20px;"><div class="candidate-grid">' + "".join(items) + "</div></div>"
 
 
 
 
 
 
 
287
 
288
 
289
- def build_chat_html(query: str, result: Dict) -> str:
290
- return f"""
291
- <div class="panel chat-panel">
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(
315
- f"""
316
- <div class="model-pill {active}">
317
- <span class="model-name">{safe_text(m["name"])}</span>
318
- <span class="model-tag">{safe_text(m["tag"])}</span>
319
- <small>{safe_text(m["desc"])}</small>
320
- </div>
321
- """
322
- )
323
- return '<div class="panel" style="padding:18px;"><div class="model-switcher">' + "".join(items) + "</div></div>"
324
-
325
 
326
- # ── Discovery logic ──────────────────────────────────────────────────────────
327
- def run_discovery(query: str, model_name: str):
328
- query_text = norm_text(query)
329
- model_text = norm_text(model_name) or "DVNC Sovereign"
330
 
331
- random.seed(len(query_text) + len(model_text))
332
-
333
- if "curie" in query_text.lower() or "einstein" in query_text.lower():
334
  primary = "Map the anomaly first, then force a distant analogy before composing the experimental programme."
335
  path = ["seed", "bio", "card", "immune", "trial"]
336
  else:
337
- primary = (
338
- "Utilization of a self-assembling conductive scaffold to transduce mechanical strain "
339
- "into localized regenerative signalling pathways."
340
- )
341
  path = DEFAULT_PATH
342
-
343
  summaries = [
344
  "Normalises the user prompt into a graph-searchable seed and isolates the tension inside the question.",
345
  "Finds remote conceptual bridges instead of staying near the starting domain cluster.",
@@ -347,20 +196,11 @@ def run_discovery(query: str, model_name: str):
347
  "Generates cross-domain analogies with a bias toward mechanism transfer rather than keyword similarity.",
348
  "Composes the lead hypothesis and two structurally different variants.",
349
  "Attacks weak assumptions, hidden confounders, and feasibility gaps.",
350
- "Produces a staged validation plan with measurable falsification criteria.",
351
  ]
352
- tags = ["input", "graph", "evidence", "analogy", "compose", "critique", "experiment"]
353
-
354
- reasoning = [
355
- {"step": i + 1, "agent": AGENTS[i], "tag": tags[i], "summary": summaries[i]}
356
- for i in range(7)
357
- ]
358
-
359
  result = {
360
- "summary": (
361
- "A deeper route was chosen through the connectome, with live alternatives preserved "
362
- "as swappable cards so the reasoning path can be inspected rather than hidden."
363
- ),
364
  "primary_hypothesis": primary,
365
  "reasoning": reasoning,
366
  "cards": CANDIDATES,
@@ -369,1079 +209,161 @@ def run_discovery(query: str, model_name: str):
369
  "Novelty": 93,
370
  "Mechanistic clarity": 89,
371
  "Experimental tractability": 82,
372
- "Cross-domain distance": 91,
373
- },
374
  }
375
-
376
- chat_html = build_chat_html(query_text, result)
377
  connectome_html = build_connectome_html(path)
378
- timeline_html = build_agent_route_cards_html(reasoning)
379
- metrics_md = build_metrics_markdown(result["metrics"])
380
  cards_html = build_cards_html(CANDIDATES)
381
- route_state = get_default_route_state()
382
-
383
- hypothesis_md = (
384
- "# Discovery Output\n\n"
385
- f"**Model:** {model_text}\n\n"
386
- f"**Primary hypothesis:** {result['primary_hypothesis']}\n\n"
387
- "## Scoring\n"
388
- f"{metrics_md}\n\n"
389
- "## Experimental outline\n"
390
- "1. Construct the candidate material or protocol.\n"
391
- "2. Test mechanistic signal expression under controlled conditions.\n"
392
- "3. Compare against baseline and nearest-neighbour alternatives.\n"
393
- "4. Falsify using the adversarial risk criteria surfaced in the reasoning path.\n"
394
- )
395
-
396
- return (
397
- chat_html,
398
- connectome_html,
399
- timeline_html,
400
- cards_html,
401
- hypothesis_md,
402
- build_models_html(model_text),
403
- route_state,
404
- )
405
-
406
-
407
- def apply_route_swap(query: str, model_name: str, route_swap_payload: str, route_state):
408
- try:
409
- idx = int((route_swap_payload or "").strip())
410
- except Exception:
411
- idx = 0
412
-
413
- if not (0 <= idx < len(ACADEMIC_INSIGHTS)):
414
- idx = 0
415
-
416
- academic = ACADEMIC_INSIGHTS[idx]
417
- connectome_html = build_connectome_html(academic["path"])
418
-
419
- result = {
420
- "summary": (
421
- "Main insight formally adopted. The connectome pathway and validation protocol "
422
- "have been realigned to the selected candidate methodology."
423
- ),
424
- "primary_hypothesis": academic["hypothesis"],
425
- }
426
- chat_html = build_chat_html(norm_text(query), result)
427
- metrics_md = build_metrics_markdown(academic["metrics"])
428
 
429
- hypothesis_md = (
430
- "# Discovery Output\n\n"
431
- f"**Model:** {norm_text(model_name) or 'DVNC Sovereign'}\n\n"
432
- f"**Primary hypothesis:** {academic['hypothesis']}\n\n"
433
- "## Scoring\n"
434
- f"{metrics_md}\n\n"
435
- "## Experimental outline\n"
436
- f"{academic['outline']}\n"
437
- )
438
-
439
- return chat_html, connectome_html, gr.update(), hypothesis_md, route_state
440
-
441
-
442
- # ── Example loaders ──────────────────────────────────────────────────────────
443
- def load_example() -> str:
444
- return (
445
- "How could a self-assembling conductive biomaterial improve cardiac tissue "
446
- "regeneration by converting mechanical strain into repair signalling?"
447
- )
448
-
449
-
450
- def load_paper_topic() -> str:
451
- return "self-assembling conductive biomaterials for cardiac repair"
452
-
453
-
454
- # ── CSS / HEAD ───────────────────────────────────────────────────────────────
455
- BASE_CSS = r"""
456
  :root {
457
- --bg: #ffffff;
458
- --panel: #ffffff;
459
- --line: rgba(0,0,0,.12);
460
- --text: #111111;
461
- --muted: #5b5b5b;
462
- --soft: rgba(0,0,0,.62);
463
- --gold: #ff6600;
464
- --teal: #17b8a6;
465
- --blue: #628dff;
466
- --chosen: #ff7a1a;
467
- --idle: #b8d8ff;
468
- --idle-stroke: #5e8fe6;
469
- --query-node: #ffd8b3;
470
- --paper-node: #d7f6f2;
471
- --upload-node: #e7defe;
472
- --shadow: 0 16px 40px rgba(0,0,0,.12);
473
  }
474
-
475
  html, body, .gradio-container {
476
- background: #ffffff !important;
477
- font-family: Inter, ui-sans-serif, system-ui, sans-serif;
478
- }
479
-
480
- .gradio-container {
481
- max-width: 1640px !important;
482
- padding: 20px !important;
483
- }
484
-
485
- #dvnc-shell {
486
- border: 1px solid var(--line);
487
- border-radius: 28px;
488
- overflow: hidden;
489
- background: #ffffff;
490
- box-shadow: var(--shadow);
491
- padding: 20px 22px 22px;
492
- }
493
-
494
- .hero-bar {
495
- display: flex;
496
- justify-content: space-between;
497
- align-items: center;
498
- gap: 16px;
499
- padding-bottom: 12px;
500
- border-bottom: 1px solid rgba(0,0,0,.06);
501
- margin-bottom: 16px;
502
- }
503
-
504
- .brand {
505
- display: flex;
506
- align-items: center;
507
- gap: 14px;
508
- }
509
-
510
- .logo {
511
- width: 42px;
512
- height: 42px;
513
- border-radius: 14px;
514
- display: grid;
515
- place-items: center;
516
- color: var(--gold);
517
- background: linear-gradient(135deg, rgba(255,122,26,.12), rgba(23,184,166,.10));
518
- border: 1px solid rgba(0,0,0,.08);
519
- }
520
-
521
- .logo svg {
522
- width: 24px;
523
- height: 24px;
524
- }
525
-
526
- .brand h1 {
527
- font-size: 1.05rem;
528
- margin: 0;
529
- font-weight: 700;
530
- letter-spacing: .12em;
531
- text-transform: uppercase;
532
- }
533
-
534
- .brand p {
535
- margin: 3px 0 0;
536
- color: var(--muted);
537
- font-size: .84rem;
538
- }
539
-
540
- .status {
541
- display: flex;
542
- gap: 10px;
543
- align-items: center;
544
- color: var(--soft);
545
- font-size: .85rem;
546
- }
547
-
548
- .status-dot {
549
- width: 10px;
550
- height: 10px;
551
- border-radius: 50%;
552
- background: var(--teal);
553
- box-shadow: 0 0 0 6px rgba(23,184,166,.10), 0 0 14px rgba(23,184,166,.25);
554
- }
555
-
556
- .panel {
557
- background: #ffffff;
558
- border: 1px solid var(--line);
559
- border-radius: 22px;
560
- box-shadow: inset 0 1px 0 rgba(255,255,255,.8);
561
- }
562
-
563
- .querybox textarea,
564
- .querybox input {
565
- background: transparent !important;
566
  color: var(--text) !important;
 
567
  }
568
-
569
- .querybox,
570
- .querybox > div {
571
- background: #ffffff !important;
572
- border-radius: 18px !important;
573
- border-color: var(--line) !important;
574
- }
575
-
576
- .chat-panel {
577
- padding: 18px;
578
- min-height: 280px;
579
- }
580
-
581
- .chat-thread {
582
- display: flex;
583
- flex-direction: column;
584
- gap: 14px;
585
- }
586
-
587
- .bubble {
588
- max-width: 88%;
589
- padding: 16px 18px;
590
- border-radius: 22px;
591
- border: 1px solid var(--line);
592
- }
593
-
594
- .bubble p {
595
- margin: 8px 0 0;
596
- line-height: 1.6;
597
- font-size: .96rem;
598
- color: var(--text);
599
- }
600
-
601
- .bubble .role {
602
- font-size: .72rem;
603
- letter-spacing: .12em;
604
- text-transform: uppercase;
605
- color: var(--muted);
606
- }
607
-
608
- .bubble-user {
609
- align-self: flex-end;
610
- background: linear-gradient(135deg, rgba(98,141,255,.16), rgba(98,141,255,.08));
611
- }
612
-
613
- .bubble-ai {
614
- align-self: flex-start;
615
- background: #ffffff;
616
- }
617
-
618
- .bubble-system {
619
- align-self: flex-start;
620
- background: linear-gradient(135deg, rgba(255,122,26,.10), rgba(255,122,26,.04));
621
- }
622
-
623
- .model-switcher {
624
- display: grid;
625
- grid-template-columns: repeat(3, 1fr);
626
- gap: 12px;
627
- }
628
-
629
- .model-pill {
630
- padding: 14px;
631
- border: 1px solid var(--line);
632
- border-radius: 18px;
633
- display: flex;
634
- flex-direction: column;
635
- gap: 4px;
636
- min-height: 98px;
637
- background: #ffffff;
638
- }
639
-
640
- .model-pill.active {
641
- border-color: rgba(255,122,26,.40);
642
- background: linear-gradient(135deg, rgba(255,122,26,.10), rgba(255,255,255,.96));
643
- }
644
-
645
- .model-name {
646
- font-weight: 650;
647
- color: var(--text);
648
- }
649
-
650
- .model-tag {
651
- font-size: .76rem;
652
- text-transform: uppercase;
653
- letter-spacing: .12em;
654
- color: var(--gold);
655
- }
656
-
657
- .model-pill small {
658
- color: var(--muted);
659
- line-height: 1.45;
660
- }
661
-
662
- .brain-shell {
663
- padding: 18px;
664
- }
665
-
666
- .brain-header {
667
- display: flex;
668
- justify-content: space-between;
669
- align-items: flex-end;
670
- gap: 16px;
671
- margin-bottom: 10px;
672
- }
673
-
674
- .eyebrow {
675
- font-size: .72rem;
676
- letter-spacing: .16em;
677
- text-transform: uppercase;
678
- color: var(--gold);
679
- margin: 0 0 4px;
680
- }
681
-
682
- .brain-header h3 {
683
- margin: 0;
684
- font-size: 1.12rem;
685
- color: var(--text);
686
- }
687
-
688
- .brain-legend {
689
- display: flex;
690
- gap: 14px;
691
- color: var(--muted);
692
- font-size: .8rem;
693
- flex-wrap: wrap;
694
- }
695
-
696
- .dot {
697
- width: 10px;
698
- height: 10px;
699
- display: inline-block;
700
- border-radius: 50%;
701
- margin-right: 6px;
702
- }
703
-
704
- .dot-live {
705
- background: var(--chosen);
706
- box-shadow: 0 0 10px rgba(255,122,26,.35);
707
- }
708
-
709
- .dot-chosen {
710
- background: var(--chosen);
711
- }
712
-
713
- .dot-idle {
714
- background: var(--idle);
715
- border: 1px solid var(--idle-stroke);
716
- }
717
-
718
- .dot-query {
719
- background: var(--query-node);
720
- border: 1px solid #de9e58;
721
- }
722
-
723
- .dot-paper {
724
- background: var(--paper-node);
725
- border: 1px solid #4fb3a5;
726
- }
727
-
728
- .dot-upload {
729
- background: var(--upload-node);
730
- border: 1px solid #8f73d9;
731
- }
732
-
733
- .brain-stage {
734
- position: relative;
735
- min-height: 420px;
736
- overflow: hidden;
737
- background: linear-gradient(180deg, rgba(250,250,250,1), rgba(255,255,255,1));
738
- border: 1px solid rgba(0,0,0,.05);
739
- border-radius: 20px;
740
- }
741
-
742
- .brain-svg {
743
- width: 100%;
744
- height: 520px;
745
- display: block;
746
- }
747
-
748
- .edge {
749
- stroke: rgba(0,0,0,.12);
750
- stroke-width: 2.4;
751
- }
752
-
753
- .edge.active {
754
- stroke: var(--chosen);
755
- stroke-width: 4.2;
756
- stroke-linecap: round;
757
- filter: drop-shadow(0 0 6px rgba(255,122,26,.45));
758
- stroke-dasharray: 8 12;
759
- animation: pulseEdge 1.5s linear infinite;
760
- }
761
-
762
- .node {
763
- stroke-width: 2.2;
764
- transition: all .25s ease;
765
- }
766
-
767
- .node.idle {
768
- fill: var(--idle);
769
- stroke: var(--idle-stroke);
770
- }
771
-
772
- .node.chosen {
773
- fill: var(--chosen);
774
- stroke: #ffb16d;
775
- }
776
-
777
- .halo {
778
- fill: none;
779
- }
780
-
781
- .halo.active {
782
- stroke: rgba(255,122,26,.18);
783
- stroke-width: 12;
784
- }
785
-
786
- .label {
787
- fill: #2c2c2c;
788
- font-size: 13px;
789
- font-weight: 500;
790
- letter-spacing: .01em;
791
- }
792
-
793
- .label.active {
794
- fill: #111111;
795
- font-weight: 700;
796
- }
797
-
798
- .learn-edge {
799
- stroke: rgba(0,0,0,.18);
800
- stroke-width: 2.2;
801
- stroke-linecap: round;
802
- }
803
-
804
- .learn-node {
805
- stroke-width: 2.2;
806
- }
807
-
808
- .learn-node.query {
809
- fill: var(--query-node);
810
- stroke: #de9e58;
811
- }
812
-
813
- .learn-node.paper {
814
- fill: var(--paper-node);
815
- stroke: #36a091;
816
- }
817
-
818
- .learn-node.upload {
819
- fill: var(--upload-node);
820
- stroke: #7e63cb;
821
- }
822
-
823
- .learn-label {
824
- fill: #1e1e1e;
825
- font-size: 12px;
826
- font-weight: 600;
827
- }
828
-
829
- .learning-empty {
830
- display: grid;
831
- place-items: center;
832
- }
833
-
834
- .empty-graph-copy {
835
- text-align: center;
836
- max-width: 440px;
837
- padding: 40px 20px;
838
- }
839
-
840
- .empty-graph-copy h4 {
841
- margin: 0 0 10px;
842
- font-size: 1.05rem;
843
- }
844
-
845
- .empty-graph-copy p {
846
- margin: 0;
847
- color: var(--muted);
848
- line-height: 1.6;
849
- }
850
-
851
- .timeline {
852
- display: flex;
853
- flex-direction: column;
854
- gap: 10px;
855
- }
856
-
857
- .agent-step {
858
- border: 1px solid var(--line);
859
- border-radius: 18px;
860
- background: #ffffff;
861
- overflow: hidden;
862
- }
863
-
864
- .agent-summary {
865
- list-style: none;
866
- display: grid;
867
- grid-template-columns: 42px 1fr;
868
- gap: 12px;
869
- align-items: center;
870
- padding: 12px;
871
- cursor: pointer;
872
- }
873
-
874
- .agent-summary::-webkit-details-marker {
875
- display: none;
876
- }
877
-
878
- .agent-index {
879
- width: 42px;
880
- height: 42px;
881
- border-radius: 14px;
882
- display: grid;
883
- place-items: center;
884
- font-weight: 700;
885
- color: var(--gold);
886
- background: rgba(255,122,26,.08);
887
- border: 1px solid rgba(255,122,26,.18);
888
- }
889
-
890
- .agent-head {
891
- display: flex;
892
- justify-content: space-between;
893
- gap: 12px;
894
- align-items: center;
895
- }
896
-
897
- .agent-head h4 {
898
- margin: 0;
899
- font-size: .98rem;
900
- color: var(--text);
901
- }
902
-
903
- .agent-head span {
904
- font-size: .72rem;
905
- letter-spacing: .12em;
906
- text-transform: uppercase;
907
- color: var(--muted);
908
- }
909
-
910
- .agent-copy {
911
- padding: 0 14px 16px 66px;
912
- }
913
-
914
- .agent-copy p {
915
- margin: 0;
916
- color: #2d2d2d;
917
- font-size: .93rem;
918
- line-height: 1.6;
919
- }
920
-
921
- .candidate-grid {
922
- display: grid;
923
- grid-template-columns: repeat(3, minmax(0,1fr));
924
- gap: 18px;
925
- }
926
-
927
- .candidate-card {
928
- background: none;
929
- perspective: 1400px;
930
- min-height: 330px;
931
- }
932
-
933
- .candidate-card-inner {
934
- position: relative;
935
- width: 100%;
936
- min-height: 330px;
937
- transition: transform .8s cubic-bezier(.2,.7,.1,1);
938
- transform-style: preserve-3d;
939
- }
940
-
941
- .candidate-card:hover .candidate-card-inner,
942
- .candidate-card:focus .candidate-card-inner,
943
- .candidate-card:focus-within .candidate-card-inner {
944
- transform: rotateY(180deg);
945
- }
946
-
947
- .candidate-face {
948
- position: absolute;
949
- inset: 0;
950
- padding: 20px;
951
- border-radius: 22px;
952
- border: 1px solid var(--line);
953
- background: #ffffff;
954
- color: var(--text);
955
- backface-visibility: hidden;
956
- box-shadow: 0 12px 24px rgba(0,0,0,.06);
957
- display: flex;
958
- flex-direction: column;
959
- gap: 14px;
960
- }
961
-
962
- .candidate-back {
963
- transform: rotateY(180deg);
964
- }
965
-
966
- .candidate-top {
967
- display: flex;
968
- justify-content: space-between;
969
- align-items: center;
970
- gap: 8px;
971
- }
972
-
973
- .chip {
974
- font-size: .72rem;
975
- text-transform: uppercase;
976
- letter-spacing: .12em;
977
- color: #0b6f66;
978
- padding: 7px 10px;
979
- border-radius: 999px;
980
- background: rgba(23,184,166,.08);
981
- border: 1px solid rgba(23,184,166,.18);
982
- }
983
-
984
- .chip.alt {
985
- color: var(--gold);
986
- background: rgba(255,122,26,.08);
987
- border-color: rgba(255,122,26,.18);
988
- }
989
-
990
- .score {
991
- font-weight: 700;
992
- color: var(--gold);
993
- }
994
-
995
- .candidate-face h4 {
996
- margin: 0;
997
- font-size: 1.08rem;
998
- line-height: 1.35;
999
- }
1000
-
1001
- .candidate-face p {
1002
- margin: 0;
1003
- color: #1e1e1e;
1004
- line-height: 1.65;
1005
- font-size: .96rem;
1006
- overflow-wrap: anywhere;
1007
- }
1008
-
1009
- .meta-row {
1010
- margin-top: auto;
1011
- display: flex;
1012
- justify-content: space-between;
1013
- color: var(--muted);
1014
- font-size: .88rem;
1015
- gap: 14px;
1016
- }
1017
-
1018
- .mini {
1019
- cursor: pointer;
1020
- margin-top: 8px;
1021
- align-self: flex-start;
1022
- color: var(--text);
1023
- padding: 10px 12px;
1024
- border-radius: 14px;
1025
- border: 1px solid var(--line);
1026
- background: #ffffff;
1027
- transition: all 0.2s;
1028
- }
1029
-
1030
- .mini:hover {
1031
- background: #f5f5f5;
1032
- border-color: var(--chosen);
1033
- }
1034
-
1035
- .papers-grid {
1036
- display: grid;
1037
- grid-template-columns: repeat(2, minmax(0,1fr));
1038
- gap: 14px;
1039
- }
1040
-
1041
- .paper-card {
1042
- border: 1px solid var(--line);
1043
- border-radius: 18px;
1044
- padding: 16px;
1045
- background: #ffffff;
1046
- }
1047
-
1048
- .paper-topline {
1049
- display: flex;
1050
- gap: 8px;
1051
- flex-wrap: wrap;
1052
- margin-bottom: 10px;
1053
- }
1054
-
1055
- .paper-badge {
1056
- font-size: .72rem;
1057
- padding: 6px 10px;
1058
- border-radius: 999px;
1059
- background: rgba(98,141,255,.08);
1060
- color: #3456b5;
1061
- border: 1px solid rgba(98,141,255,.18);
1062
- }
1063
-
1064
- .paper-badge.alt {
1065
- background: rgba(0,0,0,.04);
1066
- color: #444;
1067
- border-color: rgba(0,0,0,.08);
1068
- }
1069
-
1070
- .doi-badge {
1071
- background: rgba(255,122,26,.08);
1072
- color: #8a4105;
1073
- border-color: rgba(255,122,26,.18);
1074
- }
1075
-
1076
- .paper-card h4 {
1077
- margin: 0 0 10px;
1078
- line-height: 1.35;
1079
- font-size: 1rem;
1080
- }
1081
-
1082
- .paper-card p {
1083
- margin: 0 0 12px;
1084
- line-height: 1.6;
1085
- color: #222;
1086
- }
1087
-
1088
- .paper-links {
1089
- display: flex;
1090
- gap: 12px;
1091
- flex-wrap: wrap;
1092
- }
1093
-
1094
- .paper-meta-stack {
1095
- display: flex;
1096
- flex-direction: column;
1097
- gap: 6px;
1098
- color: #444;
1099
- margin-bottom: 12px;
1100
- font-size: .9rem;
1101
- }
1102
-
1103
- .paper-links a,
1104
- .journal-card,
1105
- .upload-note a {
1106
- color: #0b63ce;
1107
- text-decoration: none;
1108
- }
1109
-
1110
- .journal-grid {
1111
- display: grid;
1112
- grid-template-columns: repeat(2, minmax(0,1fr));
1113
- gap: 14px;
1114
- }
1115
-
1116
- .journal-card {
1117
- border: 1px solid var(--line);
1118
- border-radius: 18px;
1119
- padding: 16px;
1120
- display: flex;
1121
- justify-content: space-between;
1122
- gap: 14px;
1123
- align-items: center;
1124
- background: #ffffff;
1125
- }
1126
-
1127
- .journal-card h4 {
1128
- margin: 0 0 6px;
1129
- }
1130
-
1131
- .journal-card p {
1132
- margin: 0;
1133
- color: var(--muted);
1134
- line-height: 1.5;
1135
- }
1136
-
1137
- .upload-note {
1138
- border: 1px dashed rgba(0,0,0,.16);
1139
- border-radius: 18px;
1140
- padding: 16px;
1141
- background: rgba(0,0,0,.015);
1142
- color: #1f1f1f;
1143
- line-height: 1.6;
1144
- }
1145
-
1146
- .selection-panel {
1147
- padding: 18px;
1148
- }
1149
-
1150
- .parse-grid {
1151
- display: grid;
1152
- grid-template-columns: 1.2fr 1fr;
1153
- gap: 14px;
1154
- }
1155
-
1156
- .parse-card {
1157
- border: 1px solid var(--line);
1158
- border-radius: 18px;
1159
- padding: 16px;
1160
- background: #ffffff;
1161
- }
1162
-
1163
- .ref-list {
1164
- margin: 0;
1165
- padding-left: 18px;
1166
- }
1167
-
1168
- .ref-list li {
1169
- margin-bottom: 8px;
1170
- line-height: 1.5;
1171
- }
1172
-
1173
- .prosebox {
1174
- padding: 18px;
1175
- white-space: pre-wrap;
1176
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1177
- line-height: 1.55;
1178
- color: #1b1b1b;
1179
- }
1180
-
1181
- .gr-button-primary {
1182
- background: linear-gradient(135deg, rgba(255,122,26,.92), rgba(240,108,22,.92)) !important;
1183
- color: #ffffff !important;
1184
- border: none !important;
1185
- }
1186
-
1187
- .gr-button-secondary {
1188
- background: #ffffff !important;
1189
- color: var(--text) !important;
1190
- border: 1px solid var(--line) !important;
1191
- }
1192
-
1193
- footer {
1194
- display: none !important;
1195
- }
1196
-
1197
- @keyframes pulseEdge {
1198
- to { stroke-dashoffset: -40; }
1199
- }
1200
-
1201
- @media (max-width: 1180px) {
1202
- .model-switcher,
1203
- .candidate-grid,
1204
- .papers-grid,
1205
- .journal-grid,
1206
- .parse-grid {
1207
- grid-template-columns: 1fr;
1208
- }
1209
-
1210
- .brain-svg {
1211
- height: 460px;
1212
- }
1213
- }
1214
- """
1215
-
1216
- CSS = BASE_CSS + "\n" + get_dvnc_layout_css() + "\n" + get_discovery_css()
1217
-
1218
- HEAD = """
1219
  <link rel="preconnect" href="https://fonts.googleapis.com">
1220
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
1221
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
1222
- <script>
1223
- function triggerRouteSwap(idx) {
1224
- const container = document.getElementById("route_swap_payload");
1225
- if (!container) return;
1226
- const input = container.querySelector("textarea, input");
1227
- if (!input) return;
1228
-
1229
- input.value = String(idx);
1230
- input.dispatchEvent(new Event("input", { bubbles: true }));
1231
- input.dispatchEvent(new Event("change", { bubbles: true }));
1232
-
1233
- setTimeout(() => {
1234
- const btn = document.getElementById("route_swap_apply");
1235
- if (btn) btn.click();
1236
- }, 120);
1237
- }
1238
- </script>
1239
- """
1240
-
1241
-
1242
- # ── Gradio layout ────────────────────────────────────────────────────────────
1243
- with gr.Blocks(
1244
- css=CSS,
1245
- head=HEAD,
1246
- theme=gr.themes.Base(),
1247
- fill_height=True,
1248
- title="DVNC.AI",
1249
- ) as demo:
1250
- # Shared state
1251
- papers_state = gr.State([])
1252
- parsed_pdf_state = gr.State({})
1253
- ingest_payload_state = gr.State({})
1254
- route_state = gr.State(get_default_route_state())
1255
 
1256
- # Header
1257
- gr.HTML(
1258
- """
1259
- <div id="dvnc-shell">
 
1260
  <div class="hero-bar">
1261
  <div class="brand">
1262
  <div class="logo" aria-hidden="true">
1263
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7">
1264
- <path d="M5 17L12 4l7 13"/>
1265
- <path d="M8.5 12.5h7"/>
1266
- <circle cx="12" cy="12" r="1.8" fill="currentColor" stroke="none"/>
1267
- </svg>
1268
  </div>
1269
  <div>
1270
  <h1>DVNC.AI</h1>
1271
- <p>Sovereign discovery instrument · connectome-native reasoning</p>
1272
  </div>
1273
  </div>
1274
  <div class="status"><span class="status-dot"></span><span>Live orchestration</span></div>
1275
  </div>
1276
- </div>
1277
- """
1278
- )
1279
-
1280
- with gr.Tabs():
1281
- # ── Tab 1: Discovery Engine ──────────────────────────────────────────
1282
- with gr.Tab("Discovery Engine"):
1283
- model_html = gr.HTML(build_models_html("DVNC Sovereign"))
 
 
 
 
 
 
 
 
 
 
1284
 
1285
- with gr.Row():
1286
- with gr.Column(scale=2):
1287
- model = gr.Dropdown(
1288
- choices=[m["name"] for m in MODELS],
1289
- value="DVNC Sovereign",
1290
- label="Model tier",
1291
- )
1292
- query = gr.Textbox(
1293
- label="Discovery query",
1294
- elem_classes=["querybox"],
1295
- placeholder="Enter a scientific question, anomaly, or breakthrough direction…",
1296
- lines=4,
1297
- )
1298
- with gr.Row():
1299
- run_btn = gr.Button("Run discovery", variant="primary")
1300
- example_btn = gr.Button("Load example", variant="secondary")
1301
-
1302
- chat = gr.HTML(
1303
- """
1304
- <div class="panel chat-panel">
1305
- <div class="chat-thread">
1306
- <div class="bubble bubble-ai">
1307
- <span class="role">DVNC</span>
1308
- <p>Enter a query to activate the 7-agent discovery stack and illuminate the chosen path through the 3D connectome.</p>
1309
- </div>
1310
- </div>
1311
- </div>
1312
- """
1313
- )
1314
-
1315
- with gr.Column(scale=3):
1316
- connectome = gr.HTML(build_connectome_html(DEFAULT_PATH))
1317
- cards = gr.HTML("")
1318
-
1319
- output = gr.Markdown("# Discovery Output\n\nAwaiting query.")
1320
- timeline = gr.HTML(get_initial_discovery_timeline_html())
1321
-
1322
- route_swap_payload = gr.Textbox(value="", visible=False, elem_id="route_swap_payload")
1323
- route_swap_apply = gr.Button("Apply route swap", visible=False, elem_id="route_swap_apply")
1324
-
1325
- # ── Tab 2: Self-Learning Graph ───────────────────────────────────────
1326
- with gr.Tab("Self-Learning Graph"):
1327
- with gr.Row():
1328
- with gr.Column(scale=2):
1329
- paper_query = gr.Textbox(
1330
- label="Research topic / title / DOI / link",
1331
- elem_classes=["querybox"],
1332
- placeholder="e.g. self-assembling conductive biomaterials for cardiac repair",
1333
- lines=3,
1334
- )
1335
- search_mode = gr.Dropdown(
1336
- choices=SEARCH_MODES,
1337
- value="topic",
1338
- label="Search mode",
1339
- )
1340
- source_selector = gr.CheckboxGroup(
1341
- choices=SOURCE_OPTIONS,
1342
- value=DEFAULT_SOURCES,
1343
- label="Sources",
1344
- )
1345
- pdf_upload = gr.File(
1346
- label="Upload PDF papers",
1347
- file_types=[".pdf"],
1348
- file_count="single",
1349
- )
1350
-
1351
- with gr.Row():
1352
- learn_btn = gr.Button("Discover papers", variant="primary")
1353
- load_topic_btn = gr.Button("Load example topic", variant="secondary")
1354
-
1355
- upload_status = gr.Markdown("No PDF uploaded yet.")
1356
- discovery_status = gr.Markdown("### No discovery results yet.")
1357
- journal_panel = gr.HTML(build_journal_html("biomaterials cardiac repair"))
1358
-
1359
- gr.HTML(
1360
- '<div class="panel selection-panel"><h3 style="margin:0 0 12px;">Select papers to ingest</h3></div>'
1361
- )
1362
-
1363
- selection_box = gr.CheckboxGroup(
1364
- choices=[],
1365
- value=[],
1366
- label="Candidate papers",
1367
- )
1368
-
1369
- parser_order = gr.CheckboxGroup(
1370
- choices=PDF_PARSER_OPTIONS,
1371
- value=PDF_PARSER_OPTIONS,
1372
- label="Parser routing order",
1373
- )
1374
-
1375
- with gr.Row():
1376
- parse_btn = gr.Button("Parse uploaded PDF", variant="secondary")
1377
- ingest_btn = gr.Button("Ingest selected into graph", variant="primary")
1378
-
1379
- with gr.Column(scale=3):
1380
- learning_graph = gr.HTML(build_learning_graph_html([], []))
1381
- papers_panel = gr.HTML(
1382
- '<div class="panel papers-panel" style="padding:18px"><p>Search by topic, title, DOI, or link, then select papers before graph ingestion.</p></div>'
1383
- )
1384
- parse_summary = gr.Markdown("### PDF parse status\n\nAwaiting upload.")
1385
- parse_panel = gr.HTML(
1386
- '<div class="panel" style="padding:18px"><p>No parsed document yet.</p></div>'
1387
- )
1388
- ingest_summary = gr.Markdown("### Graph ingest status\n\nAwaiting paper selection.")
1389
- ingest_payload = gr.JSON(
1390
- label="Graph ingest payload",
1391
- value={"status": "empty", "nodes": [], "edges": []},
1392
- )
1393
-
1394
- # ── Event wiring ─────────────────────────────────────────────────────────
1395
  example_btn.click(fn=load_example, outputs=query)
1396
-
1397
- run_btn.click(
1398
- fn=run_discovery,
1399
- inputs=[query, model],
1400
- outputs=[chat, connectome, timeline, cards, output, model_html, route_state],
1401
- )
1402
-
1403
- route_swap_apply.click(
1404
- fn=apply_route_swap,
1405
- inputs=[query, model, route_swap_payload, route_state],
1406
- outputs=[chat, connectome, timeline, output, route_state],
1407
- )
1408
-
1409
- load_topic_btn.click(fn=load_paper_topic, outputs=paper_query)
1410
-
1411
- learn_btn.click(
1412
- fn=run_paper_discovery,
1413
- inputs=[paper_query, search_mode, source_selector, pdf_upload],
1414
- outputs=[
1415
- learning_graph,
1416
- papers_panel,
1417
- journal_panel,
1418
- upload_status,
1419
- selection_box,
1420
- papers_state,
1421
- discovery_status,
1422
- ],
1423
- )
1424
-
1425
- parse_btn.click(
1426
- fn=parse_uploaded_pdf,
1427
- inputs=[pdf_upload, parser_order],
1428
- outputs=[parse_summary, parsed_pdf_state],
1429
- ).then(
1430
- fn=render_parse_result,
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
+ import json
2
+ import math
 
 
 
 
 
3
  import random
 
 
 
 
4
  import gradio as gr
5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  MODELS = [
7
+ {"name": "DVNC Sovereign", "tag": "flagship", "desc": "Maximum depth orchestration for frontier discovery"},
8
+ {"name": "DVNC Atlas", "tag": "research", "desc": "Balanced reasoning, graph traversal, and synthesis"},
9
+ {"name": "DVNC Curie", "tag": "lab", "desc": "Experimental hypothesis generation for anomalous signals"},
 
 
 
 
 
 
 
 
 
 
 
 
10
  ]
11
 
12
  AGENTS = [
 
20
  ]
21
 
22
  NODES = [
23
+ {"id": "seed", "label": "Seed Query", "group": "core", "x": 0, "y": 0, "z": 0},
24
+ {"id": "bio", "label": "Biomaterials", "group": "domain", "x": 18, "y": 12, "z": -8},
25
+ {"id": "card", "label": "Cardiac Repair", "group": "domain", "x": 34, "y": 2, "z": 14},
26
+ {"id": "nano", "label": "Nanostructure", "group": "bridge", "x": 14, "y": -18, "z": 16},
27
+ {"id": "selfasm", "label": "Self-Assembly", "group": "bridge", "x": 30, "y": -16, "z": -16},
28
+ {"id": "electro", "label": "Electro-signalling", "group": "mechanism", "x": 48, "y": 12, "z": -10},
29
+ {"id": "immune", "label": "Immune Modulation", "group": "mechanism", "x": 54, "y": -8, "z": 10},
30
+ {"id": "trial", "label": "Validation Path", "group": "outcome", "x": 70, "y": 0, "z": 0},
31
+ {"id": "alt1", "label": "Piezoelectric Scaffold", "group": "candidate", "x": 44, "y": 28, "z": 14},
32
+ {"id": "alt2", "label": "Peptide Mesh", "group": "candidate", "x": 42, "y": -28, "z": -14},
33
  ]
34
 
35
  EDGES = [
36
+ ("seed", "bio"), ("seed", "nano"), ("bio", "card"), ("nano", "selfasm"),
37
+ ("selfasm", "electro"), ("card", "immune"), ("electro", "trial"), ("immune", "trial"),
38
+ ("card", "alt1"), ("selfasm", "alt2"), ("alt1", "trial"), ("alt2", "trial")
 
 
 
 
 
 
 
 
 
39
  ]
40
 
41
  DEFAULT_PATH = ["seed", "nano", "selfasm", "electro", "trial"]
 
47
  "back": "Discovery path: anomalous healing signal -> piezoelectric analog -> ion-channel entrainment -> tissue regeneration. Risk: power density and fibrosis coupling.",
48
  "score": 92,
49
  "novelty": "High",
50
+ "agent": "Hypothesis Composer"
51
  },
52
  {
53
  "title": "Peptide Self-Assembly Mesh",
 
55
  "back": "Discovery path: self-assembly -> local immune choreography -> regenerative substrate formation. Risk: degradation timing and targeting specificity.",
56
  "score": 88,
57
  "novelty": "High",
58
+ "agent": "Analogy Engine"
59
  },
60
  {
61
  "title": "Immune-Tuned Conductive Hydrogel",
 
63
  "back": "Discovery path: inflammation mismatch -> conductive medium -> macrophage polarization -> synchronized healing. Risk: persistence and biocompatibility.",
64
  "score": 85,
65
  "novelty": "Medium-High",
66
+ "agent": "Adversarial Critic"
67
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  ]
69
 
 
 
 
 
 
 
 
 
 
 
70
 
71
+ def build_connectome_html(path_ids):
 
 
72
  active = set(path_ids)
73
+ node_map = {n['id']: n for n in NODES}
74
+ lines = []
 
 
 
 
 
 
 
 
75
  for a, b in EDGES:
76
  na, nb = node_map[a], node_map[b]
77
+ active_edge = a in active and b in active and abs(path_ids.index(a) - path_ids.index(b)) == 1 if a in path_ids and b in path_ids else False
78
+ cls = "edge active" if active_edge else "edge"
79
+ lines.append(f'<line class="{cls}" x1="{na["x"]*6+420:.1f}" y1="{na["y"]*6+280:.1f}" x2="{nb["x"]*6+420:.1f}" y2="{nb["y"]*6+280:.1f}" />')
80
+ circles = []
81
+ labels = []
 
 
 
 
 
82
  for n in NODES:
83
+ cx = n['x']*6 + 420
84
+ cy = n['y']*6 + 280
85
+ cls = f"node {n['group']} {'active' if n['id'] in active else ''}"
86
+ circles.append(f'<g class="node-wrap"><circle class="{cls}" cx="{cx:.1f}" cy="{cy:.1f}" r="{17 if n["id"] in active else 12}" /><circle class="halo {'active' if n['id'] in active else ''}" cx="{cx:.1f}" cy="{cy:.1f}" r="{28 if n["id"] in active else 0}" /></g>')
87
+ labels.append(f'<text class="label {'active' if n["id"] in active else ''}" x="{cx+16:.1f}" y="{cy-16:.1f}">{n["label"]}</text>')
88
+ return f'''
89
+ <div class="brain-shell">
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  <div class="brain-header">
91
  <div>
92
  <p class="eyebrow">Connectome</p>
93
+ <h3>Concept Brain</h3>
94
  </div>
95
  <div class="brain-legend">
96
+ <span><i class="dot dot-live"></i> active route</span>
97
+ <span><i class="dot dot-node"></i> concept nodes</span>
 
98
  </div>
99
  </div>
100
  <div class="brain-stage">
101
+ <div class="orb orb-a"></div><div class="orb orb-b"></div>
102
+ <svg viewBox="0 0 840 560" class="brain-svg" role="img" aria-label="DVNC connectome visualisation">
103
+ <defs>
104
+ <filter id="glow"><feGaussianBlur stdDeviation="4" result="c"/><feMerge><feMergeNode in="c"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
105
+ </defs>
106
+ {''.join(lines)}
107
  {''.join(circles)}
108
  {''.join(labels)}
109
  </svg>
110
  </div>
111
  </div>
112
+ '''
113
 
114
 
115
+ def build_cards_html(cards):
116
  items = []
117
  for i, c in enumerate(cards):
118
+ items.append(f'''
119
+ <article class="candidate-card" tabindex="0">
120
+ <div class="candidate-card-inner">
121
+ <div class="candidate-face candidate-front">
122
+ <div class="candidate-top"><span class="chip">{c['agent']}</span><span class="score">{c['score']}</span></div>
123
+ <h4>{c['title']}</h4>
124
+ <p>{c['front']}</p>
125
+ <div class="meta-row"><span>Novelty</span><strong>{c['novelty']}</strong></div>
126
+ <button class="mini">Flip insight</button>
127
+ </div>
128
+ <div class="candidate-face candidate-back">
129
+ <div class="candidate-top"><span class="chip alt">Alternative path</span><span class="score">{c['score']}</span></div>
130
+ <h4>{c['title']}</h4>
131
+ <p>{c['back']}</p>
132
+ <div class="meta-row"><span>Swap into route</span><strong>Enabled</strong></div>
133
+ <button class="mini">Return</button>
134
+ </div>
135
+ </div>
136
+ </article>
137
+ ''')
138
+ return '<div class="candidate-grid">' + ''.join(items) + '</div>'
139
+
140
+
141
+ def build_agent_timeline(reasoning):
142
+ rows = []
143
+ for r in reasoning:
144
+ rows.append(f'''
145
+ <div class="agent-step">
146
+ <div class="agent-index">{r['step']}</div>
147
+ <div class="agent-copy">
148
+ <div class="agent-head"><h4>{r['agent']}</h4><span>{r['tag']}</span></div>
149
+ <p>{r['summary']}</p>
150
+ </div>
151
+ </div>
152
+ ''')
153
+ return '<div class="timeline">' + ''.join(rows) + '</div>'
154
 
155
 
156
+ def build_chat_html(query, result):
157
+ bubbles = f'''
158
+ <div class="chat-thread">
159
+ <div class="bubble bubble-user">
160
+ <span class="role">You</span>
161
+ <p>{query}</p>
162
+ </div>
163
+ <div class="bubble bubble-ai">
164
+ <span class="role">DVNC Sovereign</span>
165
+ <p>{result['summary']}</p>
166
+ </div>
167
+ <div class="bubble bubble-system">
168
+ <span class="role">Discovery Signal</span>
169
+ <p><strong>Primary hypothesis:</strong> {result['primary_hypothesis']}</p>
 
 
170
  </div>
171
  </div>
172
+ '''
173
+ return bubbles
174
 
175
 
176
+ def build_models_html(selected):
177
  items = []
178
  for m in MODELS:
179
+ active = 'active' if m['name'] == selected else ''
180
+ items.append(f'''<div class="model-pill {active}"><span class="model-name">{m['name']}</span><span class="model-tag">{m['tag']}</span><small>{m['desc']}</small></div>''')
181
+ return '<div class="model-switcher">' + ''.join(items) + '</div>'
 
 
 
 
 
 
 
 
 
182
 
 
 
 
 
183
 
184
+ def run_discovery(query, model_name):
185
+ random.seed(len(query) + len(model_name))
186
+ if "curie" in query.lower() or "einstein" in query.lower():
187
  primary = "Map the anomaly first, then force a distant analogy before composing the experimental programme."
188
  path = ["seed", "bio", "card", "immune", "trial"]
189
  else:
190
+ primary = "Use a self-assembling conductive scaffold that transforms mechanical strain into local regenerative signalling."
 
 
 
191
  path = DEFAULT_PATH
 
192
  summaries = [
193
  "Normalises the user prompt into a graph-searchable seed and isolates the tension inside the question.",
194
  "Finds remote conceptual bridges instead of staying near the starting domain cluster.",
 
196
  "Generates cross-domain analogies with a bias toward mechanism transfer rather than keyword similarity.",
197
  "Composes the lead hypothesis and two structurally different variants.",
198
  "Attacks weak assumptions, hidden confounders, and feasibility gaps.",
199
+ "Produces a staged validation plan with measurable falsification criteria."
200
  ]
201
+ reasoning = [{"step": i+1, "agent": AGENTS[i], "tag": ["input","graph","evidence","analogy","compose","critique","experiment"][i], "summary": summaries[i]} for i in range(7)]
 
 
 
 
 
 
202
  result = {
203
+ "summary": "A deeper route was chosen through the concept graph, with live alternatives preserved as candidate cards so the reasoning path can be swapped rather than hidden.",
 
 
 
204
  "primary_hypothesis": primary,
205
  "reasoning": reasoning,
206
  "cards": CANDIDATES,
 
209
  "Novelty": 93,
210
  "Mechanistic clarity": 89,
211
  "Experimental tractability": 82,
212
+ "Cross-domain distance": 91
213
+ }
214
  }
215
+ chat_html = build_chat_html(query, result)
 
216
  connectome_html = build_connectome_html(path)
 
 
217
  cards_html = build_cards_html(CANDIDATES)
218
+ timeline_html = build_agent_timeline(reasoning)
219
+ metrics = "\n".join([f"- {k}: {v}/100" for k, v in result["metrics"].items()])
220
+ hypothesis = f"""# Discovery Output\n\n**Model:** {model_name}\n\n**Primary hypothesis:** {result['primary_hypothesis']}\n\n## Scoring\n{metrics}\n\n## Experimental outline\n1. Construct the candidate material or protocol.\n2. Test mechanistic signal expression under controlled conditions.\n3. Compare against baseline and nearest-neighbour alternatives.\n4. Falsify using the adversarial risk criteria surfaced in the reasoning path.\n"""
221
+ return chat_html, connectome_html, timeline_html, cards_html, hypothesis, build_models_html(model_name)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
 
223
+ CSS = r'''
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
  :root {
225
+ --bg: #08090c;
226
+ --bg2: #0d1015;
227
+ --panel: rgba(17,20,27,.78);
228
+ --panel-solid: #0f131a;
229
+ --panel-2: rgba(21,26,35,.86);
230
+ --line: rgba(255,255,255,.08);
231
+ --soft: rgba(255,255,255,.55);
232
+ --text: #f5f7fb;
233
+ --muted: #8f99ab;
234
+ --gold: #f2d293;
235
+ --teal: #7be7dd;
236
+ --blue: #7ea9ff;
237
+ --shadow: 0 20px 60px rgba(0,0,0,.38);
238
+ --radius: 22px;
 
 
239
  }
 
240
  html, body, .gradio-container {
241
+ background: radial-gradient(circle at top left, rgba(120,144,255,.14), transparent 26%),
242
+ radial-gradient(circle at 90% 10%, rgba(242,210,147,.11), transparent 22%),
243
+ linear-gradient(180deg, #050608 0%, #090b10 50%, #07080c 100%) !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  color: var(--text) !important;
245
+ font-family: Inter, ui-sans-serif, system-ui, sans-serif;
246
  }
247
+ .gradio-container {max-width: 1550px !important; padding: 22px !important;}
248
+ #dvnc-shell {border: 1px solid var(--line); border-radius: 28px; overflow: hidden; background: linear-gradient(180deg, rgba(14,17,24,.86), rgba(7,8,12,.9)); box-shadow: var(--shadow); backdrop-filter: blur(18px);}
249
+ #dvnc-shell .wrap {display:grid; grid-template-columns: 1.12fr .88fr; min-height: 84vh;}
250
+ #dvnc-shell .left, #dvnc-shell .right {padding: 24px;}
251
+ #dvnc-shell .left {border-right: 1px solid var(--line); display:flex; flex-direction:column; gap:18px;}
252
+ #dvnc-shell .right {display:grid; grid-template-rows:auto auto 1fr auto; gap:18px;}
253
+ .hero-bar {display:flex; justify-content:space-between; align-items:center; gap:16px; padding-bottom:8px;}
254
+ .brand {display:flex; align-items:center; gap:14px;}
255
+ .logo {width:42px; height:42px; border-radius:14px; background: linear-gradient(135deg, rgba(242,210,147,.28), rgba(123,231,221,.16)); border:1px solid rgba(255,255,255,.1); display:grid; place-items:center; box-shadow: inset 0 1px 0 rgba(255,255,255,.08);}
256
+ .logo svg {width:24px; height:24px; color: var(--gold);}
257
+ .brand h1 {font-size: 1.05rem; margin:0; font-weight: 650; letter-spacing:.02em;}
258
+ .brand p {margin:3px 0 0; color:var(--muted); font-size:.84rem;}
259
+ .status {display:flex; gap:10px; align-items:center; color:var(--soft); font-size:.85rem;}
260
+ .status-dot {width:10px; height:10px; border-radius:50%; background:var(--teal); box-shadow:0 0 0 6px rgba(123,231,221,.08), 0 0 18px rgba(123,231,221,.4);}
261
+ .panel {background: linear-gradient(180deg, rgba(17,20,27,.82), rgba(12,14,19,.76)); border:1px solid var(--line); border-radius: 22px; box-shadow: inset 0 1px 0 rgba(255,255,255,.04);}
262
+ .chat-panel {padding:18px; display:flex; flex-direction:column; gap:14px; min-height: 340px;}
263
+ .querybox textarea, .querybox input {background: transparent !important; color: var(--text) !important;}
264
+ .querybox, .querybox > div {background: rgba(255,255,255,.02) !important; border-radius: 18px !important; border-color: var(--line) !important;}
265
+ .chat-thread {display:flex; flex-direction:column; gap:14px;}
266
+ .bubble {max-width: 86%; padding:16px 18px; border-radius: 22px; position:relative; border:1px solid var(--line);}
267
+ .bubble p {margin:8px 0 0; line-height:1.6; font-size:.96rem;}
268
+ .bubble .role {font-size:.72rem; letter-spacing:.12em; text-transform:uppercase; color:var(--muted);}
269
+ .bubble-user {align-self:flex-end; background: linear-gradient(135deg, rgba(126,169,255,.18), rgba(126,169,255,.08));}
270
+ .bubble-ai {align-self:flex-start; background: linear-gradient(135deg, rgba(255,255,255,.06), rgba(255,255,255,.03));}
271
+ .bubble-system {align-self:flex-start; background: linear-gradient(135deg, rgba(242,210,147,.12), rgba(242,210,147,.04));}
272
+ .model-switcher {display:grid; grid-template-columns:repeat(3,1fr); gap:12px;}
273
+ .model-pill {padding:14px; border:1px solid var(--line); border-radius:18px; background:rgba(255,255,255,.02); display:flex; flex-direction:column; gap:4px; min-height: 96px;}
274
+ .model-pill.active {border-color: rgba(242,210,147,.45); background: linear-gradient(135deg, rgba(242,210,147,.14), rgba(255,255,255,.03)); box-shadow: inset 0 0 0 1px rgba(242,210,147,.08);}
275
+ .model-name {font-weight:650;}
276
+ .model-tag {font-size:.76rem; text-transform:uppercase; letter-spacing:.12em; color:var(--gold);}
277
+ .model-pill small {color:var(--muted); line-height:1.45;}
278
+ .brain-shell {padding:18px; height:100%;}
279
+ .brain-header {display:flex; justify-content:space-between; align-items:flex-end; gap:16px; margin-bottom:10px;}
280
+ .eyebrow {font-size:.72rem; letter-spacing:.16em; text-transform:uppercase; color:var(--gold); margin:0 0 4px;}
281
+ .brain-header h3 {margin:0; font-size:1.12rem;}
282
+ .brain-legend {display:flex; gap:14px; color:var(--muted); font-size:.8rem; flex-wrap:wrap;}
283
+ .dot {width:10px; height:10px; display:inline-block; border-radius:50%; margin-right:6px;}
284
+ .dot-live {background:var(--gold); box-shadow:0 0 12px rgba(242,210,147,.65);} .dot-node {background:var(--teal);}
285
+ .brain-stage {position:relative; min-height: 360px; border-radius:20px; overflow:hidden; background: radial-gradient(circle at center, rgba(126,169,255,.08), transparent 35%), linear-gradient(180deg, rgba(255,255,255,.02), rgba(255,255,255,.01)); border:1px solid rgba(255,255,255,.05);}
286
+ .orb {position:absolute; border-radius:50%; filter: blur(40px); opacity:.28; pointer-events:none;}
287
+ .orb-a {width:180px; height:180px; background:rgba(123,231,221,.16); left:10%; top:18%;}
288
+ .orb-b {width:220px; height:220px; background:rgba(242,210,147,.12); right:8%; bottom:12%;}
289
+ .brain-svg {width:100%; height:100%; min-height:360px; transform: perspective(1200px) rotateX(10deg) rotateY(-8deg) scale(1.02);}
290
+ .edge {stroke: rgba(132,153,184,.18); stroke-width:2.2;}
291
+ .edge.active {stroke: var(--gold); stroke-width:3.4; filter:url(#glow); stroke-dasharray: 8 8; animation: pulseEdge 2.8s linear infinite;}
292
+ .node {fill:#7ea9ff; opacity:.88;}
293
+ .node.core {fill:#f5f7fb;} .node.domain {fill:#7be7dd;} .node.bridge {fill:#8ea4ff;} .node.mechanism {fill:#f2d293;} .node.outcome {fill:#ffd9a6;} .node.candidate {fill:#ddadff;}
294
+ .node.active {stroke:#fff7df; stroke-width:2.4; filter:url(#glow);}
295
+ .halo {fill:none;} .halo.active {stroke: rgba(242,210,147,.22); stroke-width:10;}
296
+ .label {fill: rgba(245,247,251,.68); font-size: 12px; letter-spacing: .02em;} .label.active {fill: #fff8e8;}
297
+ .timeline {display:flex; flex-direction:column; gap:10px;}
298
+ .agent-step {display:grid; grid-template-columns:42px 1fr; gap:12px; padding:12px; border:1px solid var(--line); border-radius:18px; background:rgba(255,255,255,.02);}
299
+ .agent-index {width:42px; height:42px; border-radius:14px; display:grid; place-items:center; font-weight:700; color:var(--gold); background:rgba(242,210,147,.08); border:1px solid rgba(242,210,147,.16);}
300
+ .agent-head {display:flex; justify-content:space-between; gap:12px; align-items:center; margin-bottom:4px;}
301
+ .agent-head h4 {margin:0; font-size:.98rem;} .agent-head span {font-size:.72rem; letter-spacing:.12em; text-transform:uppercase; color:var(--muted);} .agent-copy p {margin:0; color:#cbd3e2; font-size:.9rem; line-height:1.55;}
302
+ .candidate-grid {display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap:14px;}
303
+ .candidate-card {background:none; perspective:1200px; min-height: 250px;}
304
+ .candidate-card-inner {position:relative; width:100%; height:100%; min-height:250px; transition: transform .8s cubic-bezier(.2,.7,.1,1); transform-style:preserve-3d;}
305
+ .candidate-card:hover .candidate-card-inner, .candidate-card:focus .candidate-card-inner, .candidate-card:focus-within .candidate-card-inner {transform: rotateY(180deg) translateY(-4px);}
306
+ .candidate-face {position:absolute; inset:0; padding:18px; border-radius:22px; border:1px solid var(--line); background: linear-gradient(180deg, rgba(19,22,29,.96), rgba(11,13,18,.96)); backface-visibility:hidden; box-shadow: inset 0 1px 0 rgba(255,255,255,.04), var(--shadow); display:flex; flex-direction:column; gap:12px;}
307
+ .candidate-back {transform: rotateY(180deg); background: linear-gradient(180deg, rgba(29,23,16,.96), rgba(12,11,10,.96));}
308
+ .candidate-top {display:flex; justify-content:space-between; align-items:center; gap:8px;}
309
+ .chip {font-size:.72rem; text-transform:uppercase; letter-spacing:.12em; color:var(--teal); padding:7px 10px; border-radius:999px; background:rgba(123,231,221,.08); border:1px solid rgba(123,231,221,.18);} .chip.alt {color:var(--gold); background:rgba(242,210,147,.08); border-color: rgba(242,210,147,.18);}
310
+ .score {font-weight:700; color:var(--gold);}
311
+ .candidate-face h4 {margin:0; font-size:1rem; line-height:1.3;}
312
+ .candidate-face p {margin:0; color:#cbd3e2; line-height:1.6; font-size:.92rem;}
313
+ .meta-row {margin-top:auto; display:flex; justify-content:space-between; color:var(--muted); font-size:.84rem;}
314
+ .mini {margin-top:8px; align-self:flex-start; color:var(--text); padding:10px 12px; border-radius:14px; border:1px solid var(--line); background:rgba(255,255,255,.03);}
315
+ .prosebox {padding:18px; white-space:pre-wrap; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; line-height:1.55; color:#dfe6f3;}
316
+ .gr-button-primary {background: linear-gradient(135deg, rgba(242,210,147,.92), rgba(224,178,92,.92)) !important; color:#1b1408 !important; border: none !important;}
317
+ .gr-button-secondary {background: rgba(255,255,255,.05) !important; color: var(--text) !important; border: 1px solid var(--line) !important;}
318
+ footer {display:none !important;}
319
+ @keyframes pulseEdge {to {stroke-dashoffset: -32;}}
320
+ @media (max-width: 1180px){#dvnc-shell .wrap{grid-template-columns:1fr;}#dvnc-shell .left{border-right:0;border-bottom:1px solid var(--line)}.candidate-grid,.model-switcher{grid-template-columns:1fr;} }
321
+ '''
322
+
323
+ HEAD = '''
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
324
  <link rel="preconnect" href="https://fonts.googleapis.com">
325
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
326
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
327
+ '''
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
328
 
329
+ with gr.Blocks(css=CSS, head=HEAD, theme=gr.themes.Base(), fill_height=True) as demo:
330
+ gr.HTML('''
331
+ <div id="dvnc-shell">
332
+ <div class="wrap">
333
+ <section class="left">
334
  <div class="hero-bar">
335
  <div class="brand">
336
  <div class="logo" aria-hidden="true">
337
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M5 17L12 4l7 13"/><path d="M8.5 12.5h7"/><circle cx="12" cy="12" r="1.8" fill="currentColor" stroke="none"/></svg>
 
 
 
 
338
  </div>
339
  <div>
340
  <h1>DVNC.AI</h1>
341
+ <p>Sovereign discovery interface · connectome-native reasoning</p>
342
  </div>
343
  </div>
344
  <div class="status"><span class="status-dot"></span><span>Live orchestration</span></div>
345
  </div>
346
+ ''')
347
+ model_html = gr.HTML(build_models_html("DVNC Sovereign"))
348
+ with gr.Row():
349
+ model = gr.Dropdown(choices=[m["name"] for m in MODELS], value="DVNC Sovereign", label="Model tier")
350
+ query = gr.Textbox(label="Discovery query", elem_classes=["querybox"], placeholder="Enter a scientific question, anomaly, or breakthrough direction…", lines=4)
351
+ with gr.Row():
352
+ run_btn = gr.Button("Run discovery", variant="primary")
353
+ example_btn = gr.Button("Load example", variant="secondary")
354
+ chat = gr.HTML('<div class="panel chat-panel"><div class="chat-thread"><div class="bubble bubble-ai"><span class="role">DVNC</span><p>Enter a query to activate the 7-agent discovery stack and illuminate the chosen path through the concept brain.</p></div></div></div>')
355
+ gr.HTML('</section><section class="right">')
356
+ connectome = gr.HTML(build_connectome_html(DEFAULT_PATH))
357
+ timeline = gr.HTML('<div class="panel" style="padding:18px"><div class="timeline"></div></div>')
358
+ cards = gr.HTML('<div class="panel" style="padding:18px"><div class="candidate-grid"></div></div>')
359
+ output = gr.Markdown("# Discovery Output\n\nAwaiting query.")
360
+ gr.HTML('</section></div></div>')
361
+
362
+ def load_example():
363
+ return "How could a self-assembling conductive biomaterial improve cardiac tissue regeneration by converting mechanical strain into repair signalling?"
364
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
365
  example_btn.click(fn=load_example, outputs=query)
366
+ run_btn.click(fn=run_discovery, inputs=[query, model], outputs=[chat, connectome, timeline, cards, output, model_html])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
367
 
368
  if __name__ == "__main__":
369
+ demo.launch()
dvnc_ai_v2_hf/{deprecated/app_old.py → app_old.py} RENAMED
File without changes
dvnc_ai_v2_hf/{deprecated/app_old2.py → app_old2.py} RENAMED
File without changes
dvnc_ai_v2_hf/{deprecated/app_old3.py → app_old3.py} RENAMED
File without changes
dvnc_ai_v2_hf/{deprecated/app_old4.py → app_old4.py} RENAMED
File without changes
dvnc_ai_v2_hf/deprecated/agent_route_cards_old.py DELETED
@@ -1,622 +0,0 @@
1
- import html
2
- import json
3
- import re
4
- from typing import Dict, List, Optional
5
-
6
- AGENT_STAGE_DEFAULTS = {
7
- "Query Interpreter": [
8
- {
9
- "key": "mechanistic",
10
- "title": "Mechanistic decomposition",
11
- "front": "Resolve the prompt into substrate, perturbation, state variable, measurable readout, and timescale.",
12
- "back": "Swap into route when the question needs hard causal framing before graph expansion.",
13
- "badge": "input",
14
- "score": 91,
15
- "novelty": "High clarity",
16
- },
17
- {
18
- "key": "multiscale",
19
- "title": "Multi-scale framing",
20
- "front": "Split the problem across molecular, cellular, tissue, and systems levels before branching.",
21
- "back": "Swap into route when bridge variables across scales matter more than a single mechanism.",
22
- "badge": "input",
23
- "score": 86,
24
- "novelty": "Broad scope",
25
- },
26
- {
27
- "key": "constraint-led",
28
- "title": "Constraint-led framing",
29
- "front": "Start from assay, delivery, model-system, and validation constraints instead of pure theory.",
30
- "back": "Swap into route when experimental tractability should dominate from the first step.",
31
- "badge": "input",
32
- "score": 84,
33
- "novelty": "Practical",
34
- },
35
- ],
36
- "Graph Divergence Mapper": [
37
- {
38
- "key": "local-causal",
39
- "title": "Local causal neighbourhood",
40
- "front": "Expand first through nearest mechanistic neighbours to preserve interpretability.",
41
- "back": "Swap into route when you want a conservative branch around known causal structure.",
42
- "badge": "graph",
43
- "score": 88,
44
- "novelty": "Stable",
45
- },
46
- {
47
- "key": "cross-scale",
48
- "title": "Cross-scale bridge mapping",
49
- "front": "Search for hidden intermediates that convert local events into tissue or system outcomes.",
50
- "back": "Swap into route when the graph must discover bridge variables across levels.",
51
- "badge": "graph",
52
- "score": 93,
53
- "novelty": "High novelty",
54
- },
55
- {
56
- "key": "counterfactual",
57
- "title": "Counterfactual branches",
58
- "front": "Generate explicit rival graph routes that could explain the same observed effect.",
59
- "back": "Swap into route when you want disagreement and adversarial structure early.",
60
- "badge": "graph",
61
- "score": 89,
62
- "novelty": "Adversarial",
63
- },
64
- ],
65
- "Evidence Harvester": [
66
- {
67
- "key": "supportive",
68
- "title": "Supportive evidence packet",
69
- "front": "Collect papers that directly strengthen the active route and mechanism stack.",
70
- "back": "Swap into route when the composer needs the strongest positive case first.",
71
- "badge": "evidence",
72
- "score": 87,
73
- "novelty": "Direct",
74
- },
75
- {
76
- "key": "contradiction",
77
- "title": "Contradiction scan",
78
- "front": "Prioritise limiting conditions, null findings, and papers that invert the result.",
79
- "back": "Swap into route when context dependence matters more than consensus.",
80
- "badge": "evidence",
81
- "score": 92,
82
- "novelty": "Scientist-grade",
83
- },
84
- {
85
- "key": "assay-gap",
86
- "title": "Assay bias scan",
87
- "front": "Look for missing readouts and weak proxies that may distort the literature signal.",
88
- "back": "Swap into route when measurement weakness is likely driving false confidence.",
89
- "badge": "evidence",
90
- "score": 85,
91
- "novelty": "Methods-aware",
92
- },
93
- ],
94
- "Analogy Engine": [
95
- {
96
- "key": "near-transfer",
97
- "title": "Near-domain transfer",
98
- "front": "Borrow mechanisms from adjacent domains where variables and constraints still align.",
99
- "back": "Swap into route when novelty should stay close to interpretable biology or materials science.",
100
- "badge": "analogy",
101
- "score": 86,
102
- "novelty": "Controlled",
103
- },
104
- {
105
- "key": "distant-transfer",
106
- "title": "Distant mechanism transfer",
107
- "front": "Import remote control logic from another field with conserved dynamics or topology.",
108
- "back": "Swap into route when you want maximal novelty without relying on keyword similarity.",
109
- "badge": "analogy",
110
- "score": 94,
111
- "novelty": "Very high",
112
- },
113
- {
114
- "key": "material-analogy",
115
- "title": "Material behaviour analogy",
116
- "front": "Recast the system through mechanics, conductivity, self-assembly, or feedback material behaviour.",
117
- "back": "Swap into route when physical behaviour is a stronger bridge than biological naming.",
118
- "badge": "analogy",
119
- "score": 90,
120
- "novelty": "Bridge-heavy",
121
- },
122
- ],
123
- "Hypothesis Composer": [
124
- {
125
- "key": "single-lead",
126
- "title": "Single lead hypothesis",
127
- "front": "Compose one sharp mechanism-led thesis with strong causal sequencing.",
128
- "back": "Swap into route when the system should commit early to the strongest line of attack.",
129
- "badge": "compose",
130
- "score": 89,
131
- "novelty": "Focused",
132
- },
133
- {
134
- "key": "triad",
135
- "title": "Three-route composition",
136
- "front": "Produce a lead hypothesis plus two structurally different alternatives side by side.",
137
- "back": "Swap into route when route comparison is part of the product experience.",
138
- "badge": "compose",
139
- "score": 93,
140
- "novelty": "Portfolio",
141
- },
142
- {
143
- "key": "claim-evidence",
144
- "title": "Claim-evidence assembly",
145
- "front": "Bind each major claim to evidence packets, expected signals, and failure modes.",
146
- "back": "Swap into route when graph-ready structured output matters more than prose elegance.",
147
- "badge": "compose",
148
- "score": 91,
149
- "novelty": "Structured",
150
- },
151
- ],
152
- "Adversarial Critic": [
153
- {
154
- "key": "confounders",
155
- "title": "Confounder attack",
156
- "front": "Stress test hidden variables, proxy leakage, and spurious causal chains.",
157
- "back": "Swap into route when false positives and mistaken mechanisms are the main risk.",
158
- "badge": "critique",
159
- "score": 90,
160
- "novelty": "Critical",
161
- },
162
- {
163
- "key": "boundary",
164
- "title": "Boundary-condition attack",
165
- "front": "Probe dose, timescale, cell type, species, and substrate conditions that may invert the result.",
166
- "back": "Swap into route when the hypothesis is probably true only in a narrow regime.",
167
- "badge": "critique",
168
- "score": 92,
169
- "novelty": "Boundary-aware",
170
- },
171
- {
172
- "key": "feasibility",
173
- "title": "Feasibility attack",
174
- "front": "Target fabrication, delivery, stability, toxicity, and validation bottlenecks.",
175
- "back": "Swap into route when the science is elegant but the build path may fail.",
176
- "badge": "critique",
177
- "score": 87,
178
- "novelty": "Reality check",
179
- },
180
- ],
181
- "Experimental Program Designer": [
182
- {
183
- "key": "fast-falsify",
184
- "title": "Fast falsification plan",
185
- "front": "Design the smallest decisive experiments that can kill the route quickly.",
186
- "back": "Swap into route when you want rapid learning with minimal lab spend.",
187
- "badge": "experiment",
188
- "score": 88,
189
- "novelty": "Lean",
190
- },
191
- {
192
- "key": "staged-program",
193
- "title": "Staged validation program",
194
- "front": "Move from mechanistic assay to comparative benchmark to translational test in sequence.",
195
- "back": "Swap into route when the output should look like a full scientific program.",
196
- "badge": "experiment",
197
- "score": 94,
198
- "novelty": "Complete",
199
- },
200
- {
201
- "key": "orthogonal-readouts",
202
- "title": "Orthogonal readout design",
203
- "front": "Use multiple independent readouts so the route does not depend on one proxy measurement.",
204
- "back": "Swap into route when evidence quality matters more than sheer speed.",
205
- "badge": "experiment",
206
- "score": 91,
207
- "novelty": "Robust",
208
- },
209
- ],
210
- }
211
-
212
-
213
- def _safe(value: object) -> str:
214
- return html.escape(str(value if value is not None else ""))
215
-
216
-
217
- def _slug(value: str) -> str:
218
- return re.sub(r"[^a-z0-9]+", "-", (value or "").lower()).strip("-") or "stage"
219
-
220
-
221
- def get_default_stage_cards(agent_name: str) -> List[Dict]:
222
- return [dict(card) for card in AGENT_STAGE_DEFAULTS.get(agent_name, [])]
223
-
224
-
225
- def build_agent_route_cards_html(
226
- reasoning: List[Dict],
227
- stage_cards: Optional[Dict[str, List[Dict]]] = None,
228
- selected_keys: Optional[Dict[str, str]] = None,
229
- component_id: str = "dvnc-agent-route-cards",
230
- ) -> str:
231
- stage_cards = stage_cards or {}
232
- selected_keys = selected_keys or {}
233
-
234
- rows = []
235
- selected_map = {}
236
-
237
- for item in reasoning or []:
238
- agent = item.get("agent", "Agent")
239
- cards = [dict(c) for c in (stage_cards.get(agent) or get_default_stage_cards(agent))][:3]
240
- if not cards:
241
- continue
242
-
243
- selected_key = selected_keys.get(agent) or cards[0].get("key") or ""
244
- selected_map[agent] = selected_key
245
- card_html = []
246
-
247
- for idx, card in enumerate(cards):
248
- key = card.get("key") or f"option-{idx + 1}"
249
- active_cls = " is-active" if key == selected_key else ""
250
-
251
- card_html.append(
252
- f'''<article class="dvnc-route-card{active_cls}" data-card-key="{_safe(key)}">
253
- <div class="dvnc-route-card-inner">
254
- <div class="dvnc-route-face dvnc-route-front">
255
- <div class="dvnc-route-top">
256
- <span class="dvnc-route-badge">{_safe(card.get("badge", item.get("tag", "route")))}</span>
257
- <span class="dvnc-route-score">{_safe(card.get("score", ""))}</span>
258
- </div>
259
- <h4>{_safe(card.get("title", "Route option"))}</h4>
260
- <p>{_safe(card.get("front", ""))}</p>
261
- <div class="dvnc-route-meta"><span>Novelty</span><strong>{_safe(card.get("novelty", "Adaptive"))}</strong></div>
262
- <button type="button" class="dvnc-route-btn dvnc-flip-btn" data-action="flip">Flip insight</button>
263
- </div>
264
- <div class="dvnc-route-face dvnc-route-back">
265
- <div class="dvnc-route-top">
266
- <span class="dvnc-route-badge alt">alternative path</span>
267
- <span class="dvnc-route-score">{_safe(card.get("score", ""))}</span>
268
- </div>
269
- <h4>{_safe(card.get("title", "Route option"))}</h4>
270
- <p>{_safe(card.get("back", ""))}</p>
271
- <div class="dvnc-route-meta"><span>Swap into route</span><strong>{"Enabled" if key == selected_key else "Available"}</strong></div>
272
- <div class="dvnc-route-actions">
273
- <button type="button" class="dvnc-route-btn dvnc-swap-btn" data-action="swap" data-agent="{_safe(agent)}" data-key="{_safe(key)}">Swap into route</button>
274
- <button type="button" class="dvnc-route-btn ghost dvnc-flip-btn" data-action="flip">Return</button>
275
- </div>
276
- </div>
277
- </div>
278
- </article>'''
279
- )
280
-
281
- rows.append(
282
- f'''<section class="dvnc-agent-row" data-agent="{_safe(agent)}">
283
- <div class="dvnc-agent-summary-panel">
284
- <div class="dvnc-agent-step">{_safe(item.get("step", ""))}</div>
285
- <div class="dvnc-agent-copy">
286
- <div class="dvnc-agent-topline">
287
- <h3>{_safe(agent)}</h3>
288
- <span>{_safe(item.get("tag", "stage"))}</span>
289
- </div>
290
- <p>{_safe(item.get("summary", ""))}</p>
291
- </div>
292
- </div>
293
- <div class="dvnc-agent-cards">{"".join(card_html)}</div>
294
- </section>'''
295
- )
296
-
297
- selected_json = _safe(json.dumps(selected_map))
298
-
299
- style = f'''
300
- <style>
301
- #{component_id} {{
302
- --dvnc-line: rgba(0, 0, 0, 0.10);
303
- --dvnc-text: #111111;
304
- --dvnc-muted: #5b5b5b;
305
- --dvnc-gold: #ff6600;
306
- --dvnc-panel: #ffffff;
307
- --dvnc-shadow: 0 18px 38px rgba(0, 0, 0, 0.08);
308
- }}
309
-
310
- #{component_id}.dvnc-agent-route-shell {{
311
- display: flex;
312
- flex-direction: column;
313
- gap: 22px;
314
- width: min(100%, 1520px);
315
- margin: 0 auto;
316
- padding: 8px 18px 10px;
317
- box-sizing: border-box;
318
- }}
319
-
320
- #{component_id} .dvnc-agent-route-grid {{
321
- display: flex;
322
- flex-direction: column;
323
- gap: 22px;
324
- width: 100%;
325
- }}
326
-
327
- #{component_id} .dvnc-agent-row {{
328
- display: grid;
329
- grid-template-columns: minmax(300px, 360px) minmax(0, 1fr);
330
- gap: 22px;
331
- align-items: stretch;
332
- width: 100%;
333
- }}
334
-
335
- #{component_id} .dvnc-agent-summary-panel {{
336
- background: var(--dvnc-panel);
337
- border: 1px solid var(--dvnc-line);
338
- border-radius: 22px;
339
- padding: 20px;
340
- display: grid;
341
- grid-template-columns: 56px 1fr;
342
- gap: 16px;
343
- min-height: 320px;
344
- }}
345
-
346
- #{component_id} .dvnc-agent-step {{
347
- width: 56px;
348
- height: 56px;
349
- border-radius: 18px;
350
- display: grid;
351
- place-items: center;
352
- font-weight: 800;
353
- font-size: 1rem;
354
- color: var(--dvnc-gold);
355
- background: rgba(255, 102, 0, 0.08);
356
- border: 1px solid rgba(255, 102, 0, 0.18);
357
- }}
358
-
359
- #{component_id} .dvnc-agent-copy {{
360
- display: flex;
361
- flex-direction: column;
362
- justify-content: flex-start;
363
- }}
364
-
365
- #{component_id} .dvnc-agent-topline {{
366
- display: flex;
367
- justify-content: space-between;
368
- gap: 12px;
369
- align-items: center;
370
- flex-wrap: wrap;
371
- }}
372
-
373
- #{component_id} .dvnc-agent-topline h3 {{
374
- margin: 0;
375
- font-size: 1.02rem;
376
- color: var(--dvnc-text);
377
- }}
378
-
379
- #{component_id} .dvnc-agent-topline span {{
380
- font-size: 0.72rem;
381
- letter-spacing: 0.12em;
382
- text-transform: uppercase;
383
- color: var(--dvnc-muted);
384
- }}
385
-
386
- #{component_id} .dvnc-agent-copy p {{
387
- margin: 10px 0 0;
388
- color: #252525;
389
- line-height: 1.68;
390
- font-size: 0.95rem;
391
- max-width: 34ch;
392
- }}
393
-
394
- #{component_id} .dvnc-agent-cards {{
395
- display: grid;
396
- grid-template-columns: repeat(3, minmax(0, 1fr));
397
- gap: 16px;
398
- align-items: stretch;
399
- width: 100%;
400
- }}
401
-
402
- #{component_id} .dvnc-route-card {{
403
- background: none;
404
- perspective: 1400px;
405
- min-height: 320px;
406
- }}
407
-
408
- #{component_id} .dvnc-route-card-inner {{
409
- position: relative;
410
- width: 100%;
411
- min-height: 320px;
412
- transform-style: preserve-3d;
413
- transition: transform 0.75s cubic-bezier(0.2, 0.7, 0.1, 1);
414
- }}
415
-
416
- #{component_id} .dvnc-route-card.is-flipped .dvnc-route-card-inner {{
417
- transform: rotateY(180deg);
418
- }}
419
-
420
- #{component_id} .dvnc-route-face {{
421
- position: absolute;
422
- inset: 0;
423
- backface-visibility: hidden;
424
- background: #ffffff;
425
- border: 1px solid var(--dvnc-line);
426
- border-radius: 22px;
427
- padding: 20px;
428
- display: flex;
429
- flex-direction: column;
430
- gap: 14px;
431
- box-shadow: var(--dvnc-shadow);
432
- }}
433
-
434
- #{component_id} .dvnc-route-back {{
435
- transform: rotateY(180deg);
436
- }}
437
-
438
- #{component_id} .dvnc-route-card.is-active .dvnc-route-face {{
439
- border-color: rgba(255, 102, 0, 0.28);
440
- box-shadow: 0 18px 40px rgba(255, 102, 0, 0.10);
441
- }}
442
-
443
- #{component_id} .dvnc-route-top {{
444
- display: flex;
445
- justify-content: space-between;
446
- gap: 10px;
447
- align-items: center;
448
- }}
449
-
450
- #{component_id} .dvnc-route-badge {{
451
- font-size: 0.72rem;
452
- text-transform: uppercase;
453
- letter-spacing: 0.12em;
454
- color: #0b6f66;
455
- padding: 7px 10px;
456
- border-radius: 999px;
457
- background: rgba(23, 184, 166, 0.08);
458
- border: 1px solid rgba(23, 184, 166, 0.18);
459
- }}
460
-
461
- #{component_id} .dvnc-route-badge.alt {{
462
- color: var(--dvnc-gold);
463
- background: rgba(255, 102, 0, 0.08);
464
- border-color: rgba(255, 102, 0, 0.18);
465
- }}
466
-
467
- #{component_id} .dvnc-route-score {{
468
- font-weight: 800;
469
- color: var(--dvnc-gold);
470
- }}
471
-
472
- #{component_id} .dvnc-route-face h4 {{
473
- margin: 0;
474
- font-size: 1.03rem;
475
- line-height: 1.38;
476
- color: var(--dvnc-text);
477
- }}
478
-
479
- #{component_id} .dvnc-route-face p {{
480
- margin: 0;
481
- color: #202020;
482
- line-height: 1.62;
483
- font-size: 0.95rem;
484
- }}
485
-
486
- #{component_id} .dvnc-route-meta {{
487
- margin-top: auto;
488
- display: flex;
489
- justify-content: space-between;
490
- gap: 12px;
491
- color: var(--dvnc-muted);
492
- font-size: 0.87rem;
493
- }}
494
-
495
- #{component_id} .dvnc-route-actions {{
496
- display: flex;
497
- gap: 10px;
498
- flex-wrap: wrap;
499
- }}
500
-
501
- #{component_id} .dvnc-route-btn {{
502
- border: 1px solid var(--dvnc-line);
503
- background: #fff;
504
- color: var(--dvnc-text);
505
- padding: 10px 12px;
506
- border-radius: 14px;
507
- cursor: pointer;
508
- font: inherit;
509
- }}
510
-
511
- #{component_id} .dvnc-route-btn:hover {{
512
- border-color: rgba(0, 0, 0, 0.18);
513
- }}
514
-
515
- #{component_id} .dvnc-swap-btn {{
516
- background: linear-gradient(135deg, rgba(255, 102, 0, 0.95), rgba(240, 108, 22, 0.95));
517
- color: #fff;
518
- border: none;
519
- }}
520
-
521
- #{component_id} .dvnc-route-btn.ghost {{
522
- background: #fff;
523
- }}
524
-
525
- @media (max-width: 1400px) {{
526
- #{component_id}.dvnc-agent-route-shell {{
527
- width: min(100%, 1320px);
528
- padding: 8px 12px 10px;
529
- }}
530
-
531
- #{component_id} .dvnc-agent-row {{
532
- grid-template-columns: minmax(280px, 330px) minmax(0, 1fr);
533
- }}
534
-
535
- #{component_id} .dvnc-agent-cards {{
536
- grid-template-columns: repeat(2, minmax(0, 1fr));
537
- }}
538
- }}
539
-
540
- @media (max-width: 1100px) {{
541
- #{component_id} .dvnc-agent-row {{
542
- grid-template-columns: 1fr;
543
- }}
544
-
545
- #{component_id} .dvnc-agent-summary-panel {{
546
- min-height: auto;
547
- }}
548
-
549
- #{component_id} .dvnc-agent-cards {{
550
- grid-template-columns: 1fr;
551
- }}
552
- }}
553
- </style>
554
- '''
555
-
556
- script = f'''
557
- <script>
558
- (function() {{
559
- const root = document.getElementById({json.dumps(component_id)});
560
- if (!root || root.dataset.bound === "1") return;
561
- root.dataset.bound = "1";
562
-
563
- function syncSelection(agent, key) {{
564
- root.querySelectorAll(".dvnc-agent-row").forEach((row) => {{
565
- if (row.dataset.agent !== agent) return;
566
-
567
- row.querySelectorAll(".dvnc-route-card").forEach((card) => {{
568
- const active = card.dataset.cardKey === key;
569
- card.classList.toggle("is-active", active);
570
-
571
- const strong = card.querySelector(".dvnc-route-meta strong");
572
- if (strong) {{
573
- strong.textContent = active ? "Enabled" : "Available";
574
- }}
575
- }});
576
- }});
577
-
578
- const selectedMap = JSON.parse(root.dataset.selectedMap || "{{}}");
579
- selectedMap[agent] = key;
580
- root.dataset.selectedMap = JSON.stringify(selectedMap);
581
- }}
582
-
583
- root.addEventListener("click", (event) => {{
584
- const flipBtn = event.target.closest(".dvnc-flip-btn");
585
- if (flipBtn) {{
586
- const card = flipBtn.closest(".dvnc-route-card");
587
- if (card) {{
588
- card.classList.toggle("is-flipped");
589
- }}
590
- return;
591
- }}
592
-
593
- const swapBtn = event.target.closest(".dvnc-swap-btn");
594
- if (swapBtn) {{
595
- const agent = swapBtn.dataset.agent;
596
- const key = swapBtn.dataset.key;
597
- syncSelection(agent, key);
598
-
599
- const card = swapBtn.closest(".dvnc-route-card");
600
- if (card) {{
601
- card.classList.remove("is-flipped");
602
- }}
603
- }}
604
- }});
605
- }})();
606
- </script>
607
- '''
608
-
609
- return (
610
- f'<div class="dvnc-agent-route-shell" id="{_safe(component_id)}" data-selected-map="{selected_json}">'
611
- + style
612
- + f'<div class="dvnc-agent-route-grid">{"".join(rows)}</div>'
613
- + script
614
- + "</div>"
615
- )
616
-
617
-
618
- __all__ = [
619
- "AGENT_STAGE_DEFAULTS",
620
- "get_default_stage_cards",
621
- "build_agent_route_cards_html",
622
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/app_old11.py DELETED
@@ -1,696 +0,0 @@
1
- """
2
- DVNC.AI — app.py
3
- Refactored for correctness and best practices.
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
- apply_route_swap_bridge,
37
- get_default_route_state,
38
- get_discovery_css,
39
- get_initial_discovery_timeline_html,
40
- run_discovery_bridge,
41
- )
42
- from dvnc_ai_v2_hf.dvnc_ui_layout import get_dvnc_layout_css
43
- from dvnc_ai_v2_hf.self_learning_graph import (
44
- DEFAULT_SOURCES,
45
- SEARCH_MODES,
46
- SOURCE_OPTIONS,
47
- build_learning_graph_html,
48
- build_journal_html,
49
- ingest_selected_papers,
50
- parse_uploaded_pdf,
51
- render_parse_result,
52
- run_paper_discovery,
53
- safe_text,
54
- )
55
-
56
- # ── Constants ────────────────────────────────────────────────────────────────
57
- MODELS = [
58
- {"name": "DVNC Sovereign", "tag": "flagship", "desc": "Maximum depth orchestration for frontier discovery"},
59
- {"name": "DVNC Atlas", "tag": "research", "desc": "Balanced reasoning, graph traversal, and synthesis"},
60
- {"name": "DVNC Curie", "tag": "lab", "desc": "Experimental hypothesis generation for anomalous signals"},
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"),
88
- ("bio", "card"), ("nano", "selfasm"),
89
- ("selfasm", "electro"),("card", "immune"),
90
- ("electro", "trial"), ("immune", "trial"),
91
- ("card", "alt1"), ("selfasm","alt2"),
92
- ("alt1", "trial"), ("alt2", "trial"),
93
- ]
94
-
95
- DEFAULT_PATH = ["seed", "nano", "selfasm", "electro", "trial"]
96
-
97
- CANDIDATES = [
98
- {
99
- "title": "Piezoelectric Scaffold Cascade",
100
- "front": "Use mechano-electric scaffolds to convert cardiac strain into micro-current signalling.",
101
- "back": "Discovery path: anomalous healing signal -> piezoelectric analog -> ion-channel entrainment -> tissue regeneration. Risk: power density and fibrosis coupling.",
102
- "score": 92,
103
- "novelty": "High",
104
- "agent": "Hypothesis Composer",
105
- },
106
- {
107
- "title": "Peptide Self-Assembly Mesh",
108
- "front": "Deploy dynamic peptide meshes that self-assemble around damaged myocardium and guide repair.",
109
- "back": "Discovery path: self-assembly -> local immune choreography -> regenerative substrate formation. Risk: degradation timing and targeting specificity.",
110
- "score": 88,
111
- "novelty": "High",
112
- "agent": "Analogy Engine",
113
- },
114
- {
115
- "title": "Immune-Tuned Conductive Hydrogel",
116
- "front": "Blend conductivity with macrophage-state modulation to reduce scarring and restore conduction.",
117
- "back": "Discovery path: inflammation mismatch -> conductive medium -> macrophage polarization -> synchronized healing. Risk: persistence and biocompatibility.",
118
- "score": 85,
119
- "novelty": "Medium-High",
120
- "agent": "Adversarial Critic",
121
- },
122
- ]
123
-
124
- JOURNALS = [
125
- {"name": "Nature", "url": "https://www.nature.com/search", "desc": "Flagship multidisciplinary research journal."},
126
- {"name": "Science", "url": "https://www.science.org/search", "desc": "High-impact science journal and family."},
127
- {"name": "Cell", "url": "https://www.cell.com/search", "desc": "Life sciences and translational biology."},
128
- {"name": "The Lancet", "url": "https://www.thelancet.com/search", "desc": "Clinical and medical research."},
129
- {"name": "IEEE Xplore","url": "https://ieeexplore.ieee.org/search/searchresult.jsp", "desc": "Engineering, AI, signal processing, and systems."},
130
- ]
131
-
132
- SEMANTIC_SCHOLAR_API_KEY = os.getenv("SEMANTIC_SCHOLAR_API_KEY", "")
133
- GROBID_URL = os.getenv("GROBID_URL", "").strip()
134
- REQUEST_TIMEOUT = 25
135
-
136
-
137
- # ── Utility helpers ──────────────────────────────────────────────────────────
138
- def safe_text(x, default: str = "") -> str:
139
- return html.escape(str(x if x is not None else default))
140
-
141
-
142
- def norm_text(x: Optional[str]) -> str:
143
- return re.sub(r"\s+", " ", (x or "")).strip()
144
-
145
-
146
- def detect_query_type(query: str) -> str:
147
- q = (query or "").strip()
148
- if re.match(r"^10\.\d{4,9}/[-._;()/:A-Z0-9]+$", q, flags=re.I):
149
- return "doi"
150
- if q.startswith("http://") or q.startswith("https://"):
151
- return "link"
152
- return "topic"
153
-
154
-
155
- def ensure_list(x):
156
- return x if isinstance(x, list) else []
157
-
158
-
159
- # ── HTML builders ─────────────────────────────────────────────────────────────
160
- def build_connectome_html(path_ids: List[str]) -> str:
161
- active = set(path_ids)
162
- node_map = {n["id"]: n for n in NODES}
163
- path_pairs = {
164
- pair
165
- for i in range(len(path_ids) - 1)
166
- for pair in [(path_ids[i], path_ids[i + 1]), (path_ids[i + 1], path_ids[i])]
167
- }
168
-
169
- base_lines, active_lines, circles, labels = [], [], [], []
170
-
171
- for a, b in EDGES:
172
- na, nb = node_map[a], node_map[b]
173
- x1, y1 = na["x"] * 8 + 80, na["y"] * 6 + 280
174
- x2, y2 = nb["x"] * 8 + 80, nb["y"] * 6 + 280
175
- base_lines.append(f'<line class="edge" x1="{x1:.1f}" y1="{y1:.1f}" x2="{x2:.1f}" y2="{y2:.1f}" />')
176
- if (a, b) in path_pairs:
177
- active_lines.append(f'<line class="edge active" x1="{x1:.1f}" y1="{y1:.1f}" x2="{x2:.1f}" y2="{y2:.1f}" />')
178
-
179
- for n in NODES:
180
- cx, cy = n["x"] * 8 + 80, n["y"] * 6 + 280
181
- is_active = n["id"] in active
182
- state = "chosen" if is_active else "idle"
183
- halo_cls = "halo active" if is_active else "halo"
184
- lbl_cls = "label active" if is_active else "label"
185
- radius = 18 if is_active else 13
186
- halo_r = 30 if is_active else 0
187
- circles.append(
188
- f'<g class="node-wrap">'
189
- f'<circle class="{halo_cls}" cx="{cx:.1f}" cy="{cy:.1f}" r="{halo_r}" />'
190
- f'<circle class="node {n["group"]} {state}" cx="{cx:.1f}" cy="{cy:.1f}" r="{radius}" />'
191
- f'</g>'
192
- )
193
- labels.append(f'<text class="{lbl_cls}" x="{cx + 18:.1f}" y="{cy - 16:.1f}">{safe_text(n["label"])}</text>')
194
-
195
- return f"""
196
- <div class="panel brain-shell">
197
- <div class="brain-header">
198
- <div>
199
- <p class="eyebrow">Connectome</p>
200
- <h3>3D Connectome</h3>
201
- </div>
202
- <div class="brain-legend">
203
- <span><i class="dot dot-live"></i> lit path</span>
204
- <span><i class="dot dot-chosen"></i> chosen node</span>
205
- <span><i class="dot dot-idle"></i> available node</span>
206
- </div>
207
- </div>
208
- <div class="brain-stage">
209
- <svg viewBox="0 0 780 560" class="brain-svg" role="img" aria-label="DVNC 3D connectome visualisation">
210
- {"".join(base_lines)}
211
- {"".join(active_lines)}
212
- {"".join(circles)}
213
- {"".join(labels)}
214
- </svg>
215
- </div>
216
- </div>
217
- """
218
-
219
-
220
- def build_cards_html(cards: List[Dict]) -> str:
221
- items = []
222
- for c in cards:
223
- items.append(f"""
224
- <article class="candidate-card" tabindex="0">
225
- <div class="candidate-card-inner">
226
- <div class="candidate-face candidate-front">
227
- <div class="candidate-top">
228
- <span class="chip">{safe_text(c["agent"])}</span>
229
- <span class="score">{safe_text(c["score"])}</span>
230
- </div>
231
- <h4>{safe_text(c["title"])}</h4>
232
- <p>{safe_text(c["front"])}</p>
233
- <div class="meta-row"><span>Novelty</span><strong>{safe_text(c["novelty"])}</strong></div>
234
- <button class="mini" type="button">Flip insight</button>
235
- </div>
236
- <div class="candidate-face candidate-back">
237
- <div class="candidate-top">
238
- <span class="chip alt">Alternative path</span>
239
- <span class="score">{safe_text(c["score"])}</span>
240
- </div>
241
- <h4>{safe_text(c["title"])}</h4>
242
- <p>{safe_text(c["back"])}</p>
243
- <div class="meta-row"><span>Swap into route</span><strong>Enabled</strong></div>
244
- <button class="mini" type="button">Return</button>
245
- </div>
246
- </div>
247
- </article>""")
248
- return '<div class="panel" style="padding:20px;"><div class="candidate-grid">' + "".join(items) + "</div></div>"
249
-
250
-
251
- def build_agent_timeline(reasoning: List[Dict]) -> str:
252
- rows = []
253
- for r in reasoning:
254
- rows.append(f"""
255
- <details class="agent-step" {"open" if r["step"] == 1 else ""}>
256
- <summary class="agent-summary">
257
- <div class="agent-index">{safe_text(r["step"])}</div>
258
- <div class="agent-head">
259
- <h4>{safe_text(r["agent"])}</h4>
260
- <span>{safe_text(r["tag"])}</span>
261
- </div>
262
- </summary>
263
- <div class="agent-copy">
264
- <p>{safe_text(r["summary"])}</p>
265
- </div>
266
- </details>""")
267
- return '<div class="panel" style="padding:18px;"><div class="timeline">' + "".join(rows) + "</div></div>"
268
-
269
-
270
- def build_chat_html(query: str, result: Dict) -> str:
271
- return f"""
272
- <div class="panel chat-panel">
273
- <div class="chat-thread">
274
- <div class="bubble bubble-user">
275
- <span class="role">You</span>
276
- <p>{safe_text(query)}</p>
277
- </div>
278
- <div class="bubble bubble-ai">
279
- <span class="role">DVNC Sovereign</span>
280
- <p>{safe_text(result["summary"])}</p>
281
- </div>
282
- <div class="bubble bubble-system">
283
- <span class="role">Discovery Signal</span>
284
- <p><strong>Primary hypothesis:</strong> {safe_text(result["primary_hypothesis"])}</p>
285
- </div>
286
- </div>
287
- </div>
288
- """
289
-
290
-
291
- def build_models_html(selected: str) -> str:
292
- items = []
293
- for m in MODELS:
294
- active = "active" if m["name"] == selected else ""
295
- items.append(f"""
296
- <div class="model-pill {active}">
297
- <span class="model-name">{safe_text(m["name"])}</span>
298
- <span class="model-tag">{safe_text(m["tag"])}</span>
299
- <small>{safe_text(m["desc"])}</small>
300
- </div>""")
301
- return '<div class="panel" style="padding:18px;"><div class="model-switcher">' + "".join(items) + "</div></div>"
302
-
303
-
304
- # ── Discovery logic ───────────────────────────────────────────────────────────
305
- def run_discovery(query: str, model_name: str):
306
- """
307
- Runs the 7-agent discovery pipeline.
308
- Returns: chat_html, connectome_html, timeline_html, cards_html,
309
- hypothesis_md, models_html, route_state
310
- """
311
- random.seed(len(query) + len(model_name))
312
-
313
- if "curie" in query.lower() or "einstein" in query.lower():
314
- primary = "Map the anomaly first, then force a distant analogy before composing the experimental programme."
315
- path = ["seed", "bio", "card", "immune", "trial"]
316
- else:
317
- primary = "Use a self-assembling conductive scaffold that transforms mechanical strain into local regenerative signalling."
318
- path = DEFAULT_PATH
319
-
320
- summaries = [
321
- "Normalises the user prompt into a graph-searchable seed and isolates the tension inside the question.",
322
- "Finds remote conceptual bridges instead of staying near the starting domain cluster.",
323
- "Pulls evidence packets and conflict signals required for grounded hypothesis formation.",
324
- "Generates cross-domain analogies with a bias toward mechanism transfer rather than keyword similarity.",
325
- "Composes the lead hypothesis and two structurally different variants.",
326
- "Attacks weak assumptions, hidden confounders, and feasibility gaps.",
327
- "Produces a staged validation plan with measurable falsification criteria.",
328
- ]
329
-
330
- tags = ["input", "graph", "evidence", "analogy", "compose", "critique", "experiment"]
331
-
332
- reasoning = [
333
- {"step": i + 1, "agent": AGENTS[i], "tag": tags[i], "summary": summaries[i]}
334
- for i in range(7)
335
- ]
336
-
337
- result = {
338
- "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.",
339
- "primary_hypothesis": primary,
340
- "reasoning": reasoning,
341
- "cards": CANDIDATES,
342
- "path": path,
343
- "metrics": {
344
- "Novelty": 93,
345
- "Mechanistic clarity": 89,
346
- "Experimental tractability": 82,
347
- "Cross-domain distance": 91,
348
- },
349
- }
350
-
351
- chat_html = build_chat_html(query, result)
352
- connectome_html = build_connectome_html(path)
353
- timeline_html = build_agent_route_cards_html(reasoning)
354
-
355
- metrics = "\n".join(f"- {k}: {v}/100" for k, v in result["metrics"].items())
356
- hypothesis_md = (
357
- "# Discovery Output\n\n"
358
- f"**Model:** {model_name}\n\n"
359
- f"**Primary hypothesis:** {result['primary_hypothesis']}\n\n"
360
- "## Scoring\n"
361
- f"{metrics}\n\n"
362
- "## Experimental outline\n"
363
- "1. Construct the candidate material or protocol.\n"
364
- "2. Test mechanistic signal expression under controlled conditions.\n"
365
- "3. Compare against baseline and nearest-neighbour alternatives.\n"
366
- "4. Falsify using the adversarial risk criteria surfaced in the reasoning path.\n"
367
- )
368
-
369
- # cards_html intentionally blank (legacy cards removed)
370
- cards_html = ""
371
- route_state = get_default_route_state()
372
-
373
- return chat_html, connectome_html, timeline_html, cards_html, hypothesis_md, build_models_html(model_name), route_state
374
-
375
-
376
- def apply_route_swap(query: str, model_name: str, route_swap_payload: str, route_state):
377
- return apply_route_swap_bridge(
378
- query=query,
379
- model_name=model_name,
380
- route_swap_payload=route_swap_payload,
381
- route_state=route_state,
382
- chat_builder=build_chat_html,
383
- connectome_builder=build_connectome_html,
384
- )
385
-
386
-
387
- # ── Example loaders ───────────────────────────────────────────────────────────
388
- def load_example() -> str:
389
- return "How could a self-assembling conductive biomaterial improve cardiac tissue regeneration by converting mechanical strain into repair signalling?"
390
-
391
-
392
- def load_paper_topic() -> str:
393
- return "self-assembling conductive biomaterials for cardiac repair"
394
-
395
-
396
- # ── CSS / HEAD ────────────────────────────────────────────────────────────────
397
- BASE_CSS = r"""
398
- :root {
399
- --bg: #ffffff; --panel: #ffffff; --line: rgba(0,0,0,.12);
400
- --text: #111111; --muted: #5b5b5b; --soft: rgba(0,0,0,.62);
401
- --gold: #ff6600; --teal: #17b8a6; --blue: #628dff;
402
- --chosen: #ff7a1a; --idle: #b8d8ff; --idle-stroke: #5e8fe6;
403
- --query-node: #ffd8b3; --paper-node: #d7f6f2; --upload-node: #e7defe;
404
- --shadow: 0 16px 40px rgba(0,0,0,.12);
405
- }
406
- html,body,.gradio-container { background:#ffffff !important; font-family:Inter,ui-sans-serif,system-ui,sans-serif; }
407
- .gradio-container { max-width:1640px !important; padding:20px !important; }
408
- #dvnc-shell { border:1px solid var(--line); border-radius:28px; overflow:hidden; background:#ffffff; box-shadow:var(--shadow); padding:20px 22px 22px; }
409
- .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; }
410
- .brand { display:flex; align-items:center; gap:14px; }
411
- .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); }
412
- .logo svg { width:24px; height:24px; }
413
- .brand h1 { font-size:1.05rem; margin:0; font-weight:700; letter-spacing:.12em; text-transform:uppercase; }
414
- .brand p { margin:3px 0 0; color:var(--muted); font-size:.84rem; }
415
- .status { display:flex; gap:10px; align-items:center; color:var(--soft); font-size:.85rem; }
416
- .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); }
417
- .panel { background:#ffffff; border:1px solid var(--line); border-radius:22px; box-shadow:inset 0 1px 0 rgba(255,255,255,.8); }
418
- .querybox textarea,.querybox input { background:transparent !important; color:var(--text) !important; }
419
- .querybox,.querybox>div { background:#ffffff !important; border-radius:18px !important; border-color:var(--line) !important; }
420
- .chat-panel { padding:18px; min-height:280px; }
421
- .chat-thread { display:flex; flex-direction:column; gap:14px; }
422
- .bubble { max-width:88%; padding:16px 18px; border-radius:22px; border:1px solid var(--line); }
423
- .bubble p { margin:8px 0 0; line-height:1.6; font-size:.96rem; color:var(--text); }
424
- .bubble .role { font-size:.72rem; letter-spacing:.12em; text-transform:uppercase; color:var(--muted); }
425
- .bubble-user { align-self:flex-end; background:linear-gradient(135deg,rgba(98,141,255,.16),rgba(98,141,255,.08)); }
426
- .bubble-ai { align-self:flex-start; background:#ffffff; }
427
- .bubble-system { align-self:flex-start; background:linear-gradient(135deg,rgba(255,122,26,.10),rgba(255,122,26,.04)); }
428
- .model-switcher { display:grid; grid-template-columns:repeat(3,1fr); gap:12px; }
429
- .model-pill { padding:14px; border:1px solid var(--line); border-radius:18px; display:flex; flex-direction:column; gap:4px; min-height:98px; background:#ffffff; }
430
- .model-pill.active { border-color:rgba(255,122,26,.40); background:linear-gradient(135deg,rgba(255,122,26,.10),rgba(255,255,255,.96)); }
431
- .model-name { font-weight:650; color:var(--text); }
432
- .model-tag { font-size:.76rem; text-transform:uppercase; letter-spacing:.12em; color:var(--gold); }
433
- .model-pill small { color:var(--muted); line-height:1.45; }
434
- .brain-shell { padding:18px; }
435
- .brain-header { display:flex; justify-content:space-between; align-items:flex-end; gap:16px; margin-bottom:10px; }
436
- .eyebrow { font-size:.72rem; letter-spacing:.16em; text-transform:uppercase; color:var(--gold); margin:0 0 4px; }
437
- .brain-header h3 { margin:0; font-size:1.12rem; color:var(--text); }
438
- .brain-legend { display:flex; gap:14px; color:var(--muted); font-size:.8rem; flex-wrap:wrap; }
439
- .dot { width:10px; height:10px; display:inline-block; border-radius:50%; margin-right:6px; }
440
- .dot-live { background:var(--chosen); box-shadow:0 0 10px rgba(255,122,26,.35); }
441
- .dot-chosen { background:var(--chosen); }
442
- .dot-idle { background:var(--idle); border:1px solid var(--idle-stroke); }
443
- .dot-query { background:var(--query-node); border:1px solid #de9e58; }
444
- .dot-paper { background:var(--paper-node); border:1px solid #4fb3a5; }
445
- .dot-upload { background:var(--upload-node); border:1px solid #8f73d9; }
446
- .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; }
447
- .brain-svg { width:100%; height:520px; display:block; }
448
- .edge { stroke:rgba(0,0,0,.12); stroke-width:2.4; }
449
- .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; }
450
- .node { stroke-width:2.2; transition:all .25s ease; }
451
- .node.idle { fill:var(--idle); stroke:var(--idle-stroke); }
452
- .node.chosen { fill:var(--chosen); stroke:#ffb16d; }
453
- .halo { fill:none; }
454
- .halo.active { stroke:rgba(255,122,26,.18); stroke-width:12; }
455
- .label { fill:#2c2c2c; font-size:13px; font-weight:500; letter-spacing:.01em; }
456
- .label.active { fill:#111111; font-weight:700; }
457
- .learn-edge { stroke:rgba(0,0,0,.18); stroke-width:2.2; stroke-linecap:round; }
458
- .learn-node { stroke-width:2.2; }
459
- .learn-node.query { fill:var(--query-node); stroke:#de9e58; }
460
- .learn-node.paper { fill:var(--paper-node); stroke:#36a091; }
461
- .learn-node.upload { fill:var(--upload-node); stroke:#7e63cb; }
462
- .learn-label { fill:#1e1e1e; font-size:12px; font-weight:600; }
463
- .learning-empty { display:grid; place-items:center; }
464
- .empty-graph-copy { text-align:center; max-width:440px; padding:40px 20px; }
465
- .empty-graph-copy h4 { margin:0 0 10px; font-size:1.05rem; }
466
- .empty-graph-copy p { margin:0; color:var(--muted); line-height:1.6; }
467
- .timeline { display:flex; flex-direction:column; gap:10px; }
468
- .agent-step { border:1px solid var(--line); border-radius:18px; background:#ffffff; overflow:hidden; }
469
- .agent-summary { list-style:none; display:grid; grid-template-columns:42px 1fr; gap:12px; align-items:center; padding:12px; cursor:pointer; }
470
- .agent-summary::-webkit-details-marker { display:none; }
471
- .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); }
472
- .agent-head { display:flex; justify-content:space-between; gap:12px; align-items:center; }
473
- .agent-head h4 { margin:0; font-size:.98rem; color:var(--text); }
474
- .agent-head span { font-size:.72rem; letter-spacing:.12em; text-transform:uppercase; color:var(--muted); }
475
- .agent-copy { padding:0 14px 16px 66px; }
476
- .agent-copy p { margin:0; color:#2d2d2d; font-size:.93rem; line-height:1.6; }
477
- .candidate-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:18px; }
478
- .candidate-card { background:none; perspective:1400px; min-height:330px; }
479
- .candidate-card-inner { position:relative; width:100%; min-height:330px; transition:transform .8s cubic-bezier(.2,.7,.1,1); transform-style:preserve-3d; }
480
- .candidate-card:hover .candidate-card-inner,.candidate-card:focus .candidate-card-inner,.candidate-card:focus-within .candidate-card-inner { transform:rotateY(180deg); }
481
- .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; }
482
- .candidate-back { transform:rotateY(180deg); }
483
- .candidate-top { display:flex; justify-content:space-between; align-items:center; gap:8px; }
484
- .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); }
485
- .chip.alt { color:var(--gold); background:rgba(255,122,26,.08); border-color:rgba(255,122,26,.18); }
486
- .score { font-weight:700; color:var(--gold); }
487
- .candidate-face h4 { margin:0; font-size:1.08rem; line-height:1.35; }
488
- .candidate-face p { margin:0; color:#1e1e1e; line-height:1.65; font-size:.96rem; overflow-wrap:anywhere; }
489
- .meta-row { margin-top:auto; display:flex; justify-content:space-between; color:var(--muted); font-size:.88rem; gap:14px; }
490
- .mini { margin-top:8px; align-self:flex-start; color:var(--text); padding:10px 12px; border-radius:14px; border:1px solid var(--line); background:#ffffff; }
491
- .papers-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:14px; }
492
- .paper-card { border:1px solid var(--line); border-radius:18px; padding:16px; background:#ffffff; }
493
- .paper-topline { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:10px; }
494
- .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); }
495
- .paper-badge.alt { background:rgba(0,0,0,.04); color:#444; border-color:rgba(0,0,0,.08); }
496
- .doi-badge { background:rgba(255,122,26,.08); color:#8a4105; border-color:rgba(255,122,26,.18); }
497
- .paper-card h4 { margin:0 0 10px; line-height:1.35; font-size:1rem; }
498
- .paper-card p { margin:0 0 12px; line-height:1.6; color:#222; }
499
- .paper-links { display:flex; gap:12px; flex-wrap:wrap; }
500
- .paper-meta-stack { display:flex; flex-direction:column; gap:6px; color:#444; margin-bottom:12px; font-size:.9rem; }
501
- .paper-links a,.journal-card,.upload-note a { color:#0b63ce; text-decoration:none; }
502
- .journal-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:14px; }
503
- .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; }
504
- .journal-card h4 { margin:0 0 6px; }
505
- .journal-card p { margin:0; color:var(--muted); line-height:1.5; }
506
- .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; }
507
- .prosebox { padding:18px; white-space:pre-wrap; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; line-height:1.55; color:#1b1b1b; }
508
- .gr-button-primary { background:linear-gradient(135deg,rgba(255,122,26,.92),rgba(240,108,22,.92)) !important; color:#ffffff !important; border:none !important; }
509
- .gr-button-secondary { background:#ffffff !important; color:var(--text) !important; border:1px solid var(--line) !important; }
510
- .ref-list { margin:0; padding-left:18px; }
511
- .ref-list li { margin-bottom:8px; line-height:1.5; }
512
- .parse-grid { display:grid; grid-template-columns:1.2fr 1fr; gap:14px; }
513
- .parse-card { border:1px solid var(--line); border-radius:18px; padding:16px; background:#ffffff; }
514
- .selection-panel { padding:18px; }
515
- footer { display:none !important; }
516
- @keyframes pulseEdge { to { stroke-dashoffset:-40; } }
517
- @media (max-width:1180px) {
518
- .model-switcher,.candidate-grid,.papers-grid,.journal-grid,.parse-grid { grid-template-columns:1fr; }
519
- .brain-svg { height:460px; }
520
- }
521
- """
522
-
523
- CSS = BASE_CSS + "\n" + get_dvnc_layout_css()
524
- HEAD = """
525
- <link rel="preconnect" href="https://fonts.googleapis.com">
526
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
527
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
528
- """
529
-
530
- # ── Gradio layout ─────────────────────────────────────────────────────────────
531
- with gr.Blocks(css=CSS, head=HEAD, theme=gr.themes.Base(), fill_height=True) as demo:
532
-
533
- # ── Shared state ──────────────────────────────────────────────────────────
534
- papers_state = gr.State([])
535
- parsed_pdf_state = gr.State({})
536
- ingest_payload_state = gr.State({})
537
- route_state = gr.State(get_default_route_state())
538
-
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
-
561
- with gr.Tabs():
562
-
563
- # ── Tab 1 · Discovery Engine ──────────────────────────────────────────
564
- with gr.Tab("Discovery Engine"):
565
- model_html = gr.HTML(build_models_html("DVNC Sovereign"))
566
-
567
- with gr.Row():
568
- with gr.Column(scale=2):
569
- model = gr.Dropdown(
570
- choices=[m["name"] for m in MODELS],
571
- value="DVNC Sovereign",
572
- label="Model tier",
573
- )
574
- query = gr.Textbox(
575
- label="Discovery query",
576
- elem_classes=["querybox"],
577
- placeholder="Enter a scientific question, anomaly, or breakthrough direction…",
578
- lines=4,
579
- )
580
- with gr.Row():
581
- run_btn = gr.Button("Run discovery", variant="primary")
582
- example_btn = gr.Button("Load example", variant="secondary")
583
- chat = gr.HTML("""
584
- <div class="panel chat-panel">
585
- <div class="chat-thread">
586
- <div class="bubble bubble-ai">
587
- <span class="role">DVNC</span>
588
- <p>Enter a query to activate the 7-agent discovery stack and illuminate the chosen path through the 3D connectome.</p>
589
- </div>
590
- </div>
591
- </div>
592
- """)
593
-
594
- with gr.Column(scale=3):
595
- connectome = gr.HTML(build_connectome_html(DEFAULT_PATH))
596
- cards = gr.HTML("")
597
-
598
- output = gr.Markdown("# Discovery Output\n\nAwaiting query.")
599
- timeline = gr.HTML(get_initial_discovery_timeline_html())
600
-
601
- route_swap_payload = gr.Textbox(value="", visible=False, elem_id="route_swap_payload")
602
- route_swap_apply = gr.Button("Apply route swap", visible=False, elem_id="route_swap_apply")
603
-
604
- # ── Tab 2 · Self-Learning Graph ───────────────────────────────────────
605
- with gr.Tab("Self-Learning Graph"):
606
- with gr.Row():
607
- with gr.Column(scale=2):
608
- paper_query = gr.Textbox(
609
- label="Research topic / title / DOI / link",
610
- elem_classes=["querybox"],
611
- placeholder="e.g. self-assembling conductive biomaterials for cardiac repair",
612
- lines=3,
613
- )
614
- search_mode = gr.Dropdown(
615
- choices=SEARCH_MODES,
616
- value="topic",
617
- label="Search mode",
618
- )
619
- source_selector = gr.CheckboxGroup(
620
- choices=SOURCE_OPTIONS,
621
- value=DEFAULT_SOURCES,
622
- label="Sources",
623
- )
624
- pdf_upload = gr.File(label="Upload PDF papers", file_types=[".pdf"], file_count="single")
625
-
626
- with gr.Row():
627
- learn_btn = gr.Button("Discover papers", variant="primary")
628
- load_topic_btn = gr.Button("Load example topic", variant="secondary")
629
-
630
- upload_status = gr.Markdown("No PDF uploaded yet.")
631
- discovery_status = gr.Markdown("### No discovery results yet.")
632
- journal_panel = gr.HTML(build_journal_html("biomaterials cardiac repair"))
633
-
634
- gr.HTML('<div class="panel selection-panel"><h3 style="margin:0 0 12px;">Select papers to ingest</h3></div>')
635
- selection_box = gr.CheckboxGroup(choices=[], value=[], label="Candidate papers")
636
-
637
- parser_order = gr.CheckboxGroup(
638
- choices=["grobid", "docling", "pymupdf"],
639
- value=["grobid", "docling", "pymupdf"],
640
- label="Parser routing order",
641
- )
642
-
643
- with gr.Row():
644
- parse_btn = gr.Button("Parse uploaded PDF", variant="secondary")
645
- ingest_btn = gr.Button("Ingest selected into graph", variant="primary")
646
-
647
- with gr.Column(scale=3):
648
- learning_graph = gr.HTML(build_learning_graph_html([], []))
649
- 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>')
650
- parse_summary = gr.Markdown("### PDF parse status\n\nAwaiting upload.")
651
- parse_panel = gr.HTML('<div class="panel" style="padding:18px"><p>No parsed document yet.</p></div>')
652
- ingest_summary = gr.Markdown("### Graph ingest status\n\nAwaiting paper selection.")
653
- ingest_payload = gr.JSON(label="Graph ingest payload", value={"status": "empty", "nodes": [], "edges": []})
654
-
655
- # ── Event wiring ──────────────────────────────────────────────────────────
656
- example_btn.click(fn=load_example, outputs=query)
657
-
658
- run_btn.click(
659
- fn=run_discovery,
660
- inputs=[query, model],
661
- outputs=[chat, connectome, timeline, cards, output, model_html, route_state],
662
- )
663
-
664
- route_swap_apply.click(
665
- fn=apply_route_swap,
666
- inputs=[query, model, route_swap_payload, route_state],
667
- outputs=[chat, connectome, timeline, output, route_state],
668
- )
669
-
670
- load_topic_btn.click(fn=load_paper_topic, outputs=paper_query)
671
-
672
- learn_btn.click(
673
- fn=run_paper_discovery,
674
- inputs=[paper_query, search_mode, source_selector, pdf_upload],
675
- outputs=[learning_graph, papers_panel, journal_panel, upload_status, selection_box, papers_state, discovery_status],
676
- )
677
-
678
- parse_btn.click(
679
- fn=parse_uploaded_pdf,
680
- inputs=[pdf_upload, parser_order],
681
- outputs=[parse_summary, parsed_pdf_state],
682
- ).then(
683
- fn=render_parse_result,
684
- inputs=[parsed_pdf_state],
685
- outputs=[parse_panel],
686
- )
687
-
688
- ingest_btn.click(
689
- fn=ingest_selected_papers,
690
- inputs=[paper_query, selection_box, papers_state, pdf_upload, parsed_pdf_state],
691
- outputs=[learning_graph, ingest_summary, ingest_payload],
692
- )
693
-
694
-
695
- if __name__ == "__main__":
696
- demo.launch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dvnc_ai_v2_hf/deprecated/app_old5.py DELETED
@@ -1,343 +0,0 @@
1
- import json
2
- import math
3
- import random
4
- import gradio as gr
5
-
6
- MODELS = [
7
- {"name": "DVNC Sovereign", "tag": "flagship", "desc": "Maximum depth orchestration for frontier discovery"},
8
- {"name": "DVNC Atlas", "tag": "research", "desc": "Balanced reasoning, graph traversal, and synthesis"},
9
- {"name": "DVNC Curie", "tag": "lab", "desc": "Experimental hypothesis generation for anomalous signals"},
10
- ]
11
-
12
- AGENTS = [
13
- "Query Interpreter",
14
- "Graph Divergence Mapper",
15
- "Evidence Harvester",
16
- "Analogy Engine",
17
- "Hypothesis Composer",
18
- "Adversarial Critic",
19
- "Experimental Program Designer",
20
- ]
21
-
22
- NODES = [
23
- {"id": "seed", "label": "Seed Query", "group": "core", "x": 0, "y": 0, "z": 0},
24
- {"id": "bio", "label": "Biomaterials", "group": "domain", "x": 18, "y": 12, "z": -8},
25
- {"id": "card", "label": "Cardiac Repair", "group": "domain", "x": 34, "y": 2, "z": 14},
26
- {"id": "nano", "label": "Nanostructure", "group": "bridge", "x": 14, "y": -18, "z": 16},
27
- {"id": "selfasm", "label": "Self-Assembly", "group": "bridge", "x": 30, "y": -16, "z": -16},
28
- {"id": "electro", "label": "Electro-signalling", "group": "mechanism", "x": 48, "y": 12, "z": -10},
29
- {"id": "immune", "label": "Immune Modulation", "group": "mechanism", "x": 54, "y": -8, "z": 10},
30
- {"id": "trial", "label": "Validation Path", "group": "outcome", "x": 70, "y": 0, "z": 0},
31
- {"id": "alt1", "label": "Piezoelectric Scaffold", "group": "candidate", "x": 44, "y": 28, "z": 14},
32
- {"id": "alt2", "label": "Peptide Mesh", "group": "candidate", "x": 42, "y": -28, "z": -14},
33
- ]
34
-
35
- EDGES = [
36
- ("seed", "bio"), ("seed", "nano"), ("bio", "card"), ("nano", "selfasm"),
37
- ("selfasm", "electro"), ("card", "immune"), ("electro", "trial"), ("immune", "trial"),
38
- ("card", "alt1"), ("selfasm", "alt2"), ("alt1", "trial"), ("alt2", "trial")
39
- ]
40
-
41
- DEFAULT_PATH = ["seed", "nano", "selfasm", "electro", "trial"]
42
-
43
- CANDIDATES = [
44
- {
45
- "title": "Piezoelectric Scaffold Cascade",
46
- "front": "Use mechano-electric scaffolds to convert cardiac strain into micro-current signalling.",
47
- "back": "Discovery path: anomalous healing signal -> piezoelectric analog -> ion-channel entrainment -> tissue regeneration. Risk: power density and fibrosis coupling.",
48
- "score": 92,
49
- "novelty": "High",
50
- "agent": "Hypothesis Composer"
51
- },
52
- {
53
- "title": "Peptide Self-Assembly Mesh",
54
- "front": "Deploy dynamic peptide meshes that self-assemble around damaged myocardium and guide repair.",
55
- "back": "Discovery path: self-assembly -> local immune choreography -> regenerative substrate formation. Risk: degradation timing and targeting specificity.",
56
- "score": 88,
57
- "novelty": "High",
58
- "agent": "Analogy Engine"
59
- },
60
- {
61
- "title": "Immune-Tuned Conductive Hydrogel",
62
- "front": "Blend conductivity with macrophage-state modulation to reduce scarring and restore conduction.",
63
- "back": "Discovery path: inflammation mismatch -> conductive medium -> macrophage polarization -> synchronized healing. Risk: persistence and biocompatibility.",
64
- "score": 85,
65
- "novelty": "Medium-High",
66
- "agent": "Adversarial Critic"
67
- }
68
- ]
69
-
70
-
71
- def build_connectome_html(path_ids):
72
- active = set(path_ids)
73
- node_map = {n['id']: n for n in NODES}
74
- lines = []
75
- for a, b in EDGES:
76
- na, nb = node_map[a], node_map[b]
77
- active_edge = a in active and b in active and abs(path_ids.index(a) - path_ids.index(b)) == 1 if a in path_ids and b in path_ids else False
78
- cls = "edge active" if active_edge else "edge"
79
- lines.append(f'<line class="{cls}" x1="{na["x"]*6+420:.1f}" y1="{na["y"]*6+280:.1f}" x2="{nb["x"]*6+420:.1f}" y2="{nb["y"]*6+280:.1f}" />')
80
- circles = []
81
- labels = []
82
- for n in NODES:
83
- cx = n['x']*6 + 420
84
- cy = n['y']*6 + 280
85
- cls = f"node {n['group']} {'active' if n['id'] in active else ''}"
86
- circles.append(f'<g class="node-wrap"><circle class="{cls}" cx="{cx:.1f}" cy="{cy:.1f}" r="{17 if n["id"] in active else 12}" /><circle class="halo {'active' if n['id'] in active else ''}" cx="{cx:.1f}" cy="{cy:.1f}" r="{28 if n["id"] in active else 0}" /></g>')
87
- labels.append(f'<text class="label {'active' if n["id"] in active else ''}" x="{cx+16:.1f}" y="{cy-16:.1f}">{n["label"]}</text>')
88
- return f'''
89
- <div class="brain-shell">
90
- <div class="brain-header">
91
- <div>
92
- <p class="eyebrow">Connectome</p>
93
- <h3>Concept Brain</h3>
94
- </div>
95
- <div class="brain-legend">
96
- <span><i class="dot dot-live"></i> active route</span>
97
- <span><i class="dot dot-node"></i> concept nodes</span>
98
- </div>
99
- </div>
100
- <div class="brain-stage">
101
- <div class="orb orb-a"></div><div class="orb orb-b"></div>
102
- <svg viewBox="0 0 840 560" class="brain-svg" role="img" aria-label="DVNC connectome visualisation"> <defs> </defs>
103
- {''.join(lines)}
104
- {''.join(circles)}
105
- {''.join(labels)}
106
- </svg>
107
- </div>
108
- </div>
109
- '''
110
-
111
-
112
- def build_cards_html(cards):
113
- items = []
114
- for i, c in enumerate(cards):
115
- items.append(f'''
116
- <article class="candidate-card" tabindex="0">
117
- <div class="candidate-card-inner">
118
- <div class="candidate-face candidate-front">
119
- <div class="candidate-top"><span class="chip">{c['agent']}</span><span class="score">{c['score']}</span></div>
120
- <h4>{c['title']}</h4>
121
- <p>{c['front']}</p>
122
- <div class="meta-row"><span>Novelty</span><strong>{c['novelty']}</strong></div>
123
- <button class="mini">Flip insight</button>
124
- </div>
125
- <div class="candidate-face candidate-back">
126
- <div class="candidate-top"><span class="chip alt">Alternative path</span><span class="score">{c['score']}</span></div>
127
- <h4>{c['title']}</h4>
128
- <p>{c['back']}</p>
129
- <div class="meta-row"><span>Swap into route</span><strong>Enabled</strong></div>
130
- <button class="mini">Return</button>
131
- </div>
132
- </div>
133
- </article>
134
- ''')
135
- return '<div class="candidate-grid">' + ''.join(items) + '</div>'
136
-
137
-
138
- def build_agent_timeline(reasoning):
139
- rows = []
140
- for r in reasoning:
141
- rows.append(f'''
142
- <div class="agent-step">
143
- <div class="agent-index">{r['step']}</div>
144
- <div class="agent-copy">
145
- <div class="agent-head"><h4>{r['agent']}</h4><span>{r['tag']}</span></div>
146
- <p>{r['summary']}</p>
147
- </div>
148
- </div>
149
- ''')
150
- return '<div class="timeline">' + ''.join(rows) + '</div>'
151
-
152
-
153
- def build_chat_html(query, result):
154
- bubbles = f'''
155
- <div class="chat-thread">
156
- <div class="bubble bubble-user">
157
- <span class="role">You</span>
158
- <p>{query}</p>
159
- </div>
160
- <div class="bubble bubble-ai">
161
- <span class="role">DVNC Sovereign</span>
162
- <p>{result['summary']}</p>
163
- </div>
164
- <div class="bubble bubble-system">
165
- <span class="role">Discovery Signal</span>
166
- <p><strong>Primary hypothesis:</strong> {result['primary_hypothesis']}</p>
167
- </div>
168
- </div>
169
- '''
170
- return bubbles
171
-
172
-
173
- def build_models_html(selected):
174
- items = []
175
- for m in MODELS:
176
- active = 'active' if m['name'] == selected else ''
177
- items.append(f'''<div class="model-pill {active}"><span class="model-name">{m['name']}</span><span class="model-tag">{m['tag']}</span><small>{m['desc']}</small></div>''')
178
- return '<div class="model-switcher">' + ''.join(items) + '</div>'
179
-
180
-
181
- def run_discovery(query, model_name):
182
- random.seed(len(query) + len(model_name))
183
- if "curie" in query.lower() or "einstein" in query.lower():
184
- primary = "Map the anomaly first, then force a distant analogy before composing the experimental programme."
185
- path = ["seed", "bio", "card", "immune", "trial"]
186
- else:
187
- primary = "Use a self-assembling conductive scaffold that transforms mechanical strain into local regenerative signalling."
188
- path = DEFAULT_PATH
189
- summaries = [
190
- "Normalises the user prompt into a graph-searchable seed and isolates the tension inside the question.",
191
- "Finds remote conceptual bridges instead of staying near the starting domain cluster.",
192
- "Pulls evidence packets and conflict signals required for grounded hypothesis formation.",
193
- "Generates cross-domain analogies with a bias toward mechanism transfer rather than keyword similarity.",
194
- "Composes the lead hypothesis and two structurally different variants.",
195
- "Attacks weak assumptions, hidden confounders, and feasibility gaps.",
196
- "Produces a staged validation plan with measurable falsification criteria."
197
- ]
198
- reasoning = [{"step": i+1, "agent": AGENTS[i], "tag": ["input","graph","evidence","analogy","compose","critique","experiment"][i], "summary": summaries[i]} for i in range(7)]
199
- result = {
200
- "summary": "A deeper route was chosen through the concept graph, with live alternatives preserved as candidate cards so the reasoning path can be swapped rather than hidden.",
201
- "primary_hypothesis": primary,
202
- "reasoning": reasoning,
203
- "cards": CANDIDATES,
204
- "path": path,
205
- "metrics": {
206
- "Novelty": 93,
207
- "Mechanistic clarity": 89,
208
- "Experimental tractability": 82,
209
- "Cross-domain distance": 91
210
- }
211
- }
212
- chat_html = build_chat_html(query, result)
213
- connectome_html = build_connectome_html(path)
214
- cards_html = build_cards_html(CANDIDATES)
215
- timeline_html = build_agent_timeline(reasoning)
216
- metrics = "\n".join([f"- {k}: {v}/100" for k, v in result["metrics"].items()])
217
- hypothesis = f"""# Discovery Output\n\n**Model:** {model_name}\n\n**Primary hypothesis:** {result['primary_hypothesis']}\n\n## Scoring\n{metrics}\n\n## Experimental outline\n1. Construct the candidate material or protocol.\n2. Test mechanistic signal expression under controlled conditions.\n3. Compare against baseline and nearest-neighbour alternatives.\n4. Falsify using the adversarial risk criteria surfaced in the reasoning path.\n"""
218
- return chat_html, connectome_html, timeline_html, cards_html, hypothesis, build_models_html(model_name)
219
-
220
- CSS = r'''
221
- :root {
222
- --bg: #ffffff; --bg2: #f8f8f8;
223
- --panel: rgba(248,248,248,.95); --panel-solid: #fafafa;
224
- --panel-2: rgba(245,245,245,.95); --line: rgba(0,0,0,.15);
225
- --soft: rgba(0,0,0,.60); --text: #000000;
226
- --muted: #555555; --gold: #ff6600;
227
- --teal: #7be7dd;
228
- --blue: #7ea9ff;
229
- --shadow: 0 20px 60px rgba(0,0,0,.38);
230
- --radius: 22px;
231
- }
232
- html, body, .gradio-container {
233
- background: #ffffff !important; font-family: Inter, ui-sans-serif, system-ui, sans-serif;
234
- }
235
- .gradio-container {max-width: 1550px !important; padding: 22px !important;}
236
- #dvnc-shell {border: 1px solid var(--line); border-radius: 28px; overflow: hidden; background: #ffffff; box-shadow: var(--shadow); backdrop-filter: blur(18px);}#dvnc-shell .wrap {display:grid; grid-template-columns: 1.12fr .88fr; min-height: 84vh;}
237
- #dvnc-shell .left, #dvnc-shell .right {padding: 24px;}
238
- #dvnc-shell .left {border-right: 1px solid var(--line); display:flex; flex-direction:column; gap:18px;}
239
- #dvnc-shell .right {display:grid; grid-template-rows:auto auto 1fr auto; gap:18px;}
240
- .hero-bar {display:flex; justify-content:space-between; align-items:center; gap:16px; padding-bottom:8px;}
241
- .brand {display:flex; align-items:center; gap:14px;}
242
- .logo {width:42px; height:42px; border-radius:14px; background: linear-gradient(135deg, rgba(242,210,147,.28), rgba(123,231,221,.16)); border:1px solid rgba(255,255,255,.1); display:grid; place-items:center; box-shadow: inset 0 1px 0 rgba(255,255,255,.08);}
243
- .logo svg {width:24px; height:24px; color: var(--gold);}
244
- .brand h1 {font-size: 1.05rem; margin:0; font-weight: 650; letter-spacing:.02em;}
245
- .brand p {margin:3px 0 0; color:var(--muted); font-size:.84rem;}
246
- .status {display:flex; gap:10px; align-items:center; color:var(--soft); font-size:.85rem;}
247
- .status-dot {width:10px; height:10px; border-radius:50%; background:var(--teal); box-shadow:0 0 0 6px rgba(123,231,221,.08), 0 0 18px rgba(123,231,221,.4);}
248
- .panel {background: #ffffff; border:1px solid var(--line); border-radius: 22px; box-shadow: inset 0 1px 0 rgba(0,0,0,.04);}.chat-panel {padding:18px; display:flex; flex-direction:column; gap:14px; min-height: 340px;}
249
- .querybox textarea, .querybox input {background: transparent !important; color: var(--text) !important;}
250
- .querybox, .querybox > div {background: rgba(255,255,255,.02) !important; border-radius: 18px !important; border-color: var(--line) !important;}
251
- .chat-thread {display:flex; flex-direction:column; gap:14px;}
252
- .bubble {max-width: 86%; padding:16px 18px; border-radius: 22px; position:relative; border:1px solid var(--line);}
253
- .bubble p {margin:8px 0 0; line-height:1.6; font-size:.96rem;}
254
- .bubble .role {font-size:.72rem; letter-spacing:.12em; text-transform:uppercase; color:var(--muted);}
255
- .bubble-user {align-self:flex-end; background: linear-gradient(135deg, rgba(126,169,255,.18), rgba(126,169,255,.08));}
256
- .bubble-ai {align-self:flex-start; background: linear-gradient(135deg, rgba(255,255,255,.06), rgba(255,255,255,.03));}
257
- .bubble-system {align-self:flex-start; background: linear-gradient(135deg, rgba(242,210,147,.12), rgba(242,210,147,.04));}
258
- .model-switcher {display:grid; grid-template-columns:repeat(3,1fr); gap:12px;}
259
- .model-pill {padding:14px; border:1px solid var(--line); border-radius:18px; background:rgba(255,255,255,.02); display:flex; flex-direction:column; gap:4px; min-height: 96px;}
260
- .model-pill.active {border-color: rgba(242,210,147,.45); background: linear-gradient(135deg, rgba(242,210,147,.14), rgba(255,255,255,.03)); box-shadow: inset 0 0 0 1px rgba(242,210,147,.08);}
261
- .model-name {font-weight:650;}
262
- .model-tag {font-size:.76rem; text-transform:uppercase; letter-spacing:.12em; color:var(--gold);}
263
- .model-pill small {color:var(--muted); line-height:1.45;}
264
- .brain-shell {padding:18px; height:100%;}
265
- .brain-header {display:flex; justify-content:space-between; align-items:flex-end; gap:16px; margin-bottom:10px;}
266
- .eyebrow {font-size:.72rem; letter-spacing:.16em; text-transform:uppercase; color:var(--gold); margin:0 0 4px;}
267
- .brain-header h3 {margin:0; font-size:1.12rem;}
268
- .brain-legend {display:flex; gap:14px; color:var(--muted); font-size:.8rem; flex-wrap:wrap;}
269
- .dot {width:10px; height:10px; display:inline-block; border-radius:50%; margin-right:6px;}
270
- .dot-live {background:var(--gold); box-shadow:0 0 12px rgba(242,210,147,.65);} .dot-node {background:var(--teal);}
271
- .brain-stage {position:relative; min-height:360px; overflow:auto; background: #ffffff; border:1px solid rgba(0,0,0,.05);.orb-a {width:180px; height:180px; background:rgba(123,231,221,.16); left:10%; top:18%;}
272
- .brain-svg {width:1200px; height:auto; display:block; margin:0 auto; min-height:500px;}.edge {stroke: rgba(132,153,153,.18); stroke-width:2;}.node.active {stroke:#fff7df; stroke-width:2.4; filter:url(#glow);}
273
- .halo {fill:none;} .halo.active {stroke: rgba(242,210,147,.22); stroke-width:10;}
274
- .label {fill: #000000; font-size: 12px; letter-spacing: .02em;} .label.active {fill: #000000;}.timeline {display:flex; flex-direction:column; gap:10px;}
275
- .agent-step {display:grid; grid-template-columns:42px 1fr; gap:12px; padding:12px; border:1px solid var(--line); border-radius:18px; background:rgba(255,255,255,.02);}
276
- .agent-index {width:42px; height:42px; border-radius:14px; display:grid; place-items:center; font-weight:700; color:var(--gold); background:rgba(242,210,147,.08); border:1px solid rgba(242,210,147,.16);}
277
- .agent-head {display:flex; justify-content:space-between; gap:12px; align-items:center; margin-bottom:4px;}
278
- .agent-head h4 {margin:0; font-size:.98rem;} .agent-head span {font-size:.72rem; letter-spacing:.12em; text-transform:uppercase; color:var(--muted);} .agent-copy p {margin:0; color:#cbd3e2; font-size:.9rem; line-height:1.55;}
279
- .candidate-grid {display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap:14px;}
280
- .candidate-card {background:none; perspective:1200px; min-height: 250px;}
281
- .candidate-card-inner {position:relative; width:100%; height:100%; min-height:250px; transition: transform .8s cubic-bezier(.2,.7,.1,1); transform-style:preserve-3d;}
282
- .candidate-card:hover .candidate-card-inner, .candidate-card:focus .candidate-card-inner, .candidate-card:focus-within .candidate-card-inner {transform: rotateY(180deg) translateY(-4px);}
283
- .candidate-face {position:absolute; inset:0; padding:18px; border-radius:22px; border:1px solid var(--line); background: linear-gradient(180deg, rgba(19,22,29,.96), rgba(11,13,18,.96)); backface-visibility:hidden; box-shadow: inset 0 1px 0 rgba(255,255,255,.04), var(--shadow); display:flex; flex-direction:column; gap:12px;}
284
- .candidate-back {transform: rotateY(180deg); background: linear-gradient(180deg, rgba(29,23,16,.96), rgba(12,11,10,.96));}
285
- .candidate-top {display:flex; justify-content:space-between; align-items:center; gap:8px;}
286
- .chip {font-size:.72rem; text-transform:uppercase; letter-spacing:.12em; color:var(--teal); padding:7px 10px; border-radius:999px; background:rgba(123,231,221,.08); border:1px solid rgba(123,231,221,.18);} .chip.alt {color:var(--gold); background:rgba(242,210,147,.08); border-color: rgba(242,210,147,.18);}
287
- .score {font-weight:700; color:var(--gold);}
288
- .candidate-face h4 {margin:0; font-size:1rem; line-height:1.3;}
289
- .candidate-face p {margin:0; color:#cbd3e2; line-height:1.6; font-size:.92rem;}
290
- .meta-row {margin-top:auto; display:flex; justify-content:space-between; color:var(--muted); font-size:.84rem;}
291
- .mini {margin-top:8px; align-self:flex-start; color:var(--text); padding:10px 12px; border-radius:14px; border:1px solid var(--line); background:rgba(255,255,255,.03);}
292
- .prosebox {padding:18px; white-space:pre-wrap; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; line-height:1.55; color:#dfe6f3;}
293
- .gr-button-primary {background: linear-gradient(135deg, rgba(242,210,147,.92), rgba(224,178,92,.92)) !important; color:#1b1408 !important; border: none !important;}
294
- .gr-button-secondary {background: rgba(255,255,255,.05) !important; color: var(--text) !important; border: 1px solid var(--line) !important;}
295
- footer {display:none !important;}
296
- @keyframes pulseEdge {to {stroke-dashoffset: -32;}}
297
- @media (max-width: 1180px){#dvnc-shell .wrap{grid-template-columns:1fr;}#dvnc-shell .left{border-right:0;border-bottom:1px solid var(--line)}.candidate-grid,.model-switcher{grid-template-columns:1fr;} }
298
- '''
299
-
300
- HEAD = '''
301
- <link rel="preconnect" href="https://fonts.googleapis.com">
302
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
303
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
304
- '''
305
-
306
- with gr.Blocks(css=CSS, head=HEAD, theme=gr.themes.Base(), fill_height=True) as demo:
307
- gr.HTML('''
308
- <div id="dvnc-shell">
309
- <div class="hero-bar">
310
- <div class="brand">
311
- <div class="logo" aria-hidden="true">
312
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M5 17L12 4l7 13"/><path d="M8.5 12.5h7"/><circle cx="12" cy="12" r="1.8" fill="currentColor" stroke="none"/></svg>
313
- </div>
314
- <div>
315
- <h1>DVNC.AI</h1>
316
- <p>Sovereign discovery interface · connectome-native reasoning</p>
317
- </div>
318
- </div>
319
- <div class="status"><span class="status-dot"></span><span>Live orchestration</span></div>
320
- </div>
321
- </div>
322
- ''')
323
- model_html = gr.HTML(build_models_html("DVNC Sovereign"))
324
- with gr.Row():
325
- model = gr.Dropdown(choices=[m["name"] for m in MODELS], value="DVNC Sovereign", label="Model tier")
326
- query = gr.Textbox(label="Discovery query", elem_classes=["querybox"], placeholder="Enter a scientific question, anomaly, or breakthrough direction…", lines=4)
327
- with gr.Row():
328
- run_btn = gr.Button("Run discovery", variant="primary")
329
- example_btn = gr.Button("Load example", variant="secondary")
330
- chat = gr.HTML('<div class="panel chat-panel"><div class="chat-thread"><div class="bubble bubble-ai"><span class="role">DVNC</span><p>Enter a query to activate the 7-agent discovery stack and illuminate the chosen path through the concept brain.</p></div></div></div>')
331
- connectome = gr.HTML(build_connectome_html(DEFAULT_PATH))
332
- timeline = gr.HTML('<div class="panel" style="padding:18px"><div class="timeline"></div></div>')
333
- cards = gr.HTML('<div class="panel" style="padding:18px"><div class="candidate-grid"></div></div>')
334
- output = gr.Markdown("# Discovery Output\n\nAwaiting query.")
335
-
336
- def load_example():
337
- return "How could a self-assembling conductive biomaterial improve cardiac tissue regeneration by converting mechanical strain into repair signalling?"
338
-
339
- example_btn.click(fn=load_example, outputs=query)
340
- run_btn.click(fn=run_discovery, inputs=[query, model], outputs=[chat, connectome, timeline, cards, output, model_html])
341
-
342
- if __name__ == "__main__":
343
- demo.launch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dvnc_ai_v2_hf/deprecated/app_old6.py DELETED
@@ -1,935 +0,0 @@
1
- import json
2
- import math
3
- import random
4
- import gradio as gr
5
-
6
-
7
- MODELS = [
8
- {"name": "DVNC Sovereign", "tag": "flagship", "desc": "Maximum depth orchestration for frontier discovery"},
9
- {"name": "DVNC Atlas", "tag": "research", "desc": "Balanced reasoning, graph traversal, and synthesis"},
10
- {"name": "DVNC Curie", "tag": "lab", "desc": "Experimental hypothesis generation for anomalous signals"},
11
- ]
12
-
13
-
14
- AGENTS = [
15
- "Query Interpreter",
16
- "Graph Divergence Mapper",
17
- "Evidence Harvester",
18
- "Analogy Engine",
19
- "Hypothesis Composer",
20
- "Adversarial Critic",
21
- "Experimental Program Designer",
22
- ]
23
-
24
-
25
- NODES = [
26
- {"id": "seed", "label": "Seed Query", "group": "core", "x": 0, "y": 0, "z": 0},
27
- {"id": "bio", "label": "Biomaterials", "group": "domain", "x": 18, "y": 12, "z": -8},
28
- {"id": "card", "label": "Cardiac Repair", "group": "domain", "x": 34, "y": 2, "z": 14},
29
- {"id": "nano", "label": "Nanostructure", "group": "bridge", "x": 14, "y": -18, "z": 16},
30
- {"id": "selfasm", "label": "Self-Assembly", "group": "bridge", "x": 30, "y": -16, "z": -16},
31
- {"id": "electro", "label": "Electro-signalling", "group": "mechanism", "x": 48, "y": 12, "z": -10},
32
- {"id": "immune", "label": "Immune Modulation", "group": "mechanism", "x": 54, "y": -8, "z": 10},
33
- {"id": "trial", "label": "Validation Path", "group": "outcome", "x": 70, "y": 0, "z": 0},
34
- {"id": "alt1", "label": "Piezoelectric Scaffold", "group": "candidate", "x": 44, "y": 28, "z": 14},
35
- {"id": "alt2", "label": "Peptide Mesh", "group": "candidate", "x": 42, "y": -28, "z": -14},
36
- ]
37
-
38
-
39
- EDGES = [
40
- ("seed", "bio"), ("seed", "nano"), ("bio", "card"), ("nano", "selfasm"),
41
- ("selfasm", "electro"), ("card", "immune"), ("electro", "trial"), ("immune", "trial"),
42
- ("card", "alt1"), ("selfasm", "alt2"), ("alt1", "trial"), ("alt2", "trial")
43
- ]
44
-
45
-
46
- DEFAULT_PATH = ["seed", "nano", "selfasm", "electro", "trial"]
47
-
48
-
49
- CANDIDATES = [
50
- {
51
- "title": "Piezoelectric Scaffold Cascade",
52
- "front": "Use mechano-electric scaffolds to convert cardiac strain into micro-current signalling.",
53
- "back": "Discovery path: anomalous healing signal -> piezoelectric analog -> ion-channel entrainment -> tissue regeneration. Risk: power density and fibrosis coupling.",
54
- "score": 92,
55
- "novelty": "High",
56
- "agent": "Hypothesis Composer"
57
- },
58
- {
59
- "title": "Peptide Self-Assembly Mesh",
60
- "front": "Deploy dynamic peptide meshes that self-assemble around damaged myocardium and guide repair.",
61
- "back": "Discovery path: self-assembly -> local immune choreography -> regenerative substrate formation. Risk: degradation timing and targeting specificity.",
62
- "score": 88,
63
- "novelty": "High",
64
- "agent": "Analogy Engine"
65
- },
66
- {
67
- "title": "Immune-Tuned Conductive Hydrogel",
68
- "front": "Blend conductivity with macrophage-state modulation to reduce scarring and restore conduction.",
69
- "back": "Discovery path: inflammation mismatch -> conductive medium -> macrophage polarization -> synchronized healing. Risk: persistence and biocompatibility.",
70
- "score": 85,
71
- "novelty": "Medium-High",
72
- "agent": "Adversarial Critic"
73
- }
74
- ]
75
-
76
-
77
- def build_connectome_html(path_ids):
78
- active = set(path_ids)
79
- node_map = {n["id"]: n for n in NODES}
80
- lines = []
81
-
82
- for a, b in EDGES:
83
- na, nb = node_map[a], node_map[b]
84
- active_edge = (
85
- a in active
86
- and b in active
87
- and a in path_ids
88
- and b in path_ids
89
- and abs(path_ids.index(a) - path_ids.index(b)) == 1
90
- )
91
- cls = "edge active" if active_edge else "edge"
92
- lines.append(
93
- f'e class="{cls}" '
94
- f'x1="{na["x"] * 6 + 420:.1f}" y1="{na["y"] * 6 + 280:.1f}" '
95
- f'x2="{nb["x"] * 6 + 420:.1f}" y2="{nb["y"] * 6 + 280:.1f}" />'
96
- )
97
-
98
- circles = []
99
- labels = []
100
- for n in NODES:
101
- cx = n["x"] * 6 + 420
102
- cy = n["y"] * 6 + 280
103
- cls = f'node {n["group"]} {"active" if n["id"] in active else ""}'
104
- r_node = 17 if n["id"] in active else 12
105
- r_halo = 28 if n["id"] in active else 0
106
- halo_cls = f'halo {"active" if n["id"] in active else ""}'
107
- circles.append(
108
- f'<g class="node-wrap">'
109
- f'ircle class="{cls}" cx="{cx:.1f}" cy="{cy:.1f}" r="{r_node}" />'
110
- f'ircle class="{halo_cls}" cx="{cx:.1f}" cy="{cy:.1f}" r="{r_halo}" />'
111
- f"</g>"
112
- )
113
- labels.append(
114
- f'<text class="label {"active" if n["id"] in active else ""}" '
115
- f'x="{cx + 16:.1f}" y="{cy - 16:.1f}">{n["label"]}</text>'
116
- )
117
-
118
- return f"""
119
- <div class="panel brain-shell">
120
- <div class="brain-header">
121
- <div>
122
- <p class="eyebrow">Connectome</p>
123
- <h3>Concept Brain</h3>
124
- </div>
125
- <div class="brain-legend">
126
- <span><i class="dot dot-live"></i> active route</span>
127
- <span><i class="dot dot-node"></i> concept nodes</span>
128
- </div>
129
- </div>
130
- <div class="brain-stage">
131
- <div class="orb orb-a"></div>
132
- <div class="orb orb-b"></div>
133
- <svg viewBox="0 0 840 560" class="brain-svg" role="img"
134
- aria-label="DVNC connectome visualisation">
135
- {''.join(lines)}
136
- {''.join(circles)}
137
- {''.join(labels)}
138
- </svg>
139
- </div>
140
- </div>
141
- """
142
-
143
-
144
- def build_cards_html(cards):
145
- items = []
146
- for c in cards:
147
- items.append(
148
- f"""
149
- <article class="candidate-card" tabindex="0">
150
- <div class="candidate-card-inner">
151
- <div class="candidate-face candidate-front">
152
- <div class="candidate-top">
153
- <span class="chip">{c['agent']}</span>
154
- <span class="score">{c['score']}</span>
155
- </div>
156
- <h4>{c['title']}</h4>
157
- <p>{c['front']}</p>
158
- <div class="meta-row">
159
- <span>Novelty</span>
160
- <strong>{c['novelty']}</strong>
161
- </div>
162
- <button class="mini">Flip insight</button>
163
- </div>
164
- <div class="candidate-face candidate-back">
165
- <div class="candidate-top">
166
- <span class="chip alt">Alternative path</span>
167
- <span class="score">{c['score']}</span>
168
- </div>
169
- <h4>{c['title']}</h4>
170
- <p>{c['back']}</p>
171
- <div class="meta-row">
172
- <span>Swap into route</span>
173
- <strong>Enabled</strong>
174
- </div>
175
- <button class="mini">Return</button>
176
- </div>
177
- </div>
178
- </article>
179
- """
180
- )
181
- return '<div class="panel" style="padding:18px;"><div class="candidate-grid">' + "".join(items) + "</div></div>"
182
-
183
-
184
- def build_agent_timeline(reasoning):
185
- rows = []
186
- for r in reasoning:
187
- rows.append(
188
- f"""
189
- <div class="agent-step">
190
- <div class="agent-index">{r['step']}</div>
191
- <div class="agent-copy">
192
- <div class="agent-head">
193
- <h4>{r['agent']}</h4>
194
- <span>{r['tag']}</span>
195
- </div>
196
- <p>{r['summary']}</p>
197
- </div>
198
- </div>
199
- """
200
- )
201
- return '<div class="panel" style="padding:18px;"><div class="timeline">' + "".join(rows) + "</div></div>"
202
-
203
-
204
- def build_chat_html(query, result):
205
- return f"""
206
- <div class="panel chat-panel">
207
- <div class="chat-thread">
208
- <div class="bubble bubble-user">
209
- <span class="role">You</span>
210
- <p>{query}</p>
211
- </div>
212
- <div class="bubble bubble-ai">
213
- <span class="role">DVNC Sovereign</span>
214
- <p>{result['summary']}</p>
215
- </div>
216
- <div class="bubble bubble-system">
217
- <span class="role">Discovery Signal</span>
218
- <p><strong>Primary hypothesis:</strong> {result['primary_hypothesis']}</p>
219
- </div>
220
- </div>
221
- </div>
222
- """
223
-
224
-
225
- def build_models_html(selected):
226
- items = []
227
- for m in MODELS:
228
- active = "active" if m["name"] == selected else ""
229
- items.append(
230
- f"""
231
- <div class="model-pill {active}">
232
- <span class="model-name">{m['name']}</span>
233
- <span class="model-tag">{m['tag']}</span>
234
- <small>{m['desc']}</small>
235
- </div>
236
- """
237
- )
238
- return '<div class="panel" style="padding:18px;"><div class="model-switcher">' + "".join(items) + "</div></div>"
239
-
240
-
241
- def run_discovery(query, model_name):
242
- random.seed(len(query) + len(model_name))
243
-
244
- if "curie" in query.lower() or "einstein" in query.lower():
245
- primary = (
246
- "Map the anomaly first, then force a distant analogy before composing "
247
- "the experimental programme."
248
- )
249
- path = ["seed", "bio", "card", "immune", "trial"]
250
- else:
251
- primary = (
252
- "Use a self-assembling conductive scaffold that transforms mechanical "
253
- "strain into local regenerative signalling."
254
- )
255
- path = DEFAULT_PATH
256
-
257
- summaries = [
258
- "Normalises the user prompt into a graph-searchable seed and isolates the tension inside the question.",
259
- "Finds remote conceptual bridges instead of staying near the starting domain cluster.",
260
- "Pulls evidence packets and conflict signals required for grounded hypothesis formation.",
261
- "Generates cross-domain analogies with a bias toward mechanism transfer rather than keyword similarity.",
262
- "Composes the lead hypothesis and two structurally different variants.",
263
- "Attacks weak assumptions, hidden confounders, and feasibility gaps.",
264
- "Produces a staged validation plan with measurable falsification criteria.",
265
- ]
266
- reasoning = [
267
- {
268
- "step": i + 1,
269
- "agent": AGENTS[i],
270
- "tag": ["input", "graph", "evidence", "analogy", "compose", "critique", "experiment"][i],
271
- "summary": summaries[i],
272
- }
273
- for i in range(7)
274
- ]
275
-
276
- result = {
277
- "summary": (
278
- "A deeper route was chosen through the concept graph, with live alternatives "
279
- "preserved as candidate cards so the reasoning path can be swapped rather than hidden."
280
- ),
281
- "primary_hypothesis": primary,
282
- "reasoning": reasoning,
283
- "cards": CANDIDATES,
284
- "path": path,
285
- "metrics": {
286
- "Novelty": 93,
287
- "Mechanistic clarity": 89,
288
- "Experimental tractability": 82,
289
- "Cross-domain distance": 91,
290
- },
291
- }
292
-
293
- chat_html = build_chat_html(query, result)
294
- connectome_html = build_connectome_html(path)
295
- cards_html = build_cards_html(CANDIDATES)
296
- timeline_html = build_agent_timeline(reasoning)
297
-
298
- metrics = "\\n".join([f"- {k}: {v}/100" for k, v in result["metrics"].items()])
299
- hypothesis = (
300
- "# Discovery Output\\n\\n"
301
- f"**Model:** {model_name}\\n\\n"
302
- f"**Primary hypothesis:** {result['primary_hypothesis']}\\n\\n"
303
- "## Scoring\\n"
304
- f"{metrics}\\n\\n"
305
- "## Experimental outline\\n"
306
- "1. Construct the candidate material or protocol.\\n"
307
- "2. Test mechanistic signal expression under controlled conditions.\\n"
308
- "3. Compare against baseline and nearest-neighbour alternatives.\\n"
309
- "4. Falsify using the adversarial risk criteria surfaced in the reasoning path.\\n"
310
- )
311
-
312
- return (
313
- chat_html,
314
- connectome_html,
315
- timeline_html,
316
- cards_html,
317
- hypothesis,
318
- build_models_html(model_name),
319
- )
320
-
321
-
322
- CSS = r"""
323
- :root {
324
- --bg: #ffffff;
325
- --bg2: #f8f8f8;
326
- --panel: rgba(248,248,248,.95);
327
- --panel-solid: #fafafa;
328
- --panel-2: rgba(245,245,245,.95);
329
- --line: rgba(0,0,0,.15);
330
- --soft: rgba(0,0,0,.60);
331
- --text: #000000;
332
- --muted: #555555;
333
- --gold: #ff6600;
334
- --teal: #7be7dd;
335
- --blue: #7ea9ff;
336
- --shadow: 0 20px 60px rgba(0,0,0,.38);
337
- --radius: 22px;
338
- }
339
-
340
- html, body, .gradio-container {
341
- background: #ffffff !important;
342
- font-family: Inter, ui-sans-serif, system-ui, sans-serif;
343
- }
344
-
345
- .gradio-container {
346
- max-width: 1550px !important;
347
- padding: 22px !important;
348
- }
349
-
350
- #dvnc-shell {
351
- border: 1px solid var(--line);
352
- border-radius: 28px;
353
- overflow: hidden;
354
- background: #ffffff;
355
- box-shadow: var(--shadow);
356
- backdrop-filter: blur(18px);
357
- padding: 20px 22px 22px;
358
- }
359
-
360
- .hero-bar {
361
- display:flex;
362
- justify-content:space-between;
363
- align-items:center;
364
- gap:16px;
365
- padding-bottom:12px;
366
- border-bottom:1px solid rgba(0,0,0,.06);
367
- margin-bottom:16px;
368
- }
369
-
370
- .brand {
371
- display:flex;
372
- align-items:center;
373
- gap:14px;
374
- }
375
-
376
- .logo {
377
- width:42px;
378
- height:42px;
379
- border-radius:14px;
380
- background: radial-gradient(circle at 20% 0%, rgba(242,210,147,.40), transparent 55%),
381
- radial-gradient(circle at 80% 80%, rgba(123,231,221,.32), transparent 60%);
382
- border:1px solid rgba(0,0,0,.08);
383
- display:grid;
384
- place-items:center;
385
- box-shadow:
386
- 0 0 0 1px rgba(255,255,255,.5),
387
- 0 10px 25px rgba(0,0,0,.3);
388
- }
389
-
390
- .logo svg {
391
- width:24px;
392
- height:24px;
393
- color: var(--gold);
394
- }
395
-
396
- .brand h1 {
397
- font-size: 1.05rem;
398
- margin:0;
399
- font-weight: 650;
400
- letter-spacing:.12em;
401
- text-transform:uppercase;
402
- }
403
-
404
- .brand p {
405
- margin:3px 0 0;
406
- color:var(--muted);
407
- font-size:.84rem;
408
- }
409
-
410
- .status {
411
- display:flex;
412
- gap:10px;
413
- align-items:center;
414
- color:var(--soft);
415
- font-size:.85rem;
416
- }
417
-
418
- .status-dot {
419
- width:10px;
420
- height:10px;
421
- border-radius:50%;
422
- background:var(--teal);
423
- box-shadow:0 0 0 6px rgba(123,231,221,.08),
424
- 0 0 18px rgba(123,231,221,.4);
425
- }
426
-
427
- .panel {
428
- background: #ffffff;
429
- border:1px solid var(--line);
430
- border-radius: 22px;
431
- box-shadow: inset 0 1px 0 rgba(0,0,0,.04);
432
- }
433
-
434
- .chat-panel {
435
- padding:18px;
436
- display:flex;
437
- flex-direction:column;
438
- gap:14px;
439
- min-height: 260px;
440
- }
441
-
442
- .querybox textarea,
443
- .querybox input {
444
- background: transparent !important;
445
- color: var(--text) !important;
446
- }
447
-
448
- .querybox, .querybox > div {
449
- background: rgba(255,255,255,.02) !important;
450
- border-radius: 18px !important;
451
- border-color: var(--line) !important;
452
- }
453
-
454
- .chat-thread {
455
- display:flex;
456
- flex-direction:column;
457
- gap:14px;
458
- }
459
-
460
- .bubble {
461
- max-width: 86%;
462
- padding:16px 18px;
463
- border-radius: 22px;
464
- position:relative;
465
- border:1px solid var(--line);
466
- }
467
-
468
- .bubble p {
469
- margin:8px 0 0;
470
- line-height:1.6;
471
- font-size:.96rem;
472
- }
473
-
474
- .bubble .role {
475
- font-size:.72rem;
476
- letter-spacing:.12em;
477
- text-transform:uppercase;
478
- color:var(--muted);
479
- }
480
-
481
- .bubble-user {
482
- align-self:flex-end;
483
- background: linear-gradient(135deg, rgba(126,169,255,.18), rgba(126,169,255,.08));
484
- }
485
-
486
- .bubble-ai {
487
- align-self:flex-start;
488
- background: linear-gradient(135deg, rgba(255,255,255,.06), rgba(255,255,255,.03));
489
- }
490
-
491
- .bubble-system {
492
- align-self:flex-start;
493
- background: linear-gradient(135deg, rgba(242,210,147,.12), rgba(242,210,147,.04));
494
- }
495
-
496
- .model-switcher {
497
- display:grid;
498
- grid-template-columns:repeat(3,1fr);
499
- gap:12px;
500
- }
501
-
502
- .model-pill {
503
- padding:14px;
504
- border:1px solid var(--line);
505
- border-radius:18px;
506
- background:rgba(255,255,255,.02);
507
- display:flex;
508
- flex-direction:column;
509
- gap:4px;
510
- min-height: 96px;
511
- }
512
-
513
- .model-pill.active {
514
- border-color: rgba(242,210,147,.45);
515
- background: linear-gradient(135deg, rgba(242,210,147,.14), rgba(255,255,255,.03));
516
- box-shadow: inset 0 0 0 1px rgba(242,210,147,.08);
517
- }
518
-
519
- .model-name {
520
- font-weight:650;
521
- }
522
-
523
- .model-tag {
524
- font-size:.76rem;
525
- text-transform:uppercase;
526
- letter-spacing:.12em;
527
- color:var(--gold);
528
- }
529
-
530
- .model-pill small {
531
- color:var(--muted);
532
- line-height:1.45;
533
- }
534
-
535
- .brain-shell {
536
- padding:18px;
537
- height:100%;
538
- }
539
-
540
- .brain-header {
541
- display:flex;
542
- justify-content:space-between;
543
- align-items:flex-end;
544
- gap:16px;
545
- margin-bottom:10px;
546
- }
547
-
548
- .eyebrow {
549
- font-size:.72rem;
550
- letter-spacing:.16em;
551
- text-transform:uppercase;
552
- color:var(--gold);
553
- margin:0 0 4px;
554
- }
555
-
556
- .brain-header h3 {
557
- margin:0;
558
- font-size:1.12rem;
559
- }
560
-
561
- .brain-legend {
562
- display:flex;
563
- gap:14px;
564
- color:var(--muted);
565
- font-size:.8rem;
566
- flex-wrap:wrap;
567
- }
568
-
569
- .dot {
570
- width:10px;
571
- height:10px;
572
- display:inline-block;
573
- border-radius:50%;
574
- margin-right:6px;
575
- }
576
-
577
- .dot-live {
578
- background:var(--gold);
579
- box-shadow:0 0 12px rgba(242,210,147,.65);
580
- }
581
-
582
- .dot-node {
583
- background:var(--teal);
584
- }
585
-
586
- .brain-stage {
587
- position:relative;
588
- min-height:360px;
589
- overflow:auto;
590
- background: #ffffff;
591
- border:1px solid rgba(0,0,0,.05);
592
- border-radius:20px;
593
- }
594
-
595
- .brain-svg {
596
- width:1200px;
597
- height:auto;
598
- display:block;
599
- margin:0 auto;
600
- min-height:500px;
601
- }
602
-
603
- .edge {
604
- stroke: rgba(132,153,153,.18);
605
- stroke-width:2;
606
- }
607
-
608
- .edge.active {
609
- stroke: rgba(242,210,147,.95);
610
- stroke-width:2.4;
611
- stroke-dasharray: 6 10;
612
- animation: pulseEdge 1.6s linear infinite;
613
- }
614
-
615
- .node.core { fill:#111111; }
616
- .node.domain { fill:#1a2332; }
617
- .node.bridge { fill:#142225; }
618
- .node.mechanism { fill:#261a24; }
619
- .node.outcome { fill:#232013; }
620
- .node.candidate { fill:#181b24; }
621
-
622
- .node.active {
623
- stroke:#fff7df;
624
- stroke-width:2.4;
625
- }
626
-
627
- .halo {
628
- fill:none;
629
- }
630
-
631
- .halo.active {
632
- stroke: rgba(242,210,147,.22);
633
- stroke-width:10;
634
- }
635
-
636
- .label {
637
- fill: #000000;
638
- font-size: 12px;
639
- letter-spacing: .02em;
640
- }
641
-
642
- .label.active {
643
- fill: #000000;
644
- }
645
-
646
- .timeline {
647
- display:flex;
648
- flex-direction:column;
649
- gap:10px;
650
- }
651
-
652
- .agent-step {
653
- display:grid;
654
- grid-template-columns:42px 1fr;
655
- gap:12px;
656
- padding:12px;
657
- border:1px solid var(--line);
658
- border-radius:18px;
659
- background:rgba(255,255,255,.02);
660
- }
661
-
662
- .agent-index {
663
- width:42px;
664
- height:42px;
665
- border-radius:14px;
666
- display:grid;
667
- place-items:center;
668
- font-weight:700;
669
- color:var(--gold);
670
- background:rgba(242,210,147,.08);
671
- border:1px solid rgba(242,210,147,.16);
672
- }
673
-
674
- .agent-head {
675
- display:flex;
676
- justify-content:space-between;
677
- gap:12px;
678
- align-items:center;
679
- margin-bottom:4px;
680
- }
681
-
682
- .agent-head h4 {
683
- margin:0;
684
- font-size:.98rem;
685
- }
686
-
687
- .agent-head span {
688
- font-size:.72rem;
689
- letter-spacing:.12em;
690
- text-transform:uppercase;
691
- color:var(--muted);
692
- }
693
-
694
- .agent-copy p {
695
- margin:0;
696
- color:#333333;
697
- font-size:.9rem;
698
- line-height:1.55;
699
- }
700
-
701
- .candidate-grid {
702
- display:grid;
703
- grid-template-columns: repeat(3, minmax(0, 1fr));
704
- gap:14px;
705
- }
706
-
707
- .candidate-card {
708
- background:none;
709
- perspective:1200px;
710
- min-height: 250px;
711
- }
712
-
713
- .candidate-card-inner {
714
- position:relative;
715
- width:100%;
716
- height:100%;
717
- min-height:250px;
718
- transition: transform .8s cubic-bezier(.2,.7,.1,1);
719
- transform-style:preserve-3d;
720
- }
721
-
722
- .candidate-card:hover .candidate-card-inner,
723
- .candidate-card:focus .candidate-card-inner,
724
- .candidate-card:focus-within .candidate-card-inner {
725
- transform: rotateY(180deg) translateY(-4px);
726
- }
727
-
728
- .candidate-face {
729
- position:absolute;
730
- inset:0;
731
- padding:18px;
732
- border-radius:22px;
733
- border:1px solid var(--line);
734
- background: linear-gradient(180deg, rgba(19,22,29,.96), rgba(11,13,18,.96));
735
- backface-visibility:hidden;
736
- box-shadow: inset 0 1px 0 rgba(255,255,255,.04), var(--shadow);
737
- display:flex;
738
- flex-direction:column;
739
- gap:12px;
740
- }
741
-
742
- .candidate-back {
743
- transform: rotateY(180deg);
744
- background: linear-gradient(180deg, rgba(29,23,16,.96), rgba(12,11,10,.96));
745
- }
746
-
747
- .candidate-top {
748
- display:flex;
749
- justify-content:space-between;
750
- align-items:center;
751
- gap:8px;
752
- }
753
-
754
- .chip {
755
- font-size:.72rem;
756
- text-transform:uppercase;
757
- letter-spacing:.12em;
758
- color:var(--teal);
759
- padding:7px 10px;
760
- border-radius:999px;
761
- background:rgba(123,231,221,.08);
762
- border:1px solid rgba(123,231,221,.18);
763
- }
764
-
765
- .chip.alt {
766
- color:var(--gold);
767
- background:rgba(242,210,147,.08);
768
- border-color: rgba(242,210,147,.18);
769
- }
770
-
771
- .score {
772
- font-weight:700;
773
- color:var(--gold);
774
- }
775
-
776
- .candidate-face h4 {
777
- margin:0;
778
- font-size:1rem;
779
- line-height:1.3;
780
- color:#f5f3ea;
781
- }
782
-
783
- .candidate-face p {
784
- margin:0;
785
- color:#cbd3e2;
786
- line-height:1.6;
787
- font-size:.92rem;
788
- }
789
-
790
- .meta-row {
791
- margin-top:auto;
792
- display:flex;
793
- justify-content:space-between;
794
- color:var(--muted);
795
- font-size:.84rem;
796
- }
797
-
798
- .mini {
799
- margin-top:8px;
800
- align-self:flex-start;
801
- color:#f9f6ef;
802
- padding:10px 12px;
803
- border-radius:14px;
804
- border:1px solid var(--line);
805
- background:rgba(255,255,255,.03);
806
- }
807
-
808
- .prosebox {
809
- padding:18px;
810
- white-space:pre-wrap;
811
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
812
- line-height:1.55;
813
- color:#1b1b1b;
814
- }
815
-
816
- .gr-button-primary {
817
- background: linear-gradient(135deg, rgba(242,210,147,.92), rgba(224,178,92,.92)) !important;
818
- color:#1b1408 !important;
819
- border: none !important;
820
- }
821
-
822
- .gr-button-secondary {
823
- background: rgba(255,255,255,.05) !important;
824
- color: var(--text) !important;
825
- border: 1px solid var(--line) !important;
826
- }
827
-
828
- footer {
829
- display:none !important;
830
- }
831
-
832
- @keyframes pulseEdge {
833
- to { stroke-dashoffset: -32; }
834
- }
835
-
836
- @media (max-width: 1180px){
837
- .candidate-grid,
838
- .model-switcher{
839
- grid-template-columns:1fr;
840
- }
841
- }
842
- """
843
-
844
-
845
- HEAD = """
846
- //fonts.googleapis.com">
847
- //fonts.gstatic.com" crossorigin>
848
- //fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
849
- """
850
-
851
-
852
- with gr.Blocks(css=CSS, head=HEAD, theme=gr.themes.Base(), fill_height=True) as demo:
853
- gr.HTML(
854
- """
855
- <div id="dvnc-shell">
856
- <div class="hero-bar">
857
- <div class="brand">
858
- <div class="logo" aria-hidden="true">
859
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7">
860
- <path d="M5 17L12 4l7 13"/>
861
- <path d="M8.5 12.5h7"/>
862
- ircle cx="12" cy="12" r="1.8" fill="currentColor" stroke="none"/>
863
- </svg>
864
- </div>
865
- <div>
866
- <h1>DVNC.AI</h1>
867
- <p>Sovereign discovery instrument · connectome-native reasoning</p>
868
- </div>
869
- </div>
870
- <div class="status">
871
- <span class="status-dot"></span>
872
- <span>Live orchestration</span>
873
- </div>
874
- </div>
875
- </div>
876
- """
877
- )
878
-
879
- model_html = gr.HTML(build_models_html("DVNC Sovereign"))
880
-
881
- with gr.Row():
882
- with gr.Column(scale=2):
883
- model = gr.Dropdown(
884
- choices=[m["name"] for m in MODELS],
885
- value="DVNC Sovereign",
886
- label="Model tier",
887
- )
888
- query = gr.Textbox(
889
- label="Discovery query",
890
- elem_classes=["querybox"],
891
- placeholder="Enter a scientific question, anomaly, or breakthrough direction…",
892
- lines=4,
893
- )
894
- with gr.Row():
895
- run_btn = gr.Button("Run discovery", variant="primary")
896
- example_btn = gr.Button("Load example", variant="secondary")
897
- chat = gr.HTML(
898
- """
899
- <div class="panel chat-panel">
900
- <div class="chat-thread">
901
- <div class="bubble bubble-ai">
902
- <span class="role">DVNC</span>
903
- <p>Enter a query to activate the 7-agent discovery stack and illuminate the chosen path through the concept brain.</p>
904
- </div>
905
- </div>
906
- </div>
907
- """
908
- )
909
- with gr.Column(scale=3):
910
- connectome = gr.HTML(build_connectome_html(DEFAULT_PATH))
911
- timeline = gr.HTML(
912
- '<div class="panel" style="padding:18px"><div class="timeline"></div></div>'
913
- )
914
- cards = gr.HTML(
915
- '<div class="panel" style="padding:18px"><div class="candidate-grid"></div></div>'
916
- )
917
-
918
- output = gr.Markdown("# Discovery Output\n\nAwaiting query.")
919
-
920
- def load_example():
921
- return (
922
- "How could a self-assembling conductive biomaterial improve cardiac tissue "
923
- "regeneration by converting mechanical strain into repair signalling?"
924
- )
925
-
926
- example_btn.click(fn=load_example, outputs=query)
927
- run_btn.click(
928
- fn=run_discovery,
929
- inputs=[query, model],
930
- outputs=[chat, connectome, timeline, cards, output, model_html],
931
- )
932
-
933
-
934
- if __name__ == "__main__":
935
- demo.launch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dvnc_ai_v2_hf/deprecated/app_old7.py DELETED
@@ -1,907 +0,0 @@
1
- import json
2
- import math
3
- import random
4
- import gradio as gr
5
-
6
-
7
- MODELS = [
8
- {"name": "DVNC Sovereign", "tag": "flagship", "desc": "Maximum depth orchestration for frontier discovery"},
9
- {"name": "DVNC Atlas", "tag": "research", "desc": "Balanced reasoning, graph traversal, and synthesis"},
10
- {"name": "DVNC Curie", "tag": "lab", "desc": "Experimental hypothesis generation for anomalous signals"},
11
- ]
12
-
13
-
14
- AGENTS = [
15
- "Query Interpreter",
16
- "Graph Divergence Mapper",
17
- "Evidence Harvester",
18
- "Analogy Engine",
19
- "Hypothesis Composer",
20
- "Adversarial Critic",
21
- "Experimental Program Designer",
22
- ]
23
-
24
-
25
- NODES = [
26
- {"id": "seed", "label": "Seed Query", "group": "core", "x": 10, "y": 0, "z": 0},
27
- {"id": "bio", "label": "Biomaterials", "group": "domain", "x": 24, "y": 12, "z": -8},
28
- {"id": "card", "label": "Cardiac Repair", "group": "domain", "x": 38, "y": 3, "z": 14},
29
- {"id": "nano", "label": "Nanostructure", "group": "bridge", "x": 24, "y": -18, "z": 16},
30
- {"id": "selfasm", "label": "Self-Assembly", "group": "bridge", "x": 40, "y": -16, "z": -16},
31
- {"id": "electro", "label": "Electro-signalling", "group": "mechanism", "x": 58, "y": 10, "z": -10},
32
- {"id": "immune", "label": "Immune Modulation", "group": "mechanism", "x": 64, "y": -8, "z": 10},
33
- {"id": "trial", "label": "Validation Path", "group": "outcome", "x": 80, "y": 0, "z": 0},
34
- {"id": "alt1", "label": "Piezoelectric Scaffold", "group": "candidate", "x": 56, "y": 26, "z": 14},
35
- {"id": "alt2", "label": "Peptide Mesh", "group": "candidate", "x": 54, "y": -27, "z": -14},
36
- ]
37
-
38
-
39
- EDGES = [
40
- ("seed", "bio"), ("seed", "nano"), ("bio", "card"), ("nano", "selfasm"),
41
- ("selfasm", "electro"), ("card", "immune"), ("electro", "trial"), ("immune", "trial"),
42
- ("card", "alt1"), ("selfasm", "alt2"), ("alt1", "trial"), ("alt2", "trial")
43
- ]
44
-
45
-
46
- DEFAULT_PATH = ["seed", "nano", "selfasm", "electro", "trial"]
47
-
48
-
49
- CANDIDATES = [
50
- {
51
- "title": "Piezoelectric Scaffold Cascade",
52
- "front": "Use mechano-electric scaffolds to convert cardiac strain into micro-current signalling.",
53
- "back": "Discovery path: anomalous healing signal -> piezoelectric analog -> ion-channel entrainment -> tissue regeneration. Risk: power density and fibrosis coupling.",
54
- "score": 92,
55
- "novelty": "High",
56
- "agent": "Hypothesis Composer"
57
- },
58
- {
59
- "title": "Peptide Self-Assembly Mesh",
60
- "front": "Deploy dynamic peptide meshes that self-assemble around damaged myocardium and guide repair.",
61
- "back": "Discovery path: self-assembly -> local immune choreography -> regenerative substrate formation. Risk: degradation timing and targeting specificity.",
62
- "score": 88,
63
- "novelty": "High",
64
- "agent": "Analogy Engine"
65
- },
66
- {
67
- "title": "Immune-Tuned Conductive Hydrogel",
68
- "front": "Blend conductivity with macrophage-state modulation to reduce scarring and restore conduction.",
69
- "back": "Discovery path: inflammation mismatch -> conductive medium -> macrophage polarization -> synchronized healing. Risk: persistence and biocompatibility.",
70
- "score": 85,
71
- "novelty": "Medium-High",
72
- "agent": "Adversarial Critic"
73
- }
74
- ]
75
-
76
-
77
- def build_connectome_html(path_ids):
78
- active = set(path_ids)
79
- node_map = {n["id"]: n for n in NODES}
80
- path_pairs = set()
81
- for i in range(len(path_ids) - 1):
82
- path_pairs.add((path_ids[i], path_ids[i + 1]))
83
- path_pairs.add((path_ids[i + 1], path_ids[i]))
84
-
85
- lines = []
86
- active_lines = []
87
- for a, b in EDGES:
88
- na, nb = node_map[a], node_map[b]
89
- x1, y1 = na["x"] * 8 + 80, na["y"] * 6 + 280
90
- x2, y2 = nb["x"] * 8 + 80, nb["y"] * 6 + 280
91
- lines.append(
92
- f'<line class="edge" x1="{x1:.1f}" y1="{y1:.1f}" x2="{x2:.1f}" y2="{y2:.1f}" />'
93
- )
94
- if (a, b) in path_pairs:
95
- active_lines.append(
96
- f'<line class="edge active" x1="{x1:.1f}" y1="{y1:.1f}" x2="{x2:.1f}" y2="{y2:.1f}" />'
97
- )
98
-
99
- circles = []
100
- labels = []
101
- for n in NODES:
102
- cx = n["x"] * 8 + 80
103
- cy = n["y"] * 6 + 280
104
- is_active = n["id"] in active
105
- cls = f'node {n["group"]} {'chosen' if is_active else 'idle'}'
106
- halo_cls = f'halo {'active' if is_active else ''}'
107
- circles.append(
108
- f'<g class="node-wrap">'
109
- f'<circle class="{halo_cls}" cx="{cx:.1f}" cy="{cy:.1f}" r="{30 if is_active else 0}" />'
110
- f'<circle class="{cls}" cx="{cx:.1f}" cy="{cy:.1f}" r="{18 if is_active else 13}" />'
111
- f'</g>'
112
- )
113
- labels.append(
114
- f'<text class="label {'active' if is_active else ''}" x="{cx + 18:.1f}" y="{cy - 16:.1f}">{n["label"]}</text>'
115
- )
116
-
117
- return f"""
118
- <div class="panel brain-shell">
119
- <div class="brain-header">
120
- <div>
121
- <p class="eyebrow">Connectome</p>
122
- <h3>3D Connectome</h3>
123
- </div>
124
- <div class="brain-legend">
125
- <span><i class="dot dot-live"></i> lit path</span>
126
- <span><i class="dot dot-chosen"></i> chosen node</span>
127
- <span><i class="dot dot-idle"></i> available node</span>
128
- </div>
129
- </div>
130
- <div class="brain-stage">
131
- <svg viewBox="0 0 780 560" class="brain-svg" role="img" aria-label="DVNC 3D connectome visualisation">
132
- {''.join(lines)}
133
- {''.join(active_lines)}
134
- {''.join(circles)}
135
- {''.join(labels)}
136
- </svg>
137
- </div>
138
- </div>
139
- """
140
-
141
-
142
- def build_cards_html(cards):
143
- items = []
144
- for c in cards:
145
- items.append(
146
- f"""
147
- <article class="candidate-card" tabindex="0">
148
- <div class="candidate-card-inner">
149
- <div class="candidate-face candidate-front">
150
- <div class="candidate-top">
151
- <span class="chip">{c['agent']}</span>
152
- <span class="score">{c['score']}</span>
153
- </div>
154
- <h4>{c['title']}</h4>
155
- <p>{c['front']}</p>
156
- <div class="meta-row"><span>Novelty</span><strong>{c['novelty']}</strong></div>
157
- <button class="mini" type="button">Flip insight</button>
158
- </div>
159
- <div class="candidate-face candidate-back">
160
- <div class="candidate-top">
161
- <span class="chip alt">Alternative path</span>
162
- <span class="score">{c['score']}</span>
163
- </div>
164
- <h4>{c['title']}</h4>
165
- <p>{c['back']}</p>
166
- <div class="meta-row"><span>Swap into route</span><strong>Enabled</strong></div>
167
- <button class="mini" type="button">Return</button>
168
- </div>
169
- </div>
170
- </article>
171
- """
172
- )
173
- return '<div class="panel" style="padding:20px;"><div class="candidate-grid">' + ''.join(items) + '</div></div>'
174
-
175
-
176
- def build_agent_timeline(reasoning):
177
- rows = []
178
- for r in reasoning:
179
- rows.append(
180
- f"""
181
- <details class="agent-step" {'open' if r['step'] == 1 else ''}>
182
- <summary class="agent-summary">
183
- <div class="agent-index">{r['step']}</div>
184
- <div class="agent-head">
185
- <h4>{r['agent']}</h4>
186
- <span>{r['tag']}</span>
187
- </div>
188
- </summary>
189
- <div class="agent-copy">
190
- <p>{r['summary']}</p>
191
- </div>
192
- </details>
193
- """
194
- )
195
- return '<div class="panel" style="padding:18px;"><div class="timeline">' + ''.join(rows) + '</div></div>'
196
-
197
-
198
- def build_chat_html(query, result):
199
- return f"""
200
- <div class="panel chat-panel">
201
- <div class="chat-thread">
202
- <div class="bubble bubble-user">
203
- <span class="role">You</span>
204
- <p>{query}</p>
205
- </div>
206
- <div class="bubble bubble-ai">
207
- <span class="role">DVNC Sovereign</span>
208
- <p>{result['summary']}</p>
209
- </div>
210
- <div class="bubble bubble-system">
211
- <span class="role">Discovery Signal</span>
212
- <p><strong>Primary hypothesis:</strong> {result['primary_hypothesis']}</p>
213
- </div>
214
- </div>
215
- </div>
216
- """
217
-
218
-
219
- def build_models_html(selected):
220
- items = []
221
- for m in MODELS:
222
- active = "active" if m["name"] == selected else ""
223
- items.append(
224
- f"""
225
- <div class="model-pill {active}">
226
- <span class="model-name">{m['name']}</span>
227
- <span class="model-tag">{m['tag']}</span>
228
- <small>{m['desc']}</small>
229
- </div>
230
- """
231
- )
232
- return '<div class="panel" style="padding:18px;"><div class="model-switcher">' + ''.join(items) + '</div></div>'
233
-
234
-
235
- def run_discovery(query, model_name):
236
- random.seed(len(query) + len(model_name))
237
-
238
- if "curie" in query.lower() or "einstein" in query.lower():
239
- primary = "Map the anomaly first, then force a distant analogy before composing the experimental programme."
240
- path = ["seed", "bio", "card", "immune", "trial"]
241
- else:
242
- primary = "Use a self-assembling conductive scaffold that transforms mechanical strain into local regenerative signalling."
243
- path = DEFAULT_PATH
244
-
245
- summaries = [
246
- "Normalises the user prompt into a graph-searchable seed and isolates the tension inside the question.",
247
- "Finds remote conceptual bridges instead of staying near the starting domain cluster.",
248
- "Pulls evidence packets and conflict signals required for grounded hypothesis formation.",
249
- "Generates cross-domain analogies with a bias toward mechanism transfer rather than keyword similarity.",
250
- "Composes the lead hypothesis and two structurally different variants.",
251
- "Attacks weak assumptions, hidden confounders, and feasibility gaps.",
252
- "Produces a staged validation plan with measurable falsification criteria."
253
- ]
254
-
255
- reasoning = [
256
- {
257
- "step": i + 1,
258
- "agent": AGENTS[i],
259
- "tag": ["input", "graph", "evidence", "analogy", "compose", "critique", "experiment"][i],
260
- "summary": summaries[i],
261
- }
262
- for i in range(7)
263
- ]
264
-
265
- result = {
266
- "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.",
267
- "primary_hypothesis": primary,
268
- "reasoning": reasoning,
269
- "cards": CANDIDATES,
270
- "path": path,
271
- "metrics": {
272
- "Novelty": 93,
273
- "Mechanistic clarity": 89,
274
- "Experimental tractability": 82,
275
- "Cross-domain distance": 91,
276
- },
277
- }
278
-
279
- chat_html = build_chat_html(query, result)
280
- connectome_html = build_connectome_html(path)
281
- cards_html = build_cards_html(CANDIDATES)
282
- timeline_html = build_agent_timeline(reasoning)
283
-
284
- metrics = "\n".join([f"- {k}: {v}/100" for k, v in result["metrics"].items()])
285
- hypothesis = (
286
- "# Discovery Output\n\n"
287
- f"**Model:** {model_name}\n\n"
288
- f"**Primary hypothesis:** {result['primary_hypothesis']}\n\n"
289
- "## Scoring\n"
290
- f"{metrics}\n\n"
291
- "## Experimental outline\n"
292
- "1. Construct the candidate material or protocol.\n"
293
- "2. Test mechanistic signal expression under controlled conditions.\n"
294
- "3. Compare against baseline and nearest-neighbour alternatives.\n"
295
- "4. Falsify using the adversarial risk criteria surfaced in the reasoning path.\n"
296
- )
297
-
298
- return chat_html, connectome_html, timeline_html, cards_html, hypothesis, build_models_html(model_name)
299
-
300
-
301
- CSS = r"""
302
- :root {
303
- --bg: #ffffff;
304
- --panel: #ffffff;
305
- --line: rgba(0, 0, 0, 0.12);
306
- --text: #111111;
307
- --muted: #5b5b5b;
308
- --soft: rgba(0, 0, 0, 0.62);
309
- --gold: #ff6600;
310
- --teal: #17b8a6;
311
- --blue: #628dff;
312
- --chosen: #ff7a1a;
313
- --idle: #b8d8ff;
314
- --idle-stroke: #5e8fe6;
315
- --shadow: 0 16px 40px rgba(0, 0, 0, 0.12);
316
- }
317
-
318
- html, body, .gradio-container {
319
- background: #ffffff !important;
320
- font-family: Inter, ui-sans-serif, system-ui, sans-serif;
321
- }
322
-
323
- .gradio-container {
324
- max-width: 1640px !important;
325
- padding: 20px !important;
326
- }
327
-
328
- #dvnc-shell {
329
- border: 1px solid var(--line);
330
- border-radius: 28px;
331
- overflow: hidden;
332
- background: #ffffff;
333
- box-shadow: var(--shadow);
334
- padding: 20px 22px 22px;
335
- }
336
-
337
- .hero-bar {
338
- display: flex;
339
- justify-content: space-between;
340
- align-items: center;
341
- gap: 16px;
342
- padding-bottom: 12px;
343
- border-bottom: 1px solid rgba(0, 0, 0, 0.06);
344
- margin-bottom: 16px;
345
- }
346
-
347
- .brand {
348
- display: flex;
349
- align-items: center;
350
- gap: 14px;
351
- }
352
-
353
- .logo {
354
- width: 42px;
355
- height: 42px;
356
- border-radius: 14px;
357
- display: grid;
358
- place-items: center;
359
- color: var(--gold);
360
- background: linear-gradient(135deg, rgba(255, 122, 26, 0.12), rgba(23, 184, 166, 0.10));
361
- border: 1px solid rgba(0, 0, 0, 0.08);
362
- }
363
-
364
- .logo svg { width: 24px; height: 24px; }
365
-
366
- .brand h1 {
367
- font-size: 1.05rem;
368
- margin: 0;
369
- font-weight: 700;
370
- letter-spacing: .12em;
371
- text-transform: uppercase;
372
- }
373
-
374
- .brand p {
375
- margin: 3px 0 0;
376
- color: var(--muted);
377
- font-size: .84rem;
378
- }
379
-
380
- .status {
381
- display: flex;
382
- gap: 10px;
383
- align-items: center;
384
- color: var(--soft);
385
- font-size: .85rem;
386
- }
387
-
388
- .status-dot {
389
- width: 10px;
390
- height: 10px;
391
- border-radius: 50%;
392
- background: var(--teal);
393
- box-shadow: 0 0 0 6px rgba(23, 184, 166, 0.10), 0 0 14px rgba(23, 184, 166, 0.25);
394
- }
395
-
396
- .panel {
397
- background: #ffffff;
398
- border: 1px solid var(--line);
399
- border-radius: 22px;
400
- box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
401
- }
402
-
403
- .querybox textarea,
404
- .querybox input {
405
- background: transparent !important;
406
- color: var(--text) !important;
407
- }
408
-
409
- .querybox,
410
- .querybox > div {
411
- background: #ffffff !important;
412
- border-radius: 18px !important;
413
- border-color: var(--line) !important;
414
- }
415
-
416
- .chat-panel {
417
- padding: 18px;
418
- min-height: 280px;
419
- }
420
-
421
- .chat-thread {
422
- display: flex;
423
- flex-direction: column;
424
- gap: 14px;
425
- }
426
-
427
- .bubble {
428
- max-width: 88%;
429
- padding: 16px 18px;
430
- border-radius: 22px;
431
- border: 1px solid var(--line);
432
- }
433
-
434
- .bubble p {
435
- margin: 8px 0 0;
436
- line-height: 1.6;
437
- font-size: .96rem;
438
- color: var(--text);
439
- }
440
-
441
- .bubble .role {
442
- font-size: .72rem;
443
- letter-spacing: .12em;
444
- text-transform: uppercase;
445
- color: var(--muted);
446
- }
447
-
448
- .bubble-user {
449
- align-self: flex-end;
450
- background: linear-gradient(135deg, rgba(98, 141, 255, 0.16), rgba(98, 141, 255, 0.08));
451
- }
452
-
453
- .bubble-ai {
454
- align-self: flex-start;
455
- background: #ffffff;
456
- }
457
-
458
- .bubble-system {
459
- align-self: flex-start;
460
- background: linear-gradient(135deg, rgba(255, 122, 26, 0.10), rgba(255, 122, 26, 0.04));
461
- }
462
-
463
- .model-switcher {
464
- display: grid;
465
- grid-template-columns: repeat(3, 1fr);
466
- gap: 12px;
467
- }
468
-
469
- .model-pill {
470
- padding: 14px;
471
- border: 1px solid var(--line);
472
- border-radius: 18px;
473
- display: flex;
474
- flex-direction: column;
475
- gap: 4px;
476
- min-height: 98px;
477
- background: #ffffff;
478
- }
479
-
480
- .model-pill.active {
481
- border-color: rgba(255, 122, 26, 0.40);
482
- background: linear-gradient(135deg, rgba(255, 122, 26, 0.10), rgba(255, 255, 255, 0.96));
483
- }
484
-
485
- .model-name { font-weight: 650; color: var(--text); }
486
- .model-tag {
487
- font-size: .76rem;
488
- text-transform: uppercase;
489
- letter-spacing: .12em;
490
- color: var(--gold);
491
- }
492
- .model-pill small { color: var(--muted); line-height: 1.45; }
493
-
494
- .brain-shell { padding: 18px; }
495
-
496
- .brain-header {
497
- display: flex;
498
- justify-content: space-between;
499
- align-items: flex-end;
500
- gap: 16px;
501
- margin-bottom: 10px;
502
- }
503
-
504
- .eyebrow {
505
- font-size: .72rem;
506
- letter-spacing: .16em;
507
- text-transform: uppercase;
508
- color: var(--gold);
509
- margin: 0 0 4px;
510
- }
511
-
512
- .brain-header h3 {
513
- margin: 0;
514
- font-size: 1.12rem;
515
- color: var(--text);
516
- }
517
-
518
- .brain-legend {
519
- display: flex;
520
- gap: 14px;
521
- color: var(--muted);
522
- font-size: .8rem;
523
- flex-wrap: wrap;
524
- }
525
-
526
- .dot {
527
- width: 10px;
528
- height: 10px;
529
- display: inline-block;
530
- border-radius: 50%;
531
- margin-right: 6px;
532
- }
533
-
534
- .dot-live {
535
- background: var(--chosen);
536
- box-shadow: 0 0 10px rgba(255, 122, 26, 0.35);
537
- }
538
-
539
- .dot-chosen {
540
- background: var(--chosen);
541
- }
542
-
543
- .dot-idle {
544
- background: var(--idle);
545
- border: 1px solid var(--idle-stroke);
546
- }
547
-
548
- .brain-stage {
549
- position: relative;
550
- min-height: 420px;
551
- overflow: hidden;
552
- background: linear-gradient(180deg, rgba(250, 250, 250, 1), rgba(255, 255, 255, 1));
553
- border: 1px solid rgba(0, 0, 0, 0.05);
554
- border-radius: 20px;
555
- }
556
-
557
- .brain-svg {
558
- width: 100%;
559
- height: 520px;
560
- display: block;
561
- }
562
-
563
- .edge {
564
- stroke: rgba(0, 0, 0, 0.12);
565
- stroke-width: 2.4;
566
- }
567
-
568
- .edge.active {
569
- stroke: var(--chosen);
570
- stroke-width: 4.2;
571
- stroke-linecap: round;
572
- filter: drop-shadow(0 0 6px rgba(255, 122, 26, 0.45));
573
- stroke-dasharray: 8 12;
574
- animation: pulseEdge 1.5s linear infinite;
575
- }
576
-
577
- .node {
578
- stroke-width: 2.2;
579
- transition: all .25s ease;
580
- }
581
-
582
- .node.idle {
583
- fill: var(--idle);
584
- stroke: var(--idle-stroke);
585
- }
586
-
587
- .node.chosen {
588
- fill: var(--chosen);
589
- stroke: #ffb16d;
590
- }
591
-
592
- .halo { fill: none; }
593
- .halo.active {
594
- stroke: rgba(255, 122, 26, 0.18);
595
- stroke-width: 12;
596
- }
597
-
598
- .label {
599
- fill: #2c2c2c;
600
- font-size: 13px;
601
- font-weight: 500;
602
- letter-spacing: .01em;
603
- }
604
-
605
- .label.active {
606
- fill: #111111;
607
- font-weight: 700;
608
- }
609
-
610
- .timeline {
611
- display: flex;
612
- flex-direction: column;
613
- gap: 10px;
614
- }
615
-
616
- .agent-step {
617
- border: 1px solid var(--line);
618
- border-radius: 18px;
619
- background: #ffffff;
620
- overflow: hidden;
621
- }
622
-
623
- .agent-summary {
624
- list-style: none;
625
- display: grid;
626
- grid-template-columns: 42px 1fr;
627
- gap: 12px;
628
- align-items: center;
629
- padding: 12px;
630
- cursor: pointer;
631
- }
632
-
633
- .agent-summary::-webkit-details-marker { display: none; }
634
-
635
- .agent-index {
636
- width: 42px;
637
- height: 42px;
638
- border-radius: 14px;
639
- display: grid;
640
- place-items: center;
641
- font-weight: 700;
642
- color: var(--gold);
643
- background: rgba(255, 122, 26, 0.08);
644
- border: 1px solid rgba(255, 122, 26, 0.18);
645
- }
646
-
647
- .agent-head {
648
- display: flex;
649
- justify-content: space-between;
650
- gap: 12px;
651
- align-items: center;
652
- }
653
-
654
- .agent-head h4 {
655
- margin: 0;
656
- font-size: .98rem;
657
- color: var(--text);
658
- }
659
-
660
- .agent-head span {
661
- font-size: .72rem;
662
- letter-spacing: .12em;
663
- text-transform: uppercase;
664
- color: var(--muted);
665
- }
666
-
667
- .agent-copy {
668
- padding: 0 14px 16px 66px;
669
- }
670
-
671
- .agent-copy p {
672
- margin: 0;
673
- color: #2d2d2d;
674
- font-size: .93rem;
675
- line-height: 1.6;
676
- }
677
-
678
- .candidate-grid {
679
- display: grid;
680
- grid-template-columns: repeat(3, minmax(0, 1fr));
681
- gap: 18px;
682
- }
683
-
684
- .candidate-card {
685
- background: none;
686
- perspective: 1400px;
687
- min-height: 330px;
688
- }
689
-
690
- .candidate-card-inner {
691
- position: relative;
692
- width: 100%;
693
- min-height: 330px;
694
- transition: transform .8s cubic-bezier(.2,.7,.1,1);
695
- transform-style: preserve-3d;
696
- }
697
-
698
- .candidate-card:hover .candidate-card-inner,
699
- .candidate-card:focus .candidate-card-inner,
700
- .candidate-card:focus-within .candidate-card-inner {
701
- transform: rotateY(180deg);
702
- }
703
-
704
- .candidate-face {
705
- position: absolute;
706
- inset: 0;
707
- padding: 20px;
708
- border-radius: 22px;
709
- border: 1px solid var(--line);
710
- background: #ffffff;
711
- color: var(--text);
712
- backface-visibility: hidden;
713
- box-shadow: 0 12px 24px rgba(0, 0, 0, 0.06);
714
- display: flex;
715
- flex-direction: column;
716
- gap: 14px;
717
- }
718
-
719
- .candidate-back {
720
- transform: rotateY(180deg);
721
- background: #ffffff;
722
- }
723
-
724
- .candidate-top {
725
- display: flex;
726
- justify-content: space-between;
727
- align-items: center;
728
- gap: 8px;
729
- }
730
-
731
- .chip {
732
- font-size: .72rem;
733
- text-transform: uppercase;
734
- letter-spacing: .12em;
735
- color: #0b6f66;
736
- padding: 7px 10px;
737
- border-radius: 999px;
738
- background: rgba(23, 184, 166, 0.08);
739
- border: 1px solid rgba(23, 184, 166, 0.18);
740
- }
741
-
742
- .chip.alt {
743
- color: var(--gold);
744
- background: rgba(255, 122, 26, 0.08);
745
- border-color: rgba(255, 122, 26, 0.18);
746
- }
747
-
748
- .score {
749
- font-weight: 700;
750
- color: var(--gold);
751
- }
752
-
753
- .candidate-face h4 {
754
- margin: 0;
755
- font-size: 1.08rem;
756
- line-height: 1.35;
757
- color: var(--text);
758
- }
759
-
760
- .candidate-face p {
761
- margin: 0;
762
- color: #1e1e1e;
763
- line-height: 1.65;
764
- font-size: .96rem;
765
- overflow-wrap: anywhere;
766
- }
767
-
768
- .meta-row {
769
- margin-top: auto;
770
- display: flex;
771
- justify-content: space-between;
772
- color: var(--muted);
773
- font-size: .88rem;
774
- gap: 14px;
775
- }
776
-
777
- .mini {
778
- margin-top: 8px;
779
- align-self: flex-start;
780
- color: var(--text);
781
- padding: 10px 12px;
782
- border-radius: 14px;
783
- border: 1px solid var(--line);
784
- background: #ffffff;
785
- }
786
-
787
- .prosebox {
788
- padding: 18px;
789
- white-space: pre-wrap;
790
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
791
- line-height: 1.55;
792
- color: #1b1b1b;
793
- }
794
-
795
- .gr-button-primary {
796
- background: linear-gradient(135deg, rgba(255, 122, 26, 0.92), rgba(240, 108, 22, 0.92)) !important;
797
- color: #ffffff !important;
798
- border: none !important;
799
- }
800
-
801
- .gr-button-secondary {
802
- background: #ffffff !important;
803
- color: var(--text) !important;
804
- border: 1px solid var(--line) !important;
805
- }
806
-
807
- footer { display: none !important; }
808
-
809
- @keyframes pulseEdge {
810
- to { stroke-dashoffset: -40; }
811
- }
812
-
813
- @media (max-width: 1180px) {
814
- .model-switcher,
815
- .candidate-grid {
816
- grid-template-columns: 1fr;
817
- }
818
-
819
- .brain-svg {
820
- height: 460px;
821
- }
822
- }
823
- """
824
-
825
-
826
- HEAD = """
827
- <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">
828
- <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>
829
- <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap\" rel=\"stylesheet\">
830
- """
831
-
832
-
833
- with gr.Blocks(css=CSS, head=HEAD, theme=gr.themes.Base(), fill_height=True) as demo:
834
- gr.HTML(
835
- """
836
- <div id=\"dvnc-shell\">
837
- <div class=\"hero-bar\">
838
- <div class=\"brand\">
839
- <div class=\"logo\" aria-hidden=\"true\">
840
- <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\">
841
- <path d=\"M5 17L12 4l7 13\"/>
842
- <path d=\"M8.5 12.5h7\"/>
843
- <circle cx=\"12\" cy=\"12\" r=\"1.8\" fill=\"currentColor\" stroke=\"none\"/>
844
- </svg>
845
- </div>
846
- <div>
847
- <h1>DVNC.AI</h1>
848
- <p>Sovereign discovery instrument · connectome-native reasoning</p>
849
- </div>
850
- </div>
851
- <div class=\"status\"><span class=\"status-dot\"></span><span>Live orchestration</span></div>
852
- </div>
853
- </div>
854
- """
855
- )
856
-
857
- model_html = gr.HTML(build_models_html("DVNC Sovereign"))
858
-
859
- with gr.Row():
860
- with gr.Column(scale=2):
861
- model = gr.Dropdown(
862
- choices=[m["name"] for m in MODELS],
863
- value="DVNC Sovereign",
864
- label="Model tier",
865
- )
866
- query = gr.Textbox(
867
- label="Discovery query",
868
- elem_classes=["querybox"],
869
- placeholder="Enter a scientific question, anomaly, or breakthrough direction…",
870
- lines=4,
871
- )
872
- with gr.Row():
873
- run_btn = gr.Button("Run discovery", variant="primary")
874
- example_btn = gr.Button("Load example", variant="secondary")
875
- chat = gr.HTML(
876
- """
877
- <div class=\"panel chat-panel\">
878
- <div class=\"chat-thread\">
879
- <div class=\"bubble bubble-ai\">
880
- <span class=\"role\">DVNC</span>
881
- <p>Enter a query to activate the 7-agent discovery stack and illuminate the chosen path through the 3D connectome.</p>
882
- </div>
883
- </div>
884
- </div>
885
- """
886
- )
887
- timeline = gr.HTML('<div class="panel" style="padding:18px"><div class="timeline"></div></div>')
888
-
889
- with gr.Column(scale=3):
890
- connectome = gr.HTML(build_connectome_html(DEFAULT_PATH))
891
- cards = gr.HTML('<div class="panel" style="padding:20px"><div class="candidate-grid"></div></div>')
892
-
893
- output = gr.Markdown("# Discovery Output\n\nAwaiting query.")
894
-
895
- def load_example():
896
- return "How could a self-assembling conductive biomaterial improve cardiac tissue regeneration by converting mechanical strain into repair signalling?"
897
-
898
- example_btn.click(fn=load_example, outputs=query)
899
- run_btn.click(
900
- fn=run_discovery,
901
- inputs=[query, model],
902
- outputs=[chat, connectome, timeline, cards, output, model_html],
903
- )
904
-
905
-
906
- if __name__ == "__main__":
907
- demo.launch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dvnc_ai_v2_hf/deprecated/app_old8.py DELETED
The diff for this file is too large to render. See raw diff
 
dvnc_ai_v2_hf/deprecated/app_old9.py DELETED
@@ -1,1832 +0,0 @@
1
- import json
2
- import math
3
- import random
4
- import html
5
- import os
6
- import re
7
- import urllib.parse
8
- import xml.etree.ElementTree as ET
9
- from pathlib import Path
10
- from typing import List, Dict, Optional
11
- from urllib.parse import quote
12
- from dvnc_ai_v2_hf.agent_route_cards import build_agent_route_cards_html
13
- from dvnc_ai_v2_hf.dvnc_ui_layout import get_dvnc_layout_css
14
- from dvnc_ai_v2_hf.self_learning_graph import (
15
- DEFAULT_SOURCES,
16
- SEARCH_MODES,
17
- SOURCE_OPTIONS,
18
- build_learning_graph_html,
19
- build_journal_html,
20
- ingest_selected_papers,
21
- parse_uploaded_pdf,
22
- render_parse_result,
23
- run_paper_discovery,
24
- safe_text,
25
- )
26
- from urllib.parse import quote
27
-
28
- # from dvnc_ai_v2_hf.agent_route_cards import build_agent_route_cards_html
29
- from dvnc_ai_v2_hf.discovery_app_bridge import (
30
- apply_route_swap_bridge,
31
- get_default_route_state,
32
- get_discovery_css,
33
- get_initial_discovery_timeline_html,
34
- run_discovery_bridge,
35
- )
36
- from dvnc_ai_v2_hf.dvnc_ui_layout import get_dvnc_layout_css
37
- from dvnc_ai_v2_hf.self_learning_graph import (
38
- DEFAULT_SOURCES,
39
- SEARCH_MODES,
40
- SOURCE_OPTIONS,
41
- build_learning_graph_html,
42
- build_journal_html,
43
- ingest_selected_papers,
44
- parse_uploaded_pdf,
45
- render_parse_result,
46
- run_paper_discovery,
47
- safe_text,
48
- )
49
-
50
- import gradio as gr
51
- import requests
52
-
53
- try:
54
- import fitz # PyMuPDF
55
- except Exception:
56
- fitz = None
57
-
58
- try:
59
- from bs4 import BeautifulSoup
60
- except Exception:
61
- BeautifulSoup = None
62
-
63
-
64
- MODELS = [
65
- {"name": "DVNC Sovereign", "tag": "flagship", "desc": "Maximum depth orchestration for frontier discovery"},
66
- {"name": "DVNC Atlas", "tag": "research", "desc": "Balanced reasoning, graph traversal, and synthesis"},
67
- {"name": "DVNC Curie", "tag": "lab", "desc": "Experimental hypothesis generation for anomalous signals"},
68
- ]
69
-
70
- AGENTS = [
71
- "Query Interpreter",
72
- "Graph Divergence Mapper",
73
- "Evidence Harvester",
74
- "Analogy Engine",
75
- "Hypothesis Composer",
76
- "Adversarial Critic",
77
- "Experimental Program Designer",
78
- ]
79
-
80
- NODES = [
81
- {"id": "seed", "label": "Seed Query", "group": "core", "x": 10, "y": 0, "z": 0},
82
- {"id": "bio", "label": "Biomaterials", "group": "domain", "x": 24, "y": 12, "z": -8},
83
- {"id": "card", "label": "Cardiac Repair", "group": "domain", "x": 38, "y": 3, "z": 14},
84
- {"id": "nano", "label": "Nanostructure", "group": "bridge", "x": 24, "y": -18, "z": 16},
85
- {"id": "selfasm", "label": "Self-Assembly", "group": "bridge", "x": 40, "y": -16, "z": -16},
86
- {"id": "electro", "label": "Electro-signalling", "group": "mechanism", "x": 58, "y": 10, "z": -10},
87
- {"id": "immune", "label": "Immune Modulation", "group": "mechanism", "x": 64, "y": -8, "z": 10},
88
- {"id": "trial", "label": "Validation Path", "group": "outcome", "x": 80, "y": 0, "z": 0},
89
- {"id": "alt1", "label": "Piezoelectric Scaffold", "group": "candidate", "x": 56, "y": 26, "z": 14},
90
- {"id": "alt2", "label": "Peptide Mesh", "group": "candidate", "x": 54, "y": -27, "z": -14},
91
- ]
92
-
93
- EDGES = [
94
- ("seed", "bio"), ("seed", "nano"), ("bio", "card"), ("nano", "selfasm"),
95
- ("selfasm", "electro"), ("card", "immune"), ("electro", "trial"), ("immune", "trial"),
96
- ("card", "alt1"), ("selfasm", "alt2"), ("alt1", "trial"), ("alt2", "trial")
97
- ]
98
-
99
- DEFAULT_PATH = ["seed", "nano", "selfasm", "electro", "trial"]
100
-
101
- CANDIDATES = [
102
- {
103
- "title": "Piezoelectric Scaffold Cascade",
104
- "front": "Use mechano-electric scaffolds to convert cardiac strain into micro-current signalling.",
105
- "back": "Discovery path: anomalous healing signal -> piezoelectric analog -> ion-channel entrainment -> tissue regeneration. Risk: power density and fibrosis coupling.",
106
- "score": 92,
107
- "novelty": "High",
108
- "agent": "Hypothesis Composer"
109
- },
110
- {
111
- "title": "Peptide Self-Assembly Mesh",
112
- "front": "Deploy dynamic peptide meshes that self-assemble around damaged myocardium and guide repair.",
113
- "back": "Discovery path: self-assembly -> local immune choreography -> regenerative substrate formation. Risk: degradation timing and targeting specificity.",
114
- "score": 88,
115
- "novelty": "High",
116
- "agent": "Analogy Engine"
117
- },
118
- {
119
- "title": "Immune-Tuned Conductive Hydrogel",
120
- "front": "Blend conductivity with macrophage-state modulation to reduce scarring and restore conduction.",
121
- "back": "Discovery path: inflammation mismatch -> conductive medium -> macrophage polarization -> synchronized healing. Risk: persistence and biocompatibility.",
122
- "score": 85,
123
- "novelty": "Medium-High",
124
- "agent": "Adversarial Critic"
125
- }
126
- ]
127
-
128
- JOURNALS = [
129
- {"name": "Nature", "url": "https://www.nature.com/search", "desc": "Flagship multidisciplinary research journal."},
130
- {"name": "Science", "url": "https://www.science.org/search", "desc": "High-impact science journal and family."},
131
- {"name": "Cell", "url": "https://www.cell.com/search", "desc": "Life sciences and translational biology."},
132
- {"name": "The Lancet", "url": "https://www.thelancet.com/search", "desc": "Clinical and medical research."},
133
- {"name": "IEEE Xplore", "url": "https://ieeexplore.ieee.org/search/searchresult.jsp", "desc": "Engineering, AI, signal processing, and systems."},
134
- ]
135
-
136
- SEARCH_MODES = ["topic", "title", "doi", "link", "paper_name", "autonomous_web"]
137
- SOURCE_OPTIONS = ["arxiv", "crossref", "openalex", "semantic_scholar", "europe_pmc"]
138
- DEFAULT_SOURCES = ["arxiv", "openalex", "crossref", "semantic_scholar", "europe_pmc"]
139
-
140
- SEMANTIC_SCHOLAR_API_KEY = os.getenv("SEMANTIC_SCHOLAR_API_KEY", "")
141
- GROBID_URL = os.getenv("GROBID_URL", "").strip()
142
- REQUEST_TIMEOUT = 25
143
-
144
-
145
- def safe_text(x, default=""):
146
- return html.escape(str(x if x is not None else default))
147
-
148
-
149
- def norm_text(x: Optional[str]) -> str:
150
- return re.sub(r"\s+", " ", (x or "")).strip()
151
-
152
-
153
- def detect_query_type(query: str) -> str:
154
- q = (query or "").strip()
155
- doi_pattern = r"^10\.\d{4,9}/[-._;()/:A-Z0-9]+$"
156
- if re.match(doi_pattern, q, flags=re.I):
157
- return "doi"
158
- if q.startswith("http://") or q.startswith("https://"):
159
- return "link"
160
- return "topic"
161
-
162
-
163
- def ensure_list(x):
164
- return x if isinstance(x, list) else []
165
-
166
-
167
- def build_connectome_html(path_ids):
168
- active = set(path_ids)
169
- node_map = {n["id"]: n for n in NODES}
170
- path_pairs = set()
171
- for i in range(len(path_ids) - 1):
172
- path_pairs.add((path_ids[i], path_ids[i + 1]))
173
- path_pairs.add((path_ids[i + 1], path_ids[i]))
174
-
175
- lines = []
176
- active_lines = []
177
- for a, b in EDGES:
178
- na, nb = node_map[a], node_map[b]
179
- x1, y1 = na["x"] * 8 + 80, na["y"] * 6 + 280
180
- x2, y2 = nb["x"] * 8 + 80, nb["y"] * 6 + 280
181
- lines.append(f'<line class="edge" x1="{x1:.1f}" y1="{y1:.1f}" x2="{x2:.1f}" y2="{y2:.1f}" />')
182
- if (a, b) in path_pairs:
183
- active_lines.append(f'<line class="edge active" x1="{x1:.1f}" y1="{y1:.1f}" x2="{x2:.1f}" y2="{y2:.1f}" />')
184
-
185
- circles = []
186
- labels = []
187
- for n in NODES:
188
- cx = n["x"] * 8 + 80
189
- cy = n["y"] * 6 + 280
190
- is_active = n["id"] in active
191
- state_cls = "chosen" if is_active else "idle"
192
- halo_cls = "halo active" if is_active else "halo"
193
- label_cls = "label active" if is_active else "label"
194
- cls = f'node {n["group"]} {state_cls}'
195
- circles.append(
196
- f'<g class="node-wrap">'
197
- f'<circle class="{halo_cls}" cx="{cx:.1f}" cy="{cy:.1f}" r="{30 if is_active else 0}" />'
198
- f'<circle class="{cls}" cx="{cx:.1f}" cy="{cy:.1f}" r="{18 if is_active else 13}" />'
199
- f'</g>'
200
- )
201
- labels.append(f'<text class="{label_cls}" x="{cx + 18:.1f}" y="{cy - 16:.1f}">{safe_text(n["label"])}</text>')
202
-
203
- return f"""
204
- <div class="panel brain-shell">
205
- <div class="brain-header">
206
- <div>
207
- <p class="eyebrow">Connectome</p>
208
- <h3>3D Connectome</h3>
209
- </div>
210
- <div class="brain-legend">
211
- <span><i class="dot dot-live"></i> lit path</span>
212
- <span><i class="dot dot-chosen"></i> chosen node</span>
213
- <span><i class="dot dot-idle"></i> available node</span>
214
- </div>
215
- </div>
216
- <div class="brain-stage">
217
- <svg viewBox="0 0 780 560" class="brain-svg" role="img" aria-label="DVNC 3D connectome visualisation">
218
- {''.join(lines)}
219
- {''.join(active_lines)}
220
- {''.join(circles)}
221
- {''.join(labels)}
222
- </svg>
223
- </div>
224
- </div>
225
- """
226
-
227
-
228
- def build_learning_graph_html(nodes, edges, title="Self-Learning Knowledge Graph"):
229
- if not nodes:
230
- return """
231
- <div class="panel brain-shell">
232
- <div class="brain-header">
233
- <div>
234
- <p class="eyebrow">Learning Graph</p>
235
- <h3>Self-Learning Knowledge Graph</h3>
236
- </div>
237
- </div>
238
- <div class="brain-stage learning-empty">
239
- <div class="empty-graph-copy">
240
- <h4>No papers mapped yet</h4>
241
- <p>Search papers, pick a topic, select candidates, or upload a PDF to grow the graph in real time.</p>
242
- </div>
243
- </div>
244
- </div>
245
- """
246
-
247
- node_items = []
248
- label_items = []
249
- edge_items = []
250
- coords = [(110, 110), (320, 80), (540, 130), (660, 270), (550, 410), (300, 450), (110, 340), (380, 260)]
251
-
252
- graph_nodes = [dict(n) for n in nodes[:8]]
253
- for i, node in enumerate(graph_nodes):
254
- x, y = coords[i]
255
- node["sx"] = x
256
- node["sy"] = y
257
-
258
- node_map = {n["id"]: n for n in graph_nodes}
259
- for a, b in edges:
260
- if a in node_map and b in node_map:
261
- na = node_map[a]
262
- nb = node_map[b]
263
- edge_items.append(
264
- f'<line class="learn-edge" x1="{na["sx"]}" y1="{na["sy"]}" x2="{nb["sx"]}" y2="{nb["sy"]}" />'
265
- )
266
-
267
- for node in graph_nodes:
268
- kind = node.get("kind", "paper")
269
- klass = f'learn-node {kind}'
270
- radius = 24 if kind == "query" else 20
271
- node_items.append(
272
- f'<circle class="{klass}" cx="{node["sx"]}" cy="{node["sy"]}" r="{radius}" />'
273
- )
274
- label_items.append(
275
- f'<text class="learn-label" x="{node["sx"] + 28}" y="{node["sy"] - 10}">{safe_text(node["label"][:44])}</text>'
276
- )
277
-
278
- return f"""
279
- <div class="panel brain-shell">
280
- <div class="brain-header">
281
- <div>
282
- <p class="eyebrow">Learning Graph</p>
283
- <h3>{safe_text(title)}</h3>
284
- </div>
285
- <div class="brain-legend">
286
- <span><i class="dot dot-query"></i> query</span>
287
- <span><i class="dot dot-paper"></i> paper</span>
288
- <span><i class="dot dot-upload"></i> uploaded PDF</span>
289
- </div>
290
- </div>
291
- <div class="brain-stage">
292
- <svg viewBox="0 0 760 520" class="brain-svg" role="img" aria-label="Self-learning knowledge graph">
293
- {''.join(edge_items)}
294
- {''.join(node_items)}
295
- {''.join(label_items)}
296
- </svg>
297
- </div>
298
- </div>
299
- """
300
-
301
-
302
- def build_cards_html(cards):
303
- items = []
304
- for c in cards:
305
- items.append(
306
- f"""
307
- <article class="candidate-card" tabindex="0">
308
- <div class="candidate-card-inner">
309
- <div class="candidate-face candidate-front">
310
- <div class="candidate-top">
311
- <span class="chip">{safe_text(c['agent'])}</span>
312
- <span class="score">{safe_text(c['score'])}</span>
313
- </div>
314
- <h4>{safe_text(c['title'])}</h4>
315
- <p>{safe_text(c['front'])}</p>
316
- <div class="meta-row"><span>Novelty</span><strong>{safe_text(c['novelty'])}</strong></div>
317
- <button class="mini" type="button">Flip insight</button>
318
- </div>
319
- <div class="candidate-face candidate-back">
320
- <div class="candidate-top">
321
- <span class="chip alt">Alternative path</span>
322
- <span class="score">{safe_text(c['score'])}</span>
323
- </div>
324
- <h4>{safe_text(c['title'])}</h4>
325
- <p>{safe_text(c['back'])}</p>
326
- <div class="meta-row"><span>Swap into route</span><strong>Enabled</strong></div>
327
- <button class="mini" type="button">Return</button>
328
- </div>
329
- </div>
330
- </article>
331
- """
332
- )
333
- return '<div class="panel" style="padding:20px;"><div class="candidate-grid">' + ''.join(items) + '</div></div>'
334
-
335
-
336
- def build_agent_timeline(reasoning):
337
- rows = []
338
- for r in reasoning:
339
- rows.append(
340
- f"""
341
- <details class="agent-step" {'open' if r['step'] == 1 else ''}>
342
- <summary class="agent-summary">
343
- <div class="agent-index">{safe_text(r['step'])}</div>
344
- <div class="agent-head">
345
- <h4>{safe_text(r['agent'])}</h4>
346
- <span>{safe_text(r['tag'])}</span>
347
- </div>
348
- </summary>
349
- <div class="agent-copy">
350
- <p>{safe_text(r['summary'])}</p>
351
- </div>
352
- </details>
353
- """
354
- )
355
- return '<div class="panel" style="padding:18px;"><div class="timeline">' + ''.join(rows) + '</div></div>'
356
-
357
-
358
- def build_chat_html(query, result):
359
- return f"""
360
- <div class="panel chat-panel">
361
- <div class="chat-thread">
362
- <div class="bubble bubble-user">
363
- <span class="role">You</span>
364
- <p>{safe_text(query)}</p>
365
- </div>
366
- <div class="bubble bubble-ai">
367
- <span class="role">DVNC Sovereign</span>
368
- <p>{safe_text(result['summary'])}</p>
369
- </div>
370
- <div class="bubble bubble-system">
371
- <span class="role">Discovery Signal</span>
372
- <p><strong>Primary hypothesis:</strong> {safe_text(result['primary_hypothesis'])}</p>
373
- </div>
374
- </div>
375
- </div>
376
- """
377
-
378
-
379
- def build_models_html(selected):
380
- items = []
381
- for m in MODELS:
382
- active = "active" if m["name"] == selected else ""
383
- items.append(
384
- f"""
385
- <div class="model-pill {active}">
386
- <span class="model-name">{safe_text(m['name'])}</span>
387
- <span class="model-tag">{safe_text(m['tag'])}</span>
388
- <small>{safe_text(m['desc'])}</small>
389
- </div>
390
- """
391
- )
392
- return '<div class="panel" style="padding:18px;"><div class="model-switcher">' + ''.join(items) + '</div></div>'
393
-
394
-
395
- def build_journal_html(query):
396
- q = urllib.parse.quote_plus(query or "biomaterials cardiac repair")
397
- rows = []
398
- for journal in JOURNALS:
399
- url = f"{journal['url']}?q={q}" if "?" not in journal["url"] else f"{journal['url']}&q={q}"
400
- if "ieeexplore" in journal["url"]:
401
- url = f"https://ieeexplore.ieee.org/search/searchresult.jsp?queryText={q}"
402
- rows.append(
403
- f"""
404
- <a class="journal-card" href="{safe_text(url)}" target="_blank" rel="noopener noreferrer">
405
- <div>
406
- <h4>{safe_text(journal['name'])}</h4>
407
- <p>{safe_text(journal['desc'])}</p>
408
- </div>
409
- <span>Open</span>
410
- </a>
411
- """
412
- )
413
- return '<div class="journal-grid">' + ''.join(rows) + '</div>'
414
-
415
-
416
- def search_arxiv(query, max_results=8):
417
- encoded = urllib.parse.quote(query)
418
- url = (
419
- "http://export.arxiv.org/api/query?search_query=all:"
420
- f"{encoded}&start=0&max_results={max_results}&sortBy=relevance&sortOrder=descending"
421
- )
422
- response = requests.get(url, timeout=REQUEST_TIMEOUT)
423
- response.raise_for_status()
424
- root = ET.fromstring(response.text)
425
- ns = {"atom": "http://www.w3.org/2005/Atom"}
426
- papers = []
427
- for entry in root.findall("atom:entry", ns):
428
- title = " ".join((entry.findtext("atom:title", default="", namespaces=ns) or "").split())
429
- summary = " ".join((entry.findtext("atom:summary", default="", namespaces=ns) or "").split())
430
- published = entry.findtext("atom:published", default="", namespaces=ns)
431
- paper_id = entry.findtext("atom:id", default="", namespaces=ns)
432
- authors = [a.findtext("atom:name", default="", namespaces=ns) for a in entry.findall("atom:author", ns)]
433
- pdf_url = ""
434
- for link in entry.findall("atom:link", ns):
435
- if link.attrib.get("title") == "pdf":
436
- pdf_url = link.attrib.get("href", "")
437
- break
438
- papers.append(
439
- {
440
- "id": paper_id or title,
441
- "title": title,
442
- "summary": summary,
443
- "abstract": summary,
444
- "published": published[:10],
445
- "authors": [a for a in authors[:8] if a],
446
- "authors_text": ", ".join([a for a in authors[:4] if a]),
447
- "url": paper_id,
448
- "pdf": pdf_url,
449
- "doi": "",
450
- "venue": "arXiv",
451
- "year": published[:4] if published else "",
452
- "source": "arxiv",
453
- "score": 0.76,
454
- "open_access": True,
455
- "external_ids": {"arxiv": (paper_id or "").split("/")[-1]},
456
- }
457
- )
458
- return papers
459
-
460
-
461
- def search_crossref(query, mode="topic", max_results=8):
462
- headers = {"User-Agent": "dvnc-ai-space/0.1"}
463
- if mode == "doi":
464
- url = f"https://api.crossref.org/works/{quote(query)}"
465
- response = requests.get(url, headers=headers, timeout=REQUEST_TIMEOUT)
466
- if response.status_code != 200:
467
- return []
468
- items = [response.json().get("message", {})]
469
- else:
470
- params = {"rows": max_results}
471
- if mode in ("title", "paper_name"):
472
- params["query.title"] = query
473
- else:
474
- params["query.bibliographic"] = query
475
- response = requests.get("https://api.crossref.org/works", params=params, headers=headers, timeout=REQUEST_TIMEOUT)
476
- response.raise_for_status()
477
- items = response.json().get("message", {}).get("items", [])
478
-
479
- out = []
480
- for item in items:
481
- authors = []
482
- for a in item.get("author", []) or []:
483
- name = " ".join(filter(None, [a.get("given"), a.get("family")])).strip()
484
- if name:
485
- authors.append(name)
486
- title = (item.get("title") or ["Untitled"])[0]
487
- year = ""
488
- for key in ["published-print", "published-online", "created"]:
489
- if item.get(key, {}).get("date-parts"):
490
- year = str(item[key]["date-parts"][0][0])
491
- break
492
- abstract = item.get("abstract") or ""
493
- abstract = re.sub("<.*?>", "", abstract)
494
- doi = item.get("DOI", "")
495
- out.append({
496
- "id": doi or title,
497
- "title": norm_text(title),
498
- "summary": norm_text(abstract)[:400],
499
- "abstract": norm_text(abstract),
500
- "published": year,
501
- "authors": authors,
502
- "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
503
- "url": item.get("URL", ""),
504
- "pdf": "",
505
- "doi": doi,
506
- "venue": (item.get("container-title") or [""])[0],
507
- "year": year,
508
- "source": "crossref",
509
- "score": 0.72,
510
- "open_access": None,
511
- "external_ids": {"crossref": doi} if doi else {},
512
- })
513
- return out
514
-
515
-
516
- def search_openalex(query, mode="topic", max_results=8):
517
- params = {"per-page": max_results}
518
- if mode == "doi":
519
- doi = query.lower().replace("https://doi.org/", "").replace("http://doi.org/", "")
520
- params["filter"] = f"doi:https://doi.org/{doi}"
521
- else:
522
- params["search"] = query
523
- response = requests.get("https://api.openalex.org/works", params=params, timeout=REQUEST_TIMEOUT)
524
- response.raise_for_status()
525
- items = response.json().get("results", [])
526
- out = []
527
- for item in items:
528
- authors = []
529
- for auth in item.get("authorships", [])[:8]:
530
- author = auth.get("author") or {}
531
- if author.get("display_name"):
532
- authors.append(author["display_name"])
533
- oa = item.get("open_access") or {}
534
- doi = (item.get("doi") or "").replace("https://doi.org/", "")
535
- out.append({
536
- "id": item.get("id") or doi or item.get("title"),
537
- "title": norm_text(item.get("title")),
538
- "summary": "",
539
- "abstract": "",
540
- "published": str(item.get("publication_year") or ""),
541
- "authors": authors,
542
- "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
543
- "url": (item.get("primary_location") or {}).get("landing_page_url") or item.get("id") or "",
544
- "pdf": oa.get("oa_url") or "",
545
- "doi": doi,
546
- "venue": ((item.get("primary_location") or {}).get("source") or {}).get("display_name") or "",
547
- "year": str(item.get("publication_year") or ""),
548
- "source": "openalex",
549
- "score": 0.80,
550
- "open_access": oa.get("is_oa"),
551
- "external_ids": item.get("ids") or {},
552
- })
553
- return out
554
-
555
-
556
- def search_semantic_scholar(query, mode="topic", max_results=8):
557
- headers = {}
558
- if SEMANTIC_SCHOLAR_API_KEY:
559
- headers["x-api-key"] = SEMANTIC_SCHOLAR_API_KEY
560
- fields = "title,authors,year,abstract,venue,externalIds,url,openAccessPdf"
561
- if mode == "doi":
562
- doi = query.lower().replace("https://doi.org/", "").replace("http://doi.org/", "")
563
- url = f"https://api.semanticscholar.org/graph/v1/paper/DOI:{quote(doi)}"
564
- response = requests.get(url, params={"fields": fields}, headers=headers, timeout=REQUEST_TIMEOUT)
565
- if response.status_code != 200:
566
- return []
567
- items = [response.json()]
568
- else:
569
- response = requests.get(
570
- "https://api.semanticscholar.org/graph/v1/paper/search",
571
- params={"query": query, "limit": max_results, "fields": fields},
572
- headers=headers,
573
- timeout=REQUEST_TIMEOUT
574
- )
575
- if response.status_code != 200:
576
- return []
577
- items = response.json().get("data", [])
578
-
579
- out = []
580
- for item in items:
581
- external = item.get("externalIds") or {}
582
- authors = [a.get("name") for a in item.get("authors", []) if a.get("name")]
583
- out.append({
584
- "id": external.get("CorpusId") or external.get("DOI") or item.get("title"),
585
- "title": norm_text(item.get("title")),
586
- "summary": norm_text(item.get("abstract", ""))[:400],
587
- "abstract": norm_text(item.get("abstract", "")),
588
- "published": str(item.get("year") or ""),
589
- "authors": authors,
590
- "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
591
- "url": item.get("url") or "",
592
- "pdf": (item.get("openAccessPdf") or {}).get("url") or "",
593
- "doi": external.get("DOI", ""),
594
- "venue": item.get("venue") or "",
595
- "year": str(item.get("year") or ""),
596
- "source": "semantic_scholar",
597
- "score": 0.84,
598
- "open_access": bool((item.get("openAccessPdf") or {}).get("url")),
599
- "external_ids": external,
600
- })
601
- return out
602
-
603
-
604
- def search_europe_pmc(query, mode="topic", max_results=8):
605
- epmc_query = f'DOI:"{query}"' if mode == "doi" else query
606
- params = {
607
- "query": epmc_query,
608
- "format": "json",
609
- "pageSize": max_results,
610
- "resultType": "core",
611
- }
612
- response = requests.get("https://www.ebi.ac.uk/europepmc/webservices/rest/search", params=params, timeout=REQUEST_TIMEOUT)
613
- if response.status_code != 200:
614
- return []
615
- items = response.json().get("resultList", {}).get("result", [])
616
- out = []
617
- for item in items:
618
- author_string = item.get("authorString", "")
619
- authors = [x.strip() for x in author_string.split(",")[:8] if x.strip()]
620
- pmcid = item.get("pmcid", "")
621
- pdf_url = f"https://europepmc.org/articles/{pmcid}?pdf=render" if pmcid else ""
622
- landing_url = f"https://europepmc.org/article/PMC/{pmcid}" if pmcid else ""
623
- out.append({
624
- "id": item.get("id") or item.get("doi") or item.get("title"),
625
- "title": norm_text(item.get("title")),
626
- "summary": norm_text(item.get("abstractText", ""))[:400],
627
- "abstract": norm_text(item.get("abstractText", "")),
628
- "published": str(item.get("pubYear") or ""),
629
- "authors": authors,
630
- "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
631
- "url": landing_url,
632
- "pdf": pdf_url,
633
- "doi": item.get("doi", ""),
634
- "venue": item.get("journalTitle", ""),
635
- "year": str(item.get("pubYear") or ""),
636
- "source": "europe_pmc",
637
- "score": 0.78,
638
- "open_access": bool(pmcid),
639
- "external_ids": {"pmid": item.get("pmid"), "pmcid": pmcid},
640
- })
641
- return out
642
-
643
-
644
- def resolve_link(query):
645
- url = query.strip()
646
- if not url:
647
- return []
648
- try:
649
- response = requests.get(url, timeout=REQUEST_TIMEOUT, allow_redirects=True, headers={"User-Agent": "dvnc-ai-space/0.1"})
650
- content_type = response.headers.get("content-type", "")
651
- if "pdf" in content_type or url.lower().endswith(".pdf"):
652
- name = Path(url.split("?")[0]).name or "linked-paper.pdf"
653
- return [{
654
- "id": url,
655
- "title": name,
656
- "summary": "Direct PDF link detected.",
657
- "abstract": "",
658
- "published": "",
659
- "authors": [],
660
- "authors_text": "Unknown authors",
661
- "url": url,
662
- "pdf": url,
663
- "doi": "",
664
- "venue": "Direct PDF",
665
- "year": "",
666
- "source": "link",
667
- "score": 0.66,
668
- "open_access": True,
669
- "external_ids": {},
670
- }]
671
-
672
- if BeautifulSoup is None:
673
- return [{
674
- "id": url,
675
- "title": url,
676
- "summary": "Web page resolved. Install beautifulsoup4 for DOI extraction.",
677
- "abstract": "",
678
- "published": "",
679
- "authors": [],
680
- "authors_text": "Unknown authors",
681
- "url": url,
682
- "pdf": "",
683
- "doi": "",
684
- "venue": "Web Link",
685
- "year": "",
686
- "source": "link",
687
- "score": 0.48,
688
- "open_access": None,
689
- "external_ids": {},
690
- }]
691
-
692
- soup = BeautifulSoup(response.text, "html.parser")
693
- doi = ""
694
- for meta_name in ["citation_doi", "dc.identifier", "dc.Identifier"]:
695
- tag = soup.find("meta", attrs={"name": meta_name})
696
- if tag and tag.get("content"):
697
- doi = tag["content"].strip()
698
- break
699
-
700
- title = soup.title.text.strip() if soup.title else url
701
- pdf_link = ""
702
- for a in soup.find_all("a", href=True):
703
- href = a["href"]
704
- if ".pdf" in href.lower():
705
- pdf_link = href if href.startswith("http") else ""
706
- break
707
-
708
- if doi:
709
- results = search_crossref(doi, mode="doi", max_results=1)
710
- if results:
711
- if pdf_link and not results[0].get("pdf"):
712
- results[0]["pdf"] = pdf_link
713
- if url and not results[0].get("url"):
714
- results[0]["url"] = url
715
- return results
716
-
717
- return [{
718
- "id": url,
719
- "title": title,
720
- "summary": "Landing page resolved from direct link.",
721
- "abstract": "",
722
- "published": "",
723
- "authors": [],
724
- "authors_text": "Unknown authors",
725
- "url": url,
726
- "pdf": pdf_link,
727
- "doi": doi,
728
- "venue": "Web Link",
729
- "year": "",
730
- "source": "link",
731
- "score": 0.54,
732
- "open_access": bool(pdf_link),
733
- "external_ids": {},
734
- }]
735
- except Exception as e:
736
- return [{
737
- "id": url,
738
- "title": "Link resolution error",
739
- "summary": str(e),
740
- "abstract": "",
741
- "published": "",
742
- "authors": [],
743
- "authors_text": "Unknown authors",
744
- "url": url,
745
- "pdf": "",
746
- "doi": "",
747
- "venue": "Link",
748
- "year": "",
749
- "source": "link",
750
- "score": 0.20,
751
- "open_access": None,
752
- "external_ids": {},
753
- }]
754
-
755
-
756
- def dedupe_papers(items: List[Dict]) -> List[Dict]:
757
- seen = {}
758
- for item in items:
759
- key = (
760
- (item.get("doi") or "").lower().strip()
761
- or (item.get("external_ids") or {}).get("arxiv")
762
- or norm_text(item.get("title", "")).lower()
763
- or item.get("id")
764
- )
765
- if not key:
766
- key = f"{item.get('source')}::{item.get('title')}"
767
- if key not in seen or float(item.get("score", 0)) > float(seen[key].get("score", 0)):
768
- seen[key] = item
769
- return sorted(seen.values(), key=lambda x: float(x.get("score", 0)), reverse=True)
770
-
771
-
772
- def discover_papers(query, mode, sources, max_results=10):
773
- query = (query or "").strip()
774
- if not query:
775
- return []
776
-
777
- mode = detect_query_type(query) if mode == "autonomous_web" else mode
778
- selected_sources = ensure_list(sources) or DEFAULT_SOURCES
779
- results = []
780
-
781
- if mode == "link":
782
- return dedupe_papers(resolve_link(query))
783
-
784
- try:
785
- if "arxiv" in selected_sources and mode != "doi":
786
- results.extend(search_arxiv(query, max_results=max_results))
787
- except Exception:
788
- pass
789
- try:
790
- if "crossref" in selected_sources:
791
- results.extend(search_crossref(query, mode=mode, max_results=max_results))
792
- except Exception:
793
- pass
794
- try:
795
- if "openalex" in selected_sources:
796
- results.extend(search_openalex(query, mode=mode, max_results=max_results))
797
- except Exception:
798
- pass
799
- try:
800
- if "semantic_scholar" in selected_sources:
801
- results.extend(search_semantic_scholar(query, mode=mode, max_results=max_results))
802
- except Exception:
803
- pass
804
- try:
805
- if "europe_pmc" in selected_sources:
806
- results.extend(search_europe_pmc(query, mode=mode, max_results=max_results))
807
- except Exception:
808
- pass
809
-
810
- return dedupe_papers(results)
811
-
812
-
813
- def format_papers_html(papers):
814
- if not papers:
815
- return '<div class="panel papers-panel" style="padding:18px"><p>No papers found yet.</p></div>'
816
-
817
- items = []
818
- for i, paper in enumerate(papers, start=1):
819
- summary = safe_text((paper.get("summary") or "")[:280])
820
- doi_line = f'<span class="paper-badge doi-badge">{safe_text(paper.get("doi"))}</span>' if paper.get("doi") else ""
821
- pdf_link = paper.get("pdf") or "#"
822
- abs_link = paper.get("url") or "#"
823
- items.append(
824
- f"""
825
- <article class="paper-card">
826
- <div class="paper-topline">
827
- <span class="paper-badge">{safe_text(paper.get('source', 'paper'))}</span>
828
- <span class="paper-badge alt">{safe_text(paper.get('published', '') or 'Paper')}</span>
829
- {doi_line}
830
- </div>
831
- <h4>{i}. {safe_text(paper.get('title', 'Untitled'))}</h4>
832
- <p>{summary or 'No abstract snippet available.'}</p>
833
- <div class="paper-meta-stack">
834
- <div><strong>Authors:</strong> {safe_text(paper.get('authors_text', 'Unknown authors'))}</div>
835
- <div><strong>Venue:</strong> {safe_text(paper.get('venue', 'Unknown venue'))}</div>
836
- <div><strong>Score:</strong> {safe_text(round(float(paper.get('score', 0)), 3))}</div>
837
- </div>
838
- <div class="paper-links">
839
- <a href="{safe_text(abs_link)}" target="_blank" rel="noopener noreferrer">Abstract</a>
840
- <a href="{safe_text(pdf_link)}" target="_blank" rel="noopener noreferrer">PDF</a>
841
- </div>
842
- </article>
843
- """
844
- )
845
- return '<div class="papers-grid">' + ''.join(items) + '</div>'
846
-
847
-
848
- def format_selection_choices(papers):
849
- choices = []
850
- for i, paper in enumerate(papers):
851
- label = f"[{paper.get('source', 'src')}] {paper.get('title', 'Untitled')} — {paper.get('authors_text', 'Unknown authors')[:80]}"
852
- choices.append((label, str(i)))
853
- return choices
854
-
855
-
856
- def uploaded_pdf_summary(file_obj):
857
- if not file_obj:
858
- return "No PDF uploaded yet."
859
- path = getattr(file_obj, "name", None) or str(file_obj)
860
- p = Path(path)
861
- return f"Uploaded PDF ready for ingestion: {p.name}. Use Parse uploaded PDF to extract title, abstract, sections, and references."
862
-
863
-
864
- def build_learning_graph_state(query, papers, uploaded_name=None):
865
- nodes = [{"id": "query", "label": query or "Research topic", "kind": "query"}]
866
- edges = []
867
- for i, paper in enumerate(papers[:5], start=1):
868
- pid = f"paper_{i}"
869
- nodes.append({"id": pid, "label": paper["title"], "kind": "paper"})
870
- edges.append(("query", pid))
871
- if uploaded_name:
872
- nodes.append({"id": "upload", "label": uploaded_name, "kind": "upload"})
873
- edges.append(("query", "upload"))
874
- if len(nodes) > 2:
875
- edges.append(("upload", "paper_1"))
876
- return nodes, edges
877
-
878
-
879
- def graph_from_selected(query, selected_papers, uploaded_name=None):
880
- nodes = [{"id": "query", "label": query or "Research topic", "kind": "query"}]
881
- edges = []
882
- for i, paper in enumerate(selected_papers[:6], start=1):
883
- pid = f"paper_{i}"
884
- nodes.append({"id": pid, "label": paper["title"], "kind": "paper"})
885
- edges.append(("query", pid))
886
- if paper.get("doi"):
887
- doi_id = f"doi_{i}"
888
- nodes.append({"id": doi_id, "label": f"DOI {paper['doi']}", "kind": "paper"})
889
- edges.append((pid, doi_id))
890
- if uploaded_name:
891
- nodes.append({"id": "upload", "label": uploaded_name, "kind": "upload"})
892
- edges.append(("query", "upload"))
893
- if len(selected_papers) > 0:
894
- edges.append(("upload", "paper_1"))
895
- return nodes, edges
896
-
897
-
898
- def parse_pdf_with_grobid(pdf_path):
899
- if not GROBID_URL:
900
- raise RuntimeError("GROBID_URL is not set")
901
- with open(pdf_path, "rb") as f:
902
- files = {"input": (Path(pdf_path).name, f, "application/pdf")}
903
- response = requests.post(
904
- f"{GROBID_URL.rstrip('/')}/api/processFulltextDocument",
905
- files=files,
906
- data={"includeRawAffiliations": "1", "segmentSentences": "1"},
907
- timeout=120
908
- )
909
- response.raise_for_status()
910
- tei_xml = response.text
911
-
912
- if BeautifulSoup is None:
913
- return {
914
- "parser": "grobid",
915
- "title": Path(pdf_path).name,
916
- "abstract": "",
917
- "authors": [],
918
- "sections": [],
919
- "references": [],
920
- "raw_text": "",
921
- "tei_xml": tei_xml[:30000],
922
- }
923
-
924
- soup = BeautifulSoup(tei_xml, "xml")
925
- title_tag = soup.find("title")
926
- abstract_tag = soup.find("abstract")
927
- authors = []
928
- for author in soup.find_all("author"):
929
- pers = author.find("persName")
930
- if pers:
931
- name = " ".join(x.get_text(" ", strip=True) for x in pers.find_all(["forename", "surname"]))
932
- if name.strip():
933
- authors.append(name.strip())
934
-
935
- sections = []
936
- for div in soup.find_all("div"):
937
- head = div.find("head")
938
- paras = [p.get_text(" ", strip=True) for p in div.find_all("p")]
939
- text = "\n".join([p for p in paras if p.strip()])
940
- if head and text.strip():
941
- sections.append({"heading": head.get_text(" ", strip=True), "text": text[:4000]})
942
-
943
- references = []
944
- for bibl in soup.find_all("biblStruct")[:30]:
945
- ref_title = ""
946
- ref_doi = ""
947
- title_node = bibl.find("title")
948
- if title_node:
949
- ref_title = title_node.get_text(" ", strip=True)
950
- doi_node = bibl.find("idno", attrs={"type": "DOI"})
951
- if doi_node:
952
- ref_doi = doi_node.get_text(" ", strip=True)
953
- references.append({"title": ref_title, "doi": ref_doi})
954
-
955
- return {
956
- "parser": "grobid",
957
- "title": title_tag.get_text(" ", strip=True) if title_tag else Path(pdf_path).name,
958
- "abstract": abstract_tag.get_text(" ", strip=True) if abstract_tag else "",
959
- "authors": authors,
960
- "sections": sections,
961
- "references": references,
962
- "raw_text": "",
963
- "tei_xml": tei_xml[:60000],
964
- }
965
-
966
-
967
- def parse_pdf_with_pymupdf(pdf_path):
968
- if fitz is None:
969
- raise RuntimeError("PyMuPDF not installed")
970
- doc = fitz.open(pdf_path)
971
- pages = [page.get_text("text") for page in doc]
972
- raw_text = "\n".join(pages).strip()
973
- first_page = raw_text[:4000]
974
- lines = [x.strip() for x in first_page.splitlines() if x.strip()]
975
- title = lines[0][:300] if lines else Path(pdf_path).name
976
-
977
- abstract = ""
978
- match = re.search(r"abstract\s*(.+?)(?:\n\s*\n|\n1[\.\s]|introduction)", raw_text, re.I | re.S)
979
- if match:
980
- abstract = norm_text(match.group(1))[:2000]
981
-
982
- return {
983
- "parser": "pymupdf",
984
- "title": title,
985
- "abstract": abstract,
986
- "authors": [],
987
- "sections": [{"heading": "Full Text", "text": raw_text[:12000]}] if raw_text else [],
988
- "references": [],
989
- "raw_text": raw_text[:50000],
990
- "tei_xml": "",
991
- }
992
-
993
-
994
- def parse_pdf_with_docling(pdf_path):
995
- try:
996
- from docling.document_converter import DocumentConverter
997
- except Exception as e:
998
- raise RuntimeError(f"Docling import failed: {e}")
999
-
1000
- converter = DocumentConverter()
1001
- result = converter.convert(pdf_path)
1002
- doc = result.document
1003
- markdown = doc.export_to_markdown()
1004
-
1005
- title = Path(pdf_path).name
1006
- first_nonempty = next((line.strip("# ").strip() for line in markdown.splitlines() if line.strip()), "")
1007
- if first_nonempty:
1008
- title = first_nonempty[:300]
1009
-
1010
- return {
1011
- "parser": "docling",
1012
- "title": title,
1013
- "abstract": "",
1014
- "authors": [],
1015
- "sections": [{"heading": "Document", "text": markdown[:12000]}] if markdown else [],
1016
- "references": [],
1017
- "raw_text": markdown[:50000],
1018
- "tei_xml": "",
1019
- }
1020
-
1021
-
1022
- def parse_uploaded_pdf(file_obj, parser_order):
1023
- if not file_obj:
1024
- return "No PDF uploaded yet.", {}
1025
-
1026
- path = getattr(file_obj, "name", None) or str(file_obj)
1027
- parser_order = ensure_list(parser_order) or ["grobid", "docling", "pymupdf"]
1028
- errors = []
1029
-
1030
- for parser_name in parser_order:
1031
- try:
1032
- if parser_name == "grobid":
1033
- result = parse_pdf_with_grobid(path)
1034
- elif parser_name == "docling":
1035
- result = parse_pdf_with_docling(path)
1036
- elif parser_name == "pymupdf":
1037
- result = parse_pdf_with_pymupdf(path)
1038
- else:
1039
- continue
1040
-
1041
- summary = (
1042
- f"### Parsed PDF\n\n"
1043
- f"- Parser used: {result['parser']}\n"
1044
- f"- Title: {result.get('title') or 'Unknown'}\n"
1045
- f"- Authors: {', '.join(result.get('authors')[:6]) if result.get('authors') else 'Unknown'}\n"
1046
- f"- Abstract found: {'Yes' if result.get('abstract') else 'No'}\n"
1047
- f"- Sections extracted: {len(result.get('sections') or [])}\n"
1048
- f"- References extracted: {len(result.get('references') or [])}\n"
1049
- )
1050
- return summary, result
1051
- except Exception as e:
1052
- errors.append(f"{parser_name}: {e}")
1053
-
1054
- fail_summary = "### PDF parsing failed\n\n" + "\n".join([f"- {x}" for x in errors])
1055
- return fail_summary, {"parser": None, "errors": errors}
1056
-
1057
-
1058
- def render_parse_result(parsed):
1059
- if not parsed:
1060
- return '<div class="panel" style="padding:18px"><p>No parsed document yet.</p></div>'
1061
-
1062
- sections_html = []
1063
- for section in parsed.get("sections", [])[:6]:
1064
- sections_html.append(
1065
- f"""
1066
- <details class="agent-step">
1067
- <summary class="agent-summary">
1068
- <div class="agent-index">§</div>
1069
- <div class="agent-head">
1070
- <h4>{safe_text(section.get('heading', 'Section'))}</h4>
1071
- <span>section</span>
1072
- </div>
1073
- </summary>
1074
- <div class="agent-copy">
1075
- <p>{safe_text(section.get('text', '')[:1800])}</p>
1076
- </div>
1077
- </details>
1078
- """
1079
- )
1080
-
1081
- refs = parsed.get("references", [])[:12]
1082
- refs_html = "".join(
1083
- f"<li>{safe_text(r.get('title') or 'Untitled')} {'· DOI ' + safe_text(r.get('doi')) if r.get('doi') else ''}</li>"
1084
- for r in refs
1085
- ) or "<li>No references extracted.</li>"
1086
-
1087
- title = safe_text(parsed.get("title") or "Parsed document")
1088
- abstract = safe_text((parsed.get("abstract") or "")[:2400]) or "No abstract extracted."
1089
- parser_name = safe_text(parsed.get("parser") or "unknown")
1090
-
1091
- return f"""
1092
- <div class="panel" style="padding:18px">
1093
- <div class="brain-header">
1094
- <div>
1095
- <p class="eyebrow">PDF Parse</p>
1096
- <h3>{title}</h3>
1097
- </div>
1098
- <div class="brain-legend"><span><i class="dot dot-upload"></i> {parser_name}</span></div>
1099
- </div>
1100
- <div class="parse-grid">
1101
- <div class="parse-card">
1102
- <h4>Abstract</h4>
1103
- <p>{abstract}</p>
1104
- </div>
1105
- <div class="parse-card">
1106
- <h4>References</h4>
1107
- <ul class="ref-list">{refs_html}</ul>
1108
- </div>
1109
- </div>
1110
- <div class="timeline" style="margin-top:14px;">
1111
- {''.join(sections_html) if sections_html else '<div class="panel" style="padding:16px;"><p>No sections extracted.</p></div>'}
1112
- </div>
1113
- </div>
1114
- """
1115
-
1116
-
1117
- def build_ingest_payload(query, selected_papers, parsed_pdf=None):
1118
- nodes = [{"id": "topic:query", "type": "Topic", "label": query or "Research topic"}]
1119
- edges = []
1120
-
1121
- for i, p in enumerate(selected_papers, start=1):
1122
- paper_id = p.get("doi") or (p.get("external_ids") or {}).get("arxiv") or f"paper:{i}"
1123
- nodes.append({
1124
- "id": paper_id,
1125
- "type": "Paper",
1126
- "title": p.get("title"),
1127
- "year": p.get("year"),
1128
- "venue": p.get("venue"),
1129
- "doi": p.get("doi"),
1130
- "source": p.get("source"),
1131
- "url": p.get("url"),
1132
- "pdf": p.get("pdf"),
1133
- })
1134
- edges.append({"source": "topic:query", "target": paper_id, "type": "ABOUT", "weight": p.get("score", 0)})
1135
-
1136
- for author in p.get("authors", [])[:8]:
1137
- author_id = f"author:{author.lower()}"
1138
- nodes.append({"id": author_id, "type": "Author", "label": author})
1139
- edges.append({"source": paper_id, "target": author_id, "type": "WRITTEN_BY"})
1140
-
1141
- if parsed_pdf and parsed_pdf.get("title"):
1142
- doc_id = "upload:pdf"
1143
- nodes.append({
1144
- "id": doc_id,
1145
- "type": "UploadedPDF",
1146
- "title": parsed_pdf.get("title"),
1147
- "parser": parsed_pdf.get("parser"),
1148
- })
1149
- edges.append({"source": "topic:query", "target": doc_id, "type": "UPLOADED_SOURCE"})
1150
- for idx, section in enumerate(parsed_pdf.get("sections", [])[:8], start=1):
1151
- sec_id = f"{doc_id}:section:{idx}"
1152
- nodes.append({"id": sec_id, "type": "Section", "label": section.get("heading") or f"Section {idx}"})
1153
- edges.append({"source": doc_id, "target": sec_id, "type": "HAS_SECTION"})
1154
- for idx, ref in enumerate(parsed_pdf.get("references", [])[:12], start=1):
1155
- ref_id = f"{doc_id}:ref:{idx}"
1156
- nodes.append({"id": ref_id, "type": "Reference", "label": ref.get("title") or f"Reference {idx}", "doi": ref.get("doi")})
1157
- edges.append({"source": doc_id, "target": ref_id, "type": "CITES"})
1158
-
1159
- return {"status": "ok", "nodes": nodes, "edges": edges}
1160
-
1161
-
1162
- def run_paper_discovery(query, search_mode, sources, pdf_file):
1163
- if not (query or "").strip() and not pdf_file:
1164
- empty_graph = build_learning_graph_html([], [], "Self-Learning Knowledge Graph")
1165
- return (
1166
- empty_graph,
1167
- '<div class="panel papers-panel" style="padding:18px"><p>Enter a topic, title, DOI, link, or upload a PDF to start learning.</p></div>',
1168
- build_journal_html("biomaterials cardiac repair"),
1169
- "No PDF uploaded yet.",
1170
- gr.update(choices=[], value=[]),
1171
- [],
1172
- "### No discovery results yet."
1173
- )
1174
-
1175
- papers = []
1176
- if (query or "").strip():
1177
- try:
1178
- papers = discover_papers(query.strip(), search_mode, sources, max_results=10)
1179
- except Exception as e:
1180
- papers = []
1181
- timeline_html = build_agent_route_cards_html(reasoning)
1182
- error_html = f'<div class="panel papers-panel" style="padding:18px"><p>Paper search failed: {safe_text(str(e))}</p></div>'
1183
- graph_nodes, graph_edges = build_learning_graph_state(query.strip(), [], Path(getattr(pdf_file, "name", "uploaded.pdf")).name if pdf_file else None)
1184
- return (
1185
- build_learning_graph_html(graph_nodes, graph_edges),
1186
- error_html,
1187
- build_journal_html(query.strip()),
1188
- uploaded_pdf_summary(pdf_file),
1189
- gr.update(choices=[], value=[]),
1190
- [],
1191
- "### Discovery failed."
1192
- )
1193
-
1194
- uploaded_name = Path(getattr(pdf_file, "name", str(pdf_file))).name if pdf_file else None
1195
- graph_nodes, graph_edges = build_learning_graph_state(query.strip(), papers[:5], uploaded_name)
1196
- graph_html = build_learning_graph_html(graph_nodes, graph_edges)
1197
- papers_html = format_papers_html(papers)
1198
- journals_html = build_journal_html(query.strip() or "biomaterials cardiac repair")
1199
- pdf_summary = uploaded_pdf_summary(pdf_file)
1200
- choices = format_selection_choices(papers)
1201
- status_md = (
1202
- f"### Discovery results\n\n"
1203
- f"- Search mode: {search_mode}\n"
1204
- f"- Sources: {', '.join(ensure_list(sources) or DEFAULT_SOURCES)}\n"
1205
- f"- Candidates found: {len(papers)}\n"
1206
- f"- Select papers below, then click **Ingest selected into graph**.\n"
1207
- )
1208
- return graph_html, papers_html, journals_html, pdf_summary, gr.update(choices=choices, value=[]), papers, status_md
1209
-
1210
-
1211
- def ingest_selected_papers(query, selected_indices, papers_state, pdf_file, parsed_state):
1212
- papers = ensure_list(papers_state)
1213
- selected_indices = ensure_list(selected_indices)
1214
-
1215
- selected = []
1216
- for idx in selected_indices:
1217
- try:
1218
- selected.append(papers[int(idx)])
1219
- except Exception:
1220
- pass
1221
-
1222
- uploaded_name = Path(getattr(pdf_file, "name", str(pdf_file))).name if pdf_file else None
1223
-
1224
- if not selected and not parsed_state:
1225
- graph_html = build_learning_graph_html([], [], "Self-Learning Knowledge Graph")
1226
- return graph_html, "### Nothing ingested yet.\n\nSelect papers or parse an uploaded PDF first.", {"status": "empty", "nodes": [], "edges": []}
1227
-
1228
- graph_nodes, graph_edges = graph_from_selected(query, selected, uploaded_name)
1229
- graph_html = build_learning_graph_html(graph_nodes, graph_edges, "Selected Research Graph")
1230
- payload = build_ingest_payload(query, selected, parsed_state if isinstance(parsed_state, dict) else None)
1231
-
1232
- summary_lines = [
1233
- "### Graph ingest ready",
1234
- "",
1235
- f"- Topic: {query or 'Research topic'}",
1236
- f"- Selected papers: {len(selected)}",
1237
- f"- Uploaded PDF parsed: {'Yes' if parsed_state and isinstance(parsed_state, dict) and parsed_state.get('title') else 'No'}",
1238
- f"- Nodes created: {len(payload['nodes'])}",
1239
- f"- Edges created: {len(payload['edges'])}",
1240
- ]
1241
- return graph_html, "\n".join(summary_lines), payload
1242
-
1243
- def run_discovery(query, model_name):
1244
- return run_discovery_bridge(
1245
- query=query,
1246
- model_name=model_name,
1247
- chat_builder=build_chat_html,
1248
- connectome_builder=build_connectome_html,
1249
- models_builder=build_models_html,
1250
- )
1251
-
1252
-
1253
- def apply_route_swap(query, model_name, route_swap_payload, route_state):
1254
- return apply_route_swap_bridge(
1255
- query=query,
1256
- model_name=model_name,
1257
- route_swap_payload=route_swap_payload,
1258
- route_state=route_state,
1259
- chat_builder=build_chat_html,
1260
- connectome_builder=build_connectome_html,
1261
- )
1262
-
1263
- def run_discovery(query, model_name):
1264
- random.seed(len(query) + len(model_name))
1265
-
1266
- if "curie" in query.lower() or "einstein" in query.lower():
1267
- primary = "Map the anomaly first, then force a distant analogy before composing the experimental programme."
1268
- path = ["seed", "bio", "card", "immune", "trial"]
1269
- else:
1270
- primary = "Use a self-assembling conductive scaffold that transforms mechanical strain into local regenerative signalling."
1271
- path = DEFAULT_PATH
1272
-
1273
- summaries = [
1274
- "Normalises the user prompt into a graph-searchable seed and isolates the tension inside the question.",
1275
- "Finds remote conceptual bridges instead of staying near the starting domain cluster.",
1276
- "Pulls evidence packets and conflict signals required for grounded hypothesis formation.",
1277
- "Generates cross-domain analogies with a bias toward mechanism transfer rather than keyword similarity.",
1278
- "Composes the lead hypothesis and two structurally different variants.",
1279
- "Attacks weak assumptions, hidden confounders, and feasibility gaps.",
1280
- "Produces a staged validation plan with measurable falsification criteria."
1281
- ]
1282
-
1283
- reasoning = [
1284
- {
1285
- "step": i + 1,
1286
- "agent": AGENTS[i],
1287
- "tag": ["input", "graph", "evidence", "analogy", "compose", "critique", "experiment"][i],
1288
- "summary": summaries[i],
1289
- }
1290
- for i in range(7)
1291
- ]
1292
-
1293
- result = {
1294
- "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.",
1295
- "primary_hypothesis": primary,
1296
- "reasoning": reasoning,
1297
- "cards": CANDIDATES,
1298
- "path": path,
1299
- "metrics": {
1300
- "Novelty": 93,
1301
- "Mechanistic clarity": 89,
1302
- "Experimental tractability": 82,
1303
- "Cross-domain distance": 91,
1304
- },
1305
- }
1306
-
1307
- chat_html = build_chat_html(query, result)
1308
- connectome_html = build_connectome_html(path)
1309
- timeline_html = build_agent_route_cards_html(reasoning)
1310
-
1311
- metrics = "\n".join([f"- {k}: {v}/100" for k, v in result["metrics"].items()])
1312
- hypothesis = (
1313
- "# Discovery Output\n\n"
1314
- f"**Model:** {model_name}\n\n"
1315
- f"**Primary hypothesis:** {result['primary_hypothesis']}\n\n"
1316
- "## Scoring\n"
1317
- f"{metrics}\n\n"
1318
- "## Experimental outline\n"
1319
- "1. Construct the candidate material or protocol.\n"
1320
- "2. Test mechanistic signal expression under controlled conditions.\n"
1321
- "3. Compare against baseline and nearest-neighbour alternatives.\n"
1322
- "4. Falsify using the adversarial risk criteria surfaced in the reasoning path.\n"
1323
- )
1324
-
1325
- cards_html = ""
1326
- return chat_html, connectome_html, timeline_html, cards_html, hypothesis, build_models_html(model_name)
1327
-
1328
-
1329
- def load_example():
1330
- return "How could a self-assembling conductive biomaterial improve cardiac tissue regeneration by converting mechanical strain into repair signalling?"
1331
-
1332
-
1333
- def load_paper_topic():
1334
- return "self-assembling conductive biomaterials for cardiac repair"
1335
-
1336
-
1337
- BASE_CSS = r"""
1338
- :root {
1339
- --bg: #ffffff;
1340
- --panel: #ffffff;
1341
- --line: rgba(0, 0, 0, 0.12);
1342
- --text: #111111;
1343
- --muted: #5b5b5b;
1344
- --soft: rgba(0, 0, 0, 0.62);
1345
- --gold: #ff6600;
1346
- --teal: #17b8a6;
1347
- --blue: #628dff;
1348
- --chosen: #ff7a1a;
1349
- --idle: #b8d8ff;
1350
- --idle-stroke: #5e8fe6;
1351
- --query-node: #ffd8b3;
1352
- --paper-node: #d7f6f2;
1353
- --upload-node: #e7defe;
1354
- --shadow: 0 16px 40px rgba(0, 0, 0, 0.12);
1355
- }
1356
- html, body, .gradio-container {
1357
- background: #ffffff !important;
1358
- font-family: Inter, ui-sans-serif, system-ui, sans-serif;
1359
- }
1360
- .gradio-container {
1361
- max-width: 1640px !important;
1362
- padding: 20px !important;
1363
- }
1364
- #dvnc-shell {
1365
- border: 1px solid var(--line);
1366
- border-radius: 28px;
1367
- overflow: hidden;
1368
- background: #ffffff;
1369
- box-shadow: var(--shadow);
1370
- padding: 20px 22px 22px;
1371
- }
1372
- .hero-bar {
1373
- display: flex;
1374
- justify-content: space-between;
1375
- align-items: center;
1376
- gap: 16px;
1377
- padding-bottom: 12px;
1378
- border-bottom: 1px solid rgba(0, 0, 0, 0.06);
1379
- margin-bottom: 16px;
1380
- }
1381
- .brand { display: flex; align-items: center; gap: 14px; }
1382
- .logo {
1383
- width: 42px; height: 42px; border-radius: 14px; display: grid; place-items: center; color: var(--gold);
1384
- background: linear-gradient(135deg, rgba(255, 122, 26, 0.12), rgba(23, 184, 166, 0.10));
1385
- border: 1px solid rgba(0, 0, 0, 0.08);
1386
- }
1387
- .logo svg { width: 24px; height: 24px; }
1388
- .brand h1 { font-size: 1.05rem; margin: 0; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
1389
- .brand p { margin: 3px 0 0; color: var(--muted); font-size: .84rem; }
1390
- .status { display: flex; gap: 10px; align-items: center; color: var(--soft); font-size: .85rem; }
1391
- .status-dot {
1392
- width: 10px; height: 10px; border-radius: 50%; background: var(--teal);
1393
- box-shadow: 0 0 0 6px rgba(23, 184, 166, 0.10), 0 0 14px rgba(23, 184, 166, 0.25);
1394
- }
1395
- .panel {
1396
- background: #ffffff; border: 1px solid var(--line); border-radius: 22px; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
1397
- }
1398
- .querybox textarea, .querybox input { background: transparent !important; color: var(--text) !important; }
1399
- .querybox, .querybox > div { background: #ffffff !important; border-radius: 18px !important; border-color: var(--line) !important; }
1400
- .chat-panel { padding: 18px; min-height: 280px; }
1401
- .chat-thread { display: flex; flex-direction: column; gap: 14px; }
1402
- .bubble { max-width: 88%; padding: 16px 18px; border-radius: 22px; border: 1px solid var(--line); }
1403
- .bubble p { margin: 8px 0 0; line-height: 1.6; font-size: .96rem; color: var(--text); }
1404
- .bubble .role { font-size: .72rem; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); }
1405
- .bubble-user { align-self: flex-end; background: linear-gradient(135deg, rgba(98, 141, 255, 0.16), rgba(98, 141, 255, 0.08)); }
1406
- .bubble-ai { align-self: flex-start; background: #ffffff; }
1407
- .bubble-system { align-self: flex-start; background: linear-gradient(135deg, rgba(255, 122, 26, 0.10), rgba(255, 122, 26, 0.04)); }
1408
- .model-switcher { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
1409
- .model-pill {
1410
- padding: 14px; border: 1px solid var(--line); border-radius: 18px; display: flex; flex-direction: column; gap: 4px; min-height: 98px; background: #ffffff;
1411
- }
1412
- .model-pill.active {
1413
- border-color: rgba(255, 122, 26, 0.40);
1414
- background: linear-gradient(135deg, rgba(255, 122, 26, 0.10), rgba(255, 255, 255, 0.96));
1415
- }
1416
- .model-name { font-weight: 650; color: var(--text); }
1417
- .model-tag { font-size: .76rem; text-transform: uppercase; letter-spacing: .12em; color: var(--gold); }
1418
- .model-pill small { color: var(--muted); line-height: 1.45; }
1419
- .brain-shell { padding: 18px; }
1420
- .brain-header { display: flex; justify-content: space-between; align-items: flex-end; gap: 16px; margin-bottom: 10px; }
1421
- .eyebrow { font-size: .72rem; letter-spacing: .16em; text-transform: uppercase; color: var(--gold); margin: 0 0 4px; }
1422
- .brain-header h3 { margin: 0; font-size: 1.12rem; color: var(--text); }
1423
- .brain-legend { display: flex; gap: 14px; color: var(--muted); font-size: .8rem; flex-wrap: wrap; }
1424
- .dot { width: 10px; height: 10px; display: inline-block; border-radius: 50%; margin-right: 6px; }
1425
- .dot-live { background: var(--chosen); box-shadow: 0 0 10px rgba(255, 122, 26, 0.35); }
1426
- .dot-chosen { background: var(--chosen); }
1427
- .dot-idle { background: var(--idle); border: 1px solid var(--idle-stroke); }
1428
- .dot-query { background: var(--query-node); border: 1px solid #de9e58; }
1429
- .dot-paper { background: var(--paper-node); border: 1px solid #4fb3a5; }
1430
- .dot-upload { background: var(--upload-node); border: 1px solid #8f73d9; }
1431
- .brain-stage {
1432
- position: relative; min-height: 420px; overflow: hidden; background: linear-gradient(180deg, rgba(250, 250, 250, 1), rgba(255, 255, 255, 1));
1433
- border: 1px solid rgba(0, 0, 0, 0.05); border-radius: 20px;
1434
- }
1435
- .brain-svg { width: 100%; height: 520px; display: block; }
1436
- .edge { stroke: rgba(0, 0, 0, 0.12); stroke-width: 2.4; }
1437
- .edge.active {
1438
- stroke: var(--chosen); stroke-width: 4.2; stroke-linecap: round; filter: drop-shadow(0 0 6px rgba(255, 122, 26, 0.45)); stroke-dasharray: 8 12; animation: pulseEdge 1.5s linear infinite;
1439
- }
1440
- .node { stroke-width: 2.2; transition: all .25s ease; }
1441
- .node.idle { fill: var(--idle); stroke: var(--idle-stroke); }
1442
- .node.chosen { fill: var(--chosen); stroke: #ffb16d; }
1443
- .halo { fill: none; }
1444
- .halo.active { stroke: rgba(255, 122, 26, 0.18); stroke-width: 12; }
1445
- .label { fill: #2c2c2c; font-size: 13px; font-weight: 500; letter-spacing: .01em; }
1446
- .label.active { fill: #111111; font-weight: 700; }
1447
- .learn-edge { stroke: rgba(0, 0, 0, 0.18); stroke-width: 2.2; stroke-linecap: round; }
1448
- .learn-node { stroke-width: 2.2; }
1449
- .learn-node.query { fill: var(--query-node); stroke: #de9e58; }
1450
- .learn-node.paper { fill: var(--paper-node); stroke: #36a091; }
1451
- .learn-node.upload { fill: var(--upload-node); stroke: #7e63cb; }
1452
- .learn-label { fill: #1e1e1e; font-size: 12px; font-weight: 600; }
1453
- .learning-empty { display: grid; place-items: center; }
1454
- .empty-graph-copy { text-align: center; max-width: 440px; padding: 40px 20px; }
1455
- .empty-graph-copy h4 { margin: 0 0 10px; font-size: 1.05rem; }
1456
- .empty-graph-copy p { margin: 0; color: var(--muted); line-height: 1.6; }
1457
- .timeline { display: flex; flex-direction: column; gap: 10px; }
1458
- .agent-step { border: 1px solid var(--line); border-radius: 18px; background: #ffffff; overflow: hidden; }
1459
- .agent-summary { list-style: none; display: grid; grid-template-columns: 42px 1fr; gap: 12px; align-items: center; padding: 12px; cursor: pointer; }
1460
- .agent-summary::-webkit-details-marker { display: none; }
1461
- .agent-index {
1462
- width: 42px; height: 42px; border-radius: 14px; display: grid; place-items: center; font-weight: 700; color: var(--gold);
1463
- background: rgba(255, 122, 26, 0.08); border: 1px solid rgba(255, 122, 26, 0.18);
1464
- }
1465
- .agent-head { display: flex; justify-content: space-between; gap: 12px; align-items: center; }
1466
- .agent-head h4 { margin: 0; font-size: .98rem; color: var(--text); }
1467
- .agent-head span { font-size: .72rem; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); }
1468
- .agent-copy { padding: 0 14px 16px 66px; }
1469
- .agent-copy p { margin: 0; color: #2d2d2d; font-size: .93rem; line-height: 1.6; }
1470
- .candidate-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px; }
1471
- .candidate-card { background: none; perspective: 1400px; min-height: 330px; }
1472
- .candidate-card-inner { position: relative; width: 100%; min-height: 330px; transition: transform .8s cubic-bezier(.2,.7,.1,1); transform-style: preserve-3d; }
1473
- .candidate-card:hover .candidate-card-inner, .candidate-card:focus .candidate-card-inner, .candidate-card:focus-within .candidate-card-inner { transform: rotateY(180deg); }
1474
- .candidate-face {
1475
- position: absolute; inset: 0; padding: 20px; border-radius: 22px; border: 1px solid var(--line); background: #ffffff; color: var(--text);
1476
- backface-visibility: hidden; box-shadow: 0 12px 24px rgba(0, 0, 0, 0.06); display: flex; flex-direction: column; gap: 14px;
1477
- }
1478
- .candidate-back { transform: rotateY(180deg); background: #ffffff; }
1479
- .candidate-top { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
1480
- .chip {
1481
- font-size: .72rem; text-transform: uppercase; letter-spacing: .12em; color: #0b6f66; padding: 7px 10px; border-radius: 999px;
1482
- background: rgba(23, 184, 166, 0.08); border: 1px solid rgba(23, 184, 166, 0.18);
1483
- }
1484
- .chip.alt { color: var(--gold); background: rgba(255, 122, 26, 0.08); border-color: rgba(255, 122, 26, 0.18); }
1485
- .score { font-weight: 700; color: var(--gold); }
1486
- .candidate-face h4 { margin: 0; font-size: 1.08rem; line-height: 1.35; color: var(--text); }
1487
- .candidate-face p { margin: 0; color: #1e1e1e; line-height: 1.65; font-size: .96rem; overflow-wrap: anywhere; }
1488
- .meta-row { margin-top: auto; display: flex; justify-content: space-between; color: var(--muted); font-size: .88rem; gap: 14px; }
1489
- .mini { margin-top: 8px; align-self: flex-start; color: var(--text); padding: 10px 12px; border-radius: 14px; border: 1px solid var(--line); background: #ffffff; }
1490
- .papers-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
1491
- .paper-card { border: 1px solid var(--line); border-radius: 18px; padding: 16px; background: #ffffff; }
1492
- .paper-topline { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
1493
- .paper-badge { font-size: .72rem; padding: 6px 10px; border-radius: 999px; background: rgba(98, 141, 255, 0.08); color: #3456b5; border: 1px solid rgba(98, 141, 255, 0.18); }
1494
- .paper-badge.alt { background: rgba(0, 0, 0, 0.04); color: #444; border-color: rgba(0, 0, 0, 0.08); }
1495
- .doi-badge { background: rgba(255, 122, 26, 0.08); color: #8a4105; border-color: rgba(255, 122, 26, 0.18); }
1496
- .paper-card h4 { margin: 0 0 10px; line-height: 1.35; font-size: 1rem; }
1497
- .paper-card p { margin: 0 0 12px; line-height: 1.6; color: #222; }
1498
- .paper-links { display: flex; gap: 12px; flex-wrap: wrap; }
1499
- .paper-meta-stack { display: flex; flex-direction: column; gap: 6px; color: #444; margin-bottom: 12px; font-size: .9rem; }
1500
- .paper-links a, .journal-card, .upload-note a { color: #0b63ce; text-decoration: none; }
1501
- .journal-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
1502
- .journal-card {
1503
- border: 1px solid var(--line); border-radius: 18px; padding: 16px; display: flex; justify-content: space-between; gap: 14px; align-items: center; background: #ffffff;
1504
- }
1505
- .journal-card h4 { margin: 0 0 6px; color: var(--text); }
1506
- .journal-card p { margin: 0; color: var(--muted); line-height: 1.5; }
1507
- .upload-note {
1508
- 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;
1509
- }
1510
- .prosebox { padding: 18px; white-space: pre-wrap; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; line-height: 1.55; color: #1b1b1b; }
1511
- .gr-button-primary { background: linear-gradient(135deg, rgba(255, 122, 26, 0.92), rgba(240, 108, 22, 0.92)) !important; color: #ffffff !important; border: none !important; }
1512
- .gr-button-secondary { background: #ffffff !important; color: var(--text) !important; border: 1px solid var(--line) !important; }
1513
- .ref-list { margin: 0; padding-left: 18px; }
1514
- .ref-list li { margin-bottom: 8px; line-height: 1.5; }
1515
- .parse-grid { display: grid; grid-template-columns: 1.2fr 1fr; gap: 14px; }
1516
- .parse-card {
1517
- border: 1px solid var(--line);
1518
- border-radius: 18px;
1519
- padding: 16px;
1520
- background: #ffffff;
1521
- }
1522
- .selection-panel { padding: 18px; }
1523
- footer { display: none !important; }
1524
- @keyframes pulseEdge { to { stroke-dashoffset: -40; } }
1525
- @media (max-width: 1180px) {
1526
- .model-switcher, .candidate-grid, .papers-grid, .journal-grid, .parse-grid { grid-template-columns: 1fr; }
1527
- .brain-svg { height: 460px; }
1528
- }
1529
- """
1530
- CSS = BASE_CSS + "\n" + get_dvnc_layout_css() + "\n" + get_dvnc_layout_css()
1531
- HEAD = """
1532
- <link rel="preconnect" href="https://fonts.googleapis.com">
1533
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
1534
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
1535
- """
1536
- with gr.Blocks(css=CSS, head=HEAD, theme=gr.themes.Base(), fill_height=True) as demo:
1537
- papers_state = gr.State([])
1538
- parsed_pdf_state = gr.State({})
1539
- ingest_payload_state = gr.State({})
1540
- route_state = gr.State(get_default_route_state())
1541
-
1542
- with gr.Blocks(css=CSS, head=HEAD, theme=gr.themes.Base(), fill_height=True) as demo:
1543
- papers_state = gr.State([])
1544
- parsed_pdf_state = gr.State({})
1545
- ingest_payload_state = gr.State({})
1546
-
1547
- gr.HTML(
1548
- """
1549
- <div id="dvnc-shell">
1550
- <div class="hero-bar">
1551
- <div class="brand">
1552
- <div class="logo" aria-hidden="true">
1553
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7">
1554
- <path d="M5 17L12 4l7 13"/>
1555
- <path d="M8.5 12.5h7"/>
1556
- <circle cx="12" cy="12" r="1.8" fill="currentColor" stroke="none"/>
1557
- </svg>
1558
- </div>
1559
- <div>
1560
- <h1>DVNC.AI</h1>
1561
- <p>Sovereign discovery instrument · connectome-native reasoning</p>
1562
- </div>
1563
- </div>
1564
- <div class="status"><span class="status-dot"></span><span>Live orchestration</span></div>
1565
- </div>
1566
- </div>
1567
- """
1568
- )
1569
-
1570
- with gr.Tabs():
1571
- with gr.Tab("Discovery Engine"):
1572
- model_html = gr.HTML(build_models_html("DVNC Sovereign"))
1573
- with gr.Row():
1574
- with gr.Column(scale=2):
1575
- model = gr.Dropdown(
1576
- choices=[m["name"] for m in MODELS],
1577
- value="DVNC Sovereign",
1578
- label="Model tier",
1579
- )
1580
- query = gr.Textbox(
1581
- label="Discovery query",
1582
- elem_classes=["querybox"],
1583
- placeholder="Enter a scientific question, anomaly, or breakthrough direction…",
1584
- lines=4,
1585
- )
1586
- with gr.Row():
1587
- run_btn = gr.Button("Run discovery", variant="primary")
1588
- example_btn = gr.Button("Load example", variant="secondary")
1589
- chat = gr.HTML(
1590
- """
1591
- <div class="panel chat-panel">
1592
- <div class="chat-thread">
1593
- <div class="bubble bubble-ai">
1594
- <span class="role">DVNC</span>
1595
- <p>Enter a query to activate the 7-agent discovery stack and illuminate the chosen path through the 3D connectome.</p>
1596
- </div>
1597
- </div>
1598
- </div>
1599
- """
1600
- )
1601
- with gr.Column(scale=3):
1602
- connectome = gr.HTML(build_connectome_html(DEFAULT_PATH))
1603
-
1604
- # Legacy placeholder retained for rollback.
1605
- # cards = gr.HTML('<div class="panel" style="padding:20px"><div class="candidate-grid"></div></div>')
1606
- cards = gr.HTML("")
1607
-
1608
- output = gr.Markdown("# Discovery Output\n\nAwaiting query.")
1609
- timeline = gr.HTML(get_initial_discovery_timeline_html())
1610
-
1611
- route_swap_payload = gr.Textbox(
1612
- value="",
1613
- visible=False,
1614
- elem_id="route_swap_payload",
1615
- )
1616
- route_swap_apply = gr.Button(
1617
- "Apply route swap",
1618
- visible=False,
1619
- elem_id="route_swap_apply",
1620
- )
1621
-
1622
- with gr.Tab("Self-Learning Graph"):
1623
- with gr.Row():
1624
- with gr.Column(scale=2):
1625
- paper_query = gr.Textbox(
1626
- label="Research topic / title / DOI / link",
1627
- elem_classes=["querybox"],
1628
- placeholder="e.g. self-assembling conductive biomaterials for cardiac repair",
1629
- lines=3,
1630
- )
1631
- search_mode = gr.Dropdown(
1632
- choices=SEARCH_MODES,
1633
- value="topic",
1634
- label="Search mode"
1635
- )
1636
- source_selector = gr.CheckboxGroup(
1637
- choices=SOURCE_OPTIONS,
1638
- value=DEFAULT_SOURCES,
1639
- label="Sources"
1640
- )
1641
- pdf_upload = gr.File(label="Upload PDF papers", file_types=[".pdf"], file_count="single")
1642
-
1643
- with gr.Row():
1644
- learn_btn = gr.Button("Discover papers", variant="primary")
1645
- load_topic_btn = gr.Button("Load example topic", variant="secondary")
1646
-
1647
- upload_status = gr.Markdown("No PDF uploaded yet.")
1648
- discovery_status = gr.Markdown("### No discovery results yet.")
1649
- journal_panel = gr.HTML(build_journal_html("biomaterials cardiac repair"))
1650
-
1651
- gr.HTML('<div class="panel selection-panel"><h3 style="margin:0 0 12px;">Select papers to ingest</h3></div>')
1652
- selection_box = gr.CheckboxGroup(
1653
- choices=[],
1654
- value=[],
1655
- label="Candidate papers"
1656
- )
1657
-
1658
- parser_order = gr.CheckboxGroup(
1659
- choices=["grobid", "docling", "pymupdf"],
1660
- value=["grobid", "docling", "pymupdf"],
1661
- label="Parser routing order"
1662
- )
1663
-
1664
- with gr.Row():
1665
- parse_btn = gr.Button("Parse uploaded PDF", variant="secondary")
1666
- ingest_btn = gr.Button("Ingest selected into graph", variant="primary")
1667
-
1668
- with gr.Column(scale=3):
1669
- learning_graph = gr.HTML(build_learning_graph_html([], []))
1670
- 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>')
1671
- parse_summary = gr.Markdown("### PDF parse status\n\nAwaiting upload.")
1672
- parse_panel = gr.HTML('<div class="panel" style="padding:18px"><p>No parsed document yet.</p></div>')
1673
- ingest_summary = gr.Markdown("### Graph ingest status\n\nAwaiting paper selection.")
1674
- ingest_payload = gr.JSON(label="Graph ingest payload", value={"status": "empty", "nodes": [], "edges": []})
1675
- with gr.Column(scale=3):
1676
- connectome = gr.HTML(build_connectome_html(DEFAULT_PATH))
1677
-
1678
- # Legacy placeholder retained for rollback.
1679
- # cards = gr.HTML('<div class="panel" style="padding:20px"><div class="candidate-grid"></div></div>')
1680
- cards = gr.HTML("")
1681
-
1682
- output = gr.Markdown("# Discovery Output\n\nAwaiting query.")
1683
-
1684
- # Legacy timeline placeholder retained for rollback.
1685
- # timeline = gr.HTML('<div class="panel" style="padding:18px"><div class="timeline"></div></div>')
1686
- timeline = gr.HTML(get_initial_discovery_timeline_html())
1687
-
1688
- route_swap_payload = gr.Textbox(
1689
- value="",
1690
- visible=False,
1691
- elem_id="route_swap_payload",
1692
- )
1693
- route_swap_apply = gr.Button(
1694
- "Apply route swap",
1695
- visible=False,
1696
- elem_id="route_swap_apply",
1697
- )
1698
-
1699
-
1700
- with gr.Tabs():
1701
- with gr.Tab("Discovery Engine"):
1702
- model_html = gr.HTML(build_models_html("DVNC Sovereign"))
1703
- with gr.Row():
1704
- with gr.Column(scale=2):
1705
- model = gr.Dropdown(
1706
- choices=[m["name"] for m in MODELS],
1707
- value="DVNC Sovereign",
1708
- label="Model tier",
1709
- )
1710
- query = gr.Textbox(
1711
- label="Discovery query",
1712
- elem_classes=["querybox"],
1713
- placeholder="Enter a scientific question, anomaly, or breakthrough direction…",
1714
- lines=4,
1715
- )
1716
- with gr.Row():
1717
- run_btn = gr.Button("Run discovery", variant="primary")
1718
- example_btn = gr.Button("Load example", variant="secondary")
1719
- chat = gr.HTML(
1720
- """
1721
- <div class="panel chat-panel">
1722
- <div class="chat-thread">
1723
- <div class="bubble bubble-ai">
1724
- <span class="role">DVNC</span>
1725
- <p>Enter a query to activate the 7-agent discovery stack and illuminate the chosen path through the 3D connectome.</p>
1726
- </div>
1727
- </div>
1728
- </div>
1729
- """
1730
- )
1731
- with gr.Column(scale=3):
1732
- connectome = gr.HTML(build_connectome_html(DEFAULT_PATH))
1733
- cards = gr.HTML('<div class="panel" style="padding:20px"><div class="candidate-grid"></div></div>')
1734
-
1735
- output = gr.Markdown("# Discovery Output\n\nAwaiting query.")
1736
- timeline = gr.HTML('<div class="panel" style="padding:18px"><div class="timeline"></div></div>')
1737
-
1738
- with gr.Tab("Self-Learning Graph"):
1739
- with gr.Row():
1740
- with gr.Column(scale=2):
1741
- paper_query = gr.Textbox(
1742
- label="Research topic / title / DOI / link",
1743
- elem_classes=["querybox"],
1744
- placeholder="e.g. self-assembling conductive biomaterials for cardiac repair",
1745
- lines=3,
1746
- )
1747
- search_mode = gr.Dropdown(
1748
- choices=SEARCH_MODES,
1749
- value="topic",
1750
- label="Search mode"
1751
- )
1752
- source_selector = gr.CheckboxGroup(
1753
- choices=SOURCE_OPTIONS,
1754
- value=DEFAULT_SOURCES,
1755
- label="Sources"
1756
- )
1757
- pdf_upload = gr.File(label="Upload PDF papers", file_types=[".pdf"], file_count="single")
1758
-
1759
- with gr.Row():
1760
- learn_btn = gr.Button("Discover papers", variant="primary")
1761
- load_topic_btn = gr.Button("Load example topic", variant="secondary")
1762
-
1763
- upload_status = gr.Markdown("No PDF uploaded yet.")
1764
- discovery_status = gr.Markdown("### No discovery results yet.")
1765
- journal_panel = gr.HTML(build_journal_html("biomaterials cardiac repair"))
1766
-
1767
- gr.HTML('<div class="panel selection-panel"><h3 style="margin:0 0 12px;">Select papers to ingest</h3></div>')
1768
- selection_box = gr.CheckboxGroup(
1769
- choices=[],
1770
- value=[],
1771
- label="Candidate papers"
1772
- )
1773
-
1774
- parser_order = gr.CheckboxGroup(
1775
- choices=["grobid", "docling", "pymupdf"],
1776
- value=["grobid", "docling", "pymupdf"],
1777
- label="Parser routing order"
1778
- )
1779
-
1780
- with gr.Row():
1781
- parse_btn = gr.Button("Parse uploaded PDF", variant="secondary")
1782
- ingest_btn = gr.Button("Ingest selected into graph", variant="primary")
1783
-
1784
- with gr.Column(scale=3):
1785
- learning_graph = gr.HTML(build_learning_graph_html([], []))
1786
- 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>')
1787
- parse_summary = gr.Markdown("### PDF parse status\n\nAwaiting upload.")
1788
- parse_panel = gr.HTML('<div class="panel" style="padding:18px"><p>No parsed document yet.</p></div>')
1789
- ingest_summary = gr.Markdown("### Graph ingest status\n\nAwaiting paper selection.")
1790
- ingest_payload = gr.JSON(label="Graph ingest payload", value={"status": "empty", "nodes": [], "edges": []})
1791
-
1792
- example_btn.click(fn=load_example, outputs=query)
1793
-
1794
- run_btn.click(
1795
- fn=run_discovery,
1796
- inputs=[query, model],
1797
- outputs=[chat, connectome, timeline, cards, output, model_html, route_state],
1798
- )
1799
-
1800
- route_swap_apply.click(
1801
- fn=apply_route_swap,
1802
- inputs=[query, model, route_swap_payload, route_state],
1803
- outputs=[chat, connectome, timeline, output, route_state],
1804
- )
1805
-
1806
- load_topic_btn.click(fn=load_paper_topic, outputs=paper_query)
1807
-
1808
- learn_btn.click(
1809
- fn=run_paper_discovery,
1810
- inputs=[paper_query, search_mode, source_selector, pdf_upload],
1811
- outputs=[learning_graph, papers_panel, journal_panel, upload_status, selection_box, papers_state, discovery_status],
1812
- )
1813
-
1814
- parse_btn.click(
1815
- fn=parse_uploaded_pdf,
1816
- inputs=[pdf_upload, parser_order],
1817
- outputs=[parse_summary, parsed_pdf_state],
1818
- ).then(
1819
- fn=render_parse_result,
1820
- inputs=[parsed_pdf_state],
1821
- outputs=[parse_panel],
1822
- )
1823
-
1824
- ingest_btn.click(
1825
- fn=ingest_selected_papers,
1826
- inputs=[paper_query, selection_box, papers_state, pdf_upload, parsed_pdf_state],
1827
- outputs=[learning_graph, ingest_summary, ingest_payload],
1828
- )
1829
-
1830
-
1831
- if __name__ == "__main__":
1832
- demo.launch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dvnc_ai_v2_hf/deprecated/notes.md DELETED
@@ -1 +0,0 @@
1
- this folder includes legacy files which are deprecated in v2.
 
 
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/discovery_app_bridge.py DELETED
@@ -1,81 +0,0 @@
1
- from dvnc_ai_v2_hf.discovery_route_runtime import (
2
- DISCOVERY_ROUTE_CSS,
3
- build_discovery_payload,
4
- default_route_state,
5
- )
6
-
7
-
8
- def get_discovery_css():
9
- return DISCOVERY_ROUTE_CSS
10
-
11
-
12
- def get_default_route_state():
13
- return default_route_state()
14
-
15
-
16
- def get_initial_discovery_timeline_html():
17
- return build_discovery_payload(
18
- query="Scientific discovery query",
19
- model_name="DVNC Sovereign",
20
- )["timeline_html"]
21
-
22
-
23
- def run_discovery_bridge(
24
- query,
25
- model_name,
26
- chat_builder,
27
- connectome_builder,
28
- models_builder,
29
- ):
30
- payload = build_discovery_payload(query=query, model_name=model_name)
31
-
32
- result = {
33
- "summary": payload["chat_summary"],
34
- "primary_hypothesis": payload["primary_hypothesis"],
35
- }
36
-
37
- chat_html = chat_builder(query or "Scientific discovery query", result)
38
- connectome_html = connectome_builder(payload["path"])
39
- cards_html = ""
40
-
41
- return (
42
- chat_html,
43
- connectome_html,
44
- payload["timeline_html"],
45
- cards_html,
46
- payload["output_markdown"],
47
- models_builder(model_name),
48
- payload["route_state"],
49
- )
50
-
51
-
52
- def apply_route_swap_bridge(
53
- query,
54
- model_name,
55
- route_swap_payload,
56
- route_state,
57
- chat_builder,
58
- connectome_builder,
59
- ):
60
- payload = build_discovery_payload(
61
- query=query,
62
- model_name=model_name,
63
- route_state=route_state,
64
- swap_payload_json=route_swap_payload,
65
- )
66
-
67
- result = {
68
- "summary": payload["chat_summary"],
69
- "primary_hypothesis": payload["primary_hypothesis"],
70
- }
71
-
72
- chat_html = chat_builder(query or "Scientific discovery query", result)
73
- connectome_html = connectome_builder(payload["path"])
74
-
75
- return (
76
- chat_html,
77
- connectome_html,
78
- payload["timeline_html"],
79
- payload["output_markdown"],
80
- payload["route_state"],
81
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dvnc_ai_v2_hf/discovery_route_runtime.py DELETED
@@ -1,756 +0,0 @@
1
- import html
2
- import json
3
- import re
4
- from statistics import mean
5
-
6
- DISCOVERY_ROUTE_CSS = r"""
7
- .route-shell { display: flex; flex-direction: column; gap: 16px; }
8
- .route-agent {
9
- border: 1px solid rgba(0,0,0,.10);
10
- border-radius: 22px;
11
- background: #ffffff;
12
- overflow: hidden;
13
- }
14
- .route-agent-head {
15
- display: grid;
16
- grid-template-columns: 52px 1fr;
17
- gap: 14px;
18
- align-items: start;
19
- padding: 16px 18px 14px;
20
- border-bottom: 1px solid rgba(0,0,0,.06);
21
- background: linear-gradient(180deg, rgba(255,255,255,1), rgba(250,250,250,1));
22
- }
23
- .route-step {
24
- width: 52px;
25
- height: 52px;
26
- border-radius: 16px;
27
- display: grid;
28
- place-items: center;
29
- font-weight: 800;
30
- color: #ff6600;
31
- background: rgba(255, 122, 26, 0.08);
32
- border: 1px solid rgba(255, 122, 26, 0.18);
33
- }
34
- .route-agent-head h4 {
35
- margin: 0 0 4px;
36
- font-size: 1rem;
37
- color: #111111;
38
- }
39
- .route-agent-head p {
40
- margin: 0;
41
- color: #444444;
42
- line-height: 1.55;
43
- font-size: .93rem;
44
- }
45
- .route-grid {
46
- display: grid;
47
- grid-template-columns: repeat(3, minmax(0, 1fr));
48
- gap: 14px;
49
- padding: 16px;
50
- }
51
- .route-card {
52
- position: relative;
53
- border: 1px solid rgba(0,0,0,.10);
54
- border-radius: 20px;
55
- background: #ffffff;
56
- padding: 16px;
57
- display: flex;
58
- flex-direction: column;
59
- gap: 12px;
60
- min-height: 280px;
61
- transition: transform .18s ease, box-shadow .18s ease, border-color .18s ease;
62
- }
63
- .route-card:hover {
64
- transform: translateY(-2px);
65
- box-shadow: 0 10px 22px rgba(0,0,0,.06);
66
- }
67
- .route-card.active {
68
- border-color: rgba(255, 122, 26, 0.42);
69
- box-shadow:
70
- 0 0 0 1px rgba(255, 122, 26, 0.22),
71
- 0 0 0 8px rgba(255, 122, 26, 0.07),
72
- 0 16px 32px rgba(255, 122, 26, 0.10);
73
- background: linear-gradient(180deg, rgba(255,247,241,1), rgba(255,255,255,1));
74
- }
75
- .route-card.flash {
76
- animation: routeFlash 1.2s ease;
77
- }
78
- .route-top {
79
- display: flex;
80
- justify-content: space-between;
81
- align-items: center;
82
- gap: 10px;
83
- }
84
- .route-chip {
85
- font-size: .72rem;
86
- text-transform: uppercase;
87
- letter-spacing: .12em;
88
- color: #0b6f66;
89
- padding: 6px 10px;
90
- border-radius: 999px;
91
- background: rgba(23, 184, 166, 0.08);
92
- border: 1px solid rgba(23, 184, 166, 0.18);
93
- }
94
- .route-score {
95
- font-weight: 800;
96
- color: #ff6600;
97
- }
98
- .route-card h5 {
99
- margin: 0;
100
- font-size: 1rem;
101
- line-height: 1.35;
102
- color: #111111;
103
- }
104
- .route-card p {
105
- margin: 0;
106
- color: #222222;
107
- line-height: 1.6;
108
- font-size: .93rem;
109
- }
110
- .route-meta {
111
- margin-top: auto;
112
- display: flex;
113
- justify-content: space-between;
114
- gap: 12px;
115
- color: #5b5b5b;
116
- font-size: .87rem;
117
- }
118
- .route-actions {
119
- display: flex;
120
- gap: 10px;
121
- margin-top: 2px;
122
- }
123
- .route-btn {
124
- border: 1px solid rgba(0,0,0,.10);
125
- background: #ffffff;
126
- color: #111111;
127
- border-radius: 14px;
128
- padding: 10px 12px;
129
- cursor: pointer;
130
- font-weight: 600;
131
- }
132
- .route-btn.primary {
133
- background: linear-gradient(135deg, rgba(255, 122, 26, 0.94), rgba(240, 108, 22, 0.94));
134
- color: #ffffff;
135
- border-color: transparent;
136
- }
137
- .route-btn[disabled] {
138
- cursor: default;
139
- opacity: .92;
140
- }
141
- .route-note {
142
- color: #666666;
143
- font-size: .84rem;
144
- line-height: 1.45;
145
- }
146
- @keyframes routeFlash {
147
- 0% { box-shadow: 0 0 0 0 rgba(255,122,26,.35); }
148
- 100% { box-shadow: 0 0 0 12px rgba(255,122,26,0); }
149
- }
150
- @media (max-width: 1180px) {
151
- .route-grid { grid-template-columns: 1fr; }
152
- }
153
- """
154
-
155
-
156
- def safe_text(x, default=""):
157
- return html.escape(str(x if x is not None else default))
158
-
159
-
160
- AGENT_ROUTE_LIBRARY = [
161
- {
162
- "key": "query_interpreter",
163
- "step": 1,
164
- "name": "Query Interpreter",
165
- "tag": "input",
166
- "overview": "Normalises the user prompt into a graph-searchable seed and isolates the scientific tension that should drive the rest of the route.",
167
- "cards": [
168
- {
169
- "key": "mechanistic_decomposition",
170
- "title": "Mechanistic decomposition",
171
- "summary": "Resolve the prompt into substrate, perturbation, state variable, measurable readout, and timescale.",
172
- "swap_note": "Best when the route should open with hard causal framing.",
173
- "score": 91,
174
- "novelty": "High clarity",
175
- "route_bias": "electro",
176
- "program_bias": "mechanistic",
177
- },
178
- {
179
- "key": "multi_scale_framing",
180
- "title": "Multi-scale framing",
181
- "summary": "Split the problem across molecular, cellular, tissue, and systems levels before branching.",
182
- "swap_note": "Best when bridge variables across scales matter more than a single mechanism.",
183
- "score": 86,
184
- "novelty": "Broad scope",
185
- "route_bias": "immune",
186
- "program_bias": "systems",
187
- },
188
- {
189
- "key": "constraint_led_framing",
190
- "title": "Constraint-led framing",
191
- "summary": "Start from assay, delivery, model-system, and validation constraints instead of pure theory.",
192
- "swap_note": "Best when experimental tractability should dominate from the first step.",
193
- "score": 84,
194
- "novelty": "Practical",
195
- "route_bias": "assembly",
196
- "program_bias": "translation",
197
- },
198
- ],
199
- },
200
- {
201
- "key": "graph_divergence_mapper",
202
- "step": 2,
203
- "name": "Graph Divergence Mapper",
204
- "tag": "graph",
205
- "overview": "Finds remote conceptual bridges instead of staying near the starting domain cluster.",
206
- "cards": [
207
- {
208
- "key": "local_causal_neighbourhood",
209
- "title": "Local causal neighbourhood",
210
- "summary": "Expand first through nearest mechanistic neighbours to preserve interpretability.",
211
- "swap_note": "Best when the route should stay conservative around known causal structure.",
212
- "score": 88,
213
- "novelty": "Stable",
214
- "route_bias": "immune",
215
- "program_bias": "mechanistic",
216
- },
217
- {
218
- "key": "cross_scale_bridge_mapping",
219
- "title": "Cross-scale bridge mapping",
220
- "summary": "Search for hidden intermediates that convert local events into tissue or system outcomes.",
221
- "swap_note": "Best when the graph must discover bridge variables across levels.",
222
- "score": 93,
223
- "novelty": "High novelty",
224
- "route_bias": "electro",
225
- "program_bias": "systems",
226
- },
227
- {
228
- "key": "counterfactual_branches",
229
- "title": "Counterfactual branches",
230
- "summary": "Generate explicit rival graph routes that could explain the same observed effect.",
231
- "swap_note": "Best when disagreement and adversarial structure should appear early.",
232
- "score": 89,
233
- "novelty": "Adversarial",
234
- "route_bias": "piezo",
235
- "program_bias": "falsification",
236
- },
237
- ],
238
- },
239
- {
240
- "key": "evidence_harvester",
241
- "step": 3,
242
- "name": "Evidence Harvester",
243
- "tag": "evidence",
244
- "overview": "Pulls evidence packets and conflict signals required for grounded hypothesis formation.",
245
- "cards": [
246
- {
247
- "key": "supportive_evidence_packet",
248
- "title": "Supportive evidence packet",
249
- "summary": "Collect papers that directly strengthen the active route and mechanism stack.",
250
- "swap_note": "Best when the composer needs the strongest positive case first.",
251
- "score": 87,
252
- "novelty": "Direct",
253
- "route_bias": "assembly",
254
- "program_bias": "evidence",
255
- },
256
- {
257
- "key": "contradiction_scan",
258
- "title": "Contradiction scan",
259
- "summary": "Prioritise limiting conditions, null findings, and papers that invert the result.",
260
- "swap_note": "Best when context dependence matters more than consensus.",
261
- "score": 92,
262
- "novelty": "Scientist-grade",
263
- "route_bias": "immune",
264
- "program_bias": "falsification",
265
- },
266
- {
267
- "key": "assay_bias_scan",
268
- "title": "Assay bias scan",
269
- "summary": "Look for missing readouts and weak proxies that may distort the literature signal.",
270
- "swap_note": "Best when measurement weakness is likely driving false confidence.",
271
- "score": 85,
272
- "novelty": "Methods-aware",
273
- "route_bias": "piezo",
274
- "program_bias": "measurement",
275
- },
276
- ],
277
- },
278
- {
279
- "key": "analogy_engine",
280
- "step": 4,
281
- "name": "Analogy Engine",
282
- "tag": "analogy",
283
- "overview": "Generates cross-domain analogies with a bias toward mechanism transfer rather than keyword similarity.",
284
- "cards": [
285
- {
286
- "key": "near_domain_transfer",
287
- "title": "Near-domain transfer",
288
- "summary": "Borrow mechanisms from adjacent domains where variables and constraints still align.",
289
- "swap_note": "Best when novelty should stay close to interpretable biology or materials science.",
290
- "score": 86,
291
- "novelty": "Controlled",
292
- "route_bias": "immune",
293
- "program_bias": "translation",
294
- },
295
- {
296
- "key": "distant_mechanism_transfer",
297
- "title": "Distant mechanism transfer",
298
- "summary": "Import remote control logic from another field with conserved dynamics or topology.",
299
- "swap_note": "Best when maximal novelty is needed without relying on keyword similarity.",
300
- "score": 94,
301
- "novelty": "Very high",
302
- "route_bias": "piezo",
303
- "program_bias": "novelty",
304
- },
305
- {
306
- "key": "material_behaviour_analogy",
307
- "title": "Material behaviour analogy",
308
- "summary": "Recast the system through mechanics, conductivity, self-assembly, or feedback material behaviour.",
309
- "swap_note": "Best when physical behaviour is a stronger bridge than biological naming.",
310
- "score": 90,
311
- "novelty": "Bridge-heavy",
312
- "route_bias": "electro",
313
- "program_bias": "mechanistic",
314
- },
315
- ],
316
- },
317
- {
318
- "key": "hypothesis_composer",
319
- "step": 5,
320
- "name": "Hypothesis Composer",
321
- "tag": "compose",
322
- "overview": "Composes the lead hypothesis and two structurally different variants.",
323
- "cards": [
324
- {
325
- "key": "single_lead_hypothesis",
326
- "title": "Single lead hypothesis",
327
- "summary": "Compose one sharp mechanism-led thesis with strong causal sequencing.",
328
- "swap_note": "Best when the system should commit early to the strongest line of attack.",
329
- "score": 89,
330
- "novelty": "Focused",
331
- "route_bias": "electro",
332
- "program_bias": "mechanistic",
333
- },
334
- {
335
- "key": "three_route_composition",
336
- "title": "Three-route composition",
337
- "summary": "Produce a lead hypothesis plus two structurally different alternatives side by side.",
338
- "swap_note": "Best when route comparison is part of the product experience.",
339
- "score": 93,
340
- "novelty": "Portfolio",
341
- "route_bias": "assembly",
342
- "program_bias": "portfolio",
343
- },
344
- {
345
- "key": "claim_evidence_assembly",
346
- "title": "Claim-evidence assembly",
347
- "summary": "Bind each major claim to evidence packets, expected signals, and failure modes.",
348
- "swap_note": "Best when graph-ready structured output matters more than prose elegance.",
349
- "score": 91,
350
- "novelty": "Structured",
351
- "route_bias": "immune",
352
- "program_bias": "evidence",
353
- },
354
- ],
355
- },
356
- {
357
- "key": "adversarial_critic",
358
- "step": 6,
359
- "name": "Adversarial Critic",
360
- "tag": "critique",
361
- "overview": "Attacks weak assumptions, hidden confounders, and feasibility gaps.",
362
- "cards": [
363
- {
364
- "key": "confounder_attack",
365
- "title": "Confounder attack",
366
- "summary": "Stress test hidden variables, proxy leakage, and spurious causal chains.",
367
- "swap_note": "Best when false positives and mistaken mechanisms are the main risk.",
368
- "score": 90,
369
- "novelty": "Critical",
370
- "route_bias": "electro",
371
- "program_bias": "falsification",
372
- },
373
- {
374
- "key": "boundary_condition_attack",
375
- "title": "Boundary-condition attack",
376
- "summary": "Probe dose, timescale, cell type, species, and substrate conditions that may invert the result.",
377
- "swap_note": "Best when the hypothesis is probably true only in a narrow regime.",
378
- "score": 92,
379
- "novelty": "Boundary-aware",
380
- "route_bias": "immune",
381
- "program_bias": "falsification",
382
- },
383
- {
384
- "key": "feasibility_attack",
385
- "title": "Feasibility attack",
386
- "summary": "Target fabrication, delivery, stability, toxicity, and validation bottlenecks.",
387
- "swap_note": "Best when the science is elegant but the build path may fail.",
388
- "score": 87,
389
- "novelty": "Reality check",
390
- "route_bias": "assembly",
391
- "program_bias": "translation",
392
- },
393
- ],
394
- },
395
- {
396
- "key": "experimental_program_designer",
397
- "step": 7,
398
- "name": "Experimental Program Designer",
399
- "tag": "experiment",
400
- "overview": "Produces a staged validation plan with measurable falsification criteria.",
401
- "cards": [
402
- {
403
- "key": "fast_falsification_plan",
404
- "title": "Fast falsification plan",
405
- "summary": "Design the smallest decisive experiments that can kill the route quickly.",
406
- "swap_note": "Best when rapid learning should come before a full program build.",
407
- "score": 88,
408
- "novelty": "Lean",
409
- "route_bias": "electro",
410
- "program_bias": "falsification",
411
- },
412
- {
413
- "key": "staged_validation_program",
414
- "title": "Staged validation program",
415
- "summary": "Move from mechanistic assay to comparative benchmark to translational test in sequence.",
416
- "swap_note": "Best when the output should read like a complete scientific program.",
417
- "score": 94,
418
- "novelty": "Complete",
419
- "route_bias": "immune",
420
- "program_bias": "translation",
421
- },
422
- {
423
- "key": "orthogonal_readout_design",
424
- "title": "Orthogonal readout design",
425
- "summary": "Use multiple independent readouts so the route does not depend on one proxy measurement.",
426
- "swap_note": "Best when evidence quality matters more than sheer speed.",
427
- "score": 91,
428
- "novelty": "Robust",
429
- "route_bias": "piezo",
430
- "program_bias": "measurement",
431
- },
432
- ],
433
- },
434
- ]
435
-
436
- DEFAULT_ROUTE_STATE = {agent["key"]: agent["cards"][0]["key"] for agent in AGENT_ROUTE_LIBRARY}
437
-
438
-
439
- def default_route_state():
440
- return dict(DEFAULT_ROUTE_STATE)
441
-
442
-
443
- def _normalise_text(x: str) -> str:
444
- return re.sub(r"\s+", " ", x or "").strip()
445
-
446
-
447
- def _get_agent(agent_key):
448
- for agent in AGENT_ROUTE_LIBRARY:
449
- if agent["key"] == agent_key:
450
- return agent
451
- return None
452
-
453
-
454
- def _get_card(agent_key, card_key):
455
- agent = _get_agent(agent_key)
456
- if not agent:
457
- return None
458
- for card in agent["cards"]:
459
- if card["key"] == card_key:
460
- return card
461
- return agent["cards"][0]
462
-
463
-
464
- def normalise_route_state(route_state):
465
- state = dict(DEFAULT_ROUTE_STATE)
466
- if isinstance(route_state, dict):
467
- for agent in AGENT_ROUTE_LIBRARY:
468
- agent_key = agent["key"]
469
- if route_state.get(agent_key):
470
- state[agent_key] = route_state[agent_key]
471
- return state
472
-
473
-
474
- def apply_route_payload(route_state, swap_payload_json=None):
475
- state = normalise_route_state(route_state)
476
- if not swap_payload_json:
477
- return state
478
- try:
479
- payload = json.loads(swap_payload_json)
480
- except Exception:
481
- return state
482
-
483
- agent_key = payload.get("agent")
484
- card_key = payload.get("card")
485
- agent = _get_agent(agent_key)
486
- if not agent:
487
- return state
488
- valid = {card["key"] for card in agent["cards"]}
489
- if card_key in valid:
490
- state[agent_key] = card_key
491
- return state
492
-
493
-
494
- def build_reasoning(route_state):
495
- state = normalise_route_state(route_state)
496
- reasoning = []
497
- for agent in AGENT_ROUTE_LIBRARY:
498
- card = _get_card(agent["key"], state[agent["key"]])
499
- reasoning.append(
500
- {
501
- "step": agent["step"],
502
- "agent": agent["name"],
503
- "tag": agent["tag"],
504
- "summary": card["summary"],
505
- "card_title": card["title"],
506
- "score": card["score"],
507
- "route_bias": card["route_bias"],
508
- "program_bias": card["program_bias"],
509
- }
510
- )
511
- return reasoning
512
-
513
-
514
- def derive_connectome_path(route_state, query=""):
515
- state = normalise_route_state(route_state)
516
- weights = {"electro": 0, "immune": 0, "piezo": 0, "assembly": 0}
517
-
518
- for agent in AGENT_ROUTE_LIBRARY:
519
- card = _get_card(agent["key"], state[agent["key"]])
520
- weights[card["route_bias"]] += int(card["score"])
521
-
522
- q = (query or "").lower()
523
- if any(x in q for x in ["piezo", "electrical", "electro", "voltage", "conductive"]):
524
- weights["electro"] += 10
525
- weights["piezo"] += 6
526
- if any(x in q for x in ["immune", "inflammation", "macrophage", "scar"]):
527
- weights["immune"] += 10
528
- if any(x in q for x in ["self-assembly", "self assembly", "mesh", "hydrogel"]):
529
- weights["assembly"] += 10
530
-
531
- winner = max(weights, key=weights.get)
532
- if winner == "immune":
533
- return ["seed", "bio", "card", "immune", "trial"]
534
- if winner == "piezo":
535
- return ["seed", "bio", "card", "alt1", "trial"]
536
- if winner == "assembly":
537
- return ["seed", "nano", "selfasm", "alt2", "trial"]
538
- return ["seed", "nano", "selfasm", "electro", "trial"]
539
-
540
-
541
- def compute_metrics(route_state):
542
- reasoning = build_reasoning(route_state)
543
- scores = [r["score"] for r in reasoning]
544
-
545
- mechanistic = round(mean([reasoning[0]["score"], reasoning[1]["score"], reasoning[3]["score"], reasoning[4]["score"]]))
546
- falsifiability = round(mean([reasoning[2]["score"], reasoning[5]["score"], reasoning[6]["score"]]))
547
- translational = round(mean([reasoning[0]["score"], reasoning[5]["score"], reasoning[6]["score"]]))
548
- novelty = round(mean(scores))
549
-
550
- return {
551
- "Novelty": novelty,
552
- "Mechanistic resolution": mechanistic,
553
- "Falsifiability": falsifiability,
554
- "Translational plausibility": translational,
555
- }
556
-
557
-
558
- def build_primary_hypothesis(query, route_state):
559
- path = derive_connectome_path(route_state, query)
560
- q = _normalise_text(query) or "the active research problem"
561
-
562
- if path == ["seed", "bio", "card", "alt1", "trial"]:
563
- return (
564
- f"Advance a piezoelectric scaffold program for {q}, using strain-coupled microcurrent generation "
565
- f"to entrain repair-relevant signalling while benchmarking fibrosis risk, power density, and manufacturability."
566
- )
567
-
568
- if path == ["seed", "bio", "card", "immune", "trial"]:
569
- return (
570
- f"Advance an immune-tuned conductive hydrogel program for {q}, coupling electrophysiological support "
571
- f"with macrophage-state control to improve regenerative coordination while explicitly testing boundary conditions."
572
- )
573
-
574
- if path == ["seed", "nano", "selfasm", "alt2", "trial"]:
575
- return (
576
- f"Advance a peptide self-assembly mesh program for {q}, using adaptive local assembly to create a regenerative "
577
- f"substrate that can be stress-tested for degradation timing, targeting specificity, and scale-up feasibility."
578
- )
579
-
580
- return (
581
- f"Advance a self-assembling conductive scaffold program for {q}, using mechano-electrical conversion and local "
582
- f"signal steering to improve repair while preserving a fast falsification path and industrial build discipline."
583
- )
584
-
585
-
586
- def build_chat_summary(query, route_state):
587
- reasoning = build_reasoning(route_state)
588
- lead = reasoning[0]["card_title"]
589
- bridge = reasoning[1]["card_title"]
590
- critic = reasoning[5]["card_title"]
591
- experiment = reasoning[6]["card_title"]
592
-
593
- return (
594
- f"The active route now opens with {lead}, moves through {bridge}, is stress-tested by {critic}, "
595
- f"and terminates in {experiment}, so the connectome reflects a scientist-grade program rather than a generic brainstorm."
596
- )
597
-
598
-
599
- def render_stevens_output(query, model_name, route_state):
600
- reasoning = build_reasoning(route_state)
601
- metrics = compute_metrics(route_state)
602
- primary = build_primary_hypothesis(query, route_state)
603
-
604
- active_cards = {r["agent"]: r["card_title"] for r in reasoning}
605
- q = _normalise_text(query) or "the submitted scientific question"
606
-
607
- metrics_md = "\n".join([f"- {k}: {v}/100" for k, v in metrics.items()])
608
-
609
- return f"""# Discovery Output
610
-
611
- **Model:** {model_name}
612
-
613
- ## Program thesis
614
-
615
- **Lead program:** {primary}
616
-
617
- This route is structured for an industrial scientific team: it commits to one lead program, preserves explicit failure modes, and keeps the next experiment tightly coupled to the mechanistic claim under test.
618
-
619
- ## Route configuration
620
-
621
- - Query Interpreter: {active_cards['Query Interpreter']}
622
- - Graph Divergence Mapper: {active_cards['Graph Divergence Mapper']}
623
- - Evidence Harvester: {active_cards['Evidence Harvester']}
624
- - Analogy Engine: {active_cards['Analogy Engine']}
625
- - Hypothesis Composer: {active_cards['Hypothesis Composer']}
626
- - Adversarial Critic: {active_cards['Adversarial Critic']}
627
- - Experimental Program Designer: {active_cards['Experimental Program Designer']}
628
-
629
- ## Scientific scoring
630
-
631
- {metrics_md}
632
-
633
- ## Decision-grade experimental program
634
-
635
- 1. Define the minimum mechanistic claim that must be true for **{q}** to justify program expansion.
636
- 2. Run one decisive assay panel with orthogonal readouts, including a primary mechanism readout, a boundary-condition control, and a direct negative control.
637
- 3. Benchmark the lead route against the nearest conservative alternative rather than only against baseline.
638
- 4. Promote the route only if the effect survives the critic-defined failure modes, the readouts agree, and the build path remains manufacturable.
639
-
640
- ## Kill criteria
641
-
642
- - Kill the route if the main effect depends on a single proxy readout.
643
- - Kill the route if the effect collapses under realistic substrate, dose, or timescale variation.
644
- - Kill the route if fabrication, delivery, stability, or toxicity constraints dominate the scientific upside.
645
-
646
- ## Industrial translation notes
647
-
648
- - Prefer programs that preserve assay portability from mechanistic validation into benchmark comparison.
649
- - Record every promoted route as a graph-ready claim, linked to evidence, readouts, and explicit failure criteria.
650
- - Keep the output legible to a principal investigator, a platform scientist, and a translational lead at the same time.
651
- """
652
-
653
-
654
- def render_route_cards_html(route_state, payload_elem_id="route_swap_payload", trigger_elem_id="route_swap_apply"):
655
- state = normalise_route_state(route_state)
656
- sections = []
657
-
658
- for agent in AGENT_ROUTE_LIBRARY:
659
- active_key = state[agent["key"]]
660
- ordered_cards = sorted(agent["cards"], key=lambda c: 0 if c["key"] == active_key else 1)
661
-
662
- card_html = []
663
- for card in ordered_cards:
664
- is_active = card["key"] == active_key
665
- payload = json.dumps({"agent": agent["key"], "card": card["key"]})
666
- button_html = (
667
- '<button class="route-btn primary" type="button" disabled>Main route</button>'
668
- if is_active else
669
- f'<button class="route-btn" type="button" data-route-swap=\'{safe_text(payload)}\'>Flip insight</button>'
670
- )
671
-
672
- card_html.append(
673
- f"""
674
- <article class="route-card{' active flash' if is_active else ''}">
675
- <div class="route-top">
676
- <span class="route-chip">{safe_text(agent['tag'])}</span>
677
- <span class="route-score">{safe_text(card['score'])}</span>
678
- </div>
679
- <h5>{safe_text(card['title'])}</h5>
680
- <p>{safe_text(card['summary'])}</p>
681
- <div class="route-meta">
682
- <span>Novelty</span>
683
- <strong>{safe_text(card['novelty'])}</strong>
684
- </div>
685
- <div class="route-note">{safe_text(card['swap_note'])}</div>
686
- <div class="route-actions">{button_html}</div>
687
- </article>
688
- """
689
- )
690
-
691
- sections.append(
692
- f"""
693
- <section class="route-agent">
694
- <div class="route-agent-head">
695
- <div class="route-step">{agent['step']}</div>
696
- <div>
697
- <h4>{safe_text(agent['name'])}</h4>
698
- <p>{safe_text(agent['overview'])}</p>
699
- </div>
700
- </div>
701
- <div class="route-grid">
702
- {''.join(card_html)}
703
- </div>
704
- </section>
705
- """
706
- )
707
-
708
- script = f"""
709
- <script>
710
- (() => {{
711
- const shell = document.currentScript.closest('.route-shell');
712
- if (!shell || shell.dataset.bound === "1") return;
713
- shell.dataset.bound = "1";
714
-
715
- const resolveBridge = () => document.querySelector('#{payload_elem_id} textarea, #{payload_elem_id} input');
716
- const resolveTrigger = () => document.querySelector('#{trigger_elem_id}');
717
-
718
- const setValue = (el, value) => {{
719
- const proto = el.tagName === 'TEXTAREA'
720
- ? window.HTMLTextAreaElement.prototype
721
- : window.HTMLInputElement.prototype;
722
- const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
723
- if (setter) setter.call(el, value);
724
- else el.value = value;
725
- el.dispatchEvent(new Event('input', {{ bubbles: true }}));
726
- el.dispatchEvent(new Event('change', {{ bubbles: true }}));
727
- }};
728
-
729
- shell.querySelectorAll('[data-route-swap]').forEach((btn) => {{
730
- btn.addEventListener('click', () => {{
731
- const payload = btn.getAttribute('data-route-swap');
732
- const bridge = resolveBridge();
733
- const trigger = resolveTrigger();
734
- if (!bridge || !trigger) return;
735
- setValue(bridge, payload);
736
- trigger.click();
737
- }});
738
- }});
739
- }})();
740
- </script>
741
- """
742
-
743
- return f'<div class="route-shell">{"".join(sections)}{script}</div>'
744
-
745
-
746
- def build_discovery_payload(query, model_name, route_state=None, swap_payload_json=None):
747
- state = apply_route_payload(route_state or default_route_state(), swap_payload_json)
748
- return {
749
- "route_state": state,
750
- "reasoning": build_reasoning(state),
751
- "path": derive_connectome_path(state, query),
752
- "primary_hypothesis": build_primary_hypothesis(query, state),
753
- "chat_summary": build_chat_summary(query, state),
754
- "output_markdown": render_stevens_output(query, model_name, state),
755
- "timeline_html": render_route_cards_html(state),
756
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dvnc_ai_v2_hf/dvnc_ui_layout.py DELETED
@@ -1,209 +0,0 @@
1
- from textwrap import dedent
2
-
3
- def get_dvnc_layout_css() -> str:
4
- return dedent(r'''
5
- :root {
6
- --dvnc-page-max: min(1880px, calc(100vw - 32px));
7
- --dvnc-content-max: min(1840px, calc(100vw - 48px));
8
- --dvnc-route-gap: 22px;
9
- --dvnc-summary-col: clamp(280px, 18vw, 360px);
10
- }
11
-
12
- html, body, .gradio-container {
13
- overflow-x: hidden !important;
14
- }
15
-
16
- .gradio-container {
17
- max-width: var(--dvnc-page-max) !important;
18
- width: 100% !important;
19
- margin: 0 auto !important;
20
- padding: 16px !important;
21
- }
22
-
23
- #dvnc-shell {
24
- width: 100% !important;
25
- max-width: var(--dvnc-content-max) !important;
26
- margin: 0 auto !important;
27
- padding: 20px 20px 28px !important;
28
- box-sizing: border-box !important;
29
- }
30
-
31
- #dvnc-shell > .gradio-row,
32
- #dvnc-shell .gradio-row {
33
- width: 100% !important;
34
- max-width: 100% !important;
35
- flex-wrap: nowrap !important;
36
- align-items: stretch !important;
37
- }
38
-
39
- #dvnc-shell .gradio-column,
40
- #dvnc-shell .gr-block,
41
- #dvnc-shell .gr-box,
42
- #dvnc-shell .gr-panel {
43
- min-width: 0 !important;
44
- }
45
-
46
- #dvnc-shell .gradio-column.scale-0,
47
- #dvnc-shell .gradio-column.scale-1,
48
- #dvnc-shell .gradio-column.scale-2,
49
- #dvnc-shell .gradio-column.scale-3,
50
- #dvnc-shell .gradio-column.scale-4,
51
- #dvnc-shell .gradio-column.scale-5,
52
- #dvnc-shell .gradio-column.scale-6,
53
- #dvnc-shell .gradio-column.scale-7,
54
- #dvnc-shell .gradio-column.scale-8,
55
- #dvnc-shell .gradio-column.scale-9,
56
- #dvnc-shell .gradio-column.scale-10,
57
- #dvnc-shell [class*='scale-'] {
58
- min-width: 0 !important;
59
- }
60
-
61
- #dvnc-shell .stretch,
62
- #dvnc-shell [style*='flex-grow'] {
63
- min-width: 0 !important;
64
- }
65
-
66
- #dvnc-shell .prose,
67
- #dvnc-shell .markdown,
68
- #dvnc-shell .gr-markdown,
69
- #dvnc-shell .gr-html,
70
- #dvnc-shell .gradio-html {
71
- max-width: 100% !important;
72
- width: 100% !important;
73
- }
74
-
75
- #dvnc-shell .gr-html > div,
76
- #dvnc-shell .gradio-html > div,
77
- #dvnc-shell .gr-markdown > div {
78
- max-width: 100% !important;
79
- }
80
-
81
- #dvnc-shell #dvnc-agent-route-cards,
82
- #dvnc-shell #dvnc-agent-route-cards.dvnc-agent-route-shell {
83
- width: 100% !important;
84
- max-width: none !important;
85
- margin: 0 !important;
86
- padding-inline: 0 !important;
87
- box-sizing: border-box !important;
88
- }
89
-
90
- #dvnc-shell #dvnc-agent-route-cards .dvnc-agent-route-grid {
91
- width: 100% !important;
92
- max-width: none !important;
93
- }
94
-
95
- #dvnc-shell #dvnc-agent-route-cards .dvnc-agent-row {
96
- grid-template-columns: minmax(260px, var(--dvnc-summary-col)) minmax(0, 1fr) !important;
97
- gap: var(--dvnc-route-gap) !important;
98
- width: 100% !important;
99
- max-width: 100% !important;
100
- align-items: stretch !important;
101
- }
102
-
103
- #dvnc-shell #dvnc-agent-route-cards .dvnc-agent-summary-panel {
104
- min-height: 100% !important;
105
- height: 100% !important;
106
- align-self: stretch !important;
107
- position: sticky;
108
- top: 18px;
109
- }
110
-
111
- #dvnc-shell #dvnc-agent-route-cards .dvnc-agent-cards {
112
- display: grid !important;
113
- grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
114
- gap: 16px !important;
115
- width: 100% !important;
116
- max-width: 100% !important;
117
- align-items: stretch !important;
118
- justify-content: stretch !important;
119
- }
120
-
121
- #dvnc-shell #dvnc-agent-route-cards .dvnc-route-card,
122
- #dvnc-shell #dvnc-agent-route-cards .dvnc-route-card-inner,
123
- #dvnc-shell #dvnc-agent-route-cards .dvnc-route-face {
124
- width: 100% !important;
125
- max-width: 100% !important;
126
- box-sizing: border-box !important;
127
- }
128
-
129
- #dvnc-shell #dvnc-agent-route-cards .dvnc-route-face {
130
- min-height: 320px !important;
131
- }
132
-
133
- #dvnc-shell #dvnc-agent-route-cards .dvnc-route-top,
134
- #dvnc-shell #dvnc-agent-route-cards .dvnc-route-meta,
135
- #dvnc-shell #dvnc-agent-route-cards .dvnc-route-actions {
136
- gap: 10px !important;
137
- }
138
-
139
- #dvnc-shell #dvnc-agent-route-cards .dvnc-route-badge,
140
- #dvnc-shell #dvnc-agent-route-cards .dvnc-route-score,
141
- #dvnc-shell #dvnc-agent-route-cards .dvnc-route-face h4,
142
- #dvnc-shell #dvnc-agent-route-cards .dvnc-route-face p,
143
- #dvnc-shell #dvnc-agent-route-cards .dvnc-route-btn,
144
- #dvnc-shell #dvnc-agent-route-cards .dvnc-agent-copy p,
145
- #dvnc-shell #dvnc-agent-route-cards .dvnc-agent-topline span {
146
- white-space: normal !important;
147
- overflow-wrap: anywhere !important;
148
- word-break: break-word !important;
149
- }
150
-
151
- #dvnc-shell .panel,
152
- #dvnc-shell .chat-panel,
153
- #dvnc-shell .querybox,
154
- #dvnc-shell .gr-block,
155
- #dvnc-shell .gr-box {
156
- width: 100% !important;
157
- max-width: 100% !important;
158
- box-sizing: border-box !important;
159
- }
160
-
161
- @media (max-width: 1560px) {
162
- #dvnc-shell #dvnc-agent-route-cards .dvnc-agent-row {
163
- grid-template-columns: minmax(250px, 320px) minmax(0, 1fr) !important;
164
- }
165
- }
166
-
167
- @media (max-width: 1360px) {
168
- #dvnc-shell #dvnc-agent-route-cards .dvnc-agent-cards {
169
- grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
170
- }
171
- }
172
-
173
- @media (max-width: 1080px) {
174
- #dvnc-shell > .gradio-row,
175
- #dvnc-shell .gradio-row {
176
- flex-wrap: wrap !important;
177
- }
178
-
179
- #dvnc-shell #dvnc-agent-route-cards .dvnc-agent-row {
180
- grid-template-columns: 1fr !important;
181
- }
182
-
183
- #dvnc-shell #dvnc-agent-route-cards .dvnc-agent-summary-panel {
184
- position: static;
185
- }
186
-
187
- #dvnc-shell #dvnc-agent-route-cards .dvnc-agent-cards {
188
- grid-template-columns: 1fr !important;
189
- }
190
- }
191
- ''')
192
-
193
-
194
- def get_dvnc_layout_readme() -> str:
195
- return dedent('''
196
- # DVNC UI layout patch
197
-
198
- Import this file from `dvnc_ai_v2_hf/app.py` and append `get_dvnc_layout_css()` to your main CSS string.
199
-
200
- Example:
201
-
202
- ```python
203
- from dvnc_ai_v2_hf.dvnc_ui_layout import get_dvnc_layout_css
204
-
205
- CSS = BASE_CSS + "\\n" + get_dvnc_layout_css()
206
- ```
207
-
208
- This patch fixes the common Gradio/HTML layout issue where the custom route-card HTML remains constrained by an inner left-aligned container instead of expanding to full available width.
209
- ''')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dvnc_ai_v2_hf/graph_canvas_patch.py DELETED
@@ -1,977 +0,0 @@
1
- import html
2
- import json
3
- import re
4
- from typing import Any, Dict, List
5
-
6
-
7
- TYPE_STYLES = {
8
- "topic": {"color": "#38bdf8", "size": 12, "label": "Topic"},
9
- "query": {"color": "#38bdf8", "size": 12, "label": "Topic"},
10
- "paper": {"color": "#34d399", "size": 9, "label": "Paper"},
11
- "uploadedpdf": {"color": "#f59e0b", "size": 10, "label": "Uploaded PDF"},
12
- "upload": {"color": "#f59e0b", "size": 10, "label": "Uploaded PDF"},
13
- "concept": {"color": "#a78bfa", "size": 7, "label": "Concept"},
14
- "author": {"color": "#f472b6", "size": 6, "label": "Author"},
15
- "reference": {"color": "#94a3b8", "size": 6, "label": "Reference"},
16
- "claim": {"color": "#fb7185", "size": 7, "label": "Claim"},
17
- "frontierpaper": {"color": "#fde047", "size": 8, "label": "Frontier Paper"},
18
- "frontier": {"color": "#fde047", "size": 8, "label": "Frontier Paper"},
19
- }
20
-
21
-
22
- def _safe_text(x: Any, default: str = "") -> str:
23
- return html.escape(str(x if x is not None else default))
24
-
25
-
26
- def _norm_text(x: Any) -> str:
27
- return re.sub(r"\s+", " ", str(x or "")).strip()
28
-
29
-
30
- def _truncate(text: str, limit: int = 120) -> str:
31
- text = _norm_text(text)
32
- return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
33
-
34
-
35
- def _as_dict(payload: Any) -> Dict[str, Any]:
36
- if payload is None:
37
- return {"status": "empty", "nodes": [], "edges": []}
38
- if isinstance(payload, dict):
39
- return payload
40
- if isinstance(payload, str):
41
- try:
42
- parsed = json.loads(payload)
43
- if isinstance(parsed, dict):
44
- return parsed
45
- except Exception:
46
- pass
47
- return {"status": "empty", "nodes": [], "edges": []}
48
-
49
-
50
- def _style_for(kind: str) -> Dict[str, Any]:
51
- key = _norm_text(kind).lower().replace(" ", "")
52
- return TYPE_STYLES.get(key, {"color": "#cbd5e1", "size": 6, "label": _norm_text(kind or "Node") or "Node"})
53
-
54
-
55
- def _normalize_payload(payload: Any, title: str) -> Dict[str, Any]:
56
- raw = _as_dict(payload)
57
- raw_nodes = raw.get("nodes", []) or []
58
- raw_edges = raw.get("edges", raw.get("links", [])) or []
59
-
60
- nodes: List[Dict[str, Any]] = []
61
- seen = set()
62
-
63
- for i, node in enumerate(raw_nodes):
64
- if not isinstance(node, dict):
65
- continue
66
- node_id = _norm_text(node.get("id") or f"node_{i}")
67
- if not node_id or node_id in seen:
68
- continue
69
- seen.add(node_id)
70
-
71
- kind = _norm_text(node.get("kind") or node.get("type") or "paper")
72
- style = _style_for(kind)
73
- label = _truncate(node.get("label") or node.get("title") or node_id, 120)
74
-
75
- nodes.append({
76
- "id": node_id,
77
- "label": label,
78
- "kind": kind or "paper",
79
- "color": style["color"],
80
- "size": float(node.get("size") or style["size"]),
81
- "detail": {
82
- "type": style["label"],
83
- "label": label,
84
- "title": _norm_text(node.get("title") or node.get("label") or node_id),
85
- "venue": _norm_text(node.get("venue")),
86
- "year": _norm_text(node.get("year")),
87
- "doi": _norm_text(node.get("doi")),
88
- "source": _norm_text(node.get("source")),
89
- "authors_text": _norm_text(node.get("authors_text")),
90
- "text": _norm_text(node.get("text")),
91
- },
92
- })
93
-
94
- links: List[Dict[str, Any]] = []
95
- for edge in raw_edges:
96
- if not isinstance(edge, dict):
97
- continue
98
- src = _norm_text(edge.get("source"))
99
- tgt = _norm_text(edge.get("target"))
100
- if not src or not tgt or src == tgt:
101
- continue
102
- links.append({
103
- "source": src,
104
- "target": tgt,
105
- "type": _norm_text(edge.get("type") or "RELATES_TO"),
106
- })
107
-
108
- counts: Dict[str, int] = {}
109
- for n in nodes:
110
- label = _style_for(n["kind"])["label"]
111
- counts[label] = counts.get(label, 0) + 1
112
-
113
- return {
114
- "title": _norm_text(title) or "Self-Learning Knowledge Graph",
115
- "nodes": nodes,
116
- "links": links,
117
- "counts": counts,
118
- }
119
-
120
-
121
- def render_graph_canvas_html(payload: Any, title: str = "Self-Learning Knowledge Graph", height: int = 780) -> str:
122
- data = _normalize_payload(payload, title=title)
123
- payload_json = json.dumps(data, ensure_ascii=False).replace("</script>", "<\\/script>")
124
-
125
- html_doc = r"""
126
- <!doctype html>
127
- <html>
128
- <head>
129
- <meta charset="utf-8"/>
130
- <meta name="viewport" content="width=device-width, initial-scale=1"/>
131
- <style>
132
- :root {
133
- --bg0: #030712;
134
- --bg1: #0b1220;
135
- --bg2: #111827;
136
- --panel: rgba(8, 15, 29, 0.72);
137
- --panel-border: rgba(255,255,255,0.10);
138
- --text: #e5eefc;
139
- --muted: #a5b4cc;
140
- --accent: #38bdf8;
141
- }
142
- * { box-sizing: border-box; }
143
- html, body {
144
- margin: 0;
145
- width: 100%;
146
- height: 100%;
147
- overflow: hidden;
148
- background:
149
- radial-gradient(circle at 20% 20%, rgba(56,189,248,0.16), transparent 28%),
150
- radial-gradient(circle at 80% 18%, rgba(167,139,250,0.12), transparent 24%),
151
- linear-gradient(180deg, var(--bg2) 0%, var(--bg1) 45%, var(--bg0) 100%);
152
- color: var(--text);
153
- font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
154
- touch-action: none;
155
- }
156
- #app {
157
- position: relative;
158
- width: 100%;
159
- height: 100%;
160
- overflow: hidden;
161
- }
162
- #canvas {
163
- position: absolute;
164
- inset: 0;
165
- width: 100%;
166
- height: 100%;
167
- display: block;
168
- cursor: grab;
169
- }
170
- #canvas.dragging { cursor: grabbing; }
171
-
172
- .hud {
173
- position: absolute;
174
- z-index: 10;
175
- backdrop-filter: blur(10px);
176
- background: var(--panel);
177
- border: 1px solid var(--panel-border);
178
- box-shadow: 0 10px 30px rgba(0,0,0,0.28);
179
- }
180
- .topbar {
181
- top: 16px;
182
- left: 16px;
183
- width: min(420px, calc(100% - 32px));
184
- border-radius: 16px;
185
- padding: 14px 16px;
186
- }
187
- .topbar h3 {
188
- margin: 0 0 6px;
189
- font-size: 18px;
190
- line-height: 1.2;
191
- color: #f8fbff;
192
- }
193
- .topbar p {
194
- margin: 0;
195
- font-size: 12px;
196
- line-height: 1.45;
197
- color: var(--muted);
198
- }
199
- .legend {
200
- display: flex;
201
- flex-wrap: wrap;
202
- gap: 8px;
203
- margin-top: 10px;
204
- }
205
- .chip {
206
- display: inline-flex;
207
- align-items: center;
208
- gap: 8px;
209
- padding: 6px 10px;
210
- border-radius: 999px;
211
- font-size: 11px;
212
- line-height: 1;
213
- color: #dbeafe;
214
- background: rgba(255,255,255,0.04);
215
- border: 1px solid rgba(255,255,255,0.06);
216
- }
217
- .dot {
218
- width: 10px;
219
- height: 10px;
220
- border-radius: 999px;
221
- flex: 0 0 10px;
222
- }
223
-
224
- .details {
225
- right: 16px;
226
- bottom: 16px;
227
- width: min(360px, calc(100% - 32px));
228
- max-height: min(48%, 360px);
229
- overflow: auto;
230
- border-radius: 16px;
231
- padding: 14px 16px;
232
- }
233
- .details h4 {
234
- margin: 0 0 8px;
235
- font-size: 14px;
236
- color: #f8fbff;
237
- }
238
- .details p {
239
- margin: 0;
240
- font-size: 12px;
241
- line-height: 1.5;
242
- color: var(--muted);
243
- }
244
- .details dl {
245
- margin: 12px 0 0;
246
- display: grid;
247
- grid-template-columns: 78px 1fr;
248
- gap: 8px 10px;
249
- font-size: 12px;
250
- }
251
- .details dt {
252
- color: #93c5fd;
253
- font-weight: 600;
254
- }
255
- .details dd {
256
- margin: 0;
257
- color: #e5eefc;
258
- word-break: break-word;
259
- }
260
-
261
- .statusbar {
262
- position: absolute;
263
- left: 16px;
264
- bottom: 16px;
265
- z-index: 10;
266
- display: flex;
267
- gap: 8px;
268
- flex-wrap: wrap;
269
- }
270
- .status {
271
- padding: 8px 10px;
272
- border-radius: 12px;
273
- font-size: 11px;
274
- color: #dbeafe;
275
- background: rgba(8, 15, 29, 0.66);
276
- border: 1px solid rgba(255,255,255,0.08);
277
- backdrop-filter: blur(8px);
278
- }
279
-
280
- .empty {
281
- position: absolute;
282
- inset: 0;
283
- display: none;
284
- align-items: center;
285
- justify-content: center;
286
- z-index: 5;
287
- pointer-events: none;
288
- }
289
- .empty.show { display: flex; }
290
- .empty-card {
291
- width: min(520px, calc(100% - 32px));
292
- text-align: center;
293
- padding: 24px 28px;
294
- border-radius: 20px;
295
- background: rgba(8, 15, 29, 0.66);
296
- border: 1px solid rgba(255,255,255,0.08);
297
- backdrop-filter: blur(10px);
298
- box-shadow: 0 16px 40px rgba(0,0,0,0.34);
299
- }
300
- .empty-card h3 {
301
- margin: 0 0 10px;
302
- font-size: 22px;
303
- }
304
- .empty-card p {
305
- margin: 0;
306
- color: var(--muted);
307
- line-height: 1.55;
308
- }
309
-
310
- @media (max-width: 700px) {
311
- .topbar {
312
- width: calc(100% - 20px);
313
- left: 10px;
314
- top: 10px;
315
- padding: 12px;
316
- }
317
- .details {
318
- width: calc(100% - 20px);
319
- right: 10px;
320
- bottom: 10px;
321
- max-height: 34%;
322
- padding: 12px;
323
- }
324
- .statusbar {
325
- left: 10px;
326
- bottom: calc(34% + 22px);
327
- right: 10px;
328
- }
329
- }
330
- </style>
331
- </head>
332
- <body>
333
- <div id="app">
334
- <canvas id="canvas"></canvas>
335
-
336
- <div class="hud topbar">
337
- <h3 id="title"></h3>
338
- <p>Drag background to rotate, mouse wheel or trackpad pinch to zoom, two-finger pinch on touch screens, Shift-drag or right-drag to pan, and drag a node to move it in 3D space.</p>
339
- <div class="legend" id="legend"></div>
340
- </div>
341
-
342
- <div class="hud details" id="details">
343
- <h4>Node details</h4>
344
- <p>Tap or click a node to inspect it.</p>
345
- </div>
346
-
347
- <div class="statusbar" id="statusbar"></div>
348
-
349
- <div class="empty" id="empty">
350
- <div class="empty-card">
351
- <h3>No graph data yet</h3>
352
- <p>Ingest papers or pass a payload containing nodes and edges. This renderer accepts the existing graph payload shape directly.</p>
353
- </div>
354
- </div>
355
- </div>
356
-
357
- <script>
358
- const DATA = __DATA__;
359
-
360
- const canvas = document.getElementById("canvas");
361
- const ctx = canvas.getContext("2d", { alpha: true });
362
- const titleEl = document.getElementById("title");
363
- const detailsEl = document.getElementById("details");
364
- const legendEl = document.getElementById("legend");
365
- const statusbarEl = document.getElementById("statusbar");
366
- const emptyEl = document.getElementById("empty");
367
-
368
- titleEl.textContent = DATA.title || "Self-Learning Knowledge Graph";
369
-
370
- const legendMap = [
371
- ["Topic", "#38bdf8"],
372
- ["Paper", "#34d399"],
373
- ["Uploaded PDF", "#f59e0b"],
374
- ["Concept", "#a78bfa"],
375
- ["Author", "#f472b6"],
376
- ["Reference", "#94a3b8"],
377
- ["Claim", "#fb7185"],
378
- ["Frontier Paper", "#fde047"]
379
- ];
380
-
381
- legendMap.forEach(([label, color]) => {
382
- const chip = document.createElement("div");
383
- chip.className = "chip";
384
- chip.innerHTML = `<span class="dot" style="background:${color}"></span>${label}`;
385
- legendEl.appendChild(chip);
386
- });
387
-
388
- function addStatus(text) {
389
- const el = document.createElement("div");
390
- el.className = "status";
391
- el.textContent = text;
392
- statusbarEl.appendChild(el);
393
- }
394
-
395
- addStatus(`Nodes: ${DATA.nodes.length}`);
396
- addStatus(`Edges: ${DATA.links.length}`);
397
-
398
- Object.entries(DATA.counts || {}).forEach(([k, v]) => addStatus(`${k}: ${v}`));
399
-
400
- if (!DATA.nodes.length) {
401
- emptyEl.classList.add("show");
402
- }
403
-
404
- function hashCode(str) {
405
- let h = 0;
406
- for (let i = 0; i < str.length; i++) {
407
- h = ((h << 5) - h) + str.charCodeAt(i);
408
- h |= 0;
409
- }
410
- return Math.abs(h);
411
- }
412
-
413
- function seededFloat(key, salt) {
414
- const h = hashCode(key + "|" + salt);
415
- return (h % 10000) / 10000;
416
- }
417
-
418
- const nodes = DATA.nodes.map((n, i) => {
419
- const r1 = seededFloat(n.id, "x");
420
- const r2 = seededFloat(n.id, "y");
421
- const r3 = seededFloat(n.id, "z");
422
- const radius = 120 + (i % 7) * 18 + (n.size || 6) * 2;
423
- const theta = r1 * Math.PI * 2;
424
- const phi = (r2 - 0.5) * Math.PI;
425
-
426
- return {
427
- ...n,
428
- x: Math.cos(theta) * Math.cos(phi) * radius,
429
- y: Math.sin(phi) * radius * 0.72,
430
- z: Math.sin(theta) * Math.cos(phi) * radius,
431
- vx: 0,
432
- vy: 0,
433
- vz: 0,
434
- fx: null,
435
- fy: null,
436
- fz: null,
437
- screenX: 0,
438
- screenY: 0,
439
- screenR: 0,
440
- depth: 0
441
- };
442
- });
443
-
444
- const nodeById = new Map(nodes.map(n => [n.id, n]));
445
-
446
- const links = DATA.links
447
- .map(l => ({
448
- ...l,
449
- sourceRef: nodeById.get(l.source),
450
- targetRef: nodeById.get(l.target)
451
- }))
452
- .filter(l => l.sourceRef && l.targetRef);
453
-
454
- const state = {
455
- width: 0,
456
- height: 0,
457
- dpr: Math.max(1, Math.min(2, window.devicePixelRatio || 1)),
458
- cx: 0,
459
- cy: 0,
460
- yaw: 0.65,
461
- pitch: 0.25,
462
- zoom: 1.0,
463
- panX: 0,
464
- panY: 0,
465
- perspective: 940,
466
- cameraZ: 860,
467
- hovered: null,
468
- selected: null,
469
- draggingNode: null,
470
- draggingBackground: false,
471
- panMode: false,
472
- lastX: 0,
473
- lastY: 0,
474
- raf: null,
475
- lastTouchDist: 0,
476
- lastTouchCenter: null
477
- };
478
-
479
- function resize() {
480
- const rect = canvas.getBoundingClientRect();
481
- state.width = rect.width;
482
- state.height = rect.height;
483
- state.cx = rect.width / 2;
484
- state.cy = rect.height / 2;
485
- canvas.width = Math.floor(rect.width * state.dpr);
486
- canvas.height = Math.floor(rect.height * state.dpr);
487
- ctx.setTransform(state.dpr, 0, 0, state.dpr, 0, 0);
488
- }
489
- resize();
490
- window.addEventListener("resize", resize);
491
-
492
- function clamp(v, lo, hi) {
493
- return Math.max(lo, Math.min(hi, v));
494
- }
495
-
496
- function rotatePoint(x, y, z) {
497
- const cy = Math.cos(state.yaw);
498
- const sy = Math.sin(state.yaw);
499
- const cp = Math.cos(state.pitch);
500
- const sp = Math.sin(state.pitch);
501
-
502
- const x1 = x * cy - z * sy;
503
- const z1 = x * sy + z * cy;
504
-
505
- const y2 = y * cp - z1 * sp;
506
- const z2 = y * sp + z1 * cp;
507
-
508
- return { x: x1, y: y2, z: z2 };
509
- }
510
-
511
- function projectNode(n) {
512
- const p = rotatePoint(n.x, n.y, n.z);
513
- const depth = state.cameraZ - p.z;
514
- const scale = (state.perspective / Math.max(120, depth)) * state.zoom;
515
- n.depth = p.z;
516
- n.screenX = state.cx + state.panX + p.x * scale;
517
- n.screenY = state.cy + state.panY + p.y * scale;
518
- n.screenR = clamp((n.size || 6) * scale * 0.85, 4, 22);
519
- n._scale = scale;
520
- }
521
-
522
- function setDetails(n) {
523
- if (!n) {
524
- detailsEl.innerHTML = `
525
- <h4>Node details</h4>
526
- <p>Tap or click a node to inspect it.</p>
527
- `;
528
- return;
529
- }
530
- const d = n.detail || {};
531
- detailsEl.innerHTML = `
532
- <h4>Node details</h4>
533
- <dl>
534
- <dt>Label</dt><dd>${escapeHtml(n.label || "")}</dd>
535
- <dt>Type</dt><dd>${escapeHtml(d.type || n.kind || "")}</dd>
536
- <dt>Title</dt><dd>${escapeHtml(d.title || "—")}</dd>
537
- <dt>Venue</dt><dd>${escapeHtml(d.venue || "—")}</dd>
538
- <dt>Year</dt><dd>${escapeHtml(d.year || "—")}</dd>
539
- <dt>DOI</dt><dd>${escapeHtml(d.doi || "—")}</dd>
540
- <dt>Source</dt><dd>${escapeHtml(d.source || "—")}</dd>
541
- <dt>Authors</dt><dd>${escapeHtml(d.authors_text || "—")}</dd>
542
- <dt>Text</dt><dd>${escapeHtml(d.text || "—")}</dd>
543
- </dl>
544
- `;
545
- }
546
-
547
- function escapeHtml(str) {
548
- return String(str || "")
549
- .replaceAll("&", "&amp;")
550
- .replaceAll("<", "&lt;")
551
- .replaceAll(">", "&gt;")
552
- .replaceAll('"', "&quot;")
553
- .replaceAll("'", "&#039;");
554
- }
555
-
556
- function findNodeAt(x, y) {
557
- const ordered = [...nodes].sort((a, b) => b.depth - a.depth);
558
- for (const n of ordered) {
559
- const dx = x - n.screenX;
560
- const dy = y - n.screenY;
561
- const rr = n.screenR + 6;
562
- if (dx * dx + dy * dy <= rr * rr) {
563
- return n;
564
- }
565
- }
566
- return null;
567
- }
568
-
569
- function worldDeltaFromScreen(dx, dy, scale) {
570
- const localX = dx / Math.max(0.2, scale);
571
- const localY = dy / Math.max(0.2, scale);
572
-
573
- const cp = Math.cos(-state.pitch);
574
- const sp = Math.sin(-state.pitch);
575
- const cy = Math.cos(-state.yaw);
576
- const sy = Math.sin(-state.yaw);
577
-
578
- let x1 = localX;
579
- let y1 = localY * cp;
580
- let z1 = localY * sp;
581
-
582
- const wx = x1 * cy - z1 * sy;
583
- const wz = x1 * sy + z1 * cy;
584
-
585
- return { x: wx, y: y1, z: wz };
586
- }
587
-
588
- function stepPhysics() {
589
- if (!nodes.length) return;
590
-
591
- const nCount = nodes.length;
592
- const pairStride = nCount > 120 ? 2 : 1;
593
- const repulsion = 15000;
594
- const spring = 0.0035;
595
- const centering = 0.0008;
596
- const damping = 0.90;
597
-
598
- for (const n of nodes) {
599
- n.ax = 0;
600
- n.ay = 0;
601
- n.az = 0;
602
- }
603
-
604
- for (let i = 0; i < nCount; i += pairStride) {
605
- const a = nodes[i];
606
- for (let j = i + 1; j < nCount; j += pairStride) {
607
- const b = nodes[j];
608
- const dx = a.x - b.x;
609
- const dy = a.y - b.y;
610
- const dz = a.z - b.z;
611
- const d2 = dx * dx + dy * dy + dz * dz + 0.01;
612
- const f = repulsion / d2;
613
- const inv = 1 / Math.sqrt(d2);
614
- const fx = dx * inv * f;
615
- const fy = dy * inv * f;
616
- const fz = dz * inv * f;
617
- a.ax += fx; a.ay += fy; a.az += fz;
618
- b.ax -= fx; b.ay -= fy; b.az -= fz;
619
- }
620
- }
621
-
622
- for (const l of links) {
623
- const a = l.sourceRef;
624
- const b = l.targetRef;
625
- const dx = b.x - a.x;
626
- const dy = b.y - a.y;
627
- const dz = b.z - a.z;
628
- const dist = Math.sqrt(dx * dx + dy * dy + dz * dz) || 1;
629
- const target = 80 + ((a.size || 6) + (b.size || 6)) * 3;
630
- const f = (dist - target) * spring;
631
- const fx = (dx / dist) * f;
632
- const fy = (dy / dist) * f;
633
- const fz = (dz / dist) * f;
634
- a.ax += fx; a.ay += fy; a.az += fz;
635
- b.ax -= fx; b.ay -= fy; b.az -= fz;
636
- }
637
-
638
- for (const n of nodes) {
639
- if (state.draggingNode === n) {
640
- n.vx = 0; n.vy = 0; n.vz = 0;
641
- continue;
642
- }
643
- n.ax += -n.x * centering;
644
- n.ay += -n.y * centering;
645
- n.az += -n.z * centering;
646
-
647
- n.vx = (n.vx + n.ax) * damping;
648
- n.vy = (n.vy + n.ay) * damping;
649
- n.vz = (n.vz + n.az) * damping;
650
-
651
- n.x += n.vx;
652
- n.y += n.vy;
653
- n.z += n.vz;
654
- }
655
- }
656
-
657
- function drawBackground() {
658
- ctx.clearRect(0, 0, state.width, state.height);
659
-
660
- const g = ctx.createRadialGradient(state.width * 0.5, state.height * 0.46, 40, state.width * 0.5, state.height * 0.46, state.width * 0.72);
661
- g.addColorStop(0, "rgba(56,189,248,0.05)");
662
- g.addColorStop(0.45, "rgba(99,102,241,0.03)");
663
- g.addColorStop(1, "rgba(0,0,0,0)");
664
- ctx.fillStyle = g;
665
- ctx.fillRect(0, 0, state.width, state.height);
666
- }
667
-
668
- function drawEdge(a, b, type) {
669
- const depthAlpha = clamp((a._scale + b._scale) * 0.22, 0.10, 0.45);
670
- const typeColor = {
671
- "ABOUT": "rgba(56,189,248,ALPHA)",
672
- "MENTIONS": "rgba(167,139,250,ALPHA)",
673
- "WRITTEN_BY": "rgba(244,114,182,ALPHA)",
674
- "CITES": "rgba(148,163,184,ALPHA)",
675
- "ASSERTS": "rgba(251,113,133,ALPHA)",
676
- "FRONTIER": "rgba(253,224,71,ALPHA)",
677
- "FRONTIER_CANDIDATE": "rgba(253,224,71,ALPHA)",
678
- "UPLOADED_SOURCE": "rgba(245,158,11,ALPHA)"
679
- };
680
- const c = (typeColor[type] || "rgba(203,213,225,ALPHA)").replace("ALPHA", depthAlpha.toFixed(3));
681
-
682
- ctx.beginPath();
683
- ctx.moveTo(a.screenX, a.screenY);
684
- ctx.lineTo(b.screenX, b.screenY);
685
- ctx.lineWidth = type === "ABOUT" || type === "UPLOADED_SOURCE" ? 2 : 1.2;
686
- ctx.strokeStyle = c;
687
- ctx.stroke();
688
- }
689
-
690
- function drawNode(n, selected, hovered) {
691
- const r = n.screenR;
692
- ctx.beginPath();
693
- ctx.arc(n.screenX, n.screenY, r + 8, 0, Math.PI * 2);
694
- const glow = ctx.createRadialGradient(n.screenX, n.screenY, 0, n.screenX, n.screenY, r + 8);
695
- glow.addColorStop(0, hexToRgba(n.color, hovered || selected ? 0.28 : 0.18));
696
- glow.addColorStop(1, hexToRgba(n.color, 0));
697
- ctx.fillStyle = glow;
698
- ctx.fill();
699
-
700
- ctx.beginPath();
701
- ctx.arc(n.screenX, n.screenY, r, 0, Math.PI * 2);
702
- ctx.fillStyle = n.color;
703
- ctx.fill();
704
-
705
- ctx.lineWidth = selected ? 2.6 : hovered ? 2.0 : 1.0;
706
- ctx.strokeStyle = selected ? "rgba(255,255,255,0.95)" : hovered ? "rgba(255,255,255,0.75)" : "rgba(255,255,255,0.22)";
707
- ctx.stroke();
708
-
709
- if (selected || hovered || r > 10) {
710
- drawLabel(n, selected || hovered);
711
- }
712
- }
713
-
714
- function drawLabel(n, strong) {
715
- const text = n.label || n.id;
716
- ctx.font = `${strong ? 700 : 600} 12px Inter, sans-serif`;
717
- const padX = 8;
718
- const h = 24;
719
- const w = Math.min(260, ctx.measureText(text).width + padX * 2);
720
- const x = n.screenX + 10;
721
- const y = n.screenY - 10 - h;
722
-
723
- roundRect(x, y, w, h, 10);
724
- ctx.fillStyle = strong ? "rgba(8,15,29,0.92)" : "rgba(8,15,29,0.72)";
725
- ctx.fill();
726
-
727
- ctx.lineWidth = 1;
728
- ctx.strokeStyle = "rgba(255,255,255,0.08)";
729
- ctx.stroke();
730
-
731
- ctx.fillStyle = "#f8fbff";
732
- ctx.fillText(text.length > 32 ? text.slice(0, 31) + "…" : text, x + padX, y + 16);
733
- }
734
-
735
- function roundRect(x, y, w, h, r) {
736
- ctx.beginPath();
737
- ctx.moveTo(x + r, y);
738
- ctx.arcTo(x + w, y, x + w, y + h, r);
739
- ctx.arcTo(x + w, y + h, x, y + h, r);
740
- ctx.arcTo(x, y + h, x, y, r);
741
- ctx.arcTo(x, y, x + w, y, r);
742
- ctx.closePath();
743
- }
744
-
745
- function hexToRgba(hex, alpha) {
746
- const h = hex.replace("#", "");
747
- const bigint = parseInt(h.length === 3 ? h.split("").map(c => c + c).join("") : h, 16);
748
- const r = (bigint >> 16) & 255;
749
- const g = (bigint >> 8) & 255;
750
- const b = bigint & 255;
751
- return `rgba(${r}, ${g}, ${b}, ${alpha})`;
752
- }
753
-
754
- function render() {
755
- drawBackground();
756
-
757
- for (const n of nodes) {
758
- projectNode(n);
759
- }
760
-
761
- const orderedLinks = [...links].sort((a, b) => {
762
- const da = (a.sourceRef.depth + a.targetRef.depth) * 0.5;
763
- const db = (b.sourceRef.depth + b.targetRef.depth) * 0.5;
764
- return da - db;
765
- });
766
-
767
- for (const l of orderedLinks) {
768
- drawEdge(l.sourceRef, l.targetRef, l.type);
769
- }
770
-
771
- const orderedNodes = [...nodes].sort((a, b) => a.depth - b.depth);
772
- for (const n of orderedNodes) {
773
- drawNode(n, state.selected === n, state.hovered === n);
774
- }
775
- }
776
-
777
- function animate() {
778
- stepPhysics();
779
- render();
780
- state.raf = requestAnimationFrame(animate);
781
- }
782
- animate();
783
-
784
- function pointerPos(evt) {
785
- const rect = canvas.getBoundingClientRect();
786
- return { x: evt.clientX - rect.left, y: evt.clientY - rect.top };
787
- }
788
-
789
- canvas.addEventListener("contextmenu", (e) => e.preventDefault());
790
-
791
- canvas.addEventListener("mousedown", (e) => {
792
- const p = pointerPos(e);
793
- state.lastX = p.x;
794
- state.lastY = p.y;
795
- state.hovered = findNodeAt(p.x, p.y);
796
- state.panMode = e.button === 2 || e.shiftKey;
797
-
798
- if (state.hovered && e.button !== 2) {
799
- state.draggingNode = state.hovered;
800
- state.selected = state.hovered;
801
- setDetails(state.selected);
802
- } else {
803
- state.draggingBackground = true;
804
- }
805
-
806
- canvas.classList.add("dragging");
807
- });
808
-
809
- window.addEventListener("mousemove", (e) => {
810
- const rect = canvas.getBoundingClientRect();
811
- const inside = e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
812
- if (!inside && !state.draggingNode && !state.draggingBackground) return;
813
-
814
- const p = pointerPos(e);
815
- const dx = p.x - state.lastX;
816
- const dy = p.y - state.lastY;
817
- state.lastX = p.x;
818
- state.lastY = p.y;
819
-
820
- if (state.draggingNode) {
821
- const delta = worldDeltaFromScreen(dx, dy, state.draggingNode._scale || 1);
822
- state.draggingNode.x += delta.x * 1.18;
823
- state.draggingNode.y += delta.y * 1.18;
824
- state.draggingNode.z += delta.z * 1.18;
825
- state.draggingNode.vx = 0;
826
- state.draggingNode.vy = 0;
827
- state.draggingNode.vz = 0;
828
- return;
829
- }
830
-
831
- if (state.draggingBackground) {
832
- if (state.panMode) {
833
- state.panX += dx;
834
- state.panY += dy;
835
- } else {
836
- state.yaw += dx * 0.0085;
837
- state.pitch = clamp(state.pitch + dy * 0.0065, -1.25, 1.25);
838
- }
839
- return;
840
- }
841
-
842
- state.hovered = findNodeAt(p.x, p.y);
843
- });
844
-
845
- window.addEventListener("mouseup", () => {
846
- state.draggingNode = null;
847
- state.draggingBackground = false;
848
- state.panMode = false;
849
- canvas.classList.remove("dragging");
850
- });
851
-
852
- canvas.addEventListener("wheel", (e) => {
853
- e.preventDefault();
854
- const delta = e.deltaY > 0 ? 0.92 : 1.09;
855
- state.zoom = clamp(state.zoom * delta, 0.35, 4.0);
856
- }, { passive: false });
857
-
858
- canvas.addEventListener("click", (e) => {
859
- const p = pointerPos(e);
860
- const hit = findNodeAt(p.x, p.y);
861
- if (hit) {
862
- state.selected = hit;
863
- setDetails(hit);
864
- }
865
- });
866
-
867
- canvas.addEventListener("touchstart", (e) => {
868
- e.preventDefault();
869
- if (e.touches.length === 1) {
870
- const t = e.touches[0];
871
- const p = pointerPos(t);
872
- state.lastX = p.x;
873
- state.lastY = p.y;
874
- state.hovered = findNodeAt(p.x, p.y);
875
- if (state.hovered) {
876
- state.draggingNode = state.hovered;
877
- state.selected = state.hovered;
878
- setDetails(state.selected);
879
- } else {
880
- state.draggingBackground = true;
881
- }
882
- } else if (e.touches.length === 2) {
883
- state.draggingNode = null;
884
- state.draggingBackground = false;
885
- const a = e.touches[0];
886
- const b = e.touches[1];
887
- state.lastTouchDist = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
888
- state.lastTouchCenter = {
889
- x: (a.clientX + b.clientX) * 0.5,
890
- y: (a.clientY + b.clientY) * 0.5
891
- };
892
- }
893
- }, { passive: false });
894
-
895
- canvas.addEventListener("touchmove", (e) => {
896
- e.preventDefault();
897
- if (e.touches.length === 1) {
898
- const t = e.touches[0];
899
- const p = pointerPos(t);
900
- const dx = p.x - state.lastX;
901
- const dy = p.y - state.lastY;
902
- state.lastX = p.x;
903
- state.lastY = p.y;
904
-
905
- if (state.draggingNode) {
906
- const delta = worldDeltaFromScreen(dx, dy, state.draggingNode._scale || 1);
907
- state.draggingNode.x += delta.x * 1.18;
908
- state.draggingNode.y += delta.y * 1.18;
909
- state.draggingNode.z += delta.z * 1.18;
910
- state.draggingNode.vx = 0;
911
- state.draggingNode.vy = 0;
912
- state.draggingNode.vz = 0;
913
- } else if (state.draggingBackground) {
914
- state.yaw += dx * 0.0085;
915
- state.pitch = clamp(state.pitch + dy * 0.0065, -1.25, 1.25);
916
- }
917
- } else if (e.touches.length === 2) {
918
- const a = e.touches[0];
919
- const b = e.touches[1];
920
- const dist = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
921
- const center = {
922
- x: (a.clientX + b.clientX) * 0.5,
923
- y: (a.clientY + b.clientY) * 0.5
924
- };
925
-
926
- if (state.lastTouchDist) {
927
- const ratio = dist / state.lastTouchDist;
928
- state.zoom = clamp(state.zoom * ratio, 0.35, 4.0);
929
- }
930
-
931
- if (state.lastTouchCenter) {
932
- state.panX += center.x - state.lastTouchCenter.x;
933
- state.panY += center.y - state.lastTouchCenter.y;
934
- }
935
-
936
- state.lastTouchDist = dist;
937
- state.lastTouchCenter = center;
938
- }
939
- }, { passive: false });
940
-
941
- canvas.addEventListener("touchend", (e) => {
942
- e.preventDefault();
943
- if (e.touches.length === 0) {
944
- state.draggingNode = null;
945
- state.draggingBackground = false;
946
- state.lastTouchDist = 0;
947
- state.lastTouchCenter = null;
948
- }
949
- }, { passive: false });
950
-
951
- setDetails(null);
952
- </script>
953
- </body>
954
- </html>
955
- """
956
-
957
- html_doc = html_doc.replace("__DATA__", payload_json)
958
- return (
959
- f'<iframe title="{_safe_text(title)}" '
960
- f'style="width:100%; height:{int(height)}px; border:0; border-radius:18px; overflow:hidden; background:#030712;" '
961
- f'sandbox="allow-scripts allow-same-origin" '
962
- f'srcdoc="{html.escape(html_doc, quote=True)}"></iframe>'
963
- )
964
-
965
-
966
- def render_graph_canvas_component(payload: Any, title: str = "Self-Learning Knowledge Graph", height: int = 780):
967
- try:
968
- import gradio as gr
969
- except Exception as e:
970
- raise RuntimeError(f"gradio is required for render_graph_canvas_component: {e}")
971
- return gr.HTML(render_graph_canvas_html(payload, title=title, height=height))
972
-
973
-
974
- __all__ = [
975
- "render_graph_canvas_html",
976
- "render_graph_canvas_component",
977
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dvnc_ai_v2_hf/self_learning_graph.py DELETED
@@ -1,1738 +0,0 @@
1
- import html
2
- import json
3
- import re
4
- import time
5
- import urllib.parse
6
- import xml.etree.ElementTree as ET
7
- from collections import Counter
8
- from pathlib import Path
9
- from typing import Any, Dict, List, Optional
10
-
11
- import gradio as gr
12
- import requests
13
-
14
- try:
15
- import fitz # PyMuPDF
16
- except Exception:
17
- fitz = None
18
-
19
- from dvnc_ai_v2_hf.graph_canvas_patch import render_graph_canvas_html
20
-
21
- def build_learning_graph_html(nodes, edges, title="Self-Learning Knowledge Graph"):
22
- payload = {
23
- "status": "ok" if (nodes or edges) else "empty",
24
- "nodes": nodes or [],
25
- "edges": edges or [],
26
- }
27
- return render_graph_canvas_html(payload, title=title, height=780)
28
-
29
- SEARCH_MODES = ["topic", "title", "doi", "link", "paper_name", "autonomous_web"]
30
- SOURCE_OPTIONS = ["arxiv", "crossref", "openalex", "semantic_scholar", "europe_pmc"]
31
- DEFAULT_SOURCES = ["arxiv", "crossref", "openalex", "semantic_scholar", "europe_pmc"]
32
- PDF_PARSERS = ["pymupdf"]
33
-
34
- REQUEST_TIMEOUT = 25
35
- GRAPH_MAX_RESULTS = 12
36
- GRAPH_MAX_CONCEPTS = 12
37
- GRAPH_MAX_CLAIMS = 8
38
- GRAPH_MAX_EXPANSIONS = 6
39
- GRAPH_MAX_NODES = 500
40
- GRAPH_MAX_EDGES = 1600
41
- GRAPH_IFRAME_HEIGHT = 760
42
- MAX_ABSTRACT_CHARS = 4000
43
- MAX_RAW_TEXT_CHARS = 60000
44
-
45
- JOURNALS = [
46
- {"name": "Nature", "url": "https://www.nature.com/search", "desc": "Flagship multidisciplinary research journal."},
47
- {"name": "Science", "url": "https://www.science.org/search", "desc": "High-impact science journal and family."},
48
- {"name": "Cell", "url": "https://www.cell.com/search", "desc": "Life sciences and translational biology."},
49
- {"name": "The Lancet", "url": "https://www.thelancet.com/search", "desc": "Clinical and medical research."},
50
- {"name": "IEEE Xplore", "url": "https://ieeexplore.ieee.org/search/searchresult.jsp", "desc": "Engineering, AI, signal processing, and systems."},
51
- ]
52
-
53
- STOPWORDS = {
54
- "a", "an", "and", "are", "as", "at", "be", "been", "being", "by", "can", "could",
55
- "did", "do", "does", "for", "from", "had", "has", "have", "if", "in", "into", "is",
56
- "it", "its", "may", "might", "of", "on", "or", "our", "such", "that", "the", "their",
57
- "there", "these", "this", "those", "to", "using", "use", "used", "via", "was", "were",
58
- "will", "with", "within", "without", "we", "they", "you", "your", "study", "paper",
59
- "research", "results", "result", "method", "methods", "analysis", "approach", "based",
60
- "new", "novel", "effect", "effects", "model", "models", "system", "systems", "show",
61
- "shows", "shown", "however", "therefore", "also", "between", "across", "among", "et", "al",
62
- "introduction", "discussion", "conclusion", "references", "figure", "table",
63
- }
64
-
65
- GRAPH_MEMORY: Dict[str, Any] = {
66
- "papers": {},
67
- "nodes": {},
68
- "edges": [],
69
- "queries": [],
70
- "events": [],
71
- "frontier": [],
72
- "payloads": [],
73
- "concept_counts": Counter(),
74
- "claim_counts": Counter(),
75
- }
76
-
77
-
78
- # ----------------------------
79
- # Utility
80
- # ----------------------------
81
-
82
- def safe_text(x, default=""):
83
- return html.escape(str(x if x is not None else default))
84
-
85
-
86
- 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]+$"
112
- if re.match(doi_pattern, q, flags=re.I):
113
- return "doi"
114
- if q.startswith("http://") or q.startswith("https://"):
115
- return "link"
116
- return "topic"
117
-
118
-
119
- def tokenize(text: str) -> List[str]:
120
- return [
121
- t for t in re.findall(r"[A-Za-z][A-Za-z0-9\-/+]{2,}", (text or ""))
122
- if t.lower() not in STOPWORDS
123
- ]
124
-
125
-
126
- def text_overlap_score(a: str, b: str) -> float:
127
- sa = {x.lower() for x in tokenize(a)}
128
- sb = {x.lower() for x in tokenize(b)}
129
- if not sa or not sb:
130
- return 0.0
131
- return len(sa & sb) / max(1, len(sa | sb))
132
-
133
-
134
- def compute_recency_bonus(year: str) -> float:
135
- try:
136
- y = int(str(year)[:4])
137
- except Exception:
138
- return 0.0
139
- current = time.gmtime().tm_year
140
- age = max(current - y, 0)
141
- return max(0.0, 0.16 - age * 0.018)
142
-
143
-
144
- def clean_extracted_text(text: str) -> str:
145
- text = text or ""
146
- replacements = {
147
- "\u00ad": "",
148
- "\ufb01": "fi",
149
- "\ufb02": "fl",
150
- "\u2010": "-",
151
- "\u2011": "-",
152
- "\u2012": "-",
153
- "\u2013": "-",
154
- "\u2014": "-",
155
- "\u2212": "-",
156
- "\u00a0": " ",
157
- }
158
- for old, new in replacements.items():
159
- text = text.replace(old, new)
160
-
161
- text = re.sub(r"(\w)-\s*\n\s*(\w)", r"\1\2", text)
162
- text = re.sub(r"([a-z])\n([a-z])", r"\1 \2", text)
163
- text = re.sub(r"\bnjectable\b", "injectable", text, flags=re.I)
164
- text = re.sub(r"\bfhydrogel\b", "hydrogel", text, flags=re.I)
165
- text = re.sub(
166
- r"(?i)\b([a-z])?(hydrogel|conductive|responsive|injectable|biomaterial|scaffold|cardiac|patch|repair)\b",
167
- lambda m: m.group(2),
168
- text,
169
- )
170
- text = re.sub(r"[ \t]+", " ", text)
171
- text = re.sub(r"\n{3,}", "\n\n", text)
172
- return text.strip()
173
-
174
-
175
- def paper_identity_key(paper: Dict[str, Any]) -> str:
176
- return (
177
- normalize_doi(paper.get("doi") or "")
178
- or ((paper.get("external_ids") or {}).get("arxiv") or "")
179
- or norm_text(paper.get("title", "")).lower()
180
- or str(paper.get("id", ""))
181
- )
182
-
183
-
184
- def unique_keep_order(items: List[str]) -> List[str]:
185
- seen = set()
186
- out = []
187
- for item in items:
188
- key = norm_text(item).lower()
189
- if key and key not in seen:
190
- seen.add(key)
191
- out.append(norm_text(item))
192
- return out
193
-
194
-
195
- # ----------------------------
196
- # Concept / claim extraction
197
- # ----------------------------
198
-
199
- def normalize_concept_label(phrase: str) -> str:
200
- phrase = norm_text(phrase)
201
- mapping = {"ph": "pH", "ai": "AI", "ml": "ML", "3d": "3D", "ecg": "ECG", "mri": "MRI"}
202
- return " ".join(mapping.get(p.lower(), p) for p in phrase.split())
203
-
204
-
205
- def looks_like_bad_phrase(phrase: str) -> bool:
206
- phrase = norm_text(phrase)
207
- if not phrase or len(phrase) < 4 or len(phrase) > 90:
208
- return True
209
- parts = phrase.split()
210
- if len(parts) > 6:
211
- return True
212
- for p in parts:
213
- t = p.strip("-.,;:()[]{}")
214
- if not t:
215
- return True
216
- if len(t) == 1 and t.lower() not in {"p", "h"}:
217
- return True
218
- if re.match(r"^[bcdfghjklmnpqrstvwxyz]{5,}$", t.lower()):
219
- return True
220
- return False
221
-
222
-
223
- def extract_concepts_from_text(text: str, max_terms: int = GRAPH_MAX_CONCEPTS) -> List[str]:
224
- text = clean_extracted_text(text)
225
- words = re.findall(r"[A-Za-z][A-Za-z0-9\-/+]{2,}", text)
226
- phrases = []
227
-
228
- for n in (3, 2, 1):
229
- for i in range(len(words) - n + 1):
230
- phrase = " ".join(words[i:i + n])
231
- low = phrase.lower()
232
- if any(tok in STOPWORDS for tok in low.split()):
233
- continue
234
- if looks_like_bad_phrase(low):
235
- continue
236
- phrases.append(low)
237
-
238
- counts = Counter(phrases)
239
- ranked = []
240
- for phrase, count in counts.most_common(max_terms * 8):
241
- score = float(count)
242
- score += 0.25 * len(phrase.split())
243
- if any(x in phrase for x in ["hydrogel", "conductive", "responsive", "injectable", "graph", "neural", "cardiac", "biomaterial"]):
244
- score += 0.5
245
- ranked.append((score, phrase))
246
-
247
- out = []
248
- seen = set()
249
- for _, phrase in sorted(ranked, key=lambda x: x[0], reverse=True):
250
- clean = normalize_concept_label(phrase)
251
- low = clean.lower()
252
- if low in seen:
253
- continue
254
- if any((low in s or s in low) and abs(len(low) - len(s)) <= 2 for s in seen):
255
- continue
256
- seen.add(low)
257
- out.append(clean)
258
- if len(out) >= max_terms:
259
- break
260
- return out
261
-
262
-
263
- def extract_claim_like_sentences(text: str, max_items: int = GRAPH_MAX_CLAIMS) -> List[str]:
264
- text = clean_extracted_text(text)
265
- sentences = [norm_text(s) for s in re.split(r"(?<=[\.\!\?])\s+", text) if norm_text(s)]
266
- scored = []
267
- for sentence in sentences:
268
- if len(sentence) < 45 or len(sentence) > 320:
269
- continue
270
- lower = sentence.lower()
271
- score = 0.0
272
- if any(k in lower for k in ["improves", "reduces", "increases", "demonstrates", "shows", "reveals", "predicts", "achieves", "outperforms", "enables", "supports"]):
273
- score += 2.0
274
- if any(k in lower for k in ["significant", "associated", "correlated", "effective", "robust", "accurate", "validated", "statistically"]):
275
- score += 1.0
276
- score += min(len(tokenize(sentence)) / 18.0, 2.0)
277
- scored.append((score, sentence))
278
-
279
- out = []
280
- seen = set()
281
- for _, sentence in sorted(scored, key=lambda x: x[0], reverse=True):
282
- key = sentence.lower()
283
- if key not in seen:
284
- seen.add(key)
285
- out.append(sentence)
286
- if len(out) >= max_items:
287
- break
288
- return out
289
-
290
-
291
- def extract_title_from_text(raw_text: str, fallback: str = "Uploaded PDF") -> str:
292
- raw_text = clean_extracted_text(raw_text or "")
293
- lines = [norm_text(x) for x in raw_text.splitlines() if norm_text(x)]
294
- for line in lines[:18]:
295
- if 8 <= len(line) <= 240 and not re.search(r"doi|http|www\.|@", line, flags=re.I):
296
- return truncate_text(line.strip(" -:;"), 240)
297
- return fallback
298
-
299
-
300
- def extract_references_from_text(text: str) -> List[Dict[str, str]]:
301
- refs = []
302
- seen = set()
303
- doi_re = re.compile(r"10\.\d{4,9}/[-._;()/:A-Z0-9]+", re.I)
304
-
305
- for line in [norm_text(x) for x in clean_extracted_text(text).splitlines() if norm_text(x)][-250:]:
306
- doi_match = doi_re.search(line)
307
- doi = normalize_doi(doi_match.group(0)) if doi_match else ""
308
- title = line.replace(doi_match.group(0), "").strip(" .;,-") if doi_match else line
309
- if len(title) < 12:
310
- continue
311
- key = (title.lower(), doi.lower())
312
- if key in seen:
313
- continue
314
- seen.add(key)
315
- refs.append({"title": truncate_text(title, 220), "doi": doi})
316
- if len(refs) >= 40:
317
- break
318
- return refs
319
-
320
-
321
- # ----------------------------
322
- # Search sources
323
- # ----------------------------
324
-
325
- def parse_openalex_abstract(inverted_index) -> str:
326
- if not inverted_index or not isinstance(inverted_index, dict):
327
- return ""
328
- pos_to_word = {}
329
- for word, positions in inverted_index.items():
330
- for pos in positions:
331
- pos_to_word[pos] = word
332
- return " ".join(pos_to_word[i] for i in sorted(pos_to_word)) if pos_to_word else ""
333
-
334
-
335
- def enrich_paper_semantics(query: str, paper: Dict[str, Any]) -> Dict[str, Any]:
336
- paper = dict(paper)
337
- title = clean_extracted_text(paper.get("title", ""))
338
- abstract = clean_extracted_text(paper.get("abstract", "") or paper.get("summary", ""))
339
- venue = clean_extracted_text(paper.get("venue", ""))
340
-
341
- concepts = extract_concepts_from_text(" ".join([title, abstract, venue]), max_terms=GRAPH_MAX_CONCEPTS)
342
- claims = extract_claim_like_sentences(abstract, max_items=GRAPH_MAX_CLAIMS)
343
- rel = text_overlap_score(query, f"{title} {abstract}")
344
- recency = compute_recency_bonus(paper.get("year"))
345
- doi_bonus = 0.02 if paper.get("doi") else 0.0
346
- oa_bonus = 0.03 if paper.get("open_access") else 0.0
347
- learned_score = float(paper.get("score", 0)) + rel * 0.52 + recency + doi_bonus + oa_bonus + min(len(concepts), 8) * 0.012
348
-
349
- paper["title"] = title or paper.get("title", "Untitled")
350
- paper["abstract"] = abstract
351
- paper["summary"] = truncate_text(abstract or paper.get("summary", ""), 520)
352
- paper["venue"] = venue
353
- paper["concepts"] = concepts[:GRAPH_MAX_CONCEPTS]
354
- paper["claims"] = claims[:GRAPH_MAX_CLAIMS]
355
- paper["relevance"] = round(rel, 4)
356
- paper["learned_score"] = round(learned_score, 4)
357
- return paper
358
-
359
-
360
- def dedupe_papers(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
361
- seen = {}
362
- for item in items:
363
- key = paper_identity_key(item) or f"{item.get('source', 'src')}::{item.get('title', 'paper')}"
364
- cur = seen.get(key)
365
- cur_score = float(cur.get("learned_score", cur.get("score", 0))) if cur else -1
366
- new_score = float(item.get("learned_score", item.get("score", 0)))
367
- if cur is None or new_score > cur_score:
368
- seen[key] = item
369
- out = list(seen.values())
370
- out.sort(key=lambda x: float(x.get("learned_score", x.get("score", 0))), reverse=True)
371
- return out
372
-
373
-
374
- def search_arxiv(query: str, max_results: int = 8) -> List[Dict[str, Any]]:
375
- encoded = urllib.parse.quote(query)
376
- url = (
377
- "http://export.arxiv.org/api/query?search_query=all:"
378
- f"{encoded}&start=0&max_results={max_results}&sortBy=relevance&sortOrder=descending"
379
- )
380
- response = requests.get(url, timeout=REQUEST_TIMEOUT)
381
- response.raise_for_status()
382
- root = ET.fromstring(response.text)
383
- ns = {"atom": "http://www.w3.org/2005/Atom"}
384
-
385
- out = []
386
- for entry in root.findall("atom:entry", ns):
387
- title = clean_extracted_text(entry.findtext("atom:title", default="", namespaces=ns) or "")
388
- summary = truncate_text(clean_extracted_text(entry.findtext("atom:summary", default="", namespaces=ns) or ""), MAX_ABSTRACT_CHARS)
389
- published = entry.findtext("atom:published", default="", namespaces=ns) or ""
390
- paper_id = entry.findtext("atom:id", default="", namespaces=ns) or ""
391
- authors = [clean_extracted_text(a.findtext("atom:name", default="", namespaces=ns) or "") for a in entry.findall("atom:author", ns)]
392
- pdf_url = ""
393
- for link in entry.findall("atom:link", ns):
394
- if link.attrib.get("title") == "pdf":
395
- pdf_url = link.attrib.get("href", "")
396
- break
397
-
398
- out.append({
399
- "id": paper_id or title,
400
- "title": title,
401
- "summary": summary,
402
- "abstract": summary,
403
- "published": published[:10],
404
- "authors": [a for a in authors[:8] if a],
405
- "authors_text": ", ".join([a for a in authors[:4] if a]) or "Unknown authors",
406
- "url": paper_id,
407
- "pdf": pdf_url,
408
- "doi": "",
409
- "venue": "arXiv",
410
- "year": published[:4] if published else "",
411
- "source": "arxiv",
412
- "score": 0.76,
413
- "open_access": True,
414
- "external_ids": {"arxiv": (paper_id or "").split("/")[-1]},
415
- })
416
- return out
417
-
418
-
419
- def search_crossref(query: str, mode: str = "topic", max_results: int = 8) -> List[Dict[str, Any]]:
420
- headers = {"User-Agent": "dvnc-ai-space/1.0"}
421
- if mode == "doi":
422
- url = f"https://api.crossref.org/works/{urllib.parse.quote(normalize_doi(query))}"
423
- response = requests.get(url, headers=headers, timeout=REQUEST_TIMEOUT)
424
- if response.status_code != 200:
425
- return []
426
- items = [response.json().get("message", {})]
427
- else:
428
- params = {"rows": max_results}
429
- if mode in ("title", "paper_name"):
430
- params["query.title"] = query
431
- else:
432
- params["query.bibliographic"] = query
433
- response = requests.get("https://api.crossref.org/works", params=params, headers=headers, timeout=REQUEST_TIMEOUT)
434
- response.raise_for_status()
435
- items = response.json().get("message", {}).get("items", [])
436
-
437
- out = []
438
- for item in items:
439
- authors = []
440
- for a in item.get("author", []) or []:
441
- name = " ".join(filter(None, [a.get("given"), a.get("family")])).strip()
442
- if name:
443
- authors.append(clean_extracted_text(name))
444
-
445
- title = clean_extracted_text((item.get("title") or ["Untitled"])[0])
446
- year = ""
447
- for key in ["published-print", "published-online", "created"]:
448
- if item.get(key, {}).get("date-parts"):
449
- year = str(item[key]["date-parts"][0][0])
450
- break
451
- abstract = truncate_text(clean_extracted_text(re.sub("<.*?>", " ", item.get("abstract") or "")), MAX_ABSTRACT_CHARS)
452
- doi = normalize_doi(item.get("DOI", ""))
453
-
454
- out.append({
455
- "id": doi or title,
456
- "title": title,
457
- "summary": truncate_text(abstract, 500),
458
- "abstract": abstract,
459
- "published": year,
460
- "authors": authors,
461
- "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
462
- "url": item.get("URL", ""),
463
- "pdf": "",
464
- "doi": doi,
465
- "venue": clean_extracted_text((item.get("container-title") or [""])[0]),
466
- "year": year,
467
- "source": "crossref",
468
- "score": 0.72,
469
- "open_access": None,
470
- "external_ids": {"crossref": doi} if doi else {},
471
- })
472
- return out
473
-
474
-
475
- def search_openalex(query: str, mode: str = "topic", max_results: int = 8) -> List[Dict[str, Any]]:
476
- params = {"per-page": max_results}
477
- if mode == "doi":
478
- doi = normalize_doi(query)
479
- params["filter"] = f"doi:https://doi.org/{doi}"
480
- else:
481
- params["search"] = query
482
-
483
- response = requests.get("https://api.openalex.org/works", params=params, timeout=REQUEST_TIMEOUT)
484
- if response.status_code != 200:
485
- return []
486
- items = response.json().get("results", [])
487
-
488
- out = []
489
- for item in items:
490
- authors = []
491
- for auth in item.get("authorships", [])[:8]:
492
- author = auth.get("author") or {}
493
- if author.get("display_name"):
494
- authors.append(clean_extracted_text(author["display_name"]))
495
- oa = item.get("open_access") or {}
496
- doi = normalize_doi(item.get("doi") or "")
497
- abstract = truncate_text(clean_extracted_text(parse_openalex_abstract(item.get("abstract_inverted_index"))), MAX_ABSTRACT_CHARS)
498
-
499
- out.append({
500
- "id": item.get("id") or doi or item.get("title"),
501
- "title": clean_extracted_text(item.get("title") or ""),
502
- "summary": truncate_text(abstract, 500),
503
- "abstract": abstract,
504
- "published": str(item.get("publication_year") or ""),
505
- "authors": authors,
506
- "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
507
- "url": ((item.get("primary_location") or {}).get("landing_page_url") or item.get("id") or ""),
508
- "pdf": oa.get("oa_url") or "",
509
- "doi": doi,
510
- "venue": clean_extracted_text((((item.get("primary_location") or {}).get("source") or {}).get("display_name") or "")),
511
- "year": str(item.get("publication_year") or ""),
512
- "source": "openalex",
513
- "score": 0.80,
514
- "open_access": oa.get("is_oa"),
515
- "external_ids": item.get("ids") or {},
516
- })
517
- return out
518
-
519
-
520
- def search_semantic_scholar(query: str, mode: str = "topic", max_results: int = 8) -> List[Dict[str, Any]]:
521
- fields = "title,authors,year,abstract,venue,externalIds,url,openAccessPdf"
522
- if mode == "doi":
523
- doi = normalize_doi(query)
524
- url = f"https://api.semanticscholar.org/graph/v1/paper/DOI:{urllib.parse.quote(doi)}"
525
- response = requests.get(url, params={"fields": fields}, timeout=REQUEST_TIMEOUT)
526
- if response.status_code != 200:
527
- return []
528
- items = [response.json()]
529
- else:
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:
536
- return []
537
- items = response.json().get("data", [])
538
-
539
- out = []
540
- for item in items:
541
- external = item.get("externalIds") or {}
542
- authors = [clean_extracted_text(a.get("name")) for a in item.get("authors", []) if a.get("name")]
543
- abstract = truncate_text(clean_extracted_text(item.get("abstract", "")), MAX_ABSTRACT_CHARS)
544
- out.append({
545
- "id": external.get("CorpusId") or external.get("DOI") or item.get("title"),
546
- "title": clean_extracted_text(item.get("title") or ""),
547
- "summary": truncate_text(abstract, 500),
548
- "abstract": abstract,
549
- "published": str(item.get("year") or ""),
550
- "authors": authors,
551
- "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
552
- "url": item.get("url") or "",
553
- "pdf": ((item.get("openAccessPdf") or {}).get("url") or ""),
554
- "doi": normalize_doi(external.get("DOI", "")),
555
- "venue": clean_extracted_text(item.get("venue") or ""),
556
- "year": str(item.get("year") or ""),
557
- "source": "semantic_scholar",
558
- "score": 0.84,
559
- "open_access": bool((item.get("openAccessPdf") or {}).get("url")),
560
- "external_ids": external,
561
- })
562
- return out
563
-
564
-
565
- def search_europe_pmc(query: str, mode: str = "topic", max_results: int = 8) -> List[Dict[str, Any]]:
566
- epmc_query = f'DOI:"{query}"' if mode == "doi" else query
567
- params = {"query": epmc_query, "format": "json", "pageSize": max_results, "resultType": "core"}
568
- response = requests.get("https://www.ebi.ac.uk/europepmc/webservices/rest/search", params=params, timeout=REQUEST_TIMEOUT)
569
- if response.status_code != 200:
570
- return []
571
- items = response.json().get("resultList", {}).get("result", [])
572
-
573
- out = []
574
- for item in items:
575
- author_string = item.get("authorString", "")
576
- authors = [clean_extracted_text(x) for x in author_string.split(",")[:8] if norm_text(x)]
577
- pmcid = item.get("pmcid", "")
578
- pdf_url = f"https://europepmc.org/articles/{pmcid}?pdf=render" if pmcid else ""
579
- landing_url = f"https://europepmc.org/article/PMC/{pmcid}" if pmcid else ""
580
- abstract = truncate_text(clean_extracted_text(item.get("abstractText", "")), MAX_ABSTRACT_CHARS)
581
- out.append({
582
- "id": item.get("id") or item.get("doi") or item.get("title"),
583
- "title": clean_extracted_text(item.get("title") or ""),
584
- "summary": truncate_text(abstract, 500),
585
- "abstract": abstract,
586
- "published": str(item.get("pubYear") or ""),
587
- "authors": authors,
588
- "authors_text": ", ".join(authors[:4]) if authors else "Unknown authors",
589
- "url": landing_url,
590
- "pdf": pdf_url,
591
- "doi": normalize_doi(item.get("doi", "")),
592
- "venue": clean_extracted_text(item.get("journalTitle", "")),
593
- "year": str(item.get("pubYear") or ""),
594
- "source": "europe_pmc",
595
- "score": 0.78,
596
- "open_access": bool(pmcid),
597
- "external_ids": {"pmid": item.get("pmid"), "pmcid": pmcid},
598
- })
599
- return out
600
-
601
-
602
- def resolve_link(query: str) -> List[Dict[str, Any]]:
603
- url = (query or "").strip()
604
- if not url:
605
- return []
606
- return [{
607
- "id": url,
608
- "title": Path(url.split("?")[0]).name or url,
609
- "summary": "Direct link supplied.",
610
- "abstract": "",
611
- "published": "",
612
- "authors": [],
613
- "authors_text": "Unknown authors",
614
- "url": url,
615
- "pdf": url if url.lower().endswith(".pdf") else "",
616
- "doi": "",
617
- "venue": "Web Link",
618
- "year": "",
619
- "source": "link",
620
- "score": 0.4,
621
- "open_access": None,
622
- "external_ids": {},
623
- }]
624
-
625
-
626
- def discover_papers(query: str, mode: str, sources: List[str], max_results: int = 10) -> List[Dict[str, Any]]:
627
- query = (query or "").strip()
628
- if not query:
629
- return []
630
-
631
- mode = detect_query_type(query) if mode == "autonomous_web" else mode
632
- selected_sources = ensure_list(sources) or DEFAULT_SOURCES
633
- results = []
634
-
635
- if mode == "link":
636
- return dedupe_papers(resolve_link(query))
637
-
638
- if "arxiv" in selected_sources and mode != "doi":
639
- try:
640
- results.extend(search_arxiv(query, max_results=min(max_results, 6)))
641
- except Exception:
642
- pass
643
- if "crossref" in selected_sources:
644
- try:
645
- results.extend(search_crossref(query, mode=mode, max_results=min(max_results, 8)))
646
- except Exception:
647
- pass
648
- if "openalex" in selected_sources:
649
- try:
650
- results.extend(search_openalex(query, mode=mode, max_results=min(max_results, 8)))
651
- except Exception:
652
- pass
653
- if "semantic_scholar" in selected_sources:
654
- try:
655
- results.extend(search_semantic_scholar(query, mode=mode, max_results=min(max_results, 8)))
656
- except Exception:
657
- pass
658
- if "europe_pmc" in selected_sources:
659
- try:
660
- results.extend(search_europe_pmc(query, mode=mode, max_results=min(max_results, 8)))
661
- except Exception:
662
- pass
663
-
664
- papers = [enrich_paper_semantics(query, p) for p in dedupe_papers(results)]
665
- papers.sort(key=lambda x: float(x.get("learned_score", x.get("score", 0))), reverse=True)
666
- return papers[:max_results]
667
-
668
-
669
- # ----------------------------
670
- # Journal / paper HTML
671
- # ----------------------------
672
-
673
- def journal_query_links(query: str):
674
- q = urllib.parse.quote_plus(query or "biomaterials cardiac repair")
675
- rows = []
676
- for journal in JOURNALS:
677
- url = f"{journal['url']}?q={q}" if "?" not in journal["url"] else f"{journal['url']}&q={q}"
678
- if "ieeexplore" in journal["url"]:
679
- url = f"https://ieeexplore.ieee.org/search/searchresult.jsp?queryText={q}"
680
- rows.append({"name": journal["name"], "desc": journal["desc"], "url": url})
681
- return rows
682
-
683
-
684
- def build_journal_html(query):
685
- rows = []
686
- for journal in journal_query_links(query):
687
- rows.append(
688
- f"""
689
- <a class="journal-card" href="{safe_text(journal['url'])}" target="_blank" rel="noopener noreferrer">
690
- <div>
691
- <h4>{safe_text(journal['name'])}</h4>
692
- <p>{safe_text(journal['desc'])}</p>
693
- </div>
694
- <span>Open</span>
695
- </a>
696
- """
697
- )
698
- return """
699
- <style>
700
- .journal-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px}
701
- .journal-card{display:flex;justify-content:space-between;gap:12px;padding:14px 16px;border-radius:14px;border:1px solid #dbe3f0;background:#f8fbff;text-decoration:none;color:#0f172a}
702
- .journal-card h4{margin:0 0 4px;font-size:15px}
703
- .journal-card p{margin:0;color:#475569;font-size:13px;line-height:1.45}
704
- .journal-card span{align-self:center;font-size:12px;font-weight:700;color:#2563eb}
705
- </style>
706
- <div class="journal-grid">""" + "".join(rows) + "</div>"
707
-
708
-
709
- def paper_choice_value(index: int, paper: Dict[str, Any]) -> str:
710
- doi = normalize_doi(paper.get("doi") or "")
711
- title_slug = slugify(paper.get("title", ""))[:40]
712
- return f"{index}|{doi}|{title_slug}"
713
-
714
-
715
- def paper_choice_label(index: int, paper: Dict[str, Any]) -> str:
716
- score = round(float(paper.get("learned_score", paper.get("score", 0))), 3)
717
- title = paper.get("title", "Untitled")
718
- authors_text = paper.get("authors_text", "Unknown authors")[:90]
719
- source = paper.get("source", "src")
720
- return f"[{source}] {title} — {authors_text} — score {score}"
721
-
722
-
723
- def format_selection_choices(papers):
724
- return [(paper_choice_label(i, paper), paper_choice_value(i, paper)) for i, paper in enumerate(papers)]
725
-
726
-
727
- def format_papers_html(papers):
728
- if not papers:
729
- return '<div class="panel papers-panel" style="padding:18px"><p>No papers found yet.</p></div>'
730
-
731
- items = []
732
- for i, paper in enumerate(papers, start=1):
733
- summary = safe_text((paper.get("summary") or paper.get("abstract") or "")[:320])
734
- doi_line = f'<span class="paper-badge doi-badge">{safe_text(paper.get("doi"))}</span>' if paper.get("doi") else ""
735
- pdf_link = paper.get("pdf") or "#"
736
- abs_link = paper.get("url") or "#"
737
- concepts_text = ", ".join((paper.get("concepts") or [])[:5])
738
-
739
- items.append(
740
- f"""
741
- <article class="paper-card">
742
- <div class="paper-topline">
743
- <span class="paper-badge">{safe_text(paper.get('source', 'paper'))}</span>
744
- <span class="paper-badge alt">{safe_text(paper.get('published', '') or 'Paper')}</span>
745
- {doi_line}
746
- </div>
747
- <h4>{i}. {safe_text(paper.get('title', 'Untitled'))}</h4>
748
- <p>{summary or 'No abstract snippet available.'}</p>
749
- <div class="paper-meta-stack">
750
- <div><strong>Authors:</strong> {safe_text(paper.get('authors_text', 'Unknown authors'))}</div>
751
- <div><strong>Venue:</strong> {safe_text(paper.get('venue', 'Unknown venue'))}</div>
752
- <div><strong>Learned score:</strong> {safe_text(round(float(paper.get('learned_score', paper.get('score', 0))), 3))}</div>
753
- <div><strong>Concepts:</strong> {safe_text(concepts_text or 'None extracted')}</div>
754
- </div>
755
- <div class="paper-links">
756
- <a href="{safe_text(abs_link)}" target="_blank" rel="noopener noreferrer">Abstract</a>
757
- <a href="{safe_text(pdf_link)}" target="_blank" rel="noopener noreferrer">PDF</a>
758
- </div>
759
- </article>
760
- """
761
- )
762
- return '<div class="papers-grid">' + ''.join(items) + '</div>'
763
-
764
-
765
- def format_frontier_html(frontier):
766
- if not frontier:
767
- return '<div class="panel papers-panel" style="padding:18px"><p>No autonomous expansion candidates yet.</p></div>'
768
- cards = []
769
- for i, paper in enumerate(frontier[:12], start=1):
770
- cards.append(
771
- f"""
772
- <article class="paper-card frontier-card">
773
- <div class="paper-topline">
774
- <span class="paper-badge">frontier</span>
775
- <span class="paper-badge alt">{safe_text(paper.get('source', 'paper'))}</span>
776
- </div>
777
- <h4>{i}. {safe_text(paper.get('title', 'Untitled'))}</h4>
778
- <p>{safe_text((paper.get('summary') or paper.get('abstract') or '')[:280])}</p>
779
- <div class="paper-meta-stack">
780
- <div><strong>Frontier score:</strong> {safe_text(paper.get('frontier_score', paper.get('learned_score', paper.get('score', 0))))}</div>
781
- <div><strong>Concept overlap:</strong> {safe_text(paper.get('frontier_concept_overlap', 0))}</div>
782
- <div><strong>Authors:</strong> {safe_text(paper.get('authors_text', 'Unknown authors'))}</div>
783
- </div>
784
- </article>
785
- """
786
- )
787
- return '<div class="papers-grid">' + ''.join(cards) + '</div>'
788
-
789
-
790
- def uploaded_pdf_summary(file_obj):
791
- if not file_obj:
792
- return "No PDF uploaded yet."
793
- path = getattr(file_obj, "name", None) or str(file_obj)
794
- p = Path(path)
795
- return f"Uploaded PDF ready for ingestion: {p.name}. Use Parse uploaded PDF to extract title, abstract, sections, references, concepts, and claims."
796
-
797
-
798
- # ----------------------------
799
- # 3D graph
800
- # ----------------------------
801
-
802
- def graph_kind_style(kind: str) -> Dict[str, Any]:
803
- palette = {
804
- "query": {"color": "#1f8ef1", "size": 14, "label": "Research topic"},
805
- "paper": {"color": "#00c49a", "size": 10, "label": "Paper"},
806
- "upload": {"color": "#ff9f43", "size": 11, "label": "Uploaded PDF"},
807
- "concept": {"color": "#a66cff", "size": 8, "label": "Concept"},
808
- "author": {"color": "#f368e0", "size": 7, "label": "Author"},
809
- "claim": {"color": "#ff6b6b", "size": 8, "label": "Claim"},
810
- "reference": {"color": "#6c757d", "size": 7, "label": "Reference"},
811
- "frontier": {"color": "#ffd166", "size": 8, "label": "Frontier candidate"},
812
- }
813
- return palette.get(kind, {"color": "#9aa0a6", "size": 7, "label": kind.title()})
814
-
815
-
816
- def summarize_graph(nodes: List[Dict], edges: List[Dict]) -> Dict[str, Any]:
817
- counts = Counter((n.get("kind") or n.get("type") or "unknown").lower() for n in nodes)
818
- return {"nodes": len(nodes), "edges": len(edges), "counts": dict(counts)}
819
-
820
-
821
- def _prepare_3d_graph_data(nodes: List[Dict], edges: List[Dict], title: str) -> Dict[str, Any]:
822
- node_out = []
823
- for node in nodes:
824
- kind = (node.get("kind") or node.get("type") or "paper").lower()
825
- if kind == "topic":
826
- kind = "query"
827
- if kind == "uploadedpdf":
828
- kind = "upload"
829
- if kind == "frontierpaper":
830
- kind = "frontier"
831
- style = graph_kind_style(kind)
832
- node_out.append({
833
- "id": node.get("id"),
834
- "label": truncate_text(node.get("label") or node.get("title") or node.get("id") or "node", 120),
835
- "kind": kind,
836
- "color": style["color"],
837
- "val": style["size"],
838
- "detail": {
839
- "kind": style["label"],
840
- "title": node.get("title") or node.get("label") or node.get("id"),
841
- "venue": node.get("venue") or "",
842
- "year": node.get("year") or "",
843
- "doi": node.get("doi") or "",
844
- "source": node.get("source") or "",
845
- "authors_text": node.get("authors_text") or "",
846
- "text": node.get("text") or "",
847
- },
848
- })
849
- edge_out = []
850
- for edge in edges:
851
- edge_out.append({
852
- "source": edge.get("source"),
853
- "target": edge.get("target"),
854
- "type": edge.get("type") or "RELATES_TO",
855
- "label": edge.get("type") or "RELATES_TO",
856
- })
857
- return {"title": title, "nodes": node_out, "links": edge_out, "summary": summarize_graph(nodes, edges)}
858
-
859
-
860
- def build_learning_graph_html(nodes, edges, title="Self-Learning Knowledge Graph"):
861
- if not nodes:
862
- return """
863
- <div class="panel brain-shell" style="overflow:auto; max-width:100%;">
864
- <div class="brain-header">
865
- <div>
866
- <p class="eyebrow">Learning Graph</p>
867
- <h3>Self-Learning Knowledge Graph</h3>
868
- </div>
869
- </div>
870
- <div class="brain-stage learning-empty" style="min-height:420px; overflow:auto;">
871
- <div class="empty-graph-copy">
872
- <h4>No papers mapped yet</h4>
873
- <p>Search papers, select candidates, or upload a PDF to grow the graph in an interactive 3D view.</p>
874
- </div>
875
- </div>
876
- </div>
877
- """
878
-
879
- graph_data = _prepare_3d_graph_data(nodes, edges, title)
880
- payload_json = json.dumps(graph_data, ensure_ascii=False)
881
-
882
- iframe_html = f"""
883
- <!doctype html>
884
- <html>
885
- <head>
886
- <meta charset="utf-8" />
887
- <meta name="viewport" content="width=device-width, initial-scale=1" />
888
- <style>
889
- html, body {{ margin:0; height:100%; background:#0b1020; color:#eef2ff; font-family: Inter, ui-sans-serif, system-ui, sans-serif; overflow:hidden; }}
890
- #wrap {{ position:relative; width:100%; height:100%; background:radial-gradient(circle at top, #18213c 0%, #0b1020 60%, #060910 100%); }}
891
- #graph {{ position:absolute; inset:0; }}
892
- .overlay {{
893
- position:absolute; left:16px; top:16px; z-index:10; max-width:min(460px, calc(100% - 32px));
894
- padding:14px 16px; border:1px solid rgba(255,255,255,.12); border-radius:16px;
895
- background:rgba(10,14,28,.72); backdrop-filter: blur(14px); box-shadow:0 12px 28px rgba(0,0,0,.28);
896
- }}
897
- .overlay h3 {{ margin:0 0 6px; font-size:18px; line-height:1.2; }}
898
- .overlay p {{ margin:0; font-size:13px; color:#cbd5e1; line-height:1.5; }}
899
- .panel {{
900
- position:absolute; right:16px; top:16px; z-index:10; width:min(360px, calc(100% - 32px));
901
- max-height:calc(100% - 32px); overflow:auto; padding:14px 16px; border:1px solid rgba(255,255,255,.12);
902
- border-radius:16px; background:rgba(10,14,28,.72); backdrop-filter: blur(14px); box-shadow:0 12px 28px rgba(0,0,0,.28);
903
- }}
904
- .panel h4 {{ margin:0 0 8px; font-size:14px; color:#f8fafc; }}
905
- .panel p {{ margin:0; font-size:12px; color:#cbd5e1; line-height:1.5; }}
906
- .panel dl {{ margin:12px 0 0; display:grid; grid-template-columns:auto 1fr; gap:6px 10px; font-size:12px; }}
907
- .panel dt {{ color:#93c5fd; }}
908
- .panel dd {{ margin:0; color:#e2e8f0; word-break:break-word; }}
909
- .hint {{
910
- position:absolute; left:16px; bottom:16px; z-index:10; padding:10px 12px; border-radius:12px;
911
- font-size:12px; color:#dbeafe; background:rgba(15,23,42,.75); border:1px solid rgba(255,255,255,.08);
912
- }}
913
- </style>
914
- <script src="https://unpkg.com/three@0.160.0/build/three.min.js"></script>
915
- <script src="https://unpkg.com/3d-force-graph"></script>
916
- </head>
917
- <body>
918
- <div id="wrap">
919
- <div id="graph"></div>
920
- <div class="overlay">
921
- <h3></h3>
922
- <p>Drag the background to orbit, scroll to zoom, right-drag to pan, and drag a node to move or pin it in 3D space.</p>
923
- </div>
924
- <div class="panel" id="panel">
925
- <h4>Node details</h4>
926
- <p>Click any node to inspect its label, type, venue, DOI, year, and source.</p>
927
- </div>
928
- <div class="hint">Interactive 3D graph: orbit, zoom, pan, drag nodes.</div>
929
- </div>
930
- <script>
931
- const payload = {payload_json};
932
- document.querySelector('.overlay h3').textContent = payload.title || 'Self-Learning Knowledge Graph';
933
- const panelEl = document.getElementById('panel');
934
-
935
- const Graph = ForceGraph3D()(document.getElementById('graph'))
936
- .backgroundColor('#00000000')
937
- .graphData(payload)
938
- .nodeRelSize(6)
939
- .nodeOpacity(1)
940
- .nodeLabel(node => `<div style="padding:6px 8px"><strong>${{node.label}}</strong><br/>${{(node.detail || {{}}).kind || node.kind}}</div>`)
941
- .linkWidth(link => ['ABOUT','UPLOADED_SOURCE','FRONTIER_CANDIDATE'].includes(link.type) ? 2.6 : 1.35)
942
- .linkDirectionalParticles(link => ['ABOUT','FRONTIER_CANDIDATE'].includes(link.type) ? 2 : 0)
943
- .linkDirectionalParticleWidth(2.2)
944
- .cooldownTicks(140)
945
- .d3VelocityDecay(0.24)
946
- .d3Force('charge').strength(-180)
947
- .nodeColor(node => node.color)
948
- .nodeVal(node => node.val)
949
- .onNodeClick(node => {{
950
- const d = node.detail || {{}};
951
- panelEl.innerHTML = `
952
- <h4>Node details</h4>
953
- <dl>
954
- <dt>Label</dt><dd>${{node.label || ''}}</dd>
955
- <dt>Type</dt><dd>${{d.kind || node.kind || ''}}</dd>
956
- <dt>Venue</dt><dd>${{d.venue || '—'}}</dd>
957
- <dt>Year</dt><dd>${{d.year || '—'}}</dd>
958
- <dt>DOI</dt><dd>${{d.doi || '—'}}</dd>
959
- <dt>Source</dt><dd>${{d.source || '—'}}</dd>
960
- <dt>Authors</dt><dd>${{d.authors_text || '—'}}</dd>
961
- <dt>Text</dt><dd>${{d.text || '—'}}</dd>
962
- </dl>`;
963
- }})
964
- .onNodeDragEnd(node => {{
965
- node.fx = node.x;
966
- node.fy = node.y;
967
- node.fz = node.z;
968
- }});
969
-
970
- Graph.scene().add(new THREE.AmbientLight(0xffffff, 1.1));
971
- const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
972
- dirLight.position.set(120, 120, 120);
973
- Graph.scene().add(dirLight);
974
- Graph.cameraPosition({{ z: 210 }});
975
- </script>
976
- </body>
977
- </html>
978
- """
979
- return f"""
980
- <div class="panel brain-shell" style="overflow:auto; max-width:100%;">
981
- <iframe
982
- title="{safe_text(title)}"
983
- style="width:100%; height:{GRAPH_IFRAME_HEIGHT}px; border:0; border-radius:18px; overflow:auto; background:#0b1020;"
984
- sandbox="allow-scripts allow-same-origin"
985
- srcdoc="{html.escape(iframe_html, quote=True)}"
986
- ></iframe>
987
- </div>
988
- """
989
-
990
-
991
- def build_learning_graph_state(query, papers, uploaded_name=None):
992
- nodes = [{"id": "query", "label": query or "Research topic", "kind": "query"}]
993
- edges = []
994
-
995
- for i, paper in enumerate(papers[:6], start=1):
996
- pid = f"paper_{i}"
997
- nodes.append({
998
- "id": pid,
999
- "label": paper.get("title", f"Paper {i}"),
1000
- "kind": "paper",
1001
- "title": paper.get("title"),
1002
- "venue": paper.get("venue"),
1003
- "year": paper.get("year"),
1004
- "source": paper.get("source"),
1005
- "authors_text": paper.get("authors_text"),
1006
- })
1007
- edges.append({"source": "query", "target": pid, "type": "ABOUT"})
1008
- for concept in (paper.get("concepts") or [])[:3]:
1009
- cid = f"concept_{i}_{slugify(concept)[:30]}"
1010
- nodes.append({"id": cid, "label": concept, "kind": "concept"})
1011
- edges.append({"source": pid, "target": cid, "type": "MENTIONS"})
1012
-
1013
- if uploaded_name:
1014
- nodes.append({"id": "upload", "label": uploaded_name, "kind": "upload"})
1015
- edges.append({"source": "query", "target": "upload", "type": "UPLOADED_SOURCE"})
1016
- return nodes, edges
1017
-
1018
-
1019
- def graph_from_selected(query, selected_papers, uploaded_name=None, parsed_state=None, frontier=None):
1020
- nodes = [{"id": "query", "label": query or "Research topic", "kind": "query"}]
1021
- edges = []
1022
-
1023
- for i, paper in enumerate(selected_papers[:8], start=1):
1024
- pid = f"paper_{i}"
1025
- nodes.append({
1026
- "id": pid,
1027
- "label": paper.get("title", f"Paper {i}"),
1028
- "kind": "paper",
1029
- "title": paper.get("title"),
1030
- "venue": paper.get("venue"),
1031
- "year": paper.get("year"),
1032
- "doi": paper.get("doi"),
1033
- "source": paper.get("source"),
1034
- "authors_text": paper.get("authors_text"),
1035
- })
1036
- edges.append({"source": "query", "target": pid, "type": "ABOUT"})
1037
-
1038
- for author in paper.get("authors", [])[:3]:
1039
- aid = f"author_{i}_{slugify(author)[:30]}"
1040
- nodes.append({"id": aid, "label": author, "kind": "author"})
1041
- edges.append({"source": pid, "target": aid, "type": "WRITTEN_BY"})
1042
-
1043
- for concept in (paper.get("concepts") or [])[:4]:
1044
- cid = f"concept_{i}_{slugify(concept)[:30]}"
1045
- nodes.append({"id": cid, "label": concept, "kind": "concept"})
1046
- edges.append({"source": pid, "target": cid, "type": "MENTIONS"})
1047
-
1048
- for claim in (paper.get("claims") or [])[:2]:
1049
- cid = f"claim_{i}_{slugify(claim)[:30]}"
1050
- nodes.append({"id": cid, "label": truncate_text(claim, 82), "kind": "claim", "text": claim})
1051
- edges.append({"source": pid, "target": cid, "type": "ASSERTS"})
1052
-
1053
- if uploaded_name:
1054
- nodes.append({"id": "upload", "label": uploaded_name, "kind": "upload", "title": uploaded_name})
1055
- edges.append({"source": "query", "target": "upload", "type": "UPLOADED_SOURCE"})
1056
- if parsed_state and isinstance(parsed_state, dict):
1057
- for concept in (parsed_state.get("concepts") or [])[:4]:
1058
- cid = f"upload_concept_{slugify(concept)[:30]}"
1059
- nodes.append({"id": cid, "label": concept, "kind": "concept"})
1060
- edges.append({"source": "upload", "target": cid, "type": "MENTIONS"})
1061
- for claim in (parsed_state.get("claims") or [])[:3]:
1062
- cid = f"upload_claim_{slugify(claim)[:30]}"
1063
- nodes.append({"id": cid, "label": truncate_text(claim, 82), "kind": "claim", "text": claim})
1064
- edges.append({"source": "upload", "target": cid, "type": "ASSERTS"})
1065
- for ref in (parsed_state.get("references") or [])[:6]:
1066
- if ref.get("title"):
1067
- rid = f"ref_{slugify(ref.get('title'))[:30]}"
1068
- nodes.append({"id": rid, "label": truncate_text(ref.get("title"), 82), "kind": "reference", "doi": ref.get("doi", "")})
1069
- edges.append({"source": "upload", "target": rid, "type": "CITES"})
1070
-
1071
- for j, fp in enumerate(ensure_list(frontier)[:6], start=1):
1072
- fid = f"frontier_{j}"
1073
- nodes.append({
1074
- "id": fid,
1075
- "label": fp.get("title", f"Frontier {j}"),
1076
- "kind": "frontier",
1077
- "title": fp.get("title"),
1078
- "source": fp.get("source"),
1079
- "authors_text": fp.get("authors_text"),
1080
- "year": fp.get("year"),
1081
- "doi": fp.get("doi"),
1082
- })
1083
- edges.append({"source": "query", "target": fid, "type": "FRONTIER_CANDIDATE"})
1084
-
1085
- dedup_nodes = []
1086
- seen = set()
1087
- for node in nodes:
1088
- if node["id"] not in seen:
1089
- seen.add(node["id"])
1090
- dedup_nodes.append(node)
1091
- return dedup_nodes, edges
1092
-
1093
-
1094
- # ----------------------------
1095
- # PDF parsing
1096
- # ----------------------------
1097
-
1098
- def parse_pdf_with_pymupdf(pdf_path: str) -> Dict[str, Any]:
1099
- if fitz is None:
1100
- raise RuntimeError("PyMuPDF not installed")
1101
-
1102
- doc = fitz.open(pdf_path)
1103
- page_texts = [page.get_text("text") for page in doc[: min(len(doc), 10)]]
1104
- raw_text = truncate_text(clean_extracted_text("\n".join(page_texts).strip()), MAX_RAW_TEXT_CHARS)
1105
- first_page = clean_extracted_text("\n".join(page_texts[:2]))[:5000]
1106
-
1107
- title = extract_title_from_text(first_page, fallback=Path(pdf_path).name)
1108
-
1109
- abstract = ""
1110
- match = re.search(r"abstract\s*(.+?)(?:\n\s*\n|\n(?:1|i)[\.\s]|\nintroduction)", raw_text, re.I | re.S)
1111
- if match:
1112
- abstract = truncate_text(clean_extracted_text(match.group(1)), 2600)
1113
-
1114
- sections = []
1115
- blocks = re.split(r"\n(?=[A-Z][A-Za-z\s]{2,40}\n)", raw_text)
1116
- for block in blocks[:10]:
1117
- lines = [norm_text(x) for x in block.splitlines() if norm_text(x)]
1118
- if not lines:
1119
- continue
1120
- heading = lines[0] if len(lines[0]) < 60 else "Section"
1121
- body = " ".join(lines[1:] if len(lines) > 1 else lines)
1122
- if len(body) > 80:
1123
- sections.append({"heading": clean_extracted_text(heading), "text": truncate_text(body, 4200)})
1124
-
1125
- return {
1126
- "parser": "pymupdf",
1127
- "title": title,
1128
- "abstract": abstract,
1129
- "authors": [],
1130
- "sections": sections[:12] or ([{"heading": "Full Text", "text": raw_text[:12000]}] if raw_text else []),
1131
- "references": extract_references_from_text(raw_text),
1132
- "claims": extract_claim_like_sentences(raw_text, max_items=GRAPH_MAX_CLAIMS),
1133
- "concepts": extract_concepts_from_text(" ".join([title, abstract, raw_text[:18000]]), max_terms=GRAPH_MAX_CONCEPTS),
1134
- "raw_text": raw_text,
1135
- "parser_quality": "text-fallback-cleaned",
1136
- }
1137
-
1138
-
1139
- def parse_uploaded_pdf(file_obj, parser_order=None):
1140
- if not file_obj:
1141
- return "### PDF parse status\n\nNo PDF uploaded yet.", {}
1142
-
1143
- path = getattr(file_obj, "name", None) or str(file_obj)
1144
- parser_order = ensure_list(parser_order) or PDF_PARSERS
1145
- errors = []
1146
-
1147
- for parser_name in parser_order:
1148
- try:
1149
- if parser_name == "pymupdf":
1150
- result = parse_pdf_with_pymupdf(path)
1151
- else:
1152
- continue
1153
-
1154
- summary = (
1155
- f"### PDF parse status\n\n"
1156
- f"- Parser used: {result['parser']}\n"
1157
- f"- Parser quality: {result.get('parser_quality', 'unknown')}\n"
1158
- f"- Title: {result.get('title') or 'Unknown'}\n"
1159
- f"- Authors: {', '.join(result.get('authors')[:6]) if result.get('authors') else 'Unknown'}\n"
1160
- f"- Abstract found: {'Yes' if result.get('abstract') else 'No'}\n"
1161
- f"- Sections extracted: {len(result.get('sections') or [])}\n"
1162
- f"- References extracted: {len(result.get('references') or [])}\n"
1163
- f"- Concepts extracted: {len(result.get('concepts') or [])}\n"
1164
- f"- Claims extracted: {len(result.get('claims') or [])}\n"
1165
- )
1166
- return summary, result
1167
- except Exception as e:
1168
- errors.append(f"{parser_name}: {e}")
1169
-
1170
- fail_summary = "### PDF parse status\n\n" + "\n".join([f"- {x}" for x in errors])
1171
- return fail_summary, {"parser": None, "errors": errors}
1172
-
1173
-
1174
- def render_parse_result(parsed):
1175
- if not parsed or not isinstance(parsed, dict) or (not parsed.get("title") and not parsed.get("sections")):
1176
- return '<div class="panel" style="padding:18px"><p>No parsed document yet.</p></div>'
1177
-
1178
- sections_html = []
1179
- for section in parsed.get("sections", [])[:8]:
1180
- sections_html.append(
1181
- f"""
1182
- <details class="agent-step">
1183
- <summary class="agent-summary">
1184
- <div class="agent-index">§</div>
1185
- <div class="agent-head">
1186
- <h4>{safe_text(section.get('heading', 'Section'))}</h4>
1187
- <span>section</span>
1188
- </div>
1189
- </summary>
1190
- <div class="agent-copy">
1191
- <p>{safe_text(section.get('text', '')[:2200])}</p>
1192
- </div>
1193
- </details>
1194
- """
1195
- )
1196
-
1197
- refs = parsed.get("references", [])[:14]
1198
- refs_html = "".join(
1199
- f"<li>{safe_text(r.get('title') or 'Untitled')} {'· DOI ' + safe_text(r.get('doi')) if r.get('doi') else ''}</li>"
1200
- for r in refs
1201
- ) or "<li>No references extracted.</li>"
1202
-
1203
- concepts = parsed.get("concepts", [])[:12]
1204
- claims = parsed.get("claims", [])[:8]
1205
- concepts_html = "".join(f"<li>{safe_text(x)}</li>" for x in concepts) or "<li>No concepts extracted.</li>"
1206
- claims_html = "".join(f"<li>{safe_text(x)}</li>" for x in claims) or "<li>No claims extracted.</li>"
1207
-
1208
- title = safe_text(parsed.get("title") or "Parsed document")
1209
- abstract = safe_text((parsed.get("abstract") or "")[:2600]) or "No abstract extracted."
1210
- parser_name = safe_text(parsed.get("parser") or "unknown")
1211
- parser_quality = safe_text(parsed.get("parser_quality") or "unknown")
1212
-
1213
- return f"""
1214
- <div class="panel" style="padding:18px">
1215
- <div class="brain-header">
1216
- <div>
1217
- <p class="eyebrow">PDF Parse</p>
1218
- <h3>{title}</h3>
1219
- </div>
1220
- <div class="brain-legend"><span><i class="dot dot-upload"></i> {parser_name} · {parser_quality}</span></div>
1221
- </div>
1222
- <div class="parse-grid">
1223
- <div class="parse-card">
1224
- <h4>Abstract</h4>
1225
- <p>{abstract}</p>
1226
- </div>
1227
- <div class="parse-card">
1228
- <h4>References</h4>
1229
- <ul class="ref-list">{refs_html}</ul>
1230
- </div>
1231
- <div class="parse-card">
1232
- <h4>Concepts</h4>
1233
- <ul class="ref-list">{concepts_html}</ul>
1234
- </div>
1235
- <div class="parse-card">
1236
- <h4>Claims</h4>
1237
- <ul class="ref-list">{claims_html}</ul>
1238
- </div>
1239
- </div>
1240
- <div class="timeline" style="margin-top:14px; max-height:560px; overflow:auto;">
1241
- {''.join(sections_html) if sections_html else '<div class="panel" style="padding:16px;"><p>No sections extracted.</p></div>'}
1242
- </div>
1243
- </div>
1244
- """
1245
-
1246
-
1247
- # ----------------------------
1248
- # Ingest payload / learning
1249
- # ----------------------------
1250
-
1251
- def add_node(nodes_by_id: Dict[str, Dict], node_id: str, node_type: str, label: str = "", **attrs):
1252
- if not node_id:
1253
- return
1254
- current = nodes_by_id.get(node_id, {})
1255
- merged = {"id": node_id, "type": node_type, "label": label or current.get("label", node_id)}
1256
- merged.update(current)
1257
- for key, value in attrs.items():
1258
- if value not in [None, ""]:
1259
- merged[key] = value
1260
- nodes_by_id[node_id] = merged
1261
-
1262
-
1263
- def add_edge(edges: List[Dict], source: str, target: str, edge_type: str, **attrs):
1264
- if not source or not target or source == target:
1265
- return
1266
- edge = {"source": source, "target": target, "type": edge_type}
1267
- for key, value in attrs.items():
1268
- if value not in [None, ""]:
1269
- edge[key] = value
1270
- edges.append(edge)
1271
-
1272
-
1273
- def build_ingest_payload(query, selected_papers, parsed_pdf=None, frontier=None):
1274
- nodes_by_id = {}
1275
- edges = []
1276
-
1277
- topic_id = "topic:query"
1278
- add_node(nodes_by_id, topic_id, "Topic", label=query or "Research topic", query=query or "")
1279
-
1280
- for i, paper in enumerate(selected_papers, start=1):
1281
- paper_id = normalize_doi(paper.get("doi")) or ((paper.get("external_ids") or {}).get("arxiv")) or f"paper:{i}:{slugify(paper.get('title', 'paper'))[:40]}"
1282
- add_node(
1283
- nodes_by_id,
1284
- paper_id,
1285
- "Paper",
1286
- label=paper.get("title") or f"Paper {i}",
1287
- title=paper.get("title"),
1288
- year=paper.get("year"),
1289
- venue=paper.get("venue"),
1290
- doi=normalize_doi(paper.get("doi")),
1291
- source=paper.get("source"),
1292
- url=paper.get("url"),
1293
- pdf=paper.get("pdf"),
1294
- score=paper.get("score"),
1295
- learned_score=paper.get("learned_score", paper.get("score")),
1296
- open_access=paper.get("open_access"),
1297
- authors_text=paper.get("authors_text"),
1298
- )
1299
- add_edge(edges, topic_id, paper_id, "ABOUT", weight=paper.get("learned_score", paper.get("score", 0)))
1300
-
1301
- for author in paper.get("authors", [])[:6]:
1302
- author_id = f"author:{slugify(author)[:64]}"
1303
- add_node(nodes_by_id, author_id, "Author", label=author, name=author)
1304
- add_edge(edges, paper_id, author_id, "WRITTEN_BY")
1305
-
1306
- for concept in (paper.get("concepts") or [])[:8]:
1307
- concept_id = f"concept:{slugify(concept)[:72]}"
1308
- add_node(nodes_by_id, concept_id, "Concept", label=concept, name=concept)
1309
- add_edge(edges, paper_id, concept_id, "MENTIONS")
1310
-
1311
- for claim in (paper.get("claims") or [])[:4]:
1312
- claim_id = f"claim:{slugify(claim)[:72]}"
1313
- add_node(nodes_by_id, claim_id, "Claim", label=claim[:140], text=claim)
1314
- add_edge(edges, paper_id, claim_id, "ASSERTS")
1315
-
1316
- if parsed_pdf and isinstance(parsed_pdf, dict) and parsed_pdf.get("title"):
1317
- doc_id = "upload:pdf"
1318
- add_node(nodes_by_id, doc_id, "UploadedPDF", label=parsed_pdf.get("title"), title=parsed_pdf.get("title"), parser=parsed_pdf.get("parser"))
1319
- add_edge(edges, topic_id, doc_id, "UPLOADED_SOURCE")
1320
-
1321
- for concept in (parsed_pdf.get("concepts") or [])[:8]:
1322
- concept_id = f"concept:{slugify(concept)[:72]}"
1323
- add_node(nodes_by_id, concept_id, "Concept", label=concept, name=concept)
1324
- add_edge(edges, doc_id, concept_id, "MENTIONS")
1325
-
1326
- for claim in (parsed_pdf.get("claims") or [])[:6]:
1327
- claim_id = f"claim:{slugify(claim)[:72]}"
1328
- add_node(nodes_by_id, claim_id, "Claim", label=claim[:140], text=claim)
1329
- add_edge(edges, doc_id, claim_id, "ASSERTS")
1330
-
1331
- for idx, ref in enumerate(parsed_pdf.get("references", [])[:20], start=1):
1332
- ref_title = ref.get("title") or f"Reference {idx}"
1333
- ref_doi = normalize_doi(ref.get("doi") or "")
1334
- ref_id = ref_doi or f"ref:{idx}:{slugify(ref_title)[:40]}"
1335
- add_node(nodes_by_id, ref_id, "Reference", label=ref_title, title=ref_title, doi=ref_doi)
1336
- add_edge(edges, doc_id, ref_id, "CITES")
1337
-
1338
- for idx, item in enumerate(ensure_list(frontier)[:18], start=1):
1339
- fid = normalize_doi(item.get("doi")) or f"frontier:{idx}:{slugify(item.get('title', 'paper'))[:40]}"
1340
- add_node(
1341
- nodes_by_id,
1342
- fid,
1343
- "FrontierPaper",
1344
- label=item.get("title") or f"Frontier {idx}",
1345
- title=item.get("title"),
1346
- frontier_score=item.get("frontier_score"),
1347
- url=item.get("url"),
1348
- source=item.get("source"),
1349
- authors_text=item.get("authors_text"),
1350
- year=item.get("year"),
1351
- doi=item.get("doi"),
1352
- )
1353
- add_edge(edges, topic_id, fid, "FRONTIER_CANDIDATE", weight=item.get("frontier_score", item.get("learned_score", item.get("score", 0))))
1354
-
1355
- return {"status": "ok", "nodes": list(nodes_by_id.values())[:GRAPH_MAX_NODES], "edges": edges[:GRAPH_MAX_EDGES]}
1356
-
1357
-
1358
- def learn_from_payload(payload: Dict, query: str = "") -> Dict:
1359
- if not payload:
1360
- return GRAPH_MEMORY
1361
-
1362
- GRAPH_MEMORY["queries"].append(query or "")
1363
- GRAPH_MEMORY["events"].append({
1364
- "ts": time.time(),
1365
- "query": query or "",
1366
- "nodes": len(payload.get("nodes", [])),
1367
- "edges": len(payload.get("edges", [])),
1368
- })
1369
- GRAPH_MEMORY["payloads"].append(payload)
1370
-
1371
- for node in payload.get("nodes", []):
1372
- node_id = node.get("id")
1373
- if not node_id:
1374
- continue
1375
- GRAPH_MEMORY["nodes"][node_id] = node
1376
- node_type = (node.get("type") or "").lower()
1377
- if node_type in {"paper", "frontierpaper"}:
1378
- GRAPH_MEMORY["papers"][node_id] = node
1379
- if node_type == "concept" and node.get("label"):
1380
- GRAPH_MEMORY["concept_counts"][node["label"].lower()] += 1
1381
- if node_type == "claim" and node.get("label"):
1382
- GRAPH_MEMORY["claim_counts"][node["label"].lower()] += 1
1383
-
1384
- GRAPH_MEMORY["edges"].extend(payload.get("edges", []))
1385
- GRAPH_MEMORY["edges"] = GRAPH_MEMORY["edges"][:GRAPH_MAX_EDGES]
1386
- return GRAPH_MEMORY
1387
-
1388
-
1389
- def export_learning_state() -> str:
1390
- snapshot = {
1391
- "papers": list(GRAPH_MEMORY["papers"].values())[:60],
1392
- "nodes": list(GRAPH_MEMORY["nodes"].values())[:250],
1393
- "edges": GRAPH_MEMORY["edges"][:500],
1394
- "top_concepts": GRAPH_MEMORY["concept_counts"].most_common(24),
1395
- "top_claims": GRAPH_MEMORY["claim_counts"].most_common(24),
1396
- "queries": GRAPH_MEMORY["queries"][-20:],
1397
- "events": GRAPH_MEMORY["events"][-20:],
1398
- "frontier": GRAPH_MEMORY["frontier"][:24],
1399
- }
1400
- return json.dumps(snapshot, indent=2, ensure_ascii=False)
1401
-
1402
-
1403
- # ----------------------------
1404
- # Frontier expansion
1405
- # ----------------------------
1406
-
1407
- def score_frontier_candidate(query: str, seed_concepts: List[str], paper: Dict[str, Any]) -> Dict[str, Any]:
1408
- title = paper.get("title", "")
1409
- abstract = paper.get("abstract", "") or paper.get("summary", "")
1410
- venue = paper.get("venue", "")
1411
- base_text = " ".join([title, abstract, venue])
1412
- rel = text_overlap_score(query, base_text)
1413
- concept_overlap = text_overlap_score(" ".join(seed_concepts), " ".join(paper.get("concepts") or [])) if seed_concepts else 0.0
1414
- recency = compute_recency_bonus(paper.get("year"))
1415
- doi_bonus = 0.02 if paper.get("doi") else 0.0
1416
- oa_bonus = 0.03 if paper.get("open_access") else 0.0
1417
- score = float(paper.get("learned_score", paper.get("score", 0))) + rel * 0.45 + concept_overlap * 0.22 + recency + doi_bonus + oa_bonus
1418
- paper["frontier_score"] = round(score, 4)
1419
- paper["frontier_relevance"] = round(rel, 4)
1420
- paper["frontier_concept_overlap"] = round(concept_overlap, 4)
1421
- return paper
1422
-
1423
-
1424
- def propose_expansion_queries(query: str, papers: List[Dict], parsed_state: Optional[Dict] = None, limit: int = GRAPH_MAX_EXPANSIONS) -> List[str]:
1425
- concept_pool = []
1426
- venue_pool = []
1427
- for paper in papers[:8]:
1428
- concept_pool.extend((paper.get("concepts") or [])[:4])
1429
- if paper.get("venue"):
1430
- venue_pool.append(paper["venue"])
1431
- if parsed_state and isinstance(parsed_state, dict):
1432
- concept_pool.extend((parsed_state.get("concepts") or [])[:6])
1433
-
1434
- ranked_concepts = [c for c, _ in Counter([norm_text(c).lower() for c in concept_pool if c]).most_common(limit * 2)]
1435
- expansions = [norm_text(query)] if query else []
1436
- for concept in ranked_concepts:
1437
- if concept:
1438
- expansions.append(f"{query} {concept}".strip())
1439
- for venue in unique_keep_order(venue_pool)[:2]:
1440
- if venue:
1441
- expansions.append(f"{query} {venue}".strip())
1442
- return unique_keep_order(expansions)[:limit]
1443
-
1444
-
1445
- def frontier_expand(query: str, sources: List[str], selected_papers: List[Dict], parsed_state: Optional[Dict] = None, per_query: int = 4) -> List[Dict]:
1446
- seed_concepts = []
1447
- for p in selected_papers[:6]:
1448
- seed_concepts.extend((p.get("concepts") or [])[:4])
1449
- if parsed_state and isinstance(parsed_state, dict):
1450
- seed_concepts.extend((parsed_state.get("concepts") or [])[:6])
1451
-
1452
- expansion_queries = propose_expansion_queries(query, selected_papers, parsed_state=parsed_state, limit=GRAPH_MAX_EXPANSIONS)
1453
- frontier = []
1454
- for eq in expansion_queries:
1455
- try:
1456
- items = discover_papers(eq, "topic", sources, max_results=per_query)
1457
- for item in items:
1458
- frontier.append(score_frontier_candidate(query or eq, seed_concepts, item))
1459
- except Exception:
1460
- continue
1461
-
1462
- frontier = dedupe_papers(frontier)
1463
- frontier.sort(key=lambda x: float(x.get("frontier_score", x.get("learned_score", x.get("score", 0)))), reverse=True)
1464
- GRAPH_MEMORY["frontier"] = frontier[: GRAPH_MAX_EXPANSIONS * per_query]
1465
- return GRAPH_MEMORY["frontier"]
1466
-
1467
-
1468
- def autonomous_expand_into_markdown(query, payload, parsed_state=None):
1469
- frontier = GRAPH_MEMORY.get("frontier") or []
1470
- lines = [
1471
- "### Autonomous expansion plan",
1472
- "",
1473
- f"- Seed query: {query or 'Research topic'}",
1474
- f"- Current nodes: {len(payload.get('nodes', [])) if isinstance(payload, dict) else 0}",
1475
- f"- Current edges: {len(payload.get('edges', [])) if isinstance(payload, dict) else 0}",
1476
- f"- Frontier candidates: {len(frontier)}",
1477
- ]
1478
-
1479
- proposed = propose_expansion_queries(query or "", list(GRAPH_MEMORY.get("papers", {}).values())[:8], parsed_state=parsed_state, limit=GRAPH_MAX_EXPANSIONS)
1480
- if proposed:
1481
- lines.extend(["", "#### Proposed next queries", ""])
1482
- lines.extend([f"- {q}" for q in proposed])
1483
-
1484
- if frontier:
1485
- lines.extend(["", "#### Top frontier papers", ""])
1486
- for item in frontier[:8]:
1487
- lines.append(
1488
- f"- {item.get('title', 'Untitled')} ({item.get('source', 'unknown')}) — frontier score {item.get('frontier_score', item.get('learned_score', item.get('score', 0)))}"
1489
- )
1490
- return "\n".join(lines)
1491
-
1492
-
1493
- # ----------------------------
1494
- # Selection / ingest
1495
- # ----------------------------
1496
-
1497
- def resolve_selected_papers(selected_indices, papers_state):
1498
- papers = ensure_list(papers_state)
1499
- selected_indices = ensure_list(selected_indices)
1500
- selected = []
1501
- if not selected_indices:
1502
- return selected
1503
-
1504
- value_map = {paper_choice_value(i, paper): paper for i, paper in enumerate(papers)}
1505
- label_map = {paper_choice_label(i, paper): paper for i, paper in enumerate(papers)}
1506
-
1507
- for idx in selected_indices:
1508
- try:
1509
- if isinstance(idx, int):
1510
- if 0 <= idx < len(papers):
1511
- selected.append(papers[idx])
1512
- continue
1513
- idx_str = str(idx)
1514
- if idx_str in value_map:
1515
- selected.append(value_map[idx_str])
1516
- continue
1517
- if idx_str.isdigit():
1518
- num = int(idx_str)
1519
- if 0 <= num < len(papers):
1520
- selected.append(papers[num])
1521
- continue
1522
- if "|" in idx_str:
1523
- left = idx_str.split("|", 1)[0]
1524
- if left.isdigit():
1525
- num = int(left)
1526
- if 0 <= num < len(papers):
1527
- selected.append(papers[num])
1528
- continue
1529
- if idx_str in label_map:
1530
- selected.append(label_map[idx_str])
1531
- continue
1532
- except Exception:
1533
- continue
1534
-
1535
- out = []
1536
- seen = set()
1537
- for paper in selected:
1538
- key = paper_identity_key(paper)
1539
- if key not in seen:
1540
- seen.add(key)
1541
- out.append(paper)
1542
- return out
1543
-
1544
-
1545
- def build_ingest_status_markdown(query_text: str, payload: Dict, selected: List[Dict], parsed_state: Optional[Dict], frontier: List[Dict]) -> str:
1546
- payload = payload or {"nodes": [], "edges": []}
1547
- nodes = payload.get("nodes", [])
1548
- edges = payload.get("edges", [])
1549
- counts = Counter((node.get("type") or "Unknown") for node in nodes)
1550
-
1551
- node_lines = []
1552
- for idx, node in enumerate(nodes[:24], start=1):
1553
- label = node.get("label") or node.get("title") or node.get("id")
1554
- node_lines.append(f"- {idx}. [{node.get('type', 'Node')}] {label}")
1555
-
1556
- edge_lines = []
1557
- for idx, edge in enumerate(edges[:30], start=1):
1558
- edge_lines.append(f"- {idx}. {edge.get('source')} --{edge.get('type', 'RELATES_TO')}--> {edge.get('target')}")
1559
-
1560
- return "\n".join([
1561
- "### Graph ingest status",
1562
- "",
1563
- f"- Topic: {query_text}",
1564
- f"- Selected papers ingested: {len(selected)}",
1565
- f"- Uploaded PDF parsed: {'Yes' if parsed_state and isinstance(parsed_state, dict) and parsed_state.get('title') else 'No'}",
1566
- f"- Frontier candidates added: {len(frontier)}",
1567
- f"- Total nodes created: {len(nodes)}",
1568
- f"- Total edges created: {len(edges)}",
1569
- f"- Node breakdown: {', '.join([f'{k}={v}' for k, v in counts.items()]) if counts else 'None'}",
1570
- "",
1571
- "### Nodes",
1572
- *(node_lines or ["- None"]),
1573
- "",
1574
- "### Edges",
1575
- *(edge_lines or ["- None"]),
1576
- ])
1577
-
1578
-
1579
- def ingest_selected_papers(query, selected_indices, papers_state, pdf_file, parsed_state):
1580
- papers = ensure_list(papers_state)
1581
- selected = resolve_selected_papers(selected_indices, papers)
1582
- uploaded_name = Path(getattr(pdf_file, "name", str(pdf_file))).name if pdf_file else None
1583
-
1584
- if not selected and papers:
1585
- selected = papers[:3]
1586
-
1587
- if not selected and parsed_state and isinstance(parsed_state, dict) and parsed_state.get("title"):
1588
- selected = []
1589
-
1590
- if not selected and not (parsed_state and isinstance(parsed_state, dict) and parsed_state.get("title")):
1591
- graph_html = build_learning_graph_html([], [], "Self-Learning Knowledge Graph")
1592
- empty_payload = {"status": "empty", "nodes": [], "edges": []}
1593
- return (
1594
- graph_html,
1595
- "### Graph ingest status\n\n- No papers were selected and no parsed PDF is available.\n- Select papers or parse an uploaded PDF first.",
1596
- empty_payload,
1597
- )
1598
-
1599
- query_text = norm_text(query or "")
1600
- if not query_text and isinstance(parsed_state, dict):
1601
- query_text = parsed_state.get("title") or "Research topic"
1602
- if not query_text:
1603
- query_text = "Research topic"
1604
-
1605
- selected = [enrich_paper_semantics(query_text, paper) for paper in selected]
1606
- frontier = frontier_expand(query_text, DEFAULT_SOURCES, selected or papers[:3], parsed_state=parsed_state if isinstance(parsed_state, dict) else None, per_query=3)
1607
-
1608
- graph_nodes, graph_edges = graph_from_selected(
1609
- query_text,
1610
- selected,
1611
- uploaded_name,
1612
- parsed_state if isinstance(parsed_state, dict) else None,
1613
- frontier=frontier,
1614
- )
1615
- graph_html = build_learning_graph_html(graph_nodes, graph_edges, "Selected Research Graph")
1616
-
1617
- payload = build_ingest_payload(
1618
- query_text,
1619
- selected,
1620
- parsed_state if isinstance(parsed_state, dict) else None,
1621
- frontier=frontier,
1622
- )
1623
- learn_from_payload(payload, query=query_text)
1624
-
1625
- status_md = build_ingest_status_markdown(
1626
- query_text,
1627
- payload,
1628
- selected,
1629
- parsed_state if isinstance(parsed_state, dict) else None,
1630
- frontier,
1631
- )
1632
-
1633
- return graph_html, status_md, payload
1634
-
1635
-
1636
- # ----------------------------
1637
- # Discovery
1638
- # ----------------------------
1639
-
1640
- def summarize_learning_state(query_text, papers, selected_sources):
1641
- concept_pool = []
1642
- for paper in papers[:8]:
1643
- concept_pool.extend((paper.get("concepts") or [])[:4])
1644
- top_concepts = [c for c, _ in Counter([c.lower() for c in concept_pool]).most_common(8)]
1645
- return (
1646
- "### Discovery results\n\n"
1647
- f"- Query: {query_text}\n"
1648
- f"- Sources: {', '.join(selected_sources)}\n"
1649
- f"- Candidates found: {len(papers)}\n"
1650
- f"- Top learned concepts: {', '.join(top_concepts) if top_concepts else 'None'}\n"
1651
- "- Select papers below, or leave selection empty to auto-ingest the top papers.\n"
1652
- )
1653
-
1654
-
1655
- def run_paper_discovery(query, search_mode, sources, pdf_file):
1656
- query_text = norm_text(query or "")
1657
- selected_sources = ensure_list(sources) or DEFAULT_SOURCES
1658
-
1659
- if not query_text and not pdf_file:
1660
- empty_graph = build_learning_graph_html([], [], "Self-Learning Knowledge Graph")
1661
- return (
1662
- empty_graph,
1663
- '<div class="panel papers-panel" style="padding:18px"><p>Enter a topic, title, DOI, link, or upload a PDF to start learning.</p></div>',
1664
- build_journal_html("biomaterials cardiac repair"),
1665
- "No PDF uploaded yet.",
1666
- gr.update(choices=[], value=[]),
1667
- [],
1668
- "### No discovery results yet.",
1669
- )
1670
-
1671
- if not query_text and pdf_file:
1672
- uploaded_name = Path(getattr(pdf_file, "name", str(pdf_file))).name
1673
- graph_nodes, graph_edges = build_learning_graph_state("", [], uploaded_name)
1674
- return (
1675
- build_learning_graph_html(graph_nodes, graph_edges, "Uploaded PDF Waiting for Parse"),
1676
- '<div class="panel papers-panel" style="padding:18px"><p>No query yet. Parse the uploaded PDF or enter a research topic to begin discovery.</p></div>',
1677
- build_journal_html("biomaterials cardiac repair"),
1678
- uploaded_pdf_summary(pdf_file),
1679
- gr.update(choices=[], value=[]),
1680
- [],
1681
- "### Upload detected.\n\n- Parse the PDF to extract structure.\n- Or enter a topic to start discovery.",
1682
- )
1683
-
1684
- uploaded_name = Path(getattr(pdf_file, "name", str(pdf_file))).name if pdf_file else None
1685
-
1686
- try:
1687
- papers = discover_papers(query_text, search_mode, selected_sources, max_results=GRAPH_MAX_RESULTS)
1688
- graph_nodes, graph_edges = build_learning_graph_state(query_text, papers[:6], uploaded_name)
1689
- graph_html = build_learning_graph_html(graph_nodes, graph_edges, "Self-Learning Knowledge Graph")
1690
- papers_html = format_papers_html(papers)
1691
- journals_html = build_journal_html(query_text or "biomaterials cardiac repair")
1692
- pdf_summary = uploaded_pdf_summary(pdf_file)
1693
- choices = format_selection_choices(papers)
1694
- status_md = summarize_learning_state(query_text, papers, selected_sources)
1695
- return (
1696
- graph_html,
1697
- papers_html,
1698
- journals_html,
1699
- pdf_summary,
1700
- gr.update(choices=choices, value=[]),
1701
- papers,
1702
- status_md,
1703
- )
1704
- except Exception as e:
1705
- graph_nodes, graph_edges = build_learning_graph_state(query_text, [], uploaded_name)
1706
- error_html = f'<div class="panel papers-panel" style="padding:18px"><p>Paper search failed: {safe_text(str(e))}</p></div>'
1707
- return (
1708
- build_learning_graph_html(graph_nodes, graph_edges),
1709
- error_html,
1710
- build_journal_html(query_text or "biomaterials cardiac repair"),
1711
- uploaded_pdf_summary(pdf_file),
1712
- gr.update(choices=[], value=[]),
1713
- [],
1714
- f"### Discovery failed.\n\n- Error: {safe_text(str(e))}",
1715
- )
1716
-
1717
-
1718
- __all__ = [
1719
- "SEARCH_MODES",
1720
- "SOURCE_OPTIONS",
1721
- "DEFAULT_SOURCES",
1722
- "PDF_PARSERS",
1723
- "GRAPH_MEMORY",
1724
- "discover_papers",
1725
- "run_paper_discovery",
1726
- "parse_uploaded_pdf",
1727
- "render_parse_result",
1728
- "ingest_selected_papers",
1729
- "build_ingest_payload",
1730
- "learn_from_payload",
1731
- "frontier_expand",
1732
- "autonomous_expand_into_markdown",
1733
- "export_learning_state",
1734
- "format_frontier_html",
1735
- "build_learning_graph_html",
1736
- "build_journal_html",
1737
- "safe_text",
1738
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
requirements.txt CHANGED
@@ -1,19 +1,3 @@
1
  gradio>=5.29.0
2
  anthropic>=0.51.0
3
  python-dotenv>=1.0.1
4
- httpx>=0.27.0
5
- requests>=2.32.0
6
- beautifulsoup4>=4.12.3
7
- lxml>=5.2.2
8
- pydantic>=2.7.0
9
- pymupdf>=1.24.0
10
- docling>=2.0.0
11
- pandas>=2.2.2
12
- numpy>=1.26.4
13
- networkx>=3.3
14
- tenacity>=8.3.0
15
- orjson>=3.10.0
16
-
17
- python-dateutil>=2.9.0.post0
18
- rapidfuzz>=3.9.0
19
- feedparser>=6.0.11
 
1
  gradio>=5.29.0
2
  anthropic>=0.51.0
3
  python-dotenv>=1.0.1