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 == "" { 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" }