All checks were successful
release-tag / release-image (push) Successful in 2m32s
379 lines
13 KiB
Go
379 lines
13 KiB
Go
package articlequality
|
|
|
|
import (
|
|
"math"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"unicode"
|
|
)
|
|
|
|
const Algorithm = "lexical-coverage-depth-v2"
|
|
|
|
type Document struct {
|
|
ID string `json:"id"`
|
|
Text string `json:"text"`
|
|
}
|
|
type Request struct {
|
|
JobID string `json:"job_id,omitempty"`
|
|
ArticleType string `json:"article_type"`
|
|
Title string `json:"title"`
|
|
Problem string `json:"problem"`
|
|
Answer string `json:"answer"`
|
|
Prerequisites []string `json:"prerequisites,omitempty"`
|
|
Validation []string `json:"validation,omitempty"`
|
|
Troubleshoot []string `json:"troubleshooting,omitempty"`
|
|
Categories []string `json:"categories,omitempty"`
|
|
Keywords []string `json:"keywords,omitempty"`
|
|
Sources []Document `json:"sources,omitempty"`
|
|
}
|
|
type Result struct {
|
|
Algorithm string `json:"algorithm"`
|
|
Passed bool `json:"passed"`
|
|
Score float64 `json:"score"`
|
|
WordCount int `json:"word_count"`
|
|
ContentWordCount int `json:"content_word_count"`
|
|
SectionCount int `json:"section_count"`
|
|
ParagraphCount int `json:"paragraph_count"`
|
|
ListItemCount int `json:"list_item_count"`
|
|
LexicalDiversity float64 `json:"lexical_diversity"`
|
|
Redundancy float64 `json:"redundancy"`
|
|
EvidenceAlignment float64 `json:"evidence_alignment"`
|
|
SourceUtilization float64 `json:"source_utilization"`
|
|
TechnicalSpecificity float64 `json:"technical_specificity"`
|
|
TypeDepthScore float64 `json:"type_depth_score"`
|
|
Metrics map[string]float64 `json:"metrics,omitempty"`
|
|
HardFailures []string `json:"hard_failures,omitempty"`
|
|
Recommendations []string `json:"recommendations,omitempty"`
|
|
}
|
|
|
|
var headingRE = regexp.MustCompile(`(?m)^##+\s+`)
|
|
var numberedRE = regexp.MustCompile(`(?m)^\s*\d+[.)]\s+`)
|
|
var listRE = regexp.MustCompile(`(?m)^\s*(?:[-*+]\s+|\d+[.)]\s+)`)
|
|
var stopwords = map[string]bool{
|
|
"aber": true, "alle": true, "als": true, "auch": true, "auf": true, "aus": true, "bei": true, "bis": true, "das": true, "dass": true, "dem": true, "den": true, "der": true, "des": true, "die": true, "durch": true, "ein": true, "eine": true, "einer": true, "eines": true, "für": true, "hat": true, "im": true, "in": true, "ist": true, "mit": true, "nicht": true, "oder": true, "ohne": true, "sich": true, "sind": true, "und": true, "von": true, "vor": true, "werden": true, "wird": true, "zu": true, "zum": true, "zur": true,
|
|
"a": true, "an": true, "and": true, "are": true, "as": true, "at": true, "be": true, "by": true, "for": true, "from": true, "is": true, "it": true, "of": true, "on": true, "or": true, "that": true, "the": true, "to": true, "with": true,
|
|
}
|
|
|
|
func Evaluate(req Request) Result {
|
|
// Answer is expected to be the final visible body. Structural arrays remain
|
|
// separate metadata for operational gates and must not be counted twice.
|
|
visible := strings.TrimSpace(strings.Join([]string{req.Title, req.Problem, req.Answer}, "\n\n"))
|
|
all := tokenize(visible, false)
|
|
content := tokenize(visible, true)
|
|
uniqueContent := tokenSet(content)
|
|
sourceSets := make([]map[string]bool, 0, len(req.Sources))
|
|
df := map[string]int{}
|
|
for _, d := range req.Sources {
|
|
set := tokenSet(tokenize(d.Text, true))
|
|
sourceSets = append(sourceSets, set)
|
|
for t := range set {
|
|
df[t]++
|
|
}
|
|
}
|
|
paras := paragraphs(visible)
|
|
redundancy := paragraphRedundancy(paras)
|
|
alignment, specificity := weightedAlignment(content, df, len(req.Sources))
|
|
utilization := sourceUtilization(uniqueContent, sourceSets)
|
|
lexical := 0.0
|
|
if len(content) > 0 {
|
|
lexical = float64(len(uniqueContent)) / float64(len(content))
|
|
}
|
|
sections := len(headingRE.FindAllStringIndex(req.Problem+"\n"+req.Answer, -1))
|
|
listItems := len(listRE.FindAllStringIndex(req.Problem+"\n"+req.Answer, -1))
|
|
r := Result{Algorithm: Algorithm, WordCount: len(all), ContentWordCount: len(content), SectionCount: sections, ParagraphCount: len(paras), ListItemCount: listItems, LexicalDiversity: lexical, Redundancy: redundancy, EvidenceAlignment: alignment, SourceUtilization: utilization, TechnicalSpecificity: specificity}
|
|
return NormalizeResult(req, r)
|
|
}
|
|
|
|
// NormalizeResult reconstructs all decision-bearing values from bounded counters
|
|
// and metrics. Agent workers therefore cannot inject free-form rewrite text or
|
|
// choose pass/fail themselves; the Brain applies the same canonical rules again.
|
|
func NormalizeResult(req Request, r Result) Result {
|
|
r.Algorithm = Algorithm
|
|
r.TypeDepthScore = typeDepth(normalizeType(req.ArticleType), r.WordCount, r.SectionCount, r.ListItemCount, len(req.Validation), len(req.Troubleshoot))
|
|
// EvidenceAlignment is deliberately a soft signal. Lexical overlap is useful for
|
|
// detecting gross drift, but paraphrased multi-source synthesis (especially in
|
|
// German) must not be rejected before the semantic claim reviewer can inspect it.
|
|
r.Score = clamp01(.30*r.TypeDepthScore + .10*r.EvidenceAlignment + .24*r.SourceUtilization + .14*r.TechnicalSpecificity + .12*clamp01(r.LexicalDiversity/.55) + .10*(1-r.Redundancy))
|
|
r.Metrics = map[string]float64{"depth": r.TypeDepthScore, "evidence_alignment": r.EvidenceAlignment, "source_utilization": r.SourceUtilization, "technical_specificity": r.TechnicalSpecificity, "lexical_diversity": r.LexicalDiversity, "redundancy": r.Redundancy}
|
|
r.HardFailures, r.Recommendations = hardGates(req, r)
|
|
r.Passed = len(r.HardFailures) == 0
|
|
if !r.Passed && len(r.Recommendations) == 0 {
|
|
r.Recommendations = []string{"Fachliche Tiefe, Quellenabdeckung und Informationsdichte erhöhen."}
|
|
}
|
|
return r
|
|
}
|
|
func hardGates(req Request, r Result) ([]string, []string) {
|
|
typ := normalizeType(req.ArticleType)
|
|
minWords, minSections := 450, 4
|
|
switch typ {
|
|
case "reference":
|
|
minWords, minSections = 600, 4
|
|
case "concept":
|
|
minWords, minSections = 520, 4
|
|
case "decision_guide":
|
|
minWords, minSections = 520, 4
|
|
case "troubleshooting":
|
|
minWords, minSections = 500, 4
|
|
case "how_to":
|
|
minWords, minSections = 450, 4
|
|
}
|
|
var f, rec []string
|
|
if r.WordCount < minWords {
|
|
f = append(f, "article_too_short")
|
|
repairTarget := minWords + 80
|
|
rec = append(rec, "Artikel fachlich ausarbeiten und die vorhandene Substanz nicht kürzen; für die Revision mindestens etwa "+strconv.Itoa(repairTarget)+" Wörter mit zusätzlichen belegten Details anstreben.")
|
|
}
|
|
if r.SectionCount < minSections {
|
|
f = append(f, "insufficient_section_depth")
|
|
rec = append(rec, "Mehrere artikeltypspezifische Abschnitte mit eigenständigem fachlichem Inhalt ausarbeiten.")
|
|
}
|
|
if len(req.Sources) >= 3 && r.SourceUtilization < .34 {
|
|
f = append(f, "low_source_utilization")
|
|
rec = append(rec, "Mehr der bereitgestellten fachlich relevanten Quellen in konkrete, belegbare Inhalte überführen.")
|
|
}
|
|
// Low lexical overlap is not a hard evidence verdict. A well-grounded synthesis
|
|
// can paraphrase source language heavily, while the subsequent Qwen claim review
|
|
// is explicitly responsible for semantic support/contradiction. Keep the metric
|
|
// observable and only emit a diagnostic recommendation for extreme drift.
|
|
if len(req.Sources) > 0 && r.EvidenceAlignment < .12 {
|
|
rec = append(rec, "Lexikalische Evidenznähe ist niedrig; semantisches Claim-Grounding im Reviewer besonders streng prüfen.")
|
|
}
|
|
if r.Redundancy > .52 {
|
|
f = append(f, "high_redundancy")
|
|
rec = append(rec, "Wiederholungen entfernen und stattdessen zusätzliche technische Details oder Abgrenzungen ergänzen.")
|
|
}
|
|
if r.LexicalDiversity < .24 && r.WordCount > 250 {
|
|
f = append(f, "low_information_density")
|
|
rec = append(rec, "Generische Wiederholungen durch technologiespezifische Erklärungen, Zuordnungen und Beispiele ersetzen.")
|
|
}
|
|
if (typ == "how_to" || typ == "troubleshooting") && (len(numberedRE.FindAllStringIndex(req.Answer, -1)) < 3 || len(req.Validation) < 1) {
|
|
f = append(f, "insufficient_operational_structure")
|
|
rec = append(rec, "Mindestens drei belegte Arbeitsschritte und eine überprüfbare Ergebnisvalidierung angeben.")
|
|
}
|
|
if (typ == "reference" || typ == "concept") && r.ListItemCount < 3 && r.ParagraphCount < 6 {
|
|
f = append(f, "insufficient_explanatory_depth")
|
|
rec = append(rec, "Nicht nur Kernaussagen aufzählen, sondern Zusammenhänge, technische Bedeutung und Grenzen erklären.")
|
|
}
|
|
return unique(f), unique(rec)
|
|
}
|
|
func normalizeType(v string) string {
|
|
v = strings.ToLower(strings.TrimSpace(v))
|
|
switch v {
|
|
case "reference", "concept", "decision_guide", "troubleshooting", "how_to":
|
|
return v
|
|
}
|
|
return "how_to"
|
|
}
|
|
func tokenize(s string, filter bool) []string {
|
|
var out []string
|
|
var b strings.Builder
|
|
flush := func() {
|
|
if b.Len() == 0 {
|
|
return
|
|
}
|
|
t := strings.ToLower(b.String())
|
|
b.Reset()
|
|
if len([]rune(t)) < 2 {
|
|
return
|
|
}
|
|
if filter && stopwords[t] {
|
|
return
|
|
}
|
|
out = append(out, t)
|
|
}
|
|
for _, r := range s {
|
|
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-' || r == '.' || r == ':' {
|
|
b.WriteRune(r)
|
|
} else {
|
|
flush()
|
|
}
|
|
}
|
|
flush()
|
|
return out
|
|
}
|
|
func tokenSet(ts []string) map[string]bool {
|
|
m := map[string]bool{}
|
|
for _, t := range ts {
|
|
m[t] = true
|
|
}
|
|
return m
|
|
}
|
|
func paragraphs(s string) []string {
|
|
parts := regexp.MustCompile(`\n\s*\n`).Split(s, -1)
|
|
out := []string{}
|
|
for _, p := range parts {
|
|
p = strings.TrimSpace(p)
|
|
if len(tokenize(p, false)) >= 5 {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
func weightedAlignment(article []string, df map[string]int, docs int) (float64, float64) {
|
|
if len(article) == 0 || docs == 0 {
|
|
return 0, 0
|
|
}
|
|
seen := map[string]bool{}
|
|
matchedWeight, totalWeight, technicalHits, technicalTotal := 0.0, 0.0, 0.0, 0.0
|
|
for _, t := range article {
|
|
if seen[t] {
|
|
continue
|
|
}
|
|
seen[t] = true
|
|
freq := df[t]
|
|
if freq > 0 {
|
|
// Present evidence terms count fully. Rarer terms get a small bonus, but
|
|
// common cross-source terminology remains valuable instead of being
|
|
// suppressed by inverse-document weighting.
|
|
rarity := math.Log(1 + float64(docs+1)/float64(freq+1))
|
|
w := 1.0 + .25*rarity
|
|
matchedWeight += w
|
|
totalWeight += w
|
|
} else {
|
|
// v1 accidentally gave source-absent words the strongest IDF weight.
|
|
// That made normal explanatory/paraphrased prose dominate the
|
|
// denominator. Unsupported lexical additions now carry only a bounded
|
|
// penalty; semantic support is checked later by the claim reviewer.
|
|
w := .28
|
|
if looksTechnical(t) {
|
|
w = .50
|
|
}
|
|
totalWeight += w
|
|
}
|
|
if looksTechnical(t) {
|
|
technicalTotal++
|
|
if freq > 0 {
|
|
technicalHits++
|
|
}
|
|
}
|
|
}
|
|
alignment := 0.0
|
|
if totalWeight > 0 {
|
|
alignment = matchedWeight / totalWeight
|
|
}
|
|
specificity := alignment
|
|
if technicalTotal > 0 {
|
|
specificity = technicalHits / technicalTotal
|
|
}
|
|
return clamp01(alignment), clamp01(specificity)
|
|
}
|
|
|
|
func sourceUtilization(article map[string]bool, sources []map[string]bool) float64 {
|
|
if len(sources) == 0 {
|
|
return 0
|
|
}
|
|
used := 0
|
|
for _, src := range sources {
|
|
inter, den := 0, 0
|
|
for t := range src {
|
|
if len(t) < 4 {
|
|
continue
|
|
}
|
|
den++
|
|
if article[t] {
|
|
inter++
|
|
}
|
|
}
|
|
if den > 0 && (inter >= 4 || float64(inter)/float64(den) >= .09) {
|
|
used++
|
|
}
|
|
}
|
|
return float64(used) / float64(len(sources))
|
|
}
|
|
func paragraphRedundancy(ps []string) float64 {
|
|
if len(ps) < 2 {
|
|
return 0
|
|
}
|
|
sets := make([]map[string]bool, len(ps))
|
|
for i, p := range ps {
|
|
sets[i] = tokenSet(tokenize(p, true))
|
|
}
|
|
total := 0.0
|
|
for i := range sets {
|
|
best := 0.0
|
|
for j := range sets {
|
|
if i == j {
|
|
continue
|
|
}
|
|
v := jaccard(sets[i], sets[j])
|
|
if v > best {
|
|
best = v
|
|
}
|
|
}
|
|
total += best
|
|
}
|
|
return clamp01(total / float64(len(sets)))
|
|
}
|
|
func jaccard(a, b map[string]bool) float64 {
|
|
if len(a) == 0 && len(b) == 0 {
|
|
return 0
|
|
}
|
|
inter := 0
|
|
union := map[string]bool{}
|
|
for k := range a {
|
|
union[k] = true
|
|
if b[k] {
|
|
inter++
|
|
}
|
|
}
|
|
for k := range b {
|
|
union[k] = true
|
|
}
|
|
return float64(inter) / float64(len(union))
|
|
}
|
|
func typeDepth(typ string, words, sections, listItems, validation, troubleshoot int) float64 {
|
|
tw, ts := 650.0, 5.0
|
|
switch typ {
|
|
case "reference":
|
|
tw, ts = 900, 6
|
|
case "concept":
|
|
tw, ts = 800, 5
|
|
case "decision_guide":
|
|
tw, ts = 800, 5
|
|
case "troubleshooting":
|
|
tw, ts = 750, 6
|
|
}
|
|
w := math.Min(1, float64(words)/tw)
|
|
s := math.Min(1, float64(sections)/ts)
|
|
l := math.Min(1, float64(listItems)/8)
|
|
extra := math.Min(1, float64(validation+troubleshoot)/4)
|
|
if typ == "reference" || typ == "concept" {
|
|
return clamp01(.62*w + .30*s + .08*l)
|
|
}
|
|
return clamp01(.45*w + .25*s + .15*l + .15*extra)
|
|
}
|
|
func looksTechnical(t string) bool {
|
|
if len(t) >= 7 {
|
|
return true
|
|
}
|
|
for _, r := range t {
|
|
if unicode.IsDigit(r) {
|
|
return true
|
|
}
|
|
}
|
|
return strings.ContainsAny(t, "._:-/")
|
|
}
|
|
func unique(in []string) []string {
|
|
m := map[string]bool{}
|
|
out := []string{}
|
|
for _, v := range in {
|
|
if v != "" && !m[v] {
|
|
m[v] = true
|
|
out = append(out, v)
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
func clamp01(v float64) float64 {
|
|
if v < 0 {
|
|
return 0
|
|
}
|
|
if v > 1 {
|
|
return 1
|
|
}
|
|
return v
|
|
}
|