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

View File

@@ -60,7 +60,28 @@ Tracks implementation progress against the phases in `PROJECT_PLAN.md`.
- `cmd/exod/main.go` - `cmd/exod/main.go`
- `cmd/exo/main.go`, `cmd/exo/yaml.go` - `cmd/exo/main.go`, `cmd/exo/yaml.go`
## Phase 4: Knowledge Graph — NOT STARTED ## Phase 4: Knowledge Graph — COMPLETE
**Deliverables:**
- [x] `kg` package: `Node`, `Cell`, `Fact`, `Edge` types with `Store`/`Get` methods
- [x] Database schema migration (`002_knowledge_graph.sql`): nodes, cells, facts, edges, node_tags, node_categories tables
- [x] Node hierarchy (parent/children), C2 wiki-style naming, lookup by name
- [x] Cell content types (markdown, code, plain) with ordinal ordering
- [x] EAV fact storage with transaction timestamps and retraction support
- [x] `GetCurrentFactsForEntity` applies retraction logic to return only active facts
- [x] Edge types: child, parent, related, artifact_link (cross-pillar references)
- [x] Protobuf definitions (`proto/exo/v1/kg.proto`): Node, Cell, Fact, Edge messages and KnowledgeGraphService
- [x] gRPC service: CreateNode, GetNode, AddCell, RecordFact, GetFacts, AddEdge, GetEdges
- [x] CLI commands: `exo node create <name>`, `exo node get <id>`
- [x] KG service registered in exod
- [x] Shared tag/category pool: nodes use the same tags/categories tables as artifacts
- [x] Full test coverage (kg package: 10 tests covering nodes, cells, facts, edges, hierarchy, retractions)
**Files created:**
- `kg/node.go`, `kg/cell.go`, `kg/fact.go`, `kg/edge.go`, `kg/kg_test.go`
- `db/migrations/002_knowledge_graph.sql`
- `proto/exo/v1/kg.proto`, `proto/exo/v1/kg.pb.go`, `proto/exo/v1/kg_grpc.pb.go`
- `server/kg_server.go`
## Phase 5: Desktop Application — NOT STARTED (Kotlin, out of scope for Go backend) ## Phase 5: Desktop Application — NOT STARTED (Kotlin, out of scope for Go backend)

View File

@@ -29,6 +29,8 @@ Commands:
cat add <category> [...] Create categories cat add <category> [...] Create categories
cat list List all categories cat list List all categories
search tag <tag> Search artifacts by tag search tag <tag> Search artifacts by tag
node create <name> Create a knowledge graph node
node get <id> Get a node with its cells
version Print version version Print version
Environment: Environment:
@@ -53,13 +55,15 @@ func main() {
runCat(os.Args[2:]) runCat(os.Args[2:])
case "search": case "search":
runSearch(os.Args[2:]) runSearch(os.Args[2:])
case "node":
runNode(os.Args[2:])
default: default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1]) fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
usage() usage()
} }
} }
func dial() pb.ArtifactServiceClient { func dialConn() *grpc.ClientConn {
addr := os.Getenv("EXO_ADDR") addr := os.Getenv("EXO_ADDR")
if addr == "" { if addr == "" {
addr = "localhost:9090" addr = "localhost:9090"
@@ -70,8 +74,15 @@ func dial() pb.ArtifactServiceClient {
if err != nil { if err != nil {
log.Fatalf("exo: failed to connect to %s: %v", addr, err) log.Fatalf("exo: failed to connect to %s: %v", addr, err)
} }
// Connection will be closed when the process exits. return conn
return pb.NewArtifactServiceClient(conn) }
func dial() pb.ArtifactServiceClient {
return pb.NewArtifactServiceClient(dialConn())
}
func dialKG() pb.KnowledgeGraphServiceClient {
return pb.NewKnowledgeGraphServiceClient(dialConn())
} }
func runImport(args []string) { func runImport(args []string) {
@@ -203,3 +214,62 @@ func runSearch(args []string) {
os.Exit(1) os.Exit(1)
} }
} }
func runNode(args []string) {
if len(args) == 0 {
fmt.Fprintln(os.Stderr, "usage: exo node <create|get> [...]")
os.Exit(1)
}
client := dialKG()
ctx := context.Background()
switch args[0] {
case "create":
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "usage: exo node create <name>")
os.Exit(1)
}
resp, err := client.CreateNode(ctx, &pb.CreateNodeRequest{
Name: args[1],
Type: "note",
})
if err != nil {
log.Fatalf("exo: failed to create node: %v", err)
}
fmt.Printf("created node: %s\n", resp.Id)
case "get":
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "usage: exo node get <id>")
os.Exit(1)
}
resp, err := client.GetNode(ctx, &pb.GetNodeRequest{Id: args[1]})
if err != nil {
log.Fatalf("exo: failed to get node: %v", err)
}
n := resp.Node
fmt.Printf("ID: %s\n", n.Id)
fmt.Printf("Name: %s\n", n.Name)
fmt.Printf("Type: %s\n", n.Type)
fmt.Printf("Parent: %s\n", n.ParentId)
fmt.Printf("Created: %s\n", n.Created)
fmt.Printf("Modified: %s\n", n.Modified)
if len(n.Children) > 0 {
fmt.Printf("Children: %v\n", n.Children)
}
if len(n.Tags) > 0 {
fmt.Printf("Tags: %v\n", n.Tags)
}
if len(resp.Cells) > 0 {
fmt.Printf("Cells: %d\n", len(resp.Cells))
for _, c := range resp.Cells {
fmt.Printf(" [%d] %s (%s): %s\n", c.Ordinal, c.Id, c.Type, string(c.Contents))
}
}
default:
fmt.Fprintf(os.Stderr, "unknown node subcommand: %s\n", args[0])
os.Exit(1)
}
}

View File

@@ -71,6 +71,7 @@ func main() {
grpcServer := grpc.NewServer() grpcServer := grpc.NewServer()
pb.RegisterArtifactServiceServer(grpcServer, server.NewArtifactServer(database, blobStore)) pb.RegisterArtifactServiceServer(grpcServer, server.NewArtifactServer(database, blobStore))
pb.RegisterKnowledgeGraphServiceServer(grpcServer, server.NewKGServer(database))
// Graceful shutdown on SIGINT/SIGTERM. // Graceful shutdown on SIGINT/SIGTERM.
sigCh := make(chan os.Signal, 1) sigCh := make(chan os.Signal, 1)

View File

@@ -197,7 +197,7 @@ func TestSchemaVersion(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("getCurrentVersion failed: %v", err) t.Fatalf("getCurrentVersion failed: %v", err)
} }
if version != 1 { if version < 1 {
t.Fatalf("expected schema version 1, got %d", version) t.Fatalf("expected schema version >= 1, got %d", version)
} }
} }

View File

@@ -0,0 +1,82 @@
-- Migration 002: Knowledge graph tables
-- Adds nodes, cells, facts, and graph edges to the unified database.
-- Nodes are entities in the knowledge graph.
-- A node is either a note or an artifact link.
CREATE TABLE IF NOT EXISTS nodes
(
id TEXT PRIMARY KEY,
parent_id TEXT, -- parent node ID (C2 wiki style hierarchy)
name TEXT NOT NULL, -- human-readable name
type TEXT NOT NULL DEFAULT 'note', -- 'note' or 'artifact_link'
created TEXT NOT NULL, -- ISO 8601 UTC
modified TEXT NOT NULL, -- ISO 8601 UTC
FOREIGN KEY (parent_id) REFERENCES nodes (id)
);
CREATE INDEX IF NOT EXISTS idx_nodes_parent ON nodes (parent_id);
CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes (name);
-- Many-to-many junction: nodes <-> tags.
CREATE TABLE IF NOT EXISTS node_tags
(
node_id TEXT NOT NULL,
tag_id TEXT NOT NULL,
FOREIGN KEY (node_id) REFERENCES nodes (id),
FOREIGN KEY (tag_id) REFERENCES tags (id)
);
-- Many-to-many junction: nodes <-> categories.
CREATE TABLE IF NOT EXISTS node_categories
(
node_id TEXT NOT NULL,
category_id TEXT NOT NULL,
FOREIGN KEY (node_id) REFERENCES nodes (id),
FOREIGN KEY (category_id) REFERENCES categories (id)
);
-- Cells are content units within a note.
-- Inspired by Quiver's cell-based structure.
CREATE TABLE IF NOT EXISTS cells
(
id TEXT PRIMARY KEY,
node_id TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'markdown', -- 'markdown', 'code', etc.
contents BLOB,
ordinal INTEGER NOT NULL DEFAULT 0, -- display order within the node
created TEXT NOT NULL,
modified TEXT NOT NULL,
FOREIGN KEY (node_id) REFERENCES nodes (id)
);
CREATE INDEX IF NOT EXISTS idx_cells_node ON cells (node_id);
-- Facts record EAV relationships with transactional history.
-- entity + attribute + transaction form the natural key.
CREATE TABLE IF NOT EXISTS facts
(
id TEXT PRIMARY KEY,
entity_id TEXT NOT NULL, -- the subject (node or artifact UUID)
entity_name TEXT NOT NULL, -- human-readable entity name
attribute_id TEXT NOT NULL, -- attribute UUID
attribute_name TEXT NOT NULL, -- human-readable attribute name
value_contents TEXT NOT NULL,
value_type TEXT NOT NULL,
tx_timestamp INTEGER NOT NULL, -- Unix epoch of the transaction
retraction INTEGER NOT NULL DEFAULT 0 -- 1 = this fact is retracted
);
CREATE INDEX IF NOT EXISTS idx_facts_entity ON facts (entity_id);
CREATE INDEX IF NOT EXISTS idx_facts_attribute ON facts (attribute_id);
-- Graph edges link nodes to other nodes or artifacts.
CREATE TABLE IF NOT EXISTS edges
(
id TEXT PRIMARY KEY,
source_id TEXT NOT NULL, -- source node UUID
target_id TEXT NOT NULL, -- target node or artifact UUID
relation TEXT NOT NULL, -- 'child', 'parent', 'related', 'artifact_link', etc.
created TEXT NOT NULL,
FOREIGN KEY (source_id) REFERENCES nodes (id)
);
CREATE INDEX IF NOT EXISTS idx_edges_source ON edges (source_id);
CREATE INDEX IF NOT EXISTS idx_edges_target ON edges (target_id);
INSERT INTO schema_version (version, applied) VALUES (2, datetime('now'));

109
kg/cell.go Normal file
View File

@@ -0,0 +1,109 @@
package kg
import (
"context"
"database/sql"
"fmt"
"time"
"git.wntrmute.dev/kyle/exo/core"
"git.wntrmute.dev/kyle/exo/db"
)
// CellType distinguishes content types within a note.
type CellType string
const (
CellTypeMarkdown CellType = "markdown"
CellTypeCode CellType = "code"
CellTypePlain CellType = "plain"
)
// Cell is a content unit within a note. A note is composed of
// multiple cells of different types.
type Cell struct {
ID string
NodeID string
Type CellType
Contents []byte
Ordinal int
Created time.Time
Modified time.Time
}
// NewCell creates a Cell with a new UUID and current timestamps.
func NewCell(nodeID string, cellType CellType, contents []byte) *Cell {
now := time.Now().UTC()
return &Cell{
ID: core.NewUUID(),
NodeID: nodeID,
Type: cellType,
Contents: contents,
Created: now,
Modified: now,
}
}
// Store persists a Cell to the database.
func (c *Cell) Store(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx,
`INSERT INTO cells (id, node_id, type, contents, ordinal, created, modified) VALUES (?, ?, ?, ?, ?, ?, ?)`,
c.ID, c.NodeID, string(c.Type), c.Contents, c.Ordinal, db.ToDBTime(c.Created), db.ToDBTime(c.Modified))
if err != nil {
return fmt.Errorf("kg: failed to store cell: %w", err)
}
return nil
}
// Get retrieves a Cell by its ID.
func (c *Cell) Get(ctx context.Context, tx *sql.Tx) error {
if c.ID == "" {
return fmt.Errorf("kg: cell missing ID: %w", core.ErrNoID)
}
var cellType, created, modified string
row := tx.QueryRowContext(ctx,
`SELECT node_id, type, contents, ordinal, created, modified FROM cells WHERE id=?`, c.ID)
if err := row.Scan(&c.NodeID, &cellType, &c.Contents, &c.Ordinal, &created, &modified); err != nil {
return fmt.Errorf("kg: failed to retrieve cell: %w", err)
}
c.Type = CellType(cellType)
var err error
c.Created, err = db.FromDBTime(created, nil)
if err != nil {
return err
}
c.Modified, err = db.FromDBTime(modified, nil)
return err
}
// GetCellsForNode retrieves all cells for a node, ordered by ordinal.
func GetCellsForNode(ctx context.Context, tx *sql.Tx, nodeID string) ([]*Cell, error) {
rows, err := tx.QueryContext(ctx,
`SELECT id, type, contents, ordinal, created, modified FROM cells WHERE node_id=? ORDER BY ordinal`, nodeID)
if err != nil {
return nil, fmt.Errorf("kg: failed to retrieve cells for node: %w", err)
}
defer func() { _ = rows.Close() }()
var cells []*Cell
for rows.Next() {
c := &Cell{NodeID: nodeID}
var cellType, created, modified string
if err := rows.Scan(&c.ID, &cellType, &c.Contents, &c.Ordinal, &created, &modified); err != nil {
return nil, fmt.Errorf("kg: failed to scan cell: %w", err)
}
c.Type = CellType(cellType)
c.Created, err = db.FromDBTime(created, nil)
if err != nil {
return nil, err
}
c.Modified, err = db.FromDBTime(modified, nil)
if err != nil {
return nil, err
}
cells = append(cells, c)
}
return cells, rows.Err()
}

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()
}

121
kg/fact.go Normal file
View File

@@ -0,0 +1,121 @@
package kg
import (
"context"
"database/sql"
"fmt"
"time"
"git.wntrmute.dev/kyle/exo/core"
)
// Fact records an entity-attribute-value relationship with transactional
// history. A Fact with Retraction=true marks a previous fact as no longer
// valid without deleting history.
type Fact struct {
ID string
EntityID string
EntityName string
AttributeID string
AttributeName string
Value core.Value
TxTimestamp time.Time
Retraction bool
}
// NewFact creates a Fact with a new UUID and current transaction timestamp.
func NewFact(entityID, entityName, attrID, attrName string, value core.Value) *Fact {
return &Fact{
ID: core.NewUUID(),
EntityID: entityID,
EntityName: entityName,
AttributeID: attrID,
AttributeName: attrName,
Value: value,
TxTimestamp: time.Now().UTC(),
}
}
// Retract creates a retraction Fact for this fact's entity+attribute.
func (f *Fact) Retract() *Fact {
return &Fact{
ID: core.NewUUID(),
EntityID: f.EntityID,
EntityName: f.EntityName,
AttributeID: f.AttributeID,
AttributeName: f.AttributeName,
Value: f.Value,
TxTimestamp: time.Now().UTC(),
Retraction: true,
}
}
// Store persists a Fact to the database.
func (f *Fact) Store(ctx context.Context, tx *sql.Tx) error {
retraction := 0
if f.Retraction {
retraction = 1
}
_, err := tx.ExecContext(ctx,
`INSERT INTO facts (id, entity_id, entity_name, attribute_id, attribute_name, value_contents, value_type, tx_timestamp, retraction) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
f.ID, f.EntityID, f.EntityName, f.AttributeID, f.AttributeName,
f.Value.Contents, f.Value.Type, f.TxTimestamp.Unix(), retraction)
if err != nil {
return fmt.Errorf("kg: failed to store fact: %w", err)
}
return nil
}
// GetFactsForEntity retrieves all facts for a given entity, ordered by
// transaction timestamp. Includes both assertions and retractions.
func GetFactsForEntity(ctx context.Context, tx *sql.Tx, entityID string) ([]*Fact, error) {
rows, err := tx.QueryContext(ctx,
`SELECT id, entity_name, attribute_id, attribute_name, value_contents, value_type, tx_timestamp, retraction FROM facts WHERE entity_id=? ORDER BY tx_timestamp`,
entityID)
if err != nil {
return nil, fmt.Errorf("kg: failed to retrieve facts for entity: %w", err)
}
defer func() { _ = rows.Close() }()
var facts []*Fact
for rows.Next() {
f := &Fact{EntityID: entityID}
var txTS int64
var retraction int
if err := rows.Scan(&f.ID, &f.EntityName, &f.AttributeID, &f.AttributeName,
&f.Value.Contents, &f.Value.Type, &txTS, &retraction); err != nil {
return nil, fmt.Errorf("kg: failed to scan fact: %w", err)
}
f.TxTimestamp = time.Unix(txTS, 0)
f.Retraction = retraction != 0
facts = append(facts, f)
}
return facts, rows.Err()
}
// GetCurrentFactsForEntity retrieves the current (non-retracted) facts for
// an entity by applying the retraction logic: for each entity+attribute pair,
// only the most recent non-retracted assertion is returned.
func GetCurrentFactsForEntity(ctx context.Context, tx *sql.Tx, entityID string) ([]*Fact, error) {
allFacts, err := GetFactsForEntity(ctx, tx, entityID)
if err != nil {
return nil, err
}
// Track the latest fact per attribute. A retraction cancels the previous assertion.
latest := map[string]*Fact{}
for _, f := range allFacts {
if f.Retraction {
delete(latest, f.AttributeID)
} else {
latest[f.AttributeID] = f
}
}
result := make([]*Fact, 0, len(latest))
for _, f := range latest {
result = append(result, f)
}
return result, nil
}

417
kg/kg_test.go Normal file
View File

@@ -0,0 +1,417 @@
package kg
import (
"context"
"database/sql"
"path/filepath"
"testing"
"time"
"git.wntrmute.dev/kyle/exo/artifacts"
"git.wntrmute.dev/kyle/exo/core"
"git.wntrmute.dev/kyle/exo/db"
)
func mustOpenAndMigrate(t *testing.T) *sql.DB {
t.Helper()
path := filepath.Join(t.TempDir(), "test.db")
database, err := db.Open(path)
if err != nil {
t.Fatalf("Open failed: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
if err := db.Migrate(database); err != nil {
t.Fatalf("Migrate failed: %v", err)
}
return database
}
func mustTX(t *testing.T, database *sql.DB) (*sql.Tx, context.Context) {
t.Helper()
ctx := context.Background()
tx, err := db.StartTX(ctx, database)
if err != nil {
t.Fatalf("StartTX failed: %v", err)
}
return tx, ctx
}
// --- Node tests ---
func TestNodeStoreAndGet(t *testing.T) {
database := mustOpenAndMigrate(t)
tx, ctx := mustTX(t, database)
node := NewNode("TestNote", NodeTypeNote)
if err := node.Store(ctx, tx); err != nil {
t.Fatalf("Node.Store failed: %v", err)
}
got := &Node{ID: node.ID}
if err := got.Get(ctx, tx); err != nil {
t.Fatalf("Node.Get failed: %v", err)
}
if got.Name != "TestNote" {
t.Fatalf("name mismatch: got %q", got.Name)
}
if got.Type != NodeTypeNote {
t.Fatalf("type mismatch: got %q", got.Type)
}
if err := db.EndTX(tx, nil); err != nil {
t.Fatalf("EndTX failed: %v", err)
}
}
func TestNodeHierarchy(t *testing.T) {
database := mustOpenAndMigrate(t)
tx, ctx := mustTX(t, database)
parent := NewNode("ParentNote", NodeTypeNote)
if err := parent.Store(ctx, tx); err != nil {
t.Fatalf("parent Store failed: %v", err)
}
child := NewNode("ChildNote", NodeTypeNote)
child.ParentID = parent.ID
if err := child.Store(ctx, tx); err != nil {
t.Fatalf("child Store failed: %v", err)
}
// Retrieve parent and check children.
gotParent := &Node{ID: parent.ID}
if err := gotParent.Get(ctx, tx); err != nil {
t.Fatalf("parent Get failed: %v", err)
}
if len(gotParent.Children) != 1 {
t.Fatalf("expected 1 child, got %d", len(gotParent.Children))
}
if gotParent.Children[0] != child.ID {
t.Fatalf("child ID mismatch: got %q", gotParent.Children[0])
}
// Retrieve child and check parent.
gotChild := &Node{ID: child.ID}
if err := gotChild.Get(ctx, tx); err != nil {
t.Fatalf("child Get failed: %v", err)
}
if gotChild.ParentID != parent.ID {
t.Fatalf("parent ID mismatch: got %q", gotChild.ParentID)
}
if err := db.EndTX(tx, nil); err != nil {
t.Fatalf("EndTX failed: %v", err)
}
}
func TestNodeWithTags(t *testing.T) {
database := mustOpenAndMigrate(t)
tx, ctx := mustTX(t, database)
// Create tags first (shared pool).
if err := artifacts.CreateTag(ctx, tx, "philosophy"); err != nil {
t.Fatalf("CreateTag failed: %v", err)
}
node := NewNode("PhilosophyNote", NodeTypeNote)
node.Tags["philosophy"] = true
if err := node.Store(ctx, tx); err != nil {
t.Fatalf("Node.Store failed: %v", err)
}
got := &Node{ID: node.ID}
if err := got.Get(ctx, tx); err != nil {
t.Fatalf("Node.Get failed: %v", err)
}
if !got.Tags["philosophy"] {
t.Fatalf("tag not found: %v", got.Tags)
}
if err := db.EndTX(tx, nil); err != nil {
t.Fatalf("EndTX failed: %v", err)
}
}
func TestGetNodeByName(t *testing.T) {
database := mustOpenAndMigrate(t)
tx, ctx := mustTX(t, database)
node := NewNode("FindMe", NodeTypeNote)
if err := node.Store(ctx, tx); err != nil {
t.Fatalf("Store failed: %v", err)
}
got, err := GetNodeByName(ctx, tx, "FindMe")
if err != nil {
t.Fatalf("GetNodeByName failed: %v", err)
}
if got == nil {
t.Fatal("expected to find node")
}
if got.ID != node.ID {
t.Fatalf("ID mismatch: got %q, want %q", got.ID, node.ID)
}
missing, err := GetNodeByName(ctx, tx, "NotHere")
if err != nil {
t.Fatalf("GetNodeByName failed: %v", err)
}
if missing != nil {
t.Fatal("expected nil for missing node")
}
if err := db.EndTX(tx, nil); err != nil {
t.Fatalf("EndTX failed: %v", err)
}
}
// --- Cell tests ---
func TestCellStoreAndGet(t *testing.T) {
database := mustOpenAndMigrate(t)
tx, ctx := mustTX(t, database)
node := NewNode("CellNote", NodeTypeNote)
if err := node.Store(ctx, tx); err != nil {
t.Fatalf("Node.Store failed: %v", err)
}
cell := NewCell(node.ID, CellTypeMarkdown, []byte("# Hello\nThis is a note."))
if err := cell.Store(ctx, tx); err != nil {
t.Fatalf("Cell.Store failed: %v", err)
}
got := &Cell{ID: cell.ID}
if err := got.Get(ctx, tx); err != nil {
t.Fatalf("Cell.Get failed: %v", err)
}
if got.NodeID != node.ID {
t.Fatalf("node ID mismatch: got %q", got.NodeID)
}
if got.Type != CellTypeMarkdown {
t.Fatalf("type mismatch: got %q", got.Type)
}
if string(got.Contents) != "# Hello\nThis is a note." {
t.Fatalf("contents mismatch: got %q", got.Contents)
}
if err := db.EndTX(tx, nil); err != nil {
t.Fatalf("EndTX failed: %v", err)
}
}
func TestGetCellsForNode(t *testing.T) {
database := mustOpenAndMigrate(t)
tx, ctx := mustTX(t, database)
node := NewNode("MultiCell", NodeTypeNote)
if err := node.Store(ctx, tx); err != nil {
t.Fatalf("Store failed: %v", err)
}
c1 := NewCell(node.ID, CellTypeMarkdown, []byte("First"))
c1.Ordinal = 0
c2 := NewCell(node.ID, CellTypeCode, []byte("fmt.Println()"))
c2.Ordinal = 1
c3 := NewCell(node.ID, CellTypeMarkdown, []byte("Last"))
c3.Ordinal = 2
for _, c := range []*Cell{c1, c2, c3} {
if err := c.Store(ctx, tx); err != nil {
t.Fatalf("Cell.Store failed: %v", err)
}
}
cells, err := GetCellsForNode(ctx, tx, node.ID)
if err != nil {
t.Fatalf("GetCellsForNode failed: %v", err)
}
if len(cells) != 3 {
t.Fatalf("expected 3 cells, got %d", len(cells))
}
if string(cells[0].Contents) != "First" {
t.Fatalf("first cell content mismatch: %q", cells[0].Contents)
}
if cells[1].Type != CellTypeCode {
t.Fatalf("second cell type mismatch: %q", cells[1].Type)
}
if err := db.EndTX(tx, nil); err != nil {
t.Fatalf("EndTX failed: %v", err)
}
}
// --- Fact tests ---
func TestFactStoreAndGet(t *testing.T) {
database := mustOpenAndMigrate(t)
tx, ctx := mustTX(t, database)
entityID := core.NewUUID()
attrID := core.NewUUID()
fact := NewFact(entityID, "TestEntity", attrID, "color", core.Vals("blue"))
if err := fact.Store(ctx, tx); err != nil {
t.Fatalf("Fact.Store failed: %v", err)
}
facts, err := GetFactsForEntity(ctx, tx, entityID)
if err != nil {
t.Fatalf("GetFactsForEntity failed: %v", err)
}
if len(facts) != 1 {
t.Fatalf("expected 1 fact, got %d", len(facts))
}
if facts[0].Value.Contents != "blue" {
t.Fatalf("value mismatch: %q", facts[0].Value.Contents)
}
if facts[0].Retraction {
t.Fatal("should not be a retraction")
}
if err := db.EndTX(tx, nil); err != nil {
t.Fatalf("EndTX failed: %v", err)
}
}
func TestFactRetraction(t *testing.T) {
database := mustOpenAndMigrate(t)
tx, ctx := mustTX(t, database)
entityID := core.NewUUID()
attrID := core.NewUUID()
// Assert a fact.
fact := NewFact(entityID, "Entity", attrID, "status", core.Vals("active"))
if err := fact.Store(ctx, tx); err != nil {
t.Fatalf("Store assertion failed: %v", err)
}
// Retract it.
time.Sleep(10 * time.Millisecond) // ensure different timestamp
retraction := fact.Retract()
if err := retraction.Store(ctx, tx); err != nil {
t.Fatalf("Store retraction failed: %v", err)
}
// All facts should include both.
allFacts, err := GetFactsForEntity(ctx, tx, entityID)
if err != nil {
t.Fatalf("GetFactsForEntity failed: %v", err)
}
if len(allFacts) != 2 {
t.Fatalf("expected 2 facts (assertion + retraction), got %d", len(allFacts))
}
// Current facts should be empty (retracted).
current, err := GetCurrentFactsForEntity(ctx, tx, entityID)
if err != nil {
t.Fatalf("GetCurrentFactsForEntity failed: %v", err)
}
if len(current) != 0 {
t.Fatalf("expected 0 current facts after retraction, got %d", len(current))
}
// Assert a new value.
fact2 := NewFact(entityID, "Entity", attrID, "status", core.Vals("archived"))
if err := fact2.Store(ctx, tx); err != nil {
t.Fatalf("Store second assertion failed: %v", err)
}
current2, err := GetCurrentFactsForEntity(ctx, tx, entityID)
if err != nil {
t.Fatalf("GetCurrentFactsForEntity failed: %v", err)
}
if len(current2) != 1 {
t.Fatalf("expected 1 current fact, got %d", len(current2))
}
if current2[0].Value.Contents != "archived" {
t.Fatalf("expected 'archived', got %q", current2[0].Value.Contents)
}
if err := db.EndTX(tx, nil); err != nil {
t.Fatalf("EndTX failed: %v", err)
}
}
// --- Edge tests ---
func TestEdgeStoreAndGet(t *testing.T) {
database := mustOpenAndMigrate(t)
tx, ctx := mustTX(t, database)
n1 := NewNode("Source", NodeTypeNote)
n2 := NewNode("Target", NodeTypeNote)
if err := n1.Store(ctx, tx); err != nil {
t.Fatalf("Store n1 failed: %v", err)
}
if err := n2.Store(ctx, tx); err != nil {
t.Fatalf("Store n2 failed: %v", err)
}
edge := NewEdge(n1.ID, n2.ID, EdgeRelationRelated)
if err := edge.Store(ctx, tx); err != nil {
t.Fatalf("Edge.Store failed: %v", err)
}
// Get edges from source.
fromEdges, err := GetEdgesFrom(ctx, tx, n1.ID)
if err != nil {
t.Fatalf("GetEdgesFrom failed: %v", err)
}
if len(fromEdges) != 1 {
t.Fatalf("expected 1 edge from source, got %d", len(fromEdges))
}
if fromEdges[0].TargetID != n2.ID {
t.Fatalf("target mismatch: got %q", fromEdges[0].TargetID)
}
if fromEdges[0].Relation != EdgeRelationRelated {
t.Fatalf("relation mismatch: got %q", fromEdges[0].Relation)
}
// Get edges to target.
toEdges, err := GetEdgesTo(ctx, tx, n2.ID)
if err != nil {
t.Fatalf("GetEdgesTo failed: %v", err)
}
if len(toEdges) != 1 {
t.Fatalf("expected 1 edge to target, got %d", len(toEdges))
}
if err := db.EndTX(tx, nil); err != nil {
t.Fatalf("EndTX failed: %v", err)
}
}
func TestNodeToArtifactLink(t *testing.T) {
database := mustOpenAndMigrate(t)
tx, ctx := mustTX(t, database)
node := NewNode("Research", NodeTypeNote)
if err := node.Store(ctx, tx); err != nil {
t.Fatalf("Store failed: %v", err)
}
// Link to an artifact ID (doesn't need to exist in edges table).
artifactID := core.NewUUID()
edge := NewEdge(node.ID, artifactID, EdgeRelationArtifactLink)
if err := edge.Store(ctx, tx); err != nil {
t.Fatalf("Edge.Store failed: %v", err)
}
edges, err := GetEdgesFrom(ctx, tx, node.ID)
if err != nil {
t.Fatalf("GetEdgesFrom failed: %v", err)
}
if len(edges) != 1 {
t.Fatalf("expected 1 edge, got %d", len(edges))
}
if edges[0].Relation != EdgeRelationArtifactLink {
t.Fatalf("relation should be artifact_link, got %q", edges[0].Relation)
}
if err := db.EndTX(tx, nil); err != nil {
t.Fatalf("EndTX failed: %v", err)
}
}

192
kg/node.go Normal file
View File

@@ -0,0 +1,192 @@
// Package kg implements the knowledge graph pillar — notes, cells, facts,
// and graph edges that connect ideas and reference artifacts.
package kg
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"git.wntrmute.dev/kyle/exo/core"
"git.wntrmute.dev/kyle/exo/db"
)
// NodeType distinguishes notes from artifact links in the graph.
type NodeType string
const (
NodeTypeNote NodeType = "note"
NodeTypeArtifactLink NodeType = "artifact_link"
)
// Node is an entity in the knowledge graph.
type Node struct {
ID string
ParentID string // parent node ID (empty for root nodes)
Name string // human-readable name (C2 wiki style)
Type NodeType
Created time.Time
Modified time.Time
Children []string // child node IDs (populated by Get)
Tags map[string]bool
Categories map[string]bool
}
// NewNode creates a Node with a new UUID and current timestamps.
func NewNode(name string, nodeType NodeType) *Node {
now := time.Now().UTC()
return &Node{
ID: core.NewUUID(),
Name: name,
Type: nodeType,
Created: now,
Modified: now,
Tags: map[string]bool{},
Categories: map[string]bool{},
}
}
// Store persists a Node to the database.
func (n *Node) Store(ctx context.Context, tx *sql.Tx) error {
parentID := sql.NullString{String: n.ParentID, Valid: n.ParentID != ""}
_, err := tx.ExecContext(ctx,
`INSERT INTO nodes (id, parent_id, name, type, created, modified) VALUES (?, ?, ?, ?, ?, ?)`,
n.ID, parentID, n.Name, string(n.Type), db.ToDBTime(n.Created), db.ToDBTime(n.Modified))
if err != nil {
return fmt.Errorf("kg: failed to store node: %w", err)
}
// Link tags.
for tag := range n.Tags {
var tagID string
row := tx.QueryRowContext(ctx, `SELECT id FROM tags WHERE tag=?`, tag)
if err := row.Scan(&tagID); err != nil {
return fmt.Errorf("kg: unknown tag %q: %w", tag, err)
}
_, err := tx.ExecContext(ctx,
`INSERT INTO node_tags (node_id, tag_id) VALUES (?, ?)`, n.ID, tagID)
if err != nil {
return fmt.Errorf("kg: failed to link node tag: %w", err)
}
}
// Link categories.
for cat := range n.Categories {
var catID string
row := tx.QueryRowContext(ctx, `SELECT id FROM categories WHERE category=?`, cat)
if err := row.Scan(&catID); err != nil {
return fmt.Errorf("kg: unknown category %q: %w", cat, err)
}
_, err := tx.ExecContext(ctx,
`INSERT INTO node_categories (node_id, category_id) VALUES (?, ?)`, n.ID, catID)
if err != nil {
return fmt.Errorf("kg: failed to link node category: %w", err)
}
}
return nil
}
// Get retrieves a Node by its ID, including children, tags, and categories.
func (n *Node) Get(ctx context.Context, tx *sql.Tx) error {
if n.ID == "" {
return fmt.Errorf("kg: node missing ID: %w", core.ErrNoID)
}
var parentID sql.NullString
var created, modified, nodeType string
row := tx.QueryRowContext(ctx,
`SELECT parent_id, name, type, created, modified FROM nodes WHERE id=?`, n.ID)
if err := row.Scan(&parentID, &n.Name, &nodeType, &created, &modified); err != nil {
return fmt.Errorf("kg: failed to retrieve node: %w", err)
}
n.Type = NodeType(nodeType)
if parentID.Valid {
n.ParentID = parentID.String
}
var err error
n.Created, err = db.FromDBTime(created, nil)
if err != nil {
return err
}
n.Modified, err = db.FromDBTime(modified, nil)
if err != nil {
return err
}
// Load children.
n.Children = nil
childRows, err := tx.QueryContext(ctx,
`SELECT id FROM nodes WHERE parent_id=?`, n.ID)
if err != nil {
return fmt.Errorf("kg: failed to load node children: %w", err)
}
defer func() { _ = childRows.Close() }()
for childRows.Next() {
var childID string
if err := childRows.Scan(&childID); err != nil {
return err
}
n.Children = append(n.Children, childID)
}
if err := childRows.Err(); err != nil {
return err
}
// Load tags.
n.Tags = map[string]bool{}
tagRows, err := tx.QueryContext(ctx,
`SELECT t.tag FROM node_tags nt JOIN tags t ON nt.tag_id = t.id WHERE nt.node_id=?`, n.ID)
if err != nil {
return fmt.Errorf("kg: failed to load node tags: %w", err)
}
defer func() { _ = tagRows.Close() }()
for tagRows.Next() {
var tag string
if err := tagRows.Scan(&tag); err != nil {
return err
}
n.Tags[tag] = true
}
if err := tagRows.Err(); err != nil {
return err
}
// Load categories.
n.Categories = map[string]bool{}
catRows, err := tx.QueryContext(ctx,
`SELECT c.category FROM node_categories nc JOIN categories c ON nc.category_id = c.id WHERE nc.node_id=?`, n.ID)
if err != nil {
return fmt.Errorf("kg: failed to load node categories: %w", err)
}
defer func() { _ = catRows.Close() }()
for catRows.Next() {
var cat string
if err := catRows.Scan(&cat); err != nil {
return err
}
n.Categories[cat] = true
}
return catRows.Err()
}
// GetNodeByName retrieves a Node by its name.
func GetNodeByName(ctx context.Context, tx *sql.Tx, name string) (*Node, error) {
var id string
row := tx.QueryRowContext(ctx, `SELECT id FROM nodes WHERE name=?`, name)
if err := row.Scan(&id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("kg: failed to look up node by name: %w", err)
}
n := &Node{ID: id}
if err := n.Get(ctx, tx); err != nil {
return nil, err
}
return n, nil
}

1347
proto/exo/v1/kg.pb.go Normal file

File diff suppressed because it is too large Load Diff

145
proto/exo/v1/kg.proto Normal file
View File

@@ -0,0 +1,145 @@
syntax = "proto3";
package exo.v1;
option go_package = "git.wntrmute.dev/kyle/exo/proto/exo/v1;exov1";
import "exo/v1/common.proto";
// Node is an entity in the knowledge graph.
message Node {
string id = 1;
string parent_id = 2;
string name = 3;
string type = 4; // "note" or "artifact_link"
string created = 5;
string modified = 6;
repeated string children = 7;
repeated string tags = 8;
repeated string categories = 9;
}
// Cell is a content unit within a note.
message Cell {
string id = 1;
string node_id = 2;
string type = 3; // "markdown", "code", "plain"
bytes contents = 4;
int32 ordinal = 5;
string created = 6;
string modified = 7;
}
// Fact records an EAV relationship with transactional history.
message Fact {
string id = 1;
string entity_id = 2;
string entity_name = 3;
string attribute_id = 4;
string attribute_name = 5;
Value value = 6;
int64 tx_timestamp = 7;
bool retraction = 8;
}
// Edge links nodes to other nodes or artifacts.
message Edge {
string id = 1;
string source_id = 2;
string target_id = 3;
string relation = 4;
string created = 5;
}
// --- Service messages ---
message CreateNodeRequest {
string name = 1;
string type = 2;
string parent_id = 3;
repeated string tags = 4;
repeated string categories = 5;
}
message CreateNodeResponse {
string id = 1;
}
message GetNodeRequest {
string id = 1;
}
message GetNodeResponse {
Node node = 1;
repeated Cell cells = 2;
}
message AddCellRequest {
string node_id = 1;
string type = 2;
bytes contents = 3;
int32 ordinal = 4;
}
message AddCellResponse {
string id = 1;
}
message RecordFactRequest {
string entity_id = 1;
string entity_name = 2;
string attribute_id = 3;
string attribute_name = 4;
Value value = 5;
bool retraction = 6;
}
message RecordFactResponse {
string id = 1;
}
message GetFactsRequest {
string entity_id = 1;
bool current_only = 2;
}
message GetFactsResponse {
repeated Fact facts = 1;
}
message AddEdgeRequest {
string source_id = 1;
string target_id = 2;
string relation = 3;
}
message AddEdgeResponse {
string id = 1;
}
message GetEdgesRequest {
string node_id = 1;
string direction = 2; // "from", "to", or "both"
}
message GetEdgesResponse {
repeated Edge edges = 1;
}
// KnowledgeGraphService provides operations for the knowledge graph pillar.
service KnowledgeGraphService {
// Nodes
rpc CreateNode(CreateNodeRequest) returns (CreateNodeResponse);
rpc GetNode(GetNodeRequest) returns (GetNodeResponse);
// Cells
rpc AddCell(AddCellRequest) returns (AddCellResponse);
// Facts
rpc RecordFact(RecordFactRequest) returns (RecordFactResponse);
rpc GetFacts(GetFactsRequest) returns (GetFactsResponse);
// Edges
rpc AddEdge(AddEdgeRequest) returns (AddEdgeResponse);
rpc GetEdges(GetEdgesRequest) returns (GetEdgesResponse);
}

361
proto/exo/v1/kg_grpc.pb.go Normal file
View File

@@ -0,0 +1,361 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.1
// - protoc (unknown)
// source: exo/v1/kg.proto
package exov1
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
KnowledgeGraphService_CreateNode_FullMethodName = "/exo.v1.KnowledgeGraphService/CreateNode"
KnowledgeGraphService_GetNode_FullMethodName = "/exo.v1.KnowledgeGraphService/GetNode"
KnowledgeGraphService_AddCell_FullMethodName = "/exo.v1.KnowledgeGraphService/AddCell"
KnowledgeGraphService_RecordFact_FullMethodName = "/exo.v1.KnowledgeGraphService/RecordFact"
KnowledgeGraphService_GetFacts_FullMethodName = "/exo.v1.KnowledgeGraphService/GetFacts"
KnowledgeGraphService_AddEdge_FullMethodName = "/exo.v1.KnowledgeGraphService/AddEdge"
KnowledgeGraphService_GetEdges_FullMethodName = "/exo.v1.KnowledgeGraphService/GetEdges"
)
// KnowledgeGraphServiceClient is the client API for KnowledgeGraphService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// KnowledgeGraphService provides operations for the knowledge graph pillar.
type KnowledgeGraphServiceClient interface {
// Nodes
CreateNode(ctx context.Context, in *CreateNodeRequest, opts ...grpc.CallOption) (*CreateNodeResponse, error)
GetNode(ctx context.Context, in *GetNodeRequest, opts ...grpc.CallOption) (*GetNodeResponse, error)
// Cells
AddCell(ctx context.Context, in *AddCellRequest, opts ...grpc.CallOption) (*AddCellResponse, error)
// Facts
RecordFact(ctx context.Context, in *RecordFactRequest, opts ...grpc.CallOption) (*RecordFactResponse, error)
GetFacts(ctx context.Context, in *GetFactsRequest, opts ...grpc.CallOption) (*GetFactsResponse, error)
// Edges
AddEdge(ctx context.Context, in *AddEdgeRequest, opts ...grpc.CallOption) (*AddEdgeResponse, error)
GetEdges(ctx context.Context, in *GetEdgesRequest, opts ...grpc.CallOption) (*GetEdgesResponse, error)
}
type knowledgeGraphServiceClient struct {
cc grpc.ClientConnInterface
}
func NewKnowledgeGraphServiceClient(cc grpc.ClientConnInterface) KnowledgeGraphServiceClient {
return &knowledgeGraphServiceClient{cc}
}
func (c *knowledgeGraphServiceClient) CreateNode(ctx context.Context, in *CreateNodeRequest, opts ...grpc.CallOption) (*CreateNodeResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CreateNodeResponse)
err := c.cc.Invoke(ctx, KnowledgeGraphService_CreateNode_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *knowledgeGraphServiceClient) GetNode(ctx context.Context, in *GetNodeRequest, opts ...grpc.CallOption) (*GetNodeResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetNodeResponse)
err := c.cc.Invoke(ctx, KnowledgeGraphService_GetNode_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *knowledgeGraphServiceClient) AddCell(ctx context.Context, in *AddCellRequest, opts ...grpc.CallOption) (*AddCellResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AddCellResponse)
err := c.cc.Invoke(ctx, KnowledgeGraphService_AddCell_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *knowledgeGraphServiceClient) RecordFact(ctx context.Context, in *RecordFactRequest, opts ...grpc.CallOption) (*RecordFactResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(RecordFactResponse)
err := c.cc.Invoke(ctx, KnowledgeGraphService_RecordFact_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *knowledgeGraphServiceClient) GetFacts(ctx context.Context, in *GetFactsRequest, opts ...grpc.CallOption) (*GetFactsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetFactsResponse)
err := c.cc.Invoke(ctx, KnowledgeGraphService_GetFacts_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *knowledgeGraphServiceClient) AddEdge(ctx context.Context, in *AddEdgeRequest, opts ...grpc.CallOption) (*AddEdgeResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AddEdgeResponse)
err := c.cc.Invoke(ctx, KnowledgeGraphService_AddEdge_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *knowledgeGraphServiceClient) GetEdges(ctx context.Context, in *GetEdgesRequest, opts ...grpc.CallOption) (*GetEdgesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetEdgesResponse)
err := c.cc.Invoke(ctx, KnowledgeGraphService_GetEdges_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// KnowledgeGraphServiceServer is the server API for KnowledgeGraphService service.
// All implementations must embed UnimplementedKnowledgeGraphServiceServer
// for forward compatibility.
//
// KnowledgeGraphService provides operations for the knowledge graph pillar.
type KnowledgeGraphServiceServer interface {
// Nodes
CreateNode(context.Context, *CreateNodeRequest) (*CreateNodeResponse, error)
GetNode(context.Context, *GetNodeRequest) (*GetNodeResponse, error)
// Cells
AddCell(context.Context, *AddCellRequest) (*AddCellResponse, error)
// Facts
RecordFact(context.Context, *RecordFactRequest) (*RecordFactResponse, error)
GetFacts(context.Context, *GetFactsRequest) (*GetFactsResponse, error)
// Edges
AddEdge(context.Context, *AddEdgeRequest) (*AddEdgeResponse, error)
GetEdges(context.Context, *GetEdgesRequest) (*GetEdgesResponse, error)
mustEmbedUnimplementedKnowledgeGraphServiceServer()
}
// UnimplementedKnowledgeGraphServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedKnowledgeGraphServiceServer struct{}
func (UnimplementedKnowledgeGraphServiceServer) CreateNode(context.Context, *CreateNodeRequest) (*CreateNodeResponse, error) {
return nil, status.Error(codes.Unimplemented, "method CreateNode not implemented")
}
func (UnimplementedKnowledgeGraphServiceServer) GetNode(context.Context, *GetNodeRequest) (*GetNodeResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetNode not implemented")
}
func (UnimplementedKnowledgeGraphServiceServer) AddCell(context.Context, *AddCellRequest) (*AddCellResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AddCell not implemented")
}
func (UnimplementedKnowledgeGraphServiceServer) RecordFact(context.Context, *RecordFactRequest) (*RecordFactResponse, error) {
return nil, status.Error(codes.Unimplemented, "method RecordFact not implemented")
}
func (UnimplementedKnowledgeGraphServiceServer) GetFacts(context.Context, *GetFactsRequest) (*GetFactsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetFacts not implemented")
}
func (UnimplementedKnowledgeGraphServiceServer) AddEdge(context.Context, *AddEdgeRequest) (*AddEdgeResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AddEdge not implemented")
}
func (UnimplementedKnowledgeGraphServiceServer) GetEdges(context.Context, *GetEdgesRequest) (*GetEdgesResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetEdges not implemented")
}
func (UnimplementedKnowledgeGraphServiceServer) mustEmbedUnimplementedKnowledgeGraphServiceServer() {}
func (UnimplementedKnowledgeGraphServiceServer) testEmbeddedByValue() {}
// UnsafeKnowledgeGraphServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to KnowledgeGraphServiceServer will
// result in compilation errors.
type UnsafeKnowledgeGraphServiceServer interface {
mustEmbedUnimplementedKnowledgeGraphServiceServer()
}
func RegisterKnowledgeGraphServiceServer(s grpc.ServiceRegistrar, srv KnowledgeGraphServiceServer) {
// If the following call panics, it indicates UnimplementedKnowledgeGraphServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&KnowledgeGraphService_ServiceDesc, srv)
}
func _KnowledgeGraphService_CreateNode_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreateNodeRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(KnowledgeGraphServiceServer).CreateNode(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: KnowledgeGraphService_CreateNode_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(KnowledgeGraphServiceServer).CreateNode(ctx, req.(*CreateNodeRequest))
}
return interceptor(ctx, in, info, handler)
}
func _KnowledgeGraphService_GetNode_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetNodeRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(KnowledgeGraphServiceServer).GetNode(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: KnowledgeGraphService_GetNode_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(KnowledgeGraphServiceServer).GetNode(ctx, req.(*GetNodeRequest))
}
return interceptor(ctx, in, info, handler)
}
func _KnowledgeGraphService_AddCell_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AddCellRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(KnowledgeGraphServiceServer).AddCell(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: KnowledgeGraphService_AddCell_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(KnowledgeGraphServiceServer).AddCell(ctx, req.(*AddCellRequest))
}
return interceptor(ctx, in, info, handler)
}
func _KnowledgeGraphService_RecordFact_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RecordFactRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(KnowledgeGraphServiceServer).RecordFact(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: KnowledgeGraphService_RecordFact_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(KnowledgeGraphServiceServer).RecordFact(ctx, req.(*RecordFactRequest))
}
return interceptor(ctx, in, info, handler)
}
func _KnowledgeGraphService_GetFacts_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetFactsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(KnowledgeGraphServiceServer).GetFacts(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: KnowledgeGraphService_GetFacts_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(KnowledgeGraphServiceServer).GetFacts(ctx, req.(*GetFactsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _KnowledgeGraphService_AddEdge_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AddEdgeRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(KnowledgeGraphServiceServer).AddEdge(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: KnowledgeGraphService_AddEdge_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(KnowledgeGraphServiceServer).AddEdge(ctx, req.(*AddEdgeRequest))
}
return interceptor(ctx, in, info, handler)
}
func _KnowledgeGraphService_GetEdges_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetEdgesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(KnowledgeGraphServiceServer).GetEdges(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: KnowledgeGraphService_GetEdges_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(KnowledgeGraphServiceServer).GetEdges(ctx, req.(*GetEdgesRequest))
}
return interceptor(ctx, in, info, handler)
}
// KnowledgeGraphService_ServiceDesc is the grpc.ServiceDesc for KnowledgeGraphService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var KnowledgeGraphService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "exo.v1.KnowledgeGraphService",
HandlerType: (*KnowledgeGraphServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "CreateNode",
Handler: _KnowledgeGraphService_CreateNode_Handler,
},
{
MethodName: "GetNode",
Handler: _KnowledgeGraphService_GetNode_Handler,
},
{
MethodName: "AddCell",
Handler: _KnowledgeGraphService_AddCell_Handler,
},
{
MethodName: "RecordFact",
Handler: _KnowledgeGraphService_RecordFact_Handler,
},
{
MethodName: "GetFacts",
Handler: _KnowledgeGraphService_GetFacts_Handler,
},
{
MethodName: "AddEdge",
Handler: _KnowledgeGraphService_AddEdge_Handler,
},
{
MethodName: "GetEdges",
Handler: _KnowledgeGraphService_GetEdges_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "exo/v1/kg.proto",
}

307
server/kg_server.go Normal file
View File

@@ -0,0 +1,307 @@
package server
import (
"context"
"database/sql"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"git.wntrmute.dev/kyle/exo/artifacts"
"git.wntrmute.dev/kyle/exo/core"
"git.wntrmute.dev/kyle/exo/db"
"git.wntrmute.dev/kyle/exo/kg"
pb "git.wntrmute.dev/kyle/exo/proto/exo/v1"
)
// KGServer implements the KnowledgeGraphService gRPC service.
type KGServer struct {
pb.UnimplementedKnowledgeGraphServiceServer
database *sql.DB
}
// NewKGServer creates a new KGServer.
func NewKGServer(database *sql.DB) *KGServer {
return &KGServer{database: database}
}
func (s *KGServer) CreateNode(ctx context.Context, req *pb.CreateNodeRequest) (*pb.CreateNodeResponse, error) {
if req.Name == "" {
return nil, status.Error(codes.InvalidArgument, "name is required")
}
nodeType := kg.NodeType(req.Type)
if nodeType == "" {
nodeType = kg.NodeTypeNote
}
node := kg.NewNode(req.Name, nodeType)
node.ParentID = req.ParentId
node.Tags = core.MapFromList(req.Tags)
node.Categories = core.MapFromList(req.Categories)
tx, err := db.StartTX(ctx, s.database)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to start transaction: %v", err)
}
// Create tags and categories idempotently.
for tag := range node.Tags {
if err := artifacts.CreateTag(ctx, tx, tag); err != nil {
_ = tx.Rollback()
return nil, status.Errorf(codes.Internal, "failed to create tag: %v", err)
}
}
for cat := range node.Categories {
if err := artifacts.CreateCategory(ctx, tx, cat); err != nil {
_ = tx.Rollback()
return nil, status.Errorf(codes.Internal, "failed to create category: %v", err)
}
}
if err := node.Store(ctx, tx); err != nil {
_ = tx.Rollback()
return nil, status.Errorf(codes.Internal, "failed to store node: %v", err)
}
if err := tx.Commit(); err != nil {
return nil, status.Errorf(codes.Internal, "failed to commit: %v", err)
}
return &pb.CreateNodeResponse{Id: node.ID}, nil
}
func (s *KGServer) GetNode(ctx context.Context, req *pb.GetNodeRequest) (*pb.GetNodeResponse, error) {
if req.Id == "" {
return nil, status.Error(codes.InvalidArgument, "id is required")
}
tx, err := db.StartTX(ctx, s.database)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to start transaction: %v", err)
}
node := &kg.Node{ID: req.Id}
if err := node.Get(ctx, tx); err != nil {
_ = tx.Rollback()
return nil, status.Errorf(codes.NotFound, "node not found: %v", err)
}
cells, err := kg.GetCellsForNode(ctx, tx, req.Id)
if err != nil {
_ = tx.Rollback()
return nil, status.Errorf(codes.Internal, "failed to get cells: %v", err)
}
if err := tx.Commit(); err != nil {
return nil, status.Errorf(codes.Internal, "failed to commit: %v", err)
}
resp := &pb.GetNodeResponse{
Node: &pb.Node{
Id: node.ID,
ParentId: node.ParentID,
Name: node.Name,
Type: string(node.Type),
Created: db.ToDBTime(node.Created),
Modified: db.ToDBTime(node.Modified),
Children: node.Children,
Tags: core.ListFromMap(node.Tags),
Categories: core.ListFromMap(node.Categories),
},
}
for _, c := range cells {
resp.Cells = append(resp.Cells, &pb.Cell{
Id: c.ID,
NodeId: c.NodeID,
Type: string(c.Type),
Contents: c.Contents,
Ordinal: int32(c.Ordinal), //nolint:gosec // ordinal values are small
Created: db.ToDBTime(c.Created),
Modified: db.ToDBTime(c.Modified),
})
}
return resp, nil
}
func (s *KGServer) AddCell(ctx context.Context, req *pb.AddCellRequest) (*pb.AddCellResponse, error) {
if req.NodeId == "" {
return nil, status.Error(codes.InvalidArgument, "node_id is required")
}
cellType := kg.CellType(req.Type)
if cellType == "" {
cellType = kg.CellTypeMarkdown
}
cell := kg.NewCell(req.NodeId, cellType, req.Contents)
cell.Ordinal = int(req.Ordinal)
tx, err := db.StartTX(ctx, s.database)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to start transaction: %v", err)
}
if err := cell.Store(ctx, tx); err != nil {
_ = tx.Rollback()
return nil, status.Errorf(codes.Internal, "failed to store cell: %v", err)
}
if err := tx.Commit(); err != nil {
return nil, status.Errorf(codes.Internal, "failed to commit: %v", err)
}
return &pb.AddCellResponse{Id: cell.ID}, nil
}
func (s *KGServer) RecordFact(ctx context.Context, req *pb.RecordFactRequest) (*pb.RecordFactResponse, error) {
if req.EntityId == "" {
return nil, status.Error(codes.InvalidArgument, "entity_id is required")
}
value := core.Value{}
if req.Value != nil {
value = core.Value{Contents: req.Value.Contents, Type: req.Value.Type}
}
fact := kg.NewFact(req.EntityId, req.EntityName, req.AttributeId, req.AttributeName, value)
fact.Retraction = req.Retraction
tx, err := db.StartTX(ctx, s.database)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to start transaction: %v", err)
}
if err := fact.Store(ctx, tx); err != nil {
_ = tx.Rollback()
return nil, status.Errorf(codes.Internal, "failed to store fact: %v", err)
}
if err := tx.Commit(); err != nil {
return nil, status.Errorf(codes.Internal, "failed to commit: %v", err)
}
return &pb.RecordFactResponse{Id: fact.ID}, nil
}
func (s *KGServer) GetFacts(ctx context.Context, req *pb.GetFactsRequest) (*pb.GetFactsResponse, error) {
if req.EntityId == "" {
return nil, status.Error(codes.InvalidArgument, "entity_id is required")
}
tx, err := db.StartTX(ctx, s.database)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to start transaction: %v", err)
}
var facts []*kg.Fact
if req.CurrentOnly {
facts, err = kg.GetCurrentFactsForEntity(ctx, tx, req.EntityId)
} else {
facts, err = kg.GetFactsForEntity(ctx, tx, req.EntityId)
}
if err != nil {
_ = tx.Rollback()
return nil, status.Errorf(codes.Internal, "failed to get facts: %v", err)
}
if err := tx.Commit(); err != nil {
return nil, status.Errorf(codes.Internal, "failed to commit: %v", err)
}
resp := &pb.GetFactsResponse{}
for _, f := range facts {
resp.Facts = append(resp.Facts, &pb.Fact{
Id: f.ID,
EntityId: f.EntityID,
EntityName: f.EntityName,
AttributeId: f.AttributeID,
AttributeName: f.AttributeName,
Value: &pb.Value{Contents: f.Value.Contents, Type: f.Value.Type},
TxTimestamp: f.TxTimestamp.Unix(),
Retraction: f.Retraction,
})
}
return resp, nil
}
func (s *KGServer) AddEdge(ctx context.Context, req *pb.AddEdgeRequest) (*pb.AddEdgeResponse, error) {
if req.SourceId == "" || req.TargetId == "" {
return nil, status.Error(codes.InvalidArgument, "source_id and target_id are required")
}
edge := kg.NewEdge(req.SourceId, req.TargetId, kg.EdgeRelation(req.Relation))
tx, err := db.StartTX(ctx, s.database)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to start transaction: %v", err)
}
if err := edge.Store(ctx, tx); err != nil {
_ = tx.Rollback()
return nil, status.Errorf(codes.Internal, "failed to store edge: %v", err)
}
if err := tx.Commit(); err != nil {
return nil, status.Errorf(codes.Internal, "failed to commit: %v", err)
}
return &pb.AddEdgeResponse{Id: edge.ID}, nil
}
func (s *KGServer) GetEdges(ctx context.Context, req *pb.GetEdgesRequest) (*pb.GetEdgesResponse, error) {
if req.NodeId == "" {
return nil, status.Error(codes.InvalidArgument, "node_id is required")
}
tx, err := db.StartTX(ctx, s.database)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to start transaction: %v", err)
}
var edges []*kg.Edge
switch req.Direction {
case "to":
edges, err = kg.GetEdgesTo(ctx, tx, req.NodeId)
case "from", "":
edges, err = kg.GetEdgesFrom(ctx, tx, req.NodeId)
case "both":
from, err2 := kg.GetEdgesFrom(ctx, tx, req.NodeId)
if err2 != nil {
_ = tx.Rollback()
return nil, status.Errorf(codes.Internal, "failed to get edges: %v", err2)
}
to, err2 := kg.GetEdgesTo(ctx, tx, req.NodeId)
if err2 != nil {
_ = tx.Rollback()
return nil, status.Errorf(codes.Internal, "failed to get edges: %v", err2)
}
edges = append(from, to...)
default:
_ = tx.Rollback()
return nil, status.Errorf(codes.InvalidArgument, "direction must be 'from', 'to', or 'both'")
}
if err != nil {
_ = tx.Rollback()
return nil, status.Errorf(codes.Internal, "failed to get edges: %v", err)
}
if err := tx.Commit(); err != nil {
return nil, status.Errorf(codes.Internal, "failed to commit: %v", err)
}
resp := &pb.GetEdgesResponse{}
for _, e := range edges {
resp.Edges = append(resp.Edges, &pb.Edge{
Id: e.ID,
SourceId: e.SourceID,
TargetId: e.TargetID,
Relation: string(e.Relation),
Created: db.ToDBTime(e.Created),
})
}
return resp, nil
}