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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user