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:
23
PROGRESS.md
23
PROGRESS.md
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
82
db/migrations/002_knowledge_graph.sql
Normal file
82
db/migrations/002_knowledge_graph.sql
Normal 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
109
kg/cell.go
Normal 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
104
kg/edge.go
Normal 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
121
kg/fact.go
Normal 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
417
kg/kg_test.go
Normal 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
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
|
||||||
|
}
|
||||||
1347
proto/exo/v1/kg.pb.go
Normal file
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
145
proto/exo/v1/kg.proto
Normal 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
361
proto/exo/v1/kg_grpc.pb.go
Normal 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
307
server/kg_server.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user