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
35 changes: 32 additions & 3 deletions knowledge_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,33 @@
R = 9


LABEL_W = 26 # chars per label line; two lines fit inside ROW_H, then ellipsize
LABEL_LINES = 2


def _esc(s: str) -> str:
return str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")


def _wrap_label(title: str, width: int = LABEL_W, max_lines: int = LABEL_LINES) -> list[str]:
"""Word-wrap a node label to at most max_lines; only the last line ellipsizes."""
words = title.split()
lines: list[str] = []
cur = ""
for i, w in enumerate(words):
cand = (cur + " " + w).strip()
if len(cand) <= width or not cur:
cur = cand
continue
lines.append(cur)
cur = w
if len(lines) == max_lines - 1:
cur = " ".join(words[i:])
break
lines.append(cur)
return [ln if len(ln) <= width else ln[: width - 1] + "…" for ln in lines if ln]


def _depths(slugs: list[str], links: list[dict]) -> dict[str, int]:
"""Column per node = longest prerequisite chain below it (cycle-guarded)."""
keep = set(slugs)
Expand Down Expand Up @@ -99,10 +122,16 @@ def render_map(pages: list[dict], links: list[dict], cap: int = MAP_CAP) -> str:
x, y = pos[s]
p = by_slug[s]
fill = TIER_FILL.get(p["tier"], TIER_FILL["novice"])
title = p["title"][:22] + ("…" if len(p["title"]) > 22 else "")
parts.append(f'<circle cx="{x}" cy="{y}" r="{R}" fill="{fill}" fill-opacity="0.9"/>')
lines = _wrap_label(p["title"]) or [""]
parts.append(
f'<circle cx="{x}" cy="{y}" r="{R}" fill="{fill}" fill-opacity="0.9">'
f"<title>{_esc(p['title'])}</title></circle>"
)
due = f' <tspan fill="{TIER_FILL["novice"]}">·{p["due_cards"]} due</tspan>' if p.get("due_cards") else ""
parts.append(f'<text x="{x + R + 6}" y="{y + 4}" fill="{TEXT}">{_esc(title)}{due}</text>')
tx = x + R + 6
ty = y + 4 if len(lines) == 1 else y - 2
tspans = "".join(f'<tspan x="{tx}" dy="13">{_esc(ln)}</tspan>' for ln in lines[1:])
parts.append(f'<text x="{tx}" y="{ty}" fill="{TEXT}">{_esc(lines[0])}{tspans}{due}</text>')

parts.append("</svg>")
return "\n".join(parts)
2 changes: 1 addition & 1 deletion protoagent.plugin.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
# (tests/test_packaging.py enforces it).
id: learning_wiki
name: Learning Wiki
version: 0.3.2
version: 0.3.3
description: >-
An adaptive learning wiki: the agent maintains a persistent, interlinked wiki
of concept pages (Karpathy's LLM-wiki pattern) PLUS a learner ledger — per-concept
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "learning-wiki"
version = "0.3.2"
version = "0.3.3"
description = "Adaptive learning-wiki plugin for protoAgent: LLM-maintained wiki + learner ledger + FSRS spaced review."
requires-python = ">=3.11"

Expand Down
16 changes: 16 additions & 0 deletions tests/test_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,19 @@ def test_cap_limits_nodes_and_due_badges_render():
def test_titles_are_escaped():
svg = render_map([_page("x") | {"title": 'a<b>&"c'}], [])
assert "<b>" not in svg and "&amp;" in svg


def test_long_titles_wrap_instead_of_truncating():
svg = render_map([_page("x") | {"title": "Agent Failure Modes (Overview)"}], [])
# Wrapped onto a second tspan line, nothing chopped mid-title.
assert 'dy="13">(Overview)</tspan>' in svg
assert "Agent Failure Modes" in svg
assert "…" not in svg
# Full title available as a hover tooltip on the node.
assert "<title>Agent Failure Modes (Overview)</title>" in svg


def test_short_titles_stay_single_line():
svg = render_map([_page("x") | {"title": "Softmax"}], [])
assert 'dy="13"' not in svg
assert ">Softmax<" in svg
4 changes: 2 additions & 2 deletions tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,12 +236,12 @@ def wiki_export(out_dir: str = "", format: str = "markdown") -> str:
if format == "archive":
target = out_dir or str(_data_dir(cfg) / "export")
res = get_store().export_archive(Path(target) / "wiki-archive.json")
return _ok(**res)
return _ok(format="archive", **res)
if format != "markdown":
return _err("format must be 'markdown' or 'archive'")
target = out_dir or str(_data_dir(cfg) / "export")
n = get_store().export_markdown(target)
return _ok(dir=target, files=n)
return _ok(format="markdown", dir=target, files=n)
except Exception as e: # noqa: BLE001
return _err(e)

Expand Down
15 changes: 14 additions & 1 deletion view.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,19 @@
return out.join("\n");
}

// The reader renders page.title as its own heading, and filed pages usually
// open with the same text as an H1 — drop that leading H1 when it matches,
// so titles don't render twice. A deliberately different H1 is kept.
function stripDupTitle(src, title){
const lines = String(src || "").split("\n");
let i = 0;
while (i < lines.length && lines[i].trim() === "") i++;
const m = (lines[i] || "").match(/^#\s+(.*)$/);
if (m && m[1].trim().toLowerCase() === String(title || "").trim().toLowerCase())
return lines.slice(i + 1).join("\n");
return src;
}

async function api(path){
// RULES 2+3 — gated data via the kit's slug-aware authed fetch.
const r = await kit.apiFetch(path);
Expand Down Expand Up @@ -163,7 +176,7 @@
${mis.length ? `<span class="pill">⚠ ${mis.length} open misconception(s)</span>` : ""}
<span>updated ${esc((page.updated_at || "").slice(0, 10))}</span>
</div>
<div class="md"><h2>${esc(page.title)}</h2>${md(page.content_md || "*stub — nothing filed yet*")}</div>
<div class="md"><h2>${esc(page.title)}</h2>${md(stripDupTitle(page.content_md, page.title) || "*stub — nothing filed yet*")}</div>
<div class="links">
${page.links.length ? `<div class="h">Links</div>` + page.links.map(linkRow).join(" ") : ""}
${page.backlinks.length ? `<div class="h" style="margin-top:8px">Referenced by</div>` + page.backlinks.map(linkRow).join(" ") : ""}
Expand Down
Loading