Navegador

feat: planopticon ingester — load meeting/video KGs into navegador graph

lmata 2026-03-22 21:44 trunk
Commit 9c8fd06c4519a018c71e13002696eb3be61ab069e1075c56a0731e1e5beb9c58
--- navegador/cli/commands.py
+++ navegador/cli/commands.py
@@ -414,10 +414,84 @@
414414
edge_table.add_row(row[0], f"{row[1]:,}")
415415
416416
console.print(node_table)
417417
console.print(edge_table)
418418
419
+
420
+# ── PLANOPTICON ingestion ──────────────────────────────────────────────────────
421
+
422
+@main.group()
423
+def planopticon():
424
+ """Ingest planopticon output (meetings, videos, docs) into the knowledge graph."""
425
+
426
+
427
+@planopticon.command("ingest")
428
+@click.argument("path", type=click.Path(exists=True))
429
+@click.option("--type", "input_type",
430
+ type=click.Choice(["auto", "manifest", "kg", "interchange", "batch"]),
431
+ default="auto", show_default=True,
432
+ help="Input format. auto detects from filename.")
433
+@click.option("--source", default="", help="Source label for provenance (e.g. 'Q4 planning').")
434
+@click.option("--json", "as_json", is_flag=True)
435
+@DB_OPTION
436
+def planopticon_ingest(path: str, input_type: str, source: str, as_json: bool, db: str):
437
+ """Load a planopticon output directory or file into the knowledge graph.
438
+
439
+ PATH can be:
440
+ - A manifest.json file
441
+ - A knowledge_graph.json file
442
+ - An interchange.json file
443
+ - A batch manifest JSON
444
+ - A planopticon output directory (auto-detects manifest.json inside)
445
+ """
446
+ from pathlib import Path as P
447
+
448
+ from navegador.ingestion import PlanopticonIngester
449
+
450
+ p = P(path)
451
+ # Resolve directory → manifest.json
452
+ if p.is_dir():
453
+ candidates = ["manifest.json", "results/knowledge_graph.json", "interchange.json"]
454
+ for c in candidates:
455
+ if (p / c).exists():
456
+ p = p / c
457
+ break
458
+ else:
459
+ raise click.UsageError(f"No recognised planopticon file found in {path}")
460
+
461
+ # Auto-detect type from filename
462
+ if input_type == "auto":
463
+ name = p.name.lower()
464
+ if "manifest" in name:
465
+ input_type = "manifest"
466
+ elif "interchange" in name:
467
+ input_type = "interchange"
468
+ elif "batch" in name:
469
+ input_type = "batch"
470
+ else:
471
+ input_type = "kg"
472
+
473
+ ing = PlanopticonIngester(_get_store(db), source_tag=source)
474
+
475
+ dispatch = {
476
+ "manifest": ing.ingest_manifest,
477
+ "kg": ing.ingest_kg,
478
+ "interchange": ing.ingest_interchange,
479
+ "batch": ing.ingest_batch,
480
+ }
481
+ stats = dispatch[input_type](p)
482
+
483
+ if as_json:
484
+ click.echo(json.dumps(stats, indent=2))
485
+ else:
486
+ table = Table(title=f"Planopticon import ({input_type})")
487
+ table.add_column("Metric", style="cyan")
488
+ table.add_column("Count", justify="right", style="green")
489
+ for k, v in stats.items():
490
+ table.add_row(k.capitalize(), str(v))
491
+ console.print(table)
492
+
419493
420494
# ── MCP ───────────────────────────────────────────────────────────────────────
421495
422496
@main.command()
423497
@DB_OPTION
424498
--- navegador/cli/commands.py
+++ navegador/cli/commands.py
@@ -414,10 +414,84 @@
414 edge_table.add_row(row[0], f"{row[1]:,}")
415
416 console.print(node_table)
417 console.print(edge_table)
418
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
419
420 # ── MCP ───────────────────────────────────────────────────────────────────────
421
422 @main.command()
423 @DB_OPTION
424
--- navegador/cli/commands.py
+++ navegador/cli/commands.py
@@ -414,10 +414,84 @@
414 edge_table.add_row(row[0], f"{row[1]:,}")
415
416 console.print(node_table)
417 console.print(edge_table)
418
419
420 # ── PLANOPTICON ingestion ──────────────────────────────────────────────────────
421
422 @main.group()
423 def planopticon():
424 """Ingest planopticon output (meetings, videos, docs) into the knowledge graph."""
425
426
427 @planopticon.command("ingest")
428 @click.argument("path", type=click.Path(exists=True))
429 @click.option("--type", "input_type",
430 type=click.Choice(["auto", "manifest", "kg", "interchange", "batch"]),
431 default="auto", show_default=True,
432 help="Input format. auto detects from filename.")
433 @click.option("--source", default="", help="Source label for provenance (e.g. 'Q4 planning').")
434 @click.option("--json", "as_json", is_flag=True)
435 @DB_OPTION
436 def planopticon_ingest(path: str, input_type: str, source: str, as_json: bool, db: str):
437 """Load a planopticon output directory or file into the knowledge graph.
438
439 PATH can be:
440 - A manifest.json file
441 - A knowledge_graph.json file
442 - An interchange.json file
443 - A batch manifest JSON
444 - A planopticon output directory (auto-detects manifest.json inside)
445 """
446 from pathlib import Path as P
447
448 from navegador.ingestion import PlanopticonIngester
449
450 p = P(path)
451 # Resolve directory → manifest.json
452 if p.is_dir():
453 candidates = ["manifest.json", "results/knowledge_graph.json", "interchange.json"]
454 for c in candidates:
455 if (p / c).exists():
456 p = p / c
457 break
458 else:
459 raise click.UsageError(f"No recognised planopticon file found in {path}")
460
461 # Auto-detect type from filename
462 if input_type == "auto":
463 name = p.name.lower()
464 if "manifest" in name:
465 input_type = "manifest"
466 elif "interchange" in name:
467 input_type = "interchange"
468 elif "batch" in name:
469 input_type = "batch"
470 else:
471 input_type = "kg"
472
473 ing = PlanopticonIngester(_get_store(db), source_tag=source)
474
475 dispatch = {
476 "manifest": ing.ingest_manifest,
477 "kg": ing.ingest_kg,
478 "interchange": ing.ingest_interchange,
479 "batch": ing.ingest_batch,
480 }
481 stats = dispatch[input_type](p)
482
483 if as_json:
484 click.echo(json.dumps(stats, indent=2))
485 else:
486 table = Table(title=f"Planopticon import ({input_type})")
487 table.add_column("Metric", style="cyan")
488 table.add_column("Count", justify="right", style="green")
489 for k, v in stats.items():
490 table.add_row(k.capitalize(), str(v))
491 console.print(table)
492
493
494 # ── MCP ───────────────────────────────────────────────────────────────────────
495
496 @main.command()
497 @DB_OPTION
498
--- navegador/ingestion/__init__.py
+++ navegador/ingestion/__init__.py
@@ -1,5 +1,6 @@
11
from .knowledge import KnowledgeIngester
22
from .parser import RepoIngester
3
+from .planopticon import PlanopticonIngester
34
from .wiki import WikiIngester
45
5
-__all__ = ["RepoIngester", "KnowledgeIngester", "WikiIngester"]
6
+__all__ = ["RepoIngester", "KnowledgeIngester", "WikiIngester", "PlanopticonIngester"]
67
78
ADDED navegador/ingestion/planopticon.py
--- navegador/ingestion/__init__.py
+++ navegador/ingestion/__init__.py
@@ -1,5 +1,6 @@
1 from .knowledge import KnowledgeIngester
2 from .parser import RepoIngester
 
3 from .wiki import WikiIngester
4
5 __all__ = ["RepoIngester", "KnowledgeIngester", "WikiIngester"]
6
7 DDED navegador/ingestion/planopticon.py
--- navegador/ingestion/__init__.py
+++ navegador/ingestion/__init__.py
@@ -1,5 +1,6 @@
1 from .knowledge import KnowledgeIngester
2 from .parser import RepoIngester
3 from .planopticon import PlanopticonIngester
4 from .wiki import WikiIngester
5
6 __all__ = ["RepoIngester", "KnowledgeIngester", "WikiIngester", "PlanopticonIngester"]
7
8 DDED navegador/ingestion/planopticon.py
--- a/navegador/ingestion/planopticon.py
+++ b/navegador/ingestion/planopticon.py
@@ -0,0 +1,476 @@
1
+"""
2
+PlanopticonIngester — loads planopticon output into the navegador knowledge graph.
3
+
4
+Planopticon extracts structured knowledge from videos, meetings, and documents:
5
+entities, relationships, key points, action items, and diagrams. This ingester
6
+maps that output onto navegador's knowledge layer so agents can query business
7
+context alongside code.
8
+
9
+Supported input:
10
+ - manifest.json (single video — primary entry point)
11
+ - interchange.json (canonical planopticon exchange format)
12
+ - knowledge_graph.json (raw KG export, no manifest required)
13
+
14
+Mapping:
15
+ planopticon → navegador
16
+ ─────────────────────────────────────────
17
+ node type=person → Person
18
+ node type=concept/technology → Concept
19
+ node type=organization → Concept (domain = "organization")
20
+ PlanningEntity type=decision → Decision
21
+ PlanningEntity type=requirement/constraint → Rule
22
+ diagram → WikiPage (content = mermaid/description)
23
+ key_point → Concept (tagged from source)
24
+ action_item.assignee → Person + ASSIGNED_TO edge
25
+ relationship.type → mapped EdgeType (see EDGE_MAP)
26
+
27
+Usage:
28
+ from navegador.ingestion.planopticon import PlanopticonIngester
29
+ store = GraphStore.sqlite(".navegador/graph.db")
30
+ ing = PlanopticonIngester(store)
31
+
32
+ stats = ing.ingest_manifest("planopticon-output/manifest.json")
33
+ stats = ing.ingest_kg("planopticon-output/results/knowledge_graph.json")
34
+ stats = ing.ingest_interchange("planopticon-output/interchange.json")
35
+"""
36
+
37
+import json
38
+import logging
39
+from pathlib import Path
40
+from typing import Any
41
+
42
+from navegador.graph.schema import EdgeType, NodeLabel
43
+from navegador.graph.store import GraphStore
44
+
45
+logger = logging.getLogger(__name__)
46
+
47
+# ── Relationship type mapping ─────────────────────────────────────────────────
48
+
49
+EDGE_MAP: dict[str, EdgeType] = {
50
+ "related_to": � Rule
51
+ diagram uses":get("edgDEPENDS_ON,
52
+ "built_on":depends_on": DEPENDS_ON,
53
+ "built_on":built_on": DEPENDS_ON,
54
+ ��─
55
+ node type=person requires": DEPENDS_ON,
56
+ "built_on":blocked_by": DEPENDS_ON,
57
+ "built_on":has_risk": � Rule
58
+ diagram addresses": � Rule
59
+ diagram ��───────� ��─
60
+ node ept/technolontity type=decision → Decowned_by": ntity type=decision → Decowns": tity type=decintity type=decision → Decworks_with": � Rule
61
+ d ]) -> None:
62
+ namermaid/description)
63
+ key_point → Concept (tagged from NodeLabel.Concept,
64
+ "technology": NodeLabel.Concept,
65
+ "organizae("planopticon-output/interchandiagram":{
66
+ planopticon import Pla "content":pticon import Planoptic{
67
+ ite(".navegador/graph.db")
68
+ ing = PlanopticonIngester(store)
69
+
70
+ stats = ing.ing output/manifest.json")
71
+ stats = ing.ingest_kg("planopticon-output/resullts/knowledge_graph.json")
72
+ trip()
73
+ Rule,
74
+ "goal": "content":opticon-output/interchange. NodeLabel.Concept,
75
+ "feature": NodeLabel.Concept,
76
+ "milestone": NodeLabel.Concept,
77
+ "task": "content":Concept,
78
+ "dependency": NodeLabel.Concept,
79
+}
80
+
81
+
82
+classngestion.planopticonraph.store import GraphStore
83
+
84
+logger = logging.getLogger(__name__)
85
+
86
+# ── Relationship type mapping ─────────────────────────────────────────────────
87
+
88
+EDGE_MAP: dict[str, EdgeType] = {
89
+ "related_to": EdgeType.RELATED_TO,
90
+ "uses": EdgeType.DEPENDS_ON,
91
+ "depends_on": EdgeType.DEPENDS_ON,
92
+ "built_on": EdgeType.DEPENDS_ON,
93
+ "implements": EdgeType.IMPLEMENTS,
94
+ "requires": EdgeType.DEPENDS_ON,
95
+ "blocked_by": EdgeType.DEPENDS_ON,
96
+ "has_risk": EdgeType.RELATED_TO,
97
+ "addresses": EdgeType.RELATED_TO,
98
+ "has_tradeoff": EdgeType.RELATED_TO,
99
+ "delivers": EdgeType.IMPLEMENTS,
100
+ "parent_of": EdgeType.CONTAINS,
101
+ "assigned_to": EdgeType.ASSIGNED_TO,
102
+ "owned_by": EdgeType.ASSIGNED_TO,
103
+ "owns": EdgeType.ASSIGNED_TO,
104
+ "employed_by": EdgeType.ASSIGNED_TO,
105
+ "works_with": EdgeType.RELATED_TO,
106
+ "governs": EdgeType.GOVERNS,
107
+ "documents": EdgeType.DOCUMENTS,
108
+}
109
+
110
+# planopticon node type → navegador NodeLabel
111
+NODE_TYPE_MAP: dict[str, NodeLabel] = {
112
+ "concept": NodeLabel.Concept,
113
+ "technology": NodeLabel.Concept,
114
+ "organization": NodeLabel.Concept,
115
+ "diagram": NodeLabel.WikiPage,
116
+ "time": NodeLabel.Concept,
117
+ "person": NodeLabel.Person,
118
+}
119
+
120
+# planning_type → navegador NodeLabel
121
+PLANNING_TYPE_MAP: dict[str, NodeLabel] = {
122
+ "decision": NodeLabel.Decision,
123
+ "requirement": NodeLabel.Rule,
124
+ "constraint": NodeLabel.Rule,
125
+ "risk": NodeLabel.Rule,
126
+ "goal": NodeLabel.Concept,
127
+ "assumption": NodeLabel.Concept,
128
+ "feature": NodeLabel.Concept,
129
+ "milestone": NodeLabel.Concept,
130
+ "task": NodeLabel.Concept,
131
+ "dependency": NodeLabel.Concept,
132
+}
133
+
134
+
135
+class PlanopticonIngester:
136
+ """
137
+ Reads planopticon output and writes it into a GraphStore.
138
+
139
+ All paths may be relative (resolved against the manifest's parent directory)
140
+ or absolute.
141
+ """
142
+
143
+ def __init__(self, store: GraphStore, source_tag: str = "") -> None:
144
+ self.store = store
145
+ self.source_tag = source_tag # optional label for provenance
146
+ self._stats: dic stats.get("nodes", 0), stats.get("edges", 0)ntent[:4000],
147
+ for rel in data.get("relkg(self, kg_path: str | Path) -> dict[str, int]:
148
+ """
149
+ Ingest a knowledge_graph.json (KnowledgeGraphData) directly.
150
+ Can be used standalone without a manifest.
151
+ """
152
+ kg_path = Path(kg_path).resolve()
153
+ data = self._load_json(kg_path)
154
+ stats = self._reset_stats()
155
+
156
+ for node in data.get("nodes", []):
157
+ self._ingest_kg_node(node)
158
+
159
+ for rel in data.get("relationships", []):
160
+ self._ingest_kg_relationship(rel)
161
+
162
+ # Ingest sources as WikiPage nodes
163
+ for source in data.get("sources", []):
164
+ self._ingest_source(source)
165
+
166
+ return stats
167
+
168
+ def ingest_interchange(self, interchange_path: str | Path) -> dict[str, int]:
169
+ """
170
+ Ingest a planopticon interchange.json (PlanOpticonExchange format).
171
+ Includes planning taxonomy, artifacts, and full entity graph.
172
+ """
173
+ interchange_path = Path(interchange_path).resolve()
174
+ data = self._load_json(interchange_path)
175
+ stats = self._reset_stats()
176
+
177
+ project = data.get("project", {})
178
+ project_name = project.get("name", interchange_path.parent.name)
179
+ self.source_tag = self.source_tag or project_name
180
+
181
+ # Domain from project tags
182
+ for tag in project.get("tags", []):
183
+ self.store.create_node(NodeLabel.Domain, {"name": tag, "description": ""})
184
+
185
+ # Entities (planning taxonomy takes priority over raw type)
186
+ for entity in data.get("entities", []):
187
+ planning_type = entity.get("planning_type")
188
+ if planning_type and planning_type in PLANNING_TYPE_MAP:
189
+ self._ingest_planning_entity(entity)
190
+ else:
191
+ self._ingest_kg_node(entity)
192
+
193
+ # Relationships
194
+ for rel in data.get("relationships", []):
195
+ self._ingest_kg_relationship(rel)
196
+
197
+ # Artifacts → WikiPage nodes
198
+ for artifact in data.get("artifacts", []):
199
+ self._ingest_artifact(artifact, project_name)
200
+
201
+ # Sources
202
+ for source in data.get("sou if no onto navegador's knowl"""
203
+P": "",
204
+ "updated_at": source.get("ingested_at", ""),
205
+ },
206
+ )
207
+ self._stats["nodes"] = self._stats.get("nodes", 0) + 1
208
+
209
+ def _ingest_artifact(self, artifact: dict[str, Any], project_name: str) -> None:
210
+ name = (artifact.get("name") or "").strip()
211
+ if not name:
212
+ return
213
+ content = artifact.get("content", "")
214
+ self.store.create_node(
215
+ NodeLabel.WikiPage,
216
+ {
217
+ "name": name,
218
+ "url": "",
219
+ "source": project_name,
220
+ "content": content[:4000],
221
+ },
222
+ )
223
+ self._stats["nodes"] = self._stats.get("nodes", 0) + 1
224
+
225
+ # ── Helpers ───────────────────────────────────────────────────────────────
226
+
227
+ def _ensure_domain(self, name: str) -> None:
228
+ self.store.create_node(NodeLabel.Domain, {"name": name, "description": ""})
229
+
230
+ def _lazy_wiki_link(self, name: str, label: NodeLabel, source_id: str) -> None:
231
+ """Create a DOCUMENTS edge from a WikiPage to this node if the page exists."""
232
+ try:
233
+ self.store.create_edge(
234
+ NodeLabel.WikiPage,
235
+ {"name": source_id},
236
+ EdgeType.DOCUMENTS,
237
+ label,
238
+ {"name": name},
239
+ )
240
+ except Exception:
241
+ logger.debug("Could not link %s to wiki page %s", name, source_id)
242
+
243
+ def _load_json(self, path: Path) -> Any:
244
+ return json.loads(Path(path).read_text(encoding="utf-8"))
245
+
246
+ def _reset_stats(self) -> dict[str, int]:
247
+ self._stats = {"nodes": 0, "edges": 0}
248
+ return self._stats
249
+
250
+ def _merge_stats(self, other: dict[str, int]) -> None:
251
+ for k, v in other.items():
252
+ self._stats[k] = self._stats.get(k, 0) + v
253
+role": node.get("r, ""),
254
+ } and documents:
255
+entities, relationships, key points, action items, and diagrams"""
256
+PlanopticonInge onto navegador's knowledge l"""
257
+Planoptic, ""),
258
+ "content": descr})
259
+ "n },
260
+ )
261
+ else:
262
+ domain = "organization" if raw_type == "organization" else node.get("domain", "")
263
+ },
264
+ )
265
+
266
+ onto navegador's knowl"""
267
+PlanopticonI"""
268
+PlanopticonIngester — loads sub = self.i})
269
+ if rn stats
270
+
271
+ # ── Node ingestion ──────────────────────────────� {"name": name},
272
+ ", 0) + 1
273
+
274
+ def _ingest_kg_relat {"name": domain},
275
+ )
276
+
277
+ self._stats["nodes"] = self._stats.get("nodes", 0) + 1
278
+
279
+ P.get(raw_type, NodeLabel.Concept)
280
+
281
+ name = (node.get("name") or node.get("id") or "").strip()
282
+ if not name:
283
+ return
284
+
285
+ descriptions = node.get("descriptions", [])
286
+ description = descriptions[0] if descriptions else node.get("description", "")
287
+
288
+ if label == NodeLabel.Person:
289
+ self.store.create_node(
290
+ NodeLabel.Person,
291
+ {
292
+ "name": name,
293
+ "email": "",
294
+ "role": node.get("role", ""),
295
+ "team": node.get("organization", ""),
296
+ },
297
+ )
298
+ elif label == NodeLabel.WikiPage:
299
+ self.store.create_node(
300
+ NodeLabel.WikiPautput/manifest.json onto navegador's knowl"""
301
+PlanopticonI"""
302
+PlanopticonIngester — loads "status": status else:
303
+ domain"rationale": entity.get("ra} and documents:
304
+entities, relation"""
305
+PlanopticoNodeLabel.Rule, onto navegador's knowl"""
306
+PlanopticonI"""
307
+PlanopticonIngester — loads "severity": "critical" if priority == "high" else "info",
308
+ "rationale": entity.get("ra})
309
+ "nels self.store},
310
+ )
311
+
312
+ onto navegador's knowl"""
313
+PlanopticonI"""
314
+PlanopticonIngester — loads "status": status,
315
+ })
316
+
317
+ conceptmerge_stats(sub)
318
+
319
+ ode ingestiondiagrams. Thi"""
320
+PlBELONGS_TOr, Any]) -> None:
321
+NodeLabel.Domain, {"name": domain},
322
+ )
323
+
324
+ self._stats["nodes"] = self._stats.get("nodes", 0) + 1
325
+
326
+ def _ingest_kg_relationship(self, rel: dict[str, Any]) -> None:
327
+ src = (rel.get("source") or "").strip()
328
+ tgt = (rel.get("target") or "").strip()
329
+ rel_type = (rel.get("type") or "related_to").lower().replace(" ", "_")
330
+
331
+ if not src or not tgt:
332
+ return
333
+
334
+ edge_type = EDGE_MAP.get(rel_type, EdgeType.RELATED_TO)
335
+
336
+ # We don't know the exact label of each node — use a label-agnos cypher = (
337
+ """
338
+ MATCH (a), (b)
339
+ WHERE a.name = $src AND b.name = $t + edge_type + """]->(b)
340
+ """
341
+ """
342
+ )
343
+ try:
344
+ self.store.query(cypher, {"src": src, "tgt": tgt})
345
+ self._stats["edges"] = self._stats.get("edges", 0) + 1
346
+ except Exception:
347
+ logger.warning("Could not create edge %s -[%s]-> %s", src, edge_type, tgt)
348
+
349
+ def _ingest_key_points(self, key_points: list[dict], source: str) -> None:
350
+ for kp in key_points:
351
+ point = (kp.get("point") or "").strip()
352
+ if not point:
353
+ continue
354
+ topic = kp.get("topic") or ""
355
+ name = point[:120] # use the point text as the concept name
356
+ },
357
+ )
358
+
359
+ onto navegador's knowl"""
360
+PlanopticonIngester �, ""),
361
+ "domai
362
+PlanopticonIngester — loads plan )
363
+ if topic:
364
+ me": name},
365
+ EdgeType.BELONGS_TO,
366
+ NodeLabel.Dom {"name": name},
367
+ ", 0) + 1
368
+
369
+ def _ingest_kg_relatict[str, Any]) -> None:
370
+ src = (rel.get("sdebu) or "").strip()
371
+ tgt = (rel.get("target") or "").strip()
372
+ rel_type = (rel.get("type") or "related_to").lower().replace(" ", "_")
373
+
374
+ if not src or not tgt:
375
+ return
376
+
377
+ edge_type = EDGE_MAP.get(rel_type, EdgeType.RELATED_TO)
378
+
379
+ # We don't know the exact label of each node — use a label-agnostic match
380
+ cypher = (
381
+ """
382
+ MATCH (a), (b)
383
+ NodeLabel.Rule, onto navegador's knowledge"""
384
+PlanopticonIngester — lo.store.query(cypher, {"src": ": "",
385
+ "domainms, and diagrams. This ingester"""
386
+PlanopticonIngester — er, {"src": src, "tgt": tgt}nodes"] = self._stats.get("aw_type, NodeLabel.Concesource: str) -> None:
387
+ if no {
388
+ "name": assignee, "em": "",
389
+ "updated_at": source.get("ingested_at", ""),
390
+ },
391
+ )
392
+ self._stats["nodes"] = self._stats.get("nodes", 0) + 1
393
+
394
+ def _ingest_artifact(self, artifact: dict[str, Any], project_name: str) -> None:
395
+ name = (artifact.get("name") or "").strip()
396
+ if not name:
397
+ return
398
+ content = artifact.get("content", "")
399
+ self.store.create_node(
400
+ NodeLabel.WikiPage,
401
+ {
402
+ "name": name,
403
+ "url": "",
404
+ "source": project_name,
405
+ "content": content[:4000],
406
+ },
407
+ )
408
+ self._stats["nodes"] = self._stats.get("nodes", 0) + 1
409
+
410
+ # ── Helpers ───────────────────────────────────────────────────────────────
411
+
412
+ def _ensure_domain(self, name: str) -> None:
413
+ self.store.create_node(NodeLabel.Domain, {"name": name, "description": ""})
414
+
415
+ def _lazy_wiki_link(self, name: str, label: NodeLabel, source_id: str) -> None:
416
+ """Create a DOCUMENTS edge from a WikiPage to this node if the page exists."""
417
+ try:
418
+ self.store.create_edge(
419
+ NodeLabel.WikiPage,
420
+ {"name": source_id},
421
+ EdgeType.DOCUMENTS,
422
+ label,
423
+ {"name": name},
424
+ )
425
+ except Exception:
426
+ logger.debug("Could not link %s to wiki page %s", name, source_id)
427
+
428
+ def _load_json(self, path: Path) -> Any:
429
+ return json.loads(Path(path).read_text(encoding="utf-8"))
430
+
431
+ def _reset_stats(self) -> dict[str, int]:
432
+ self._stats = {"nodes": 0, "edges": 0}
433
+ return self._stats
434
+
435
+ def _merge_stats(self, other: dict[str, int]) -> None:
436
+ for k, v in other.items():
437
+ self._stats[k] = self._stats.get(k, 0) + v
438
+}assignee},
439
+ store.create_edge(
440
+
441
+ + edgeget("details", ""),
442
+ "domain": topic,
443
+ {"name": assignee},
444
+ )
445
+ self._stats["edges"] = self._stats.get("edges", 0) + 1
446
+
447
+ def _ingest_diagram(self, diagram: dict[str, Any], base_dir: Path, source: str) -> None:
448
+ dtype = diagram.get("diagram_type", "diagram")
449
+ desc = diagram.get("description") or diagram.get("text_content") or ""
450
+ mermaid = diagram.get("mermaid", "")
451
+ ts = diagram.get("timestamp")
452
+ name = f"{dtype.capitalize()} @ {ts:.0f}s" if ts is not None else f"{dtype.capitalize()}"
453
+
454
+ content = mermaid or desc
455
+ self.store.create_node(NodeLabel.WikiPage, {
456
+"
457
+Planopticngester — loads planoath", ""),
458
+ "so as concepts
459
+ for element in}) raw_type = node.get("type", "concept")
460
+ label = NODE_TYPE_MAP.get(Link diagram elements as concepts
461
+ for element in diagram.get("elements", []):
462
+ element = element.strip()
463
+ if not element:
464
+ continue
465
+ self.store.create_node(},
466
+ )
467
+
468
+ onto navegador's knowledge"""
469
+PlanopticonIngester —"""
470
+PlanopticonIng "status": "",
471
+ }) passype", "concept")
472
+ labtryam elements as concepts
473
+ except FileNotFoundErrorepts
474
+ for elemwarning("File not found: %s", path)ador's knowledge la"""
475
+PlanopticonInepts
476
+
--- a/navegador/ingestion/planopticon.py
+++ b/navegador/ingestion/planopticon.py
@@ -0,0 +1,476 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/navegador/ingestion/planopticon.py
+++ b/navegador/ingestion/planopticon.py
@@ -0,0 +1,476 @@
1 """
2 PlanopticonIngester — loads planopticon output into the navegador knowledge graph.
3
4 Planopticon extracts structured knowledge from videos, meetings, and documents:
5 entities, relationships, key points, action items, and diagrams. This ingester
6 maps that output onto navegador's knowledge layer so agents can query business
7 context alongside code.
8
9 Supported input:
10 - manifest.json (single video — primary entry point)
11 - interchange.json (canonical planopticon exchange format)
12 - knowledge_graph.json (raw KG export, no manifest required)
13
14 Mapping:
15 planopticon → navegador
16 ─────────────────────────────────────────
17 node type=person → Person
18 node type=concept/technology → Concept
19 node type=organization → Concept (domain = "organization")
20 PlanningEntity type=decision → Decision
21 PlanningEntity type=requirement/constraint → Rule
22 diagram → WikiPage (content = mermaid/description)
23 key_point → Concept (tagged from source)
24 action_item.assignee → Person + ASSIGNED_TO edge
25 relationship.type → mapped EdgeType (see EDGE_MAP)
26
27 Usage:
28 from navegador.ingestion.planopticon import PlanopticonIngester
29 store = GraphStore.sqlite(".navegador/graph.db")
30 ing = PlanopticonIngester(store)
31
32 stats = ing.ingest_manifest("planopticon-output/manifest.json")
33 stats = ing.ingest_kg("planopticon-output/results/knowledge_graph.json")
34 stats = ing.ingest_interchange("planopticon-output/interchange.json")
35 """
36
37 import json
38 import logging
39 from pathlib import Path
40 from typing import Any
41
42 from navegador.graph.schema import EdgeType, NodeLabel
43 from navegador.graph.store import GraphStore
44
45 logger = logging.getLogger(__name__)
46
47 # ── Relationship type mapping ─────────────────────────────────────────────────
48
49 EDGE_MAP: dict[str, EdgeType] = {
50 "related_to": � Rule
51 diagram uses":get("edgDEPENDS_ON,
52 "built_on":depends_on": DEPENDS_ON,
53 "built_on":built_on": DEPENDS_ON,
54 ��─
55 node type=person requires": DEPENDS_ON,
56 "built_on":blocked_by": DEPENDS_ON,
57 "built_on":has_risk": � Rule
58 diagram addresses": � Rule
59 diagram ��───────� ��─
60 node ept/technolontity type=decision → Decowned_by": ntity type=decision → Decowns": tity type=decintity type=decision → Decworks_with": � Rule
61 d ]) -> None:
62 namermaid/description)
63 key_point → Concept (tagged from NodeLabel.Concept,
64 "technology": NodeLabel.Concept,
65 "organizae("planopticon-output/interchandiagram":{
66 planopticon import Pla "content":pticon import Planoptic{
67 ite(".navegador/graph.db")
68 ing = PlanopticonIngester(store)
69
70 stats = ing.ing output/manifest.json")
71 stats = ing.ingest_kg("planopticon-output/resullts/knowledge_graph.json")
72 trip()
73 Rule,
74 "goal": "content":opticon-output/interchange. NodeLabel.Concept,
75 "feature": NodeLabel.Concept,
76 "milestone": NodeLabel.Concept,
77 "task": "content":Concept,
78 "dependency": NodeLabel.Concept,
79 }
80
81
82 classngestion.planopticonraph.store import GraphStore
83
84 logger = logging.getLogger(__name__)
85
86 # ── Relationship type mapping ─────────────────────────────────────────────────
87
88 EDGE_MAP: dict[str, EdgeType] = {
89 "related_to": EdgeType.RELATED_TO,
90 "uses": EdgeType.DEPENDS_ON,
91 "depends_on": EdgeType.DEPENDS_ON,
92 "built_on": EdgeType.DEPENDS_ON,
93 "implements": EdgeType.IMPLEMENTS,
94 "requires": EdgeType.DEPENDS_ON,
95 "blocked_by": EdgeType.DEPENDS_ON,
96 "has_risk": EdgeType.RELATED_TO,
97 "addresses": EdgeType.RELATED_TO,
98 "has_tradeoff": EdgeType.RELATED_TO,
99 "delivers": EdgeType.IMPLEMENTS,
100 "parent_of": EdgeType.CONTAINS,
101 "assigned_to": EdgeType.ASSIGNED_TO,
102 "owned_by": EdgeType.ASSIGNED_TO,
103 "owns": EdgeType.ASSIGNED_TO,
104 "employed_by": EdgeType.ASSIGNED_TO,
105 "works_with": EdgeType.RELATED_TO,
106 "governs": EdgeType.GOVERNS,
107 "documents": EdgeType.DOCUMENTS,
108 }
109
110 # planopticon node type → navegador NodeLabel
111 NODE_TYPE_MAP: dict[str, NodeLabel] = {
112 "concept": NodeLabel.Concept,
113 "technology": NodeLabel.Concept,
114 "organization": NodeLabel.Concept,
115 "diagram": NodeLabel.WikiPage,
116 "time": NodeLabel.Concept,
117 "person": NodeLabel.Person,
118 }
119
120 # planning_type → navegador NodeLabel
121 PLANNING_TYPE_MAP: dict[str, NodeLabel] = {
122 "decision": NodeLabel.Decision,
123 "requirement": NodeLabel.Rule,
124 "constraint": NodeLabel.Rule,
125 "risk": NodeLabel.Rule,
126 "goal": NodeLabel.Concept,
127 "assumption": NodeLabel.Concept,
128 "feature": NodeLabel.Concept,
129 "milestone": NodeLabel.Concept,
130 "task": NodeLabel.Concept,
131 "dependency": NodeLabel.Concept,
132 }
133
134
135 class PlanopticonIngester:
136 """
137 Reads planopticon output and writes it into a GraphStore.
138
139 All paths may be relative (resolved against the manifest's parent directory)
140 or absolute.
141 """
142
143 def __init__(self, store: GraphStore, source_tag: str = "") -> None:
144 self.store = store
145 self.source_tag = source_tag # optional label for provenance
146 self._stats: dic stats.get("nodes", 0), stats.get("edges", 0)ntent[:4000],
147 for rel in data.get("relkg(self, kg_path: str | Path) -> dict[str, int]:
148 """
149 Ingest a knowledge_graph.json (KnowledgeGraphData) directly.
150 Can be used standalone without a manifest.
151 """
152 kg_path = Path(kg_path).resolve()
153 data = self._load_json(kg_path)
154 stats = self._reset_stats()
155
156 for node in data.get("nodes", []):
157 self._ingest_kg_node(node)
158
159 for rel in data.get("relationships", []):
160 self._ingest_kg_relationship(rel)
161
162 # Ingest sources as WikiPage nodes
163 for source in data.get("sources", []):
164 self._ingest_source(source)
165
166 return stats
167
168 def ingest_interchange(self, interchange_path: str | Path) -> dict[str, int]:
169 """
170 Ingest a planopticon interchange.json (PlanOpticonExchange format).
171 Includes planning taxonomy, artifacts, and full entity graph.
172 """
173 interchange_path = Path(interchange_path).resolve()
174 data = self._load_json(interchange_path)
175 stats = self._reset_stats()
176
177 project = data.get("project", {})
178 project_name = project.get("name", interchange_path.parent.name)
179 self.source_tag = self.source_tag or project_name
180
181 # Domain from project tags
182 for tag in project.get("tags", []):
183 self.store.create_node(NodeLabel.Domain, {"name": tag, "description": ""})
184
185 # Entities (planning taxonomy takes priority over raw type)
186 for entity in data.get("entities", []):
187 planning_type = entity.get("planning_type")
188 if planning_type and planning_type in PLANNING_TYPE_MAP:
189 self._ingest_planning_entity(entity)
190 else:
191 self._ingest_kg_node(entity)
192
193 # Relationships
194 for rel in data.get("relationships", []):
195 self._ingest_kg_relationship(rel)
196
197 # Artifacts → WikiPage nodes
198 for artifact in data.get("artifacts", []):
199 self._ingest_artifact(artifact, project_name)
200
201 # Sources
202 for source in data.get("sou if no onto navegador's knowl"""
203 P": "",
204 "updated_at": source.get("ingested_at", ""),
205 },
206 )
207 self._stats["nodes"] = self._stats.get("nodes", 0) + 1
208
209 def _ingest_artifact(self, artifact: dict[str, Any], project_name: str) -> None:
210 name = (artifact.get("name") or "").strip()
211 if not name:
212 return
213 content = artifact.get("content", "")
214 self.store.create_node(
215 NodeLabel.WikiPage,
216 {
217 "name": name,
218 "url": "",
219 "source": project_name,
220 "content": content[:4000],
221 },
222 )
223 self._stats["nodes"] = self._stats.get("nodes", 0) + 1
224
225 # ── Helpers ───────────────────────────────────────────────────────────────
226
227 def _ensure_domain(self, name: str) -> None:
228 self.store.create_node(NodeLabel.Domain, {"name": name, "description": ""})
229
230 def _lazy_wiki_link(self, name: str, label: NodeLabel, source_id: str) -> None:
231 """Create a DOCUMENTS edge from a WikiPage to this node if the page exists."""
232 try:
233 self.store.create_edge(
234 NodeLabel.WikiPage,
235 {"name": source_id},
236 EdgeType.DOCUMENTS,
237 label,
238 {"name": name},
239 )
240 except Exception:
241 logger.debug("Could not link %s to wiki page %s", name, source_id)
242
243 def _load_json(self, path: Path) -> Any:
244 return json.loads(Path(path).read_text(encoding="utf-8"))
245
246 def _reset_stats(self) -> dict[str, int]:
247 self._stats = {"nodes": 0, "edges": 0}
248 return self._stats
249
250 def _merge_stats(self, other: dict[str, int]) -> None:
251 for k, v in other.items():
252 self._stats[k] = self._stats.get(k, 0) + v
253 role": node.get("r, ""),
254 } and documents:
255 entities, relationships, key points, action items, and diagrams"""
256 PlanopticonInge onto navegador's knowledge l"""
257 Planoptic, ""),
258 "content": descr})
259 "n },
260 )
261 else:
262 domain = "organization" if raw_type == "organization" else node.get("domain", "")
263 },
264 )
265
266 onto navegador's knowl"""
267 PlanopticonI"""
268 PlanopticonIngester — loads sub = self.i})
269 if rn stats
270
271 # ── Node ingestion ──────────────────────────────� {"name": name},
272 ", 0) + 1
273
274 def _ingest_kg_relat {"name": domain},
275 )
276
277 self._stats["nodes"] = self._stats.get("nodes", 0) + 1
278
279 P.get(raw_type, NodeLabel.Concept)
280
281 name = (node.get("name") or node.get("id") or "").strip()
282 if not name:
283 return
284
285 descriptions = node.get("descriptions", [])
286 description = descriptions[0] if descriptions else node.get("description", "")
287
288 if label == NodeLabel.Person:
289 self.store.create_node(
290 NodeLabel.Person,
291 {
292 "name": name,
293 "email": "",
294 "role": node.get("role", ""),
295 "team": node.get("organization", ""),
296 },
297 )
298 elif label == NodeLabel.WikiPage:
299 self.store.create_node(
300 NodeLabel.WikiPautput/manifest.json onto navegador's knowl"""
301 PlanopticonI"""
302 PlanopticonIngester — loads "status": status else:
303 domain"rationale": entity.get("ra} and documents:
304 entities, relation"""
305 PlanopticoNodeLabel.Rule, onto navegador's knowl"""
306 PlanopticonI"""
307 PlanopticonIngester — loads "severity": "critical" if priority == "high" else "info",
308 "rationale": entity.get("ra})
309 "nels self.store},
310 )
311
312 onto navegador's knowl"""
313 PlanopticonI"""
314 PlanopticonIngester — loads "status": status,
315 })
316
317 conceptmerge_stats(sub)
318
319 ode ingestiondiagrams. Thi"""
320 PlBELONGS_TOr, Any]) -> None:
321 NodeLabel.Domain, {"name": domain},
322 )
323
324 self._stats["nodes"] = self._stats.get("nodes", 0) + 1
325
326 def _ingest_kg_relationship(self, rel: dict[str, Any]) -> None:
327 src = (rel.get("source") or "").strip()
328 tgt = (rel.get("target") or "").strip()
329 rel_type = (rel.get("type") or "related_to").lower().replace(" ", "_")
330
331 if not src or not tgt:
332 return
333
334 edge_type = EDGE_MAP.get(rel_type, EdgeType.RELATED_TO)
335
336 # We don't know the exact label of each node — use a label-agnos cypher = (
337 """
338 MATCH (a), (b)
339 WHERE a.name = $src AND b.name = $t + edge_type + """]->(b)
340 """
341 """
342 )
343 try:
344 self.store.query(cypher, {"src": src, "tgt": tgt})
345 self._stats["edges"] = self._stats.get("edges", 0) + 1
346 except Exception:
347 logger.warning("Could not create edge %s -[%s]-> %s", src, edge_type, tgt)
348
349 def _ingest_key_points(self, key_points: list[dict], source: str) -> None:
350 for kp in key_points:
351 point = (kp.get("point") or "").strip()
352 if not point:
353 continue
354 topic = kp.get("topic") or ""
355 name = point[:120] # use the point text as the concept name
356 },
357 )
358
359 onto navegador's knowl"""
360 PlanopticonIngester �, ""),
361 "domai
362 PlanopticonIngester — loads plan )
363 if topic:
364 me": name},
365 EdgeType.BELONGS_TO,
366 NodeLabel.Dom {"name": name},
367 ", 0) + 1
368
369 def _ingest_kg_relatict[str, Any]) -> None:
370 src = (rel.get("sdebu) or "").strip()
371 tgt = (rel.get("target") or "").strip()
372 rel_type = (rel.get("type") or "related_to").lower().replace(" ", "_")
373
374 if not src or not tgt:
375 return
376
377 edge_type = EDGE_MAP.get(rel_type, EdgeType.RELATED_TO)
378
379 # We don't know the exact label of each node — use a label-agnostic match
380 cypher = (
381 """
382 MATCH (a), (b)
383 NodeLabel.Rule, onto navegador's knowledge"""
384 PlanopticonIngester — lo.store.query(cypher, {"src": ": "",
385 "domainms, and diagrams. This ingester"""
386 PlanopticonIngester — er, {"src": src, "tgt": tgt}nodes"] = self._stats.get("aw_type, NodeLabel.Concesource: str) -> None:
387 if no {
388 "name": assignee, "em": "",
389 "updated_at": source.get("ingested_at", ""),
390 },
391 )
392 self._stats["nodes"] = self._stats.get("nodes", 0) + 1
393
394 def _ingest_artifact(self, artifact: dict[str, Any], project_name: str) -> None:
395 name = (artifact.get("name") or "").strip()
396 if not name:
397 return
398 content = artifact.get("content", "")
399 self.store.create_node(
400 NodeLabel.WikiPage,
401 {
402 "name": name,
403 "url": "",
404 "source": project_name,
405 "content": content[:4000],
406 },
407 )
408 self._stats["nodes"] = self._stats.get("nodes", 0) + 1
409
410 # ── Helpers ───────────────────────────────────────────────────────────────
411
412 def _ensure_domain(self, name: str) -> None:
413 self.store.create_node(NodeLabel.Domain, {"name": name, "description": ""})
414
415 def _lazy_wiki_link(self, name: str, label: NodeLabel, source_id: str) -> None:
416 """Create a DOCUMENTS edge from a WikiPage to this node if the page exists."""
417 try:
418 self.store.create_edge(
419 NodeLabel.WikiPage,
420 {"name": source_id},
421 EdgeType.DOCUMENTS,
422 label,
423 {"name": name},
424 )
425 except Exception:
426 logger.debug("Could not link %s to wiki page %s", name, source_id)
427
428 def _load_json(self, path: Path) -> Any:
429 return json.loads(Path(path).read_text(encoding="utf-8"))
430
431 def _reset_stats(self) -> dict[str, int]:
432 self._stats = {"nodes": 0, "edges": 0}
433 return self._stats
434
435 def _merge_stats(self, other: dict[str, int]) -> None:
436 for k, v in other.items():
437 self._stats[k] = self._stats.get(k, 0) + v
438 }assignee},
439 store.create_edge(
440
441 + edgeget("details", ""),
442 "domain": topic,
443 {"name": assignee},
444 )
445 self._stats["edges"] = self._stats.get("edges", 0) + 1
446
447 def _ingest_diagram(self, diagram: dict[str, Any], base_dir: Path, source: str) -> None:
448 dtype = diagram.get("diagram_type", "diagram")
449 desc = diagram.get("description") or diagram.get("text_content") or ""
450 mermaid = diagram.get("mermaid", "")
451 ts = diagram.get("timestamp")
452 name = f"{dtype.capitalize()} @ {ts:.0f}s" if ts is not None else f"{dtype.capitalize()}"
453
454 content = mermaid or desc
455 self.store.create_node(NodeLabel.WikiPage, {
456 "
457 Planopticngester — loads planoath", ""),
458 "so as concepts
459 for element in}) raw_type = node.get("type", "concept")
460 label = NODE_TYPE_MAP.get(Link diagram elements as concepts
461 for element in diagram.get("elements", []):
462 element = element.strip()
463 if not element:
464 continue
465 self.store.create_node(},
466 )
467
468 onto navegador's knowledge"""
469 PlanopticonIngester —"""
470 PlanopticonIng "status": "",
471 }) passype", "concept")
472 labtryam elements as concepts
473 except FileNotFoundErrorepts
474 for elemwarning("File not found: %s", path)ador's knowledge la"""
475 PlanopticonInepts
476

Keyboard Shortcuts

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