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>
122 lines
3.6 KiB
Go
122 lines
3.6 KiB
Go
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
|
|
}
|