Files
glpi-neural-brain/internal/engine/article_provenance.go
jbergner 440423c5b6
All checks were successful
release-tag / release-image (push) Successful in 2m43s
RC-3
2026-08-09 11:29:13 +02:00

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
}