package engine import ( "context" "errors" "fmt" "math" "strings" "time" "github.com/local/glpi-neural-brain/internal/articlequality" "github.com/local/glpi-neural-brain/internal/model" "github.com/local/glpi-neural-brain/internal/sourceagent" ) type articleCPUQualityExecution struct { Result articlequality.Result Offloaded bool AgentID string ComputeMS int64 FallbackReason string } func buildArticleCPUQualityRequest(draft model.KnowledgeArticleDraft, articleType string, sources []articleSource, research []model.ResearchResult, language string) articlequality.Request { docs := make([]articlequality.Document, 0, len(sources)+len(research)) for _, src := range sources { text := articleQualitySample(strings.TrimSpace(src.Node.Label+"\n"+src.Content), 20000) if text != "" { docs = append(docs, articlequality.Document{ID: src.Node.ID, Text: text}) } } for i, r := range research { text := strings.TrimSpace(r.Content) if text == "" { text = strings.TrimSpace(r.Snippet) } if text == "" { continue } id := fmt.Sprintf("R%d", i+1) if strings.TrimSpace(r.URL) != "" { id = r.URL } docs = append(docs, articlequality.Document{ID: id, Text: articleQualitySample(strings.TrimSpace(r.Title+"\n"+text), 20000)}) } return articlequality.Request{ArticleType: articleType, Title: draft.Title, Problem: draft.Text, Answer: formatArticleAnswer(draft, language), Prerequisites: draft.Prerequisites, Validation: draft.Validation, Troubleshoot: draft.Troubleshooting, Categories: draft.Categories, Keywords: draft.Keywords, Sources: docs} } func articleQualitySample(value string, maxRunes int) string { value = strings.TrimSpace(value) if maxRunes <= 0 { maxRunes = 20000 } r := []rune(value) if len(r) <= maxRunes { return value } // Preserve context from the beginning, middle and end instead of silently // biasing the lexical metric toward only the first section of long sources. head := maxRunes * 2 / 5 middle := maxRunes * 3 / 10 tail := maxRunes - head - middle midStart := len(r)/2 - middle/2 if midStart < head { midStart = head } if midStart+middle > len(r)-tail { midStart = len(r) - tail - middle } return strings.TrimSpace(string(r[:head]) + "\n[… Stichprobe Mitte …]\n" + string(r[midStart:midStart+middle]) + "\n[… Stichprobe Ende …]\n" + string(r[len(r)-tail:])) } func validateArticleCPUQualityResult(r articlequality.Result) error { if r.Algorithm != articlequality.Algorithm { return fmt.Errorf("unexpected article quality algorithm %q", r.Algorithm) } for n, v := range map[string]float64{"score": r.Score, "lexical_diversity": r.LexicalDiversity, "redundancy": r.Redundancy, "evidence_alignment": r.EvidenceAlignment, "source_utilization": r.SourceUtilization, "technical_specificity": r.TechnicalSpecificity, "type_depth_score": r.TypeDepthScore} { if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 || v > 1.000001 { return fmt.Errorf("invalid article quality metric %s=%v", n, v) } } if r.WordCount < 0 || r.SectionCount < 0 || r.ParagraphCount < 0 { return errors.New("invalid article quality counters") } return nil } func (e *Engine) evaluateArticleCPUQuality(ctx context.Context, articleRunID string, draft model.KnowledgeArticleDraft, articleType string, sources []articleSource, research []model.ResearchResult) (articleCPUQualityExecution, error) { payload := buildArticleCPUQualityRequest(draft, articleType, sources, research, e.Cfg.ArticleLanguage) if !e.Cfg.ArticleCPUQualityEnabled { r := articlequality.Evaluate(payload) r.Passed = true return articleCPUQualityExecution{Result: r, FallbackReason: "disabled_gate_observation_only"}, nil } if !e.Cfg.ArticleCPUQualityAgentOffload || e.SourceInbox == nil { r := articlequality.Evaluate(payload) return articleCPUQualityExecution{Result: r}, validateArticleCPUQualityResult(r) } checkCtx, cancel := context.WithTimeout(ctx, 2*time.Second) hasAgent, checkErr := e.SourceInbox.HasOnlineComputeAgent(checkCtx, sourceagent.ComputeKindArticleQuality, 3*time.Minute) cancel() if checkErr != nil || !hasAgent { reason := "no_article_quality_compute_agent" if checkErr != nil { reason = "article_quality_agent_check_failed: " + checkErr.Error() } if e.Cfg.ArticleCPUQualityAgentRequired { return articleCPUQualityExecution{FallbackReason: reason}, errors.New(reason) } r := articlequality.Evaluate(payload) return articleCPUQualityExecution{Result: r, FallbackReason: reason}, validateArticleCPUQualityResult(r) } jobCtx, cancelJob := context.WithTimeout(ctx, e.Cfg.ArticleCPUQualityAgentWait) result, err := e.SourceInbox.SubmitArticleQualityJob(jobCtx, sourceagent.ArticleQualityComputeRequest{Payload: payload}) cancelJob() if err == nil { // Reconstruct pass/fail and recommendations locally. The agent contributes // bounded math only; it cannot inject arbitrary author instructions. result.Quality = articlequality.NormalizeResult(payload, result.Quality) err = validateArticleCPUQualityResult(result.Quality) } if err != nil { reason := err.Error() if e.Cfg.ArticleCPUQualityAgentRequired { return articleCPUQualityExecution{FallbackReason: reason}, err } r := articlequality.Evaluate(payload) return articleCPUQualityExecution{Result: r, FallbackReason: reason}, validateArticleCPUQualityResult(r) } return articleCPUQualityExecution{Result: result.Quality, Offloaded: true, AgentID: result.AgentID, ComputeMS: result.DurationMS}, nil } func articleCPUQualityBetter(a, b articlequality.Result) bool { if a.Passed != b.Passed { return a.Passed } if len(a.HardFailures) != len(b.HardFailures) { return len(a.HardFailures) < len(b.HardFailures) } if math.Abs(a.Score-b.Score) > 0.000001 { return a.Score > b.Score } if a.WordCount != b.WordCount { return a.WordCount > b.WordCount } if a.SectionCount != b.SectionCount { return a.SectionCount > b.SectionCount } return a.SourceUtilization > b.SourceUtilization }