diff --git a/knowledge_map.py b/knowledge_map.py index d1646d0..51bbaee 100644 --- a/knowledge_map.py +++ b/knowledge_map.py @@ -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("&", "&").replace("<", "<").replace(">", ">").replace('"', """) +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) @@ -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'') + lines = _wrap_label(p["title"]) or [""] + parts.append( + f'' + f"{_esc(p['title'])}" + ) due = f' ·{p["due_cards"]} due' if p.get("due_cards") else "" - parts.append(f'{_esc(title)}{due}') + tx = x + R + 6 + ty = y + 4 if len(lines) == 1 else y - 2 + tspans = "".join(f'{_esc(ln)}' for ln in lines[1:]) + parts.append(f'{_esc(lines[0])}{tspans}{due}') parts.append("") return "\n".join(parts) diff --git a/protoagent.plugin.yaml b/protoagent.plugin.yaml index 9925dad..6cd2761 100644 --- a/protoagent.plugin.yaml +++ b/protoagent.plugin.yaml @@ -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 diff --git a/pyproject.toml b/pyproject.toml index acc9795..ac045c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/test_map.py b/tests/test_map.py index fe18c08..b379cfb 100644 --- a/tests/test_map.py +++ b/tests/test_map.py @@ -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&"c'}], []) assert "" not in svg and "&" 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)' in svg + assert "Agent Failure Modes" in svg + assert "…" not in svg + # Full title available as a hover tooltip on the node. + assert "Agent Failure Modes (Overview)" 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 diff --git a/tools.py b/tools.py index 8bc4ca0..7135ad9 100644 --- a/tools.py +++ b/tools.py @@ -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) diff --git a/view.py b/view.py index 39759ed..b1d320e 100644 --- a/view.py +++ b/view.py @@ -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); @@ -163,7 +176,7 @@ ${mis.length ? `⚠ ${mis.length} open misconception(s)` : ""} updated ${esc((page.updated_at || "").slice(0, 10))} -

${esc(page.title)}

${md(page.content_md || "*stub — nothing filed yet*")}
+

${esc(page.title)}

${md(stripDupTitle(page.content_md, page.title) || "*stub — nothing filed yet*")}