Files
jbergner a6bc71fb3a
Some checks failed
release-tag / Resolve release metadata (push) Successful in 30s
release-tag / Build knowledge (push) Failing after 4m51s
release-tag / Build control (push) Failing after 5m0s
release-tag / Build agent (push) Failing after 5m0s
release-tag / Build agent-data-init (push) Failing after 5m5s
release-tag / Build neuroforge-worker (push) Failing after 5m7s
release-tag / Build neuroforge (push) Failing after 5m9s
Init
2026-08-26 18:34:41 +02:00

307 lines
13 KiB
Go

package web
import (
"crypto/subtle"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"github.com/example/glpi-ai-agent/internal/learning"
"github.com/example/glpi-ai-agent/internal/model"
)
// graphNode/graphEdge are deliberately generic. They form the small, read-only
// interchange contract consumed by the Mega Control Center. The contract does
// not expose raw prompts, credentials or provider URLs.
type graphNode struct {
ID string `json:"id"`
Kind string `json:"kind"`
Label string `json:"label"`
Group string `json:"group,omitempty"`
Community string `json:"community,omitempty"`
Status string `json:"status,omitempty"`
Score float64 `json:"score,omitempty"`
Meta map[string]any `json:"meta,omitempty"`
}
type graphEdge struct {
ID string `json:"id"`
From string `json:"from"`
To string `json:"to"`
Kind string `json:"kind"`
Label string `json:"label,omitempty"`
Status string `json:"status,omitempty"`
Weight float64 `json:"weight,omitempty"`
Meta map[string]any `json:"meta,omitempty"`
}
type graphPayload struct {
Scope string `json:"scope"`
Title string `json:"title"`
Nodes []graphNode `json:"nodes"`
Edges []graphEdge `json:"edges"`
Meta map[string]any `json:"meta,omitempty"`
}
func (s *Server) controlReadAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := strings.TrimSpace(s.cfg.ControlReadToken)
if token == "" {
http.NotFound(w, r)
return
}
got := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
if subtle.ConstantTimeCompare([]byte(got), []byte(token)) != 1 {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
func (s *Server) controlRuns(w http.ResponseWriter, r *http.Request) {
limit := boundedInt(r.URL.Query().Get("limit"), 40, 1, 100)
runs := s.state.Recent(limit)
type runSummary struct {
RunID string `json:"run_id"`
TicketID int64 `json:"ticket_id"`
TicketName string `json:"ticket_name"`
Outcome string `json:"outcome"`
Trigger string `json:"trigger,omitempty"`
KnowledgeID string `json:"knowledge_id,omitempty"`
Score float64 `json:"knowledge_score,omitempty"`
Reply bool `json:"reply_proposed"`
FinishedAt any `json:"finished_at"`
}
out := make([]runSummary, 0, len(runs))
for _, x := range runs {
out = append(out, runSummary{RunID: x.RunID, TicketID: x.TicketID, TicketName: x.TicketName, Outcome: x.Outcome, Trigger: x.Trigger, KnowledgeID: x.KnowledgeID, Score: x.KnowledgeScore, Reply: x.ReplyProposed, FinishedAt: x.FinishedAt})
}
respondJSON(w, out)
}
func (s *Server) controlRunGraph(w http.ResponseWriter, r *http.Request) {
runID := strings.TrimSpace(r.PathValue("id"))
run, ok := s.state.FindRun(runID)
if !ok {
http.Error(w, "run not found", http.StatusNotFound)
return
}
respondJSON(w, buildRunGraph(run, s.feedback.TicketOutcomes()))
}
func (s *Server) controlLearningGraph(w http.ResponseWriter, r *http.Request) {
limit := boundedInt(r.URL.Query().Get("limit"), 180, 1, 500)
items := s.feedback.TicketOutcomes()
if len(items) > limit {
items = items[:limit]
}
respondJSON(w, buildLearningGraph(items))
}
func boundedInt(raw string, def, min, max int) int {
n, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || n < min {
return def
}
if n > max {
return max
}
return n
}
func buildRunGraph(run model.RunRecord, outcomes []learning.TicketOutcome) graphPayload {
g := graphPayload{Scope: "ticket", Title: fmt.Sprintf("Ticket #%d · %s", run.TicketID, run.TicketName), Meta: map[string]any{"run_id": run.RunID, "ticket_id": run.TicketID, "outcome": run.Outcome, "trigger": run.Trigger, "dry_run": run.DryRun}}
seen := map[string]bool{}
addNode := func(n graphNode) {
if n.ID == "" || seen[n.ID] {
return
}
seen[n.ID] = true
g.Nodes = append(g.Nodes, n)
}
addEdge := func(e graphEdge) {
if e.ID == "" {
e.ID = e.From + "->" + e.To + ":" + e.Kind
}
g.Edges = append(g.Edges, e)
}
ticketID := fmt.Sprintf("ticket:%d", run.TicketID)
runNode := "run:" + run.RunID
addNode(graphNode{ID: ticketID, Kind: "ticket", Label: fmt.Sprintf("#%d · %s", run.TicketID, compactGraph(run.TicketName, 72)), Group: "ticket", Community: "decision", Status: run.Outcome, Meta: map[string]any{"source_version": run.SourceVersion, "trigger": run.Trigger}})
addNode(graphNode{ID: runNode, Kind: "run", Label: "AI Run", Group: "decision", Community: "decision", Status: run.Outcome, Meta: map[string]any{"reason": run.Reason, "policy_reason": run.PolicyReason, "started_at": run.StartedAt, "finished_at": run.FinishedAt}})
addEdge(graphEdge{From: ticketID, To: runNode, Kind: "analysed_by", Label: run.Trigger})
if run.CategoryBefore > 0 {
id := fmt.Sprintf("category:%d", run.CategoryBefore)
label := run.CategoryBeforeName
if label == "" {
label = fmt.Sprintf("Kategorie #%d", run.CategoryBefore)
}
addNode(graphNode{ID: id, Kind: "category", Label: label, Group: "policy", Community: "classification", Status: "current"})
addEdge(graphEdge{From: ticketID, To: id, Kind: "categorized_as", Status: "current"})
}
if run.AIRecommendedCategoryID > 0 {
id := fmt.Sprintf("category:%d", run.AIRecommendedCategoryID)
label := run.AIRecommendedCategoryName
if label == "" {
label = fmt.Sprintf("Kategorie #%d", run.AIRecommendedCategoryID)
}
addNode(graphNode{ID: id, Kind: "category", Label: label, Group: "policy", Community: "classification", Status: run.CategoryDecision, Score: run.AICategoryConfidence})
addEdge(graphEdge{From: runNode, To: id, Kind: "recommended_category", Label: run.CategoryDecision, Weight: run.AICategoryConfidence})
}
candidates := run.ReplyKnowledgeCandidates
if len(candidates) == 0 {
candidates = run.KnowledgeCandidates
}
if len(candidates) > 24 {
candidates = candidates[:24]
}
for _, c := range candidates {
id := "knowledge:" + c.ID
status := "candidate"
if c.ID == run.KnowledgeID || c.ID == run.AIKnowledgeID {
status = "selected"
}
addNode(graphNode{ID: id, Kind: "knowledge", Label: compactGraph(c.Title, 80), Group: "knowledge", Community: "evidence", Status: status, Score: c.Score, Meta: map[string]any{"source": c.Source, "semantic_score": c.SemanticScore, "category_score": c.CategoryScore, "auto_reply": c.AutoReply, "selection_reason": c.SelectionReason, "excerpt": compactGraph(c.BestChunkExcerpt, 220)}})
addEdge(graphEdge{From: runNode, To: id, Kind: "retrieved", Label: fmt.Sprintf("rank %d", c.RetrievalRank), Weight: c.Score, Status: status})
}
for _, x := range run.ValidatedOutcomeCandidates {
id := "memory:" + x.MemoryID
addNode(graphNode{ID: id, Kind: "validated_outcome", Label: compactGraph(x.Text, 110), Group: "learning", Community: "evidence", Status: x.Decision, Score: x.Similarity, Meta: map[string]any{"source": x.Source, "ticket_id": x.TicketID, "outcome_id": x.OutcomeID, "knowledge_id": x.KnowledgeID}})
addEdge(graphEdge{From: runNode, To: id, Kind: "experience_evidence", Label: x.Decision, Weight: x.Similarity})
}
checks := append([]model.RuleCheck(nil), run.CategoryChecks...)
checks = append(checks, run.ReplyChecks...)
checks = append(checks, run.ExecutionChecks...)
for i, c := range checks {
id := fmt.Sprintf("check:%d:%s", i, c.Code)
addNode(graphNode{ID: id, Kind: "policy_check", Label: compactGraph(c.Label, 90), Group: "policy", Community: "gates", Status: c.Status, Meta: map[string]any{"code": c.Code, "blocking": c.Blocking, "actual": c.Actual, "expected": c.Expected, "detail": compactGraph(c.Detail, 220)}})
addEdge(graphEdge{From: runNode, To: id, Kind: "checked", Status: c.Status, Weight: boolWeight(c.Blocking)})
}
for i, c := range run.ContextDetails {
id := fmt.Sprintf("context:%s:%d:%d", c.Kind, c.ID, i)
addNode(graphNode{ID: id, Kind: "context_" + c.Kind, Label: compactGraph(c.Name, 90), Group: "context", Community: "context", Status: c.Status, Score: c.Relevance, Meta: map[string]any{"detail": compactGraph(c.Detail, 220)}})
addEdge(graphEdge{From: ticketID, To: id, Kind: "context", Weight: c.Relevance})
}
for _, a := range run.Analyses {
id := "analysis:" + a.AnalysisID
addNode(graphNode{ID: id, Kind: "analysis", Label: strings.Title(strings.ReplaceAll(a.AnalysisType, "_", " ")), Group: "analysis", Community: "decision", Status: a.Outcome, Score: a.Confidence, Meta: map[string]any{"duration_ms": a.DurationMS, "model": a.Model, "prompt_version": a.PromptVersion, "reason_codes": a.ReasonCodes, "explanation": compactGraph(a.Explanation, 260)}})
addEdge(graphEdge{From: runNode, To: id, Kind: "analysis_stage", Weight: a.Confidence})
for _, attempt := range a.Provider.Attempts {
node := "model:" + attempt.NodeName + ":" + attempt.ModelDigest
addNode(graphNode{ID: node, Kind: "model_node", Label: attempt.NodeName, Group: "runtime", Community: "runtime", Status: attempt.Outcome, Meta: map[string]any{"model_digest": attempt.ModelDigest, "duration_ms": attempt.DurationMS, "http_status": attempt.HTTPStatus}})
addEdge(graphEdge{From: id, To: node, Kind: "executed_on", Status: attempt.Outcome})
}
}
if run.ReplyProposed || run.ReplyProposedText != "" {
status := "proposed"
if run.ReplyWritten {
status = "written"
}
if run.ReplyDecision != "" && !run.ReplyProposed {
status = "blocked"
}
replyID := "reply:" + run.RunID
addNode(graphNode{ID: replyID, Kind: "reply", Label: compactGraph(run.ReplyProposedText, 120), Group: "decision", Community: "decision", Status: status, Score: run.AIReplyConfidence, Meta: map[string]any{"decision": run.ReplyDecision, "knowledge_id": run.KnowledgeID, "written": run.ReplyWritten}})
addEdge(graphEdge{From: runNode, To: replyID, Kind: "proposed_reply", Status: status, Weight: run.AIReplyConfidence})
}
byID := map[string]learning.TicketOutcome{}
for _, x := range outcomes {
byID[x.ID] = x
}
for _, x := range outcomes {
if x.RunID != run.RunID {
continue
}
appendOutcomeToGraph(&g, seen, x, byID, ticketID, "reply:"+run.RunID)
}
return g
}
func buildLearningGraph(items []learning.TicketOutcome) graphPayload {
g := graphPayload{Scope: "learning", Title: "Learning Lineage", Meta: map[string]any{"outcomes": len(items)}}
seen := map[string]bool{}
byID := make(map[string]learning.TicketOutcome, len(items))
for _, x := range items {
byID[x.ID] = x
}
for _, x := range items {
ticketID := fmt.Sprintf("ticket:%d", x.TicketID)
if !seen[ticketID] {
seen[ticketID] = true
g.Nodes = append(g.Nodes, graphNode{ID: ticketID, Kind: "ticket", Label: fmt.Sprintf("Ticket #%d", x.TicketID), Group: "ticket", Community: "learning"})
}
appendOutcomeToGraph(&g, seen, x, byID, ticketID, "")
}
sort.SliceStable(g.Nodes, func(i, j int) bool { return g.Nodes[i].ID < g.Nodes[j].ID })
return g
}
func appendOutcomeToGraph(g *graphPayload, seen map[string]bool, x learning.TicketOutcome, byID map[string]learning.TicketOutcome, ticketID, replyID string) {
id := "outcome:" + x.ID
if !seen[id] {
seen[id] = true
g.Nodes = append(g.Nodes, graphNode{ID: id, Kind: "human_outcome", Label: compactGraph(x.ConfirmedReply, 110), Group: "learning", Community: "learning", Status: x.Decision, Meta: map[string]any{"actor": x.Actor, "created_at": x.CreatedAt, "sync_status": x.SyncStatus, "note": compactGraph(x.Note, 180), "run_id": x.RunID}})
}
from := ticketID
if replyID != "" && seen[replyID] {
from = replyID
}
g.Edges = append(g.Edges, graphEdge{ID: from + "->" + id, From: from, To: id, Kind: "validated_by", Label: x.Decision, Status: x.SyncStatus, Weight: 1})
if x.KnowledgeID != "" {
kid := "knowledge:" + x.KnowledgeID
if !seen[kid] {
seen[kid] = true
g.Nodes = append(g.Nodes, graphNode{ID: kid, Kind: "knowledge", Label: x.KnowledgeID, Group: "knowledge", Community: "learning"})
}
g.Edges = append(g.Edges, graphEdge{ID: id + "->" + kid, From: id, To: kid, Kind: "based_on"})
}
if x.NeuroForgeID != "" {
mid := "memory:" + x.NeuroForgeID
if !seen[mid] {
seen[mid] = true
g.Nodes = append(g.Nodes, graphNode{ID: mid, Kind: "memory", Label: "NeuroForge Memory", Group: "brain", Community: "learning", Status: x.SyncStatus, Meta: map[string]any{"memory_id": x.NeuroForgeID}})
}
g.Edges = append(g.Edges, graphEdge{ID: id + "->" + mid, From: id, To: mid, Kind: "learned_as", Status: x.SyncStatus})
}
if x.SupersedesID != "" {
prevID := "outcome:" + x.SupersedesID
if prev, ok := byID[x.SupersedesID]; ok && !seen[prevID] {
seen[prevID] = true
g.Nodes = append(g.Nodes, graphNode{ID: prevID, Kind: "human_outcome", Label: compactGraph(prev.ConfirmedReply, 110), Group: "learning", Community: "learning", Status: "superseded"})
}
g.Edges = append(g.Edges, graphEdge{ID: id + "->" + prevID, From: id, To: prevID, Kind: "supersedes", Status: "active", Weight: 1})
}
}
func compactGraph(v string, n int) string {
v = strings.Join(strings.Fields(strings.TrimSpace(v)), " ")
if n <= 0 {
return v
}
r := []rune(v)
if len(r) <= n {
return v
}
return string(r[:n]) + "…"
}
func boolWeight(v bool) float64 {
if v {
return 1
}
return .25
}