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 }