Skip to content

Commit 46c13f7

Browse files
feat: openkb visualize — interactive knowledge graph (3D · mind-map · radial) (#103)
* fix(test): align stale deck default-skill expectation with shipped openkb-deck-neon #101 made openkb-deck-neon the default deck skill (creator.py DEFAULT_DECK_SKILL) but left this test asserting the old openkb-deck-editorial, so it was red on main. Unrelated to visualize. * feat(visualize): build the wikilink graph + render the self-contained HTML build_graph(wiki_dir) walks summaries/concepts/entities, collects nodes (id/label/type/description/sources + in/out degree), resolves [[wikilinks]] to edges (reusing lint._extract_wikilinks/_normalize_target and frontmatter.parse; dirs from schema.PAGE_CONTENT_DIRS), and drops broken/self/duplicate links. render_html injects the graph as JSON into the template (escaping </ so it can't break out of <script>). * feat(cli): add the visualize command Thin read-only command mirroring the other commands' decorators + KB lock: resolve KB -> build_graph -> render_html -> write output/visualize/graph.html. Opens in the browser by default (--no-open for headless), resolve()s the path for a valid file URI, and degrades with a hint if no browser launches. * feat(visualize): self-contained interactive graph template (3D / mind-map / radial) One offline HTML page (canvas + DOM, no CDN/network) with three switchable views of the same KB: a 3D force 'nebula' (default; glow, degree-sized nodes, flow particles, idle auto-rotation), an OpenKB-rooted horizontal mind-map (collapsible provenance tree), and a radial OpenKB-centred circle with faint cross-references. Shared: glass inspector panel, search, legend type-filter, spacing slider, reset, smooth auto-fit. Neon-on-dark aurora. * test(visualize): build_graph + render_html + CLI tests build_graph (nodes/edges/types, broken-link drop, orphan, degree, provenance sources incl. summary full_text); render_html self-contained (canvas, JSON embedded, no http(s), unicode round-trip); CLI writes output/visualize/ graph.html, opens by default, --no-open suppresses, empty wiki writes nothing. * docs(readme): document openkb visualize Add it to the Layer 2 generators table and a '(iii) Visualize' subsection matching the existing (i)/(ii) generator sections: a self-contained interactive knowledge graph (3D / mind-map / radial) written to output/visualize/graph.html.
1 parent a65b612 commit 46c13f7

7 files changed

Lines changed: 1125 additions & 2 deletions

File tree

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ A "generator" reads from the compiled wiki and produces something usable: an ans
206206
|---|---|
207207
| <code>openkb&nbsp;query&nbsp;"question"</code> | A grounded answer with citations (`--save` to persist to `wiki/explorations/`) |
208208
| <code>openkb&nbsp;chat</code> | Interactive multi-turn session over the wiki (`--resume`, `--list`, `--delete` to manage sessions) |
209+
| <code>openkb&nbsp;visualize</code> | A self-contained interactive knowledge graph at `output/visualize/graph.html` — 3D, mind-map, and radial views |
209210
| | |
210211
| <code>openkb&nbsp;skill&nbsp;new&nbsp;&lt;skill-name&gt;&nbsp;"&lt;intent&gt;"</code> | Distill a redistributable agent skill from your wiki (see [Skill Factory](#-skill-factory--drop-in-a-book-out-comes-a-digital-expert) below) |
211212

@@ -339,6 +340,14 @@ openkb skill rollback karpathy-thinking --to 2
339340

340341
</details>
341342

343+
### (iii) 🗺 Visualize — *see the shape of your knowledge*
344+
345+
`openkb visualize` renders the wiki as a single self-contained, offline HTML page with three views of the same knowledge base — a **3D** force graph, an OpenKB-rooted **mind-map**, and a **radial** tree — coloured by type and linked by `[[wikilinks]]`.
346+
347+
```bash
348+
openkb visualize # build + open output/visualize/graph.html
349+
```
350+
342351
# 🔧 Configuration
343352

344353
### Settings

openkb/cli.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1494,6 +1494,36 @@ def lint(ctx, fix):
14941494
asyncio.run(run_lint(kb_dir))
14951495

14961496

1497+
@cli.command()
1498+
@click.option("--open/--no-open", "open_browser", default=True,
1499+
help="Open the graph in your browser after generating (default: on; --no-open for headless).")
1500+
@click.pass_context
1501+
@_with_kb_lock(exclusive=False)
1502+
def visualize(ctx, open_browser):
1503+
"""Render the wiki's [[wikilink]] graph as a self-contained interactive HTML page."""
1504+
kb_dir = _find_kb_dir(ctx.obj.get("kb_dir_override"))
1505+
if kb_dir is None:
1506+
click.echo("No knowledge base found. Run `openkb init` first.")
1507+
return
1508+
from openkb import visualize as viz
1509+
graph = viz.build_graph(kb_dir / "wiki")
1510+
if not graph["nodes"]:
1511+
click.echo("No wiki pages to visualize yet. Run `openkb add` first.")
1512+
return
1513+
out = kb_dir / "output" / "visualize" / "graph.html"
1514+
out.parent.mkdir(parents=True, exist_ok=True)
1515+
out.write_text(viz.render_html(graph), encoding="utf-8")
1516+
click.echo(f"Graph written to {out} ({len(graph['nodes'])} nodes, {len(graph['edges'])} edges)")
1517+
if open_browser:
1518+
import webbrowser
1519+
try:
1520+
opened = webbrowser.open(out.resolve().as_uri()) # resolve() so a relative --kb-dir still yields a valid file URI
1521+
except Exception:
1522+
opened = False
1523+
if not opened:
1524+
click.echo("(couldn't launch a browser — open the file above manually, or use --no-open)")
1525+
1526+
14971527
def print_list(kb_dir: Path) -> None:
14981528
"""Print all documents in the knowledge base. Usable from CLI and chat REPL."""
14991529
openkb_dir = kb_dir / ".openkb"

openkb/templates/graph.html

Lines changed: 907 additions & 0 deletions
Large diffs are not rendered by default.

openkb/visualize.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""Render the wiki's [[wikilink]] graph as a self-contained interactive HTML page."""
2+
from __future__ import annotations
3+
4+
import json
5+
from importlib import resources
6+
from pathlib import Path
7+
8+
from openkb import frontmatter
9+
from openkb.lint import _extract_wikilinks, _normalize_target
10+
from openkb.schema import PAGE_CONTENT_DIRS
11+
12+
# Singular display type per content dir; falls back to a derived name for any
13+
# dir not listed (so a new PAGE_CONTENT_DIRS entry never KeyErrors here).
14+
_DIR_TYPE = {"summaries": "Summary", "concepts": "Concept", "entities": "Entity"}
15+
16+
17+
def _type_for_dir(sub: str) -> str:
18+
return _DIR_TYPE.get(sub) or (sub[:-1] if sub.endswith("s") else sub).capitalize() or sub
19+
20+
21+
def build_graph(wiki_dir: Path) -> dict:
22+
"""Collect nodes (pages), directed edges (wikilinks), and the set of types."""
23+
nodes: dict[str, dict] = {}
24+
texts: dict[str, str] = {} # nid -> file text, read once and reused for edges
25+
for sub in PAGE_CONTENT_DIRS:
26+
d = wiki_dir / sub
27+
if not d.exists():
28+
continue
29+
for p in sorted(d.glob("*.md")):
30+
nid = f"{sub}/{p.stem}"
31+
text = p.read_text(encoding="utf-8")
32+
texts[nid] = text
33+
fm = frontmatter.parse(text)
34+
t = fm.get("type")
35+
t = t.strip() if isinstance(t, str) and t.strip() else _type_for_dir(sub)
36+
desc = fm.get("description")
37+
desc = desc.strip() if isinstance(desc, str) else ""
38+
srcs = fm.get("sources")
39+
srcs = [str(s) for s in srcs] if isinstance(srcs, list) else []
40+
ft = fm.get("full_text") # summaries record their origin document here, not in `sources`
41+
if isinstance(ft, str) and ft.strip():
42+
srcs.insert(0, ft.strip())
43+
nodes[nid] = {"id": nid, "label": p.stem, "type": t,
44+
"description": desc, "sources": srcs, "out": 0, "in": 0}
45+
46+
norm = {_normalize_target(nid): nid for nid in nodes}
47+
edges: list[dict] = []
48+
seen: set[tuple[str, str]] = set()
49+
for src, text in texts.items():
50+
for raw in _extract_wikilinks(text):
51+
tgt = norm.get(_normalize_target(raw))
52+
if not tgt or tgt == src or (src, tgt) in seen:
53+
continue
54+
seen.add((src, tgt))
55+
edges.append({"source": src, "target": tgt})
56+
nodes[src]["out"] += 1
57+
nodes[tgt]["in"] += 1
58+
59+
types = sorted({n["type"] for n in nodes.values()})
60+
return {"nodes": list(nodes.values()), "edges": edges, "types": types}
61+
62+
63+
def render_html(graph: dict) -> str:
64+
"""Inject the graph as JSON into the self-contained HTML template."""
65+
template = resources.files("openkb").joinpath("templates/graph.html").read_text(encoding="utf-8")
66+
data = json.dumps(graph, ensure_ascii=False).replace("</", "<\\/") # avoid </script> breakout
67+
return template.replace("__GRAPH_DATA__", data)

tests/test_generator.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ async def test_generator_deck_dispatches_to_deck_creator(tmp_path):
8585
# validation up to self.validation.
8686
from openkb.agent.skill_runner import SkillRunResult
8787
fake_run_result = SkillRunResult(
88-
skill_name="openkb-deck-editorial",
88+
skill_name="openkb-deck-neon",
8989
output_path=gen.output_dir / "index.html",
9090
validation=DeckValidationResult(),
9191
metadata={"mode": "deck"},
@@ -102,7 +102,7 @@ async def test_generator_deck_dispatches_to_deck_creator(tmp_path):
102102
intent="…",
103103
model="openai/gpt-4o",
104104
critique=False,
105-
skill_name="openkb-deck-editorial",
105+
skill_name="openkb-deck-neon",
106106
)
107107
regen.assert_not_called() # marketplace is skill-only
108108
assert result == gen.output_dir

tests/test_visualize.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
from pathlib import Path
2+
3+
from openkb.visualize import build_graph, render_html
4+
5+
6+
def _wiki(tmp_path: Path) -> Path:
7+
wiki = tmp_path / "wiki"
8+
for sub in ("summaries", "concepts", "entities", "reports", "sources"):
9+
(wiki / sub).mkdir(parents=True)
10+
(wiki / "index.md").write_text("# Index\n", encoding="utf-8")
11+
return wiki
12+
13+
14+
def test_build_graph_nodes_edges_types(tmp_path):
15+
wiki = _wiki(tmp_path)
16+
(wiki / "summaries" / "paper.md").write_text(
17+
'---\ntype: "Summary"\ndescription: "A paper."\nfull_text: "sources/paper.json"\n---\n\n'
18+
"Discusses [[concepts/attention]] and [[entities/anthropic]].\n", encoding="utf-8")
19+
(wiki / "concepts" / "attention.md").write_text(
20+
'---\ntype: "Concept"\ndescription: "Focus."\nsources: ["summaries/paper"]\n---\n\n'
21+
"Used by [[concepts/attention]] (self) and [[concepts/missing]] (broken).\n", encoding="utf-8")
22+
(wiki / "entities" / "anthropic.md").write_text(
23+
'---\ntype: "Organization"\ndescription: "AI lab."\n---\n\n'
24+
"# Anthropic\n", encoding="utf-8")
25+
(wiki / "concepts" / "orphan.md").write_text("# Orphan\n\nNo links.\n", encoding="utf-8")
26+
27+
g = build_graph(wiki)
28+
ids = {n["id"] for n in g["nodes"]}
29+
assert ids == {"summaries/paper", "concepts/attention", "entities/anthropic", "concepts/orphan"}
30+
by = {n["id"]: n for n in g["nodes"]}
31+
assert by["concepts/orphan"]["type"] == "Concept"
32+
assert by["entities/anthropic"]["type"] == "Organization"
33+
edge_pairs = {(e["source"], e["target"]) for e in g["edges"]}
34+
assert ("summaries/paper", "concepts/attention") in edge_pairs
35+
assert ("summaries/paper", "entities/anthropic") in edge_pairs
36+
assert not any(e["target"] == "concepts/missing" for e in g["edges"])
37+
assert not any(e["source"] == e["target"] for e in g["edges"])
38+
assert by["concepts/attention"]["in"] == 1 and by["summaries/paper"]["out"] == 2
39+
assert g["types"] == ["Concept", "Organization", "Summary"]
40+
# sources: concepts use the `sources` field; summaries fall back to `full_text` (the origin doc)
41+
assert by["concepts/attention"]["sources"] == ["summaries/paper"]
42+
assert by["summaries/paper"]["sources"] == ["sources/paper.json"]
43+
44+
45+
def test_build_graph_empty_wiki(tmp_path):
46+
assert build_graph(_wiki(tmp_path)) == {"nodes": [], "edges": [], "types": []}
47+
48+
49+
def test_render_html_self_contained():
50+
g = {"nodes":[{"id":"concepts/a","label":"a","type":"Concept","description":"x—y","sources":[],"out":0,"in":0}],
51+
"edges":[], "types":["Concept"]}
52+
html = render_html(g)
53+
assert "<canvas" in html
54+
assert '"concepts/a"' in html and '"Concept"' in html
55+
assert "__GRAPH_DATA__" not in html
56+
assert "x—y" in html
57+
assert "http://" not in html and "https://" not in html

tests/test_visualize_cli.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
from pathlib import Path
2+
from unittest.mock import patch
3+
from click.testing import CliRunner
4+
from openkb.cli import cli
5+
6+
7+
def _kb(tmp_path: Path) -> Path:
8+
for sub in ("summaries", "concepts", "entities"):
9+
(tmp_path / "wiki" / sub).mkdir(parents=True)
10+
(tmp_path / ".openkb").mkdir()
11+
(tmp_path / ".openkb" / "config.yaml").write_text("model: gpt-4o-mini\n", encoding="utf-8")
12+
(tmp_path / "wiki" / "concepts" / "a.md").write_text(
13+
'---\ntype: "Concept"\ndescription: "d"\n---\n\nlinks [[concepts/b]]\n', encoding="utf-8")
14+
(tmp_path / "wiki" / "concepts" / "b.md").write_text(
15+
'---\ntype: "Concept"\ndescription: "d2"\n---\n\n# B\n', encoding="utf-8")
16+
return tmp_path
17+
18+
19+
def test_visualize_writes_html_and_opens_by_default(tmp_path):
20+
kb = _kb(tmp_path)
21+
with patch("openkb.cli._find_kb_dir", return_value=kb), \
22+
patch("webbrowser.open") as wb:
23+
result = CliRunner().invoke(cli, ["visualize"])
24+
assert result.exit_code == 0, result.output
25+
out = kb / "output" / "visualize" / "graph.html"
26+
assert out.exists()
27+
html = out.read_text(encoding="utf-8")
28+
assert "<canvas" in html and '"concepts/a"' in html
29+
wb.assert_called_once() # auto-opens the browser by default
30+
31+
32+
def test_visualize_no_open_suppresses_browser(tmp_path):
33+
kb = _kb(tmp_path)
34+
with patch("openkb.cli._find_kb_dir", return_value=kb), \
35+
patch("webbrowser.open") as wb:
36+
result = CliRunner().invoke(cli, ["visualize", "--no-open"])
37+
assert result.exit_code == 0, result.output
38+
assert (kb / "output" / "visualize" / "graph.html").exists()
39+
wb.assert_not_called() # --no-open keeps it headless-friendly
40+
41+
42+
def test_visualize_empty_wiki(tmp_path):
43+
for sub in ("summaries", "concepts", "entities"):
44+
(tmp_path / "wiki" / sub).mkdir(parents=True)
45+
(tmp_path / ".openkb").mkdir()
46+
(tmp_path / ".openkb" / "config.yaml").write_text("model: gpt-4o-mini\n", encoding="utf-8")
47+
with patch("openkb.cli._find_kb_dir", return_value=tmp_path), \
48+
patch("webbrowser.open") as wb:
49+
result = CliRunner().invoke(cli, ["visualize"])
50+
assert result.exit_code == 0
51+
assert "No wiki pages" in result.output
52+
assert not (tmp_path / "output" / "visualize" / "graph.html").exists()
53+
wb.assert_not_called() # nothing to show → no browser

0 commit comments

Comments
 (0)