Complete Phase 13 glossary module

This commit is contained in:
2026-05-12 11:20:02 +00:00
parent fc3a7b7a67
commit 543a7ff8ef
14 changed files with 1049 additions and 97 deletions

View File

@@ -4,8 +4,6 @@ import (
"context"
"fmt"
"strings"
"gitea.maximumdirect.net/eric/audita/internal/framework/proposals"
)
type ConfidenceThresholdValidator struct{}
@@ -110,11 +108,12 @@ func (v ProtectedGlossaryTermValidator) Validate(_ context.Context, req Request)
return Result{ValidatorName: v.Name(), Decisions: decisions}, nil
}
terms := glossaryTerms(req)
vocab := NewProtectedVocabulary(req.Glossary)
decisions := make([]Decision, 0, len(req.CandidateProposal))
for _, c := range req.CandidateProposal {
if altersProtectedTerm(c.CorrectionProposal, terms) {
decisions = append(decisions, rejection(c.ProposalIndex, ReasonProtectedGlossaryTerm, "proposal may alter protected glossary terminology"))
reason := vocab.violationReason(c.OriginalText, c.CorrectedText)
if reason != "" {
decisions = append(decisions, rejection(c.ProposalIndex, ReasonProtectedGlossaryTerm, reason))
continue
}
decisions = append(decisions, approval(c.ProposalIndex))
@@ -125,37 +124,25 @@ func (v ProtectedGlossaryTermValidator) Validate(_ context.Context, req Request)
return Result{ValidatorName: v.Name(), Decisions: decisions}, nil
}
func glossaryTerms(req Request) []string {
if req.Glossary == nil {
return nil
}
out := make([]string, 0)
for _, e := range req.Glossary.Entries {
if t := strings.TrimSpace(strings.ToLower(e.Name)); t != "" {
out = append(out, t)
}
for _, a := range e.Aliases {
if t := strings.TrimSpace(strings.ToLower(a)); t != "" {
out = append(out, t)
}
}
if t := strings.TrimSpace(strings.ToLower(e.Plural)); t != "" {
out = append(out, t)
}
}
return out
type GlossaryStageProtectedGlossaryTermValidator struct{}
func (v GlossaryStageProtectedGlossaryTermValidator) Name() string {
return "glossary_stage_protected_glossary_terms"
}
func altersProtectedTerm(p proposals.CorrectionProposal, terms []string) bool {
if len(terms) == 0 {
return false
}
orig := strings.ToLower(p.OriginalText)
corr := strings.ToLower(p.CorrectedText)
for _, t := range terms {
if strings.Contains(orig, t) && !strings.Contains(corr, t) {
return true
func (v GlossaryStageProtectedGlossaryTermValidator) Validate(_ context.Context, req Request) (Result, error) {
vocab := NewProtectedVocabulary(req.Glossary)
decisions := make([]Decision, 0, len(req.CandidateProposal))
for _, c := range req.CandidateProposal {
reason := vocab.glossaryStageViolationReason(c.OriginalText, c.CorrectedText)
if reason != "" {
decisions = append(decisions, rejection(c.ProposalIndex, ReasonProtectedGlossaryTerm, reason))
continue
}
decisions = append(decisions, approval(c.ProposalIndex))
}
return false
if err := EnforceDecisionCardinality(req.CandidateProposal, decisions); err != nil {
return Result{}, err
}
return Result{ValidatorName: v.Name(), Decisions: decisions}, nil
}

View File

@@ -0,0 +1,188 @@
package validators
import (
"regexp"
"sort"
"strings"
"gitea.maximumdirect.net/eric/audita/internal/core/schema"
)
type protectedTermDef struct {
identity int
canonical string
}
type protectedOccurrence struct {
text string
identity int
canonical string
}
// ProtectedVocabulary is a deterministic glossary-derived protected term set.
type ProtectedVocabulary struct {
termsByFolded map[string]protectedTermDef
pattern *regexp.Regexp
}
// ExtractProtectedTerms returns a stable, de-duplicated list of protected terms
// derived from glossary names, aliases, synthetic plural forms, and explicit
// plural fields.
func ExtractProtectedTerms(glossary *schema.Glossary) []string {
vocab := NewProtectedVocabulary(glossary)
out := make([]string, 0, len(vocab.termsByFolded))
for _, def := range vocab.termsByFolded {
out = append(out, def.canonical)
}
sort.SliceStable(out, func(i, j int) bool {
li := strings.ToLower(out[i])
lj := strings.ToLower(out[j])
if li == lj {
return out[i] < out[j]
}
return li < lj
})
return out
}
// NewProtectedVocabulary builds a deterministic protected vocabulary from a glossary.
func NewProtectedVocabulary(glossary *schema.Glossary) ProtectedVocabulary {
termsByFolded := make(map[string]protectedTermDef)
if glossary != nil {
for identity, entry := range glossary.Entries {
entryTerms := append([]string{entry.Name}, entry.Aliases...)
for _, term := range entryTerms {
trimmed := strings.TrimSpace(term)
if trimmed == "" {
continue
}
addProtectedTerm(termsByFolded, trimmed, identity)
addProtectedTerm(termsByFolded, trimmed+"s", identity)
}
addProtectedTerm(termsByFolded, entry.Plural, identity)
}
}
alternatives := make([]string, 0, len(termsByFolded))
for _, def := range termsByFolded {
alternatives = append(alternatives, regexp.QuoteMeta(def.canonical))
}
sort.SliceStable(alternatives, func(i, j int) bool { return len(alternatives[i]) > len(alternatives[j]) })
if len(alternatives) == 0 {
return ProtectedVocabulary{termsByFolded: termsByFolded}
}
pattern := regexp.MustCompile(`(?i)\b(?:` + strings.Join(alternatives, "|") + `)\b`)
return ProtectedVocabulary{termsByFolded: termsByFolded, pattern: pattern}
}
func addProtectedTerm(terms map[string]protectedTermDef, term string, identity int) {
trimmed := strings.TrimSpace(term)
if trimmed == "" {
return
}
folded := strings.ToLower(trimmed)
if _, ok := terms[folded]; ok {
return
}
terms[folded] = protectedTermDef{identity: identity, canonical: trimmed}
}
func (v ProtectedVocabulary) violationReason(before, after string) string {
beforeByID := v.occurrencesByIdentity(before)
afterByID := v.occurrencesByIdentity(after)
if reason := validateIdentityPreservation(beforeByID, afterByID); reason != "" {
return reason
}
if reason := validateCapitalizationTransitions(beforeByID, afterByID); reason != "" {
return reason
}
return ""
}
func (v ProtectedVocabulary) glossaryStageViolationReason(before, after string) string {
beforeByID := v.occurrencesByIdentity(before)
afterByID := v.occurrencesByIdentity(after)
if reason := validateGlossaryStageIdentityPreservation(beforeByID, afterByID); reason != "" {
return reason
}
if reason := validateCapitalizationTransitions(beforeByID, afterByID); reason != "" {
return reason
}
return ""
}
func (v ProtectedVocabulary) occurrencesByIdentity(text string) map[int][]protectedOccurrence {
byID := make(map[int][]protectedOccurrence)
for _, o := range v.occurrences(text) {
byID[o.identity] = append(byID[o.identity], o)
}
return byID
}
func (v ProtectedVocabulary) occurrences(text string) []protectedOccurrence {
if v.pattern == nil {
return nil
}
matches := v.pattern.FindAllStringIndex(text, -1)
if len(matches) == 0 {
return nil
}
out := make([]protectedOccurrence, 0, len(matches))
for _, idx := range matches {
matched := text[idx[0]:idx[1]]
def, ok := v.termsByFolded[strings.ToLower(matched)]
if !ok {
continue
}
out = append(out, protectedOccurrence{
text: matched,
identity: def.identity,
canonical: def.canonical,
})
}
return out
}
func validateIdentityPreservation(beforeByID, afterByID map[int][]protectedOccurrence) string {
for identity, beforeItems := range beforeByID {
if len(afterByID[identity]) < len(beforeItems) {
return "proposal may alter protected glossary terminology"
}
}
return ""
}
func validateGlossaryStageIdentityPreservation(beforeByID, afterByID map[int][]protectedOccurrence) string {
beforeTotal := 0
for _, items := range beforeByID {
beforeTotal += len(items)
}
afterTotal := 0
for _, items := range afterByID {
afterTotal += len(items)
}
if afterTotal < beforeTotal {
return "proposal may alter protected glossary terminology"
}
return ""
}
func validateCapitalizationTransitions(beforeByID, afterByID map[int][]protectedOccurrence) string {
for identity, afterItems := range afterByID {
beforeItems := beforeByID[identity]
for i, afterItem := range afterItems {
if i >= len(beforeItems) {
continue
}
beforeItem := beforeItems[i]
if afterItem.text == beforeItem.text {
continue
}
if afterItem.text == afterItem.canonical {
continue
}
return "proposal may alter protected glossary terminology"
}
}
return ""
}

View File

@@ -0,0 +1,86 @@
package validators
import (
"context"
"reflect"
"testing"
"gitea.maximumdirect.net/eric/audita/internal/core/schema"
"gitea.maximumdirect.net/eric/audita/internal/framework/proposals"
)
func TestExtractProtectedTermsStableDeduplicatedWithPlurals(t *testing.T) {
glossary := &schema.Glossary{
Entries: []schema.GlossaryEntry{
{Name: "Jesters", Aliases: []string{"Jester", " "}, Plural: "Jesters", Category: "faction", Summary: "Faction"},
{Name: "Hrank", Aliases: []string{"hrank", "Hrank"}, Plural: "Hranks", Category: "pc", Summary: "Character"},
{Name: "Lyra", Aliases: []string{}, Plural: "", Category: "npc", Summary: "NPC"},
},
}
got := ExtractProtectedTerms(glossary)
want := []string{"Hrank", "Hranks", "Jester", "Jesters", "Jesterss", "Lyra", "Lyras"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("unexpected protected terms\n got: %v\nwant: %v", got, want)
}
}
func TestExtractProtectedTermsEmptyGlossary(t *testing.T) {
if terms := ExtractProtectedTerms(&schema.Glossary{}); len(terms) != 0 {
t.Fatalf("expected no terms, got %v", terms)
}
}
func TestProtectedGlossaryTermValidatorAppliesToNonGlossaryModule(t *testing.T) {
req := Request{
ModuleKey: "grammar",
Glossary: &schema.Glossary{
Entries: []schema.GlossaryEntry{{Name: "Jesters", Category: "faction", Summary: "Faction"}},
},
CandidateProposal: []proposals.EnrichedCorrectionProposal{
{
CorrectionProposal: proposals.CorrectionProposal{
TargetSegmentID: 1,
OriginalText: "Jesters",
CorrectedText: "Gestures",
Confidence: 0.9,
},
ProposalMetadata: proposals.ProposalMetadata{ProposalIndex: 0},
},
},
}
res, err := (ProtectedGlossaryTermValidator{}).Validate(context.Background(), req)
if err != nil {
t.Fatalf("validator error: %v", err)
}
if len(res.Decisions) != 1 || res.Decisions[0].Approved {
t.Fatalf("expected protected-term rejection, got %+v", res.Decisions)
}
}
func TestGlossaryStageProtectedGlossaryTermValidatorAllowsProtectedTermSwap(t *testing.T) {
req := Request{
ModuleKey: "glossary",
Glossary: &schema.Glossary{
Entries: []schema.GlossaryEntry{{Name: "Jesters", Category: "faction", Summary: "Faction"}},
},
CandidateProposal: []proposals.EnrichedCorrectionProposal{
{
CorrectionProposal: proposals.CorrectionProposal{
TargetSegmentID: 1,
OriginalText: "gestures",
CorrectedText: "Jesters",
Confidence: 0.9,
},
ProposalMetadata: proposals.ProposalMetadata{ProposalIndex: 0},
},
},
}
res, err := (GlossaryStageProtectedGlossaryTermValidator{}).Validate(context.Background(), req)
if err != nil {
t.Fatalf("validator error: %v", err)
}
if len(res.Decisions) != 1 || !res.Decisions[0].Approved {
t.Fatalf("expected approval for glossary-stage protected term correction, got %+v", res.Decisions)
}
}