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

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
}