|
0981a08…
|
noreply
|
1 |
"""Skill: Export knowledge graph as structured notes (Obsidian, Notion).""" |
|
0981a08…
|
noreply
|
2 |
|
|
0981a08…
|
noreply
|
3 |
import csv |
|
0981a08…
|
noreply
|
4 |
import io |
|
0981a08…
|
noreply
|
5 |
import logging |
|
0981a08…
|
noreply
|
6 |
from datetime import date |
|
0981a08…
|
noreply
|
7 |
from pathlib import Path |
|
0981a08…
|
noreply
|
8 |
from typing import Dict, List, Optional |
|
0981a08…
|
noreply
|
9 |
|
|
0981a08…
|
noreply
|
10 |
from video_processor.agent.skills.base import ( |
|
0981a08…
|
noreply
|
11 |
AgentContext, |
|
0981a08…
|
noreply
|
12 |
Artifact, |
|
0981a08…
|
noreply
|
13 |
Skill, |
|
0981a08…
|
noreply
|
14 |
register_skill, |
|
0981a08…
|
noreply
|
15 |
) |
|
0981a08…
|
noreply
|
16 |
|
|
0981a08…
|
noreply
|
17 |
logger = logging.getLogger(__name__) |
|
0981a08…
|
noreply
|
18 |
|
|
0981a08…
|
noreply
|
19 |
|
|
0981a08…
|
noreply
|
20 |
def _sanitize_filename(name: str) -> str: |
|
0981a08…
|
noreply
|
21 |
"""Convert a name to a filesystem-safe filename.""" |
|
0981a08…
|
noreply
|
22 |
return ( |
|
0981a08…
|
noreply
|
23 |
name.replace("/", "-") |
|
0981a08…
|
noreply
|
24 |
.replace("\\", "-") |
|
0981a08…
|
noreply
|
25 |
.replace(":", "-") |
|
0981a08…
|
noreply
|
26 |
.replace('"', "") |
|
0981a08…
|
noreply
|
27 |
.replace("?", "") |
|
0981a08…
|
noreply
|
28 |
.replace("*", "") |
|
0981a08…
|
noreply
|
29 |
.replace("<", "") |
|
0981a08…
|
noreply
|
30 |
.replace(">", "") |
|
0981a08…
|
noreply
|
31 |
.replace("|", "") |
|
0981a08…
|
noreply
|
32 |
) |
|
0981a08…
|
noreply
|
33 |
|
|
0981a08…
|
noreply
|
34 |
|
|
0981a08…
|
noreply
|
35 |
def _build_indexes(kg_data: dict): |
|
0981a08…
|
noreply
|
36 |
"""Build lookup structures from knowledge graph data. |
|
0981a08…
|
noreply
|
37 |
|
|
0981a08…
|
noreply
|
38 |
Returns (nodes, by_type, node_lookup, outgoing, incoming). |
|
0981a08…
|
noreply
|
39 |
""" |
|
0981a08…
|
noreply
|
40 |
nodes = kg_data.get("nodes", []) |
|
0981a08…
|
noreply
|
41 |
relationships = kg_data.get("relationships", []) |
|
0981a08…
|
noreply
|
42 |
|
|
0981a08…
|
noreply
|
43 |
by_type: Dict[str, list] = {} |
|
0981a08…
|
noreply
|
44 |
node_lookup: Dict[str, dict] = {} |
|
0981a08…
|
noreply
|
45 |
for node in nodes: |
|
0981a08…
|
noreply
|
46 |
name = node.get("name", node.get("id", "")) |
|
0981a08…
|
noreply
|
47 |
ntype = node.get("type", "concept") |
|
0981a08…
|
noreply
|
48 |
by_type.setdefault(ntype, []).append(node) |
|
0981a08…
|
noreply
|
49 |
node_lookup[name] = node |
|
0981a08…
|
noreply
|
50 |
|
|
0981a08…
|
noreply
|
51 |
outgoing: Dict[str, list] = {} |
|
0981a08…
|
noreply
|
52 |
incoming: Dict[str, list] = {} |
|
0981a08…
|
noreply
|
53 |
for rel in relationships: |
|
0981a08…
|
noreply
|
54 |
src = rel.get("source", "") |
|
0981a08…
|
noreply
|
55 |
tgt = rel.get("target", "") |
|
0981a08…
|
noreply
|
56 |
rtype = rel.get("type", "related_to") |
|
0981a08…
|
noreply
|
57 |
outgoing.setdefault(src, []).append((tgt, rtype)) |
|
0981a08…
|
noreply
|
58 |
incoming.setdefault(tgt, []).append((src, rtype)) |
|
0981a08…
|
noreply
|
59 |
|
|
0981a08…
|
noreply
|
60 |
return nodes, by_type, node_lookup, outgoing, incoming |
|
0981a08…
|
noreply
|
61 |
|
|
0981a08…
|
noreply
|
62 |
|
|
0981a08…
|
noreply
|
63 |
# --------------------------------------------------------------------------- |
|
0981a08…
|
noreply
|
64 |
# Obsidian export |
|
0981a08…
|
noreply
|
65 |
# --------------------------------------------------------------------------- |
|
0981a08…
|
noreply
|
66 |
|
|
0981a08…
|
noreply
|
67 |
|
|
0981a08…
|
noreply
|
68 |
def export_to_obsidian( |
|
0981a08…
|
noreply
|
69 |
kg_data: dict, |
|
0981a08…
|
noreply
|
70 |
output_dir: Path, |
|
0981a08…
|
noreply
|
71 |
artifacts: Optional[List[Artifact]] = None, |
|
0981a08…
|
noreply
|
72 |
) -> List[Path]: |
|
0981a08…
|
noreply
|
73 |
"""Export knowledge graph as an Obsidian vault. |
|
0981a08…
|
noreply
|
74 |
|
|
0981a08…
|
noreply
|
75 |
Creates one ``.md`` file per entity with YAML frontmatter and |
|
0981a08…
|
noreply
|
76 |
``[[wiki-links]]``, an ``_Index.md`` Map of Content, tag pages per |
|
0981a08…
|
noreply
|
77 |
entity type, and optional artifact notes. |
|
0981a08…
|
noreply
|
78 |
""" |
|
0981a08…
|
noreply
|
79 |
output_dir.mkdir(parents=True, exist_ok=True) |
|
0981a08…
|
noreply
|
80 |
artifacts = artifacts or [] |
|
0981a08…
|
noreply
|
81 |
created: List[Path] = [] |
|
0981a08…
|
noreply
|
82 |
today = date.today().isoformat() |
|
0981a08…
|
noreply
|
83 |
|
|
0981a08…
|
noreply
|
84 |
nodes, by_type, node_lookup, outgoing, incoming = _build_indexes(kg_data) |
|
0981a08…
|
noreply
|
85 |
|
|
0981a08…
|
noreply
|
86 |
# --- Individual entity notes --- |
|
0981a08…
|
noreply
|
87 |
for node in nodes: |
|
0981a08…
|
noreply
|
88 |
name = node.get("name", node.get("id", "")) |
|
0981a08…
|
noreply
|
89 |
if not name: |
|
0981a08…
|
noreply
|
90 |
continue |
|
0981a08…
|
noreply
|
91 |
ntype = node.get("type", "concept") |
|
0981a08…
|
noreply
|
92 |
descs = node.get("descriptions", []) |
|
0981a08…
|
noreply
|
93 |
aliases = node.get("aliases", []) |
|
0981a08…
|
noreply
|
94 |
|
|
0981a08…
|
noreply
|
95 |
# YAML frontmatter |
|
0981a08…
|
noreply
|
96 |
tags_yaml = f" - {ntype}" |
|
0981a08…
|
noreply
|
97 |
aliases_yaml = "" |
|
0981a08…
|
noreply
|
98 |
if aliases: |
|
0981a08…
|
noreply
|
99 |
alias_lines = "\n".join(f" - {a}" for a in aliases) |
|
0981a08…
|
noreply
|
100 |
aliases_yaml = f"aliases:\n{alias_lines}\n" |
|
0981a08…
|
noreply
|
101 |
|
|
0981a08…
|
noreply
|
102 |
frontmatter = f"---\ntype: {ntype}\ntags:\n{tags_yaml}\n{aliases_yaml}date: {today}\n---\n" |
|
0981a08…
|
noreply
|
103 |
|
|
0981a08…
|
noreply
|
104 |
parts = [frontmatter, f"# {name}", ""] |
|
0981a08…
|
noreply
|
105 |
|
|
0981a08…
|
noreply
|
106 |
# Descriptions |
|
0981a08…
|
noreply
|
107 |
if descs: |
|
0981a08…
|
noreply
|
108 |
for d in descs: |
|
0981a08…
|
noreply
|
109 |
parts.append(f"{d}") |
|
0981a08…
|
noreply
|
110 |
parts.append("") |
|
0981a08…
|
noreply
|
111 |
|
|
0981a08…
|
noreply
|
112 |
# Outgoing relationships |
|
0981a08…
|
noreply
|
113 |
outs = outgoing.get(name, []) |
|
0981a08…
|
noreply
|
114 |
if outs: |
|
0981a08…
|
noreply
|
115 |
parts.append("## Relationships") |
|
0981a08…
|
noreply
|
116 |
parts.append("") |
|
0981a08…
|
noreply
|
117 |
for tgt, rtype in outs: |
|
0981a08…
|
noreply
|
118 |
parts.append(f"- **{rtype}**: [[{tgt}]]") |
|
0981a08…
|
noreply
|
119 |
parts.append("") |
|
0981a08…
|
noreply
|
120 |
|
|
0981a08…
|
noreply
|
121 |
# Incoming relationships |
|
0981a08…
|
noreply
|
122 |
ins = incoming.get(name, []) |
|
0981a08…
|
noreply
|
123 |
if ins: |
|
0981a08…
|
noreply
|
124 |
parts.append("## Referenced by") |
|
0981a08…
|
noreply
|
125 |
parts.append("") |
|
0981a08…
|
noreply
|
126 |
for src, rtype in ins: |
|
0981a08…
|
noreply
|
127 |
parts.append(f"- **{rtype}** from [[{src}]]") |
|
0981a08…
|
noreply
|
128 |
parts.append("") |
|
0981a08…
|
noreply
|
129 |
|
|
0981a08…
|
noreply
|
130 |
filename = _sanitize_filename(name) + ".md" |
|
0981a08…
|
noreply
|
131 |
path = output_dir / filename |
|
0981a08…
|
noreply
|
132 |
path.write_text("\n".join(parts), encoding="utf-8") |
|
0981a08…
|
noreply
|
133 |
created.append(path) |
|
0981a08…
|
noreply
|
134 |
|
|
0981a08…
|
noreply
|
135 |
# --- Index note (Map of Content) --- |
|
0981a08…
|
noreply
|
136 |
index_parts = [ |
|
0981a08…
|
noreply
|
137 |
"---", |
|
0981a08…
|
noreply
|
138 |
"type: index", |
|
0981a08…
|
noreply
|
139 |
"tags:", |
|
0981a08…
|
noreply
|
140 |
" - MOC", |
|
0981a08…
|
noreply
|
141 |
f"date: {today}", |
|
0981a08…
|
noreply
|
142 |
"---", |
|
0981a08…
|
noreply
|
143 |
"", |
|
0981a08…
|
noreply
|
144 |
"# Index", |
|
0981a08…
|
noreply
|
145 |
"", |
|
0981a08…
|
noreply
|
146 |
f"**{len(nodes)}** entities | **{len(kg_data.get('relationships', []))}** relationships", |
|
0981a08…
|
noreply
|
147 |
"", |
|
0981a08…
|
noreply
|
148 |
] |
|
0981a08…
|
noreply
|
149 |
|
|
0981a08…
|
noreply
|
150 |
for etype in sorted(by_type.keys()): |
|
0981a08…
|
noreply
|
151 |
elist = by_type[etype] |
|
0981a08…
|
noreply
|
152 |
index_parts.append(f"## {etype.title()}") |
|
0981a08…
|
noreply
|
153 |
index_parts.append("") |
|
0981a08…
|
noreply
|
154 |
for node in sorted(elist, key=lambda n: n.get("name", "")): |
|
0981a08…
|
noreply
|
155 |
name = node.get("name", "") |
|
0981a08…
|
noreply
|
156 |
index_parts.append(f"- [[{name}]]") |
|
0981a08…
|
noreply
|
157 |
index_parts.append("") |
|
0981a08…
|
noreply
|
158 |
|
|
0981a08…
|
noreply
|
159 |
if artifacts: |
|
0981a08…
|
noreply
|
160 |
index_parts.append("## Artifacts") |
|
0981a08…
|
noreply
|
161 |
index_parts.append("") |
|
0981a08…
|
noreply
|
162 |
for art in artifacts: |
|
0981a08…
|
noreply
|
163 |
index_parts.append(f"- [[{art.name}]]") |
|
0981a08…
|
noreply
|
164 |
index_parts.append("") |
|
0981a08…
|
noreply
|
165 |
|
|
0981a08…
|
noreply
|
166 |
index_path = output_dir / "_Index.md" |
|
0981a08…
|
noreply
|
167 |
index_path.write_text("\n".join(index_parts), encoding="utf-8") |
|
0981a08…
|
noreply
|
168 |
created.append(index_path) |
|
0981a08…
|
noreply
|
169 |
|
|
0981a08…
|
noreply
|
170 |
# --- Tag pages (one per entity type) --- |
|
0981a08…
|
noreply
|
171 |
for etype, elist in sorted(by_type.items()): |
|
0981a08…
|
noreply
|
172 |
tag_parts = [ |
|
0981a08…
|
noreply
|
173 |
"---", |
|
0981a08…
|
noreply
|
174 |
"type: tag", |
|
0981a08…
|
noreply
|
175 |
"tags:", |
|
0981a08…
|
noreply
|
176 |
f" - {etype}", |
|
0981a08…
|
noreply
|
177 |
f"date: {today}", |
|
0981a08…
|
noreply
|
178 |
"---", |
|
0981a08…
|
noreply
|
179 |
"", |
|
0981a08…
|
noreply
|
180 |
f"# {etype.title()}", |
|
0981a08…
|
noreply
|
181 |
"", |
|
0981a08…
|
noreply
|
182 |
f"All entities of type **{etype}** ({len(elist)}).", |
|
0981a08…
|
noreply
|
183 |
"", |
|
0981a08…
|
noreply
|
184 |
] |
|
0981a08…
|
noreply
|
185 |
for node in sorted(elist, key=lambda n: n.get("name", "")): |
|
0981a08…
|
noreply
|
186 |
name = node.get("name", "") |
|
0981a08…
|
noreply
|
187 |
descs = node.get("descriptions", []) |
|
0981a08…
|
noreply
|
188 |
summary = descs[0] if descs else "" |
|
0981a08…
|
noreply
|
189 |
tag_parts.append(f"- [[{name}]]" + (f" - {summary}" if summary else "")) |
|
0981a08…
|
noreply
|
190 |
tag_parts.append("") |
|
0981a08…
|
noreply
|
191 |
|
|
0981a08…
|
noreply
|
192 |
tag_filename = f"Tag - {etype.title()}.md" |
|
0981a08…
|
noreply
|
193 |
tag_path = output_dir / _sanitize_filename(tag_filename) |
|
0981a08…
|
noreply
|
194 |
tag_path.write_text("\n".join(tag_parts), encoding="utf-8") |
|
0981a08…
|
noreply
|
195 |
created.append(tag_path) |
|
0981a08…
|
noreply
|
196 |
|
|
0981a08…
|
noreply
|
197 |
# --- Artifact notes --- |
|
0981a08…
|
noreply
|
198 |
for art in artifacts: |
|
0981a08…
|
noreply
|
199 |
art_parts = [ |
|
0981a08…
|
noreply
|
200 |
"---", |
|
0981a08…
|
noreply
|
201 |
"type: artifact", |
|
0981a08…
|
noreply
|
202 |
f"artifact_type: {art.artifact_type}", |
|
0981a08…
|
noreply
|
203 |
"tags:", |
|
0981a08…
|
noreply
|
204 |
" - artifact", |
|
0981a08…
|
noreply
|
205 |
f" - {art.artifact_type}", |
|
0981a08…
|
noreply
|
206 |
f"date: {today}", |
|
0981a08…
|
noreply
|
207 |
"---", |
|
0981a08…
|
noreply
|
208 |
"", |
|
0981a08…
|
noreply
|
209 |
f"# {art.name}", |
|
0981a08…
|
noreply
|
210 |
"", |
|
0981a08…
|
noreply
|
211 |
art.content, |
|
0981a08…
|
noreply
|
212 |
"", |
|
0981a08…
|
noreply
|
213 |
] |
|
0981a08…
|
noreply
|
214 |
art_filename = _sanitize_filename(art.name) + ".md" |
|
0981a08…
|
noreply
|
215 |
art_path = output_dir / art_filename |
|
0981a08…
|
noreply
|
216 |
art_path.write_text("\n".join(art_parts), encoding="utf-8") |
|
0981a08…
|
noreply
|
217 |
created.append(art_path) |
|
0981a08…
|
noreply
|
218 |
|
|
0981a08…
|
noreply
|
219 |
logger.info("Exported %d Obsidian notes to %s", len(created), output_dir) |
|
0981a08…
|
noreply
|
220 |
return created |
|
0981a08…
|
noreply
|
221 |
|
|
0981a08…
|
noreply
|
222 |
|
|
0981a08…
|
noreply
|
223 |
# --------------------------------------------------------------------------- |
|
0981a08…
|
noreply
|
224 |
# Notion-compatible markdown export |
|
0981a08…
|
noreply
|
225 |
# --------------------------------------------------------------------------- |
|
0981a08…
|
noreply
|
226 |
|
|
0981a08…
|
noreply
|
227 |
|
|
0981a08…
|
noreply
|
228 |
def export_to_notion_md( |
|
0981a08…
|
noreply
|
229 |
kg_data: dict, |
|
0981a08…
|
noreply
|
230 |
output_dir: Path, |
|
0981a08…
|
noreply
|
231 |
artifacts: Optional[List[Artifact]] = None, |
|
0981a08…
|
noreply
|
232 |
) -> List[Path]: |
|
0981a08…
|
noreply
|
233 |
"""Export knowledge graph as Notion-compatible markdown. |
|
0981a08…
|
noreply
|
234 |
|
|
0981a08…
|
noreply
|
235 |
Creates ``.md`` files with Notion-style callout blocks and a |
|
0981a08…
|
noreply
|
236 |
database-style CSV for bulk import. |
|
0981a08…
|
noreply
|
237 |
""" |
|
0981a08…
|
noreply
|
238 |
output_dir.mkdir(parents=True, exist_ok=True) |
|
0981a08…
|
noreply
|
239 |
artifacts = artifacts or [] |
|
0981a08…
|
noreply
|
240 |
created: List[Path] = [] |
|
0981a08…
|
noreply
|
241 |
|
|
0981a08…
|
noreply
|
242 |
nodes, by_type, node_lookup, outgoing, incoming = _build_indexes(kg_data) |
|
0981a08…
|
noreply
|
243 |
|
|
0981a08…
|
noreply
|
244 |
# --- Database CSV --- |
|
0981a08…
|
noreply
|
245 |
csv_buffer = io.StringIO() |
|
0981a08…
|
noreply
|
246 |
writer = csv.writer(csv_buffer) |
|
0981a08…
|
noreply
|
247 |
writer.writerow(["Name", "Type", "Description", "Related To"]) |
|
0981a08…
|
noreply
|
248 |
|
|
0981a08…
|
noreply
|
249 |
for node in nodes: |
|
0981a08…
|
noreply
|
250 |
name = node.get("name", node.get("id", "")) |
|
0981a08…
|
noreply
|
251 |
ntype = node.get("type", "concept") |
|
0981a08…
|
noreply
|
252 |
descs = node.get("descriptions", []) |
|
0981a08…
|
noreply
|
253 |
desc_text = "; ".join(descs[:2]) if descs else "" |
|
0981a08…
|
noreply
|
254 |
outs = outgoing.get(name, []) |
|
0981a08…
|
noreply
|
255 |
related = ", ".join(tgt for tgt, _ in outs) if outs else "" |
|
0981a08…
|
noreply
|
256 |
writer.writerow([name, ntype, desc_text, related]) |
|
0981a08…
|
noreply
|
257 |
|
|
0981a08…
|
noreply
|
258 |
csv_path = output_dir / "entities_database.csv" |
|
0981a08…
|
noreply
|
259 |
csv_path.write_text(csv_buffer.getvalue(), encoding="utf-8") |
|
0981a08…
|
noreply
|
260 |
created.append(csv_path) |
|
0981a08…
|
noreply
|
261 |
|
|
0981a08…
|
noreply
|
262 |
# --- Individual entity pages --- |
|
0981a08…
|
noreply
|
263 |
for node in nodes: |
|
0981a08…
|
noreply
|
264 |
name = node.get("name", node.get("id", "")) |
|
0981a08…
|
noreply
|
265 |
if not name: |
|
0981a08…
|
noreply
|
266 |
continue |
|
0981a08…
|
noreply
|
267 |
ntype = node.get("type", "concept") |
|
0981a08…
|
noreply
|
268 |
descs = node.get("descriptions", []) |
|
0981a08…
|
noreply
|
269 |
|
|
0981a08…
|
noreply
|
270 |
type_emoji = { |
|
0981a08…
|
noreply
|
271 |
"person": "person", |
|
0981a08…
|
noreply
|
272 |
"technology": "computer", |
|
0981a08…
|
noreply
|
273 |
"organization": "building", |
|
0981a08…
|
noreply
|
274 |
"concept": "bulb", |
|
0981a08…
|
noreply
|
275 |
"event": "calendar", |
|
0981a08…
|
noreply
|
276 |
"location": "round_pushpin", |
|
0981a08…
|
noreply
|
277 |
} |
|
0981a08…
|
noreply
|
278 |
emoji = type_emoji.get(ntype, "bulb") |
|
0981a08…
|
noreply
|
279 |
|
|
0981a08…
|
noreply
|
280 |
parts = [ |
|
0981a08…
|
noreply
|
281 |
f"# {name}", |
|
0981a08…
|
noreply
|
282 |
"", |
|
0981a08…
|
noreply
|
283 |
f"> :{emoji}: **Type:** {ntype}", |
|
0981a08…
|
noreply
|
284 |
"", |
|
0981a08…
|
noreply
|
285 |
] |
|
0981a08…
|
noreply
|
286 |
|
|
0981a08…
|
noreply
|
287 |
if descs: |
|
0981a08…
|
noreply
|
288 |
parts.append("## Description") |
|
0981a08…
|
noreply
|
289 |
parts.append("") |
|
0981a08…
|
noreply
|
290 |
for d in descs: |
|
0981a08…
|
noreply
|
291 |
parts.append(f"{d}") |
|
0981a08…
|
noreply
|
292 |
parts.append("") |
|
0981a08…
|
noreply
|
293 |
|
|
0981a08…
|
noreply
|
294 |
# Properties callout |
|
0981a08…
|
noreply
|
295 |
properties = node.get("properties", {}) |
|
0981a08…
|
noreply
|
296 |
if properties: |
|
0981a08…
|
noreply
|
297 |
parts.append("> :memo: **Properties**") |
|
0981a08…
|
noreply
|
298 |
for k, v in properties.items(): |
|
0981a08…
|
noreply
|
299 |
parts.append(f"> - **{k}:** {v}") |
|
0981a08…
|
noreply
|
300 |
parts.append("") |
|
0981a08…
|
noreply
|
301 |
|
|
0981a08…
|
noreply
|
302 |
# Outgoing relationships |
|
0981a08…
|
noreply
|
303 |
outs = outgoing.get(name, []) |
|
0981a08…
|
noreply
|
304 |
if outs: |
|
0981a08…
|
noreply
|
305 |
parts.append("## Relationships") |
|
0981a08…
|
noreply
|
306 |
parts.append("") |
|
0981a08…
|
noreply
|
307 |
parts.append("| Target | Relationship |") |
|
0981a08…
|
noreply
|
308 |
parts.append("|--------|-------------|") |
|
0981a08…
|
noreply
|
309 |
for tgt, rtype in outs: |
|
0981a08…
|
noreply
|
310 |
parts.append(f"| {tgt} | {rtype} |") |
|
0981a08…
|
noreply
|
311 |
parts.append("") |
|
0981a08…
|
noreply
|
312 |
|
|
0981a08…
|
noreply
|
313 |
# Incoming relationships |
|
0981a08…
|
noreply
|
314 |
ins = incoming.get(name, []) |
|
0981a08…
|
noreply
|
315 |
if ins: |
|
0981a08…
|
noreply
|
316 |
parts.append("## Referenced by") |
|
0981a08…
|
noreply
|
317 |
parts.append("") |
|
0981a08…
|
noreply
|
318 |
parts.append("| Source | Relationship |") |
|
0981a08…
|
noreply
|
319 |
parts.append("|--------|-------------|") |
|
0981a08…
|
noreply
|
320 |
for src, rtype in ins: |
|
0981a08…
|
noreply
|
321 |
parts.append(f"| {src} | {rtype} |") |
|
0981a08…
|
noreply
|
322 |
parts.append("") |
|
0981a08…
|
noreply
|
323 |
|
|
0981a08…
|
noreply
|
324 |
filename = _sanitize_filename(name) + ".md" |
|
0981a08…
|
noreply
|
325 |
path = output_dir / filename |
|
0981a08…
|
noreply
|
326 |
path.write_text("\n".join(parts), encoding="utf-8") |
|
0981a08…
|
noreply
|
327 |
created.append(path) |
|
0981a08…
|
noreply
|
328 |
|
|
0981a08…
|
noreply
|
329 |
# --- Overview page --- |
|
0981a08…
|
noreply
|
330 |
overview_parts = [ |
|
0981a08…
|
noreply
|
331 |
"# Knowledge Graph Overview", |
|
0981a08…
|
noreply
|
332 |
"", |
|
0981a08…
|
noreply
|
333 |
f"> :bar_chart: **Stats:** {len(nodes)} entities, " |
|
0981a08…
|
noreply
|
334 |
f"{len(kg_data.get('relationships', []))} relationships", |
|
0981a08…
|
noreply
|
335 |
"", |
|
0981a08…
|
noreply
|
336 |
"## Entity Types", |
|
0981a08…
|
noreply
|
337 |
"", |
|
0981a08…
|
noreply
|
338 |
] |
|
0981a08…
|
noreply
|
339 |
for etype in sorted(by_type.keys()): |
|
0981a08…
|
noreply
|
340 |
elist = by_type[etype] |
|
0981a08…
|
noreply
|
341 |
overview_parts.append(f"### {etype.title()} ({len(elist)})") |
|
0981a08…
|
noreply
|
342 |
overview_parts.append("") |
|
0981a08…
|
noreply
|
343 |
for node in sorted(elist, key=lambda n: n.get("name", "")): |
|
0981a08…
|
noreply
|
344 |
name = node.get("name", "") |
|
0981a08…
|
noreply
|
345 |
overview_parts.append(f"- {name}") |
|
0981a08…
|
noreply
|
346 |
overview_parts.append("") |
|
0981a08…
|
noreply
|
347 |
|
|
0981a08…
|
noreply
|
348 |
if artifacts: |
|
0981a08…
|
noreply
|
349 |
overview_parts.append("## Artifacts") |
|
0981a08…
|
noreply
|
350 |
overview_parts.append("") |
|
0981a08…
|
noreply
|
351 |
for art in artifacts: |
|
0981a08…
|
noreply
|
352 |
overview_parts.append(f"- **{art.name}** ({art.artifact_type})") |
|
0981a08…
|
noreply
|
353 |
overview_parts.append("") |
|
0981a08…
|
noreply
|
354 |
|
|
0981a08…
|
noreply
|
355 |
overview_path = output_dir / "Overview.md" |
|
0981a08…
|
noreply
|
356 |
overview_path.write_text("\n".join(overview_parts), encoding="utf-8") |
|
0981a08…
|
noreply
|
357 |
created.append(overview_path) |
|
0981a08…
|
noreply
|
358 |
|
|
0981a08…
|
noreply
|
359 |
# --- Artifact pages --- |
|
0981a08…
|
noreply
|
360 |
for art in artifacts: |
|
0981a08…
|
noreply
|
361 |
art_parts = [ |
|
0981a08…
|
noreply
|
362 |
f"# {art.name}", |
|
0981a08…
|
noreply
|
363 |
"", |
|
0981a08…
|
noreply
|
364 |
f"> :page_facing_up: **Type:** {art.artifact_type} | **Format:** {art.format}", |
|
0981a08…
|
noreply
|
365 |
"", |
|
0981a08…
|
noreply
|
366 |
art.content, |
|
0981a08…
|
noreply
|
367 |
"", |
|
0981a08…
|
noreply
|
368 |
] |
|
0981a08…
|
noreply
|
369 |
art_filename = _sanitize_filename(art.name) + ".md" |
|
0981a08…
|
noreply
|
370 |
art_path = output_dir / art_filename |
|
0981a08…
|
noreply
|
371 |
art_path.write_text("\n".join(art_parts), encoding="utf-8") |
|
0981a08…
|
noreply
|
372 |
created.append(art_path) |
|
0981a08…
|
noreply
|
373 |
|
|
0981a08…
|
noreply
|
374 |
logger.info("Exported %d Notion markdown files to %s", len(created), output_dir) |
|
0981a08…
|
noreply
|
375 |
return created |
|
0981a08…
|
noreply
|
376 |
|
|
0981a08…
|
noreply
|
377 |
|
|
0981a08…
|
noreply
|
378 |
# --------------------------------------------------------------------------- |
|
0981a08…
|
noreply
|
379 |
# Skill class |
|
0981a08…
|
noreply
|
380 |
# --------------------------------------------------------------------------- |
|
0981a08…
|
noreply
|
381 |
|
|
0981a08…
|
noreply
|
382 |
|
|
0981a08…
|
noreply
|
383 |
class NotesExportSkill(Skill): |
|
0981a08…
|
noreply
|
384 |
"""Export knowledge graph as structured notes (Obsidian, Notion). |
|
0981a08…
|
noreply
|
385 |
|
|
0981a08…
|
noreply
|
386 |
For GitHub wiki export, see the ``wiki_generator`` skill. |
|
0981a08…
|
noreply
|
387 |
""" |
|
0981a08…
|
noreply
|
388 |
|
|
0981a08…
|
noreply
|
389 |
name = "notes_export" |
|
0981a08…
|
noreply
|
390 |
description = "Export knowledge graph as structured notes (Obsidian, Notion)" |
|
0981a08…
|
noreply
|
391 |
|
|
0981a08…
|
noreply
|
392 |
def execute(self, context: AgentContext, **kwargs) -> Artifact: |
|
0981a08…
|
noreply
|
393 |
fmt = kwargs.get("format", "obsidian") |
|
0981a08…
|
noreply
|
394 |
output_dir = Path(kwargs.get("output_dir", f"notes_export_{fmt}")) |
|
0981a08…
|
noreply
|
395 |
kg_data = context.knowledge_graph.to_dict() |
|
0981a08…
|
noreply
|
396 |
artifacts = context.artifacts or [] |
|
0981a08…
|
noreply
|
397 |
|
|
0981a08…
|
noreply
|
398 |
if fmt == "notion": |
|
0981a08…
|
noreply
|
399 |
created = export_to_notion_md(kg_data, output_dir, artifacts=artifacts) |
|
0981a08…
|
noreply
|
400 |
else: |
|
0981a08…
|
noreply
|
401 |
created = export_to_obsidian(kg_data, output_dir, artifacts=artifacts) |
|
0981a08…
|
noreply
|
402 |
|
|
0981a08…
|
noreply
|
403 |
file_list = "\n".join(f"- {p.name}" for p in created) |
|
0981a08…
|
noreply
|
404 |
summary = f"Exported {len(created)} {fmt} notes to `{output_dir}`:\n\n{file_list}" |
|
0981a08…
|
noreply
|
405 |
|
|
0981a08…
|
noreply
|
406 |
return Artifact( |
|
0981a08…
|
noreply
|
407 |
name=f"Notes Export ({fmt.title()})", |
|
0981a08…
|
noreply
|
408 |
content=summary, |
|
0981a08…
|
noreply
|
409 |
artifact_type="notes_export", |
|
0981a08…
|
noreply
|
410 |
format="markdown", |
|
0981a08…
|
noreply
|
411 |
metadata={ |
|
0981a08…
|
noreply
|
412 |
"output_dir": str(output_dir), |
|
0981a08…
|
noreply
|
413 |
"format": fmt, |
|
0981a08…
|
noreply
|
414 |
"file_count": len(created), |
|
0981a08…
|
noreply
|
415 |
"files": [str(p) for p in created], |
|
0981a08…
|
noreply
|
416 |
}, |
|
0981a08…
|
noreply
|
417 |
) |
|
0981a08…
|
noreply
|
418 |
|
|
0981a08…
|
noreply
|
419 |
|
|
0981a08…
|
noreply
|
420 |
register_skill(NotesExportSkill()) |