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 }