All checks were successful
release-tag / release-image (push) Successful in 2m32s
150 lines
5.9 KiB
Go
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
|
|
}
|