PlanOpticon

planopticon / video_processor / exchange.py
Source Blame History 209 lines
0981a08… noreply 1 """PlanOpticonExchange -- canonical interchange format.
0981a08… noreply 2
0981a08… noreply 3 Every command produces it, every export adapter consumes it.
0981a08… noreply 4 """
0981a08… noreply 5
0981a08… noreply 6 from __future__ import annotations
0981a08… noreply 7
0981a08… noreply 8 import json
0981a08… noreply 9 from datetime import datetime
0981a08… noreply 10 from pathlib import Path
0981a08… noreply 11 from typing import Any, Dict, List, Optional
0981a08… noreply 12
0981a08… noreply 13 from pydantic import BaseModel, Field
0981a08… noreply 14
0981a08… noreply 15 from video_processor.models import Entity, Relationship, SourceRecord
0981a08… noreply 16
0981a08… noreply 17
0981a08… noreply 18 class ArtifactMeta(BaseModel):
0981a08… noreply 19 """Pydantic mirror of the Artifact dataclass for serialisation."""
0981a08… noreply 20
0981a08… noreply 21 name: str = Field(description="Artifact name")
0981a08… noreply 22 content: str = Field(description="Generated content (markdown, json, etc.)")
0981a08… noreply 23 artifact_type: str = Field(
0981a08… noreply 24 description="Artifact kind: project_plan, prd, roadmap, task_list, document, issues"
0981a08… noreply 25 )
0981a08… noreply 26 format: str = Field(
0981a08… noreply 27 default="markdown",
0981a08… noreply 28 description="Content format: markdown, json, mermaid",
0981a08… noreply 29 )
0981a08… noreply 30 metadata: Dict[str, Any] = Field(
0981a08… noreply 31 default_factory=dict,
0981a08… noreply 32 description="Arbitrary key-value metadata",
0981a08… noreply 33 )
0981a08… noreply 34
0981a08… noreply 35
0981a08… noreply 36 class ProjectMeta(BaseModel):
0981a08… noreply 37 """Lightweight project descriptor embedded in an exchange payload."""
0981a08… noreply 38
0981a08… noreply 39 name: str = Field(description="Project name")
0981a08… noreply 40 description: str = Field(
0981a08… noreply 41 default="",
0981a08… noreply 42 description="Short project description",
0981a08… noreply 43 )
0981a08… noreply 44 created_at: str = Field(
0981a08… noreply 45 default_factory=lambda: datetime.now().isoformat(),
0981a08… noreply 46 description="ISO-8601 creation timestamp",
0981a08… noreply 47 )
0981a08… noreply 48 updated_at: str = Field(
0981a08… noreply 49 default_factory=lambda: datetime.now().isoformat(),
0981a08… noreply 50 description="ISO-8601 last-updated timestamp",
0981a08… noreply 51 )
0981a08… noreply 52 tags: List[str] = Field(
0981a08… noreply 53 default_factory=list,
0981a08… noreply 54 description="Freeform tags for categorisation",
0981a08… noreply 55 )
0981a08… noreply 56
0981a08… noreply 57
0981a08… noreply 58 class PlanOpticonExchange(BaseModel):
0981a08… noreply 59 """Wire format for PlanOpticon data interchange.
0981a08… noreply 60
0981a08… noreply 61 Produced by every command, consumed by every export adapter.
0981a08… noreply 62 """
0981a08… noreply 63
0981a08… noreply 64 version: str = Field(
0981a08… noreply 65 default="1.0",
0981a08… noreply 66 description="Schema version of this exchange payload",
0981a08… noreply 67 )
0981a08… noreply 68 project: ProjectMeta = Field(
0981a08… noreply 69 description="Project-level metadata",
0981a08… noreply 70 )
0981a08… noreply 71 entities: List[Entity] = Field(
0981a08… noreply 72 default_factory=list,
0981a08… noreply 73 description="Knowledge-graph entities",
0981a08… noreply 74 )
0981a08… noreply 75 relationships: List[Relationship] = Field(
0981a08… noreply 76 default_factory=list,
0981a08… noreply 77 description="Knowledge-graph relationships",
0981a08… noreply 78 )
0981a08… noreply 79 artifacts: List[ArtifactMeta] = Field(
0981a08… noreply 80 default_factory=list,
0981a08… noreply 81 description="Generated artifacts (plans, PRDs, etc.)",
0981a08… noreply 82 )
0981a08… noreply 83 sources: List[SourceRecord] = Field(
0981a08… noreply 84 default_factory=list,
0981a08… noreply 85 description="Content-source provenance records",
0981a08… noreply 86 )
0981a08… noreply 87
0981a08… noreply 88 # ------------------------------------------------------------------
0981a08… noreply 89 # Convenience helpers
0981a08… noreply 90 # ------------------------------------------------------------------
0981a08… noreply 91
0981a08… noreply 92 @classmethod
0981a08… noreply 93 def json_schema(cls) -> Dict[str, Any]:
0981a08… noreply 94 """Return the JSON Schema for validation / documentation."""
0981a08… noreply 95 return cls.model_json_schema()
0981a08… noreply 96
0981a08… noreply 97 @classmethod
0981a08… noreply 98 def from_knowledge_graph(
0981a08… noreply 99 cls,
0981a08… noreply 100 kg_data: Dict[str, Any],
0981a08… noreply 101 *,
0981a08… noreply 102 project_name: str = "Untitled",
0981a08… noreply 103 project_description: str = "",
0981a08… noreply 104 tags: Optional[List[str]] = None,
0981a08… noreply 105 ) -> "PlanOpticonExchange":
0981a08… noreply 106 """Build an exchange payload from a ``KnowledgeGraph.to_dict()`` dict.
0981a08… noreply 107
0981a08… noreply 108 The dict is expected to have ``nodes`` and ``relationships`` keys,
0981a08… noreply 109 with an optional ``sources`` key.
0981a08… noreply 110 """
0981a08… noreply 111 entities = [Entity(**_normalise_entity(n)) for n in kg_data.get("nodes", [])]
0981a08… noreply 112 relationships = [
0981a08… noreply 113 Relationship(**_normalise_relationship(r)) for r in kg_data.get("relationships", [])
0981a08… noreply 114 ]
0981a08… noreply 115 sources = [SourceRecord(**s) for s in kg_data.get("sources", [])]
0981a08… noreply 116
0981a08… noreply 117 now = datetime.now().isoformat()
0981a08… noreply 118 project = ProjectMeta(
0981a08… noreply 119 name=project_name,
0981a08… noreply 120 description=project_description,
0981a08… noreply 121 created_at=now,
0981a08… noreply 122 updated_at=now,
0981a08… noreply 123 tags=tags or [],
0981a08… noreply 124 )
0981a08… noreply 125
0981a08… noreply 126 return cls(
0981a08… noreply 127 project=project,
0981a08… noreply 128 entities=entities,
0981a08… noreply 129 relationships=relationships,
0981a08… noreply 130 sources=sources,
0981a08… noreply 131 )
0981a08… noreply 132
0981a08… noreply 133 # ------------------------------------------------------------------
0981a08… noreply 134 # File I/O
0981a08… noreply 135 # ------------------------------------------------------------------
0981a08… noreply 136
0981a08… noreply 137 def to_file(self, path: str | Path) -> Path:
0981a08… noreply 138 """Serialise this exchange to a JSON file."""
0981a08… noreply 139 path = Path(path)
0981a08… noreply 140 path.parent.mkdir(parents=True, exist_ok=True)
0981a08… noreply 141 path.write_text(self.model_dump_json(indent=2))
0981a08… noreply 142 return path
0981a08… noreply 143
0981a08… noreply 144 @classmethod
0981a08… noreply 145 def from_file(cls, path: str | Path) -> "PlanOpticonExchange":
0981a08… noreply 146 """Deserialise an exchange from a JSON file."""
0981a08… noreply 147 path = Path(path)
0981a08… noreply 148 raw = json.loads(path.read_text())
0981a08… noreply 149 return cls.model_validate(raw)
0981a08… noreply 150
0981a08… noreply 151 # ------------------------------------------------------------------
0981a08… noreply 152 # Merge
0981a08… noreply 153 # ------------------------------------------------------------------
0981a08… noreply 154
0981a08… noreply 155 def merge(self, other: "PlanOpticonExchange") -> None:
0981a08… noreply 156 """Merge *other* into this exchange, deduplicating entities by name."""
0981a08… noreply 157 existing_names = {e.name for e in self.entities}
0981a08… noreply 158 for entity in other.entities:
0981a08… noreply 159 if entity.name not in existing_names:
0981a08… noreply 160 self.entities.append(entity)
0981a08… noreply 161 existing_names.add(entity.name)
0981a08… noreply 162
0981a08… noreply 163 existing_rels = {(r.source, r.target, r.type) for r in self.relationships}
0981a08… noreply 164 for rel in other.relationships:
0981a08… noreply 165 key = (rel.source, rel.target, rel.type)
0981a08… noreply 166 if key not in existing_rels:
0981a08… noreply 167 self.relationships.append(rel)
0981a08… noreply 168 existing_rels.add(key)
0981a08… noreply 169
0981a08… noreply 170 existing_artifact_names = {a.name for a in self.artifacts}
0981a08… noreply 171 for artifact in other.artifacts:
0981a08… noreply 172 if artifact.name not in existing_artifact_names:
0981a08… noreply 173 self.artifacts.append(artifact)
0981a08… noreply 174 existing_artifact_names.add(artifact.name)
0981a08… noreply 175
0981a08… noreply 176 existing_source_ids = {s.source_id for s in self.sources}
0981a08… noreply 177 for source in other.sources:
0981a08… noreply 178 if source.source_id not in existing_source_ids:
0981a08… noreply 179 self.sources.append(source)
0981a08… noreply 180 existing_source_ids.add(source.source_id)
0981a08… noreply 181
0981a08… noreply 182 self.project.updated_at = datetime.now().isoformat()
0981a08… noreply 183
0981a08… noreply 184
0981a08… noreply 185 # ------------------------------------------------------------------
0981a08… noreply 186 # Internal helpers
0981a08… noreply 187 # ------------------------------------------------------------------
0981a08… noreply 188
0981a08… noreply 189
0981a08… noreply 190 def _normalise_entity(raw: Dict[str, Any]) -> Dict[str, Any]:
0981a08… noreply 191 """Coerce a KG node dict into Entity-compatible kwargs."""
0981a08… noreply 192 return {
0981a08… noreply 193 "name": raw.get("name", raw.get("id", "")),
0981a08… noreply 194 "type": raw.get("type", "concept"),
0981a08… noreply 195 "descriptions": list(raw.get("descriptions", [])),
0981a08… noreply 196 "source": raw.get("source"),
0981a08… noreply 197 "occurrences": raw.get("occurrences", []),
0981a08… noreply 198 }
0981a08… noreply 199
0981a08… noreply 200
0981a08… noreply 201 def _normalise_relationship(raw: Dict[str, Any]) -> Dict[str, Any]:
0981a08… noreply 202 """Coerce a KG relationship dict into Relationship-compatible kwargs."""
0981a08… noreply 203 return {
0981a08… noreply 204 "source": raw.get("source", ""),
0981a08… noreply 205 "target": raw.get("target", ""),
0981a08… noreply 206 "type": raw.get("type", "related_to"),
0981a08… noreply 207 "content_source": raw.get("content_source"),
0981a08… noreply 208 "timestamp": raw.get("timestamp"),
0981a08… noreply 209 }

Keyboard Shortcuts

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