All checks were successful
release-tag / release-image (push) Successful in 2m43s
529 lines
28 KiB
Go
529 lines
28 KiB
Go
package web
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
"unicode"
|
||
|
||
"github.com/local/glpi-neural-brain/internal/articlequality"
|
||
"github.com/local/glpi-neural-brain/internal/graph"
|
||
"github.com/local/glpi-neural-brain/internal/model"
|
||
"github.com/local/glpi-neural-brain/internal/research"
|
||
"github.com/local/glpi-neural-brain/internal/sourceagent"
|
||
)
|
||
|
||
type readinessCheck struct {
|
||
ID string `json:"id"`
|
||
Label string `json:"label"`
|
||
Status string `json:"status"`
|
||
Message string `json:"message"`
|
||
Details map[string]any `json:"details,omitempty"`
|
||
}
|
||
|
||
type readinessSummary struct {
|
||
Status string `json:"status"`
|
||
Passed int `json:"passed"`
|
||
Warnings int `json:"warnings"`
|
||
Failed int `json:"failed"`
|
||
Checks []readinessCheck `json:"checks"`
|
||
}
|
||
|
||
func (s *Server) productionReadiness(ctx context.Context, detail graph.DetailedGraphAnalysis, history graph.AnalysisHistory, system map[string]any) readinessSummary {
|
||
checks := make([]readinessCheck, 0, 10)
|
||
add := func(id, label, status, message string, details map[string]any) {
|
||
checks = append(checks, readinessCheck{ID: id, Label: label, Status: status, Message: message, Details: details})
|
||
}
|
||
|
||
bootstrapComplete, _ := system["bootstrap_complete"].(bool)
|
||
bootstrapError := strings.TrimSpace(fmt.Sprint(system["bootstrap_error"]))
|
||
if bootstrapError == "<nil>" {
|
||
bootstrapError = ""
|
||
}
|
||
if bootstrapError != "" {
|
||
add("bootstrap", "Startup-Bootstrap", "fail", "Der initiale Knowledge-/Embedding-Bootstrap wurde mit Fehler beendet.", map[string]any{"error": bootstrapError})
|
||
} else if !bootstrapComplete {
|
||
add("bootstrap", "Startup-Bootstrap", "warning", "Der initiale Knowledge-/Embedding-Bootstrap läuft noch; autonome Security-/Thinking-Workflows warten absichtlich.", nil)
|
||
} else {
|
||
add("bootstrap", "Startup-Bootstrap", "pass", "Initialer Knowledge-/Embedding-Bootstrap ist abgeschlossen; autonome Workflows sind freigegeben.", map[string]any{"completed_at": system["bootstrap_completed_at"]})
|
||
}
|
||
|
||
if history.DroppedEvents > 0 || strings.TrimSpace(history.Audit.LastError) != "" {
|
||
add("audit", "Analyse-Audit", "fail", "Audit-Ereignisse wurden verloren oder konnten nicht persistiert werden.", map[string]any{"dropped_events": history.DroppedEvents, "queue_dropped_events": history.Audit.QueueDroppedEvents, "persist_dropped_events": history.Audit.PersistDroppedEvents, "last_drop_reason": history.Audit.LastDropReason, "last_drop_at": history.Audit.LastDropAt, "last_error": history.Audit.LastError})
|
||
} else {
|
||
add("audit", "Analyse-Audit", "pass", "Keine verlorenen Audit-Ereignisse erkannt.", map[string]any{"queue_depth": history.Audit.QueueDepth, "queue_capacity": history.Audit.QueueCapacity})
|
||
}
|
||
|
||
dimensions := make([]string, 0, len(detail.EmbeddingDimensions))
|
||
for dimension, count := range detail.EmbeddingDimensions {
|
||
if count > 0 {
|
||
dimensions = append(dimensions, dimension)
|
||
}
|
||
}
|
||
sort.Strings(dimensions)
|
||
vectorStatus := "pass"
|
||
missingVectors := detail.VectorEligibleNodes - detail.VectorRows
|
||
if missingVectors < 0 {
|
||
missingVectors = 0
|
||
}
|
||
vectorMessage := fmt.Sprintf("%d/%d vektorberechtigte Nodes besitzen ein Embedding.", detail.VectorRows, detail.VectorEligibleNodes)
|
||
if detail.VectorEligibleNodes > 0 && detail.VectorRows != detail.VectorEligibleNodes {
|
||
if bootstrapComplete {
|
||
vectorStatus = "fail"
|
||
vectorMessage = fmt.Sprintf("%d vektorberechtigte Nodes besitzen nach abgeschlossenem Bootstrap kein Embedding.", missingVectors)
|
||
} else {
|
||
vectorStatus = "warning"
|
||
vectorMessage = fmt.Sprintf("Der Bootstrap läuft noch; %d Embeddings fehlen derzeit.", missingVectors)
|
||
}
|
||
} else if len(dimensions) != 1 || (len(dimensions) == 1 && dimensions[0] != "768") {
|
||
if bootstrapComplete {
|
||
vectorStatus = "fail"
|
||
} else {
|
||
vectorStatus = "warning"
|
||
}
|
||
vectorMessage = "Gemischte oder unerwartete Embedding-Dimensionen sind vorhanden; ein Produktionsgraph muss nach Bootstrap ausschließlich die erwartete 768D-Dimension verwenden."
|
||
}
|
||
add("vectors", "Embedding-Konsistenz", vectorStatus, vectorMessage, map[string]any{"rows": detail.VectorRows, "eligible_nodes": detail.VectorEligibleNodes, "coverage": detail.VectorCoverage, "missing_vectors": missingVectors, "dimensions": detail.EmbeddingDimensions})
|
||
|
||
if detail.Summary.NodeCount >= 10000 && s.Engine.Cfg.ScanInterval < time.Minute {
|
||
add("scan-cadence", "Knowledge-Scan-Intervall", "warning", "Der Graph ist groß, aber BRAIN_SCAN_INTERVAL liegt unter einer Minute. Der Manifest-Fast-Path reduziert die Kosten, ein längeres Intervall vermeidet dennoch unnötige Dateisystem-Walks.", map[string]any{"scan_interval": s.Engine.Cfg.ScanInterval.String(), "recommended_minimum": "1m", "knowledge_full_verify_interval": s.Engine.Cfg.KnowledgeFullVerifyInterval.String(), "nodes": detail.Summary.NodeCount})
|
||
} else {
|
||
add("scan-cadence", "Knowledge-Scan-Intervall", "pass", "Das Knowledge-Scan-Intervall ist für die aktuelle Graphgröße plausibel; periodische Vollverifikation bleibt aktiviert, sofern nicht explizit auf 0 gesetzt.", map[string]any{"scan_interval": s.Engine.Cfg.ScanInterval.String(), "knowledge_full_verify_interval": s.Engine.Cfg.KnowledgeFullVerifyInterval.String(), "nodes": detail.Summary.NodeCount})
|
||
}
|
||
|
||
persistence := s.Engine.Persistence.Status()
|
||
switch {
|
||
case strings.TrimSpace(persistence.LastError) != "" || persistence.FailedFlushes > 0:
|
||
add("persistence", "Persistenz", "fail", "Mindestens ein Persistenz-Flush ist fehlgeschlagen.", map[string]any{"last_error": persistence.LastError, "failed_flushes": persistence.FailedFlushes, "last_flush": persistence.LastFlush})
|
||
case strings.TrimSpace(persistence.LastWALCheckpointErr) != "":
|
||
add("persistence", "Persistenz", "warning", "Graphdaten wurden persistiert, aber der letzte große WAL-Checkpoint konnte nicht abgeschlossen werden.", map[string]any{"successful_flushes": persistence.SuccessfulFlushes, "graph_dirty": persistence.GraphDirty, "pending_files": persistence.PendingFiles, "wal_checkpoints": persistence.WALCheckpoints, "last_wal_checkpoint": persistence.LastWALCheckpoint, "last_wal_checkpoint_error": persistence.LastWALCheckpointErr})
|
||
case persistence.SuccessfulFlushes == 0 && persistence.CurrentGraphVersion > 0:
|
||
add("persistence", "Persistenz", "warning", "Der aktuelle Prozess hat noch keinen erfolgreichen Flush bestätigt.", map[string]any{"graph_dirty": persistence.GraphDirty, "pending_files": persistence.PendingFiles, "wal_checkpoints": persistence.WALCheckpoints})
|
||
default:
|
||
add("persistence", "Persistenz", "pass", "Persistenz arbeitet ohne gemeldeten Fehler.", map[string]any{"successful_flushes": persistence.SuccessfulFlushes, "graph_dirty": persistence.GraphDirty, "pending_files": persistence.PendingFiles, "wal_checkpoints": persistence.WALCheckpoints, "last_wal_checkpoint": persistence.LastWALCheckpoint})
|
||
}
|
||
|
||
ollamaOK, _ := system["ollama_ok"].(bool)
|
||
if ollamaOK {
|
||
add("ollama", "Ollama", "pass", "Ollama ist erreichbar und wurde vom Brain als gesund markiert.", nil)
|
||
} else {
|
||
add("ollama", "Ollama", "fail", "Ollama ist aktuell nicht als gesund markiert; Learning/Thinking/Review können dadurch unvollständig sein.", nil)
|
||
}
|
||
if models, ok := system["article_model_status"].(map[string]any); ok {
|
||
missing := make([]string, 0, 2)
|
||
for _, role := range []string{"synthesis", "review"} {
|
||
if status, ok := models[role].(map[string]any); ok {
|
||
healthy := int(numberValue(status["healthy_nodes_with_model"]))
|
||
if healthy < 1 {
|
||
missing = append(missing, fmt.Sprint(status["model"]))
|
||
}
|
||
}
|
||
}
|
||
if len(missing) > 0 {
|
||
add("article-models", "Artikelmodelle", "fail", "Mindestens ein Author-/Reviewer-Modell ist auf keinem gesunden Ollama-Node verfügbar.", map[string]any{"missing_models": missing})
|
||
} else {
|
||
add("article-models", "Artikelmodelle", "pass", "Author- und Reviewer-Modell sind auf mindestens einem gesunden Node vorhanden.", nil)
|
||
}
|
||
}
|
||
|
||
researchEnabled, _ := system["research_enabled"].(bool)
|
||
if researchEnabled {
|
||
searxOK := false
|
||
switch raw := system["searxng"].(type) {
|
||
case research.Diagnostic:
|
||
searxOK = raw.OK
|
||
case map[string]any:
|
||
searxOK, _ = raw["ok"].(bool)
|
||
}
|
||
if searxOK {
|
||
add("searxng", "SearXNG", "pass", "Webrecherche ist aktiviert und der letzte SearXNG-Check war erfolgreich.", nil)
|
||
} else {
|
||
add("searxng", "SearXNG", "warning", "Webrecherche ist aktiviert, SearXNG ist aber nicht als gesund bestätigt.", nil)
|
||
}
|
||
} else {
|
||
add("searxng", "SearXNG", "pass", "Webrecherche ist bewusst deaktiviert.", nil)
|
||
}
|
||
|
||
if s.SourceAgents != nil {
|
||
agents, agentsErr := s.SourceAgents.ListAgents(ctx)
|
||
if agentsErr != nil {
|
||
add("agents", "Source Agents", "fail", "Die Agentenregistrierung konnte nicht gelesen werden.", map[string]any{"error": agentsErr.Error()})
|
||
} else {
|
||
enabled, online, withTasks := 0, 0, 0
|
||
for _, agent := range agents {
|
||
if !agent.Enabled {
|
||
continue
|
||
}
|
||
enabled++
|
||
if agent.TaskCount > 0 {
|
||
withTasks++
|
||
}
|
||
if !agent.LastSeen.IsZero() && time.Since(agent.LastSeen) < 15*time.Minute {
|
||
online++
|
||
}
|
||
}
|
||
if enabled == 0 {
|
||
add("agents", "Source Agents", "warning", "Source-Agent-Infrastruktur ist verfügbar, wurde in diesem Lauf aber noch nicht mit einem aktivierten Agenten geprüft.", map[string]any{"enabled_agents": enabled, "online_agents": online, "agents_with_tasks": withTasks})
|
||
} else if withTasks == 0 {
|
||
add("agents", "Source Agents", "warning", "Aktive Agenten sind registriert, aber noch ohne Polling-Aufgabe; der End-to-End-Ingest ist damit noch nicht nachgewiesen.", map[string]any{"enabled_agents": enabled, "online_agents": online, "agents_with_tasks": withTasks})
|
||
} else if online == 0 {
|
||
add("agents", "Source Agents", "warning", "Aktive Polling-Aufgaben sind konfiguriert, aber aktuell ist kein Agent online.", map[string]any{"enabled_agents": enabled, "online_agents": online, "agents_with_tasks": withTasks})
|
||
} else {
|
||
add("agents", "Source Agents", "pass", "Mindestens ein aktiver Agent mit Polling-Aufgabe und aktuellem Heartbeat ist verbunden.", map[string]any{"enabled_agents": enabled, "online_agents": online, "agents_with_tasks": withTasks})
|
||
}
|
||
}
|
||
if policy, policyErr := s.SourceAgents.ControllerPolicy(ctx); policyErr != nil {
|
||
add("docker-controller", "Docker Controller", "warning", "Die zentrale Docker-Controller-Policy konnte nicht gelesen werden.", map[string]any{"error": policyErr.Error()})
|
||
} else if !policy.Enabled {
|
||
add("docker-controller", "Docker Controller", "pass", "Host-Docker-Steuerung ist durch den zentralen Hauptschalter deaktiviert.", map[string]any{"enabled": false, "autonomous_enabled": false})
|
||
} else {
|
||
controllers := 0
|
||
compose := 0
|
||
for _, agent := range agents {
|
||
if !agent.Enabled || agent.LastSeen.IsZero() || time.Since(agent.LastSeen) >= 3*time.Minute {
|
||
continue
|
||
}
|
||
for _, capability := range agent.Capabilities {
|
||
if capability == sourceagent.CapabilityDockerController {
|
||
controllers++
|
||
}
|
||
if capability == sourceagent.CapabilityDockerCompose {
|
||
compose++
|
||
}
|
||
}
|
||
}
|
||
details := map[string]any{"enabled": true, "autonomous_enabled": policy.AutonomousEnabled, "dry_run": policy.DryRun, "allow_destructive": policy.AllowDestructive, "online_controllers": controllers, "compose_controllers": compose, "stats": s.SourceAgents.ControllerStats(ctx)}
|
||
if controllers == 0 {
|
||
add("docker-controller", "Docker Controller", "warning", "Der zentrale Controller ist freigegeben, aber kein online Agent meldet docker_controller.", details)
|
||
} else {
|
||
add("docker-controller", "Docker Controller", "pass", "Host-Docker-Steuerung ist zentral freigegeben und mindestens ein Controller-Agent ist online.", details)
|
||
}
|
||
}
|
||
if s.Engine != nil && s.Engine.Cfg.VectorGraphAgentOffload {
|
||
hasComputeAgent, computeErr := s.SourceAgents.HasOnlineComputeAgent(ctx, sourceagent.ComputeKindVectorGraph, 15*time.Minute)
|
||
details := map[string]any{"required": s.Engine.Cfg.VectorGraphAgentRequired, "compute": s.SourceAgents.ComputeStats()}
|
||
switch {
|
||
case computeErr != nil:
|
||
add("vector-agent-compute", "Vector-Graph / Agent-CPU", "warning", "Compute-Agent-Verfügbarkeit konnte nicht zuverlässig geprüft werden.", map[string]any{"error": computeErr.Error(), "required": s.Engine.Cfg.VectorGraphAgentRequired})
|
||
case hasComputeAgent:
|
||
add("vector-agent-compute", "Vector-Graph / Agent-CPU", "pass", "Mindestens ein online Agent bietet den modellfreien vector_graph-CPU-Job an.", details)
|
||
case s.Engine.Cfg.VectorGraphAgentRequired:
|
||
add("vector-agent-compute", "Vector-Graph / Agent-CPU", "fail", "Agent-Offload ist verpflichtend, aber kein online Agent meldet die Capability vector_graph.", details)
|
||
default:
|
||
add("vector-agent-compute", "Vector-Graph / Agent-CPU", "warning", "Agent-Offload ist aktiviert, aber aktuell steht kein Compute-Agent bereit; das Brain verwendet den lokalen CPU-Fallback.", details)
|
||
}
|
||
}
|
||
if s.Engine != nil && s.Engine.Cfg.ArticleCPUQualityEnabled && s.Engine.Cfg.ArticleCPUQualityAgentOffload {
|
||
hasComputeAgent, computeErr := s.SourceAgents.HasOnlineComputeAgent(ctx, sourceagent.ComputeKindArticleQuality, 15*time.Minute)
|
||
details := map[string]any{"required": s.Engine.Cfg.ArticleCPUQualityAgentRequired, "algorithm": articlequality.Algorithm, "no_model_call": true, "compute": s.SourceAgents.ComputeStats()}
|
||
switch {
|
||
case computeErr != nil:
|
||
add("article-quality-agent-compute", "Artikelqualität / Agent-CPU", "warning", "Die Verfügbarkeit des modellfreien Artikel-Quality-Workers konnte nicht zuverlässig geprüft werden.", map[string]any{"error": computeErr.Error(), "required": s.Engine.Cfg.ArticleCPUQualityAgentRequired, "algorithm": articlequality.Algorithm})
|
||
case hasComputeAgent:
|
||
add("article-quality-agent-compute", "Artikelqualität / Agent-CPU", "pass", "Mindestens ein online Agent bietet die modellfreie mathematische Artikelprüfung an.", details)
|
||
case s.Engine.Cfg.ArticleCPUQualityAgentRequired:
|
||
add("article-quality-agent-compute", "Artikelqualität / Agent-CPU", "fail", "Agent-Offload für die mathematische Artikelprüfung ist verpflichtend, aber kein online Agent meldet article_quality.", details)
|
||
default:
|
||
add("article-quality-agent-compute", "Artikelqualität / Agent-CPU", "warning", "Kein article_quality-Agent ist online; das Brain verwendet den identischen lokalen CPU-Fallback.", details)
|
||
}
|
||
}
|
||
stats, statsErr := s.SourceAgents.Stats(ctx)
|
||
lifecycles, lifecycleErr := s.SourceAgents.SecurityLifecycles(ctx, time.Unix(0, 0).UTC())
|
||
if statsErr != nil || lifecycleErr != nil {
|
||
add("source-inbox", "Source Inbox", "fail", "Der Source-Inbox-Zustand konnte nicht vollständig geprüft werden.", map[string]any{"stats_error": errorString(statsErr), "lifecycle_error": errorString(lifecycleErr)})
|
||
} else {
|
||
missingNodes := 0
|
||
wrongOrigin := 0
|
||
staleProcessing := 0
|
||
authoritativeDone := 0
|
||
authoritativeNodeIDs := map[string]bool{}
|
||
for _, lifecycle := range lifecycles {
|
||
state := strings.ToLower(strings.TrimSpace(lifecycle.ProactiveState))
|
||
outcome := strings.ToLower(strings.TrimSpace(lifecycle.Outcome))
|
||
status := strings.ToLower(strings.TrimSpace(lifecycle.Status))
|
||
if state == "processing" && !lifecycle.StartedAt.IsZero() && time.Since(lifecycle.StartedAt) > 15*time.Minute {
|
||
staleProcessing++
|
||
}
|
||
if outcome == "materialized" || state == "done" || status == "materialized" || status == "used" {
|
||
authoritativeDone++
|
||
if lifecycle.MaterializedNodeID != "" {
|
||
authoritativeNodeIDs[lifecycle.MaterializedNodeID] = true
|
||
}
|
||
if lifecycle.MaterializedNodeID == "" {
|
||
missingNodes++
|
||
continue
|
||
}
|
||
node, ok := s.Graph.GetNode(lifecycle.MaterializedNodeID)
|
||
if !ok {
|
||
missingNodes++
|
||
} else if node.Origin != "source-agent-security" {
|
||
wrongOrigin++
|
||
}
|
||
}
|
||
}
|
||
graphSecurityNodes := detail.NodeOrigins["source-agent-security"]
|
||
authoritativeUniqueNodes := len(authoritativeNodeIDs)
|
||
if missingNodes > 0 || wrongOrigin > 0 || staleProcessing > 0 {
|
||
add("source-inbox", "Source Inbox / Security", "fail", "Operativer Security-State und Graph sind nicht vollständig konsistent.", map[string]any{"materialized_records": authoritativeDone, "unique_materialized_nodes": authoritativeUniqueNodes, "graph_security_nodes": graphSecurityNodes, "missing_nodes": missingNodes, "wrong_origin": wrongOrigin, "stale_processing": staleProcessing, "queued": stats.SecurityQueued, "processing": stats.SecurityProcessing})
|
||
} else if stats.SecurityQueued > 0 || stats.SecurityProcessing > 0 {
|
||
add("source-inbox", "Source Inbox / Security", "warning", "Security-Verarbeitung ist konsistent, aber es warten oder laufen noch Jobs.", map[string]any{"materialized_records": authoritativeDone, "unique_materialized_nodes": authoritativeUniqueNodes, "graph_security_nodes": graphSecurityNodes, "queued": stats.SecurityQueued, "processing": stats.SecurityProcessing})
|
||
} else if authoritativeUniqueNodes != graphSecurityNodes {
|
||
add("source-inbox", "Source Inbox / Security", "warning", "Alle bekannten Security-Lifecycles sind gültig, die Zahl eindeutiger materialisierter Node-IDs weicht jedoch vom Graph ab (z. B. Altbestand außerhalb des Stores). Mehrere Revisionen derselben Advisory-URL zählen absichtlich nur einmal.", map[string]any{"materialized_records": authoritativeDone, "unique_materialized_nodes": authoritativeUniqueNodes, "graph_security_nodes": graphSecurityNodes})
|
||
} else if len(lifecycles) == 0 && stats.Total == 0 {
|
||
add("source-inbox", "Source Inbox / Security", "warning", "Source Inbox ist konsistent, wurde in diesem Lauf aber noch nicht durch Agent-Ingest/Security-Materialisierung ausgeübt.", map[string]any{"materialized_records": authoritativeDone, "unique_materialized_nodes": authoritativeUniqueNodes, "graph_security_nodes": graphSecurityNodes})
|
||
} else {
|
||
add("source-inbox", "Source Inbox / Security", "pass", "Materialisierte Security-Lifecycles besitzen konsistente Graph-Nodes; keine hängenden Worker erkannt.", map[string]any{"materialized_records": authoritativeDone, "unique_materialized_nodes": authoritativeUniqueNodes, "graph_security_nodes": graphSecurityNodes})
|
||
}
|
||
|
||
reconciledRunning := 0
|
||
for _, run := range history.Runs {
|
||
if run.Kind == "security-source" && run.Status == "running" {
|
||
reconciledRunning++
|
||
}
|
||
}
|
||
if reconciledRunning != stats.SecurityProcessing {
|
||
add("security-runs", "Security-Run-Rekonstruktion", "fail", "Analyse und operativer Source-Inbox-State widersprechen sich bei laufenden Security-Jobs.", map[string]any{"analysis_running": reconciledRunning, "store_processing": stats.SecurityProcessing})
|
||
} else if len(lifecycles) == 0 {
|
||
add("security-runs", "Security-Run-Rekonstruktion", "warning", "Noch keine Security-Lifecycles vorhanden; die Run-Rekonstruktion ist in diesem Lauf noch nicht end-to-end bewiesen.", map[string]any{"running": reconciledRunning})
|
||
} else {
|
||
add("security-runs", "Security-Run-Rekonstruktion", "pass", "Analyse und operativer Store stimmen bei laufenden Security-Jobs überein.", map[string]any{"running": reconciledRunning})
|
||
}
|
||
}
|
||
} else {
|
||
add("source-inbox", "Source Inbox / Security", "pass", "Source-Agent-Store ist in dieser Installation nicht aktiviert.", nil)
|
||
}
|
||
|
||
if detail.Summary.Components == 1 || detail.Summary.NodeCount == 0 {
|
||
add("graph-connectivity", "Graph-Konnektivität", "pass", "Der Gesamtgraph bildet eine zusammenhängende Komponente.", map[string]any{"components": detail.Summary.Components})
|
||
} else {
|
||
add("graph-connectivity", "Graph-Konnektivität", "warning", "Der Graph enthält mehrere getrennte Komponenten; das kann fachlich gewollt sein, sollte aber geprüft werden.", map[string]any{"components": detail.Summary.Components})
|
||
}
|
||
|
||
knowledgeNodes := detail.NodeKinds["knowledge"] + detail.NodeKinds["ai-think"]
|
||
if knowledgeNodes > 0 {
|
||
orphanRatio := float64(detail.Summary.KnowledgeOrphans) / float64(knowledgeNodes)
|
||
details := map[string]any{"knowledge_nodes": knowledgeNodes, "knowledge_orphans": detail.Summary.KnowledgeOrphans, "orphan_ratio": orphanRatio}
|
||
switch {
|
||
case orphanRatio > .25:
|
||
add("knowledge-connectivity", "Wissens-Konnektivität", "fail", "Zu viele Knowledge-Nodes besitzen keine direkte Wissens-/Evidenzbeziehung; eine einzige Gesamtkomponente reicht als Readiness-Nachweis nicht aus.", details)
|
||
case orphanRatio > .05:
|
||
add("knowledge-connectivity", "Wissens-Konnektivität", "warning", "Ein relevanter Anteil der Knowledge-Nodes ist semantisch/evidenzseitig nicht direkt verbunden.", details)
|
||
default:
|
||
add("knowledge-connectivity", "Wissens-Konnektivität", "pass", "Der Anteil semantisch/evidenzseitig unverbundener Knowledge-Nodes liegt im tolerierten Bereich.", details)
|
||
}
|
||
}
|
||
|
||
articleRuns, articleFailures := 0, 0
|
||
for _, run := range history.Runs {
|
||
if run.Kind != "article" || run.Status == "running" {
|
||
continue
|
||
}
|
||
articleRuns++
|
||
if run.Status == "error" || run.Status == "failed" {
|
||
articleFailures++
|
||
}
|
||
}
|
||
if articleRuns >= 5 {
|
||
failureRatio := float64(articleFailures) / float64(articleRuns)
|
||
details := map[string]any{"completed_runs": articleRuns, "failures": articleFailures, "failure_ratio": failureRatio}
|
||
switch {
|
||
case failureRatio > .25:
|
||
add("article-runtime-slo", "Artikelsynthese / Runtime-SLO", "fail", "Die Fehlerrate abgeschlossener Artikelläufe ist zu hoch für automatisches Publizieren.", details)
|
||
case failureRatio > .10:
|
||
add("article-runtime-slo", "Artikelsynthese / Runtime-SLO", "warning", "Die Artikelsynthese zeigt eine erhöhte operative Fehlerrate.", details)
|
||
default:
|
||
add("article-runtime-slo", "Artikelsynthese / Runtime-SLO", "pass", "Die Fehlerrate abgeschlossener Artikelläufe liegt im tolerierten Bereich.", details)
|
||
}
|
||
}
|
||
|
||
// Runtime knowledge-synthesis articles must retain their source provenance.
|
||
snapshot := s.Engine.Graph.Snapshot()
|
||
edgeKeys := make(map[string]bool, len(snapshot.Edges))
|
||
for _, edge := range snapshot.Edges {
|
||
if edge.Type == "synthesized_from" {
|
||
edgeKeys[edge.Source+"\x00"+edge.Target] = true
|
||
}
|
||
}
|
||
articleCount, expectedProvenance, missingProvenance := 0, 0, 0
|
||
mixedTopicArticles := make([]string, 0)
|
||
invalidOperationalArticles := make([]string, 0)
|
||
nodesByID := make(map[string]model.Node, len(snapshot.Nodes))
|
||
for _, node := range snapshot.Nodes {
|
||
nodesByID[node.ID] = node
|
||
}
|
||
for _, node := range snapshot.Nodes {
|
||
if node.Kind != "ai-think" || strings.TrimSpace(fmt.Sprint(node.Metadata["subtype"])) != "knowledge_synthesis" {
|
||
continue
|
||
}
|
||
articleCount++
|
||
articleType := strings.ToLower(strings.TrimSpace(fmt.Sprint(node.Metadata["article_type"])))
|
||
if articleType == "how_to" || articleType == "troubleshooting" {
|
||
steps := int(numberValue(node.Metadata["solution_step_count"]))
|
||
validation := int(numberValue(node.Metadata["validation_step_count"]))
|
||
if steps < 3 || validation < 1 {
|
||
invalidOperationalArticles = append(invalidOperationalArticles, nonemptyReadiness(node.ExternalID, node.ID))
|
||
}
|
||
}
|
||
titleTerms := readinessTopicTerms(node.Label)
|
||
foreignTopics := make([]map[string]bool, 0)
|
||
for _, sourceID := range readinessStringSlice(node.Metadata["source_node_ids"]) {
|
||
if strings.TrimSpace(sourceID) == "" {
|
||
continue
|
||
}
|
||
expectedProvenance++
|
||
if !edgeKeys[node.ID+"\x00"+sourceID] {
|
||
missingProvenance++
|
||
}
|
||
if source, ok := nodesByID[sourceID]; ok {
|
||
terms := readinessTopicTerms(source.Label)
|
||
if len(terms) > 0 && readinessTermIntersection(titleTerms, terms) == 0 {
|
||
foreignTopics = append(foreignTopics, terms)
|
||
}
|
||
}
|
||
}
|
||
if readinessHasRepeatedForeignTopic(foreignTopics) {
|
||
mixedTopicArticles = append(mixedTopicArticles, nonemptyReadiness(node.ExternalID, node.ID))
|
||
}
|
||
}
|
||
if len(invalidOperationalArticles) > 0 {
|
||
add("article-task-structure", "Artikelqualität / Task-Completeness", "fail", "Operational markierte Knowledge-Synthesis-Artikel besitzen nicht genügend ausführbare Schritte oder keine Ergebnisvalidierung.", map[string]any{"articles": invalidOperationalArticles, "minimum_solution_steps": 3, "minimum_validation_steps": 1})
|
||
} else if articleCount > 0 {
|
||
add("article-task-structure", "Artikelqualität / Task-Completeness", "pass", "Alle neu erzeugten operationalen Artikel erfüllen die deterministische Mindeststruktur.", map[string]any{"articles": articleCount})
|
||
}
|
||
|
||
if missingProvenance > 0 || len(mixedTopicArticles) > 0 {
|
||
message := fmt.Sprintf("%d erwartete synthesized_from-Kanten fehlen bei %d Knowledge-Synthesis-Artikeln.", missingProvenance, articleCount)
|
||
if len(mixedTopicArticles) > 0 {
|
||
message = fmt.Sprintf("%d Knowledge-Synthesis-Artikel zeigen wiederholte fachfremde Quellthemen; bestehende Altentwürfe müssen bereinigt werden.", len(mixedTopicArticles))
|
||
}
|
||
add("article-provenance", "Artikel-Provenienz / Topic-Coherence", "fail", message, map[string]any{"articles": articleCount, "expected_edges": expectedProvenance, "missing_edges": missingProvenance, "mixed_topic_articles": mixedTopicArticles})
|
||
} else if articleCount == 0 {
|
||
add("article-provenance", "Artikel-Provenienz / Topic-Coherence", "warning", "Noch kein Knowledge-Synthesis-Artikel vorhanden; Provenienz und Topic-Coherence wurden in diesem Lauf noch nicht end-to-end bewiesen.", nil)
|
||
} else {
|
||
add("article-provenance", "Artikel-Provenienz / Topic-Coherence", "pass", fmt.Sprintf("Alle %d erwarteten synthesized_from-Kanten für %d Knowledge-Synthesis-Artikel sind vorhanden; keine wiederholten fachfremden Quellcluster erkannt.", expectedProvenance, articleCount), map[string]any{"articles": articleCount, "expected_edges": expectedProvenance})
|
||
}
|
||
|
||
summary := readinessSummary{Status: "pass", Checks: checks}
|
||
for _, check := range checks {
|
||
switch check.Status {
|
||
case "fail":
|
||
summary.Failed++
|
||
case "warning":
|
||
summary.Warnings++
|
||
default:
|
||
summary.Passed++
|
||
}
|
||
}
|
||
if summary.Failed > 0 {
|
||
summary.Status = "fail"
|
||
} else if summary.Warnings > 0 {
|
||
summary.Status = "warning"
|
||
}
|
||
return summary
|
||
}
|
||
|
||
func numberValue(value any) float64 {
|
||
switch v := value.(type) {
|
||
case int:
|
||
return float64(v)
|
||
case int64:
|
||
return float64(v)
|
||
case uint64:
|
||
return float64(v)
|
||
case float64:
|
||
return v
|
||
case float32:
|
||
return float64(v)
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func errorString(err error) string {
|
||
if err == nil {
|
||
return ""
|
||
}
|
||
return err.Error()
|
||
}
|
||
|
||
func readinessStringSlice(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
|
||
}
|
||
}
|
||
|
||
var readinessTopicStop = map[string]bool{"security": true, "sicherheit": true, "it": true, "hardening": true, "haertung": true, "härtung": true, "praevention": true, "prävention": true, "ueberwachung": true, "überwachung": true, "forensik": true, "incident": true, "response": true, "risiko": true, "risiken": true, "praeventiv": true, "präventiv": true, "sicher": true, "gestalten": true, "haerten": true, "härten": true, "untersuchen": true, "erkennen": true, "und": true, "der": true, "die": true, "das": true, "in": true, "im": true, "bei": true, "von": true, "mit": true, "fuer": true, "für": true}
|
||
|
||
func readinessTopicTerms(label string) map[string]bool {
|
||
label = strings.TrimSpace(label)
|
||
for _, separator := range []string{" – ", " - "} {
|
||
if idx := strings.Index(label, separator); idx > 0 {
|
||
label = label[:idx]
|
||
break
|
||
}
|
||
}
|
||
terms := map[string]bool{}
|
||
for _, term := range strings.FieldsFunc(strings.ToLower(label), func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsDigit(r) }) {
|
||
term = strings.TrimSpace(term)
|
||
if len([]rune(term)) < 2 || readinessTopicStop[term] {
|
||
continue
|
||
}
|
||
terms[term] = true
|
||
}
|
||
return terms
|
||
}
|
||
func readinessTermIntersection(a, b map[string]bool) int {
|
||
n := 0
|
||
for term := range a {
|
||
if b[term] {
|
||
n++
|
||
}
|
||
}
|
||
return n
|
||
}
|
||
func readinessHasRepeatedForeignTopic(values []map[string]bool) bool {
|
||
for i := 0; i < len(values); i++ {
|
||
for j := i + 1; j < len(values); j++ {
|
||
intersection := readinessTermIntersection(values[i], values[j])
|
||
if intersection == 0 {
|
||
continue
|
||
}
|
||
union := len(values[i]) + len(values[j]) - intersection
|
||
jaccard := 0.0
|
||
if union > 0 {
|
||
jaccard = float64(intersection) / float64(union)
|
||
}
|
||
minSize := len(values[i])
|
||
if len(values[j]) < minSize {
|
||
minSize = len(values[j])
|
||
}
|
||
containment := float64(intersection) / float64(minSize)
|
||
if jaccard >= .50 || containment >= .67 {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func nonemptyReadiness(values ...string) string {
|
||
for _, value := range values {
|
||
if strings.TrimSpace(value) != "" {
|
||
return strings.TrimSpace(value)
|
||
}
|
||
}
|
||
return "unknown"
|
||
}
|