All checks were successful
release-tag / release-image (push) Successful in 2m43s
205 lines
6.8 KiB
Go
205 lines
6.8 KiB
Go
package engine
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/local/glpi-neural-brain/internal/graph"
|
|
"github.com/local/glpi-neural-brain/internal/model"
|
|
)
|
|
|
|
type articleProvenanceMetadata struct {
|
|
ArticleID string `json:"article_id"`
|
|
Action string `json:"action"`
|
|
TargetNodeID string `json:"target_node_id"`
|
|
SourceNodeIDs []string `json:"source_node_ids"`
|
|
Confidence float64 `json:"confidence"`
|
|
GenerationDepth int `json:"generation_depth"`
|
|
ProductiveSourceCount int `json:"productive_source_count"`
|
|
AISourceCount int `json:"ai_source_count"`
|
|
ProductionRatio float64 `json:"production_ratio"`
|
|
SourceFingerprint string `json:"source_fingerprint"`
|
|
SynthesisModel string `json:"synthesis_model"`
|
|
ReviewModel string `json:"review_model"`
|
|
Pipeline string `json:"pipeline"`
|
|
Planning struct {
|
|
Reason string `json:"reason"`
|
|
} `json:"planning"`
|
|
GroundedResearch []struct {
|
|
URL string `json:"url"`
|
|
} `json:"grounded_research_evidence"`
|
|
}
|
|
|
|
// reconcileArticleProvenance repairs provenance edges that may have been lost
|
|
// by older staging reconciliation logic. article-metadata is the durable source
|
|
// of truth for source/target provenance after an accepted draft was written.
|
|
func (e *Engine) reconcileArticleProvenance() graph.MutationStats {
|
|
var stats graph.MutationStats
|
|
root := filepath.Join(e.Cfg.DataDir, "article-metadata")
|
|
files, err := filepath.Glob(filepath.Join(root, "*.json"))
|
|
if err != nil {
|
|
slog.Warn("article provenance metadata glob failed", "error", err)
|
|
return stats
|
|
}
|
|
snapshot := e.Graph.Snapshot()
|
|
existing := make(map[string]bool, len(snapshot.Edges))
|
|
for _, edge := range snapshot.Edges {
|
|
existing[articleProvenanceSemanticKey(edge.Source, edge.Target, edge.Type)] = true
|
|
}
|
|
for _, path := range files {
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
var meta articleProvenanceMetadata
|
|
if err := json.Unmarshal(raw, &meta); err != nil || strings.TrimSpace(meta.ArticleID) == "" {
|
|
continue
|
|
}
|
|
articleNodeID := graph.ID("knowledge", meta.ArticleID)
|
|
articleNode, ok := e.Graph.GetNode(articleNodeID)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if articleNode.Metadata == nil {
|
|
articleNode.Metadata = map[string]any{}
|
|
}
|
|
// Older staging JSONs did not persist ai_think provenance. Restore the
|
|
// structural metadata from the durable sidecar so restart/reimport cannot
|
|
// erase source lineage or make readiness blind to missing edges.
|
|
needsMetadataRepair := strings.TrimSpace(fmt.Sprint(articleNode.Metadata["subtype"])) != "knowledge_synthesis" ||
|
|
strings.TrimSpace(fmt.Sprint(articleNode.Metadata["action"])) != strings.TrimSpace(meta.Action) ||
|
|
strings.TrimSpace(fmt.Sprint(articleNode.Metadata["target_node_id"])) != strings.TrimSpace(meta.TargetNodeID) ||
|
|
intMetadataValue(articleNode.Metadata["generation_depth"]) != meta.GenerationDepth ||
|
|
strings.TrimSpace(fmt.Sprint(articleNode.Metadata["source_fingerprint"])) != strings.TrimSpace(meta.SourceFingerprint) ||
|
|
!sameStringSet(engineStringSlice(articleNode.Metadata["source_node_ids"]), meta.SourceNodeIDs)
|
|
if needsMetadataRepair {
|
|
articleNode.Metadata["subtype"] = "knowledge_synthesis"
|
|
articleNode.Metadata["action"] = meta.Action
|
|
articleNode.Metadata["target_node_id"] = meta.TargetNodeID
|
|
articleNode.Metadata["source_node_ids"] = append([]string(nil), meta.SourceNodeIDs...)
|
|
articleNode.Metadata["confidence"] = meta.Confidence
|
|
articleNode.Metadata["generation_depth"] = meta.GenerationDepth
|
|
articleNode.Metadata["productive_source_count"] = meta.ProductiveSourceCount
|
|
articleNode.Metadata["ai_source_count"] = meta.AISourceCount
|
|
articleNode.Metadata["production_ratio"] = meta.ProductionRatio
|
|
articleNode.Metadata["source_fingerprint"] = meta.SourceFingerprint
|
|
articleNode.Metadata["synthesis_model"] = meta.SynthesisModel
|
|
articleNode.Metadata["review_model"] = meta.ReviewModel
|
|
articleNode.Metadata["pipeline"] = meta.Pipeline
|
|
stats.Add(e.Graph.UpsertNodeWithStats(articleNode))
|
|
}
|
|
confidence := meta.Confidence
|
|
if confidence <= 0 {
|
|
confidence = .8
|
|
}
|
|
add := func(edge model.Edge) {
|
|
edge.Origin = "knowledge-synthesis"
|
|
edge.Status = "staging"
|
|
if edge.Confidence == 0 {
|
|
edge.Confidence = confidence
|
|
}
|
|
if edge.Weight == 0 {
|
|
edge.Weight = .65
|
|
}
|
|
semanticKey := articleProvenanceSemanticKey(edge.Source, edge.Target, edge.Type)
|
|
if existing[semanticKey] {
|
|
return
|
|
}
|
|
edge.ID = graph.EdgeID(edge.Source, edge.Target, edge.Type, edge.Origin)
|
|
stats.Add(e.Graph.UpsertEdgeWithStats(edge))
|
|
existing[semanticKey] = true
|
|
}
|
|
for _, sourceID := range meta.SourceNodeIDs {
|
|
sourceID = strings.TrimSpace(sourceID)
|
|
if sourceID == "" {
|
|
continue
|
|
}
|
|
if _, ok := e.Graph.GetNode(sourceID); !ok {
|
|
continue
|
|
}
|
|
add(model.Edge{Source: articleNodeID, Target: sourceID, Type: "synthesized_from", Weight: .65, Explanation: meta.Planning.Reason})
|
|
}
|
|
if target := strings.TrimSpace(meta.TargetNodeID); target != "" {
|
|
if _, ok := e.Graph.GetNode(target); ok {
|
|
action := safeArticleAction(meta.Action)
|
|
if action != "skip" {
|
|
add(model.Edge{Source: articleNodeID, Target: target, Type: "proposes_" + action, Weight: .8, Explanation: meta.Planning.Reason})
|
|
}
|
|
}
|
|
}
|
|
for _, evidence := range meta.GroundedResearch {
|
|
if strings.TrimSpace(evidence.URL) == "" {
|
|
continue
|
|
}
|
|
researchID := graph.ID("external", evidence.URL)
|
|
if _, ok := e.Graph.GetNode(researchID); ok {
|
|
add(model.Edge{Source: articleNodeID, Target: researchID, Type: "grounded_by", Weight: .65, Explanation: "Persistierter, vom Reviewer verwendeter Beleg"})
|
|
}
|
|
}
|
|
}
|
|
return stats
|
|
}
|
|
|
|
func articleProvenanceSemanticKey(source, target, edgeType string) string {
|
|
return strings.TrimSpace(source) + "\x00" + strings.TrimSpace(target) + "\x00" + strings.TrimSpace(edgeType)
|
|
}
|
|
|
|
func intMetadataValue(value any) int {
|
|
switch v := value.(type) {
|
|
case int:
|
|
return v
|
|
case int64:
|
|
return int(v)
|
|
case float64:
|
|
return int(v)
|
|
case float32:
|
|
return int(v)
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func engineStringSlice(value any) []string {
|
|
switch values := value.(type) {
|
|
case []string:
|
|
return append([]string(nil), values...)
|
|
case []any:
|
|
out := make([]string, 0, len(values))
|
|
for _, raw := range values {
|
|
if text := strings.TrimSpace(fmt.Sprint(raw)); text != "" {
|
|
out = append(out, text)
|
|
}
|
|
}
|
|
return out
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func sameStringSet(a, b []string) bool {
|
|
a = append([]string(nil), a...)
|
|
b = append([]string(nil), b...)
|
|
for i := range a {
|
|
a[i] = strings.TrimSpace(a[i])
|
|
}
|
|
for i := range b {
|
|
b[i] = strings.TrimSpace(b[i])
|
|
}
|
|
sort.Strings(a)
|
|
sort.Strings(b)
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|