Add Phase 4 knowledge graph: nodes, cells, facts, edges, gRPC service

Build the knowledge graph pillar with the kg package:
- Node: hierarchical notes with parent/children, C2 wiki-style naming,
  shared tag/category pool with artifacts
- Cell: content units (markdown, code, plain) with ordinal ordering
- Fact: EAV tuples with transaction timestamps and retraction support
- Edge: directed graph links (child, parent, related, artifact_link)

Includes schema migration (002_knowledge_graph.sql), protobuf definitions
(kg.proto), full gRPC KnowledgeGraphService implementation, CLI commands
(node create/get), and comprehensive test coverage.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-21 10:05:43 -07:00
parent a336dc1ebb
commit 051a85d846
14 changed files with 3283 additions and 6 deletions

104
kg/edge.go Normal file
View File

@@ -0,0 +1,104 @@
package kg
import (
"context"
"database/sql"
"fmt"
"time"
"git.wntrmute.dev/kyle/exo/core"
"git.wntrmute.dev/kyle/exo/db"
)
// EdgeRelation describes the type of relationship between two nodes.
type EdgeRelation string
const (
EdgeRelationChild EdgeRelation = "child"
EdgeRelationParent EdgeRelation = "parent"
EdgeRelationRelated EdgeRelation = "related"
EdgeRelationArtifactLink EdgeRelation = "artifact_link"
)
// Edge links a source node to a target node or artifact.
type Edge struct {
ID string
SourceID string
TargetID string
Relation EdgeRelation
Created time.Time
}
// NewEdge creates an Edge with a new UUID and current timestamp.
func NewEdge(sourceID, targetID string, relation EdgeRelation) *Edge {
return &Edge{
ID: core.NewUUID(),
SourceID: sourceID,
TargetID: targetID,
Relation: relation,
Created: time.Now().UTC(),
}
}
// Store persists an Edge to the database.
func (e *Edge) Store(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx,
`INSERT INTO edges (id, source_id, target_id, relation, created) VALUES (?, ?, ?, ?, ?)`,
e.ID, e.SourceID, e.TargetID, string(e.Relation), db.ToDBTime(e.Created))
if err != nil {
return fmt.Errorf("kg: failed to store edge: %w", err)
}
return nil
}
// GetEdgesFrom retrieves all edges originating from a given node.
func GetEdgesFrom(ctx context.Context, tx *sql.Tx, sourceID string) ([]*Edge, error) {
rows, err := tx.QueryContext(ctx,
`SELECT id, target_id, relation, created FROM edges WHERE source_id=?`, sourceID)
if err != nil {
return nil, fmt.Errorf("kg: failed to retrieve edges: %w", err)
}
defer func() { _ = rows.Close() }()
var edges []*Edge
for rows.Next() {
e := &Edge{SourceID: sourceID}
var relation, created string
if err := rows.Scan(&e.ID, &e.TargetID, &relation, &created); err != nil {
return nil, fmt.Errorf("kg: failed to scan edge: %w", err)
}
e.Relation = EdgeRelation(relation)
e.Created, err = db.FromDBTime(created, nil)
if err != nil {
return nil, err
}
edges = append(edges, e)
}
return edges, rows.Err()
}
// GetEdgesTo retrieves all edges pointing to a given node/artifact.
func GetEdgesTo(ctx context.Context, tx *sql.Tx, targetID string) ([]*Edge, error) {
rows, err := tx.QueryContext(ctx,
`SELECT id, source_id, relation, created FROM edges WHERE target_id=?`, targetID)
if err != nil {
return nil, fmt.Errorf("kg: failed to retrieve incoming edges: %w", err)
}
defer func() { _ = rows.Close() }()
var edges []*Edge
for rows.Next() {
e := &Edge{TargetID: targetID}
var relation, created string
if err := rows.Scan(&e.ID, &e.SourceID, &relation, &created); err != nil {
return nil, fmt.Errorf("kg: failed to scan edge: %w", err)
}
e.Relation = EdgeRelation(relation)
e.Created, err = db.FromDBTime(created, nil)
if err != nil {
return nil, err
}
edges = append(edges, e)
}
return edges, rows.Err()
}