421 lines
15 KiB
Python
421 lines
15 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""
|
||
|
|
GOFAI Knowledge Graph for Hermes Fleet
|
||
|
|
Property graph for wizard lineage, tasks, and relationships
|
||
|
|
"""
|
||
|
|
|
||
|
|
import json
|
||
|
|
import hashlib
|
||
|
|
from datetime import datetime
|
||
|
|
from typing import Dict, List, Optional, Set, Any, Tuple
|
||
|
|
from dataclasses import dataclass, asdict
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class Node:
|
||
|
|
"""Graph node representing an entity"""
|
||
|
|
id: str
|
||
|
|
label: str # Type: Wizard, Task, Artifact, etc.
|
||
|
|
properties: Dict[str, Any]
|
||
|
|
created_at: str = None
|
||
|
|
|
||
|
|
def __post_init__(self):
|
||
|
|
if self.created_at is None:
|
||
|
|
self.created_at = datetime.now().isoformat()
|
||
|
|
|
||
|
|
def to_dict(self) -> Dict:
|
||
|
|
return {
|
||
|
|
'id': self.id,
|
||
|
|
'label': self.label,
|
||
|
|
'properties': self.properties,
|
||
|
|
'created_at': self.created_at
|
||
|
|
}
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def from_dict(cls, data: Dict) -> 'Node':
|
||
|
|
return cls(**data)
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class Edge:
|
||
|
|
"""Graph edge representing a relationship"""
|
||
|
|
id: str
|
||
|
|
source: str # Source node ID
|
||
|
|
target: str # Target node ID
|
||
|
|
relation: str # Type of relationship
|
||
|
|
properties: Dict[str, Any]
|
||
|
|
created_at: str = None
|
||
|
|
|
||
|
|
def __post_init__(self):
|
||
|
|
if self.created_at is None:
|
||
|
|
self.created_at = datetime.now().isoformat()
|
||
|
|
|
||
|
|
def to_dict(self) -> Dict:
|
||
|
|
return {
|
||
|
|
'id': self.id,
|
||
|
|
'source': self.source,
|
||
|
|
'target': self.target,
|
||
|
|
'relation': self.relation,
|
||
|
|
'properties': self.properties,
|
||
|
|
'created_at': self.created_at
|
||
|
|
}
|
||
|
|
|
||
|
|
class KnowledgeGraph:
|
||
|
|
"""
|
||
|
|
Property graph for fleet knowledge
|
||
|
|
Stores wizards, tasks, artifacts and their relationships
|
||
|
|
"""
|
||
|
|
|
||
|
|
def __init__(self, storage_path: Optional[str] = None):
|
||
|
|
self.nodes: Dict[str, Node] = {}
|
||
|
|
self.edges: Dict[str, Edge] = {}
|
||
|
|
self.indexes: Dict[str, Dict[str, Set[str]]] = {
|
||
|
|
'label': {}, # label -> node_ids
|
||
|
|
'relation': {}, # relation -> edge_ids
|
||
|
|
}
|
||
|
|
self.storage_path = storage_path
|
||
|
|
|
||
|
|
if storage_path and Path(storage_path).exists():
|
||
|
|
self.load()
|
||
|
|
|
||
|
|
def _generate_id(self, data: str) -> str:
|
||
|
|
"""Generate unique ID from data"""
|
||
|
|
return hashlib.sha256(data.encode()).hexdigest()[:16]
|
||
|
|
|
||
|
|
def add_node(self, label: str, properties: Dict, node_id: Optional[str] = None) -> str:
|
||
|
|
"""Add a node to the graph"""
|
||
|
|
if node_id is None:
|
||
|
|
node_id = self._generate_id(f"{label}:{json.dumps(properties, sort_keys=True)}")
|
||
|
|
|
||
|
|
node = Node(id=node_id, label=label, properties=properties)
|
||
|
|
self.nodes[node_id] = node
|
||
|
|
|
||
|
|
# Update index
|
||
|
|
if label not in self.indexes['label']:
|
||
|
|
self.indexes['label'][label] = set()
|
||
|
|
self.indexes['label'][label].add(node_id)
|
||
|
|
|
||
|
|
return node_id
|
||
|
|
|
||
|
|
def add_edge(self, source: str, target: str, relation: str,
|
||
|
|
properties: Optional[Dict] = None, edge_id: Optional[str] = None) -> str:
|
||
|
|
"""Add an edge between nodes"""
|
||
|
|
if properties is None:
|
||
|
|
properties = {}
|
||
|
|
|
||
|
|
if edge_id is None:
|
||
|
|
edge_id = self._generate_id(f"{source}:{relation}:{target}")
|
||
|
|
|
||
|
|
edge = Edge(id=edge_id, source=source, target=target,
|
||
|
|
relation=relation, properties=properties)
|
||
|
|
self.edges[edge_id] = edge
|
||
|
|
|
||
|
|
# Update index
|
||
|
|
if relation not in self.indexes['relation']:
|
||
|
|
self.indexes['relation'][relation] = set()
|
||
|
|
self.indexes['relation'][relation].add(edge_id)
|
||
|
|
|
||
|
|
return edge_id
|
||
|
|
|
||
|
|
def get_node(self, node_id: str) -> Optional[Node]:
|
||
|
|
"""Get a node by ID"""
|
||
|
|
return self.nodes.get(node_id)
|
||
|
|
|
||
|
|
def get_neighbors(self, node_id: str, relation: Optional[str] = None,
|
||
|
|
direction: str = 'out') -> List[Tuple[Node, Edge]]:
|
||
|
|
"""Get neighboring nodes"""
|
||
|
|
results = []
|
||
|
|
|
||
|
|
for edge in self.edges.values():
|
||
|
|
if direction in ('out', 'both') and edge.source == node_id:
|
||
|
|
if relation is None or edge.relation == relation:
|
||
|
|
target = self.nodes.get(edge.target)
|
||
|
|
if target:
|
||
|
|
results.append((target, edge))
|
||
|
|
|
||
|
|
if direction in ('in', 'both') and edge.target == node_id:
|
||
|
|
if relation is None or edge.relation == relation:
|
||
|
|
source = self.nodes.get(edge.source)
|
||
|
|
if source:
|
||
|
|
results.append((source, edge))
|
||
|
|
|
||
|
|
return results
|
||
|
|
|
||
|
|
def query(self, label: Optional[str] = None,
|
||
|
|
properties: Optional[Dict] = None) -> List[Node]:
|
||
|
|
"""Query nodes by label and/or properties"""
|
||
|
|
results = []
|
||
|
|
|
||
|
|
# Start with label filter if provided
|
||
|
|
if label and label in self.indexes['label']:
|
||
|
|
candidates = [self.nodes[nid] for nid in self.indexes['label'][label]]
|
||
|
|
else:
|
||
|
|
candidates = list(self.nodes.values())
|
||
|
|
|
||
|
|
# Filter by properties
|
||
|
|
if properties:
|
||
|
|
for node in candidates:
|
||
|
|
matches = all(
|
||
|
|
node.properties.get(k) == v
|
||
|
|
for k, v in properties.items()
|
||
|
|
)
|
||
|
|
if matches:
|
||
|
|
results.append(node)
|
||
|
|
else:
|
||
|
|
results = candidates
|
||
|
|
|
||
|
|
return results
|
||
|
|
|
||
|
|
def find_path(self, start_id: str, end_id: str,
|
||
|
|
max_depth: int = 5) -> Optional[List[Edge]]:
|
||
|
|
"""Find path between two nodes (BFS)"""
|
||
|
|
if start_id not in self.nodes or end_id not in self.nodes:
|
||
|
|
return None
|
||
|
|
|
||
|
|
# BFS
|
||
|
|
visited = {start_id}
|
||
|
|
queue = [(start_id, [])]
|
||
|
|
|
||
|
|
while queue:
|
||
|
|
current_id, path = queue.pop(0)
|
||
|
|
|
||
|
|
if current_id == end_id:
|
||
|
|
return path
|
||
|
|
|
||
|
|
if len(path) >= max_depth:
|
||
|
|
continue
|
||
|
|
|
||
|
|
# Get outgoing edges
|
||
|
|
for edge in self.edges.values():
|
||
|
|
if edge.source == current_id and edge.target not in visited:
|
||
|
|
visited.add(edge.target)
|
||
|
|
queue.append((edge.target, path + [edge]))
|
||
|
|
|
||
|
|
return None
|
||
|
|
|
||
|
|
def get_lineage(self, wizard_id: str) -> Dict:
|
||
|
|
"""Get full lineage tree for a wizard"""
|
||
|
|
wizard = self.nodes.get(wizard_id)
|
||
|
|
if not wizard or wizard.label != 'Wizard':
|
||
|
|
return {}
|
||
|
|
|
||
|
|
tree = {
|
||
|
|
'wizard': wizard.properties.get('name', wizard_id),
|
||
|
|
'father': None,
|
||
|
|
'grandfather': None,
|
||
|
|
'children': [],
|
||
|
|
'siblings': []
|
||
|
|
}
|
||
|
|
|
||
|
|
# Find father
|
||
|
|
father_edges = self.get_neighbors(wizard_id, relation='child_of', direction='out')
|
||
|
|
for father_node, edge in father_edges:
|
||
|
|
tree['father'] = father_node.properties.get('name', father_node.id)
|
||
|
|
|
||
|
|
# Find grandfather
|
||
|
|
grandfather_edges = self.get_neighbors(father_node.id, relation='child_of', direction='out')
|
||
|
|
for grandpa_node, _ in grandfather_edges:
|
||
|
|
tree['grandfather'] = grandpa_node.properties.get('name', grandpa_node.id)
|
||
|
|
|
||
|
|
# Find siblings (other children of father)
|
||
|
|
sibling_edges = self.get_neighbors(father_node.id, relation='child_of', direction='in')
|
||
|
|
for sibling_node, _ in sibling_edges:
|
||
|
|
if sibling_node.id != wizard_id:
|
||
|
|
tree['siblings'].append(sibling_node.properties.get('name', sibling_node.id))
|
||
|
|
|
||
|
|
# Find children
|
||
|
|
child_edges = self.get_neighbors(wizard_id, relation='child_of', direction='in')
|
||
|
|
for child_node, _ in child_edges:
|
||
|
|
tree['children'].append(child_node.properties.get('name', child_node.id))
|
||
|
|
|
||
|
|
return tree
|
||
|
|
|
||
|
|
def get_task_dependencies(self, task_id: str) -> Dict:
|
||
|
|
"""Get task dependency graph"""
|
||
|
|
task = self.nodes.get(task_id)
|
||
|
|
if not task:
|
||
|
|
return {}
|
||
|
|
|
||
|
|
deps = {
|
||
|
|
'task': task.properties.get('name', task_id),
|
||
|
|
'depends_on': [],
|
||
|
|
'blocks': [],
|
||
|
|
'assignee': None
|
||
|
|
}
|
||
|
|
|
||
|
|
# Find dependencies
|
||
|
|
dep_edges = self.get_neighbors(task_id, relation='depends_on', direction='out')
|
||
|
|
for dep_task, _ in dep_edges:
|
||
|
|
deps['depends_on'].append(dep_task.properties.get('name', dep_task.id))
|
||
|
|
|
||
|
|
# Find what this blocks
|
||
|
|
blocker_edges = self.get_neighbors(task_id, relation='depends_on', direction='in')
|
||
|
|
for blocked_task, _ in blocker_edges:
|
||
|
|
deps['blocks'].append(blocked_task.properties.get('name', blocked_task.id))
|
||
|
|
|
||
|
|
# Find assignee
|
||
|
|
assignee_edges = self.get_neighbors(task_id, relation='assigned_to', direction='out')
|
||
|
|
for wizard, _ in assignee_edges:
|
||
|
|
deps['assignee'] = wizard.properties.get('name', wizard.id)
|
||
|
|
|
||
|
|
return deps
|
||
|
|
|
||
|
|
def save(self) -> None:
|
||
|
|
"""Save graph to disk"""
|
||
|
|
if not self.storage_path:
|
||
|
|
return
|
||
|
|
|
||
|
|
data = {
|
||
|
|
'nodes': {nid: node.to_dict() for nid, node in self.nodes.items()},
|
||
|
|
'edges': {eid: edge.to_dict() for eid, edge in self.edges.items()},
|
||
|
|
'indexes': {k: {ik: list(iv) for ik, iv in v.items()}
|
||
|
|
for k, v in self.indexes.items()}
|
||
|
|
}
|
||
|
|
|
||
|
|
Path(self.storage_path).write_text(json.dumps(data, indent=2))
|
||
|
|
|
||
|
|
def load(self) -> None:
|
||
|
|
"""Load graph from disk"""
|
||
|
|
if not self.storage_path or not Path(self.storage_path).exists():
|
||
|
|
return
|
||
|
|
|
||
|
|
data = json.loads(Path(self.storage_path).read_text())
|
||
|
|
|
||
|
|
# Load nodes
|
||
|
|
for nid, node_data in data.get('nodes', {}).items():
|
||
|
|
self.nodes[nid] = Node.from_dict(node_data)
|
||
|
|
|
||
|
|
# Load edges
|
||
|
|
for eid, edge_data in data.get('edges', {}).items():
|
||
|
|
self.edges[eid] = Edge(**edge_data)
|
||
|
|
|
||
|
|
# Load indexes
|
||
|
|
self.indexes = {
|
||
|
|
k: {ik: set(iv) for ik, iv in v.items()}
|
||
|
|
for k, v in data.get('indexes', {}).items()
|
||
|
|
}
|
||
|
|
|
||
|
|
def export_graphviz(self) -> str:
|
||
|
|
"""Export as Graphviz DOT format"""
|
||
|
|
lines = ['digraph Fleet {', ' rankdir=TB;']
|
||
|
|
|
||
|
|
# Add nodes
|
||
|
|
for node in self.nodes.values():
|
||
|
|
label = node.properties.get('name', node.id[:8])
|
||
|
|
shape = 'box' if node.label == 'Wizard' else 'ellipse'
|
||
|
|
color = 'lightblue' if node.label == 'Wizard' else 'lightgreen'
|
||
|
|
lines.append(f' "{node.id}" [label="{label}", shape={shape}, style=filled, fillcolor={color}];')
|
||
|
|
|
||
|
|
# Add edges
|
||
|
|
for edge in self.edges.values():
|
||
|
|
lines.append(f' "{edge.source}" -> "{edge.target}" [label="{edge.relation}"];')
|
||
|
|
|
||
|
|
lines.append('}')
|
||
|
|
return '\n'.join(lines)
|
||
|
|
|
||
|
|
def get_stats(self) -> Dict:
|
||
|
|
"""Get graph statistics"""
|
||
|
|
return {
|
||
|
|
'nodes': len(self.nodes),
|
||
|
|
'edges': len(self.edges),
|
||
|
|
'by_label': {label: len(nodes) for label, nodes in self.indexes['label'].items()},
|
||
|
|
'by_relation': {rel: len(edges) for rel, edges in self.indexes['relation'].items()}
|
||
|
|
}
|
||
|
|
|
||
|
|
class FleetKnowledgeBase:
|
||
|
|
"""
|
||
|
|
High-level interface for fleet knowledge operations
|
||
|
|
"""
|
||
|
|
|
||
|
|
def __init__(self, storage_path: str = '/root/wizards/allegro/gofai/fleet_kg.json'):
|
||
|
|
self.kg = KnowledgeGraph(storage_path)
|
||
|
|
|
||
|
|
def register_wizard(self, name: str, role: str, father: Optional[str] = None,
|
||
|
|
api_port: int = 0, model: str = "", capabilities: List[str] = None) -> str:
|
||
|
|
"""Register a wizard in the fleet"""
|
||
|
|
|
||
|
|
properties = {
|
||
|
|
'name': name,
|
||
|
|
'role': role,
|
||
|
|
'father': father,
|
||
|
|
'api_port': api_port,
|
||
|
|
'model': model,
|
||
|
|
'capabilities': capabilities or [],
|
||
|
|
'status': 'active'
|
||
|
|
}
|
||
|
|
|
||
|
|
wizard_id = self.kg.add_node('Wizard', properties, node_id=f'wizard_{name.lower()}')
|
||
|
|
|
||
|
|
# Add father relationship if specified
|
||
|
|
if father:
|
||
|
|
father_id = f'wizard_{father.lower()}'
|
||
|
|
if father_id in self.kg.nodes:
|
||
|
|
self.kg.add_edge(wizard_id, father_id, 'child_of')
|
||
|
|
|
||
|
|
self.kg.save()
|
||
|
|
return wizard_id
|
||
|
|
|
||
|
|
def register_task(self, name: str, priority: str, assignee: str,
|
||
|
|
dependencies: List[str] = None) -> str:
|
||
|
|
"""Register a task"""
|
||
|
|
|
||
|
|
properties = {
|
||
|
|
'name': name,
|
||
|
|
'priority': priority,
|
||
|
|
'status': 'pending',
|
||
|
|
'assignee': assignee
|
||
|
|
}
|
||
|
|
|
||
|
|
task_id = self.kg.add_node('Task', properties)
|
||
|
|
|
||
|
|
# Link to assignee
|
||
|
|
wizard_id = f'wizard_{assignee.lower()}'
|
||
|
|
if wizard_id in self.kg.nodes:
|
||
|
|
self.kg.add_edge(task_id, wizard_id, 'assigned_to')
|
||
|
|
|
||
|
|
# Add dependencies
|
||
|
|
if dependencies:
|
||
|
|
for dep_name in dependencies:
|
||
|
|
# Find task by name
|
||
|
|
for nid, node in self.kg.nodes.items():
|
||
|
|
if node.label == 'Task' and node.properties.get('name') == dep_name:
|
||
|
|
self.kg.add_edge(task_id, nid, 'depends_on')
|
||
|
|
break
|
||
|
|
|
||
|
|
self.kg.save()
|
||
|
|
return task_id
|
||
|
|
|
||
|
|
def get_wizard_info(self, name: str) -> Dict:
|
||
|
|
"""Get comprehensive info about a wizard"""
|
||
|
|
wizard_id = f'wizard_{name.lower()}'
|
||
|
|
|
||
|
|
lineage = self.kg.get_lineage(wizard_id)
|
||
|
|
|
||
|
|
# Get assigned tasks
|
||
|
|
tasks = []
|
||
|
|
for node in self.kg.query(label='Task'):
|
||
|
|
if node.properties.get('assignee') == name:
|
||
|
|
tasks.append({
|
||
|
|
'name': node.properties.get('name'),
|
||
|
|
'priority': node.properties.get('priority'),
|
||
|
|
'status': node.properties.get('status')
|
||
|
|
})
|
||
|
|
|
||
|
|
wizard = self.kg.get_node(wizard_id)
|
||
|
|
|
||
|
|
return {
|
||
|
|
'name': name,
|
||
|
|
'lineage': lineage,
|
||
|
|
'tasks': tasks,
|
||
|
|
'capabilities': wizard.properties.get('capabilities', []) if wizard else [],
|
||
|
|
'api_port': wizard.properties.get('api_port', 0) if wizard else 0
|
||
|
|
}
|
||
|
|
|
||
|
|
def who_can_do(self, capability: str) -> List[str]:
|
||
|
|
"""Find wizards with a specific capability"""
|
||
|
|
results = []
|
||
|
|
for node in self.kg.query(label='Wizard'):
|
||
|
|
caps = node.properties.get('capabilities', [])
|
||
|
|
if capability in caps:
|
||
|
|
results.append(node.properties.get('name', node.id))
|
||
|
|
return results
|