Files
glpi-neural-brain/internal/web/readiness.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

529 lines
28 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
}