Files
glpi-neural-brain/internal/graph/analysis_dashboard.go
2026-08-07 21:54:37 +02:00

1254 lines
46 KiB
Go

package graph
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"sync/atomic"
"time"
"github.com/local/glpi-neural-brain/internal/model"
)
// MutationStats are monotonic, process-local counters for material changes to
// the live graph. They deliberately distinguish creation, updates and removal
// so the analysis dashboard can explain a run even when the net graph size did
// not change.
type MutationStats struct {
NodesCreated uint64 `json:"nodes_created"`
NodesUpdated uint64 `json:"nodes_updated"`
NodesDeleted uint64 `json:"nodes_deleted"`
EdgesCreated uint64 `json:"edges_created"`
EdgesUpdated uint64 `json:"edges_updated"`
EdgesDeleted uint64 `json:"edges_deleted"`
VectorsCreated uint64 `json:"vectors_created"`
VectorsUpdated uint64 `json:"vectors_updated"`
VectorsDeleted uint64 `json:"vectors_deleted"`
}
func (m MutationStats) Delta(previous MutationStats) MutationStats {
return MutationStats{
NodesCreated: safeDelta(m.NodesCreated, previous.NodesCreated),
NodesUpdated: safeDelta(m.NodesUpdated, previous.NodesUpdated),
NodesDeleted: safeDelta(m.NodesDeleted, previous.NodesDeleted),
EdgesCreated: safeDelta(m.EdgesCreated, previous.EdgesCreated),
EdgesUpdated: safeDelta(m.EdgesUpdated, previous.EdgesUpdated),
EdgesDeleted: safeDelta(m.EdgesDeleted, previous.EdgesDeleted),
VectorsCreated: safeDelta(m.VectorsCreated, previous.VectorsCreated),
VectorsUpdated: safeDelta(m.VectorsUpdated, previous.VectorsUpdated),
VectorsDeleted: safeDelta(m.VectorsDeleted, previous.VectorsDeleted),
}
}
func (m *MutationStats) Add(other MutationStats) {
m.NodesCreated += other.NodesCreated
m.NodesUpdated += other.NodesUpdated
m.NodesDeleted += other.NodesDeleted
m.EdgesCreated += other.EdgesCreated
m.EdgesUpdated += other.EdgesUpdated
m.EdgesDeleted += other.EdgesDeleted
m.VectorsCreated += other.VectorsCreated
m.VectorsUpdated += other.VectorsUpdated
m.VectorsDeleted += other.VectorsDeleted
}
func (m MutationStats) Empty() bool {
return m.NodesCreated+m.NodesUpdated+m.NodesDeleted+
m.EdgesCreated+m.EdgesUpdated+m.EdgesDeleted+
m.VectorsCreated+m.VectorsUpdated+m.VectorsDeleted == 0
}
func safeDelta(current, previous uint64) uint64 {
if current < previous {
return current
}
return current - previous
}
type GraphChange struct {
ID int64 `json:"id,omitempty"`
EventID string `json:"event_id,omitempty"`
Timestamp time.Time `json:"timestamp"`
ProcessID string `json:"process_id"`
GraphVersion uint64 `json:"graph_version"`
EntityKind string `json:"entity_kind"`
Action string `json:"action"`
EntityID string `json:"entity_id"`
Label string `json:"label,omitempty"`
RelationType string `json:"relation_type,omitempty"`
Origin string `json:"origin,omitempty"`
Details map[string]any `json:"details,omitempty"`
}
type AnalysisPoint struct {
ProcessID string `json:"process_id"`
GraphVersion uint64 `json:"graph_version"`
NodeCount int `json:"node_count"`
EdgeCount int `json:"edge_count"`
VectorCount int `json:"vector_count"`
Mutations MutationStats `json:"mutations"`
Delta MutationStats `json:"delta"`
}
type AnalysisEventRecord struct {
Activity model.Activity `json:"activity"`
Point AnalysisPoint `json:"point"`
ChangeCount int `json:"change_count"`
ChangesTruncated int `json:"changes_truncated,omitempty"`
}
type AnalysisTimelineBucket struct {
Start time.Time `json:"start"`
Events int `json:"events"`
Successes int `json:"successes"`
Warnings int `json:"warnings"`
Failures int `json:"failures"`
Comparisons int64 `json:"comparisons"`
SearchResults int64 `json:"search_results"`
Mutations MutationStats `json:"mutations"`
}
type AnalysisRun struct {
ID string `json:"id"`
Kind string `json:"kind"`
Title string `json:"title"`
Status string `json:"status"`
Verdict string `json:"verdict"`
Explanation string `json:"explanation"`
StartedAt time.Time `json:"started_at"`
CompletedAt time.Time `json:"completed_at,omitempty"`
DurationMS int64 `json:"duration_ms"`
Trigger string `json:"trigger,omitempty"`
Outcome string `json:"outcome,omitempty"`
EventCount int `json:"event_count"`
Mutations MutationStats `json:"mutations"`
NodeIDs []string `json:"node_ids,omitempty"`
EdgeIDs []string `json:"edge_ids,omitempty"`
Metrics map[string]any `json:"metrics,omitempty"`
Events []AnalysisEventRecord `json:"events,omitempty"`
}
type DetailedGraphAnalysis struct {
Summary model.GraphAnalysis `json:"summary"`
NodeKinds map[string]int `json:"node_kinds"`
NodeStatuses map[string]int `json:"node_statuses"`
NodeOrigins map[string]int `json:"node_origins"`
NodeSources map[string]int `json:"node_sources"`
EdgeTypes map[string]int `json:"edge_types"`
EdgeStatuses map[string]int `json:"edge_statuses"`
EdgeOrigins map[string]int `json:"edge_origins"`
VectorRows int `json:"vector_rows"`
VectorEligibleNodes int `json:"vector_eligible_nodes"`
VectorCoverage float64 `json:"vector_coverage"`
EmbeddingDimensions map[string]int `json:"embedding_dimensions"`
AverageAIConfidence float64 `json:"average_ai_confidence"`
AverageAISimilarity float64 `json:"average_ai_similarity"`
SimilarityEdgeCount int `json:"similarity_edge_count"`
NewestNodes []model.Node `json:"newest_nodes"`
NewestEdges []model.Edge `json:"newest_edges"`
CurrentProcessChange MutationStats `json:"current_process_changes"`
}
type AnalysisAuditStatus struct {
QueueDepth int `json:"queue_depth"`
QueueCapacity int `json:"queue_capacity"`
DroppedEvents uint64 `json:"dropped_events"`
LastPersistedAt time.Time `json:"last_persisted_at,omitempty"`
LastError string `json:"last_error,omitempty"`
}
type AnalysisHistory struct {
GeneratedAt time.Time `json:"generated_at"`
Since time.Time `json:"since"`
Events []AnalysisEventRecord `json:"events"`
Runs []AnalysisRun `json:"runs"`
Timeline []AnalysisTimelineBucket `json:"timeline"`
Changes []GraphChange `json:"changes"`
Totals MutationStats `json:"totals"`
EventCounts map[string]int `json:"event_counts"`
StatusCounts map[string]int `json:"status_counts"`
DroppedEvents uint64 `json:"dropped_events"`
DroppedDetailedChanges uint64 `json:"dropped_detailed_changes"`
RawEventCount int `json:"raw_event_count"`
ChangeCount int `json:"change_count"`
Audit AnalysisAuditStatus `json:"audit"`
}
type analysisRecord struct {
activity model.Activity
point AnalysisPoint
changes []GraphChange
changesTruncated int
}
var processCounter atomic.Uint64
func newProcessID() string {
return fmt.Sprintf("process-%d-%d", time.Now().UTC().UnixNano(), processCounter.Add(1))
}
func (s *Store) initAnalysisWriter() {
s.analysisMu.Lock()
defer s.analysisMu.Unlock()
if s.analysisQueue != nil {
return
}
if strings.TrimSpace(s.processID) == "" {
s.processID = newProcessID()
}
s.analysisQueue = make(chan analysisRecord, 4096)
s.analysisWG.Add(1)
go func(queue <-chan analysisRecord) {
defer s.analysisWG.Done()
for {
first, ok := <-queue
if !ok {
return
}
batch := []analysisRecord{first}
timer := time.NewTimer(50 * time.Millisecond)
collect:
for len(batch) < 64 {
select {
case record, open := <-queue:
if !open {
if !timer.Stop() {
<-timer.C
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
err := s.persistAnalysisBatch(ctx, batch)
cancel()
s.recordAnalysisPersistResult(err)
return
}
batch = append(batch, record)
case <-timer.C:
break collect
}
}
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
err := s.persistAnalysisBatch(ctx, batch)
cancel()
s.recordAnalysisPersistResult(err)
}
}(s.analysisQueue)
}
func (s *Store) recordAnalysisPersistResult(err error) {
s.analysisMu.Lock()
defer s.analysisMu.Unlock()
if err != nil {
s.analysisLastError = err.Error()
return
}
s.analysisLastError = ""
s.analysisLastPersisted = time.Now().UTC()
}
// RecordActivity captures a cheap O(1) graph checkpoint synchronously and
// performs the SQLite write asynchronously. The expensive graph analysis is
// only calculated when the dedicated dashboard is opened.
func (s *Store) RecordActivity(activity model.Activity) {
if s == nil || s.db == nil || activity.Type == "brain.idle" {
return
}
if activity.Timestamp.IsZero() {
activity.Timestamp = time.Now().UTC()
}
s.mu.Lock()
nodes := len(s.nodes)
edges := len(s.edges)
point := AnalysisPoint{
ProcessID: s.processID,
GraphVersion: s.version,
NodeCount: nodes,
EdgeCount: edges,
VectorCount: len(s.vectors),
Mutations: s.mutations,
}
changes := append([]GraphChange(nil), s.analysisPendingChanges...)
changesTruncated := s.analysisPendingTruncated
s.analysisPendingChanges = s.analysisPendingChanges[:0]
s.analysisPendingTruncated = 0
s.mu.Unlock()
s.analysisMu.Lock()
if s.analysisQueue == nil {
s.analysisMu.Unlock()
return
}
point.Delta = point.Mutations.Delta(s.analysisLastMutations)
s.analysisLastMutations = point.Mutations
queued := true
select {
case s.analysisQueue <- analysisRecord{activity: activity, point: point, changes: changes, changesTruncated: changesTruncated}:
default:
s.analysisDropped++
queued = false
}
s.analysisMu.Unlock()
if !queued && len(changes)+changesTruncated > 0 {
s.mu.Lock()
s.analysisChangesDropped += uint64(len(changes) + changesTruncated)
s.mu.Unlock()
}
}
func (s *Store) closeAnalysisWriter() {
s.analysisMu.Lock()
queue := s.analysisQueue
if queue != nil {
s.analysisQueue = nil
close(queue)
}
s.analysisMu.Unlock()
if queue != nil {
s.analysisWG.Wait()
}
}
func (s *Store) persistAnalysisRecord(ctx context.Context, record analysisRecord) error {
return s.persistAnalysisBatch(ctx, []analysisRecord{record})
}
func (s *Store) persistAnalysisBatch(ctx context.Context, records []analysisRecord) error {
if len(records) == 0 {
return nil
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
eventStatement, err := tx.PrepareContext(ctx, `INSERT OR REPLACE INTO analysis_events(id,type,source,phase,query,message,node_ids_json,edge_ids_json,strength,metadata_json,timestamp_ns,process_id)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`)
if err != nil {
return err
}
defer eventStatement.Close()
pointStatement, err := tx.PrepareContext(ctx, `INSERT OR REPLACE INTO analysis_points(event_id,timestamp_ns,process_id,graph_version,node_count,edge_count,vector_count,
node_created,node_updated,node_deleted,edge_created,edge_updated,edge_deleted,vector_created,vector_updated,vector_deleted,
delta_node_created,delta_node_updated,delta_node_deleted,delta_edge_created,delta_edge_updated,delta_edge_deleted,delta_vector_created,delta_vector_updated,delta_vector_deleted,
change_count,changes_truncated)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
if err != nil {
return err
}
defer pointStatement.Close()
changeStatement, err := tx.PrepareContext(ctx, `INSERT INTO analysis_changes(event_id,timestamp_ns,process_id,graph_version,entity_kind,action,entity_id,label,relation_type,origin,details_json)
VALUES(?,?,?,?,?,?,?,?,?,?,?)`)
if err != nil {
return err
}
defer changeStatement.Close()
for _, record := range records {
nodeIDs, _ := json.Marshal(record.activity.NodeIDs)
edgeIDs, _ := json.Marshal(record.activity.EdgeIDs)
metadata, _ := json.Marshal(record.activity.Metadata)
if _, err := eventStatement.ExecContext(ctx, record.activity.ID, record.activity.Type, record.activity.Source, record.activity.Phase, record.activity.Query, record.activity.Message, string(nodeIDs), string(edgeIDs), record.activity.Strength, string(metadata), record.activity.Timestamp.UnixNano(), record.point.ProcessID); err != nil {
return err
}
m, d := record.point.Mutations, record.point.Delta
if _, err := pointStatement.ExecContext(ctx, record.activity.ID, record.activity.Timestamp.UnixNano(), record.point.ProcessID, record.point.GraphVersion, record.point.NodeCount, record.point.EdgeCount, record.point.VectorCount,
m.NodesCreated, m.NodesUpdated, m.NodesDeleted, m.EdgesCreated, m.EdgesUpdated, m.EdgesDeleted, m.VectorsCreated, m.VectorsUpdated, m.VectorsDeleted,
d.NodesCreated, d.NodesUpdated, d.NodesDeleted, d.EdgesCreated, d.EdgesUpdated, d.EdgesDeleted, d.VectorsCreated, d.VectorsUpdated, d.VectorsDeleted,
len(record.changes), record.changesTruncated); err != nil {
return err
}
for _, change := range record.changes {
details, _ := json.Marshal(change.Details)
timestamp := change.Timestamp
if timestamp.IsZero() {
timestamp = record.activity.Timestamp
}
if _, err := changeStatement.ExecContext(ctx, record.activity.ID, timestamp.UnixNano(), change.ProcessID, change.GraphVersion, change.EntityKind, change.Action, change.EntityID, change.Label, change.RelationType, change.Origin, string(details)); err != nil {
return err
}
}
}
if err := tx.Commit(); err != nil {
return err
}
last := records[len(records)-1]
if last.activity.Timestamp.Unix()%997 == 0 {
cutoff := time.Now().UTC().Add(-90 * 24 * time.Hour).UnixNano()
_, _ = s.db.ExecContext(ctx, `DELETE FROM analysis_events WHERE timestamp_ns<?`, cutoff)
}
return nil
}
func (s *Store) MutationStats() MutationStats {
s.mu.RLock()
defer s.mu.RUnlock()
return s.mutations
}
func (s *Store) DetailedAnalysis() DetailedGraphAnalysis {
s.mu.RLock()
requestedVersion := s.version
s.mu.RUnlock()
s.analysisDetailMu.Lock()
defer s.analysisDetailMu.Unlock()
if s.analysisDetailVersion == requestedVersion && s.analysisDetailCache.Summary.NodeCount > 0 {
return s.analysisDetailCache
}
summary := s.Analyze()
detail := DetailedGraphAnalysis{
Summary: summary,
NodeKinds: map[string]int{},
NodeStatuses: map[string]int{},
NodeOrigins: map[string]int{},
NodeSources: map[string]int{},
EdgeTypes: map[string]int{},
EdgeStatuses: map[string]int{},
EdgeOrigins: map[string]int{},
EmbeddingDimensions: map[string]int{},
}
s.mu.RLock()
for _, node := range s.nodes {
detail.NodeKinds[nonemptyAnalysis(node.Kind, "unknown")]++
detail.NodeStatuses[nonemptyAnalysis(node.Status, "unspecified")]++
detail.NodeOrigins[nonemptyAnalysis(node.Origin, "unknown")]++
source := explicitNodeSource(node)
if source == "" {
source = "(ohne source)"
}
detail.NodeSources[source]++
if node.Kind == "knowledge" || node.Kind == "ai-think" || node.Kind == "external" {
detail.VectorEligibleNodes++
}
detail.NewestNodes = insertNewestNode(detail.NewestNodes, node, 12)
}
var confidenceSum, similaritySum float64
for _, edge := range s.edges {
if edge.Status == "rejected" {
continue
}
detail.EdgeTypes[nonemptyAnalysis(edge.Type, "unknown")]++
detail.EdgeStatuses[nonemptyAnalysis(edge.Status, "unspecified")]++
detail.EdgeOrigins[nonemptyAnalysis(edge.Origin, "unknown")]++
if edge.Origin == "ai-inference" {
confidenceSum += edge.Confidence
if similarity, ok := numericMetadata(edge.Metadata, "semantic_similarity"); ok {
similaritySum += similarity
detail.SimilarityEdgeCount++
}
}
detail.NewestEdges = insertNewestEdge(detail.NewestEdges, edge, 12)
}
if summary.AIEdges > 0 {
detail.AverageAIConfidence = confidenceSum / float64(summary.AIEdges)
}
if detail.SimilarityEdgeCount > 0 {
detail.AverageAISimilarity = similaritySum / float64(detail.SimilarityEdgeCount)
}
detail.VectorRows = len(s.vectors)
if detail.VectorEligibleNodes > 0 {
detail.VectorCoverage = float64(detail.VectorRows) / float64(detail.VectorEligibleNodes)
}
for _, vector := range s.vectors {
detail.EmbeddingDimensions[fmt.Sprint(len(vector))]++
}
detail.CurrentProcessChange = s.mutations
cachedVersion := s.version
s.mu.RUnlock()
s.analysisDetailVersion = cachedVersion
s.analysisDetailCache = detail
return detail
}
func insertNewestNode(nodes []model.Node, node model.Node, limit int) []model.Node {
index := sort.Search(len(nodes), func(i int) bool { return !nodes[i].UpdatedAt.After(node.UpdatedAt) })
if index >= limit {
return nodes
}
nodes = append(nodes, model.Node{})
copy(nodes[index+1:], nodes[index:])
nodes[index] = node
if len(nodes) > limit {
nodes = nodes[:limit]
}
return nodes
}
func insertNewestEdge(edges []model.Edge, edge model.Edge, limit int) []model.Edge {
index := sort.Search(len(edges), func(i int) bool { return !edges[i].UpdatedAt.After(edge.UpdatedAt) })
if index >= limit {
return edges
}
edges = append(edges, model.Edge{})
copy(edges[index+1:], edges[index:])
edges[index] = edge
if len(edges) > limit {
edges = edges[:limit]
}
return edges
}
func explicitNodeSource(node model.Node) string {
if node.Metadata == nil {
return ""
}
value, ok := node.Metadata["source"]
if !ok || value == nil {
return ""
}
text := strings.TrimSpace(fmt.Sprint(value))
if text == "" || strings.EqualFold(text, "<nil>") || strings.EqualFold(text, "null") {
return ""
}
return text
}
func nonemptyAnalysis(value, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return value
}
func numericMetadata(metadata map[string]any, key string) (float64, bool) {
if metadata == nil {
return 0, false
}
value, ok := metadata[key]
if !ok {
return 0, false
}
switch typed := value.(type) {
case float64:
return typed, true
case float32:
return float64(typed), true
case int:
return float64(typed), true
case int64:
return float64(typed), true
case json.Number:
parsed, err := typed.Float64()
return parsed, err == nil
default:
return 0, false
}
}
func (s *Store) AnalysisHistory(ctx context.Context, since time.Time, limit int) (AnalysisHistory, error) {
if limit < 20 {
limit = 200
}
if limit > 1000 {
limit = 1000
}
if since.IsZero() {
since = time.Now().UTC().Add(-24 * time.Hour)
}
fetchLimit := limit * 8
if fetchLimit < 2000 {
fetchLimit = 2000
}
if fetchLimit > 10000 {
fetchLimit = 10000
}
events, err := s.analysisEvents(ctx, since, fetchLimit)
if err != nil {
return AnalysisHistory{}, err
}
chronological := append([]AnalysisEventRecord(nil), events...)
sort.Slice(chronological, func(i, j int) bool {
return chronological[i].Activity.Timestamp.Before(chronological[j].Activity.Timestamp)
})
runs := buildAnalysisRuns(chronological)
sort.Slice(runs, func(i, j int) bool { return runs[i].StartedAt.After(runs[j].StartedAt) })
if len(runs) > limit {
runs = runs[:limit]
}
if len(events) > limit {
events = events[:limit]
}
changeLimit := limit * 10
if changeLimit < 500 {
changeLimit = 500
}
if changeLimit > 10000 {
changeLimit = 10000
}
changes, changeCount, changeErr := s.analysisChanges(ctx, since, changeLimit)
if changeErr != nil {
return AnalysisHistory{}, changeErr
}
totals, rawCount, persistedTruncated, counts, aggregateErr := s.analysisAggregate(ctx, since)
if aggregateErr != nil {
return AnalysisHistory{}, aggregateErr
}
statusCounts := map[string]int{}
for _, event := range chronological {
statusCounts[eventSeverity(event.Activity)]++
}
timeline := buildTimeline(chronological, since)
s.analysisMu.Lock()
dropped := s.analysisDropped
audit := AnalysisAuditStatus{DroppedEvents: dropped, LastPersistedAt: s.analysisLastPersisted, LastError: s.analysisLastError}
if s.analysisQueue != nil {
audit.QueueDepth = len(s.analysisQueue)
audit.QueueCapacity = cap(s.analysisQueue)
}
s.analysisMu.Unlock()
s.mu.RLock()
droppedChanges := s.analysisChangesDropped
s.mu.RUnlock()
return AnalysisHistory{
GeneratedAt: time.Now().UTC(),
Since: since.UTC(),
Events: events,
Runs: runs,
Timeline: timeline,
Changes: changes,
Totals: totals,
EventCounts: counts,
StatusCounts: statusCounts,
DroppedEvents: dropped,
DroppedDetailedChanges: uint64(persistedTruncated) + droppedChanges,
RawEventCount: rawCount,
ChangeCount: changeCount,
Audit: audit,
}, nil
}
func (s *Store) analysisAggregate(ctx context.Context, since time.Time) (MutationStats, int, int, map[string]int, error) {
var totals MutationStats
var count, truncated int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*),
COALESCE(SUM(delta_node_created),0),COALESCE(SUM(delta_node_updated),0),COALESCE(SUM(delta_node_deleted),0),
COALESCE(SUM(delta_edge_created),0),COALESCE(SUM(delta_edge_updated),0),COALESCE(SUM(delta_edge_deleted),0),
COALESCE(SUM(delta_vector_created),0),COALESCE(SUM(delta_vector_updated),0),COALESCE(SUM(delta_vector_deleted),0),
COALESCE(SUM(changes_truncated),0)
FROM analysis_points WHERE timestamp_ns>=?`, since.UnixNano()).Scan(&count,
&totals.NodesCreated, &totals.NodesUpdated, &totals.NodesDeleted,
&totals.EdgesCreated, &totals.EdgesUpdated, &totals.EdgesDeleted,
&totals.VectorsCreated, &totals.VectorsUpdated, &totals.VectorsDeleted, &truncated)
if err != nil {
return MutationStats{}, 0, 0, nil, err
}
rows, err := s.db.QueryContext(ctx, `SELECT type,COUNT(*) FROM analysis_events WHERE timestamp_ns>=? GROUP BY type`, since.UnixNano())
if err != nil {
return MutationStats{}, 0, 0, nil, err
}
defer rows.Close()
counts := map[string]int{}
for rows.Next() {
var eventType string
var eventCount int
if err := rows.Scan(&eventType, &eventCount); err != nil {
return MutationStats{}, 0, 0, nil, err
}
counts[eventType] = eventCount
}
return totals, count, truncated, counts, rows.Err()
}
func (s *Store) analysisEvents(ctx context.Context, since time.Time, limit int) ([]AnalysisEventRecord, error) {
rows, err := s.db.QueryContext(ctx, `SELECT e.id,e.type,e.source,e.phase,e.query,e.message,e.node_ids_json,e.edge_ids_json,e.strength,e.metadata_json,e.timestamp_ns,
p.process_id,p.graph_version,p.node_count,p.edge_count,p.vector_count,
p.node_created,p.node_updated,p.node_deleted,p.edge_created,p.edge_updated,p.edge_deleted,p.vector_created,p.vector_updated,p.vector_deleted,
p.delta_node_created,p.delta_node_updated,p.delta_node_deleted,p.delta_edge_created,p.delta_edge_updated,p.delta_edge_deleted,p.delta_vector_created,p.delta_vector_updated,p.delta_vector_deleted,
p.change_count,p.changes_truncated
FROM analysis_events e JOIN analysis_points p ON p.event_id=e.id
WHERE e.timestamp_ns>=? ORDER BY e.timestamp_ns DESC LIMIT ?`, since.UnixNano(), limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []AnalysisEventRecord{}
for rows.Next() {
var record AnalysisEventRecord
var nodeIDs, edgeIDs, metadata string
var timestamp int64
m, d := &record.Point.Mutations, &record.Point.Delta
if err := rows.Scan(&record.Activity.ID, &record.Activity.Type, &record.Activity.Source, &record.Activity.Phase, &record.Activity.Query, &record.Activity.Message, &nodeIDs, &edgeIDs, &record.Activity.Strength, &metadata, &timestamp,
&record.Point.ProcessID, &record.Point.GraphVersion, &record.Point.NodeCount, &record.Point.EdgeCount, &record.Point.VectorCount,
&m.NodesCreated, &m.NodesUpdated, &m.NodesDeleted, &m.EdgesCreated, &m.EdgesUpdated, &m.EdgesDeleted, &m.VectorsCreated, &m.VectorsUpdated, &m.VectorsDeleted,
&d.NodesCreated, &d.NodesUpdated, &d.NodesDeleted, &d.EdgesCreated, &d.EdgesUpdated, &d.EdgesDeleted, &d.VectorsCreated, &d.VectorsUpdated, &d.VectorsDeleted,
&record.ChangeCount, &record.ChangesTruncated); err != nil {
return nil, err
}
record.Activity.Timestamp = time.Unix(0, timestamp).UTC()
_ = decodeJSON(nodeIDs, &record.Activity.NodeIDs)
_ = decodeJSON(edgeIDs, &record.Activity.EdgeIDs)
_ = decodeJSON(metadata, &record.Activity.Metadata)
if record.Activity.Metadata == nil {
record.Activity.Metadata = map[string]any{}
}
out = append(out, record)
}
return out, rows.Err()
}
func (s *Store) analysisChanges(ctx context.Context, since time.Time, limit int) ([]GraphChange, int, error) {
var total int
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM analysis_changes WHERE timestamp_ns>=?`, since.UnixNano()).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := s.db.QueryContext(ctx, `SELECT id,event_id,timestamp_ns,process_id,graph_version,entity_kind,action,entity_id,label,relation_type,origin,details_json
FROM analysis_changes WHERE timestamp_ns>=? ORDER BY timestamp_ns DESC,id DESC LIMIT ?`, since.UnixNano(), limit)
if err != nil {
return nil, 0, err
}
defer rows.Close()
out := []GraphChange{}
for rows.Next() {
var change GraphChange
var timestamp int64
var details string
if err := rows.Scan(&change.ID, &change.EventID, &timestamp, &change.ProcessID, &change.GraphVersion, &change.EntityKind, &change.Action, &change.EntityID, &change.Label, &change.RelationType, &change.Origin, &details); err != nil {
return nil, 0, err
}
change.Timestamp = time.Unix(0, timestamp).UTC()
_ = decodeJSON(details, &change.Details)
if change.Details == nil {
change.Details = map[string]any{}
}
out = append(out, change)
}
return out, total, rows.Err()
}
func buildTimeline(events []AnalysisEventRecord, since time.Time) []AnalysisTimelineBucket {
duration := time.Since(since)
bucket := time.Hour
if duration <= 3*time.Hour {
bucket = 10 * time.Minute
} else if duration <= 12*time.Hour {
bucket = 30 * time.Minute
} else if duration > 72*time.Hour {
bucket = 6 * time.Hour
}
buckets := map[int64]*AnalysisTimelineBucket{}
for _, event := range events {
start := event.Activity.Timestamp.Truncate(bucket)
key := start.UnixNano()
entry := buckets[key]
if entry == nil {
entry = &AnalysisTimelineBucket{Start: start}
buckets[key] = entry
}
entry.Events++
entry.Mutations.Add(event.Point.Delta)
switch eventSeverity(event.Activity) {
case "success":
entry.Successes++
case "warning":
entry.Warnings++
case "error":
entry.Failures++
}
entry.Comparisons += int64(metadataNumber(event.Activity.Metadata, "candidate_comparisons", "comparisons"))
entry.SearchResults += int64(metadataNumber(event.Activity.Metadata, "result_count", "research_search_results"))
}
keys := make([]int64, 0, len(buckets))
for key := range buckets {
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] })
out := make([]AnalysisTimelineBucket, 0, len(keys))
for _, key := range keys {
out = append(out, *buckets[key])
}
return out
}
func buildAnalysisRuns(events []AnalysisEventRecord) []AnalysisRun {
active := map[string]*AnalysisRun{}
activeOrder := []string{}
completed := []AnalysisRun{}
standalone := []AnalysisRun{}
removeActive := func(key string) {
delete(active, key)
for i := len(activeOrder) - 1; i >= 0; i-- {
if activeOrder[i] == key {
activeOrder = append(activeOrder[:i], activeOrder[i+1:]...)
break
}
}
}
for _, event := range events {
kind, key, phase := classifyRunEvent(event.Activity)
if strings.HasPrefix(key, "latest:") {
wanted := strings.TrimPrefix(key, "latest:")
key = ""
for i := len(activeOrder) - 1; i >= 0; i-- {
candidate := active[activeOrder[i]]
if candidate != nil && candidate.Kind == wanted {
key = activeOrder[i]
break
}
}
}
if phase == "start" {
// A duplicated start event for the same native run ID must never add
// another active-order entry. Keep the original start timestamp and
// attach the duplicate as an event so diagnostics stay lossless.
if existing := active[key]; existing != nil {
appendRunEvent(existing, event)
continue
}
run := newAnalysisRun(kind, key, event)
active[key] = &run
activeOrder = append(activeOrder, key)
continue
}
if key != "" {
if run := active[key]; run != nil {
appendRunEvent(run, event)
if phase == "end" {
finalizeAnalysisRun(run, event.Activity)
completed = append(completed, *run)
removeActive(key)
}
continue
}
}
// Nested article, research and embedding events belong to the most recent
// active primary operation. This reflects how the engine actually runs:
// one AI-THINK cycle and one autonomous task are serialized.
if len(activeOrder) > 0 {
key := activeOrder[len(activeOrder)-1]
if run := active[key]; run != nil {
appendRunEvent(run, event)
continue
}
}
if isMeaningfulStandalone(event.Activity) {
run := newAnalysisRun(kindForStandalone(event.Activity), "standalone:"+event.Activity.ID, event)
finalizeAnalysisRun(&run, event.Activity)
standalone = append(standalone, run)
}
}
for _, key := range activeOrder {
if run := active[key]; run != nil {
// Old relation-research histories (written before research.completed
// existed) can contain research.results but no terminal event whenever
// no source was ingested. Do not display those historical records as
// running forever. A grace period avoids prematurely closing a live
// relation review that has just received its SearXNG results.
if run.Kind == "research" && time.Since(run.CompletedAt) > 2*time.Minute && analysisRunHasEventType(run, "research.results") {
run.Status = "success"
run.Verdict = "abgeschlossen (Legacy)"
run.Explanation = "Der ältere Lauf enthält SearXNG-Ergebnisse, aber kein explizites research.completed. Der Abschluss wurde aus dem letzten Ergebnis-Event rekonstruiert."
completed = append(completed, *run)
continue
}
run.Status = "running"
run.Verdict = "läuft"
run.Explanation = "Der Lauf ist noch nicht abgeschlossen oder sein Abschluss liegt außerhalb des gewählten Zeitfensters."
completed = append(completed, *run)
}
}
return append(completed, standalone...)
}
func analysisRunHasEventType(run *AnalysisRun, typeName string) bool {
if run == nil {
return false
}
for _, event := range run.Events {
if event.Activity.Type == typeName {
return true
}
}
return false
}
func newAnalysisRun(kind, key string, event AnalysisEventRecord) AnalysisRun {
if kind == "" {
kind = kindForStandalone(event.Activity)
}
run := AnalysisRun{
ID: key,
Kind: kind,
Title: runTitle(kind, event.Activity),
Status: "running",
Verdict: "läuft",
StartedAt: event.Activity.Timestamp,
Metrics: map[string]any{},
}
appendRunEvent(&run, event)
return run
}
func appendRunEvent(run *AnalysisRun, event AnalysisEventRecord) {
run.EventCount++
run.Mutations.Add(event.Point.Delta)
run.NodeIDs = uniqueStrings(append(run.NodeIDs, event.Activity.NodeIDs...))
run.EdgeIDs = uniqueStrings(append(run.EdgeIDs, event.Activity.EdgeIDs...))
if len(run.Events) < 80 {
run.Events = append(run.Events, event)
}
if trigger := metadataString(event.Activity.Metadata, "trigger", "requested_by"); trigger != "" {
run.Trigger = trigger
}
mergeRunMetrics(run.Metrics, event.Activity)
if event.Activity.Timestamp.After(run.CompletedAt) {
run.CompletedAt = event.Activity.Timestamp
}
}
func finalizeAnalysisRun(run *AnalysisRun, terminal model.Activity) {
run.CompletedAt = terminal.Timestamp
run.DurationMS = run.CompletedAt.Sub(run.StartedAt).Milliseconds()
if duration := int64(metadataNumber(terminal.Metadata, "duration_ms")); duration > 0 {
run.DurationMS = duration
}
run.Outcome = metadataString(terminal.Metadata, "result", "outcome", "reason")
run.Status = eventSeverity(terminal)
outcome := strings.ToLower(strings.TrimSpace(run.Outcome))
if run.Status != "error" {
switch outcome {
case "no_candidate", "unchanged", "idle", "completed_no_change":
run.Status = "neutral"
case "skipped", "rejected", "no_useful_evidence", "deferred", "disabled":
run.Status = "warning"
}
derived := verdictFromRun(run)
if derived == "success" {
run.Status = "success"
} else if run.Status == "neutral" && derived == "warning" {
run.Status = "warning"
}
}
run.Verdict, run.Explanation = explainRun(run, terminal)
}
func classifyRunEvent(activity model.Activity) (kind, key, phase string) {
typeName := activity.Type
switch typeName {
case "think.cycle.started":
return "thinking", "thinking:" + activity.ID, "start"
case "think.cycle.completed", "think.cycle.failed":
return "thinking", latestSyntheticKey("thinking"), "end"
case "learning.scan.started":
return "learning", nonemptyAnalysis(metadataString(activity.Metadata, "run_id"), "learning:"+activity.ID), "start"
case "learning.scan.completed", "learning.scan.failed":
return "learning", nonemptyAnalysis(metadataString(activity.Metadata, "run_id"), latestSyntheticKey("learning")), "end"
case "autonomous.research.task.started":
return "autonomous-research", "autonomous:" + nonemptyAnalysis(metadataString(activity.Metadata, "task_id"), activity.ID), "start"
case "autonomous.research.task.completed", "autonomous.research.task.failed", "autonomous.research.task.cancelled":
return "autonomous-research", "autonomous:" + nonemptyAnalysis(metadataString(activity.Metadata, "task_id"), activity.ID), "end"
case "query.started":
return "query", "query:" + activity.ID, "start"
case "query.completed", "query.failed":
return "query", latestSyntheticKey("query"), "end"
case "research.test.started":
return "searxng-test", "research-test:" + activity.ID, "start"
case "research.test.results", "research.test.failed":
return "searxng-test", latestSyntheticKey("searxng-test"), "end"
}
// Only the root lifecycle events open or close a research run. Nested
// operations such as article.research.fetch.started/completed are updates of
// the parent run, not independent processes. Treating every *.started as a
// new run caused the same research_id to be appended to activeOrder once per
// fetched page and made a single query appear five to seven times as
// "running" in the Analysis Center.
if strings.HasPrefix(typeName, "research.") || strings.HasPrefix(typeName, "article.research.") {
id := metadataString(activity.Metadata, "research_id")
if id != "" {
switch typeName {
case "research.started", "article.research.started":
return "research", "research:" + id, "start"
case "research.completed", "research.failed", "article.research.completed", "article.research.failed":
return "research", "research:" + id, "end"
default:
return "research", "research:" + id, "update"
}
}
}
return "", "", ""
}
// latestSyntheticKey is a marker resolved by buildAnalysisRuns. Terminal events
// without a native run ID are attached to the most recent active run of the
// same kind.
func latestSyntheticKey(kind string) string { return "latest:" + kind }
func kindForStandalone(activity model.Activity) string {
switch {
case strings.HasPrefix(activity.Type, "glpi.kb."):
return "glpi-sync"
case strings.HasPrefix(activity.Type, "persistence."):
return "persistence"
case strings.HasPrefix(activity.Type, "article."):
return "article"
case strings.HasPrefix(activity.Type, "embedding."):
return "embedding"
case strings.HasPrefix(activity.Type, "autonomous.research.scan."):
return "opportunity-scan"
case activity.Type == "agent.run":
return "agent"
default:
return "activity"
}
}
func isMeaningfulStandalone(activity model.Activity) bool {
if activity.Type == "brain.idle" || activity.Type == "think.queued" {
return false
}
return strings.Contains(activity.Type, "completed") || strings.Contains(activity.Type, "failed") || strings.Contains(activity.Type, "created") || strings.Contains(activity.Type, "synced") || strings.Contains(activity.Type, "flushed") || activity.Type == "agent.run" || activity.Type == "graph.updated"
}
func runTitle(kind string, activity model.Activity) string {
switch kind {
case "thinking":
return "AI-THINK-Zyklus"
case "learning":
return "KB-Lernlauf"
case "autonomous-research":
return nonemptyAnalysis(metadataString(activity.Metadata, "topic"), nonemptyAnalysis(activity.Message, "Autonome Recherche"))
case "query":
return nonemptyAnalysis(activity.Query, "Wissensabfrage")
case "research", "searxng-test":
return nonemptyAnalysis(metadataString(activity.Metadata, "research_query", "query"), nonemptyAnalysis(activity.Query, "SearXNG-Recherche"))
case "article":
return nonemptyAnalysis(metadataString(activity.Metadata, "title"), "Artikelsynthese")
case "glpi-sync":
return "GLPI-KB-Synchronisierung"
case "persistence":
return "Persistenz-Flush"
case "embedding":
return "Embedding-Lauf"
case "opportunity-scan":
return "Autonome Wissenslückensuche"
case "agent":
return "Agent-Lauf"
default:
return nonemptyAnalysis(activity.Message, activity.Type)
}
}
func eventSeverity(activity model.Activity) string {
typeName := strings.ToLower(activity.Type)
message := strings.ToLower(activity.Message)
if strings.Contains(typeName, "failed") || strings.Contains(typeName, "error") || strings.Contains(message, "fehlgeschlagen") {
return "error"
}
if strings.Contains(typeName, "skipped") || strings.Contains(typeName, "rejected") || strings.Contains(typeName, "no_candidate") || strings.Contains(typeName, "paused") || strings.Contains(typeName, "deferred") || strings.Contains(message, "übersprungen") {
return "warning"
}
if strings.Contains(typeName, "completed") || strings.Contains(typeName, "created") || strings.Contains(typeName, "accepted") || strings.Contains(typeName, "ingested") || strings.Contains(typeName, "learned") || strings.Contains(typeName, "synced") || strings.Contains(typeName, "results") || strings.Contains(typeName, "flushed") {
return "success"
}
return "neutral"
}
func verdictFromRun(run *AnalysisRun) string {
if hasFailureEvent(run.Events) {
return "error"
}
if !run.Mutations.Empty() {
return "success"
}
if hasWarningEvent(run.Events) {
return "warning"
}
return "neutral"
}
func explainRun(run *AnalysisRun, terminal model.Activity) (string, string) {
status := run.Status
if status == "error" {
return "fehlgeschlagen", nonemptyAnalysis(metadataString(terminal.Metadata, "error"), nonemptyAnalysis(terminal.Message, "Der Lauf wurde mit einem Fehler beendet."))
}
if run.Mutations.NodesCreated > 0 || run.Mutations.EdgesCreated > 0 || run.Mutations.VectorsCreated > 0 {
parts := []string{}
if run.Mutations.NodesCreated > 0 {
parts = append(parts, fmt.Sprintf("%d neue Nodes", run.Mutations.NodesCreated))
}
if run.Mutations.EdgesCreated > 0 {
parts = append(parts, fmt.Sprintf("%d neue Edges", run.Mutations.EdgesCreated))
}
if run.Mutations.VectorsCreated > 0 {
parts = append(parts, fmt.Sprintf("%d neue Embeddings", run.Mutations.VectorsCreated))
}
if run.Mutations.VectorsUpdated > 0 {
parts = append(parts, fmt.Sprintf("%d neu berechnete Embeddings", run.Mutations.VectorsUpdated))
}
return "positives Ergebnis", "Der Lauf hat den Wissenszustand materiell erweitert: " + strings.Join(parts, ", ") + "."
}
if run.Mutations.NodesDeleted+run.Mutations.EdgesDeleted+run.Mutations.VectorsDeleted > 0 {
return "Bereinigung", fmt.Sprintf("Der Lauf hat veraltete Daten entfernt: %d Nodes, %d Edges und %d Embeddings.", run.Mutations.NodesDeleted, run.Mutations.EdgesDeleted, run.Mutations.VectorsDeleted)
}
if run.Mutations.NodesUpdated+run.Mutations.EdgesUpdated+run.Mutations.VectorsUpdated > 0 {
return "aktualisiert", fmt.Sprintf("Bestehendes Wissen wurde verändert: %d Nodes, %d Edges und %d Embeddings wurden aktualisiert oder neu berechnet.", run.Mutations.NodesUpdated, run.Mutations.EdgesUpdated, run.Mutations.VectorsUpdated)
}
if status == "warning" {
return "ohne Übernahme", nonemptyAnalysis(terminal.Message, "Der Lauf hat geprüft, aber wegen Qualitäts- oder Relevanzregeln nichts in den Graphen übernommen.")
}
return "ohne Graphänderung", nonemptyAnalysis(terminal.Message, "Der Lauf wurde beendet, hat aber keine Nodes, Edges oder Embeddings verändert.")
}
func hasFailureEvent(events []AnalysisEventRecord) bool {
for _, event := range events {
if eventSeverity(event.Activity) == "error" {
return true
}
}
return false
}
func hasWarningEvent(events []AnalysisEventRecord) bool {
for _, event := range events {
if eventSeverity(event.Activity) == "warning" {
return true
}
}
return false
}
func mergeRunMetrics(metrics map[string]any, activity model.Activity) {
if metrics == nil {
return
}
for _, keys := range [][]string{{"comparisons", "candidate_comparisons"}, {"exact_comparisons"}, {"coarse_comparisons"}, {"candidate_pool"}, {"checked"}, {"relations_created"}, {"articles_created"}, {"articles_skipped"}, {"research_search_results", "result_count"}, {"research_fetched", "pages_fetched"}, {"research_accepted", "evidence_count"}, {"research_rejected"}, {"queries_executed"}, {"batch_count"}, {"duration_ms"}} {
name := keys[0]
value := metadataNumber(activity.Metadata, keys...)
if value == 0 {
continue
}
previous, _ := metrics[name].(float64)
metrics[name] = previous + value
}
if similarity := metadataNumber(activity.Metadata, "semantic_similarity"); similarity > 0 {
metrics["last_semantic_similarity"] = similarity
}
if confidence := metadataNumber(activity.Metadata, "confidence"); confidence > 0 {
metrics["last_confidence"] = confidence
}
if modelName := metadataString(activity.Metadata, "model"); modelName != "" {
metrics["model"] = modelName
}
if mode := metadataString(activity.Metadata, "processing_mode"); mode != "" {
metrics["processing_mode"] = mode
}
}
func metadataNumber(metadata map[string]any, keys ...string) float64 {
for _, key := range keys {
if value, ok := numericMetadata(metadata, key); ok {
return value
}
}
return 0
}
func metadataString(metadata map[string]any, keys ...string) string {
for _, key := range keys {
if metadata == nil {
return ""
}
value, ok := metadata[key]
if !ok || value == nil {
continue
}
text := strings.TrimSpace(fmt.Sprint(value))
if text != "" && !strings.EqualFold(text, "<nil>") && !strings.EqualFold(text, "null") {
return text
}
}
return ""
}
func uniqueStrings(values []string) []string {
seen := map[string]struct{}{}
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
func (s *Store) countNodeCreatedLocked() { s.mutations.NodesCreated++ }
func (s *Store) countNodeUpdatedLocked() { s.mutations.NodesUpdated++ }
func (s *Store) countNodeDeletedLocked() { s.mutations.NodesDeleted++ }
func (s *Store) countEdgeCreatedLocked() { s.mutations.EdgesCreated++ }
func (s *Store) countEdgeUpdatedLocked() { s.mutations.EdgesUpdated++ }
func (s *Store) countEdgeDeletedLocked() { s.mutations.EdgesDeleted++ }
func (s *Store) countVectorCreatedLocked() { s.mutations.VectorsCreated++ }
func (s *Store) countVectorUpdatedLocked() { s.mutations.VectorsUpdated++ }
func (s *Store) countVectorDeletedLocked() { s.mutations.VectorsDeleted++ }
const analysisDetailedChangeLimit = 2000
func (s *Store) recordChangeLocked(change GraphChange) {
change.Timestamp = time.Now().UTC()
change.ProcessID = s.processID
change.GraphVersion = s.version
if change.Details == nil {
change.Details = map[string]any{}
}
if len(s.analysisPendingChanges) < analysisDetailedChangeLimit {
s.analysisPendingChanges = append(s.analysisPendingChanges, change)
return
}
s.analysisPendingTruncated++
}
func nodeChange(node model.Node, action string) GraphChange {
return GraphChange{EntityKind: "node", Action: action, EntityID: node.ID, Label: node.Label, Origin: node.Origin, Details: map[string]any{"kind": node.Kind, "status": node.Status, "source": explicitNodeSource(node)}}
}
func nodeUpdateChange(previous, current model.Node) GraphChange {
change := nodeChange(current, "updated")
change.Details["previous_label"] = previous.Label
change.Details["previous_status"] = previous.Status
change.Details["previous_source"] = explicitNodeSource(previous)
return change
}
func edgeChange(edge model.Edge, action string) GraphChange {
details := map[string]any{"source": edge.Source, "target": edge.Target, "status": edge.Status, "confidence": edge.Confidence}
if similarity, ok := numericMetadata(edge.Metadata, "semantic_similarity"); ok {
details["semantic_similarity"] = similarity
}
return GraphChange{EntityKind: "edge", Action: action, EntityID: edge.ID, RelationType: edge.Type, Origin: edge.Origin, Details: details}
}
func edgeUpdateChange(previous, current model.Edge) GraphChange {
change := edgeChange(current, "updated")
change.Details["previous_status"] = previous.Status
change.Details["previous_confidence"] = previous.Confidence
if similarity, ok := numericMetadata(previous.Metadata, "semantic_similarity"); ok {
change.Details["previous_semantic_similarity"] = similarity
}
return change
}
func vectorRecalculatedChange(nodeID string, previousDimensions, dimensions int, label string) GraphChange {
change := vectorChange(nodeID, "recalculated", dimensions, label)
change.Details["previous_dimensions"] = previousDimensions
return change
}
func vectorChange(nodeID, action string, dimensions int, label string) GraphChange {
return GraphChange{EntityKind: "vector", Action: action, EntityID: nodeID, Label: label, Origin: "embedding", Details: map[string]any{"dimensions": dimensions}}
}