-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathknowledge_map.py
More file actions
137 lines (113 loc) · 5.25 KB
/
Copy pathknowledge_map.py
File metadata and controls
137 lines (113 loc) · 5.25 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
"""Knowledge-map renderer: the wiki as a tier-colored SVG, pure Python.
Layout is a prerequisite-layered DAG: an edge A --prerequisite--> B means "to
understand A you first need B", so B sits in an earlier column. Colors match
the console view's tier fallbacks. Deterministic output (sorted everywhere) so
tests can pin it and repeated calls dedupe in the media store.
SVG (not PNG) keeps the plugin zero-dep — the console <img> renders it fine.
The trade-off, documented in SEAMS.md: no `multimodal_tool_result` for the map,
because vision models take raster formats and rasterizing needs a pip dep.
"""
from __future__ import annotations
MAP_CAP = 60 # most-recently-updated pages; the tool reports truncation loudly
TIER_FILL = {"novice": "#d9a441", "frontier": "#6f9bff", "fluent": "#57b98a"}
TEXT = "#8a94a3"
EDGE = "#7a8697"
COL_W = 230
ROW_H = 64
PAD = 40
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)
prereqs: dict[str, list[str]] = {s: [] for s in slugs}
for e in links:
if e["rel"] == "prerequisite" and e["from_slug"] in keep and e["to_slug"] in keep:
prereqs[e["from_slug"]].append(e["to_slug"])
memo: dict[str, int] = {}
def depth(slug: str, trail: frozenset) -> int:
if slug in memo:
return memo[slug]
if slug in trail: # cycle — break it, don't recurse forever
return 0
below = [depth(p, trail | {slug}) for p in prereqs[slug]]
memo[slug] = 1 + max(below) if below else 0
return memo[slug]
for s in slugs:
depth(s, frozenset())
return memo
def render_map(pages: list[dict], links: list[dict], cap: int = MAP_CAP) -> str:
"""pages: store.list_pages() rows (recency-ordered); links: store.all_links()."""
kept = pages[:cap]
slugs = [p["slug"] for p in kept]
by_slug = {p["slug"]: p for p in kept}
depths = _depths(slugs, links)
cols: dict[int, list[str]] = {}
for s in sorted(slugs):
cols.setdefault(depths[s], []).append(s)
pos: dict[str, tuple[int, int]] = {}
for d, members in cols.items():
for i, s in enumerate(members):
pos[s] = (PAD + d * COL_W, PAD + i * ROW_H)
n_cols = max(cols) + 1 if cols else 1
n_rows = max((len(m) for m in cols.values()), default=1)
width = PAD + n_cols * COL_W + 130
height = PAD + n_rows * ROW_H + PAD
parts = [
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" '
f'viewBox="0 0 {width} {height}" font-family="system-ui,sans-serif" font-size="12">',
'<defs><marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" '
f'markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="{EDGE}"/></marker></defs>',
]
# Edges first (under the nodes). Prerequisite: solid arrow FROM the
# prerequisite TO the dependent (the learning direction). Others: dashed.
for e in sorted(links, key=lambda x: (x["from_slug"], x["to_slug"], x["rel"])):
a, b = e["from_slug"], e["to_slug"]
if a not in pos or b not in pos:
continue
if e["rel"] == "prerequisite":
(x1, y1), (x2, y2) = pos[b], pos[a] # fundamental → dependent
extra = ' marker-end="url(#arrow)"'
else:
(x1, y1), (x2, y2) = pos[a], pos[b]
extra = ' stroke-dasharray="4 4"'
parts.append(
f'<line x1="{x1 + R}" y1="{y1}" x2="{x2 - R}" y2="{y2}" stroke="{EDGE}" '
f'stroke-opacity="0.55" stroke-width="1.3"{extra}/>'
)
for s in sorted(slugs):
x, y = pos[s]
p = by_slug[s]
fill = TIER_FILL.get(p["tier"], TIER_FILL["novice"])
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 ""
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)