Files
glpi-neural-brain/internal/engine/article_cpu_quality.go
jbergner 94dbd4ccab
All checks were successful
release-tag / release-image (push) Successful in 2m32s
RC-4
2026-08-09 18:41:47 +02:00

150 lines
5.9 KiB
Go

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
}