wpl-discovery / graph_build.py
GameTheory's picture
Sync from GitHub
24c5932 verified
Raw
History Blame Contribute Delete
12.2 kB
"""
graph_build.py - turn library_kb.json into a knowledge graph (GraphRAG-style).
Pipeline (mirrors microsoft/graphrag's stages, but deterministic + local so it
runs on a laptop with no LLM calls and no API cost):
KB documents -> Entities -> Relationships -> Communities -> Reports
Why a graph and not flat RAG? It answers MULTI-HOP questions a chatbot can't,
e.g. "which late-opening library has free parking and a cafe?" - that traverses
Branch -HAS_FACILITY-> Facility and Branch -OFFERS-> Libraries Unlocked in one go.
Run after build_kb.py: python build_kb.py && python graph_build.py
Output: library_graph.json (nodes, typed edges, communities, reports)
"""
from __future__ import annotations
import json
import re
from datetime import datetime, timezone
import networkx as nx
from networkx.algorithms import community as nx_comm
try: # curated online-hub access detail
from library_sources import CURATED_HUB
except Exception:
CURATED_HUB = {}
KB_PATH = "library_kb.json"
HIVE_KB_PATH = "hive_kb.json"
OUT_PATH = "library_graph.json"
GOV = "https://www.worcestershire.gov.uk"
# Facility signals scanned across the Hive pages (same idea as build_kb.py).
HIVE_FACILITY_VOCAB = {
"café": r"\bcaf[eé]\b",
"meeting rooms": r"\bmeeting rooms?|rooms? (for|to) hire|space for hire\b",
"study space": r"\bstudy (space|area|room)\b",
"free Wi-Fi": r"\bwi-?fi\b",
"public computers": r"\b(public )?computers?\b",
"printing": r"\bprint(ing)?|photocopy",
"public toilets": r"\btoilets?\b",
"baby changing": r"\bbaby chang",
"wheelchair access": r"\bwheelchair|step-?free|level access|accessib",
"archives": r"\barchives?\b",
"children's library": r"\bchildren'?s (library|area)\b",
"exhibition space": r"\bexhibition\b",
}
POSTCODE = re.compile(r"[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}")
def town_of(address: str) -> str:
m = POSTCODE.search(address or "")
if not m:
return ""
before = (address[:m.start()]).strip().split()
return before[-1].title() if before else ""
def infer_tier(need) -> str | None:
t = " ".join(need).lower() if isinstance(need, list) else str(need).lower()
if "induction" in t or "unlocked" in t:
return "Libraries Unlocked"
if "papercut" in t or "print" in t:
return "Full membership"
if "digital" in t or "postcode" in t:
return "Digital membership"
if any(w in t for w in ("member", "card", "join", "pin")):
return "Full membership"
return None
def build_graph(kb: dict) -> nx.Graph:
G = nx.Graph()
def add(node_id, ntype, label, **attrs):
G.add_node(node_id, type=ntype, label=label, **attrs)
return node_id
# --- Membership tiers (the "what you need to sign up" spine) ---
for tier in kb.get("membership_tiers", []):
add(f"tier::{tier['tier']}", "Membership", tier["tier"],
what_you_need=tier.get("what_you_need", ""),
unlocks=tier.get("unlocks", ""), url=tier.get("url", ""))
hub_node = add("hub::online", "Hub", "Online library hub",
url=f"{GOV}/council-services/libraries/online-library-hub")
# --- Branches -> facilities, area, Libraries Unlocked ---
for b in kb.get("branches", []):
bid = add(f"branch::{b['name']}", "Branch", b["name"],
address=b.get("address", ""), hours=b.get("hours", {}),
facilities=b.get("facilities", []),
libraries_unlocked=b.get("libraries_unlocked", False),
url=b.get("url", ""))
for fac in b.get("facilities", []):
fid = add(f"facility::{fac}", "Facility", fac)
G.add_edge(bid, fid, rel="HAS_FACILITY")
town = town_of(b.get("address", ""))
if town:
aid = add(f"area::{town}", "Area", town)
G.add_edge(bid, aid, rel="LOCATED_IN")
if b.get("libraries_unlocked"):
G.add_edge(bid, "tier::Libraries Unlocked", rel="OFFERS")
# --- Services -> category topic, required membership tier ---
for s in kb.get("services", []):
sid = add(f"service::{s['title']}", "Service", s["title"],
category=s.get("category", "general"),
summary=s.get("summary", ""),
what_you_need=s.get("what_you_need", []),
how_to=s.get("how_to", []), url=s.get("url", ""))
tid = add(f"topic::{s.get('category','general')}", "Topic",
s.get("category", "general").title())
G.add_edge(sid, tid, rel="IN_CATEGORY")
tier = infer_tier(s.get("what_you_need", []))
if tier and f"tier::{tier}" in G:
G.add_edge(sid, f"tier::{tier}", rel="REQUIRES")
# --- Online-hub resources -> hub, required tier, curated access ---
for h in kb.get("online_hub", []):
cur = next((v for k, v in CURATED_HUB.items()
if k in h["name"].lower() or h["name"].lower() in k), {})
rid = add(f"resource::{h['name']}", "Resource", h["name"],
summary=cur.get("inside") or h.get("summary", ""),
what_you_need=cur.get("what_you_need", ""),
access=cur.get("access", []), at_home=cur.get("at_home"),
titles=cur.get("titles", []), url=h.get("url", ""))
G.add_edge(rid, hub_node, rel="PART_OF")
tier = "Digital membership" if cur.get("at_home", True) else "Full membership"
if f"tier::{tier}" in G:
G.add_edge(rid, f"tier::{tier}", rel="REQUIRES")
# --- Mobile library villages (live fetch; falls back to the last graph) ---
mob = add("service::Mobile library", "Service", "Mobile library",
category="access",
url=f"{GOV}/council-services/libraries/your-library-membership/mobile-library")
villages: dict[str, str] = {}
try:
from library_sources import _village_index
villages = dict(_village_index())
except Exception as e:
try: # offline rebuild: keep the villages from the previous graph
with open(OUT_PATH, encoding="utf-8") as f:
for n in json.load(f).get("nodes", []):
if n.get("type") == "Village":
villages[n["label"].lower()] = n.get("url", "")
print(f" (mobile villages: live fetch failed [{e}]; "
f"kept {len(villages)} from previous graph)")
except Exception:
print(f" (mobile villages skipped: {e})")
for name, url in villages.items():
vid = add(f"village::{name}", "Village", name.title(), url=url)
G.add_edge(vid, mob, rel="SERVED_BY")
# --- The Hive: page-level KB -> enrich the branch + capability nodes ---
try:
with open(HIVE_KB_PATH, encoding="utf-8") as f:
hive = json.load(f)
except FileNotFoundError:
hive = {}
if hive:
prof = hive.get("hive_profile", {})
pages = [p for p in hive.get("pages", []) if not p.get("error")]
hive_id = next((n for n in G.nodes
if n.startswith("branch::") and "hive" in n.lower()), None)
blob = " ".join(o for p in pages for o in p.get("offerings", []))
facilities = [name for name, pat in HIVE_FACILITY_VOCAB.items()
if re.search(pat, blob, re.I)]
hours = (prof.get("opening_hours") or {}).get("building", "")
attrs = dict(
open_late=True, # 8:30am–10pm, seven days - later than Unlocked
hive_hours=hours, source="thehiveworcester.org",
partnership=prof.get("partnership", "")[:200],
summary="Worcester city's library - Europe's first joint university "
"+ public library, open 8:30am–10pm every day.",
)
if hive_id: # enrich the council-crawled branch node
G.nodes[hive_id].update(attrs)
facs = set(G.nodes[hive_id].get("facilities", [])) | set(facilities)
G.nodes[hive_id]["facilities"] = sorted(facs)
for fac in facs:
fid = add(f"facility::{fac}", "Facility", fac)
G.add_edge(hive_id, fid, rel="HAS_FACILITY")
else:
hive_id = add("branch::The Hive", "Branch", "The Hive",
address=prof.get("address", ""), facilities=facilities,
url="https://www.thehiveworcester.org", **attrs)
for fac in facilities:
fid = add(f"facility::{fac}", "Facility", fac)
G.add_edge(hive_id, fid, rel="HAS_FACILITY")
aid = add("area::Worcester", "Area", "Worcester")
G.add_edge(hive_id, aid, rel="LOCATED_IN")
for cap in prof.get("extended_capabilities", []):
if not isinstance(cap, dict):
continue
label = cap.get("capability", "")[:80]
if not label:
continue
cid = add(f"hive::{label}", "HiveService", label,
summary=cap.get("detail", ""), url=cap.get("source", ""),
source="thehiveworcester.org")
G.add_edge(hive_id, cid, rel="OFFERS")
return G
def detect_communities(G: nx.Graph) -> list[dict]:
coms = nx_comm.greedy_modularity_communities(G)
reports = []
for i, members in enumerate(sorted(coms, key=len, reverse=True)):
members = list(members)
by_type: dict[str, list[str]] = {}
for n in members:
by_type.setdefault(G.nodes[n]["type"], []).append(G.nodes[n]["label"])
# readable, deterministic "community report"
title_bits = []
for t in ("Branch", "Area", "Service", "Resource", "Facility", "Membership"):
if by_type.get(t):
title_bits.append(f"{len(by_type[t])} {t.lower()}{'s' if len(by_type[t])>1 else ''}")
title = f"Cluster {i+1}: " + ", ".join(title_bits[:3]) if title_bits else f"Cluster {i+1}"
lines = [f"{t}: {', '.join(sorted(set(v))[:12])}"
for t, v in sorted(by_type.items())]
reports.append({
"id": f"community::{i}",
"title": title,
"size": len(members),
"members": members,
"report": " | ".join(lines),
})
return reports
def main():
with open(KB_PATH, encoding="utf-8") as f:
kb = json.load(f)
G = build_graph(kb)
communities = detect_communities(G)
out = {
"generated": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"built_from": f"{KB_PATH} + {HIVE_KB_PATH}",
"method": "deterministic GraphRAG-style graph (entities->relationships->"
"communities->reports); inspired by microsoft/graphrag",
"stats": {"nodes": G.number_of_nodes(), "edges": G.number_of_edges(),
"communities": len(communities),
"node_types": _count_types(G)},
"nodes": [{"id": n, **{k: v for k, v in d.items()}}
for n, d in G.nodes(data=True)],
"edges": [{"source": u, "target": v, "rel": d.get("rel", "RELATED")}
for u, v, d in G.edges(data=True)],
"communities": communities,
}
with open(OUT_PATH, "w", encoding="utf-8") as f:
json.dump(out, f, indent=2, ensure_ascii=False)
print(f"wrote {OUT_PATH}")
print("stats:", out["stats"])
# --- prove the multi-hop value (a query flat RAG can't answer) ---
print("\nMulti-hop demo: late-opening (Libraries Unlocked) libraries that "
"also have a café AND meeting rooms:")
hits = 0
for n, d in G.nodes(data=True):
if d["type"]!= "Branch" or not d.get("libraries_unlocked"):
continue
facs = set(d.get("facilities", []))
if any("caf" in f.lower() for f in facs) and "meeting rooms" in facs:
print(f" {d['label']} - open to 8pm, café + meeting rooms")
hits += 1
print(f" ({hits} match - traversed Branch→OFFERS→Unlocked + Branch→HAS_FACILITY)")
def _count_types(G):
c = {}
for _, d in G.nodes(data=True):
c[d["type"]] = c.get(d["type"], 0) + 1
return c
if __name__ == "__main__":
main()