Complete Phase 13 glossary module
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/audita/internal/core/config"
|
||||
"gitea.maximumdirect.net/eric/audita/internal/core/schema"
|
||||
"gitea.maximumdirect.net/eric/audita/internal/framework/contracts"
|
||||
glossarymodule "gitea.maximumdirect.net/eric/audita/internal/modules/glossary"
|
||||
grammarmodule "gitea.maximumdirect.net/eric/audita/internal/modules/grammar"
|
||||
)
|
||||
|
||||
@@ -70,6 +71,7 @@ func NewFactory(deps Dependencies) *Factory {
|
||||
deps: deps,
|
||||
constructors: make(map[string]Constructor, len(knownModuleKeys)),
|
||||
}
|
||||
_ = factory.RegisterConstructor(ModuleKeyGlossary, constructGlossaryModule)
|
||||
_ = factory.RegisterConstructor(ModuleKeyGrammar, constructGrammarModule)
|
||||
return factory
|
||||
}
|
||||
@@ -96,6 +98,12 @@ func constructGrammarModule(ctx context.Context, req ConstructRequest) (contract
|
||||
return grammarmodule.New()
|
||||
}
|
||||
|
||||
func constructGlossaryModule(ctx context.Context, req ConstructRequest) (contracts.TranscriptModule, error) {
|
||||
_ = ctx
|
||||
_ = req
|
||||
return glossarymodule.New()
|
||||
}
|
||||
|
||||
// ModuleForSpec resolves one configured run spec into a module instance.
|
||||
func (f *Factory) ModuleForSpec(spec contracts.ModuleRunSpec) (contracts.TranscriptModule, error) {
|
||||
if f == nil {
|
||||
|
||||
@@ -66,7 +66,7 @@ func TestUnsupportedUnknownModuleKeyFailsCleanly(t *testing.T) {
|
||||
|
||||
func TestRecognizedButUnimplementedModuleKeyFailsCleanly(t *testing.T) {
|
||||
factory := NewFactory(Dependencies{})
|
||||
for _, key := range []string{ModuleKeyGlossary, ModuleKeyHomophones, ModuleKeySpokenWord} {
|
||||
for _, key := range []string{ModuleKeyHomophones, ModuleKeySpokenWord} {
|
||||
t.Run(key, func(t *testing.T) {
|
||||
_, err := factory.ModuleForSpec(contracts.ModuleRunSpec{ModuleKey: key, InstanceName: key})
|
||||
if err == nil {
|
||||
@@ -95,6 +95,17 @@ func TestGrammarIsRegisteredAndConstructibleByDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlossaryIsRegisteredAndConstructibleByDefault(t *testing.T) {
|
||||
factory := NewFactory(Dependencies{})
|
||||
module, err := factory.ModuleForSpec(contracts.ModuleRunSpec{ModuleKey: ModuleKeyGlossary, InstanceName: ModuleKeyGlossary})
|
||||
if err != nil {
|
||||
t.Fatalf("ModuleForSpec error: %v", err)
|
||||
}
|
||||
if module.Key() != ModuleKeyGlossary {
|
||||
t.Fatalf("expected glossary module key, got %q", module.Key())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterConstructorAndConstruct(t *testing.T) {
|
||||
factory := NewFactory(Dependencies{})
|
||||
if err := factory.RegisterConstructor(ModuleKeyGlossary, func(ctx context.Context, req ConstructRequest) (contracts.TranscriptModule, error) {
|
||||
|
||||
@@ -686,3 +686,37 @@ func TestRunnerGrammarModuleUsesConfidenceThreshold(t *testing.T) {
|
||||
t.Fatalf("expected low confidence reason, got %+v", out.ModuleResults[0].ValidatorRejected[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerGlossaryModuleUsesConfidenceThreshold(t *testing.T) {
|
||||
client := &fakeProposalStructuredClient{
|
||||
responses: []proposal_generation.StructuredCorrectionSet{
|
||||
{
|
||||
Corrections: []proposal_generation.StructuredCorrectionProposal{
|
||||
{TargetSegmentID: 1, OriginalText: "gestures", CorrectedText: "Jesters", Confidence: 0.5},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
cfg := config.Default()
|
||||
cfg.Thresholds.Glossary = 0.9
|
||||
factory := modules.NewFactory(modules.Dependencies{})
|
||||
out, err := New(factory).Run(context.Background(), RunInput{
|
||||
Config: &cfg,
|
||||
Transcript: &schema.Transcript{Segments: []schema.Segment{{ID: 1, Text: "There were gestures"}}},
|
||||
Glossary: &schema.Glossary{Entries: []schema.GlossaryEntry{{Name: "Jesters", Category: "faction", Summary: "Faction"}}},
|
||||
ModuleSpecs: []contracts.ModuleRunSpec{{ModuleKey: "glossary", InstanceName: "glossary"}},
|
||||
ProposalLLMClient: client,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run error: %v", err)
|
||||
}
|
||||
if out.FinalTranscript.Segments[0].Text != "There were gestures" {
|
||||
t.Fatalf("expected no changes due to glossary confidence threshold, got %q", out.FinalTranscript.Segments[0].Text)
|
||||
}
|
||||
if len(out.ModuleResults) != 1 || len(out.ModuleResults[0].ValidatorRejected) == 0 {
|
||||
t.Fatalf("expected validator rejection, got %+v", out.ModuleResults)
|
||||
}
|
||||
if out.ModuleResults[0].ValidatorRejected[0].ReasonCode != validators.ReasonLowConfidence {
|
||||
t.Fatalf("expected low confidence reason, got %+v", out.ModuleResults[0].ValidatorRejected[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
188
internal/framework/validators/protected_terms.go
Normal file
188
internal/framework/validators/protected_terms.go
Normal 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 ""
|
||||
}
|
||||
86
internal/framework/validators/protected_terms_test.go
Normal file
86
internal/framework/validators/protected_terms_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user