PlanOpticon

planopticon / video_processor / agent / skills / notes_export.py
Source Blame History 420 lines
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())

Keyboard Shortcuts

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