| """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 |
|
|
| |
| |
| |
|
|
| 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 |
|
|
| |
| for m in VERSE_RE.finditer(text): |
| verse_label = m.group(0).strip() |
| add_node(verse_label.lower(), "verse", verse_label) |
|
|
| |
| 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} |
|
|
|
|
| |
| |
| |
|
|
| 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, |
| } |
|
|
|
|
| |
| |
| |
|
|
| 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"]), |
| } |
|
|
|
|
| |
| |
| |
|
|
| _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: |
| accumulated += f"\n\n---\n**Error generating insights:** {e}" |
| yield accumulated |
|
|
|
|
| |
| |
| |
|
|
| 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"]), |
| } |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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; } |
| } |
| """ |
|
|
|
|
| |
| |
| |
|
|
| _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> | Degree <b>' + (d.degree || 0) + |
| '</b> | 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_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); } |
| } |
| """ |
|
|
|
|
| |
| |
| |
|
|
| 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, |
| ) |
|
|