narniagooooo's picture
Fix italic-text contrast, response truncation, and tab label color
9a767ad
Raw
History Blame Contribute Delete
80.9 kB
"""Scripture Knowledge Graph — Gradio app for Hugging Face Spaces.
NLP entity extraction, NetworkX graph algorithms, an Ant Colony System swarm,
and server-side streaming Claude insights, wired to a D3.js visualization
injected via gr.HTML.
"""
import json
import os
import random
import re
from collections import defaultdict
import anthropic
import gradio as gr
import networkx as nx
# ═══════════════════════════════════════════════════════════════════════════
# Entity dictionaries
# ═══════════════════════════════════════════════════════════════════════════
PEOPLE = {
"Jesus", "Christ", "God", "Yahweh", "Jehovah", "Holy Spirit", "Spirit", "Lord", "Father",
"Paul", "Peter", "John", "James", "Mary", "Moses", "Abraham", "David", "Solomon",
"Isaiah", "Jeremiah", "Ezekiel", "Daniel", "Elijah", "Elisha", "Joshua", "Samuel",
"Ruth", "Naomi", "Esther", "Mordecai", "Job", "Adam", "Eve", "Noah", "Jacob", "Israel",
"Joseph", "Isaac", "Rebekah", "Rachel", "Leah", "Benjamin", "Judah", "Reuben", "Levi",
"Saul", "Jonathan", "Barnabas", "Timothy", "Silas", "Luke", "Mark", "Matthew", "Stephen",
"Philip", "Andrew", "Thomas", "Bartholomew", "Thaddaeus", "Simon", "Judas", "Iscariot",
"Lazarus", "Martha", "Mary Magdalene", "Herod", "Pilate", "Satan", "Nicodemus", "Zacchaeus",
"Cornelius", "Lydia", "Priscilla", "Aquila", "Apollos", "Titus", "Philemon", "Onesimus",
"Gideon", "Deborah", "Barak", "Samson", "Delilah", "Boaz", "Hannah", "Eli", "Nathan",
"Bathsheba", "Absalom", "Rehoboam", "Jeroboam", "Ahab", "Jezebel", "Elihu", "Amos",
"Hosea", "Joel", "Obadiah", "Jonah", "Micah", "Nahum", "Habakkuk", "Zephaniah", "Haggai",
"Zechariah", "Malachi", "Ezra", "Nehemiah", "Melchizedek", "Enoch", "Methuselah", "Lamech",
"Cain", "Abel", "Seth", "Terah", "Lot", "Hagar", "Ishmael", "Laban", "Dinah", "Tamar",
"Er", "Onan", "Perez", "Pharaoh", "Aaron", "Miriam", "Caleb", "Balaam", "Rahab", "Achan",
"Othniel", "Ehud", "Shamgar", "Tola", "Jair", "Jephthah", "Ibzan", "Elon", "Abdon",
"Uriah", "Joab", "Amnon", "Adonijah", "Hiram", "Asa", "Jehoshaphat", "Josiah", "Hezekiah",
"Manasseh", "Amon", "Jehoiakim", "Zedekiah", "Nebuchadnezzar", "Cyrus", "Darius",
"Shadrach", "Meshach", "Abednego", "Belshazzar", "Haman", "Vashti", "Tobiah", "Sanballat",
"Simeon", "Anna", "Zechariah the priest", "Elizabeth", "Gabriel", "Michael", "Herod Antipas",
"Herodias", "Salome", "Caiaphas", "Annas", "Barabbas", "Simon of Cyrene", "Joseph of Arimathea",
"Nathanael", "Matthias", "Ananias", "Sapphira", "Gamaliel", "Dorcas", "Tabitha", "Rhoda",
"Felix", "Festus", "Agrippa", "Sosthenes", "Crispus", "Erastus", "Gaius", "Tertius",
"Phoebe", "Junia", "Andronicus", "Epaphras", "Epaphroditus", "Demas", "Tychicus", "Trophimus",
"Archippus", "Clement", "Diotrephes", "Demetrius", "Cephas",
}
PLACES = {
"Jerusalem", "Bethlehem", "Nazareth", "Galilee", "Judea", "Israel", "Egypt", "Babylon",
"Rome", "Corinth", "Ephesus", "Antioch", "Athens", "Philippi", "Thessalonica",
"Colossae", "Galatia", "Samaria", "Jordan", "Sinai", "Canaan", "Eden", "Zion",
"Capernaum", "Bethsaida", "Jericho", "Hebron", "Bethany", "Gethsemane", "Calvary",
"Golgotha", "Mount Sinai", "Mount Zion", "Mount Carmel", "Mount of Olives", "Dead Sea",
"Red Sea", "Sea of Galilee", "Mediterranean", "Persia", "Assyria", "Syria", "Macedonia",
"Crete", "Cyprus", "Malta", "Arabia", "Moab", "Edom", "Ammon", "Midian", "Philistia",
"Gaza", "Ashkelon", "Ashdod", "Gath", "Ekron", "Shiloh", "Gilgal", "Shechem", "Bethel",
"Dan", "Beersheba", "Tyre", "Sidon", "Damascus", "Nineveh", "Ur", "Haran", "Padan Aram",
"Goshen", "Rameses", "Kadesh Barnea", "Mount Nebo", "Jabbok", "Peniel", "Ai", "Gibeon",
"Ramah", "Mizpah", "Gilead", "Bashan", "Perga", "Lystra", "Derbe", "Iconium", "Troas",
"Berea", "Cenchreae", "Miletus", "Patmos", "Laodicea", "Smyrna", "Pergamum", "Thyatira",
"Sardis", "Philadelphia", "Caesarea", "Joppa", "Emmaus", "Cana", "Tiberias", "Decapolis",
"Perea", "Idumea", "Susa", "Ecbatana", "Chaldea",
}
BOOK_ORDER = [
"Genesis", "Exodus", "Leviticus", "Numbers", "Deuteronomy", "Joshua", "Judges", "Ruth",
"1 Samuel", "2 Samuel", "1 Kings", "2 Kings", "1 Chronicles", "2 Chronicles", "Ezra",
"Nehemiah", "Esther", "Job", "Psalms", "Proverbs", "Ecclesiastes", "Song of Songs",
"Isaiah", "Jeremiah", "Lamentations", "Ezekiel", "Daniel", "Hosea", "Joel", "Amos",
"Obadiah", "Jonah", "Micah", "Nahum", "Habakkuk", "Zephaniah", "Haggai", "Zechariah",
"Malachi", "Matthew", "Mark", "Luke", "John", "Acts", "Romans", "1 Corinthians",
"2 Corinthians", "Galatians", "Ephesians", "Philippians", "Colossians",
"1 Thessalonians", "2 Thessalonians", "1 Timothy", "2 Timothy", "Titus", "Philemon",
"Hebrews", "James", "1 Peter", "2 Peter", "1 John", "2 John", "3 John", "Jude", "Revelation",
]
BOOK_ABBREVIATIONS = {
"gen": "Genesis", "gn": "Genesis", "ex": "Exodus", "exod": "Exodus", "lev": "Leviticus",
"lv": "Leviticus", "num": "Numbers", "nm": "Numbers", "deut": "Deuteronomy", "dt": "Deuteronomy",
"josh": "Joshua", "judg": "Judges", "jdg": "Judges", "ruth": "Ruth", "1 sam": "1 Samuel",
"2 sam": "2 Samuel", "1 kgs": "1 Kings", "2 kgs": "2 Kings", "1 chr": "1 Chronicles",
"2 chr": "2 Chronicles", "ezra": "Ezra", "neh": "Nehemiah", "esth": "Esther", "job": "Job",
"ps": "Psalms", "psa": "Psalms", "psalm": "Psalms", "prov": "Proverbs", "eccl": "Ecclesiastes",
"eccles": "Ecclesiastes", "song": "Song of Songs", "isa": "Isaiah", "jer": "Jeremiah",
"lam": "Lamentations", "ezek": "Ezekiel", "eze": "Ezekiel", "dan": "Daniel", "hos": "Hosea",
"joel": "Joel", "amos": "Amos", "obad": "Obadiah", "jonah": "Jonah", "mic": "Micah",
"nah": "Nahum", "hab": "Habakkuk", "zeph": "Zephaniah", "hag": "Haggai", "zech": "Zechariah",
"mal": "Malachi", "matt": "Matthew", "mt": "Matthew", "mk": "Mark", "mrk": "Mark",
"lk": "Luke", "luk": "Luke", "jn": "John", "jhn": "John", "acts": "Acts", "rom": "Romans",
"1 cor": "1 Corinthians", "2 cor": "2 Corinthians", "gal": "Galatians", "eph": "Ephesians",
"phil": "Philippians", "col": "Colossians", "1 thess": "1 Thessalonians",
"2 thess": "2 Thessalonians", "1 tim": "1 Timothy", "2 tim": "2 Timothy", "titus": "Titus",
"phlm": "Philemon", "heb": "Hebrews", "jas": "James", "1 pet": "1 Peter", "2 pet": "2 Peter",
"1 jn": "1 John", "2 jn": "2 John", "3 jn": "3 John", "jude": "Jude", "rev": "Revelation",
}
BOOKS = set(BOOK_ORDER) | {
"Samuel", "Kings", "Chronicles", "Corinthians", "Thessalonians", "Timothy", "Peter", "John",
}
THEMES = {
"faith", "grace", "love", "salvation", "redemption", "covenant", "forgiveness", "mercy",
"justice", "righteousness", "holiness", "prayer", "worship", "sin", "repentance", "hope",
"peace", "joy", "obedience", "discipleship", "kingdom", "gospel", "resurrection",
"eternal life", "sanctification", "justification", "atonement", "baptism", "communion",
"prophecy", "wisdom", "truth", "light", "darkness", "healing", "miracles", "creation",
"judgment", "reconciliation", "fellowship", "community", "sacrifice", "suffering",
"perseverance", "humility", "trust", "guidance", "provision", "protection", "power",
"glory", "praise", "servanthood", "stewardship", "generosity", "temptation", "victory",
"identity", "adoption", "inheritance", "election", "predestination", "assurance",
"confession", "intercession", "fasting", "tithing", "hospitality", "purity", "integrity",
"patience", "gentleness", "self-control", "spiritual warfare", "discernment", "revelation",
"blessing", "curse", "restoration", "renewal", "transformation", "surrender", "gratitude",
"unity", "diversity", "witness", "mission", "evangelism", "compassion", "loyalty",
}
THEOLOGICAL_CONCEPTS = {
"trinity", "incarnation", "eschatology", "hermeneutics", "typology", "soteriology",
"ecclesiology", "pneumatology", "christology", "theodicy", "hypostatic union",
"imputation", "propitiation", "expiation", "kenosis", "parousia", "millennialism",
"dispensationalism", "covenant theology", "replacement theology", "supersessionism",
"inerrancy", "sola scriptura", "sola fide", "total depravity", "common grace",
}
EVENTS = {
"crucifixion", "resurrection", "ascension", "pentecost", "creation", "flood", "exodus",
"last supper", "baptism of jesus", "transfiguration", "temptation", "birth", "nativity",
"second coming", "rapture", "great commission", "sermon on the mount",
"feeding of the 5000", "raising of lazarus", "wedding at cana", "tower of babel",
"fall of man", "passover", "day of atonement", "tabernacle", "temple dedication",
"burning bush", "parting of the red sea", "ten commandments", "golden calf",
"fall of jericho", "anointing of david", "fall of jerusalem", "babylonian exile",
"return from exile", "rebuilding of the temple", "annunciation", "triumphal entry",
"last judgment", "great flood", "covenant with abraham", "binding of isaac",
"jacob's ladder", "joseph sold into slavery", "plagues of egypt", "wilderness wandering",
"conquest of canaan", "united monarchy", "divided kingdom", "fall of samaria",
"council of jerusalem", "conversion of paul", "stoning of stephen", "damascus road",
"road to emmaus", "doubting thomas", "great flood narrative", "tower of siloam",
"cleansing of the temple", "washing of feet", "gethsemane prayer", "trial of jesus",
"empty tomb", "road to calvary", "day of the lord",
}
TIME_PERIODS = {
"old testament", "new testament", "early church", "exile", "wilderness",
"intertestamental period", "apostolic age", "patriarchal age", "monarchy period",
"judges period", "second temple period", "diaspora",
}
RELATION_PATTERNS = [
(re.compile(r"\b(\w[\w' ]*?)\s+said to\s+(\w[\w' ]*?)\b", re.I), "said to"),
(re.compile(r"\b(\w[\w' ]*?)\s+spoke to\s+(\w[\w' ]*?)\b", re.I), "spoke to"),
(re.compile(r"\b(\w[\w' ]*?)\s+wrote to\s+(\w[\w' ]*?)\b", re.I), "wrote to"),
(re.compile(r"\b(\w[\w' ]*?)\s+taught\s+(\w[\w' ]*?)\b", re.I), "taught"),
(re.compile(r"\b(\w[\w' ]*?)\s+healed\s+(\w[\w' ]*?)\b", re.I), "healed"),
(re.compile(r"\b(\w[\w' ]*?)\s+baptized\s+(\w[\w' ]*?)\b", re.I), "baptized"),
(re.compile(r"\b(\w[\w' ]*?)\s+killed\s+(\w[\w' ]*?)\b", re.I), "killed"),
(re.compile(r"\b(\w[\w' ]*?)\s+anointed\s+(\w[\w' ]*?)\b", re.I), "anointed"),
(re.compile(r"\b(\w[\w' ]*?)\s+sent\s+(\w[\w' ]*?)\b", re.I), "sent"),
(re.compile(r"\b(\w[\w' ]*?)\s+called\s+(\w[\w' ]*?)\b", re.I), "called"),
(re.compile(r"\b(\w[\w' ]*?)\s+appeared to\s+(\w[\w' ]*?)\b", re.I), "appeared to"),
(re.compile(r"\b(\w[\w' ]*?)\s+rebuked\s+(\w[\w' ]*?)\b", re.I), "rebuked"),
(re.compile(r"\b(\w[\w' ]*?)\s+blessed\s+(\w[\w' ]*?)\b", re.I), "blessed"),
(re.compile(r"\b(\w[\w' ]*?)\s+betrayed\s+(\w[\w' ]*?)\b", re.I), "betrayed"),
(re.compile(r"\b(\w[\w' ]*?)\s+forgave\s+(\w[\w' ]*?)\b", re.I), "forgave"),
]
VERSE_RE = re.compile(
r"\b([1-3]?\s?[A-Z][a-z]+\.?)\s+(\d{1,3}):(\d{1,3})(?:[-–](\d{1,3}))?\b"
)
NODE_TYPE_ORDER = ["person", "place", "theme", "concept", "book", "verse", "event", "period"]
def _canon_book(word_or_phrase):
key = word_or_phrase.lower().strip().rstrip(".")
if key in BOOK_ABBREVIATIONS:
return BOOK_ABBREVIATIONS[key]
for b in BOOK_ORDER:
if b.lower() == key:
return b
return None
def extract_entities(text):
"""Pure-Python NLP extraction: entities + co-occurrence/relationship edges."""
nodes = {}
edge_weights = defaultdict(int)
relation_edges = []
def add_node(key, ntype, label):
key = key.lower().strip()
if not key:
return None
if key not in nodes:
nodes[key] = {"id": key, "label": label.strip(), "type": ntype, "weight": 1}
else:
nodes[key]["weight"] += 1
return key
# Verse references
for m in VERSE_RE.finditer(text):
verse_label = m.group(0).strip()
add_node(verse_label.lower(), "verse", verse_label)
# Directed relationship extraction
for pattern, relation in RELATION_PATTERNS:
for m in pattern.finditer(text):
a_raw, b_raw = m.group(1).strip(), m.group(2).strip()
a_cap = a_raw.split()[-1].capitalize() if a_raw else ""
b_cap = b_raw.split()[0].capitalize() if b_raw else ""
if a_cap in PEOPLE and b_cap in PEOPLE:
ka = add_node(a_cap.lower(), "person", a_cap)
kb = add_node(b_cap.lower(), "person", b_cap)
if ka and kb and ka != kb:
relation_edges.append({"source": ka, "target": kb, "label": relation})
sentences = re.split(r"[.!?;\n]+", text)
for sentence in sentences:
if len(sentence.strip()) < 3:
continue
words = sentence.split()
sentence_keys = []
for i, raw in enumerate(words):
w = re.sub(r"[^a-zA-Z']", "", raw)
if not w:
continue
w_cap = w[0].upper() + w[1:] if w else w
w_low = w.lower()
matched = False
if w_cap in PEOPLE or w in PEOPLE:
sentence_keys.append(add_node(w_low, "person", w_cap))
matched = True
elif w_cap in PLACES:
sentence_keys.append(add_node(w_low, "place", w_cap))
matched = True
elif w_cap in BOOKS or w_low in BOOK_ABBREVIATIONS:
canon = _canon_book(w_cap) or w_cap
sentence_keys.append(add_node(canon.lower(), "book", canon))
matched = True
elif w_low in THEMES:
sentence_keys.append(add_node(w_low, "theme", w_low))
matched = True
elif w_low in THEOLOGICAL_CONCEPTS:
sentence_keys.append(add_node(w_low, "concept", w_low))
matched = True
elif w_low in EVENTS:
sentence_keys.append(add_node(w_low, "event", w_low))
matched = True
if not matched and i + 1 < len(words):
nxt = re.sub(r"[^a-zA-Z']", "", words[i + 1]).lower()
bigram = f"{w_low} {nxt}"
if bigram in EVENTS:
sentence_keys.append(add_node(bigram, "event", bigram))
elif bigram in THEMES:
sentence_keys.append(add_node(bigram, "theme", bigram))
elif bigram in THEOLOGICAL_CONCEPTS:
sentence_keys.append(add_node(bigram, "concept", bigram))
elif bigram in TIME_PERIODS:
sentence_keys.append(add_node(bigram, "period", bigram))
if not matched and i + 2 < len(words):
w2 = re.sub(r"[^a-zA-Z']", "", words[i + 1]).lower()
w3 = re.sub(r"[^a-zA-Z']", "", words[i + 2]).lower()
trigram = f"{w_low} {w2} {w3}"
if trigram in TIME_PERIODS:
sentence_keys.append(add_node(trigram, "period", trigram))
elif trigram in EVENTS:
sentence_keys.append(add_node(trigram, "event", trigram))
sentence_keys = [k for k in sentence_keys if k]
for a in range(len(sentence_keys)):
for b in range(a + 1, len(sentence_keys)):
if sentence_keys[a] != sentence_keys[b]:
k = tuple(sorted((sentence_keys[a], sentence_keys[b])))
edge_weights[k] += 1
edges = [{"source": s, "target": t, "weight": w} for (s, t), w in edge_weights.items()]
node_list = [n for n in nodes.values() if len(n["label"]) > 1]
node_ids = {n["id"] for n in node_list}
edges = [e for e in edges if e["source"] in node_ids and e["target"] in node_ids]
return {"nodes": node_list, "edges": edges, "relations": relation_edges}
# ═══════════════════════════════════════════════════════════════════════════
# Graph algorithms (NetworkX)
# ═══════════════════════════════════════════════════════════════════════════
def build_graph(data):
G = nx.Graph()
for n in data["nodes"]:
G.add_node(n["id"], **n)
for e in data["edges"]:
G.add_edge(e["source"], e["target"], weight=e["weight"])
return G
def run_algorithms(G):
if G.number_of_nodes() == 0:
return None
pagerank = nx.pagerank(G, weight="weight") if G.number_of_edges() else {n: 1 / max(G.number_of_nodes(), 1) for n in G.nodes()}
communities_raw = list(nx.community.greedy_modularity_communities(G, weight="weight")) if G.number_of_edges() else [{n} for n in G.nodes()]
community_map = {}
for idx, comm in enumerate(communities_raw):
for node in comm:
community_map[node] = idx
betweenness = nx.betweenness_centrality(G, weight="weight") if G.number_of_nodes() > 2 else {n: 0 for n in G.nodes()}
degree_centrality = nx.degree_centrality(G)
try:
eigenvector = nx.eigenvector_centrality(G, weight="weight", max_iter=500)
except (nx.PowerIterationFailedConvergence, nx.AmbiguousSolution, ZeroDivisionError):
eigenvector = {n: 0 for n in G.nodes()}
clustering = nx.average_clustering(G, weight="weight") if G.number_of_edges() else 0.0
density = nx.density(G)
if G.number_of_nodes() > 1 and nx.is_connected(G):
diameter = nx.diameter(G)
else:
comps = list(nx.connected_components(G))
largest = max(comps, key=len) if comps else set()
sub = G.subgraph(largest)
diameter = nx.diameter(sub) if len(largest) > 1 else 0
bridges = list(nx.bridges(G)) if G.number_of_nodes() > 1 else []
articulation_pts = list(nx.articulation_points(G)) if G.number_of_nodes() > 2 else []
return {
"pagerank": pagerank,
"communities": [list(c) for c in communities_raw],
"community_map": community_map,
"betweenness": betweenness,
"degree_centrality": degree_centrality,
"eigenvector": eigenvector,
"clustering": clustering,
"density": density,
"diameter": diameter,
"bridges": bridges,
"articulation_points": articulation_pts,
}
# ═══════════════════════════════════════════════════════════════════════════
# ACO Swarm — Ant Colony System (Dorigo & Gambardella, 1997)
# ═══════════════════════════════════════════════════════════════════════════
class ACS:
"""Ant Colony System on the conceptual graph. Traces the strongest
conceptual pathway between the two highest-PageRank nodes."""
def __init__(self, G, pagerank, alpha=1.0, beta=2.0, rho=0.1, q0=0.9, Q=1.0):
self.G = G
self.pr = pagerank
self.alpha = alpha
self.beta = beta
self.rho = rho
self.q0 = q0
self.Q = Q
n_edges = max(G.number_of_edges(), 1)
self.tau0 = 1.0 / n_edges
self.tau = {frozenset(e): self.tau0 for e in G.edges()}
def _key(self, a, b):
return frozenset((a, b))
def _weight(self, a, b):
return self.G[a][b].get("weight", 1)
def _choose_next(self, current, visited):
neighbors = [n for n in self.G.neighbors(current) if n not in visited]
if not neighbors:
return None
scores = []
for n in neighbors:
tau = self.tau.get(self._key(current, n), self.tau0)
eta = self._weight(current, n)
scores.append((tau ** self.alpha) * (eta ** self.beta))
total = sum(scores)
if total <= 0:
return random.choice(neighbors)
if random.random() < self.q0:
return neighbors[scores.index(max(scores))]
r = random.random() * total
acc = 0.0
for n, s in zip(neighbors, scores):
acc += s
if r <= acc:
return n
return neighbors[-1]
def _local_update(self, a, b):
k = self._key(a, b)
self.tau[k] = (1 - self.rho) * self.tau.get(k, self.tau0) + self.rho * self.tau0
def _global_update(self, path, strength):
deposit = self.Q * strength
for i in range(len(path) - 1):
k = self._key(path[i], path[i + 1])
self.tau[k] = (1 - self.rho) * self.tau.get(k, self.tau0) + self.rho * deposit
def _path_strength(self, path, reached_target):
if len(path) < 2:
return 0.0
w = sum(self._weight(path[i], path[i + 1]) for i in range(len(path) - 1))
pr = sum(self.pr.get(n, 0) for n in path)
score = (w / len(path)) * (1 + pr)
return score if reached_target else score * 0.4
def run(self, n_ants=40, n_iter=30, source=None, target=None, max_len=10):
nodes = list(self.G.nodes())
if not nodes:
yield {"iteration": 0, "n_iter": n_iter, "best_path": [], "best_score": 0,
"convergence": [], "tau": {}}
return
if source is None:
source = max(nodes, key=lambda n: self.pr.get(n, 0))
if target is None:
ranked = sorted(nodes, key=lambda n: -self.pr.get(n, 0))
target = next((n for n in ranked if n != source), source)
best_path, best_score = [source], 0.0
convergence = []
for iteration in range(n_iter):
iter_best_path, iter_best_score, iter_reached = None, -1.0, False
for _ in range(n_ants):
path = [source]
visited = {source}
current = source
reached = False
for _step in range(max_len):
if current == target:
reached = True
break
nxt = self._choose_next(current, visited)
if nxt is None:
break
self._local_update(current, nxt)
path.append(nxt)
visited.add(nxt)
current = nxt
if current == target:
reached = True
score = self._path_strength(path, reached)
if score > iter_best_score:
iter_best_score, iter_best_path, iter_reached = score, path, reached
for k in list(self.tau.keys()):
self.tau[k] = max((1 - self.rho) * self.tau[k], 0.001)
if iter_best_path and iter_best_score > best_score:
best_score, best_path = iter_best_score, iter_best_path
if best_path and len(best_path) > 1:
self._global_update(best_path, best_score)
convergence.append(round(best_score, 4))
yield {
"iteration": iteration + 1,
"n_iter": n_iter,
"best_path": best_path,
"best_score": round(best_score, 4),
"convergence": convergence,
"tau": {"|||".join(sorted(k)): round(v, 4) for k, v in self.tau.items()},
}
def compute_swarm_insights(G, algo, swarm_result):
degrees = dict(G.degree())
hub_concepts = sorted(degrees.items(), key=lambda x: -x[1])[:5]
isolated = [n for n, d in degrees.items() if d <= 1]
community_map = algo["community_map"]
bridges_by_node = defaultdict(set)
for u, v in G.edges():
cu, cv = community_map.get(u), community_map.get(v)
if cu != cv:
bridges_by_node[u].add(cv)
bridges_by_node[v].add(cu)
community_bridges = sorted(
((n, len(c)) for n, c in bridges_by_node.items() if len(c) >= 1),
key=lambda x: -x[1],
)[:5]
def label(nid):
return G.nodes[nid].get("label", nid) if nid in G.nodes else nid
return {
"hub_concepts": [f"{label(n)} (degree {d})" for n, d in hub_concepts],
"isolated_concepts": [label(n) for n in isolated[:6]],
"community_bridges": [f"{label(n)} bridges {c} communities" for n, c in community_bridges],
"dominant_pathway": " → ".join(label(n) for n in swarm_result["best_path"]),
}
# ═══════════════════════════════════════════════════════════════════════════
# Anthropic streaming insights
# ═══════════════════════════════════════════════════════════════════════════
_client = None
def get_client():
global _client
if _client is None:
api_key = os.environ.get("ANTHROPIC_API_KEY")
if not api_key:
return None
_client = anthropic.Anthropic(api_key=api_key)
return _client
def build_insights_prompt(notes, G, algo, swarm_state):
top_nodes = sorted(algo["pagerank"].items(), key=lambda x: -x[1])[:10]
num_communities = len(algo["communities"])
top_pr_id = top_nodes[0][0] if top_nodes else None
top_pr_label = G.nodes[top_pr_id].get("label", top_pr_id) if top_pr_id else "unknown"
bridge_id = max(algo["betweenness"].items(), key=lambda x: x[1])[0] if algo["betweenness"] else None
bridge_label = G.nodes[bridge_id].get("label", bridge_id) if bridge_id else "unknown"
top_lines = "\n".join(
f" {i+1}. {G.nodes[n].get('label', n)} (PR={v:.4f})" for i, (n, v) in enumerate(top_nodes[:8])
)
swarm_state = swarm_state or {}
insights = swarm_state.get("insights", {})
pathway = insights.get("dominant_pathway", "not yet computed")
strength = swarm_state.get("best_score", 0)
hub_concepts = ", ".join(insights.get("hub_concepts", [])[:5])
isolated = ", ".join(insights.get("isolated_concepts", [])[:3])
diameter = algo["diameter"]
prompt = f"""You are a biblical scholar, theologian, and data scientist analyzing a scripture knowledge graph built from a student's Bible study notes using graph theory and swarm intelligence algorithms.
STUDY NOTES (excerpt):
{notes[:2500]}
GRAPH TOPOLOGY:
- Nodes: {G.number_of_nodes()} biblical entities
- Edges: {G.number_of_edges()} conceptual relationships
- Communities detected (greedy modularity): {num_communities}
- Graph density: {algo['density']:.4f}
- Average clustering coefficient: {algo['clustering']:.4f}
- Graph diameter: {diameter}
TOP ENTITIES BY PAGERANK (most theologically central):
{top_lines}
BETWEENNESS CENTRALITY (conceptual bridges):
Bridge node: {bridge_label}
ACO SWARM RESULTS:
Dominant conceptual pathway: {pathway}
Path strength: {strength}
Hub concepts identified by swarm: {hub_concepts}
Isolated concepts (weak connections): {isolated}
Provide deep theological and structural analysis in exactly these sections:
## CENTRAL THEME
What is the dominant theological theme? Why does PageRank converge on {top_pr_label}? (3-4 sentences, theologically substantive)
## GRAPH STRUCTURE REVELATION
What does the clustering into {num_communities} communities reveal about how these biblical concepts relate? What surprised you about the graph topology? (3-4 sentences)
## SWARM INTELLIGENCE FINDING
The ACO ants, operating with no global knowledge, organically traced: {pathway}. Why is this path theologically significant? What does it reveal that a linear reading of the notes might miss? (3-4 sentences)
## UNEXPECTED CONNECTION
Identify one non-obvious connection between entities in the graph — ideally involving the bridge node ({bridge_label}) — that reveals something theologically significant. (2-3 sentences)
## CROSS-REFERENCES
Suggest 3 specific passages that deepen this study, explaining the graph-theoretic reason each is relevant (e.g., "Hebrews 11 would add 15+ faith connections, increasing graph density in the salvation community"):
1. [Book Chapter:Verse] — reason
2. [Book Chapter:Verse] — reason
3. [Book Chapter:Verse] — reason
## STUDY PROMPT
One penetrating question for deeper reflection, grounded in what the swarm and graph revealed."""
return prompt
def generate_insights(notes, graph_state, algo_state, swarm_state):
if not graph_state or not algo_state:
yield "Build a knowledge graph first."
return
client = get_client()
if client is None:
yield ("**No Anthropic API key found.**\n\nSet the `ANTHROPIC_API_KEY` secret in this "
"Space's settings (Settings → Variables and secrets) to enable AI insights.")
return
G = build_graph(graph_state)
prompt = build_insights_prompt(notes or "", G, algo_state, swarm_state)
accumulated = ""
try:
with client.messages.stream(
model="claude-opus-4-8",
max_tokens=2400,
messages=[{"role": "user", "content": prompt}],
) as stream:
for text in stream.text_stream:
accumulated += text
yield accumulated
except Exception as e: # noqa: BLE001 — surface any API error to the UI
accumulated += f"\n\n---\n**Error generating insights:** {e}"
yield accumulated
# ═══════════════════════════════════════════════════════════════════════════
# HTML fragments rendered server-side
# ═══════════════════════════════════════════════════════════════════════════
EMPTY_STATS_HTML = '<div class="skg-panel"><div class="nd-placeholder">Build a graph to see statistics.</div></div>'
EMPTY_SWARM_HTML = '<div class="skg-panel"><div class="nd-placeholder">Build a graph, then run swarm analysis.</div></div>'
HEADER_HTML = """
<div id="skg-header">
<div>
<h1>✦ Scripture Knowledge Graph</h1>
<p>Paste your notes · Extract entities · Run graph algorithms · Swarm intelligence · AI insights</p>
</div>
<div class="skg-pills">
<span class="skg-pill">NLP</span>
<span class="skg-pill">Graph Theory</span>
<span class="skg-pill">ACS Swarm</span>
<span class="skg-pill">Claude</span>
</div>
</div>
"""
GRAPH_CONTAINER_HTML = """
<div id="graph-canvas-wrap">
<div class="grid-bg"></div>
<div class="scanline"></div>
<svg id="graph-svg"></svg>
<svg id="minimap-svg"></svg>
<div class="ov-toolbar">
<button class="ov-btn active" id="ov-force" onclick="SKG.setLayout('force')">Force</button>
<button class="ov-btn" id="ov-radial" onclick="SKG.setLayout('radial')">Radial</button>
<button class="ov-btn" id="ov-hierarchical" onclick="SKG.setLayout('hierarchical')">Hierarchy</button>
<button class="ov-btn" id="ov-timeline" onclick="SKG.setLayout('timeline')">Timeline</button>
<button class="ov-btn" id="ov-path" onclick="SKG.togglePathMode()">Pathfind</button>
<button class="ov-btn" id="ov-reset" onclick="SKG.resetZoom()">Reset View</button>
</div>
<div class="legend">
<div class="legend-item"><div class="legend-dot" style="background:#00e5ff"></div>Person</div>
<div class="legend-item"><div class="legend-dot" style="background:#b44fff"></div>Place</div>
<div class="legend-item"><div class="legend-dot" style="background:#39ff14"></div>Theme</div>
<div class="legend-item"><div class="legend-dot" style="background:#ff6b35"></div>Book</div>
<div class="legend-item"><div class="legend-dot" style="background:#ffde00"></div>Verse</div>
<div class="legend-item"><div class="legend-dot" style="background:#ff2d6b"></div>Event</div>
</div>
<div id="empty-state">
<div class="es-icon">✦</div>
<div class="es-text">Your knowledge graph will materialize here</div>
<div class="es-sub">Paste notes → Build graph → Explore</div>
</div>
<div id="context-menu" class="context-menu" style="display:none;"></div>
</div>
"""
NODE_DETAIL_HTML = """
<div class="skg-panel" id="node-detail-wrap">
<div class="section-label" style="margin-top:0;">Selected Node</div>
<div id="node-detail"><div class="nd-placeholder">Click a node to inspect</div></div>
<div class="section-label">Entity Preview</div>
<div id="entity-preview" class="nd-placeholder">Paste notes to see a live entity estimate.</div>
</div>
"""
def render_stats_html(G, algo):
n_nodes, n_edges = G.number_of_nodes(), G.number_of_edges()
top_pr = sorted(algo["pagerank"].items(), key=lambda x: -x[1])[:6]
rank_items = "".join(
f"<li>{i + 1}. {G.nodes[n].get('label', n)} <span class='algo-val'>{v:.4f}</span></li>"
for i, (n, v) in enumerate(top_pr)
)
bridge_id = max(algo["betweenness"].items(), key=lambda x: x[1])[0] if algo["betweenness"] else None
bridge_label = G.nodes[bridge_id].get("label", bridge_id) if bridge_id else "—"
articulation = ", ".join(G.nodes[n].get("label", n) for n in algo["articulation_points"][:8]) or "None"
bridges_desc = ", ".join(
f"{G.nodes[a].get('label', a)}{G.nodes[b].get('label', b)}" for a, b in algo["bridges"][:6]
) or "None"
diameter = algo["diameter"] if algo["diameter"] else "∞"
return f"""
<div class="skg-panel">
<div class="stats-grid">
<div class="stat-cell"><span class="stat-val">{n_nodes}</span><span class="stat-lbl">Nodes</span></div>
<div class="stat-cell"><span class="stat-val">{n_edges}</span><span class="stat-lbl">Edges</span></div>
<div class="stat-cell"><span class="stat-val">{algo['density']:.3f}</span><span class="stat-lbl">Density</span></div>
<div class="stat-cell"><span class="stat-val">{len(algo['communities'])}</span><span class="stat-lbl">Communities</span></div>
<div class="stat-cell"><span class="stat-val">{algo['clustering']:.3f}</span><span class="stat-lbl">Avg CC</span></div>
<div class="stat-cell"><span class="stat-val">{diameter}</span><span class="stat-lbl">Diameter</span></div>
</div>
<div class="algo-row"><span class="algo-name">Central bridge node</span><span class="algo-val">{bridge_label}</span></div>
<div class="section-label">Centrality Rankings (PageRank)</div>
<ul class="rank-list">{rank_items}</ul>
<div class="section-label">Articulation Points</div>
<div class="pathway-text" style="color:var(--rose)">{articulation}</div>
<div class="section-label">Bridges</div>
<div class="pathway-text" style="color:var(--ember)">{bridges_desc}</div>
</div>
"""
def render_swarm_progress_html(G, progress, running, insights=None):
n_iter = progress.get("n_iter", 30) or 30
pct = int(100 * progress.get("iteration", 0) / n_iter)
status = '<span class="val-running">RUNNING</span>' if running else '<span class="val-idle">CONVERGED</span>'
path_labels = [G.nodes[n].get("label", n) for n in progress.get("best_path", []) if n in G.nodes]
pathway = " → ".join(path_labels) if path_labels else "—"
insights_html = ""
if insights:
hubs = "".join(f"<li>{h}</li>" for h in insights["hub_concepts"]) or "<li>—</li>"
isolated = "".join(f"<li>{i}</li>" for i in insights["isolated_concepts"]) or "<li>None — fully connected</li>"
bridges = "".join(f"<li>{b}</li>" for b in insights["community_bridges"]) or "<li>—</li>"
insights_html = f"""
<div class="section-label">Hub Concepts</div>
<ul class="rank-list">{hubs}</ul>
<div class="section-label">Isolated Concepts</div>
<ul class="flag-list">{isolated}</ul>
<div class="section-label">Community Bridges</div>
<ul class="flag-list">{bridges}</ul>
"""
return f"""
<div class="skg-panel">
<div class="swarm-status"><span>ACS Status</span>{status}</div>
<div class="swarm-status"><span>Ants active</span><span>40</span></div>
<div class="swarm-status"><span>Iteration</span><span>{progress.get('iteration', 0)} / {n_iter}</span></div>
<div class="conv-bar-outer"><div class="conv-bar-inner" style="width:{pct}%;"></div></div>
<div class="section-label">Dominant Pathway</div>
<div class="pathway-text">{pathway}</div>
<div class="algo-row"><span class="algo-name">Path strength</span><span class="algo-val">{progress.get('best_score', 0)}</span></div>
{insights_html}
</div>
"""
def build_graph_payload(data, algo):
deg = defaultdict(int)
for e in data["edges"]:
deg[e["source"]] += 1
deg[e["target"]] += 1
nodes = []
for n in data["nodes"]:
nid = n["id"]
nodes.append({
**n,
"pagerank": algo["pagerank"].get(nid, 0),
"betweenness": algo["betweenness"].get(nid, 0),
"community": algo["community_map"].get(nid, 0),
"degree": deg.get(nid, 0),
})
max_pr = max(algo["pagerank"].values()) if algo["pagerank"] else 1
return {
"nodes": nodes,
"edges": data["edges"],
"relations": data.get("relations", []),
"maxPagerank": max_pr,
"numCommunities": len(algo["communities"]),
}
# ═══════════════════════════════════════════════════════════════════════════
# Gradio event handlers
# ═══════════════════════════════════════════════════════════════════════════
SAMPLE_NOTES = """John 3:16 is central to understanding God's grace and the nature of salvation. Jesus said to Nicodemus that faith in the Son of God leads to eternal life. This connects to Romans 8:28, where Paul reminds us that God works all things for good for those who love him.
The theme of redemption runs through both the Old Testament and New Testament. Moses led the exodus from Egypt, foreshadowing the salvation Jesus brings. Abraham's covenant with God in Genesis established the foundation of faith that Paul later expounds in Galatians and Romans.
In Ephesians, Paul wrote to the church about grace through faith, not works. This echoes Isaiah's prophecy about the suffering servant and connects to the crucifixion and resurrection of Jesus in Jerusalem. Peter also addresses this in his letters, emphasizing holiness and perseverance. Paul wrote to Timothy about guarding sound doctrine, and Paul wrote to Titus about godly living.
The Psalms of David reflect themes of worship, trust, and God's provision and protection. These themes appear again in Matthew's Sermon on the Mount, where Jesus taught the crowds about prayer, forgiveness, and the Kingdom of God. Jesus healed the blind man and the lame man, revealing his compassion.
The covenant between God and Israel runs from Genesis through Revelation. The Holy Spirit, promised by Jesus before his ascension, arrived at Pentecost in Jerusalem, empowering the early church in Acts. Paul's missionary journeys through Corinth, Ephesus, and Rome spread the gospel of grace.
Daniel's prophecies in Babylon connect to Revelation's vision of John. Both speak of judgment, hope, and the glory of God. The resurrection is the cornerstone — without it, Paul says in 1 Corinthians, faith is empty. Jesus appeared to Thomas after the resurrection, and Thomas believed. Christ is risen, making reconciliation and eternal life possible for all who believe."""
def analyze_notes(notes):
notes = (notes or "").strip()
if not notes:
raise gr.Error("Paste or upload some study notes first.")
data = extract_entities(notes)
if len(data["nodes"]) < 2:
raise gr.Error("Not enough biblical entities found. Try adding more names, places, themes, or verse references.")
G = build_graph(data)
algo = run_algorithms(G)
payload = build_graph_payload(data, algo)
stats = render_stats_html(G, algo)
return payload, stats, algo, data
def load_sample():
return SAMPLE_NOTES
def handle_upload(file):
if file is None:
raise gr.Error("No file selected.")
with open(file.name, "r", encoding="utf-8", errors="ignore") as f:
return f.read()
def clear_all():
return (
"", None, EMPTY_STATS_HTML, EMPTY_SWARM_HTML, None, None, None, None,
"Build a graph first, then click **Generate Insights**.",
)
def run_swarm_gen(graph_state, algo_state):
if not graph_state or not algo_state:
raise gr.Error("Build a knowledge graph first.")
G = build_graph(graph_state)
if G.number_of_edges() == 0:
raise gr.Error("This graph has no connections for the swarm to explore.")
acs = ACS(G, algo_state["pagerank"])
last = None
for progress in acs.run(n_ants=40, n_iter=30):
last = progress
yield render_swarm_progress_html(G, progress, running=True), progress, progress
insights = compute_swarm_insights(G, algo_state, last)
last = {**last, "insights": insights}
yield render_swarm_progress_html(G, last, running=False, insights=insights), last, last
# ═══════════════════════════════════════════════════════════════════════════
# Futuristic neon CSS
# ═══════════════════════════════════════════════════════════════════════════
CUSTOM_CSS = """
:root {
--void: #020408;
--deep: #060d18;
--panel: #0a1220;
--border: rgba(0,200,255,0.14);
--cyan: #00e5ff;
--cyan-dim: #00a8cc;
--plasma: #b44fff;
--plasma-dim: #7a2eb8;
--ember: #ff6b35;
--lime: #39ff14;
--rose: #ff2d6b;
--ice: #a8f0ff;
--fog: #9db9cc;
--font-head: 'Orbitron', 'Share Tech Mono', monospace;
--font-mono: 'JetBrains Mono', 'Space Mono', monospace;
--font-body: 'Inter', sans-serif;
}
gradio-app, .gradio-container, body {
background: var(--void) !important;
color: var(--ice) !important;
}
.gradio-container, .gradio-container * { font-family: var(--font-body); }
.gradio-container { max-width: 100% !important; }
.gradio-container .block, .gradio-container .form, .gradio-container .panel,
.gradio-container .tabitem, .gradio-container .tabs {
background: transparent !important;
border-color: var(--border) !important;
box-shadow: none !important;
}
.gradio-container label span, .gradio-container .label-wrap span { color: var(--fog) !important; }
.gradio-container [role="tab"] {
color: var(--fog) !important; font-family: var(--font-mono) !important;
text-transform: uppercase; letter-spacing: 0.05em; font-size: 12px !important;
}
.gradio-container [role="tab"][aria-selected="true"] { color: var(--cyan) !important; }
#skg-header {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 22px; border-bottom: 1px solid var(--border);
background: linear-gradient(180deg, rgba(10,18,32,0.9), rgba(2,4,8,0.3));
backdrop-filter: blur(10px); margin-bottom: 8px; border-radius: 6px;
}
#skg-header h1 {
font-family: var(--font-head); font-size: 18px; letter-spacing: 0.16em;
color: var(--cyan); text-shadow: 0 0 16px rgba(0,229,255,0.55); margin: 0;
}
#skg-header p { font-family: var(--font-mono); font-size: 10px; color: var(--fog) !important; letter-spacing: 0.05em; margin-top: 5px; }
.skg-pills { display: flex; gap: 8px; }
.skg-pill {
font-family: var(--font-mono); font-size: 9px; padding: 4px 11px; border: 1px solid var(--border);
border-radius: 20px; color: var(--plasma) !important; letter-spacing: 0.1em; text-transform: uppercase;
}
.skg-panel {
background: rgba(10,18,32,0.55); border: 1px solid var(--border); border-radius: 6px;
backdrop-filter: blur(14px); padding: 13px 15px; margin-bottom: 10px;
}
.nd-name { font-family: var(--font-head); font-size: 14px; margin-bottom: 4px; color: var(--cyan) !important; }
.nd-type { font-family: var(--font-mono); font-size: 9px; color: var(--fog) !important; letter-spacing: 0.08em; margin-bottom: 6px; }
.nd-info { font-family: var(--font-body); font-size: 11.5px; color: var(--ice) !important; opacity: 0.95; line-height: 1.55; }
.nd-info b { color: var(--cyan) !important; font-weight: 700; }
.nd-placeholder { color: var(--fog) !important; font-style: italic; font-size: 11px; }
#entity-preview { font-family: var(--font-mono); font-size: 10.5px; color: var(--lime) !important; }
.section-label {
font-family: var(--font-mono); font-size: 9px; color: var(--plasma) !important; text-transform: uppercase;
letter-spacing: 0.1em; margin: 10px 0 4px;
}
.stats-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: var(--border);
border: 1px solid var(--border); border-radius: 4px; overflow: hidden; margin-bottom: 8px;
}
.stat-cell { background: rgba(10,18,32,0.75); padding: 10px; text-align: center; }
.stat-val { display: block; font-family: var(--font-head); font-size: 19px; color: var(--cyan) !important; text-shadow: 0 0 6px rgba(0,229,255,0.35); }
.stat-lbl { font-family: var(--font-mono); font-size: 8px; color: var(--fog) !important; text-transform: uppercase; letter-spacing: 0.08em; }
.algo-row { display: flex; justify-content: space-between; padding: 5px 0; border-bottom: 1px solid rgba(0,229,255,0.06); font-size: 11px; }
.algo-name { font-family: var(--font-mono); font-size: 9.5px; color: var(--fog) !important; }
.algo-val { font-family: var(--font-mono); font-size: 10px; color: var(--cyan) !important; }
.rank-list, .flag-list { list-style: none; margin: 4px 0 0; padding: 0; font-family: var(--font-mono); font-size: 10px; }
.rank-list li, .flag-list li { padding: 3px 0; color: var(--ice) !important; opacity: 1; }
.pathway-text { font-family: var(--font-mono); font-size: 11px; color: var(--lime) !important; line-height: 1.6; word-break: break-word; }
.swarm-status { display: flex; justify-content: space-between; font-family: var(--font-mono); font-size: 10px; margin-bottom: 6px; }
.swarm-status span { color: var(--ice) !important; }
.swarm-status span.val-running { color: var(--lime) !important; text-shadow: 0 0 8px rgba(57,255,20,0.5); }
.swarm-status span.val-idle { color: var(--fog) !important; }
.conv-bar-outer { height: 8px; background: rgba(255,255,255,0.06); border-radius: 4px; overflow: hidden; border: 1px solid var(--border); margin: 6px 0 10px; }
.conv-bar-inner { height: 100%; background: linear-gradient(90deg, var(--cyan), var(--lime)); box-shadow: 0 0 10px rgba(57,255,20,0.5); transition: width 0.3s ease; }
#notes-input textarea {
background: rgba(10,18,32,0.6) !important; border: 1px solid var(--border) !important;
color: var(--ice) !important; font-family: var(--font-mono) !important; font-size: 12.5px !important;
backdrop-filter: blur(12px); border-radius: 6px !important;
}
#notes-input textarea:focus {
border-color: var(--cyan) !important;
box-shadow: 0 0 0 1px var(--cyan), 0 0 18px rgba(0,229,255,0.25) !important;
}
#notes-input label span { font-family: var(--font-mono) !important; font-size: 9px !important; color: var(--fog) !important; text-transform: uppercase; letter-spacing: 0.08em; }
.skg-btn, button.skg-btn {
font-family: var(--font-mono) !important; letter-spacing: 0.07em; text-transform: uppercase;
font-size: 10.5px !important; background: rgba(0,229,255,0.05) !important;
border: 1px solid var(--border) !important; color: var(--ice) !important; border-radius: 4px !important;
}
.skg-btn:hover {
border-color: var(--cyan) !important; background: rgba(0,229,255,0.15) !important;
color: var(--cyan) !important; box-shadow: 0 0 14px rgba(0,229,255,0.3);
}
.skg-btn-primary, button.skg-btn-primary {
font-family: var(--font-mono) !important; letter-spacing: 0.08em; text-transform: uppercase;
font-size: 11px !important; background: linear-gradient(90deg, rgba(0,229,255,0.18), rgba(180,79,255,0.18)) !important;
border: 1px solid var(--cyan) !important; color: var(--cyan) !important; border-radius: 4px !important;
box-shadow: 0 0 16px rgba(0,229,255,0.2);
}
.skg-btn-primary:hover { box-shadow: 0 0 26px rgba(0,229,255,0.45); }
.skg-btn-swarm, button.skg-btn-swarm { border-color: var(--lime) !important; color: var(--lime) !important; }
.skg-btn-swarm:hover { box-shadow: 0 0 20px rgba(57,255,20,0.35); }
#graph-outer { padding: 0 !important; border: none !important; background: none !important; }
#graph-canvas-wrap {
position: relative; height: 74vh; min-height: 480px;
border: 1px solid var(--border); border-radius: 6px; overflow: hidden;
background: radial-gradient(ellipse at 50% 28%, rgba(0,229,255,0.07) 0%, transparent 55%), var(--void);
}
.grid-bg {
position: absolute; inset: 0; pointer-events: none;
background-image: linear-gradient(rgba(0,229,255,0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,229,255,0.05) 1px, transparent 1px);
background-size: 34px 34px;
mask-image: radial-gradient(ellipse at 50% 40%, black 5%, transparent 78%);
-webkit-mask-image: radial-gradient(ellipse at 50% 40%, black 5%, transparent 78%);
}
.scanline {
position: absolute; left: 0; right: 0; height: 2px; top: -2%;
background: linear-gradient(90deg, transparent, rgba(0,229,255,0.6), transparent);
animation: scan 5.5s linear infinite; pointer-events: none;
}
@keyframes scan { 0% { top: -2%; } 100% { top: 102%; } }
#graph-svg { position: absolute; inset: 0; width: 100%; height: 100%; }
#minimap-svg {
position: absolute; left: 12px; bottom: 12px; width: 150px; height: 96px;
background: rgba(6,13,24,0.78); border: 1px solid var(--border); border-radius: 4px; backdrop-filter: blur(6px);
}
.ov-toolbar { position: absolute; top: 12px; left: 12px; display: flex; gap: 6px; flex-wrap: wrap; z-index: 5; max-width: 70%; }
.ov-btn {
font-family: var(--font-mono) !important; font-size: 9px !important; letter-spacing: 0.07em; text-transform: uppercase;
padding: 6px 10px; border: 1px solid var(--border) !important; background: rgba(6,13,24,0.9) !important; color: var(--fog) !important;
cursor: pointer; backdrop-filter: blur(6px); border-radius: 3px; transition: all 0.15s; box-shadow: none !important;
}
.ov-btn:hover, .ov-btn.active {
border-color: var(--cyan) !important; color: var(--cyan) !important; background: rgba(0,229,255,0.16) !important;
box-shadow: 0 0 10px rgba(0,229,255,0.3) !important;
}
.legend { position: absolute; bottom: 12px; right: 12px; display: flex; gap: 10px; flex-wrap: wrap; z-index: 5; max-width: 55%; justify-content: flex-end; }
.legend-item { display: flex; align-items: center; gap: 5px; font-family: var(--font-mono); font-size: 9px; color: var(--fog) !important; }
.legend-dot { width: 8px; height: 8px; border-radius: 2px; }
#empty-state { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; z-index: 1; }
#empty-state .es-icon { font-size: 44px; color: var(--cyan) !important; text-shadow: 0 0 26px rgba(0,229,255,0.6); animation: pulseIcon 3s ease-in-out infinite; }
@keyframes pulseIcon { 0%, 100% { opacity: 0.35; transform: scale(1); } 50% { opacity: 1; transform: scale(1.08); } }
#empty-state .es-text { font-family: var(--font-mono); font-size: 12px; color: var(--fog) !important; letter-spacing: 0.05em; }
#empty-state .es-sub { font-family: var(--font-mono); font-size: 10px; color: var(--fog) !important; opacity: 0.6; }
.node { cursor: pointer; }
.node-shape { transition: fill-opacity 0.15s, stroke-width 0.15s; }
.node-label { font-family: var(--font-mono); font-size: 9.5px; fill: var(--ice); pointer-events: none; text-shadow: 0 0 6px var(--void), 0 0 10px var(--void); }
.pulse-ring { animation: pulseRing 2.4s ease-out infinite; transform-box: fill-box; transform-origin: center; }
@keyframes pulseRing { 0% { transform: scale(0.9); opacity: 0.7; } 100% { transform: scale(1.9); opacity: 0; } }
.aco-trail { stroke-dasharray: 4 3; animation: marchAnts 1s linear infinite; }
@keyframes marchAnts { to { stroke-dashoffset: -14; } }
.context-menu {
position: fixed; z-index: 1000; background: rgba(6,13,24,0.97); border: 1px solid var(--border);
border-radius: 4px; backdrop-filter: blur(10px); min-width: 170px; box-shadow: 0 8px 30px rgba(0,0,0,0.55);
}
.cm-item { padding: 9px 13px; font-family: var(--font-mono); font-size: 10px; color: var(--ice) !important; cursor: pointer; letter-spacing: 0.03em; }
.cm-item:hover { background: rgba(0,229,255,0.12); color: var(--cyan) !important; }
#insights-output { font-family: var(--font-body) !important; }
#insights-output, #insights-output p, #insights-output li, #insights-output span, #insights-output div {
color: var(--ice) !important;
}
#insights-output strong, #insights-output b { color: var(--cyan) !important; }
#insights-output em, #insights-output i { color: var(--ice) !important; opacity: 0.92; font-style: italic; }
#insights-output h2 {
font-family: var(--font-head) !important; font-size: 12px !important; letter-spacing: 0.07em;
text-transform: uppercase; color: var(--plasma) !important; border-bottom: 1px solid var(--border);
padding-bottom: 4px; margin-top: 16px !important;
}
@media (max-width: 1024px) {
#graph-canvas-wrap { height: 58vh; min-height: 400px; }
}
"""
# ═══════════════════════════════════════════════════════════════════════════
# Head: fonts, D3, and the client-side SKG visualization module
# ═══════════════════════════════════════════════════════════════════════════
_PREVIEW_WORDS = sorted(
{w.lower() for w in PEOPLE} | {w.lower() for w in PLACES} | {b.lower() for b in BOOKS}
| set(BOOK_ABBREVIATIONS.keys()) | THEMES | THEOLOGICAL_CONCEPTS | EVENTS | TIME_PERIODS
)
HEAD_EXTRA = f"""
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@500;700&family=JetBrains+Mono:wght@400;600&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"></script>
<script>
window.SKG_WORDS = {json.dumps(_PREVIEW_WORDS)};
window.SKG_BOOK_ORDER = {json.dumps(BOOK_ORDER)};
const NODE_COLORS = {{
person: '#00e5ff', place: '#b44fff', theme: '#39ff14', concept: '#a6ff4d',
book: '#ff6b35', verse: '#ffde00', event: '#ff2d6b', period: '#00ffa2',
}};
function nodeRadius(d, maxPR) {{
const pr = (d.pagerank || 0) / (maxPR || 1);
return 7 + pr * 15 + Math.min(d.weight || 1, 6) * 0.6;
}}
function hexagonPath(r) {{
let d = '';
for (let i = 0; i < 6; i++) {{
const a = Math.PI / 3 * i - Math.PI / 2;
const x = r * Math.cos(a), y = r * Math.sin(a);
d += (i === 0 ? 'M' : 'L') + x.toFixed(2) + ',' + y.toFixed(2);
}}
return d + 'Z';
}}
function shapePath(type, r) {{
const size = r * r * 3.2;
switch (type) {{
case 'place': return d3.symbol().type(d3.symbolDiamond).size(size * 1.3)();
case 'verse': return d3.symbol().type(d3.symbolStar).size(size * 1.7)();
case 'event': return d3.symbol().type(d3.symbolTriangle).size(size * 1.4)();
case 'theme': case 'concept': case 'period': return d3.symbol().type(d3.symbolSquare).size(size * 1.1)();
case 'book': return hexagonPath(r);
default: return d3.symbol().type(d3.symbolCircle).size(size)();
}}
}}
const SKG = {{ state: {{}} }};
SKG.clearGraph = function() {{
if (SKG.antsRafId) {{ cancelAnimationFrame(SKG.antsRafId); SKG.antsRafId = null; }}
d3.select('#graph-svg').selectAll('*').remove();
d3.select('#minimap-svg').selectAll('*').remove();
const es = document.getElementById('empty-state');
if (es) es.style.display = 'flex';
const nd = document.getElementById('node-detail');
if (nd) nd.innerHTML = '<div class="nd-placeholder">Click a node to inspect</div>';
const ep = document.getElementById('entity-preview');
if (ep) ep.textContent = 'Paste notes to see a live entity estimate.';
document.querySelectorAll('.ov-btn').forEach(b => b.classList.remove('active'));
const forceBtn = document.getElementById('ov-force');
if (forceBtn) forceBtn.classList.add('active');
const pathBtn = document.getElementById('ov-path');
if (pathBtn) pathBtn.classList.remove('active');
SKG.state = {{}};
}};
SKG.updateEntityPreview = function(text) {{
const el = document.getElementById('entity-preview');
if (!el) return;
if (!text || !text.trim()) {{ el.textContent = 'Paste notes to see a live entity estimate.'; return; }}
const lower = text.toLowerCase();
const words = lower.match(/[a-z']+/g) || [];
const wordSet = new Set(words);
let count = 0;
(window.SKG_WORDS || []).forEach(w => {{
if (w.indexOf(' ') >= 0) {{ if (lower.includes(w)) count++; }}
else if (wordSet.has(w)) count++;
}});
const verseMatches = (text.match(/\\b[1-3]?\\s?[A-Z][a-z]+\\.?\\s+\\d{{1,3}}:\\d{{1,3}}/g) || []).length;
const wordCount = text.trim().split(/\\s+/).length;
el.textContent = '~' + (count + verseMatches) + ' entities detected · ' + wordCount + ' words';
}};
function dragStarted(e, d) {{ if (!e.active) SKG.state.simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; }}
function dragged(e, d) {{ d.fx = e.x; d.fy = e.y; }}
function dragEnded(e, d) {{ if (!e.active) SKG.state.simulation.alphaTarget(0); d.fx = null; d.fy = null; }}
function updateNodeDetail(d) {{
const nd = document.getElementById('node-detail');
if (!nd) return;
nd.innerHTML = '<div class="nd-name" style="color:' + (NODE_COLORS[d.type] || '#0ff') + '">' + d.label + '</div>' +
'<div class="nd-type">' + d.type.toUpperCase() + ' · COMMUNITY ' + (d.community != null ? d.community + 1 : '—') + '</div>' +
'<div class="nd-info">PageRank <b>' + (d.pagerank || 0).toFixed(4) + '</b> &nbsp;|&nbsp; Degree <b>' + (d.degree || 0) +
'</b> &nbsp;|&nbsp; Betweenness <b>' + (d.betweenness || 0).toFixed(3) + '</b><br>Mentioned ' + d.weight + '× in your notes</div>';
}}
function hideContextMenu() {{ const m = document.getElementById('context-menu'); if (m) m.style.display = 'none'; }}
document.addEventListener('click', hideContextMenu);
function showContextMenu(e, d) {{
const menu = document.getElementById('context-menu');
if (!menu) return;
menu.style.display = 'block';
menu.style.left = e.pageX + 'px';
menu.style.top = e.pageY + 'px';
const safeLabel = d.label.replace(/'/g, "\\\\'");
menu.innerHTML =
'<div class="cm-item" onclick="SKG.findPathFrom(\\'' + d.id + '\\')">Find paths from here…</div>' +
'<div class="cm-item" onclick="SKG.highlightCommunity(' + d.community + ')">Highlight community</div>' +
'<div class="cm-item" onclick="SKG.copyReference(\\'' + safeLabel + '\\')">Copy reference</div>';
}}
SKG.findPathFrom = function(id) {{
SKG.state.pathMode = true;
SKG.state.pathSelected = [id];
const btn = document.getElementById('ov-path');
if (btn) btn.classList.add('active');
hideContextMenu();
}};
SKG.highlightCommunity = function(cid) {{
if (SKG.state.nodeSel) SKG.state.nodeSel.select('.node-shape').attr('fill-opacity', d => d.community === cid ? 0.6 : 0.06);
hideContextMenu();
}};
SKG.copyReference = function(label) {{
if (navigator.clipboard) navigator.clipboard.writeText(label).catch(() => {{}});
hideContextMenu();
}};
function highlightPath(a, b) {{
const st = SKG.state;
const adj = {{}};
st.payload.nodes.forEach(n => adj[n.id] = []);
st.payload.edges.forEach(e => {{
const s = e.source.id || e.source, t = e.target.id || e.target;
if (adj[s]) adj[s].push(t);
if (adj[t]) adj[t].push(s);
}});
const visited = new Set([a]);
const queue = [[a]];
let path = null;
while (queue.length) {{
const p = queue.shift();
const cur = p[p.length - 1];
if (cur === b) {{ path = p; break; }}
for (const nb of (adj[cur] || [])) {{
if (!visited.has(nb)) {{ visited.add(nb); queue.push([...p, nb]); }}
}}
}}
if (!path) return;
const pathSet = new Set();
for (let i = 0; i < path.length - 1; i++) pathSet.add([path[i], path[i + 1]].sort().join('|||'));
st.linkSel
.attr('stroke-opacity', e => {{
const s = e.source.id || e.source, t = e.target.id || e.target;
return pathSet.has([s, t].sort().join('|||')) ? 0.95 : 0.04;
}})
.attr('stroke-width', e => {{
const s = e.source.id || e.source, t = e.target.id || e.target;
return pathSet.has([s, t].sort().join('|||')) ? 3.5 : 0.6;
}});
const labels = path.map(id => (st.payload.nodes.find(n => n.id === id) || {{}}).label).filter(Boolean);
const nd = document.getElementById('node-detail');
if (nd) nd.innerHTML = '<div class="nd-name" style="color:var(--cyan)">Shortest Path</div><div class="nd-info">' + labels.join(' → ') + '</div>';
}}
function onNodeClick(e, d) {{
hideContextMenu();
const st = SKG.state;
if (st.pathMode) {{
st.pathSelected.push(d.id);
if (st.pathSelected.length === 2) {{
highlightPath(st.pathSelected[0], st.pathSelected[1]);
st.pathSelected = [];
}}
return;
}}
updateNodeDetail(d);
const neighbors = new Set();
st.payload.edges.forEach(e2 => {{
const s = e2.source.id || e2.source, t = e2.target.id || e2.target;
if (s === d.id) neighbors.add(t);
if (t === d.id) neighbors.add(s);
}});
st.nodeSel.select('.node-shape').attr('fill-opacity', n => (n.id === d.id || neighbors.has(n.id)) ? 0.55 : 0.08);
st.linkSel.attr('stroke-opacity', l => {{
const s = l.source.id || l.source, t = l.target.id || l.target;
return (s === d.id || t === d.id) ? 0.75 : 0.04;
}});
}}
function renderMinimap(payload, W, H) {{
const mm = d3.select('#minimap-svg');
mm.selectAll('*').remove();
const nodes = payload.nodes.filter(n => n.x != null);
if (!nodes.length) return;
const xs = nodes.map(n => n.x), ys = nodes.map(n => n.y);
const minX = Math.min(...xs), maxX = Math.max(...xs), minY = Math.min(...ys), maxY = Math.max(...ys);
const pad = 8, mw = 150, mh = 96;
const sx = (mw - 2 * pad) / Math.max(maxX - minX, 1);
const sy = (mh - 2 * pad) / Math.max(maxY - minY, 1);
const s = Math.min(sx, sy);
mm.attr('viewBox', '0 0 ' + mw + ' ' + mh);
mm.selectAll('circle').data(nodes).enter().append('circle')
.attr('cx', d => pad + (d.x - minX) * s)
.attr('cy', d => pad + (d.y - minY) * s)
.attr('r', 1.4)
.attr('fill', d => NODE_COLORS[d.type] || '#0ff')
.attr('fill-opacity', 0.85);
SKG.state.minimap = {{ minX, minY, s, pad, mw, mh, W, H }};
mm.append('rect').attr('id', 'minimap-viewport').attr('fill', 'none').attr('stroke', '#00e5ff').attr('stroke-width', 1)
.attr('x', pad).attr('y', pad).attr('width', Math.min(mw - 2 * pad, W * s)).attr('height', Math.min(mh - 2 * pad, H * s));
}}
function updateMinimapViewport(transform, W, H) {{
const mmState = SKG.state.minimap;
if (!mmState) return;
const rect = d3.select('#minimap-viewport');
if (rect.empty()) return;
const {{ minX, minY, s, pad }} = mmState;
const vx = pad + ((-transform.x / transform.k) - minX) * s;
const vy = pad + ((-transform.y / transform.k) - minY) * s;
rect.attr('x', vx).attr('y', vy).attr('width', (W / transform.k) * s).attr('height', (H / transform.k) * s);
}}
SKG.renderGraph = function(payload) {{
if (!payload || !payload.nodes || !payload.nodes.length) return;
if (SKG.antsRafId) {{ cancelAnimationFrame(SKG.antsRafId); SKG.antsRafId = null; }}
const wrap = document.getElementById('graph-canvas-wrap');
const es = document.getElementById('empty-state');
if (es) es.style.display = 'none';
const W = wrap.clientWidth || 800, H = wrap.clientHeight || 560;
const svg = d3.select('#graph-svg');
svg.selectAll('*').remove();
svg.attr('viewBox', '0 0 ' + W + ' ' + H);
const defs = svg.append('defs');
const glow = defs.append('filter').attr('id', 'glow').attr('x', '-60%').attr('y', '-60%').attr('width', '220%').attr('height', '220%');
glow.append('feGaussianBlur').attr('stdDeviation', 3).attr('result', 'blur');
const merge = glow.append('feMerge');
merge.append('feMergeNode').attr('in', 'blur');
merge.append('feMergeNode').attr('in', 'SourceGraphic');
const acoGlow = defs.append('filter').attr('id', 'aco-glow').attr('x', '-80%').attr('y', '-80%').attr('width', '260%').attr('height', '260%');
acoGlow.append('feGaussianBlur').attr('stdDeviation', 2.2);
const zoomG = svg.append('g').attr('class', 'zoom-g');
const zoomBehavior = d3.zoom().scaleExtent([0.15, 4]).on('zoom', e => {{
zoomG.attr('transform', e.transform);
updateMinimapViewport(e.transform, W, H);
}});
svg.call(zoomBehavior);
svg.on('dblclick.zoom', null);
svg.on('dblclick', () => svg.transition().duration(400).call(zoomBehavior.transform, d3.zoomIdentity));
payload.edges.forEach((e, i) => {{
const s = payload.nodes.find(n => n.id === e.source);
const t = payload.nodes.find(n => n.id === e.target);
const grad = defs.append('linearGradient').attr('id', 'egrad' + i).attr('gradientUnits', 'userSpaceOnUse');
grad.append('stop').attr('offset', '0%').attr('stop-color', NODE_COLORS[s && s.type] || '#4dd');
grad.append('stop').attr('offset', '100%').attr('stop-color', NODE_COLORS[t && t.type] || '#4dd');
}});
const linkSel = zoomG.append('g').attr('class', 'links').selectAll('line')
.data(payload.edges).enter().append('line')
.attr('stroke', (d, i) => 'url(#egrad' + i + ')')
.attr('stroke-opacity', d => 0.15 + Math.min(d.weight / 5, 1) * 0.45)
.attr('stroke-width', d => 0.6 + Math.min(d.weight, 6) * 0.5);
const acoLinkSel = zoomG.append('g').attr('class', 'aco-links').selectAll('line')
.data(payload.edges).enter().append('line')
.attr('class', 'aco-trail')
.attr('stroke', '#39ff14')
.attr('stroke-opacity', 0)
.attr('stroke-width', 1.5)
.attr('filter', 'url(#aco-glow)')
.attr('pointer-events', 'none');
const antsG = zoomG.append('g').attr('class', 'ants-g');
const maxPR = payload.maxPagerank || 1;
const nodeSel = zoomG.append('g').attr('class', 'nodes').selectAll('g')
.data(payload.nodes, d => d.id).enter().append('g')
.attr('class', d => 'node node-' + d.type)
.call(d3.drag().on('start', dragStarted).on('drag', dragged).on('end', dragEnded))
.on('click', (e, d) => onNodeClick(e, d))
.on('contextmenu', (e, d) => {{ e.preventDefault(); showContextMenu(e, d); }});
nodeSel.append('title').text(d =>
d.label + '\\nType: ' + d.type + '\\nPageRank: ' + (d.pagerank || 0).toFixed(4) + '\\nCommunity: ' + (d.community != null ? d.community + 1 : '—')
);
nodeSel.append('path')
.attr('class', 'node-shape')
.attr('d', d => shapePath(d.type, nodeRadius(d, maxPR)))
.attr('fill', d => NODE_COLORS[d.type] || '#88a')
.attr('fill-opacity', 0.22)
.attr('stroke', d => NODE_COLORS[d.type] || '#88a')
.attr('stroke-width', 1.6)
.attr('filter', 'url(#glow)');
nodeSel.filter(d => (d.pagerank / maxPR) > 0.55).append('circle')
.attr('class', 'pulse-ring')
.attr('r', d => nodeRadius(d, maxPR) + 4)
.attr('fill', 'none')
.attr('stroke', d => NODE_COLORS[d.type] || '#0ff')
.attr('stroke-width', 1.5);
nodeSel.append('text')
.attr('class', 'node-label')
.attr('y', d => -(nodeRadius(d, maxPR) + 8))
.attr('text-anchor', 'middle')
.text(d => d.label);
const simulation = d3.forceSimulation(payload.nodes)
.force('link', d3.forceLink(payload.edges).id(d => d.id).distance(85).strength(0.35))
.force('charge', d3.forceManyBody().strength(-170))
.force('center', d3.forceCenter(W / 2, H / 2))
.force('collision', d3.forceCollide().radius(d => nodeRadius(d, maxPR) + 10))
.on('tick', () => {{
linkSel.attr('x1', d => d.source.x).attr('y1', d => d.source.y).attr('x2', d => d.target.x).attr('y2', d => d.target.y);
acoLinkSel.attr('x1', d => d.source.x).attr('y1', d => d.source.y).attr('x2', d => d.target.x).attr('y2', d => d.target.y);
nodeSel.attr('transform', d => 'translate(' + d.x + ',' + d.y + ')');
renderMinimap(payload, W, H);
}});
SKG.state = {{
payload, svg, zoomG, zoomBehavior, simulation, linkSel, acoLinkSel, nodeSel, antsG,
W, H, layoutMode: 'force', pathMode: false, pathSelected: [],
}};
}};
SKG.setLayout = function(mode) {{
const st = SKG.state;
st.layoutMode = mode;
document.querySelectorAll('.ov-btn').forEach(b => b.classList.remove('active'));
const btn = document.getElementById('ov-' + mode);
if (btn) btn.classList.add('active');
if (!st.simulation || !st.payload) return;
const {{ W, H }} = st;
const nodes = st.payload.nodes;
nodes.forEach(n => {{ n.fx = null; n.fy = null; }});
if (mode === 'radial') {{
const comms = [...new Set(nodes.map(n => n.community))];
const angleFor = {{}};
comms.forEach((c, i) => angleFor[c] = (i / comms.length) * Math.PI * 2);
nodes.forEach(n => {{
const a = angleFor[n.community] || 0;
const r = 90 + (n.community % 5) * 55;
n.fx = W / 2 + Math.cos(a) * r + (Math.random() - 0.5) * 40;
n.fy = H / 2 + Math.sin(a) * r + (Math.random() - 0.5) * 40;
}});
st.simulation.alpha(0.6).restart();
setTimeout(() => nodes.forEach(n => {{ n.fx = null; n.fy = null; }}), 1800);
}} else if (mode === 'hierarchical') {{
const sorted = [...nodes].sort((a, b) => (b.pagerank || 0) - (a.pagerank || 0));
const bands = 5;
sorted.forEach((n, i) => {{ n.fy = 60 + Math.floor((i / sorted.length) * bands) * ((H - 120) / bands); }});
st.simulation.force('x', d3.forceX(W / 2).strength(0.05));
st.simulation.alpha(0.6).restart();
setTimeout(() => {{ nodes.forEach(n => n.fy = null); st.simulation.force('x', null); }}, 1800);
}} else if (mode === 'timeline') {{
const order = window.SKG_BOOK_ORDER || [];
const books = nodes.filter(n => n.type === 'book');
books.forEach(n => {{
const idx = order.findIndex(b => b.toLowerCase() === n.label.toLowerCase());
n.fx = idx >= 0 ? 60 + (idx / Math.max(order.length - 1, 1)) * (W - 120) : W / 2;
n.fy = H / 2;
}});
st.simulation.force('x', d3.forceX(W / 2).strength(0.03));
st.simulation.alpha(0.6).restart();
setTimeout(() => {{ books.forEach(n => n.fx = null); st.simulation.force('x', null); }}, 2200);
}} else {{
st.simulation.force('center', d3.forceCenter(W / 2, H / 2));
st.simulation.alpha(0.6).restart();
}}
}};
SKG.togglePathMode = function() {{
const st = SKG.state;
st.pathMode = !st.pathMode;
st.pathSelected = [];
const btn = document.getElementById('ov-path');
if (btn) btn.classList.toggle('active', st.pathMode);
if (st.nodeSel) {{
st.nodeSel.select('.node-shape').attr('fill-opacity', 0.22).attr('stroke-width', 1.6);
if (st.linkSel) st.linkSel.attr('stroke-opacity', d => 0.15 + Math.min(d.weight / 5, 1) * 0.45).attr('stroke-width', d => 0.6 + Math.min(d.weight, 6) * 0.5);
}}
}};
SKG.resetZoom = function() {{
const st = SKG.state;
if (!st.svg || !st.zoomBehavior) return;
st.svg.transition().duration(400).call(st.zoomBehavior.transform, d3.zoomIdentity);
}};
SKG.renderACOTrails = function(swarmState) {{
const st = SKG.state;
if (!st.acoLinkSel || !swarmState || !swarmState.tau) return;
const tau = swarmState.tau;
const vals = Object.values(tau);
const maxTau = vals.length ? Math.max(...vals) : 0.01;
st.acoLinkSel
.attr('stroke-opacity', d => {{
const s = d.source.id || d.source, t = d.target.id || d.target;
const v = tau[[s, t].sort().join('|||')] || 0;
return Math.min((v / maxTau) * 0.9, 0.9);
}})
.attr('stroke-width', d => {{
const s = d.source.id || d.source, t = d.target.id || d.target;
const v = tau[[s, t].sort().join('|||')] || 0;
return 1 + (v / maxTau) * 4;
}});
if (swarmState.best_path && st.nodeSel) {{
const pathSet = new Set(swarmState.best_path);
st.nodeSel.select('.node-shape').attr('stroke-width', d => pathSet.has(d.id) ? 3.2 : 1.6);
}}
SKG.startAntAnimation(tau);
}};
SKG.startAntAnimation = function(tau) {{
const st = SKG.state;
if (SKG.antsRafId) {{ cancelAnimationFrame(SKG.antsRafId); SKG.antsRafId = null; }}
if (!st.antsG || !st.payload || !st.payload.edges.length) return;
const scored = st.payload.edges.map(e => {{
const s = e.source.id || e.source, t = e.target.id || e.target;
const v = tau[[s, t].sort().join('|||')] || 0;
return {{ edge: e, v }};
}}).sort((a, b) => b.v - a.v);
const topEdges = scored.slice(0, Math.min(16, scored.length)).filter(x => x.v > 0).map(x => x.edge);
st.antsG.selectAll('circle').remove();
if (!topEdges.length) return;
const ants = topEdges.map(edge => ({{
edge, t: Math.random(), dir: Math.random() < 0.5 ? 1 : -1, speed: 0.15 + Math.random() * 0.2,
}}));
const antSel = st.antsG.selectAll('circle').data(ants).enter().append('circle')
.attr('r', 2.4)
.attr('fill', '#88ffaa')
.attr('filter', 'url(#aco-glow)');
let lastTime = performance.now();
function tick(now) {{
const dt = Math.min((now - lastTime) / 1000, 0.1);
lastTime = now;
antSel.each(function(a) {{
a.t += a.dir * a.speed * dt;
if (a.t > 1) {{ a.t = 1; a.dir = -1; }}
if (a.t < 0) {{ a.t = 0; a.dir = 1; }}
}});
antSel
.attr('cx', a => a.edge.source.x + (a.edge.target.x - a.edge.source.x) * a.t)
.attr('cy', a => a.edge.source.y + (a.edge.target.y - a.edge.source.y) * a.t);
SKG.antsRafId = requestAnimationFrame(tick);
}}
SKG.antsRafId = requestAnimationFrame(tick);
}};
function skgCheckDemoParam() {{
try {{
const params = new URLSearchParams(window.location.search);
if (params.get('demo') === 'true') {{
setTimeout(() => {{
const btn = document.querySelector('#sample-btn button') || document.getElementById('sample-btn');
if (btn) btn.click();
}}, 900);
}}
}} catch (err) {{ /* no-op */ }}
}}
function skgBoot() {{
skgCheckDemoParam();
const observer = new MutationObserver(() => {{
const ta = document.querySelector('#notes-input textarea');
if (ta && !ta.dataset.skgBound) {{
ta.dataset.skgBound = '1';
let timer;
ta.addEventListener('input', () => {{
clearTimeout(timer);
timer = setTimeout(() => SKG.updateEntityPreview(ta.value), 500);
}});
}}
}});
observer.observe(document.body, {{ childList: true, subtree: true }});
}}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', skgBoot); else skgBoot();
window.SKG = SKG;
</script>
"""
# ═══════════════════════════════════════════════════════════════════════════
# JS bridges for Gradio events (post-process outputs client-side)
# ═══════════════════════════════════════════════════════════════════════════
JS_RENDER_GRAPH = """
(payload) => {
if (window.SKG && payload && payload.nodes) { window.SKG.renderGraph(payload); }
}
"""
JS_CLEAR_GRAPH = """
() => {
if (window.SKG) { window.SKG.clearGraph(); }
}
"""
JS_RENDER_ACO = """
(swarmState) => {
if (window.SKG && swarmState && swarmState.tau) { window.SKG.renderACOTrails(swarmState); }
}
"""
# ═══════════════════════════════════════════════════════════════════════════
# Gradio Blocks app
# ═══════════════════════════════════════════════════════════════════════════
with gr.Blocks(title="Scripture Knowledge Graph") as demo:
graph_state = gr.State(value=None)
algo_state = gr.State(value=None)
swarm_state = gr.State(value=None)
gr.HTML(HEADER_HTML)
with gr.Row(equal_height=True):
with gr.Column(scale=1, min_width=280, elem_id="sidebar-left"):
notes_input = gr.Textbox(
lines=16, placeholder="Paste or type your Bible study notes here…",
label="Study Notes", elem_id="notes-input",
)
with gr.Row():
upload_btn = gr.UploadButton("⬆ Upload", file_types=[".txt", ".md"], elem_classes="skg-btn", size="sm")
sample_btn = gr.Button("📄 Sample", elem_id="sample-btn", elem_classes="skg-btn", size="sm")
clear_btn = gr.Button("✕ Clear", elem_id="clear-btn", elem_classes="skg-btn", size="sm")
analyze_btn = gr.Button("✦ Build Knowledge Graph", elem_id="analyze-btn", elem_classes="skg-btn-primary")
with gr.Column(scale=3, min_width=480):
graph_data_json = gr.JSON(visible=False)
swarm_data_json = gr.JSON(visible=False)
gr.HTML(GRAPH_CONTAINER_HTML, elem_id="graph-outer")
with gr.Column(scale=1, min_width=300, elem_id="sidebar-right"):
gr.HTML(NODE_DETAIL_HTML)
with gr.Tabs():
with gr.Tab("Graph"):
stats_html = gr.HTML(EMPTY_STATS_HTML)
with gr.Tab("Swarm"):
swarm_html = gr.HTML(EMPTY_SWARM_HTML)
run_swarm_btn = gr.Button("▶ Run Swarm Analysis", elem_id="swarm-btn", elem_classes="skg-btn skg-btn-swarm")
with gr.Tab("AI Insights"):
insights_btn = gr.Button("✦ Generate Insights", elem_id="insights-btn", elem_classes="skg-btn-primary")
insights_out = gr.Markdown("Build a graph first, then click **Generate Insights**.", elem_id="insights-output")
analyze_outputs = [graph_data_json, stats_html, algo_state, graph_state]
analyze_btn.click(fn=analyze_notes, inputs=[notes_input], outputs=analyze_outputs).then(
fn=None, inputs=[graph_data_json], outputs=[], js=JS_RENDER_GRAPH
)
sample_btn.click(fn=load_sample, outputs=[notes_input]).then(
fn=analyze_notes, inputs=[notes_input], outputs=analyze_outputs
).then(
fn=None, inputs=[graph_data_json], outputs=[], js=JS_RENDER_GRAPH
)
upload_btn.upload(fn=handle_upload, inputs=[upload_btn], outputs=[notes_input]).then(
fn=analyze_notes, inputs=[notes_input], outputs=analyze_outputs
).then(
fn=None, inputs=[graph_data_json], outputs=[], js=JS_RENDER_GRAPH
)
clear_btn.click(
fn=clear_all,
outputs=[notes_input, graph_data_json, stats_html, swarm_html, algo_state, graph_state, swarm_state, swarm_data_json, insights_out],
).then(fn=None, outputs=[], js=JS_CLEAR_GRAPH)
run_swarm_btn.click(
fn=run_swarm_gen, inputs=[graph_state, algo_state], outputs=[swarm_html, swarm_data_json, swarm_state]
).then(
fn=None, inputs=[swarm_data_json], outputs=[], js=JS_RENDER_ACO
)
insights_btn.click(fn=generate_insights, inputs=[notes_input, graph_state, algo_state, swarm_state], outputs=[insights_out])
if __name__ == "__main__":
demo.queue().launch(
theme=gr.themes.Base(primary_hue="cyan", neutral_hue="slate"),
css=CUSTOM_CSS,
head=HEAD_EXTRA,
)