PlanOpticon

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

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button