Keep match boundaries for exempt glossary names

This commit is contained in:
2026-11-10 20:33:24 +00:00
parent 50940083c8
commit 7710101da0
9 changed files with 94 additions and 22 deletions

View File

@@ -139,17 +139,24 @@ func (v NoEffectValidator) Validate(_ context.Context, req Request) (Result, err
func ValidateProtectedTerms(req Request) (Result, error) {
vocab := NewProtectedVocabulary(req.ProtectedTerms)
decisions := make([]Decision, 0, len(req.CandidateProposal))
glossaryStage := req.ModuleKey == modulecatalog.KeyGlossary
for _, c := range req.CandidateProposal {
reason := vocab.violationReason(c.OriginalText, c.CorrectedText)
if reason != "" && req.ModuleKey == modulecatalog.KeySpokenWord && req.WorkingTranscript != nil {
repetitionCleanup := reason != "" && req.ModuleKey == modulecatalog.KeySpokenWord
if vocab.pattern != nil && (glossaryStage || repetitionCleanup) && req.WorkingTranscript != nil {
for i := range req.WorkingTranscript.Segments {
segment := &req.WorkingTranscript.Segments[i]
if segment.ID != c.TargetSegmentID {
continue
}
preview := proposals.PreviewProposalForSegment(segment, c.CorrectionProposal, req.ReplacementPolicy)
if preview.Applicable && vocab.preservesRepeatedIdentities(segment.Text, preview.CorrectedSegmentText) {
reason = ""
if preview.Applicable {
if glossaryStage {
// A correction span may sit inside a longer exempt name.
reason = vocab.violationReason(segment.Text, preview.CorrectedSegmentText)
} else if vocab.preservesRepeatedIdentities(segment.Text, preview.CorrectedSegmentText) {
reason = ""
}
}
break
}

View File

@@ -30,13 +30,15 @@ func NewProtectedVocabulary(terms []glossary.Term) ProtectedVocabulary {
termsByFolded := make(map[string]protectedTermDef, len(terms))
alternatives := make([]string, 0, len(terms))
for _, term := range terms {
termsByFolded[strings.ToLower(term.Spelling)] = protectedTermDef{identities: term.Identities, canonical: term.Spelling}
if !term.Exempt {
termsByFolded[strings.ToLower(term.Spelling)] = protectedTermDef{identities: term.Identities, canonical: term.Spelling}
}
alternatives = append(alternatives, regexp.QuoteMeta(term.Spelling))
}
sort.SliceStable(alternatives, func(i, j int) bool { return len(alternatives[i]) > len(alternatives[j]) })
if len(alternatives) == 0 {
return ProtectedVocabulary{termsByFolded: termsByFolded}
if len(termsByFolded) == 0 {
return ProtectedVocabulary{}
}
sort.SliceStable(alternatives, func(i, j int) bool { return len(alternatives[i]) > len(alternatives[j]) })
pattern := regexp.MustCompile(`(?i)\b(?:` + strings.Join(alternatives, "|") + `)\b`)
return ProtectedVocabulary{termsByFolded: termsByFolded, pattern: pattern}
}

View File

@@ -61,6 +61,53 @@ func TestApplicationChecksCurrentTextWithSelectedProtection(t *testing.T) {
}
}
func TestExemptGlossarySpellingKeepsLongestMatchBoundary(t *testing.T) {
active := []glossary.Entry{
{ID: "greater-mage", Name: "Greater Mage Armor"},
{ID: "greater-magus", Name: "Greater Magus Armor"},
}
protected := []glossary.Entry{
active[0], active[1],
{ID: "short", Name: "Mage Armor"},
{ID: "long", Name: "Superior Greater Mage Armor"},
}
for _, tc := range []struct {
name, module, before, original, replacement string
exempt, allowed bool
}{
{"full active name", "glossary", "Cast Greater Mage Armor.", "Greater Mage Armor", "Greater Magus Armor", true, true},
{"span within active name", "glossary", "Cast Greater Mage Armor.", "Mage Armor", "Magus Armor", true, true},
{"standalone protected name", "glossary", "Cast Mage Armor.", "Mage Armor", "Magus Armor", true, false},
{"longer protected name", "glossary", "Cast Superior Greater Mage Armor.", "Greater Mage Armor", "Greater Magus Armor", true, false},
{"same name in a grammar pass", "grammar", "Cast Greater Mage Armor.", "Greater Mage Armor", "Greater Magus Armor", false, false},
} {
t.Run(tc.name, func(t *testing.T) {
var exclusions []glossary.Entry
if tc.exempt {
exclusions = active
}
terms := glossary.Vocabulary(protected, exclusions)
transcript := &schema.Transcript{Segments: []schema.Segment{{ID: 1, Text: tc.before}}}
candidate := proposals.EnrichedCorrectionProposal{CorrectionProposal: proposals.CorrectionProposal{
TargetSegmentID: 1, OriginalText: tc.original, CorrectedText: tc.replacement, Confidence: 1,
}}
policy := proposals.ReplacementPolicyRequireUnique
decision, err := ValidateProtectedTerms(Request{ModuleKey: tc.module, WorkingTranscript: transcript, ReplacementPolicy: policy,
ProtectedTerms: terms, CandidateProposal: []proposals.EnrichedCorrectionProposal{candidate}})
if err != nil || len(decision.Decisions) != 1 || decision.Decisions[0].Approved != tc.allowed {
t.Fatalf("decision=%+v err=%v", decision, err)
}
vocabulary := NewProtectedVocabulary(terms)
result := proposals.ApplyProposals(transcript, []proposals.EnrichedCorrectionProposal{candidate}, policy, func(before, after string) bool {
return vocabulary.AllowsChange(before, after, false)
})
if (len(result.Applied) == 1) != tc.allowed {
t.Fatalf("apply-time protection disagrees with validation: %+v", result)
}
})
}
}
func TestValidationContextIncludesEachSharedDefinition(t *testing.T) {
entries := []glossary.Entry{
{Collection: "spells", ID: "shield", Name: "Shield", Category: "spell"},