Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ A "generator" reads from the compiled wiki and produces something usable: an ans
|---|---|
| <code>openkb&nbsp;query&nbsp;"question"</code> | A grounded answer with citations (`--save` to persist to `wiki/explorations/`) |
| <code>openkb&nbsp;chat</code> | Interactive multi-turn session over the wiki (`--resume`, `--list`, `--delete` to manage sessions) |
| <code>openkb&nbsp;visualize</code> | A self-contained interactive knowledge graph at `output/visualize/graph.html` — 3D, mind-map, and radial views |
| | |
| <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) |

Expand Down Expand Up @@ -339,6 +340,14 @@ openkb skill rollback karpathy-thinking --to 2

</details>

### (iii) 🗺 Visualize — *see the shape of your knowledge*

`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]]`.

```bash
openkb visualize # build + open output/visualize/graph.html
```

# 🔧 Configuration

### Settings
Expand Down
30 changes: 30 additions & 0 deletions openkb/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1494,6 +1494,36 @@ def lint(ctx, fix):
asyncio.run(run_lint(kb_dir))


@cli.command()
@click.option("--open/--no-open", "open_browser", default=True,
help="Open the graph in your browser after generating (default: on; --no-open for headless).")
@click.pass_context
@_with_kb_lock(exclusive=False)
def visualize(ctx, open_browser):
"""Render the wiki's [[wikilink]] graph as a self-contained interactive HTML page."""
kb_dir = _find_kb_dir(ctx.obj.get("kb_dir_override"))
if kb_dir is None:
click.echo("No knowledge base found. Run `openkb init` first.")
return
from openkb import visualize as viz
graph = viz.build_graph(kb_dir / "wiki")
if not graph["nodes"]:
click.echo("No wiki pages to visualize yet. Run `openkb add` first.")
return
out = kb_dir / "output" / "visualize" / "graph.html"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(viz.render_html(graph), encoding="utf-8")
click.echo(f"Graph written to {out} ({len(graph['nodes'])} nodes, {len(graph['edges'])} edges)")
if open_browser:
import webbrowser
try:
opened = webbrowser.open(out.resolve().as_uri()) # resolve() so a relative --kb-dir still yields a valid file URI
except Exception:
opened = False
if not opened:
click.echo("(couldn't launch a browser — open the file above manually, or use --no-open)")


def print_list(kb_dir: Path) -> None:
"""Print all documents in the knowledge base. Usable from CLI and chat REPL."""
openkb_dir = kb_dir / ".openkb"
Expand Down
907 changes: 907 additions & 0 deletions openkb/templates/graph.html

Large diffs are not rendered by default.

67 changes: 67 additions & 0 deletions openkb/visualize.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""Render the wiki's [[wikilink]] graph as a self-contained interactive HTML page."""
from __future__ import annotations

import json
from importlib import resources
from pathlib import Path

from openkb import frontmatter
from openkb.lint import _extract_wikilinks, _normalize_target
from openkb.schema import PAGE_CONTENT_DIRS

# Singular display type per content dir; falls back to a derived name for any
# dir not listed (so a new PAGE_CONTENT_DIRS entry never KeyErrors here).
_DIR_TYPE = {"summaries": "Summary", "concepts": "Concept", "entities": "Entity"}


def _type_for_dir(sub: str) -> str:
return _DIR_TYPE.get(sub) or (sub[:-1] if sub.endswith("s") else sub).capitalize() or sub


def build_graph(wiki_dir: Path) -> dict:
"""Collect nodes (pages), directed edges (wikilinks), and the set of types."""
nodes: dict[str, dict] = {}
texts: dict[str, str] = {} # nid -> file text, read once and reused for edges
for sub in PAGE_CONTENT_DIRS:
d = wiki_dir / sub
if not d.exists():
continue
for p in sorted(d.glob("*.md")):
nid = f"{sub}/{p.stem}"
text = p.read_text(encoding="utf-8")
texts[nid] = text
fm = frontmatter.parse(text)
t = fm.get("type")
t = t.strip() if isinstance(t, str) and t.strip() else _type_for_dir(sub)
desc = fm.get("description")
desc = desc.strip() if isinstance(desc, str) else ""
srcs = fm.get("sources")
srcs = [str(s) for s in srcs] if isinstance(srcs, list) else []
ft = fm.get("full_text") # summaries record their origin document here, not in `sources`
if isinstance(ft, str) and ft.strip():
srcs.insert(0, ft.strip())
nodes[nid] = {"id": nid, "label": p.stem, "type": t,
"description": desc, "sources": srcs, "out": 0, "in": 0}

norm = {_normalize_target(nid): nid for nid in nodes}
edges: list[dict] = []
seen: set[tuple[str, str]] = set()
for src, text in texts.items():
for raw in _extract_wikilinks(text):
tgt = norm.get(_normalize_target(raw))
if not tgt or tgt == src or (src, tgt) in seen:
continue
seen.add((src, tgt))
edges.append({"source": src, "target": tgt})
nodes[src]["out"] += 1
nodes[tgt]["in"] += 1

types = sorted({n["type"] for n in nodes.values()})
return {"nodes": list(nodes.values()), "edges": edges, "types": types}


def render_html(graph: dict) -> str:
"""Inject the graph as JSON into the self-contained HTML template."""
template = resources.files("openkb").joinpath("templates/graph.html").read_text(encoding="utf-8")
data = json.dumps(graph, ensure_ascii=False).replace("</", "<\\/") # avoid </script> breakout
return template.replace("__GRAPH_DATA__", data)
4 changes: 2 additions & 2 deletions tests/test_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ async def test_generator_deck_dispatches_to_deck_creator(tmp_path):
# validation up to self.validation.
from openkb.agent.skill_runner import SkillRunResult
fake_run_result = SkillRunResult(
skill_name="openkb-deck-editorial",
skill_name="openkb-deck-neon",
output_path=gen.output_dir / "index.html",
validation=DeckValidationResult(),
metadata={"mode": "deck"},
Expand All @@ -102,7 +102,7 @@ async def test_generator_deck_dispatches_to_deck_creator(tmp_path):
intent="…",
model="openai/gpt-4o",
critique=False,
skill_name="openkb-deck-editorial",
skill_name="openkb-deck-neon",
)
regen.assert_not_called() # marketplace is skill-only
assert result == gen.output_dir
Expand Down
57 changes: 57 additions & 0 deletions tests/test_visualize.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
from pathlib import Path

from openkb.visualize import build_graph, render_html


def _wiki(tmp_path: Path) -> Path:
wiki = tmp_path / "wiki"
for sub in ("summaries", "concepts", "entities", "reports", "sources"):
(wiki / sub).mkdir(parents=True)
(wiki / "index.md").write_text("# Index\n", encoding="utf-8")
return wiki


def test_build_graph_nodes_edges_types(tmp_path):
wiki = _wiki(tmp_path)
(wiki / "summaries" / "paper.md").write_text(
'---\ntype: "Summary"\ndescription: "A paper."\nfull_text: "sources/paper.json"\n---\n\n'
"Discusses [[concepts/attention]] and [[entities/anthropic]].\n", encoding="utf-8")
(wiki / "concepts" / "attention.md").write_text(
'---\ntype: "Concept"\ndescription: "Focus."\nsources: ["summaries/paper"]\n---\n\n'
"Used by [[concepts/attention]] (self) and [[concepts/missing]] (broken).\n", encoding="utf-8")
(wiki / "entities" / "anthropic.md").write_text(
'---\ntype: "Organization"\ndescription: "AI lab."\n---\n\n'
"# Anthropic\n", encoding="utf-8")
(wiki / "concepts" / "orphan.md").write_text("# Orphan\n\nNo links.\n", encoding="utf-8")

g = build_graph(wiki)
ids = {n["id"] for n in g["nodes"]}
assert ids == {"summaries/paper", "concepts/attention", "entities/anthropic", "concepts/orphan"}
by = {n["id"]: n for n in g["nodes"]}
assert by["concepts/orphan"]["type"] == "Concept"
assert by["entities/anthropic"]["type"] == "Organization"
edge_pairs = {(e["source"], e["target"]) for e in g["edges"]}
assert ("summaries/paper", "concepts/attention") in edge_pairs
assert ("summaries/paper", "entities/anthropic") in edge_pairs
assert not any(e["target"] == "concepts/missing" for e in g["edges"])
assert not any(e["source"] == e["target"] for e in g["edges"])
assert by["concepts/attention"]["in"] == 1 and by["summaries/paper"]["out"] == 2
assert g["types"] == ["Concept", "Organization", "Summary"]
# sources: concepts use the `sources` field; summaries fall back to `full_text` (the origin doc)
assert by["concepts/attention"]["sources"] == ["summaries/paper"]
assert by["summaries/paper"]["sources"] == ["sources/paper.json"]


def test_build_graph_empty_wiki(tmp_path):
assert build_graph(_wiki(tmp_path)) == {"nodes": [], "edges": [], "types": []}


def test_render_html_self_contained():
g = {"nodes":[{"id":"concepts/a","label":"a","type":"Concept","description":"x—y","sources":[],"out":0,"in":0}],
"edges":[], "types":["Concept"]}
html = render_html(g)
assert "<canvas" in html
assert '"concepts/a"' in html and '"Concept"' in html
assert "__GRAPH_DATA__" not in html
assert "x—y" in html
assert "http://" not in html and "https://" not in html
53 changes: 53 additions & 0 deletions tests/test_visualize_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from pathlib import Path
from unittest.mock import patch
from click.testing import CliRunner
from openkb.cli import cli


def _kb(tmp_path: Path) -> Path:
for sub in ("summaries", "concepts", "entities"):
(tmp_path / "wiki" / sub).mkdir(parents=True)
(tmp_path / ".openkb").mkdir()
(tmp_path / ".openkb" / "config.yaml").write_text("model: gpt-4o-mini\n", encoding="utf-8")
(tmp_path / "wiki" / "concepts" / "a.md").write_text(
'---\ntype: "Concept"\ndescription: "d"\n---\n\nlinks [[concepts/b]]\n', encoding="utf-8")
(tmp_path / "wiki" / "concepts" / "b.md").write_text(
'---\ntype: "Concept"\ndescription: "d2"\n---\n\n# B\n', encoding="utf-8")
return tmp_path


def test_visualize_writes_html_and_opens_by_default(tmp_path):
kb = _kb(tmp_path)
with patch("openkb.cli._find_kb_dir", return_value=kb), \
patch("webbrowser.open") as wb:
result = CliRunner().invoke(cli, ["visualize"])
assert result.exit_code == 0, result.output
out = kb / "output" / "visualize" / "graph.html"
assert out.exists()
html = out.read_text(encoding="utf-8")
assert "<canvas" in html and '"concepts/a"' in html
wb.assert_called_once() # auto-opens the browser by default


def test_visualize_no_open_suppresses_browser(tmp_path):
kb = _kb(tmp_path)
with patch("openkb.cli._find_kb_dir", return_value=kb), \
patch("webbrowser.open") as wb:
result = CliRunner().invoke(cli, ["visualize", "--no-open"])
assert result.exit_code == 0, result.output
assert (kb / "output" / "visualize" / "graph.html").exists()
wb.assert_not_called() # --no-open keeps it headless-friendly


def test_visualize_empty_wiki(tmp_path):
for sub in ("summaries", "concepts", "entities"):
(tmp_path / "wiki" / sub).mkdir(parents=True)
(tmp_path / ".openkb").mkdir()
(tmp_path / ".openkb" / "config.yaml").write_text("model: gpt-4o-mini\n", encoding="utf-8")
with patch("openkb.cli._find_kb_dir", return_value=tmp_path), \
patch("webbrowser.open") as wb:
result = CliRunner().invoke(cli, ["visualize"])
assert result.exit_code == 0
assert "No wiki pages" in result.output
assert not (tmp_path / "output" / "visualize" / "graph.html").exists()
wb.assert_not_called() # nothing to show → no browser