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:
192
kg/node.go
Normal file
192
kg/node.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user