Skip to content

Commit dfe2cf9

Browse files
committed
Apply /review findings for the toolkit
- propose.apply(): preserve pre-frontmatter content byte-for-byte; handle empty frontmatter; Proposal carries target_iri resolved at propose time (single IRI derivation, idempotent re-runs); link regex handles title attributes and angle-bracket targets; cues added for wasAttributedTo and joinsWith with a coverage test; dead 'see also' alternative dropped - cli: --json composes with --apply (rows gain "applied"); single-member subcommand registry inlined - schema: resolution is now ancestors-first (checkout edits always win) with the packaged copy as fallback; zip-safe packaged lookup; Vocabulary.subclasses_of(); drift-guard + resolution-order tests - model: O(1) Bundle.get via cached IRI index; graph() parses once via a single @graph document; build.py assemble/to_rdf ported onto the Bundle API (bundle.json verified byte-identical; .nt graphs isomorphic) and the packaged-data refresh guarded to the lokf repo itself - export: reified relations flagged (reified=true) and page copy states the flattening honestly; scalar relations entries skipped; JSON-LD resolves relative refs and uses the passed vocabulary; hook caches the bundle once per build and emits meta.source_base for the page's source links (no hardcoded GitHub URL in JS) - graph page: init driven via document$ with an on-demand cytoscape loader — renders on direct load, instant-nav arrival, and repeated visits (browser-verified) - toolkit docs: resolution-order and reified-RDF claims corrected; pip lockfile caveat; tutorial validate block embeds the README snippet
1 parent 25ba42e commit dfe2cf9

14 files changed

Lines changed: 568 additions & 170 deletions

File tree

docs/graph.md

Lines changed: 72 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ Every edge below is an **RDF predicate** drawn from a concept's typed
44
frontmatter relations — labels like `prov:wasDerivedFrom`, `dcterms:requires`,
55
or `lokf:measures` (compacted through the LOKF
66
[vocabulary](guide/relationships.md)). Plain markdown links in a concept's
7-
*body* are **not** edges: only the typed relations that project to RDF triples
8-
appear here, so this graph is exactly the concept-to-concept subset of the
9-
[bundle's RDF projection](examples.md).
7+
*body* are **not** edges. Named typed relations appear exactly as in the
8+
[bundle's RDF projection](examples.md); reified `relations:` entries are
9+
flattened to labeled edges for display (in RDF they are reified statements).
1010

1111
The data is the [Acme knowledge bundle](examples.md) — six concepts, eight
1212
typed relations. Search dims non-matching nodes; the filters toggle node types
@@ -100,27 +100,74 @@ and predicates; click a node for its details.
100100
}
101101
</style>
102102

103-
<script src="../assets/js/cytoscape.min.js"></script>
104103
<script>
105104
(function () {
106-
var mount = document.getElementById("lokf-cy");
107-
if (!mount || typeof cytoscape === "undefined") { return; }
105+
// Material's navigation.instant swaps page content and re-executes these
106+
// scripts without waiting for external <script src> tags, so the vendored
107+
// cytoscape build is loaded on demand behind a shared promise, and init is
108+
// driven off document$ (re-emitted on every SPA navigation) when available.
109+
var CY_SRC = "../assets/js/cytoscape.min.js";
110+
var DATA_SRC = "../assets/graph.json";
108111
// Small type -> color palette; nodes without a known type fall through.
109112
var PALETTE = [
110113
"#5b8def", "#e8833a", "#2ea77d", "#c1558b",
111114
"#9b6dd6", "#d2b13a", "#4bacc6", "#d05b5b"
112115
];
113-
var SRC_BASE =
114-
"https://github.com/nicholsn/lokf/tree/main/examples/acme-knowledge/";
115116

116-
fetch("../assets/graph.json")
117-
.then(function (r) { return r.json(); })
118-
.then(function (graph) { render(graph); })
119-
.catch(function (e) {
120-
mount.textContent = "Could not load graph data: " + e;
117+
function ensureCytoscape() {
118+
if (window.cytoscape) { return Promise.resolve(); }
119+
if (!window.__lokfCyLoad) {
120+
window.__lokfCyLoad = new Promise(function (resolve, reject) {
121+
var s = document.createElement("script");
122+
s.src = CY_SRC;
123+
s.onload = function () { resolve(); };
124+
s.onerror = function () {
125+
window.__lokfCyLoad = null;
126+
reject(new Error("could not load " + CY_SRC));
127+
};
128+
document.head.appendChild(s);
129+
});
130+
}
131+
return window.__lokfCyLoad;
132+
}
133+
134+
function boot() {
135+
var mount = document.getElementById("lokf-cy");
136+
if (!mount || mount.dataset.lokfInit) { return; }
137+
mount.dataset.lokfInit = "1";
138+
Promise.all([
139+
ensureCytoscape(),
140+
fetch(DATA_SRC).then(function (r) {
141+
if (!r.ok) { throw new Error(r.status + " " + r.statusText); }
142+
return r.json();
143+
})
144+
]).then(function (results) {
145+
if (!mount.isConnected) { return; } // page swapped away while loading
146+
if (window.__lokfCy) {
147+
try { window.__lokfCy.destroy(); } catch (e) { /* already gone */ }
148+
window.__lokfCy = null;
149+
}
150+
window.__lokfCy = render(mount, results[1]);
151+
}).catch(function (e) {
152+
mount.textContent = "Could not load graph: " + (e.message || e);
121153
});
154+
}
122155

123-
function render(graph) {
156+
// Subscribe to document$ exactly once (this script re-runs per instant-nav
157+
// visit); on themes without it, fall back to plain page-load events. All
158+
// other listeners live on elements inside the swapped content (or on the
159+
// cy instance we destroy), so they die with the old page.
160+
if (window.document$ && typeof window.document$.subscribe === "function") {
161+
if (!window.__lokfGraphSub) {
162+
window.__lokfGraphSub = window.document$.subscribe(boot);
163+
}
164+
} else if (document.readyState === "loading") {
165+
document.addEventListener("DOMContentLoaded", boot, { once: true });
166+
} else {
167+
boot();
168+
}
169+
170+
function render(mount, graph) {
124171
var types = [];
125172
graph.nodes.forEach(function (n) {
126173
if (types.indexOf(n.data.type) === -1) { types.push(n.data.type); }
@@ -155,7 +202,7 @@ and predicates; click a node for its details.
155202

156203
var cy = cytoscape({
157204
container: mount,
158-
elements: graph,
205+
elements: { nodes: graph.nodes, edges: graph.edges },
159206
layout: { name: "cose", padding: 30, animate: false,
160207
nodeRepulsion: 9000, idealEdgeLength: 130 },
161208
style: [
@@ -193,7 +240,8 @@ and predicates; click a node for its details.
193240
buildTypeFilter(cy, types, colorOf);
194241
buildPredicateFilter(cy, predicates);
195242
wireSearch(cy);
196-
wireDetail(cy);
243+
wireDetail(cy, graph.meta);
244+
return cy;
197245
}
198246

199247
function buildTypeFilter(cy, types, colorOf) {
@@ -262,7 +310,8 @@ and predicates; click a node for its details.
262310
});
263311
}
264312

265-
function wireDetail(cy) {
313+
function wireDetail(cy, meta) {
314+
var srcBase = meta && meta.source_base;
266315
var panel = document.getElementById("lokf-detail");
267316
var close = document.getElementById("lokf-detail-close");
268317
cy.on("tap", "node", function (evt) {
@@ -272,7 +321,12 @@ and predicates; click a node for its details.
272321
document.getElementById("lokf-detail-iri").textContent = d.id;
273322
document.getElementById("lokf-detail-cid").textContent = d.concept_id;
274323
var src = document.getElementById("lokf-detail-src");
275-
src.href = SRC_BASE + d.concept_id + ".md";
324+
if (srcBase) {
325+
src.href = srcBase + d.concept_id + ".md";
326+
src.hidden = false;
327+
} else {
328+
src.hidden = true; // no repo_url configured: nothing to link to
329+
}
276330
panel.hidden = false;
277331
});
278332
cy.on("tap", function (evt) {

docs/hooks/lokf_hooks.py

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
Runs in CI where ``lokf`` is not installed and ``rdflib`` is absent, so the
44
repo's ``src`` directory is put on ``sys.path`` and all logic lives in
55
``lokf.export`` (which derives everything from frontmatter + Vocabulary).
6+
7+
The bundle and its projections are computed at most once per build: the cache
8+
is reset in ``on_config`` and filled lazily, so ``on_post_page`` (which fires
9+
per JSON-LD page) and ``on_post_build`` share one bundle load.
610
"""
711
from __future__ import annotations
812

@@ -19,25 +23,59 @@
1923
_BUNDLE = _REPO / "examples" / "acme-knowledge"
2024
_JSONLD_PAGES = ("examples.md", "graph.md")
2125

26+
_cache: dict = {}
27+
28+
29+
def on_config(config, **kwargs):
30+
"""Reset the per-build cache; remember ``repo_url`` for graph metadata."""
31+
_cache.clear()
32+
_cache["repo_url"] = (config.get("repo_url") or "").rstrip("/")
33+
return config
34+
35+
36+
def _bundle():
37+
if "bundle" not in _cache:
38+
_cache["bundle"] = load_bundle(_BUNDLE)
39+
return _cache["bundle"]
2240

23-
def on_post_build(config, **kwargs):
24-
"""Write ``site/assets/graph.json`` from the example bundle's graph."""
25-
out = pathlib.Path(config["site_dir"]) / "assets" / "graph.json"
26-
out.parent.mkdir(parents=True, exist_ok=True)
27-
graph = to_cytoscape(load_bundle(_BUNDLE))
28-
out.write_text(json.dumps(graph, indent=2), encoding="utf-8")
41+
42+
def _graph_json() -> str:
43+
"""The cytoscape graph + ``meta.source_base`` as a JSON string."""
44+
if "graph_json" not in _cache:
45+
graph = to_cytoscape(_bundle())
46+
meta = {}
47+
repo_url = _cache.get("repo_url", "")
48+
if repo_url:
49+
rel = _BUNDLE.relative_to(_REPO).as_posix()
50+
meta["source_base"] = f"{repo_url}/tree/main/{rel}/"
51+
graph["meta"] = meta
52+
_cache["graph_json"] = json.dumps(graph, indent=2)
53+
return _cache["graph_json"]
54+
55+
56+
def _jsonld_blocks() -> str:
57+
if "jsonld_blocks" not in _cache:
58+
_cache["jsonld_blocks"] = "\n".join(
59+
'<script type="application/ld+json">' + json.dumps(doc) + "</script>"
60+
for doc in dataset_search_jsonld(_bundle())
61+
)
62+
return _cache["jsonld_blocks"]
2963

3064

3165
def on_post_page(output, page, config, **kwargs):
3266
"""Inject schema.org Dataset JSON-LD into the dataset-bearing pages."""
3367
if page.file.src_uri not in _JSONLD_PAGES:
3468
return output
35-
blocks = "\n".join(
36-
'<script type="application/ld+json">' + json.dumps(doc) + "</script>"
37-
for doc in dataset_search_jsonld(load_bundle(_BUNDLE))
38-
)
69+
blocks = _jsonld_blocks()
3970
if not blocks:
4071
return output
4172
if "</article>" in output:
4273
return output.replace("</article>", blocks + "\n</article>", 1)
4374
return output + blocks
75+
76+
77+
def on_post_build(config, **kwargs):
78+
"""Write ``site/assets/graph.json`` from the example bundle's graph."""
79+
out = pathlib.Path(config["site_dir"]) / "assets" / "graph.json"
80+
out.parent.mkdir(parents=True, exist_ok=True)
81+
out.write_text(_graph_json(), encoding="utf-8")

docs/toolkit/index.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ never hardcodes a type or predicate it can read from the schema.
3535
pip install git+https://github.com/nicholsn/lokf
3636
```
3737

38+
Note that pip bypasses `uv.lock` (the locked toolchain) — fine for
39+
consumers of the package; contributors should use `uv sync`.
40+
3841
Working inside a clone of this repository? `uv sync` already installs the
3942
package in editable mode — see [Getting started](../getting-started.md).
4043

@@ -80,9 +83,11 @@ vocab.expand("prov:wasDerivedFrom") # -> 'http://www.w3.org/ns/prov#wasD
8083
!!! tip "The schema travels with the package"
8184

8285
`vocabulary()` and `load_context()` resolve `lokf.yaml` and
83-
`lokf.context.jsonld` from the copies packaged inside the wheel, so an
84-
installed `lokf` works outside a repo checkout. An explicit path or a
85-
checkout in an ancestor directory takes precedence.
86+
`lokf.context.jsonld` in this order: an explicit path argument first,
87+
then a checkout found by walking up from the current directory — so
88+
local schema edits always win — and only then the copies packaged
89+
inside the wheel, the fallback that lets an installed `lokf` work
90+
outside a repo checkout.
8691

8792
## Where next
8893

docs/toolkit/proposer.md

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ uv run lokf propose mykb/ # dry-run: table of proposals
1212
uv run lokf propose mykb/ --json # same list, machine-readable
1313
uv run lokf propose mykb/ --min-confidence 0.5 # drop the weak ones
1414
uv run lokf propose mykb/ --apply --min-confidence 0.5 # write frontmatter
15+
uv run lokf propose mykb/ --json --apply # apply + JSON report of what was written
1516
```
1617

1718
Or from Python:
@@ -53,8 +54,10 @@ matched against a priority-ordered table of cue patterns: wording like
5354
*derived / computed / built from* points at `derivedFrom`
5455
(`prov:wasDerivedFrom`), *depends / requires / needs* at `dependsOn`
5556
(`dcterms:requires`), *measures / counts* at `measures`, *part of / within*
56-
at `isPartOf`, *same as / alias* at `sameAs`, and so on across the
57-
[relation vocabulary](../guide/relationships.md). The first row that matches
57+
at `isPartOf`, *same as / alias* at `sameAs`, *attributed to / authored by*
58+
at `wasAttributedTo`, *joins with / joined on* at `joinsWith`, and so on
59+
across the [relation vocabulary](../guide/relationships.md). The first row
60+
that matches
5861
(and whose relation the source's type may carry) wins. A link whose sentence
5962
matches no cue at all falls back to a low-confidence `relatedTo` — the
6063
weakest, most honest claim available.
@@ -119,8 +122,19 @@ untouched. Where a proposal lands depends on the relation:
119122
target: https://acme.example/knowledge/tables/customers
120123
```
121124
122-
Both forms project to identical RDF, so nothing downstream cares which one a
123-
proposal used.
125+
The two forms do **not** project to identical RDF. A named slot becomes a
126+
direct triple with its bound predicate
127+
(`<metric> prov:wasDerivedFrom <dataset>`); a `relations:` entry projects as
128+
a [reified statement](../guide/relationships.md#custom-predicates-relations)
129+
— an `rdf:Statement` node reached via `lokf:relations` that carries the
130+
predicate and target — not a direct triple. The
131+
[knowledge-graph page](../graph.md) flattens reified entries back into
132+
labeled edges for display, so both forms look the same in the picture, but
133+
SPARQL over `bundle.graph()` sees the difference.
134+
135+
`--json` composes with `--apply`: the proposals are still written, and the
136+
JSON output reports the outcome — every proposal that was written gains
137+
`"applied": true`.
124138

125139
## Honest limits
126140

docs/toolkit/tutorial.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,10 @@ session window.
115115
## 3. Validate
116116

117117
The JSON Schema validates *documents*, so there is one assembly step: fold
118-
the concepts (plus their resolved `id`s) into a single `KnowledgeBundle`
119-
JSON, exactly as `lokf-build` does for the reference bundle.
118+
the concepts into a single `KnowledgeBundle` JSON, injecting each concept's
119+
`id` where it is missing (a `setdefault`, resolved from `base_iri` + Concept
120+
ID — explicit `id`s are kept), exactly as `lokf-build` does for the
121+
reference bundle.
120122

121123
=== "Whole bundle"
122124

src/lokf/build.py

Lines changed: 26 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,14 @@
1818
examples/weekly-active-users.nt RDF triples for one concept
1919
"""
2020
from __future__ import annotations
21-
import glob
2221
import json
2322
import os
2423
import pathlib
2524
import shutil
2625
import subprocess
2726
import sys
2827

29-
from lokf.parse import isoify, parse_concept
28+
from lokf.model import load_bundle
3029

3130

3231
def _find_root() -> pathlib.Path:
@@ -83,26 +82,28 @@ def generate(root: pathlib.Path) -> None:
8382
pass
8483

8584
# Refresh the copies packaged with the lokf toolkit so an installed wheel
86-
# is self-sufficient (see lokf.schema's resolution order).
87-
data = root / "src" / "lokf" / "data"
88-
data.mkdir(parents=True, exist_ok=True)
89-
shutil.copy(root / "lokf.yaml", data / "lokf.yaml")
90-
shutil.copy(root / "lokf.context.jsonld", data / "lokf.context.jsonld")
91-
print(" -> lokf.context.jsonld, lokf.schema.json, lokf.shacl.ttl, lokf.owl.ttl "
92-
"(+ src/lokf/data copies)")
85+
# is self-sufficient (see lokf.schema's resolution order). Only when run
86+
# inside the lokf repo itself: a downstream knowledge repo that satisfies
87+
# _find_root must not have a src/lokf/ tree planted in it.
88+
outputs = " -> lokf.context.jsonld, lokf.schema.json, lokf.shacl.ttl, lokf.owl.ttl"
89+
if (root / "src" / "lokf" / "__init__.py").exists():
90+
data = root / "src" / "lokf" / "data"
91+
data.mkdir(parents=True, exist_ok=True)
92+
shutil.copy(root / "lokf.yaml", data / "lokf.yaml")
93+
shutil.copy(root / "lokf.context.jsonld", data / "lokf.context.jsonld")
94+
outputs += " (+ src/lokf/data copies)"
95+
print(outputs)
9396

9497

9598
def assemble(root: pathlib.Path) -> dict:
9699
"""Assemble all concept files (+ root index.md metadata) into one bundle."""
97-
import yaml
98-
bundle_dir = root / "examples" / "acme-knowledge"
99-
idx = open(bundle_dir / "index.md", encoding="utf-8").read().split("---", 2)
100-
bundle = isoify(yaml.safe_load(idx[1]))
101-
concepts = [
102-
parse_concept(p)
103-
for p in sorted(glob.glob(str(bundle_dir / "**" / "*.md"), recursive=True))
104-
if os.path.basename(p) not in ("index.md", "log.md")
105-
]
100+
b = load_bundle(root / "examples" / "acme-knowledge")
101+
bundle = dict(b.meta)
102+
concepts = []
103+
for c in b.concepts:
104+
doc = dict(c.data)
105+
doc.setdefault("id", b.iri(c)) # no-op where frontmatter has explicit id
106+
concepts.append(doc)
106107
bundle["concepts"] = concepts
107108
json.dump(bundle, open(root / "examples" / "acme-knowledge.bundle.json", "w"), indent=2)
108109
print(f"== assembled bundle: {len(concepts)} concepts "
@@ -123,11 +124,14 @@ def to_rdf(root: pathlib.Path, bundle: dict) -> None:
123124
ex = root / "examples"
124125
ctx = json.load(open(root / "lokf.context.jsonld"))["@context"]
125126

127+
# Same single-parse projection as lokf.model.Bundle.graph(): the
128+
# assembled concepts already carry injected ids, so one @graph document
129+
# (context compiled once) covers the whole bundle.
126130
whole = Graph()
127-
for c in bundle["concepts"]:
128-
doc = dict(c)
129-
doc["@context"] = ctx
130-
whole.parse(data=json.dumps(doc), format="json-ld")
131+
whole.parse(
132+
data=json.dumps({"@context": ctx, "@graph": bundle["concepts"]}),
133+
format="json-ld",
134+
)
131135
whole.serialize(destination=str(ex / "acme-knowledge.nt"), format="nt")
132136

133137
metric = next(c for c in bundle["concepts"] if c["type"] == "Metric")

0 commit comments

Comments
 (0)