feat: add the relationship graph with extraction, corrections and explorer
Phase 11. Entities are mind-scoped nodes with canonical matching,
aliases and deterministic kind guessing (hostnames and hyphenated ids
are systems, capitalised names people, file suffixes documents).
Relationships carry a relation from the shared vocabulary, confidence,
source, reinforcement count and the event that produced them; links tie
entities to memories, beliefs, events, goals and predictions.
The perceive and attend handlers upsert the entities a message or
observation mentions and record co-mention edges cited to the event, so
the graph grows only from what was actually seen. Operators can merge
("these are the same": edges and links move, the old name becomes an
alias, the old id still resolves), record that two entities are
different, correct kinds and add typed edges; every correction is an
operator action and a corrections row.
The explorer renders a bounded subgraph on a canvas with a small
force-directed layout in plain JavaScript, with kind filters, focus on
a neighbourhood and a side panel per entity. API, entity page, CLI and
docs/graph.md.
This commit is contained in:
@@ -5,6 +5,7 @@ All notable changes to Noema are recorded here. Format follows Keep a Changelog;
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Phase 11 relationship graph: mind-scoped entities with aliases, evidenced typed edges, links to memories/beliefs/events, deterministic extraction from conversation and observations, merge/distinct corrections, bounded canvas explorer, API and CLI.
|
||||
- Phase 10 background cognition: metacognitive self-checks, scheduled reflection with lessons, episode consolidation into evidenced generalisations with operator review, internal narrative, thresholded notifications with aggregation.
|
||||
- Phase 9 motivation: goal tree with attention relevance, closed drive set and rule-driven affect state with decay and weight modulation, explicit predictions with deterministic resolution, prediction errors feeding beliefs, calibration dashboard, curiosity questions becoming investigation goals.
|
||||
- Phase 8 conversation (milestone M1): messages become observations, deterministic perception with optional model enrichment, claims become evidenced beliefs, grounded introspective answers from real state, model or deterministic wording with full provenance, chat UI, `noemactl ask`, sample mind Dani with a strict seed import, and an end-to-end acceptance test.
|
||||
|
||||
@@ -146,8 +146,12 @@ An administrator can: start Noema → log in → create a mind → configure per
|
||||
- ☑ Background job: scheduled consolidation and reflection per mind with mode switching; API, UI (Background tab, notifications page, overview narrative and warnings), CLI (narrative, consolidate, reflect, notifications); docs/cognition.md
|
||||
- ☑ Demo scenario 3 verified live; tests for notifications, consolidation, reflection
|
||||
|
||||
### Phase 11 – Relationship graph
|
||||
- ☐ entities, entity_relationships, extraction from perception, merge/split corrections, interactive explorer (bounded)
|
||||
### Phase 11 – Relationship graph ☑
|
||||
- ☑ `migrations/0011_graph`: entities (aliases, merged_into), entity_relationships (relation, confidence, source, event, count), entity_links
|
||||
- ☑ `internal/graph`: canonical matching, deterministic kind guessing, upsert with alias resolution, reinforced edges cited to events, links, bounded subgraph/neighbourhood, merge preserving old ids, kind/description correction, stats
|
||||
- ☑ Cognition: perceive and attend handlers link extracted entities, edges (discussed_with, related_to) and references to memories/beliefs/events
|
||||
- ☑ API: graph, entities search/detail, merge (entities_same correction), distinct (entities_different correction), update, relate; UI: canvas force explorer with filters, focus and side panel, entity page with corrections; `noemactl entity`; docs/graph.md
|
||||
- ☑ Tests: kind guessing, canonicalisation, upsert dedupe, edge reinforcement, cross-mind refusal, merge with alias/link migration, bounded subgraph, stats
|
||||
|
||||
### Phase 12 – Capability and action policy system
|
||||
- ☐ registry, policy engine, approvals, approval centre UI, policy matrix UI, dream/replay refusal, locked DENYs, tool events
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
)
|
||||
|
||||
func init() {
|
||||
extraCommands = append(extraCommands, command{"entity", "list <mind> [query]|inspect <mind> <id>|merge <mind> <id> <other>|distinct <mind> <id> <other> – relationship graph", cmdEntity})
|
||||
}
|
||||
|
||||
func cmdEntity(args []string) int {
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: noemactl entity list <mind> [query] | inspect <mind> <id> | merge <mind> <id> <other> | distinct <mind> <id> <other>")
|
||||
return exitUsage
|
||||
}
|
||||
mind := args[1]
|
||||
switch args[0] {
|
||||
case "list":
|
||||
q := ""
|
||||
if len(args) > 2 {
|
||||
q = "?q=" + url.QueryEscape(strings.Join(args[2:], " "))
|
||||
}
|
||||
var out struct{ Items []map[string]any }
|
||||
if code, err := apiRequest("GET", "/api/v1/minds/"+mind+"/entities"+q, nil, &out); err != nil {
|
||||
return exitFor(code, err)
|
||||
}
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "KIND\tNAME\tMENTIONS\tID")
|
||||
for _, e := range out.Items {
|
||||
fmt.Fprintf(tw, "%v\t%v\t%v\t%v\n", e["kind"], e["name"], e["mention_count"], e["id"])
|
||||
}
|
||||
tw.Flush()
|
||||
return exitOK
|
||||
case "inspect":
|
||||
if len(args) < 3 {
|
||||
return exitUsage
|
||||
}
|
||||
var d map[string]any
|
||||
if code, err := apiRequest("GET", "/api/v1/minds/"+mind+"/entities/"+args[2], nil, &d); err != nil {
|
||||
return exitFor(code, err)
|
||||
}
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
enc.Encode(d) //nolint:errcheck
|
||||
return exitOK
|
||||
case "merge", "distinct":
|
||||
if len(args) < 4 {
|
||||
return exitUsage
|
||||
}
|
||||
var d map[string]any
|
||||
if code, err := apiRequest("POST", "/api/v1/minds/"+mind+"/entities/"+args[2]+"/"+args[0], map[string]any{"other": strings.Join(args[3:], " ")}, &d); err != nil {
|
||||
return exitFor(code, err)
|
||||
}
|
||||
fmt.Println(args[0], "recorded")
|
||||
return exitOK
|
||||
}
|
||||
return exitUsage
|
||||
}
|
||||
+10
-1
@@ -25,6 +25,7 @@ import (
|
||||
"github.com/andydixon/noema/internal/conversation"
|
||||
evstore "github.com/andydixon/noema/internal/events"
|
||||
"github.com/andydixon/noema/internal/goals"
|
||||
"github.com/andydixon/noema/internal/graph"
|
||||
"github.com/andydixon/noema/internal/llm/prompts"
|
||||
"github.com/andydixon/noema/internal/llm/router"
|
||||
"github.com/andydixon/noema/internal/memory"
|
||||
@@ -130,6 +131,7 @@ func run() error {
|
||||
deps.Motivation = motivation.NewStore(db)
|
||||
deps.Predictions = predictions.NewStore(db)
|
||||
deps.Resolver = &predictions.Resolver{Store: deps.Predictions, Events: deps.Events, Beliefs: deps.Beliefs, Log: log, Metrics: metrics}
|
||||
deps.Graph = graph.NewStore(db)
|
||||
deps.Narrative = narrative.NewStore(db)
|
||||
deps.Notifications = notifications.NewStore(db)
|
||||
deps.Consolidation = &consolidation.Service{DB: db, Memory: deps.Memory, Beliefs: deps.Beliefs, Events: deps.Events, LLM: deps.LLM, Prompts: deps.Prompts, Minds: deps.Minds, Log: log}
|
||||
@@ -137,7 +139,7 @@ func run() error {
|
||||
registry := brain.NewRegistry().AddProcess(cognition.Attend{}).AddProcess(cognition.Retrieve{}).AddProcess(cognition.Associate{}).AddProcess(cognition.Perceive{}).
|
||||
AddProcess(cognition.GoalMonitor{}).AddProcess(cognition.Curiosity{}).AddProcess(cognition.Metacognition{}).
|
||||
AddHook(cognition.MotivationHook{Store: deps.Motivation}).AddHook(cognition.NarrativeHook{Store: deps.Narrative}).AddHook(cognition.NotifyHook{Store: deps.Notifications})
|
||||
cognition.Handlers{Memory: deps.Memory, Beliefs: deps.Beliefs, Conversations: deps.Conversations}.Register(registry)
|
||||
cognition.Handlers{Memory: deps.Memory, Beliefs: deps.Beliefs, Conversations: deps.Conversations, Graph: deps.Graph}.Register(registry)
|
||||
deps.Brain = brain.NewManager(brain.Config{DB: db, Events: deps.Events, Minds: deps.Minds, Registry: registry, Pool: scheduler.NewPool(cfg.MaxWorkers),
|
||||
Log: log, Metrics: metrics, Services: brain.Services{Events: deps.Events, Memory: deps.Memory, Beliefs: deps.Beliefs, LLM: deps.LLM, Prompts: deps.Prompts,
|
||||
Goals: deps.Goals, Motivation: deps.Motivation, Predictions: deps.Predictions}}, group)
|
||||
@@ -173,6 +175,13 @@ func run() error {
|
||||
ResetState: func(r *http.Request, mind ids.ID) (motivation.State, error) { return srv.ResetState(r, mind) },
|
||||
Consolidate: func(r *http.Request, mind ids.ID) (consolidation.Job, error) { return srv.RunConsolidation(r, mind) },
|
||||
Reflect: func(r *http.Request, mind ids.ID) (reflection.Reflection, error) { return srv.RunReflection(r, mind) },
|
||||
Entity: func(r *http.Request, mind, id ids.ID) (api.EntityDetail, error) { return srv.EntityDetail(r, mind, id) },
|
||||
Merge: func(r *http.Request, mind ids.ID, e graph.Entity, other string) error {
|
||||
return srv.MergeEntities(r, mind, e, other)
|
||||
},
|
||||
Distinct: func(r *http.Request, mind ids.ID, e graph.Entity, other string) error {
|
||||
return srv.DistinctEntities(r, mind, e, other)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Relationship graph
|
||||
|
||||
Each mind keeps its own entity graph (`entities`, `entity_relationships`, `entity_links`), mind-scoped like everything else.
|
||||
|
||||
## Entities
|
||||
|
||||
Kinds: person, system, project, document, concept, repository, organisation, place, other. Names are canonicalised (lower-cased, trimmed) for matching; aliases accumulate through merges. Every mention bumps `mention_count` and `last_seen`.
|
||||
|
||||
Extraction is deterministic and conservative: hostnames (`atlas.local`), hyphenated identifiers (`server-09`, `web-api-prod`), letter-digit names and capitalised words become entities; plain lower-case nouns do not. The `perceive` prompt may add entities with kinds when a model is configured. Operators can correct the kind and description of any entity.
|
||||
|
||||
## Relationships
|
||||
|
||||
Edges carry a relation from the shared vocabulary (`related_to`, `discussed_with`, `caused_by`, `supports`, `contradicts`, `depends_on`, `belongs_to`, `observed_during`, `derived_from`, `similar_to`, `follows`), a confidence, a source, a count of reinforcements and the event that produced them. Co-mention in one message yields `discussed_with` (speaker → entity) and `related_to` (entity ↔ entity) at low confidence; repeats raise the count. Operators can add typed edges with higher confidence.
|
||||
|
||||
`entity_links` record which memories, beliefs, events, goals and predictions involve an entity, so the entity page answers "where has this come up".
|
||||
|
||||
## Corrections
|
||||
|
||||
- **These are the same**: `Merge` re-points edges and links to the survivor, adds the loser's name as an alias, and keeps the loser row with `merged_into` so old references still resolve. An `operator_action` event and an `entities_same` correction are recorded.
|
||||
- **These are different**: an `entities_different` correction and event are recorded; it documents the operator's judgement and is visible to later extraction.
|
||||
|
||||
## Explorer
|
||||
|
||||
`/minds/{mind}/graph` renders a bounded subgraph (node cap, kind filter, optional focus entity with its neighbourhood) on a canvas with a small force-directed layout written in plain JavaScript. Clicking a node loads its panel (edges, links, corrections); double-clicking focuses on it. The API endpoint `GET /api/v1/minds/{mind}/graph` returns the same nodes and edges as JSON.
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/andydixon/noema/internal/conversation"
|
||||
evstore "github.com/andydixon/noema/internal/events"
|
||||
"github.com/andydixon/noema/internal/goals"
|
||||
"github.com/andydixon/noema/internal/graph"
|
||||
"github.com/andydixon/noema/internal/llm/prompts"
|
||||
"github.com/andydixon/noema/internal/llm/router"
|
||||
"github.com/andydixon/noema/internal/memory"
|
||||
@@ -49,6 +50,7 @@ func testServer(t *testing.T) (*httptest.Server, string) {
|
||||
Audit: audit.New(pool, log), Metrics: observability.NewMetrics(), Minds: minds.NewStore(pool), Conversations: conversation.NewStore(pool), Goals: goals.NewStore(pool), Motivation: motivation.NewStore(pool), Predictions: predictions.NewStore(pool), Beliefs: beliefs.NewStore(pool), Memory: memory.NewStore(context.Background(), pool, nil), Events: evstore.NewStore(pool, evstore.NewBus(50), nil), Migrations: migrations.FS, Started: time.Now()}
|
||||
ctx := context.Background()
|
||||
deps.Resolver = &predictions.Resolver{Store: deps.Predictions, Events: deps.Events, Beliefs: deps.Beliefs, Log: log}
|
||||
deps.Graph = graph.NewStore(pool)
|
||||
deps.Narrative = narrative.NewStore(pool)
|
||||
deps.Notifications = notifications.NewStore(pool)
|
||||
deps.Consolidation = &consolidation.Service{DB: pool, Memory: deps.Memory, Beliefs: deps.Beliefs, Events: deps.Events, Minds: deps.Minds, Log: log}
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/andydixon/noema/internal/auth"
|
||||
"github.com/andydixon/noema/internal/beliefs"
|
||||
"github.com/andydixon/noema/internal/graph"
|
||||
"github.com/andydixon/noema/pkg/concepts"
|
||||
"github.com/andydixon/noema/pkg/events"
|
||||
"github.com/andydixon/noema/pkg/ids"
|
||||
)
|
||||
|
||||
func (s *Server) graphRoutes() {
|
||||
m := s.mux
|
||||
m.Handle("GET /api/v1/minds/{mind}/graph", require(auth.RoleObserver, s.graphGet))
|
||||
m.Handle("GET /api/v1/minds/{mind}/entities", require(auth.RoleObserver, s.entitiesList))
|
||||
m.Handle("GET /api/v1/minds/{mind}/entities/{id}", require(auth.RoleObserver, s.entityGet))
|
||||
m.Handle("POST /api/v1/minds/{mind}/entities/{id}/merge", require(auth.RoleOperator, s.entityMerge))
|
||||
m.Handle("POST /api/v1/minds/{mind}/entities/{id}/distinct", require(auth.RoleOperator, s.entityDistinct))
|
||||
m.Handle("PATCH /api/v1/minds/{mind}/entities/{id}", require(auth.RoleOperator, s.entityPatch))
|
||||
m.Handle("POST /api/v1/minds/{mind}/entities/{id}/relationships", require(auth.RoleOperator, s.entityRelate))
|
||||
}
|
||||
|
||||
func graphErr(err error) error {
|
||||
if errors.Is(err, graph.ErrInvalid) {
|
||||
return ValidationError(err.Error())
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Server) graphGet(w http.ResponseWriter, r *http.Request) {
|
||||
m, ok := s.mind(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
var focus ids.ID
|
||||
if v := q.Get("focus"); v != "" {
|
||||
focus, _ = ids.Parse(v)
|
||||
}
|
||||
limit, _ := strconv.Atoi(q.Get("limit"))
|
||||
sg, err := s.deps.Graph.Subgraph(r.Context(), m.ID, focus, graph.Kind(q.Get("kind")), limit)
|
||||
if err != nil {
|
||||
fail(w, r, err)
|
||||
return
|
||||
}
|
||||
if sg.Nodes == nil {
|
||||
sg.Nodes = []graph.Entity{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, sg)
|
||||
}
|
||||
|
||||
func (s *Server) entitiesList(w http.ResponseWriter, r *http.Request) {
|
||||
m, ok := s.mind(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
list, err := s.deps.Graph.Search(r.Context(), m.ID, r.URL.Query().Get("q"), graph.Kind(r.URL.Query().Get("kind")), limit)
|
||||
if err != nil {
|
||||
fail(w, r, err)
|
||||
return
|
||||
}
|
||||
if list == nil {
|
||||
list = []graph.Entity{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, page[graph.Entity]{Items: list})
|
||||
}
|
||||
|
||||
// EntityDetail is an entity with its edges and links; shared with the UI.
|
||||
type EntityDetail struct {
|
||||
Entity graph.Entity `json:"entity"`
|
||||
Edges []graph.Edge `json:"edges"`
|
||||
Links []graph.Link `json:"links"`
|
||||
}
|
||||
|
||||
// EntityDetail assembles the detail view.
|
||||
func (s *Server) EntityDetail(r *http.Request, mind, id ids.ID) (EntityDetail, error) {
|
||||
e, err := s.deps.Graph.Get(r.Context(), mind, id)
|
||||
if err != nil {
|
||||
return EntityDetail{}, err
|
||||
}
|
||||
d := EntityDetail{Entity: e}
|
||||
d.Edges, _ = s.deps.Graph.Neighbourhood(r.Context(), mind, e.ID, 100)
|
||||
d.Links, _ = s.deps.Graph.Links(r.Context(), mind, e.ID, 100)
|
||||
if d.Edges == nil {
|
||||
d.Edges = []graph.Edge{}
|
||||
}
|
||||
if d.Links == nil {
|
||||
d.Links = []graph.Link{}
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (s *Server) entityGet(w http.ResponseWriter, r *http.Request) {
|
||||
m, ok := s.mind(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := ids.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
fail(w, r, ValidationError("bad id"))
|
||||
return
|
||||
}
|
||||
d, err := s.EntityDetail(r, m.ID, id)
|
||||
if err != nil {
|
||||
fail(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, d)
|
||||
}
|
||||
|
||||
// resolveEntity accepts an id or a name.
|
||||
func (s *Server) resolveEntity(r *http.Request, mind ids.ID, ref string) (graph.Entity, error) {
|
||||
if id, err := ids.Parse(ref); err == nil {
|
||||
return s.deps.Graph.Get(r.Context(), mind, id)
|
||||
}
|
||||
list, err := s.deps.Graph.Search(r.Context(), mind, ref, "", 5)
|
||||
if err != nil {
|
||||
return graph.Entity{}, err
|
||||
}
|
||||
for _, e := range list {
|
||||
if e.Canonical == graph.Canonical(ref) {
|
||||
return e, nil
|
||||
}
|
||||
}
|
||||
if len(list) == 1 {
|
||||
return list[0], nil
|
||||
}
|
||||
return graph.Entity{}, ValidationError("no unique entity matches " + ref)
|
||||
}
|
||||
|
||||
// MergeEntities is the "these two are the same" correction; shared with the UI.
|
||||
func (s *Server) MergeEntities(r *http.Request, mind ids.ID, survivor graph.Entity, otherRef string) error {
|
||||
loser, err := s.resolveEntity(r, mind, otherRef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.deps.Graph.Merge(r.Context(), mind, survivor.ID, loser.ID); err != nil {
|
||||
return graphErr(err)
|
||||
}
|
||||
evs, _ := s.deps.Events.Append(r.Context(), events.New{MindID: mind, Type: events.OperatorAction, Source: "operator", Summary: "Operator " + actor(r) + " merged entities: " + loser.Name + " is the same as " + survivor.Name,
|
||||
Salience: 0.4, Confidence: 1, Metadata: map[string]any{"action": "entities.same", "actor": actor(r)}, Payload: map[string]any{"survivor": survivor.ID, "loser": loser.ID}})
|
||||
if len(evs) > 0 {
|
||||
s.deps.Beliefs.AddCorrection(r.Context(), s.deps.DB, mind, beliefs.Correction{Kind: "entities_same", TargetKind: "entity", TargetID: survivor.ID.String(), Statement: loser.Name + " = " + survivor.Name, CreatedBy: actor(r), EventID: evs[0].ID}) //nolint:errcheck
|
||||
}
|
||||
s.deps.Audit.Log(r.Context(), "entity.merge", "entity", survivor.ID.String(), map[string]any{"loser": loser.ID})
|
||||
return nil
|
||||
}
|
||||
|
||||
// DistinctEntities records "these are different"; shared with the UI.
|
||||
func (s *Server) DistinctEntities(r *http.Request, mind ids.ID, e graph.Entity, otherRef string) error {
|
||||
evs, err := s.deps.Events.Append(r.Context(), events.New{MindID: mind, Type: events.OperatorAction, Source: "operator", Summary: "Operator " + actor(r) + " stated that " + e.Name + " and " + otherRef + " are different entities",
|
||||
Salience: 0.3, Confidence: 1, Metadata: map[string]any{"action": "entities.different", "actor": actor(r)}, Payload: map[string]any{"entity": e.ID, "other": otherRef}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.deps.Beliefs.AddCorrection(r.Context(), s.deps.DB, mind, beliefs.Correction{Kind: "entities_different", TargetKind: "entity", TargetID: e.ID.String(), Statement: e.Name + " ≠ " + otherRef, CreatedBy: actor(r), EventID: evs[0].ID})
|
||||
s.deps.Audit.Log(r.Context(), "entity.distinct", "entity", e.ID.String(), map[string]any{"other": otherRef})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Server) entityMerge(w http.ResponseWriter, r *http.Request) {
|
||||
m, ok := s.mind(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := ids.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
fail(w, r, ValidationError("bad id"))
|
||||
return
|
||||
}
|
||||
e, err := s.deps.Graph.Get(r.Context(), m.ID, id)
|
||||
if err != nil {
|
||||
fail(w, r, err)
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Other string `json:"other"`
|
||||
}
|
||||
if err := decode(r, &in); err != nil {
|
||||
fail(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := s.MergeEntities(r, m.ID, e, in.Other); err != nil {
|
||||
fail(w, r, err)
|
||||
return
|
||||
}
|
||||
d, _ := s.EntityDetail(r, m.ID, e.ID)
|
||||
writeJSON(w, http.StatusOK, d)
|
||||
}
|
||||
|
||||
func (s *Server) entityDistinct(w http.ResponseWriter, r *http.Request) {
|
||||
m, ok := s.mind(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := ids.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
fail(w, r, ValidationError("bad id"))
|
||||
return
|
||||
}
|
||||
e, err := s.deps.Graph.Get(r.Context(), m.ID, id)
|
||||
if err != nil {
|
||||
fail(w, r, err)
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Other string `json:"other"`
|
||||
}
|
||||
if err := decode(r, &in); err != nil {
|
||||
fail(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := s.DistinctEntities(r, m.ID, e, in.Other); err != nil {
|
||||
fail(w, r, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) entityPatch(w http.ResponseWriter, r *http.Request) {
|
||||
m, ok := s.mind(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := ids.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
fail(w, r, ValidationError("bad id"))
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Kind graph.Kind `json:"kind"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if err := decode(r, &in); err != nil {
|
||||
fail(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := s.deps.Graph.Update(r.Context(), m.ID, id, in.Kind, in.Description); err != nil {
|
||||
fail(w, r, graphErr(err))
|
||||
return
|
||||
}
|
||||
s.deps.Audit.Log(r.Context(), "entity.update", "entity", id.String(), map[string]any{"kind": in.Kind})
|
||||
d, _ := s.EntityDetail(r, m.ID, id)
|
||||
writeJSON(w, http.StatusOK, d)
|
||||
}
|
||||
|
||||
func (s *Server) entityRelate(w http.ResponseWriter, r *http.Request) {
|
||||
m, ok := s.mind(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := ids.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
fail(w, r, ValidationError("bad id"))
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
To string `json:"to"`
|
||||
Relation concepts.Relation `json:"relation"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
}
|
||||
if err := decode(r, &in); err != nil {
|
||||
fail(w, r, err)
|
||||
return
|
||||
}
|
||||
to, err := s.resolveEntity(r, m.ID, in.To)
|
||||
if err != nil {
|
||||
fail(w, r, err)
|
||||
return
|
||||
}
|
||||
if in.Confidence <= 0 {
|
||||
in.Confidence = 0.8
|
||||
}
|
||||
rel, err := s.deps.Graph.Relate(r.Context(), s.deps.DB, m.ID, id, to.ID, in.Relation, in.Confidence, "operator:"+actor(r), ids.Nil)
|
||||
if err != nil {
|
||||
fail(w, r, graphErr(err))
|
||||
return
|
||||
}
|
||||
s.deps.Audit.Log(r.Context(), "entity.relate", "entity", id.String(), map[string]any{"to": to.ID, "relation": in.Relation})
|
||||
writeJSON(w, http.StatusCreated, rel)
|
||||
}
|
||||
@@ -62,6 +62,7 @@ func (s *Server) routes() {
|
||||
s.importRoutes()
|
||||
s.motivationRoutes()
|
||||
s.cognitionRoutes()
|
||||
s.graphRoutes()
|
||||
|
||||
m.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, r, http.StatusNotFound, "not_found", "no such endpoint")
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/andydixon/noema/internal/conversation"
|
||||
"github.com/andydixon/noema/internal/events"
|
||||
"github.com/andydixon/noema/internal/goals"
|
||||
"github.com/andydixon/noema/internal/graph"
|
||||
"github.com/andydixon/noema/internal/llm/prompts"
|
||||
"github.com/andydixon/noema/internal/llm/router"
|
||||
"github.com/andydixon/noema/internal/memory"
|
||||
@@ -54,6 +55,7 @@ type Deps struct {
|
||||
Notifications *notifications.Store
|
||||
Consolidation *consolidation.Service
|
||||
Reflection *reflection.Service
|
||||
Graph *graph.Store
|
||||
LLM *router.Router
|
||||
LLMStore *router.Store
|
||||
Prompts *prompts.Store
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package cognition
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/andydixon/noema/internal/brain"
|
||||
"github.com/andydixon/noema/internal/conversation"
|
||||
"github.com/andydixon/noema/internal/graph"
|
||||
"github.com/andydixon/noema/pkg/concepts"
|
||||
"github.com/andydixon/noema/pkg/events"
|
||||
"github.com/andydixon/noema/pkg/ids"
|
||||
)
|
||||
|
||||
// linkEntities upserts the entities mentioned in text, links them to the
|
||||
// given objects and records co-mention edges. Deterministic: the graph grows
|
||||
// from what was actually said, and every edge cites the event it came from.
|
||||
func (h Handlers) linkEntities(ctx context.Context, c *brain.Cycle, names []string, actor string, sourceEvent ids.ID, refs map[string]ids.ID) []graph.Entity {
|
||||
if h.Graph == nil {
|
||||
return nil
|
||||
}
|
||||
mind := c.State.Mind.ID
|
||||
var ents []graph.Entity
|
||||
seen := map[ids.ID]bool{}
|
||||
add := func(kind graph.Kind, name string) {
|
||||
e, err := h.Graph.Upsert(ctx, c.Tx, mind, kind, name, c.State.Now)
|
||||
if err != nil || seen[e.ID] {
|
||||
return
|
||||
}
|
||||
seen[e.ID] = true
|
||||
ents = append(ents, e)
|
||||
if !sourceEvent.IsZero() {
|
||||
h.Graph.LinkRef(ctx, c.Tx, mind, e.ID, "event", sourceEvent) //nolint:errcheck
|
||||
}
|
||||
for kind, id := range refs {
|
||||
if !id.IsZero() {
|
||||
h.Graph.LinkRef(ctx, c.Tx, mind, e.ID, kind, id) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
}
|
||||
if actor != "" && actor != "operator" && actor != "system" {
|
||||
add(graph.Person, actor)
|
||||
}
|
||||
for _, n := range names {
|
||||
if len(n) >= 2 && len(ents) < 12 {
|
||||
add("", n)
|
||||
}
|
||||
}
|
||||
// The speaker discussed each entity; mentioned entities relate to each other.
|
||||
for i := range ents {
|
||||
for j := i + 1; j < len(ents); j++ {
|
||||
rel := concepts.RelatedTo
|
||||
if ents[i].Kind == graph.Person && i == 0 && actor != "" {
|
||||
rel = concepts.DiscussedWith
|
||||
}
|
||||
h.Graph.Relate(ctx, c.Tx, mind, ents[i].ID, ents[j].ID, rel, 0.3, "co-mention", sourceEvent) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
return ents
|
||||
}
|
||||
|
||||
// entitiesFromText runs the deterministic entity extractor over free text.
|
||||
func entitiesFromText(text string) []string {
|
||||
return conversation.Analyse(text).Entities
|
||||
}
|
||||
|
||||
var _ = events.Observation
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/andydixon/noema/internal/beliefs"
|
||||
"github.com/andydixon/noema/internal/brain"
|
||||
"github.com/andydixon/noema/internal/conversation"
|
||||
"github.com/andydixon/noema/internal/graph"
|
||||
"github.com/andydixon/noema/internal/memory"
|
||||
"github.com/andydixon/noema/internal/predictions"
|
||||
"github.com/andydixon/noema/internal/storage"
|
||||
@@ -25,6 +26,7 @@ type Handlers struct {
|
||||
Memory *memory.Store
|
||||
Beliefs *beliefs.Store
|
||||
Conversations *conversation.Store
|
||||
Graph *graph.Store
|
||||
}
|
||||
|
||||
// Register installs the handlers on a registry.
|
||||
@@ -163,6 +165,8 @@ func (h Handlers) attend(ctx context.Context, c *brain.Cycle, t workspace.Though
|
||||
item := workspace.Item{Key: "attend:" + t.Subject, Content: t.Summary, Kind: "observation", SourceEvent: ids.ID(evID)}
|
||||
if !mem.ID.IsZero() {
|
||||
item.Ref = string(mem.ID)
|
||||
text, _ := t.Content["text"].(string)
|
||||
h.linkEntities(ctx, c, entitiesFromText(text), "", ids.ID(evID), map[string]ids.ID{"memory": mem.ID})
|
||||
}
|
||||
c.Runtime.Hold(item)
|
||||
return nil
|
||||
|
||||
@@ -110,6 +110,11 @@ func (h Handlers) perceive(ctx context.Context, c *brain.Cycle, t workspace.Thou
|
||||
claimed = append(claimed, BeliefRef{ID: res.Belief.ID, Proposition: res.Belief.Proposition, Confidence: res.Belief.Confidence, Status: string(res.Belief.Status)})
|
||||
}
|
||||
}
|
||||
refs := map[string]ids.ID{"memory": memRef}
|
||||
for _, b := range claimed {
|
||||
refs["belief"] = b.ID
|
||||
}
|
||||
h.linkEntities(ctx, c, a.Entities, actor, ids.ID(srcID), refs)
|
||||
intent := h.buildIntent(ctx, c, a, text, claimed)
|
||||
reply, callID, err := h.generate(ctx, c, text, intent)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
// Package graph maintains a mind's entity graph: people, systems, projects,
|
||||
// documents, concepts and the typed, evidenced relationships between them
|
||||
// (spec §24). Extraction elsewhere calls Upsert and Relate; operators can
|
||||
// merge or separate entities and every such correction is recorded.
|
||||
package graph
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/andydixon/noema/internal/storage"
|
||||
"github.com/andydixon/noema/pkg/concepts"
|
||||
"github.com/andydixon/noema/pkg/ids"
|
||||
)
|
||||
|
||||
// Kind of entity.
|
||||
type Kind string
|
||||
|
||||
// Kinds.
|
||||
const (
|
||||
Person Kind = "person"
|
||||
System Kind = "system"
|
||||
Project Kind = "project"
|
||||
Document Kind = "document"
|
||||
Concept Kind = "concept"
|
||||
Repository Kind = "repository"
|
||||
Organisation Kind = "organisation"
|
||||
Place Kind = "place"
|
||||
Other Kind = "other"
|
||||
)
|
||||
|
||||
// AllKinds lists kinds.
|
||||
var AllKinds = []Kind{Person, System, Project, Document, Concept, Repository, Organisation, Place, Other}
|
||||
|
||||
// Valid reports whether k is known.
|
||||
func (k Kind) Valid() bool {
|
||||
for _, x := range AllKinds {
|
||||
if x == k {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Entity is a node.
|
||||
type Entity struct {
|
||||
ID ids.ID `json:"id"`
|
||||
MindID ids.ID `json:"mind_id"`
|
||||
Kind Kind `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
Canonical string `json:"canonical"`
|
||||
Aliases []string `json:"aliases"`
|
||||
Description string `json:"description"`
|
||||
Attributes map[string]any `json:"attributes"`
|
||||
MentionCount int `json:"mention_count"`
|
||||
FirstSeen time.Time `json:"first_seen"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
MergedInto ids.ID `json:"merged_into,omitempty"`
|
||||
}
|
||||
|
||||
// Relationship is an edge.
|
||||
type Relationship struct {
|
||||
ID ids.ID `json:"id"`
|
||||
FromEntity ids.ID `json:"from_entity"`
|
||||
ToEntity ids.ID `json:"to_entity"`
|
||||
Relation concepts.Relation `json:"relation"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
Source string `json:"source"`
|
||||
EventID ids.ID `json:"event_id,omitempty"`
|
||||
Count int `json:"count"`
|
||||
FirstSeen time.Time `json:"first_seen"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
}
|
||||
|
||||
// Link ties an entity to another object.
|
||||
type Link struct {
|
||||
Kind string `json:"kind"`
|
||||
RefID ids.ID `json:"ref_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ErrInvalid marks bad input.
|
||||
var ErrInvalid = errors.New("graph: invalid")
|
||||
|
||||
// Store persists the graph.
|
||||
type Store struct{ db *pgxpool.Pool }
|
||||
|
||||
// NewStore creates a store.
|
||||
func NewStore(db *pgxpool.Pool) *Store { return &Store{db: db} }
|
||||
|
||||
var spaces = regexp.MustCompile(`\s+`)
|
||||
|
||||
// Canonical normalises a name for matching: lower-case, collapsed spaces,
|
||||
// trailing punctuation removed.
|
||||
func Canonical(name string) string {
|
||||
c := strings.ToLower(strings.TrimSpace(name))
|
||||
c = strings.Trim(c, ".,;:!?\"'()[]")
|
||||
return spaces.ReplaceAllString(c, " ")
|
||||
}
|
||||
|
||||
// GuessKind classifies a mention deterministically. A model may override
|
||||
// the kind later; this keeps the graph useful without one.
|
||||
func GuessKind(name string) Kind {
|
||||
l := strings.ToLower(name)
|
||||
switch {
|
||||
case strings.HasSuffix(l, ".pdf"), strings.HasSuffix(l, ".md"), strings.HasSuffix(l, ".txt"), strings.HasSuffix(l, ".docx"):
|
||||
return Document
|
||||
case regexp.MustCompile(`^[a-z0-9-]+\.[a-z]{2,}(\.[a-z]{2,})*$`).MatchString(l), regexp.MustCompile(`^[a-z]+-?[0-9]{1,3}$`).MatchString(l), regexp.MustCompile(`^[a-z]+(-[a-z0-9]+)+$`).MatchString(l):
|
||||
return System
|
||||
case strings.Contains(l, "/") && !strings.Contains(l, " "):
|
||||
return Repository
|
||||
case len(strings.Fields(name)) == 1 && name != "" && name[0] >= 'A' && name[0] <= 'Z' && !strings.ContainsAny(name, "0123456789-."):
|
||||
return Person
|
||||
}
|
||||
return Concept
|
||||
}
|
||||
|
||||
// Upsert finds an entity by canonical name or alias, or creates it, and
|
||||
// bumps its mention count. Merged entities resolve to their survivor. A
|
||||
// recorded "entities_different" correction between the exact names prevents
|
||||
// alias matching from merging them.
|
||||
func (s *Store) Upsert(ctx context.Context, q storage.Querier, mind ids.ID, kind Kind, name string, seen time.Time) (Entity, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
canon := Canonical(name)
|
||||
if canon == "" || len(canon) > 200 {
|
||||
return Entity{}, fmt.Errorf("%w: name required (max 200)", ErrInvalid)
|
||||
}
|
||||
if !kind.Valid() {
|
||||
kind = GuessKind(name)
|
||||
}
|
||||
if seen.IsZero() {
|
||||
seen = time.Now()
|
||||
}
|
||||
e, err := s.find(ctx, q, mind, canon)
|
||||
if err == nil {
|
||||
_, err = q.Exec(ctx, `UPDATE entities SET mention_count=mention_count+1, last_seen=GREATEST(last_seen,$2) WHERE id=$1`, e.ID, seen)
|
||||
e.MentionCount++
|
||||
return e, err
|
||||
}
|
||||
if !errors.Is(err, storage.ErrNotFound) {
|
||||
return Entity{}, err
|
||||
}
|
||||
e = Entity{ID: ids.New(), MindID: mind, Kind: kind, Name: name, Canonical: canon, Aliases: []string{}, Attributes: map[string]any{}, MentionCount: 1, FirstSeen: seen, LastSeen: seen}
|
||||
_, err = q.Exec(ctx, `INSERT INTO entities (id, mind_id, kind, name, canonical, mention_count, first_seen, last_seen) VALUES ($1,$2,$3,$4,$5,1,$6,$6)
|
||||
ON CONFLICT (mind_id, canonical) WHERE merged_into IS NULL DO UPDATE SET mention_count=entities.mention_count+1, last_seen=GREATEST(entities.last_seen, excluded.last_seen)`, e.ID, mind, string(kind), name, canon, seen)
|
||||
if err != nil {
|
||||
return Entity{}, err
|
||||
}
|
||||
return s.find(ctx, q, mind, canon)
|
||||
}
|
||||
|
||||
func (s *Store) find(ctx context.Context, q storage.Querier, mind ids.ID, canon string) (Entity, error) {
|
||||
e, err := scan(q.QueryRow(ctx, `SELECT `+cols+` FROM entities e WHERE e.mind_id=$1 AND e.merged_into IS NULL AND (e.canonical=$2 OR $2 = ANY(e.aliases)) ORDER BY (e.canonical=$2) DESC LIMIT 1`, mind, canon))
|
||||
if err != nil {
|
||||
return Entity{}, storage.Translate(err)
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
const cols = `e.id, e.mind_id, e.kind, e.name, e.canonical, e.aliases, e.description, e.attributes, e.mention_count, e.first_seen, e.last_seen, e.merged_into`
|
||||
|
||||
func scan(row pgx.Row) (Entity, error) {
|
||||
var e Entity
|
||||
var kind string
|
||||
var attrs []byte
|
||||
var merged *string
|
||||
if err := row.Scan(&e.ID, &e.MindID, &kind, &e.Name, &e.Canonical, &e.Aliases, &e.Description, &attrs, &e.MentionCount, &e.FirstSeen, &e.LastSeen, &merged); err != nil {
|
||||
return e, storage.Translate(err)
|
||||
}
|
||||
e.Kind = Kind(kind)
|
||||
json.Unmarshal(attrs, &e.Attributes) //nolint:errcheck
|
||||
if e.Attributes == nil {
|
||||
e.Attributes = map[string]any{}
|
||||
}
|
||||
if e.Aliases == nil {
|
||||
e.Aliases = []string{}
|
||||
}
|
||||
if merged != nil {
|
||||
e.MergedInto = ids.ID(*merged)
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// Get fetches an entity, following merges.
|
||||
func (s *Store) Get(ctx context.Context, mind, id ids.ID) (Entity, error) {
|
||||
for i := 0; i < 5; i++ {
|
||||
e, err := scan(s.db.QueryRow(ctx, `SELECT `+cols+` FROM entities e WHERE e.id=$1 AND e.mind_id=$2`, id, mind))
|
||||
if err != nil {
|
||||
return Entity{}, err
|
||||
}
|
||||
if e.MergedInto.IsZero() {
|
||||
return e, nil
|
||||
}
|
||||
id = e.MergedInto
|
||||
}
|
||||
return Entity{}, storage.ErrNotFound
|
||||
}
|
||||
|
||||
// Search lists entities by name prefix/substring and kind, most mentioned first.
|
||||
func (s *Store) Search(ctx context.Context, mind ids.ID, query string, kind Kind, limit int) ([]Entity, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `SELECT `+cols+` FROM entities e WHERE e.mind_id=$1 AND e.merged_into IS NULL AND ($2='' OR e.canonical LIKE '%'||$2||'%') AND ($3='' OR e.kind=$3) ORDER BY e.mention_count DESC, e.last_seen DESC LIMIT $4`, mind, Canonical(query), string(kind), limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Entity
|
||||
for rows.Next() {
|
||||
e, err := scan(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Relate adds or reinforces an edge. Repeats increase count and take the
|
||||
// higher confidence; both endpoints must belong to the mind.
|
||||
func (s *Store) Relate(ctx context.Context, q storage.Querier, mind, from, to ids.ID, rel concepts.Relation, confidence float64, source string, eventID ids.ID) (Relationship, error) {
|
||||
if !rel.Valid() || from == to || from.IsZero() || to.IsZero() {
|
||||
return Relationship{}, fmt.Errorf("%w: bad relation", ErrInvalid)
|
||||
}
|
||||
var ev *string
|
||||
if !eventID.IsZero() {
|
||||
e := string(eventID)
|
||||
ev = &e
|
||||
}
|
||||
r := Relationship{ID: ids.New(), FromEntity: from, ToEntity: to, Relation: rel, Confidence: clamp01(confidence), Source: source, EventID: eventID, Count: 1, FirstSeen: time.Now(), LastSeen: time.Now()}
|
||||
tag, err := q.Exec(ctx, `INSERT INTO entity_relationships (id, mind_id, from_entity, to_entity, relation, confidence, source, event_id)
|
||||
SELECT $1,$2,$3,$4,$5,$6,$7,$8 WHERE (SELECT count(*) FROM entities WHERE id IN ($3,$4) AND mind_id=$2) = 2
|
||||
ON CONFLICT (from_entity, to_entity, relation) DO UPDATE SET count=entity_relationships.count+1, confidence=GREATEST(entity_relationships.confidence, excluded.confidence), last_seen=now()`,
|
||||
r.ID, mind, from, to, string(rel), r.Confidence, source, ev)
|
||||
if err != nil {
|
||||
return Relationship{}, err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return Relationship{}, fmt.Errorf("%w: entities not in mind", ErrInvalid)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// LinkRef records that an entity is involved in a memory, belief, event, goal or prediction.
|
||||
func (s *Store) LinkRef(ctx context.Context, q storage.Querier, mind, entity ids.ID, kind string, ref ids.ID) error {
|
||||
_, err := q.Exec(ctx, `INSERT INTO entity_links (id, mind_id, entity_id, kind, ref_id) SELECT $1,$2,$3,$4,$5 WHERE EXISTS (SELECT 1 FROM entities WHERE id=$3 AND mind_id=$2) ON CONFLICT DO NOTHING`, ids.New(), mind, entity, kind, ref)
|
||||
return err
|
||||
}
|
||||
|
||||
// Links returns an entity's links, newest first.
|
||||
func (s *Store) Links(ctx context.Context, mind, entity ids.ID, limit int) ([]Link, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `SELECT kind, ref_id, created_at FROM entity_links WHERE mind_id=$1 AND entity_id=$2 ORDER BY created_at DESC LIMIT $3`, mind, entity, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Link
|
||||
for rows.Next() {
|
||||
var l Link
|
||||
if err := rows.Scan(&l.Kind, &l.RefID, &l.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, l)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Edge is a relationship with both entities loaded for rendering.
|
||||
type Edge struct {
|
||||
Relationship
|
||||
From Entity `json:"from"`
|
||||
To Entity `json:"to"`
|
||||
}
|
||||
|
||||
// Neighbourhood returns the entity's edges with the other endpoints.
|
||||
func (s *Store) Neighbourhood(ctx context.Context, mind, entity ids.ID, limit int) ([]Edge, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `SELECT r.id, r.from_entity, r.to_entity, r.relation, r.confidence, r.source, r.event_id, r.count, r.first_seen, r.last_seen FROM entity_relationships r
|
||||
WHERE r.mind_id=$1 AND (r.from_entity=$2 OR r.to_entity=$2) ORDER BY r.count DESC, r.confidence DESC LIMIT $3`, mind, entity, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var rels []Relationship
|
||||
for rows.Next() {
|
||||
r, err := scanRel(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rels = append(rels, r)
|
||||
}
|
||||
rows.Close()
|
||||
return s.hydrate(ctx, mind, rels)
|
||||
}
|
||||
|
||||
func scanRel(row pgx.Row) (Relationship, error) {
|
||||
var r Relationship
|
||||
var rel string
|
||||
var ev *string
|
||||
if err := row.Scan(&r.ID, &r.FromEntity, &r.ToEntity, &rel, &r.Confidence, &r.Source, &ev, &r.Count, &r.FirstSeen, &r.LastSeen); err != nil {
|
||||
return r, err
|
||||
}
|
||||
r.Relation = concepts.Relation(rel)
|
||||
if ev != nil {
|
||||
r.EventID = ids.ID(*ev)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (s *Store) hydrate(ctx context.Context, mind ids.ID, rels []Relationship) ([]Edge, error) {
|
||||
cache := map[ids.ID]Entity{}
|
||||
get := func(id ids.ID) (Entity, error) {
|
||||
if e, ok := cache[id]; ok {
|
||||
return e, nil
|
||||
}
|
||||
e, err := s.Get(ctx, mind, id)
|
||||
if err == nil {
|
||||
cache[id] = e
|
||||
}
|
||||
return e, err
|
||||
}
|
||||
var out []Edge
|
||||
for _, r := range rels {
|
||||
from, err := get(r.FromEntity)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
to, err := get(r.ToEntity)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, Edge{Relationship: r, From: from, To: to})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Subgraph returns the most-connected entities (optionally around a focus
|
||||
// entity or filtered by kind) and the edges among them, bounded by limit so
|
||||
// the explorer never renders thousands of nodes.
|
||||
type Subgraph struct {
|
||||
Nodes []Entity `json:"nodes"`
|
||||
Edges []Edge `json:"edges"`
|
||||
Total int `json:"total_entities"`
|
||||
}
|
||||
|
||||
// Subgraph builds a bounded view.
|
||||
func (s *Store) Subgraph(ctx context.Context, mind ids.ID, focus ids.ID, kind Kind, limit int) (Subgraph, error) {
|
||||
if limit <= 0 || limit > 300 {
|
||||
limit = 80
|
||||
}
|
||||
var sg Subgraph
|
||||
s.db.QueryRow(ctx, `SELECT count(*) FROM entities WHERE mind_id=$1 AND merged_into IS NULL`, mind).Scan(&sg.Total) //nolint:errcheck
|
||||
var nodes []Entity
|
||||
if !focus.IsZero() {
|
||||
f, err := s.Get(ctx, mind, focus)
|
||||
if err != nil {
|
||||
return sg, err
|
||||
}
|
||||
nodes = append(nodes, f)
|
||||
edges, err := s.Neighbourhood(ctx, mind, f.ID, limit)
|
||||
if err != nil {
|
||||
return sg, err
|
||||
}
|
||||
seen := map[ids.ID]bool{f.ID: true}
|
||||
for _, e := range edges {
|
||||
for _, n := range []Entity{e.From, e.To} {
|
||||
if !seen[n.ID] && (kind == "" || n.Kind == kind || n.ID == f.ID) {
|
||||
seen[n.ID] = true
|
||||
nodes = append(nodes, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
nodes, err = s.Search(ctx, mind, "", kind, limit)
|
||||
if err != nil {
|
||||
return sg, err
|
||||
}
|
||||
}
|
||||
idList := make([]string, 0, len(nodes))
|
||||
for _, n := range nodes {
|
||||
idList = append(idList, string(n.ID))
|
||||
}
|
||||
sg.Nodes = nodes
|
||||
if len(idList) == 0 {
|
||||
sg.Edges = []Edge{}
|
||||
return sg, nil
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `SELECT r.id, r.from_entity, r.to_entity, r.relation, r.confidence, r.source, r.event_id, r.count, r.first_seen, r.last_seen FROM entity_relationships r
|
||||
WHERE r.mind_id=$1 AND r.from_entity = ANY($2) AND r.to_entity = ANY($2) ORDER BY r.count DESC LIMIT 1000`, mind, idList)
|
||||
if err != nil {
|
||||
return sg, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var rels []Relationship
|
||||
for rows.Next() {
|
||||
r, err := scanRel(rows)
|
||||
if err != nil {
|
||||
return sg, err
|
||||
}
|
||||
rels = append(rels, r)
|
||||
}
|
||||
sg.Edges, err = s.hydrate(ctx, mind, rels)
|
||||
if sg.Edges == nil {
|
||||
sg.Edges = []Edge{}
|
||||
}
|
||||
return sg, err
|
||||
}
|
||||
|
||||
// Merge folds loser into survivor: relationships and links are re-pointed,
|
||||
// the loser's name becomes an alias, and the loser row stays with
|
||||
// merged_into set so old references still resolve.
|
||||
func (s *Store) Merge(ctx context.Context, mind, survivor, loser ids.ID) error {
|
||||
if survivor == loser {
|
||||
return fmt.Errorf("%w: same entity", ErrInvalid)
|
||||
}
|
||||
return storage.Tx(ctx, s.db, func(tx pgx.Tx) error {
|
||||
sv, err := scan(tx.QueryRow(ctx, `SELECT `+cols+` FROM entities e WHERE e.id=$1 AND e.mind_id=$2 AND e.merged_into IS NULL FOR UPDATE`, survivor, mind))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lo, err := scan(tx.QueryRow(ctx, `SELECT `+cols+` FROM entities e WHERE e.id=$1 AND e.mind_id=$2 AND e.merged_into IS NULL FOR UPDATE`, loser, mind))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Re-point edges; drop those that would become self-loops or duplicates.
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM entity_relationships WHERE mind_id=$1 AND ((from_entity=$2 AND to_entity=$3) OR (from_entity=$3 AND to_entity=$2))`, mind, survivor, loser); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE entity_relationships r SET from_entity=$1 WHERE r.from_entity=$2 AND NOT EXISTS (SELECT 1 FROM entity_relationships x WHERE x.from_entity=$1 AND x.to_entity=r.to_entity AND x.relation=r.relation)`, survivor, loser); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE entity_relationships r SET to_entity=$1 WHERE r.to_entity=$2 AND NOT EXISTS (SELECT 1 FROM entity_relationships x WHERE x.to_entity=$1 AND x.from_entity=r.from_entity AND x.relation=r.relation)`, survivor, loser); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM entity_relationships WHERE from_entity=$1 OR to_entity=$1`, loser); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE entity_links l SET entity_id=$1 WHERE l.entity_id=$2 AND NOT EXISTS (SELECT 1 FROM entity_links x WHERE x.entity_id=$1 AND x.kind=l.kind AND x.ref_id=l.ref_id)`, survivor, loser); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM entity_links WHERE entity_id=$1`, loser); err != nil {
|
||||
return err
|
||||
}
|
||||
aliases := append(append([]string{}, sv.Aliases...), lo.Canonical)
|
||||
aliases = append(aliases, lo.Aliases...)
|
||||
if _, err := tx.Exec(ctx, `UPDATE entities SET aliases=$2, mention_count=mention_count+$3, first_seen=LEAST(first_seen,$4), last_seen=GREATEST(last_seen,$5) WHERE id=$1`, survivor, uniq(aliases), lo.MentionCount, lo.FirstSeen, lo.LastSeen); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE entities SET merged_into=$2 WHERE id=$1`, loser, survivor)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// SetKind and description let operators correct classification.
|
||||
func (s *Store) Update(ctx context.Context, mind, id ids.ID, kind Kind, description string) error {
|
||||
if !kind.Valid() {
|
||||
return fmt.Errorf("%w: unknown kind", ErrInvalid)
|
||||
}
|
||||
tag, err := s.db.Exec(ctx, `UPDATE entities SET kind=$3, description=$4 WHERE id=$1 AND mind_id=$2`, id, mind, string(kind), description)
|
||||
if err == nil && tag.RowsAffected() == 0 {
|
||||
return storage.ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Stats counts entities by kind and edges.
|
||||
type Stats struct {
|
||||
Entities int64 `json:"entities"`
|
||||
Edges int64 `json:"edges"`
|
||||
ByKind map[string]int64 `json:"by_kind"`
|
||||
}
|
||||
|
||||
// Stats returns counts.
|
||||
func (s *Store) Stats(ctx context.Context, mind ids.ID) (Stats, error) {
|
||||
st := Stats{ByKind: map[string]int64{}}
|
||||
rows, err := s.db.Query(ctx, `SELECT kind, count(*) FROM entities WHERE mind_id=$1 AND merged_into IS NULL GROUP BY kind`, mind)
|
||||
if err != nil {
|
||||
return st, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var k string
|
||||
var n int64
|
||||
if err := rows.Scan(&k, &n); err != nil {
|
||||
rows.Close()
|
||||
return st, err
|
||||
}
|
||||
st.ByKind[k] = n
|
||||
st.Entities += n
|
||||
}
|
||||
rows.Close()
|
||||
err = s.db.QueryRow(ctx, `SELECT count(*) FROM entity_relationships WHERE mind_id=$1`, mind).Scan(&st.Edges)
|
||||
return st, err
|
||||
}
|
||||
|
||||
func uniq(xs []string) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, x := range xs {
|
||||
if x != "" && !seen[x] {
|
||||
seen[x] = true
|
||||
out = append(out, x)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func clamp01(v float64) float64 {
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
if v > 1 {
|
||||
return 1
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package graph
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/andydixon/noema/internal/minds"
|
||||
"github.com/andydixon/noema/internal/storage"
|
||||
"github.com/andydixon/noema/migrations"
|
||||
"github.com/andydixon/noema/pkg/concepts"
|
||||
"github.com/andydixon/noema/pkg/ids"
|
||||
)
|
||||
|
||||
func TestGuessKindAndCanonical(t *testing.T) {
|
||||
cases := map[string]Kind{"atlas.local": System, "server-01": System, "web-api-prod": System, "Andy": Person, "report.pdf": Document, "andydixon/noema": Repository, "latency": Concept}
|
||||
for name, want := range cases {
|
||||
if got := GuessKind(name); got != want {
|
||||
t.Errorf("%s: got %s want %s", name, got, want)
|
||||
}
|
||||
}
|
||||
if Canonical(" Atlas, ") != "atlas" {
|
||||
t.Fatal("canonical wrong")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertRelateMergeAndIsolation(t *testing.T) {
|
||||
pool := storage.TestPool(t, migrations.FS)
|
||||
ctx := context.Background()
|
||||
ms := minds.NewStore(pool)
|
||||
a, _ := ms.Create(ctx, minds.CreateParams{Slug: "a"})
|
||||
b, _ := ms.Create(ctx, minds.CreateParams{Slug: "b"})
|
||||
s := NewStore(pool)
|
||||
now := time.Now()
|
||||
andy, _ := s.Upsert(ctx, pool, a.ID, "", "Andy", now)
|
||||
andy2, _ := s.Upsert(ctx, pool, a.ID, "", "andy", now)
|
||||
if andy.ID != andy2.ID || andy2.MentionCount != 2 || andy.Kind != Person {
|
||||
t.Fatalf("upsert dedupe wrong: %+v %+v", andy, andy2)
|
||||
}
|
||||
atlas, _ := s.Upsert(ctx, pool, a.ID, "", "atlas", now)
|
||||
atlasHost, _ := s.Upsert(ctx, pool, a.ID, "", "atlas.local", now)
|
||||
if _, err := s.Relate(ctx, pool, a.ID, andy.ID, atlas.ID, concepts.DiscussedWith, 0.3, "co-mention", ids.Nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r2, _ := s.Relate(ctx, pool, a.ID, andy.ID, atlas.ID, concepts.DiscussedWith, 0.6, "co-mention", ids.Nil)
|
||||
_ = r2
|
||||
nb, _ := s.Neighbourhood(ctx, a.ID, andy.ID, 10)
|
||||
if len(nb) != 1 || nb[0].Count != 2 || nb[0].Confidence != 0.6 {
|
||||
t.Fatalf("relate reinforcement wrong: %+v", nb)
|
||||
}
|
||||
// Cross-mind endpoints are refused.
|
||||
other, _ := s.Upsert(ctx, pool, b.ID, "", "atlas", now)
|
||||
if _, err := s.Relate(ctx, pool, a.ID, andy.ID, other.ID, concepts.RelatedTo, 0.5, "x", ids.Nil); err == nil {
|
||||
t.Fatal("cross-mind edge accepted")
|
||||
}
|
||||
if _, err := s.Get(ctx, b.ID, andy.ID); err == nil {
|
||||
t.Fatal("cross-mind get")
|
||||
}
|
||||
// Merge atlas.local into atlas; old id still resolves; alias matches.
|
||||
s.Relate(ctx, pool, a.ID, atlasHost.ID, andy.ID, concepts.BelongsTo, 0.5, "x", ids.Nil) //nolint:errcheck
|
||||
s.LinkRef(ctx, pool, a.ID, atlasHost.ID, "memory", ids.New()) //nolint:errcheck
|
||||
if err := s.Merge(ctx, a.ID, atlas.ID, atlasHost.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := s.Get(ctx, a.ID, atlasHost.ID)
|
||||
if err != nil || got.ID != atlas.ID || len(got.Aliases) == 0 {
|
||||
t.Fatalf("merge resolution wrong: %v %+v", err, got)
|
||||
}
|
||||
again, _ := s.Upsert(ctx, pool, a.ID, "", "atlas.local", now)
|
||||
if again.ID != atlas.ID {
|
||||
t.Fatal("alias did not match after merge")
|
||||
}
|
||||
links, _ := s.Links(ctx, a.ID, atlas.ID, 10)
|
||||
if len(links) != 1 {
|
||||
t.Fatalf("links not moved: %d", len(links))
|
||||
}
|
||||
sg, _ := s.Subgraph(ctx, a.ID, ids.Nil, "", 50)
|
||||
if len(sg.Nodes) != 2 || len(sg.Edges) < 1 || sg.Total != 2 {
|
||||
t.Fatalf("subgraph wrong: %d nodes %d edges total %d", len(sg.Nodes), len(sg.Edges), sg.Total)
|
||||
}
|
||||
st, _ := s.Stats(ctx, a.ID)
|
||||
if st.Entities != 2 || st.ByKind["person"] != 1 {
|
||||
t.Fatalf("stats: %+v", st)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
|
||||
"github.com/andydixon/noema/internal/api"
|
||||
"github.com/andydixon/noema/internal/auth"
|
||||
"github.com/andydixon/noema/internal/graph"
|
||||
"github.com/andydixon/noema/pkg/ids"
|
||||
)
|
||||
|
||||
func (u *UI) graphRoutes(m *http.ServeMux) {
|
||||
m.Handle("GET /minds/{mind}/graph", u.require(auth.RoleObserver, u.graphPage))
|
||||
m.Handle("GET /minds/{mind}/entities/{id}", u.require(auth.RoleObserver, u.entityPage))
|
||||
m.Handle("POST /minds/{mind}/entities/{id}/merge", u.require(auth.RoleOperator, u.entityMerge))
|
||||
m.Handle("POST /minds/{mind}/entities/{id}/distinct", u.require(auth.RoleOperator, u.entityDistinct))
|
||||
m.Handle("POST /minds/{mind}/entities/{id}/update", u.require(auth.RoleOperator, u.entityUpdate))
|
||||
}
|
||||
|
||||
func (u *UI) graphPage(w http.ResponseWriter, r *http.Request) {
|
||||
m, ok := u.mind(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
st, _ := u.deps.Graph.Stats(r.Context(), m.ID)
|
||||
top, _ := u.deps.Graph.Search(r.Context(), m.ID, "", graph.Kind(r.URL.Query().Get("kind")), 25)
|
||||
u.render(w, r, "graph", m.DisplayName+" · Relationships", map[string]any{"Mind": m, "Stats": st, "Entities": top, "Kind": r.URL.Query().Get("kind"), "Focus": r.URL.Query().Get("focus")})
|
||||
}
|
||||
|
||||
func (u *UI) entityPage(w http.ResponseWriter, r *http.Request) {
|
||||
m, ok := u.mind(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := ids.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
u.errorPage(w, r, 404, "No such entity.")
|
||||
return
|
||||
}
|
||||
d, err := u.entF(r, m.ID, id)
|
||||
if err != nil {
|
||||
u.errorPage(w, r, 404, "No such entity.")
|
||||
return
|
||||
}
|
||||
data := map[string]any{"Mind": m, "Entity": d.Entity, "Edges": d.Edges, "Links": d.Links}
|
||||
if r.URL.Query().Get("partial") == "1" {
|
||||
// Rendered into the graph page's side panel by graph.js.
|
||||
t := u.pages["entity"]
|
||||
var buf bytes.Buffer
|
||||
if err := t.ExecuteTemplate(&buf, "entity_panel", View{Data: data}); err != nil {
|
||||
http.Error(w, "render failed", 500)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
buf.WriteTo(w) //nolint:errcheck
|
||||
return
|
||||
}
|
||||
u.render(w, r, "entity", d.Entity.Name, data)
|
||||
}
|
||||
|
||||
func (u *UI) entity(w http.ResponseWriter, r *http.Request) (api.EntityDetail, bool) {
|
||||
m, ok := u.mind(w, r)
|
||||
if !ok {
|
||||
return api.EntityDetail{}, false
|
||||
}
|
||||
id, err := ids.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
u.errorPage(w, r, 404, "No such entity.")
|
||||
return api.EntityDetail{}, false
|
||||
}
|
||||
d, err := u.entF(r, m.ID, id)
|
||||
if err != nil {
|
||||
u.errorPage(w, r, 404, "No such entity.")
|
||||
return api.EntityDetail{}, false
|
||||
}
|
||||
return d, true
|
||||
}
|
||||
|
||||
func (u *UI) entityMerge(w http.ResponseWriter, r *http.Request) {
|
||||
m, _ := u.mind(w, r)
|
||||
d, ok := u.entity(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := u.mergeF(r, m.ID, d.Entity, r.PostFormValue("other")); err != nil {
|
||||
u.flash(w, r, "error", err.Error(), "/minds/"+m.Slug+"/entities/"+d.Entity.ID.String())
|
||||
return
|
||||
}
|
||||
u.flash(w, r, "ok", "Entities merged; the old name is now an alias.", "/minds/"+m.Slug+"/entities/"+d.Entity.ID.String())
|
||||
}
|
||||
|
||||
func (u *UI) entityDistinct(w http.ResponseWriter, r *http.Request) {
|
||||
m, _ := u.mind(w, r)
|
||||
d, ok := u.entity(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := u.distinctF(r, m.ID, d.Entity, r.PostFormValue("other")); err != nil {
|
||||
u.flash(w, r, "error", err.Error(), "/minds/"+m.Slug+"/entities/"+d.Entity.ID.String())
|
||||
return
|
||||
}
|
||||
u.flash(w, r, "ok", "Recorded as different entities.", "/minds/"+m.Slug+"/entities/"+d.Entity.ID.String())
|
||||
}
|
||||
|
||||
func (u *UI) entityUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
m, _ := u.mind(w, r)
|
||||
d, ok := u.entity(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := u.deps.Graph.Update(r.Context(), m.ID, d.Entity.ID, graph.Kind(r.PostFormValue("kind")), r.PostFormValue("description")); err != nil {
|
||||
u.flash(w, r, "error", err.Error(), "/minds/"+m.Slug+"/entities/"+d.Entity.ID.String())
|
||||
return
|
||||
}
|
||||
u.deps.Audit.Log(r.Context(), "entity.update", "entity", d.Entity.ID.String(), map[string]any{"kind": r.PostFormValue("kind")})
|
||||
u.flash(w, r, "ok", "Entity updated.", "/minds/"+m.Slug+"/entities/"+d.Entity.ID.String())
|
||||
}
|
||||
+29
-18
@@ -24,6 +24,7 @@ import (
|
||||
"github.com/andydixon/noema/internal/consolidation"
|
||||
"github.com/andydixon/noema/internal/conversation"
|
||||
"github.com/andydixon/noema/internal/goals"
|
||||
"github.com/andydixon/noema/internal/graph"
|
||||
"github.com/andydixon/noema/internal/llm"
|
||||
"github.com/andydixon/noema/internal/minds"
|
||||
"github.com/andydixon/noema/internal/motivation"
|
||||
@@ -37,23 +38,26 @@ import (
|
||||
|
||||
// UI holds handlers and the parsed template set.
|
||||
type UI struct {
|
||||
deps app.Deps
|
||||
pages map[string]*template.Template
|
||||
static fs.FS
|
||||
statusF func(r *http.Request) any // system status provider injected by api
|
||||
ctxF func(r *http.Request, id ids.ID) (api.EventContext, error) // event forensic context, shared with api
|
||||
wsF func(r *http.Request, m minds.Mind) (api.WorkspaceView, error)
|
||||
memF func(r *http.Request, mind, id ids.ID) (api.MemoryDetail, error)
|
||||
belF func(r *http.Request, mind, id ids.ID) (api.BeliefDetail, error)
|
||||
emitF func() beliefs.Emitter
|
||||
postF func(r *http.Request, mind ids.ID, conv conversation.Conversation, text, who string) (conversation.Message, error)
|
||||
goalF func(r *http.Request, n goals.New) (goals.Goal, error)
|
||||
goalUpdF func(r *http.Request, mind, id ids.ID, u goals.Update) (goals.Goal, error)
|
||||
predF func(r *http.Request, n predictions.New) (predictions.Prediction, error)
|
||||
resolveF func(r *http.Request, mind, id ids.ID, outcome predictions.Status, reason string) (predictions.Result, error)
|
||||
resetF func(r *http.Request, mind ids.ID) (motivation.State, error)
|
||||
consolF func(r *http.Request, mind ids.ID) (consolidation.Job, error)
|
||||
reflF func(r *http.Request, mind ids.ID) (reflection.Reflection, error)
|
||||
deps app.Deps
|
||||
pages map[string]*template.Template
|
||||
static fs.FS
|
||||
statusF func(r *http.Request) any // system status provider injected by api
|
||||
ctxF func(r *http.Request, id ids.ID) (api.EventContext, error) // event forensic context, shared with api
|
||||
wsF func(r *http.Request, m minds.Mind) (api.WorkspaceView, error)
|
||||
memF func(r *http.Request, mind, id ids.ID) (api.MemoryDetail, error)
|
||||
belF func(r *http.Request, mind, id ids.ID) (api.BeliefDetail, error)
|
||||
emitF func() beliefs.Emitter
|
||||
postF func(r *http.Request, mind ids.ID, conv conversation.Conversation, text, who string) (conversation.Message, error)
|
||||
goalF func(r *http.Request, n goals.New) (goals.Goal, error)
|
||||
goalUpdF func(r *http.Request, mind, id ids.ID, u goals.Update) (goals.Goal, error)
|
||||
predF func(r *http.Request, n predictions.New) (predictions.Prediction, error)
|
||||
resolveF func(r *http.Request, mind, id ids.ID, outcome predictions.Status, reason string) (predictions.Result, error)
|
||||
resetF func(r *http.Request, mind ids.ID) (motivation.State, error)
|
||||
consolF func(r *http.Request, mind ids.ID) (consolidation.Job, error)
|
||||
reflF func(r *http.Request, mind ids.ID) (reflection.Reflection, error)
|
||||
entF func(r *http.Request, mind, id ids.ID) (api.EntityDetail, error)
|
||||
mergeF func(r *http.Request, mind ids.ID, e graph.Entity, other string) error
|
||||
distinctF func(r *http.Request, mind ids.ID, e graph.Entity, other string) error
|
||||
}
|
||||
|
||||
// Actions groups the server-side operations the UI delegates to the API layer
|
||||
@@ -73,6 +77,9 @@ type Actions struct {
|
||||
ResetState func(r *http.Request, mind ids.ID) (motivation.State, error)
|
||||
Consolidate func(r *http.Request, mind ids.ID) (consolidation.Job, error)
|
||||
Reflect func(r *http.Request, mind ids.ID) (reflection.Reflection, error)
|
||||
Entity func(r *http.Request, mind, id ids.ID) (api.EntityDetail, error)
|
||||
Merge func(r *http.Request, mind ids.ID, e graph.Entity, other string) error
|
||||
Distinct func(r *http.Request, mind ids.ID, e graph.Entity, other string) error
|
||||
}
|
||||
|
||||
// View is the data passed to every template.
|
||||
@@ -102,7 +109,7 @@ func New(deps app.Deps, a Actions) (*UI, error) {
|
||||
deps.Log.Info("ui: serving templates and static files from ./web (development)")
|
||||
}
|
||||
}
|
||||
u := &UI{deps: deps, static: staticFS, statusF: a.Status, ctxF: a.Context, wsF: a.Workspace, memF: a.Memory, belF: a.Belief, emitF: a.Emitter, postF: a.Post, goalF: a.Goal, goalUpdF: a.GoalUpdate, predF: a.Predict, resolveF: a.Resolve, resetF: a.ResetState, consolF: a.Consolidate, reflF: a.Reflect}
|
||||
u := &UI{deps: deps, static: staticFS, statusF: a.Status, ctxF: a.Context, wsF: a.Workspace, memF: a.Memory, belF: a.Belief, emitF: a.Emitter, postF: a.Post, goalF: a.Goal, goalUpdF: a.GoalUpdate, predF: a.Predict, resolveF: a.Resolve, resetF: a.ResetState, consolF: a.Consolidate, reflF: a.Reflect, entF: a.Entity, mergeF: a.Merge, distinctF: a.Distinct}
|
||||
if err := u.parse(tmplFS); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -112,6 +119,7 @@ func New(deps app.Deps, a Actions) (*UI, error) {
|
||||
func (u *UI) parse(fsys fs.FS) error {
|
||||
funcs := template.FuncMap{
|
||||
"hasPrefix": strings.HasPrefix,
|
||||
"hasSuffix": strings.HasSuffix,
|
||||
"roles": auth.AllRoles,
|
||||
"date": func(t time.Time) string { return t.Local().Format("2006-01-02 15:04") },
|
||||
"ago": ago,
|
||||
@@ -155,6 +163,8 @@ func (u *UI) parse(fsys fs.FS) error {
|
||||
"relations": func() []concepts.Relation { return concepts.AllRelations },
|
||||
"llmRoles": func() []llm.RoleInfo { return llm.Roles },
|
||||
"pretty": prettyRaw,
|
||||
"entityKinds": func() []graph.Kind { return graph.AllKinds },
|
||||
"join": strings.Join,
|
||||
"outputs": func(raw json.RawMessage) []consolidation.Output {
|
||||
var out []consolidation.Output
|
||||
json.Unmarshal(raw, &out) //nolint:errcheck
|
||||
@@ -358,6 +368,7 @@ func (u *UI) Register(m *http.ServeMux) {
|
||||
u.conversationRoutes(m)
|
||||
u.motivationRoutes(m)
|
||||
u.cognitionRoutes(m)
|
||||
u.graphRoutes(m)
|
||||
|
||||
for _, p := range []struct{ path, title, note string }{
|
||||
{"/approvals", "Approvals", "The approval centre arrives with the capability system."},
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
DROP TABLE IF EXISTS entity_links;
|
||||
DROP TABLE IF EXISTS entity_relationships;
|
||||
DROP TABLE IF EXISTS entities;
|
||||
@@ -0,0 +1,48 @@
|
||||
-- Entities and typed relationships (spec §24). Entities are mind-scoped;
|
||||
-- merges keep the loser row with merged_into so history never dangles.
|
||||
|
||||
CREATE TABLE entities (
|
||||
id uuid PRIMARY KEY,
|
||||
mind_id uuid NOT NULL REFERENCES minds(id),
|
||||
kind text NOT NULL CHECK (kind IN ('person','system','project','document','concept','repository','organisation','place','other')),
|
||||
name text NOT NULL,
|
||||
canonical text NOT NULL,
|
||||
aliases text[] NOT NULL DEFAULT '{}',
|
||||
description text NOT NULL DEFAULT '',
|
||||
attributes jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
mention_count integer NOT NULL DEFAULT 0,
|
||||
first_seen timestamptz NOT NULL DEFAULT now(),
|
||||
last_seen timestamptz NOT NULL DEFAULT now(),
|
||||
merged_into uuid REFERENCES entities(id),
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE UNIQUE INDEX entities_canonical_idx ON entities (mind_id, canonical) WHERE merged_into IS NULL;
|
||||
CREATE INDEX entities_mind_kind_idx ON entities (mind_id, kind, mention_count DESC);
|
||||
|
||||
CREATE TABLE entity_relationships (
|
||||
id uuid PRIMARY KEY,
|
||||
mind_id uuid NOT NULL REFERENCES minds(id),
|
||||
from_entity uuid NOT NULL REFERENCES entities(id),
|
||||
to_entity uuid NOT NULL REFERENCES entities(id),
|
||||
relation text NOT NULL,
|
||||
confidence double precision NOT NULL DEFAULT 0.3,
|
||||
source text NOT NULL DEFAULT '',
|
||||
event_id uuid,
|
||||
count integer NOT NULL DEFAULT 1,
|
||||
first_seen timestamptz NOT NULL DEFAULT now(),
|
||||
last_seen timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (from_entity, to_entity, relation)
|
||||
);
|
||||
CREATE INDEX entity_relationships_to_idx ON entity_relationships (to_entity);
|
||||
|
||||
-- Mentions: which memories, beliefs, events, goals and predictions involve an entity.
|
||||
CREATE TABLE entity_links (
|
||||
id uuid PRIMARY KEY,
|
||||
mind_id uuid NOT NULL REFERENCES minds(id),
|
||||
entity_id uuid NOT NULL REFERENCES entities(id),
|
||||
kind text NOT NULL,
|
||||
ref_id uuid NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (entity_id, kind, ref_id)
|
||||
);
|
||||
CREATE INDEX entity_links_ref_idx ON entity_links (kind, ref_id);
|
||||
+3
-1
@@ -27,6 +27,7 @@ import (
|
||||
"github.com/andydixon/noema/internal/conversation"
|
||||
evstore "github.com/andydixon/noema/internal/events"
|
||||
"github.com/andydixon/noema/internal/goals"
|
||||
"github.com/andydixon/noema/internal/graph"
|
||||
"github.com/andydixon/noema/internal/llm"
|
||||
"github.com/andydixon/noema/internal/llm/prompts"
|
||||
"github.com/andydixon/noema/internal/llm/router"
|
||||
@@ -61,7 +62,7 @@ func start(t *testing.T, deps app.Deps, perceive, respond llm.LanguageModel) *ha
|
||||
registry := brain.NewRegistry().AddProcess(cognition.Attend{}).AddProcess(cognition.Retrieve{}).AddProcess(cognition.Associate{}).AddProcess(cognition.Perceive{}).
|
||||
AddProcess(cognition.GoalMonitor{}).AddProcess(cognition.Curiosity{}).AddProcess(cognition.Metacognition{}).
|
||||
AddHook(cognition.MotivationHook{Store: deps.Motivation}).AddHook(cognition.NarrativeHook{Store: deps.Narrative}).AddHook(cognition.NotifyHook{Store: deps.Notifications})
|
||||
cognition.Handlers{Memory: deps.Memory, Beliefs: deps.Beliefs, Conversations: deps.Conversations}.Register(registry)
|
||||
cognition.Handlers{Memory: deps.Memory, Beliefs: deps.Beliefs, Conversations: deps.Conversations, Graph: deps.Graph}.Register(registry)
|
||||
deps.LLM.Override = map[llm.Role]llm.LanguageModel{llm.RolePerceive: perceive, llm.RoleRespond: respond}
|
||||
deps.Brain = brain.NewManager(brain.Config{DB: deps.DB, Events: deps.Events, Minds: deps.Minds, Registry: registry, Pool: scheduler.NewPool(4), Log: deps.Log, Metrics: deps.Metrics,
|
||||
Services: brain.Services{Events: deps.Events, Memory: deps.Memory, Beliefs: deps.Beliefs, LLM: deps.LLM, Prompts: deps.Prompts, Goals: deps.Goals, Motivation: deps.Motivation, Predictions: deps.Predictions}}, group)
|
||||
@@ -113,6 +114,7 @@ func TestFirstMilestone(t *testing.T) {
|
||||
Minds: minds.NewStore(pool), Events: evstore.NewStore(pool, evstore.NewBus(64), nil), Memory: memory.NewStore(ctx, pool, nil), Beliefs: beliefs.NewStore(pool),
|
||||
Conversations: conversation.NewStore(pool), Goals: goals.NewStore(pool), Motivation: motivation.NewStore(pool), Predictions: predictions.NewStore(pool), Secrets: sec, LLMStore: llmStore, LLM: router.NewRouter(llmStore, sec, log, nil, 2), Prompts: prompts.NewStore(pool), Migrations: migrations.FS, Started: time.Now()}
|
||||
deps.Resolver = &predictions.Resolver{Store: deps.Predictions, Events: deps.Events, Beliefs: deps.Beliefs, Log: log}
|
||||
deps.Graph = graph.NewStore(pool)
|
||||
deps.Narrative = narrative.NewStore(pool)
|
||||
deps.Notifications = notifications.NewStore(pool)
|
||||
deps.Consolidation = &consolidation.Service{DB: pool, Memory: deps.Memory, Beliefs: deps.Beliefs, Events: deps.Events, Minds: deps.Minds, Log: log}
|
||||
|
||||
@@ -209,3 +209,10 @@ tr.unread td { font-weight: 500; }
|
||||
.narrative { font-size: 1rem; line-height: 1.6; white-space: pre-wrap; }
|
||||
.warnlist li { padding: .3rem 0; border-bottom: 1px solid var(--border); }
|
||||
.topbar .navcount { background: var(--danger); color: #fff; border-radius: 999px; font-size: .7rem; padding: 0 .4rem; margin-left: .3rem; }
|
||||
|
||||
/* Graph */
|
||||
.graph-card { padding: .5rem; }
|
||||
canvas.graph { width: 100%; height: 560px; display: block; border-radius: 8px; background: var(--surface-2); cursor: grab; }
|
||||
.legend { display: flex; flex-wrap: wrap; gap: .6rem; padding: .4rem .3rem 0; font-size: .78rem; color: var(--muted); }
|
||||
.legend-item i { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: .3rem; vertical-align: middle; }
|
||||
.legend-item i.k-person { background: #2f6fed; } .legend-item i.k-system { background: #0ea5a4; } .legend-item i.k-project { background: #16a34a; } .legend-item i.k-document { background: #d97706; } .legend-item i.k-concept { background: #8b5cf6; } .legend-item i.k-repository { background: #64748b; } .legend-item i.k-organisation { background: #db2777; } .legend-item i.k-place { background: #0891b2; } .legend-item i.k-other { background: #9ca3af; }
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/* Relationship explorer: a small force-directed layout on canvas. No library.
|
||||
Data comes from the graph API as {nodes:[{id,name,kind,mention_count}], edges:[{from_entity,to_entity,relation,confidence,count}]}.
|
||||
Bounded by the server (node cap), so the simulation stays cheap. */
|
||||
(function () {
|
||||
"use strict";
|
||||
const KIND_COLOURS = { person: "#2f6fed", system: "#0ea5a4", project: "#16a34a", document: "#d97706", concept: "#8b5cf6", repository: "#64748b", organisation: "#db2777", place: "#0891b2", other: "#9ca3af" };
|
||||
|
||||
function explorer(canvas, opts) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
let nodes = [], edges = [], byId = {}, selected = null, hover = null, dragging = null, running = true;
|
||||
let scale = 1, ox = 0, oy = 0;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
function resize() {
|
||||
const w = canvas.clientWidth, h = canvas.clientHeight;
|
||||
canvas.width = w * dpr; canvas.height = h * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
}
|
||||
function load(data) {
|
||||
const prev = byId; byId = {};
|
||||
nodes = (data.nodes || []).map((n, i) => {
|
||||
const p = prev[n.id];
|
||||
const angle = (i / Math.max(1, data.nodes.length)) * Math.PI * 2;
|
||||
const node = Object.assign({}, n, { x: p ? p.x : canvas.clientWidth / 2 + Math.cos(angle) * 120, y: p ? p.y : canvas.clientHeight / 2 + Math.sin(angle) * 120, vx: 0, vy: 0, r: 6 + Math.min(14, Math.sqrt(n.mention_count || 1) * 2) });
|
||||
byId[n.id] = node; return node;
|
||||
});
|
||||
edges = (data.edges || []).filter((e) => byId[e.from_entity] && byId[e.to_entity]);
|
||||
running = true; energy = 1;
|
||||
}
|
||||
let energy = 1;
|
||||
function step() {
|
||||
if (!running) return;
|
||||
const w = canvas.clientWidth, h = canvas.clientHeight;
|
||||
// repulsion
|
||||
for (let i = 0; i < nodes.length; i++) for (let j = i + 1; j < nodes.length; j++) {
|
||||
const a = nodes[i], b = nodes[j]; let dx = b.x - a.x, dy = b.y - a.y; let d2 = dx * dx + dy * dy + 0.01; const d = Math.sqrt(d2);
|
||||
const f = 1800 / d2; dx /= d; dy /= d; a.vx -= dx * f; a.vy -= dy * f; b.vx += dx * f; b.vy += dy * f;
|
||||
}
|
||||
// springs
|
||||
for (const e of edges) {
|
||||
const a = byId[e.from_entity], b = byId[e.to_entity]; const dx = b.x - a.x, dy = b.y - a.y; const d = Math.sqrt(dx * dx + dy * dy) + 0.01;
|
||||
const target = 90 - 20 * Math.min(1, (e.count || 1) / 5); const f = (d - target) * 0.02; a.vx += (dx / d) * f; a.vy += (dy / d) * f; b.vx -= (dx / d) * f; b.vy -= (dy / d) * f;
|
||||
}
|
||||
// gravity to centre, integrate
|
||||
let motion = 0;
|
||||
for (const n of nodes) {
|
||||
if (n === dragging) continue;
|
||||
n.vx += (w / 2 - n.x) * 0.002; n.vy += (h / 2 - n.y) * 0.002; n.vx *= 0.82; n.vy *= 0.82; n.x += n.vx * energy; n.y += n.vy * energy; motion += Math.abs(n.vx) + Math.abs(n.vy);
|
||||
}
|
||||
energy = Math.max(0.05, energy * 0.995);
|
||||
if (motion < 0.02 * nodes.length) running = false;
|
||||
}
|
||||
function draw() {
|
||||
const w = canvas.clientWidth, h = canvas.clientHeight; ctx.clearRect(0, 0, w, h);
|
||||
ctx.save(); ctx.translate(ox, oy); ctx.scale(scale, scale);
|
||||
const cs = getComputedStyle(document.documentElement);
|
||||
const muted = cs.getPropertyValue("--muted").trim() || "#888", text = cs.getPropertyValue("--text").trim() || "#111", surface = cs.getPropertyValue("--surface").trim() || "#fff";
|
||||
for (const e of edges) {
|
||||
const a = byId[e.from_entity], b = byId[e.to_entity]; ctx.strokeStyle = muted; ctx.globalAlpha = 0.25 + 0.6 * (e.confidence || 0.3); ctx.lineWidth = 1 + Math.min(3, (e.count || 1) / 3);
|
||||
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
|
||||
if (scale > 0.9 && (e === hoverEdge || selected && (selected.id === a.id || selected.id === b.id))) {
|
||||
ctx.globalAlpha = 1; ctx.fillStyle = muted; ctx.font = "10px sans-serif"; ctx.fillText(e.relation.replace(/_/g, " "), (a.x + b.x) / 2 + 4, (a.y + b.y) / 2 - 4);
|
||||
}
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
for (const n of nodes) {
|
||||
ctx.beginPath(); ctx.arc(n.x, n.y, n.r, 0, Math.PI * 2); ctx.fillStyle = KIND_COLOURS[n.kind] || KIND_COLOURS.other; ctx.fill();
|
||||
if (n === selected || n === hover) { ctx.lineWidth = 3; ctx.strokeStyle = text; ctx.stroke(); }
|
||||
else { ctx.lineWidth = 1.5; ctx.strokeStyle = surface; ctx.stroke(); }
|
||||
ctx.fillStyle = text; ctx.font = (n === selected ? "bold " : "") + "12px sans-serif"; ctx.fillText(n.name, n.x + n.r + 4, n.y + 4);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
let hoverEdge = null;
|
||||
function loop() { step(); draw(); requestAnimationFrame(loop); }
|
||||
function toWorld(ev) { const rect = canvas.getBoundingClientRect(); return { x: (ev.clientX - rect.left - ox) / scale, y: (ev.clientY - rect.top - oy) / scale }; }
|
||||
function nodeAt(p) { for (let i = nodes.length - 1; i >= 0; i--) { const n = nodes[i]; if ((p.x - n.x) ** 2 + (p.y - n.y) ** 2 <= (n.r + 4) ** 2) return n; } return null; }
|
||||
canvas.addEventListener("mousemove", (ev) => { const p = toWorld(ev); if (dragging) { dragging.x = p.x; dragging.y = p.y; running = true; energy = Math.max(energy, 0.3); return; } hover = nodeAt(p); canvas.style.cursor = hover ? "pointer" : "grab"; });
|
||||
canvas.addEventListener("mousedown", (ev) => { const p = toWorld(ev); dragging = nodeAt(p); if (!dragging) { panStart = { x: ev.clientX - ox, y: ev.clientY - oy }; } });
|
||||
let panStart = null;
|
||||
window.addEventListener("mousemove", (ev) => { if (panStart) { ox = ev.clientX - panStart.x; oy = ev.clientY - panStart.y; } });
|
||||
window.addEventListener("mouseup", () => { dragging = null; panStart = null; });
|
||||
canvas.addEventListener("click", (ev) => { const n = nodeAt(toWorld(ev)); selected = n; if (opts.onSelect) opts.onSelect(n); });
|
||||
canvas.addEventListener("dblclick", (ev) => { const n = nodeAt(toWorld(ev)); if (n && opts.onFocus) opts.onFocus(n); });
|
||||
canvas.addEventListener("wheel", (ev) => { ev.preventDefault(); const f = ev.deltaY < 0 ? 1.1 : 0.9; scale = Math.min(3, Math.max(0.3, scale * f)); }, { passive: false });
|
||||
window.addEventListener("resize", resize);
|
||||
resize(); loop();
|
||||
return { load, select: (id) => { selected = byId[id] || null; }, colours: KIND_COLOURS };
|
||||
}
|
||||
window.noemaGraph = { explorer };
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const canvas = document.querySelector("canvas[data-graph]");
|
||||
if (!canvas) return;
|
||||
const panel = document.getElementById("entity-panel");
|
||||
const form = document.querySelector("[data-graph-filter]");
|
||||
const base = canvas.dataset.graph;
|
||||
const mindSlug = canvas.dataset.mind;
|
||||
const ex = explorer(canvas, {
|
||||
onSelect: (n) => { if (!n) { panel.innerHTML = '<p class="muted">Click a node.</p>'; return; } fetchEntity(n.id); },
|
||||
onFocus: (n) => { if (n) { const u = new URL(location.href); u.searchParams.set("focus", n.id); location.href = u.toString(); } },
|
||||
});
|
||||
function fetchEntity(id) {
|
||||
fetch(`/minds/${mindSlug}/entities/${id}?partial=1`, { credentials: "same-origin" }).then((r) => r.text()).then((html) => { panel.innerHTML = html; }).catch(() => { panel.textContent = "Could not load entity."; });
|
||||
}
|
||||
function loadGraph() {
|
||||
const params = new URLSearchParams(form ? new FormData(form) : {});
|
||||
fetch(base + (params.toString() ? "?" + params.toString() : ""), { credentials: "same-origin" }).then((r) => r.json()).then((data) => {
|
||||
ex.load(data);
|
||||
const info = document.querySelector("[data-graph-info]");
|
||||
if (info) info.textContent = `${data.nodes.length} of ${data.total_entities} entities · ${data.edges.length} relationships`;
|
||||
const focus = params.get("focus"); if (focus) { ex.select(focus); fetchEntity(focus); }
|
||||
});
|
||||
}
|
||||
if (form) form.addEventListener("change", loadGraph);
|
||||
loadGraph();
|
||||
});
|
||||
})();
|
||||
@@ -8,6 +8,7 @@
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
|
||||
<script src="/static/app.js" defer></script>
|
||||
{{if hasSuffix .Path "/graph"}}<script src="/static/graph.js" defer></script>{{end}}
|
||||
</head>
|
||||
<body class="{{if .Principal}}app{{else}}bare{{end}}">
|
||||
{{if .Principal}}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{{define "content"}}
|
||||
{{$m := .Data.Mind}}{{$e := .Data.Entity}}
|
||||
<div class="pagehead"><p class="muted small"><a href="/minds/{{$m.Slug}}">{{$m.DisplayName}}</a> › <a href="/minds/{{$m.Slug}}/graph">relationships</a></p><h1><span class="badge">{{$e.Kind}}</span> {{$e.Name}}</h1><p class="muted">{{$e.MentionCount}} mentions · first {{date $e.FirstSeen}} · last {{ago $e.LastSeen}}{{if $e.Aliases}} · also known as {{join $e.Aliases ", "}}{{end}}</p></div>
|
||||
<section class="grid cols-3-1">
|
||||
<div class="stack">{{template "entity_panel" .}}</div>
|
||||
<div class="stack">
|
||||
{{if .Principal.User.Role.AtLeast "operator"}}
|
||||
<div class="card"><h2>Correct</h2>
|
||||
<form method="post" action="/minds/{{$m.Slug}}/entities/{{$e.ID}}/merge" class="stack"><input type="hidden" name="_csrf" value="{{.CSRF}}"><label>This is the same as <input name="other" placeholder="other entity name or id" required></label><div><button class="btn small">Merge into this</button></div></form>
|
||||
<form method="post" action="/minds/{{$m.Slug}}/entities/{{$e.ID}}/distinct" class="stack"><input type="hidden" name="_csrf" value="{{.CSRF}}"><label>This is different from <input name="other" placeholder="other entity name" required></label><div><button class="btn small">Record as different</button></div></form>
|
||||
<form method="post" action="/minds/{{$m.Slug}}/entities/{{$e.ID}}/update" class="stack"><input type="hidden" name="_csrf" value="{{.CSRF}}"><label>Kind <select name="kind">{{range entityKinds}}<option value="{{.}}" {{if eq . $e.Kind}}selected{{end}}>{{.}}</option>{{end}}</select></label><label>Description <input name="description" value="{{$e.Description}}"></label><div><button class="btn small">Save</button></div></form>
|
||||
</div>{{end}}
|
||||
<div class="card small"><span class="muted">ID</span> <span class="mono">{{$e.ID}}</span> · <a href="/minds/{{$m.Slug}}/graph?focus={{$e.ID}}">neighbourhood</a></div>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,18 @@
|
||||
{{define "content"}}
|
||||
{{$m := .Data.Mind}}
|
||||
<div class="pagehead mindhead"><div><h1>{{$m.DisplayName}} <span class="muted small">relationships</span></h1><p class="muted">{{.Data.Stats.Entities}} entities · {{.Data.Stats.Edges}} relationships · {{range $k, $v := .Data.Stats.ByKind}}<span class="badge">{{$k}} {{$v}}</span> {{end}}</p></div>
|
||||
<span class="muted small" data-graph-info></span></div>
|
||||
{{template "mind_tabs" (dict "M" $m "Active" "graph")}}
|
||||
<form class="card filterbar" data-graph-filter onsubmit="return false">
|
||||
<label>Kind <select name="kind"><option value="">all</option>{{range entityKinds}}<option value="{{.}}" {{if eq (print .) $.Data.Kind}}selected{{end}}>{{.}}</option>{{end}}</select></label>
|
||||
<label>Limit <select name="limit"><option>40</option><option selected>80</option><option>150</option><option>300</option></select></label>
|
||||
{{if .Data.Focus}}<input type="hidden" name="focus" value="{{.Data.Focus}}"><a class="btn small" href="/minds/{{$m.Slug}}/graph">Clear focus</a>{{end}}
|
||||
<span class="muted small">Drag to pan, scroll to zoom, click a node for details, double-click to focus on its neighbourhood.</span>
|
||||
</form>
|
||||
<section class="grid cols-3-1">
|
||||
<div class="card graph-card"><canvas data-graph="/api/v1/minds/{{$m.Slug}}/graph" data-mind="{{$m.Slug}}" class="graph" aria-label="Relationship graph"></canvas>
|
||||
<div class="legend">{{range entityKinds}}<span class="legend-item"><i class="k-{{.}}"></i>{{.}}</span>{{end}}</div></div>
|
||||
<div class="card" id="entity-panel"><p class="muted">Click a node.</p></div>
|
||||
</section>
|
||||
{{if .Data.Entities}}<div class="card"><h2>Most mentioned</h2><table class="table compact"><tbody>{{range .Data.Entities}}<tr><td><span class="badge">{{.Kind}}</span></td><td><a href="/minds/{{$m.Slug}}/entities/{{.ID}}">{{.Name}}</a>{{if .Aliases}} <span class="muted small">also {{join .Aliases ", "}}</span>{{end}}</td><td class="num">{{.MentionCount}}×</td><td class="small muted">{{ago .LastSeen}}</td></tr>{{end}}</tbody></table></div>{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,10 @@
|
||||
{{define "entity_panel"}}{{$m := .Data.Mind}}{{$e := .Data.Entity}}
|
||||
<div class="card">
|
||||
<h2><span class="badge">{{$e.Kind}}</span> <a href="/minds/{{$m.Slug}}/entities/{{$e.ID}}">{{$e.Name}}</a></h2>
|
||||
{{with $e.Description}}<p>{{.}}</p>{{end}}
|
||||
<h3>Relationships</h3>
|
||||
{{if .Data.Edges}}<ul class="plain small">{{range .Data.Edges}}<li>{{if eq .From.ID $e.ID}}<em>{{.Relation}}</em> → <a href="/minds/{{$m.Slug}}/entities/{{.To.ID}}">{{.To.Name}}</a>{{else}}<a href="/minds/{{$m.Slug}}/entities/{{.From.ID}}">{{.From.Name}}</a> <em>{{.Relation}}</em> →{{end}} <span class="muted">{{conf .Confidence}} · ×{{.Count}} · {{.Source}}{{if .EventID}} · <a href="/events/{{.EventID}}">event</a>{{end}}</span></li>{{end}}</ul>{{else}}<p class="muted small">None.</p>{{end}}
|
||||
<h3>Involved in</h3>
|
||||
{{if .Data.Links}}<ul class="plain small">{{range .Data.Links}}<li><span class="badge">{{.Kind}}</span> {{if eq .Kind "memory"}}<a href="/minds/{{$m.Slug}}/memory/{{.RefID}}">memory</a>{{else if eq .Kind "belief"}}<a href="/minds/{{$m.Slug}}/beliefs/{{.RefID}}">belief</a>{{else if eq .Kind "event"}}<a href="/events/{{.RefID}}">event</a>{{else}}{{.RefID}}{{end}} <span class="muted">{{ago .CreatedAt}}</span></li>{{end}}</ul>{{else}}<p class="muted small">Nothing linked yet.</p>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user