Derive independent glossary context and protection

This commit is contained in:
2026-11-10 17:25:08 +00:00
parent d42e2095d7
commit 89de2a7014
23 changed files with 364 additions and 173 deletions

View File

@@ -136,17 +136,12 @@ func (v NoEffectValidator) Validate(_ context.Context, req Request) (Result, err
return Result{ValidatorName: v.Name(), Decisions: decisions}, nil
}
func ValidateProtectedTerms(req Request, glossaryStage bool) (Result, error) {
vocab := NewProtectedVocabulary(req.Glossary)
func ValidateProtectedTerms(req Request) (Result, error) {
vocab := NewProtectedVocabulary(req.ProtectedTerms)
decisions := make([]Decision, 0, len(req.CandidateProposal))
for _, c := range req.CandidateProposal {
var reason string
if glossaryStage {
reason = vocab.glossaryStageViolationReason(c.OriginalText, c.CorrectedText)
} else {
reason = vocab.violationReason(c.OriginalText, c.CorrectedText)
}
if reason != "" && !glossaryStage && req.ModuleKey == modulecatalog.KeySpokenWord && req.WorkingTranscript != nil {
reason := vocab.violationReason(c.OriginalText, c.CorrectedText)
if reason != "" && req.ModuleKey == modulecatalog.KeySpokenWord && req.WorkingTranscript != nil {
for i := range req.WorkingTranscript.Segments {
segment := &req.WorkingTranscript.Segments[i]
if segment.ID != c.TargetSegmentID {

View File

@@ -2,13 +2,14 @@ package validators
import (
"gitea.maximumdirect.net/eric/audita/v3/internal/core/chunking"
"gitea.maximumdirect.net/eric/audita/v3/internal/core/glossary"
"gitea.maximumdirect.net/eric/audita/v3/internal/core/schema"
)
// validationContext supplies adjacent source segments within the current chunk
// and glossary definitions mentioned in the candidate or that local context.
// These fields travel in each item so batching accounts for their size.
func validationContext(segments []schema.Segment, position int, glossary *schema.Glossary, vocabulary ProtectedVocabulary, corrected string) ([]NeighboringSegment, []schema.GlossaryEntry) {
func validationContext(segments []schema.Segment, position int, selected []glossary.Entry, vocabulary ProtectedVocabulary, corrected string) ([]NeighboringSegment, []glossary.Entry) {
var neighbors []NeighboringSegment
source := segments[position].Text
if position > 0 {
@@ -22,16 +23,16 @@ func validationContext(segments []schema.Segment, position int, glossary *schema
for _, neighbor := range neighbors {
texts = append(texts, neighbor.Text)
}
var entries []schema.GlossaryEntry
if glossary != nil {
mentioned := make(map[int]bool)
var entries []glossary.Entry
if selected != nil {
mentioned := make(map[glossary.Identity]bool)
for _, text := range texts {
for identity := range vocabulary.occurrencesByIdentity(text) {
mentioned[identity] = true
}
}
for i, entry := range glossary.Entries {
if mentioned[i] {
for _, entry := range selected {
if mentioned[glossary.Identity{Collection: entry.Collection, ID: entry.ID}] {
entries = append(entries, entry)
}
}

View File

@@ -1,7 +1,7 @@
package validators
import (
"gitea.maximumdirect.net/eric/audita/v3/internal/core/schema"
"gitea.maximumdirect.net/eric/audita/v3/internal/core/glossary"
"gitea.maximumdirect.net/eric/audita/v3/internal/framework/structuredoutput"
)
@@ -20,15 +20,15 @@ type StructuredLLMClient = structuredoutput.Client
// LLMValidationItem retains the internal proposal index until prompt encoding.
// BuildValidationVariables substitutes a one-based reference for each batch.
type LLMValidationItem struct {
CorrectionIndex int `json:"correction_index"`
SegmentID int `json:"-"`
OriginalText string `json:"original_text"`
CorrectedText string `json:"corrected_text"`
OriginalSegmentText string `json:"original_segment_text"`
CorrectedSegmentText string `json:"corrected_segment_text"`
Categories []string `json:"categories,omitempty"`
NeighboringSegments []NeighboringSegment `json:"neighboring_segments,omitempty"`
GlossaryEntries []schema.GlossaryEntry `json:"glossary_entries,omitempty"`
CorrectionIndex int `json:"correction_index"`
SegmentID int `json:"-"`
OriginalText string `json:"original_text"`
CorrectedText string `json:"corrected_text"`
OriginalSegmentText string `json:"original_segment_text"`
CorrectedSegmentText string `json:"corrected_segment_text"`
Categories []string `json:"categories,omitempty"`
NeighboringSegments []NeighboringSegment `json:"neighboring_segments,omitempty"`
GlossaryEntries []glossary.Entry `json:"glossary_entries,omitempty"`
}
// LLMValidationRequest is the canonical request model for LLM-backed validators.
@@ -38,7 +38,6 @@ type LLMValidationRequest struct {
ModuleKey string `json:"module_key"`
ModuleInstance string `json:"module_instance"`
ReplacementPolicy string `json:"replacement_policy"`
Glossary *schema.Glossary `json:"-"`
Items []LLMValidationItem `json:"corrections"`
}

View File

@@ -9,6 +9,7 @@ import (
"gitea.maximumdirect.net/eric/audita/v3/internal/core/chunking"
"gitea.maximumdirect.net/eric/audita/v3/internal/core/config"
"gitea.maximumdirect.net/eric/audita/v3/internal/core/glossary"
"gitea.maximumdirect.net/eric/audita/v3/internal/core/schema"
"gitea.maximumdirect.net/eric/audita/v3/internal/framework/outcomes"
"gitea.maximumdirect.net/eric/audita/v3/internal/framework/proposals"
@@ -253,7 +254,7 @@ func BuildLLMValidationRequest(validatorName string, validatorType LLMValidatorT
immediate := make([]Decision, 0)
var segments []schema.Segment
positions := make(map[int]int)
vocabulary := NewProtectedVocabulary(req.Glossary)
vocabulary := NewProtectedVocabulary(glossary.Vocabulary(req.Glossary, nil))
if req.WorkingTranscript != nil {
segments = req.WorkingTranscript.Segments
for i, seg := range segments {
@@ -295,7 +296,6 @@ func BuildLLMValidationRequest(validatorName string, validatorType LLMValidatorT
ModuleKey: req.ModuleKey,
ModuleInstance: req.ModuleInstance,
ReplacementPolicy: string(req.ReplacementPolicy),
Glossary: req.Glossary,
Items: items,
}, immediate
}

View File

@@ -6,6 +6,7 @@ import (
"strings"
"gitea.maximumdirect.net/eric/audita/v3/internal/core/config"
"gitea.maximumdirect.net/eric/audita/v3/internal/core/glossary"
"gitea.maximumdirect.net/eric/audita/v3/internal/core/modulecatalog"
"gitea.maximumdirect.net/eric/audita/v3/internal/core/schema"
"gitea.maximumdirect.net/eric/audita/v3/internal/framework/outcomes"
@@ -41,7 +42,8 @@ type Request struct {
ModuleInstance string `json:"module_instance"`
ValidatorOrder *int `json:"validator_order,omitempty"`
ReplacementPolicy proposals.ReplacementPolicy `json:"replacement_policy"`
Glossary *schema.Glossary `json:"-"`
Glossary []glossary.Entry `json:"-"`
ProtectedTerms []glossary.Term `json:"-"`
Config *config.Config `json:"-"`
LLMClient StructuredLLMClient `json:"-"`
DiagnosticsWriter InteractionDiagnosticsWriter `json:"-"`

View File

@@ -5,17 +5,17 @@ import (
"sort"
"strings"
"gitea.maximumdirect.net/eric/audita/v3/internal/core/schema"
"gitea.maximumdirect.net/eric/audita/v3/internal/core/glossary"
)
type protectedTermDef struct {
identity int
canonical string
identities []glossary.Identity
canonical string
}
type protectedOccurrence struct {
text string
identity int
identity glossary.Identity
canonical string
}
@@ -25,47 +25,13 @@ type ProtectedVocabulary struct {
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))
// NewProtectedVocabulary prepares the independently selected terms for matching.
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}
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 {
@@ -75,16 +41,13 @@ func NewProtectedVocabulary(glossary *schema.Glossary) ProtectedVocabulary {
return ProtectedVocabulary{termsByFolded: termsByFolded, pattern: pattern}
}
func addProtectedTerm(terms map[string]protectedTermDef, term string, identity int) {
trimmed := strings.TrimSpace(term)
if trimmed == "" {
return
// AllowsChange checks a proposed segment against the effective protection view.
// Spoken-word cleanup may reduce repeated mentions while preserving each identity.
func (v ProtectedVocabulary) AllowsChange(before, after string, allowRepetitionCleanup bool) bool {
if v.violationReason(before, after) == "" {
return true
}
folded := strings.ToLower(trimmed)
if _, ok := terms[folded]; ok {
return
}
terms[folded] = protectedTermDef{identity: identity, canonical: trimmed}
return allowRepetitionCleanup && v.preservesRepeatedIdentities(before, after)
}
func (v ProtectedVocabulary) violationReason(before, after string) string {
@@ -99,20 +62,8 @@ func (v ProtectedVocabulary) violationReason(before, after string) string {
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)
func (v ProtectedVocabulary) occurrencesByIdentity(text string) map[glossary.Identity][]protectedOccurrence {
byID := make(map[glossary.Identity][]protectedOccurrence)
for _, o := range v.occurrences(text) {
byID[o.identity] = append(byID[o.identity], o)
}
@@ -134,16 +85,14 @@ func (v ProtectedVocabulary) occurrences(text string) []protectedOccurrence {
if !ok {
continue
}
out = append(out, protectedOccurrence{
text: matched,
identity: def.identity,
canonical: def.canonical,
})
for _, identity := range def.identities {
out = append(out, protectedOccurrence{text: matched, identity: identity, canonical: def.canonical})
}
}
return out
}
func validateIdentityPreservation(beforeByID, afterByID map[int][]protectedOccurrence) string {
func validateIdentityPreservation(beforeByID, afterByID map[glossary.Identity][]protectedOccurrence) string {
for identity, beforeItems := range beforeByID {
if len(afterByID[identity]) < len(beforeItems) {
return "proposal may alter protected glossary terminology"
@@ -152,22 +101,7 @@ func validateIdentityPreservation(beforeByID, afterByID map[int][]protectedOccur
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 {
func validateCapitalizationTransitions(beforeByID, afterByID map[glossary.Identity][]protectedOccurrence) string {
for identity, afterItems := range afterByID {
beforeItems := beforeByID[identity]
for i, afterItem := range afterItems {

View File

@@ -0,0 +1,75 @@
package validators
import (
"testing"
"gitea.maximumdirect.net/eric/audita/v3/internal/core/glossary"
"gitea.maximumdirect.net/eric/audita/v3/internal/core/schema"
"gitea.maximumdirect.net/eric/audita/v3/internal/framework/proposals"
)
func TestIndependentProtectionForSelectedGlossary(t *testing.T) {
active := []glossary.Entry{{Collection: "spells", ID: "shield", Name: "Shield"}}
entries := append(append([]glossary.Entry(nil), active...), glossary.Entry{Collection: "items", ID: "shield", Name: "Shield", Aliases: []string{"Aegis"}})
cases := []struct {
name string
terms []glossary.Term
before, after string
approved bool
}{
{"shared spelling exempt", glossary.Vocabulary(entries, active), "Shield", "Barrier", true},
{"other alias protected", glossary.Vocabulary(entries, active), "Aegis", "Armor", false},
{"full protection retained", glossary.Vocabulary(entries, nil), "Shield", "Barrier", false},
{"empty protection", nil, "Aegis", "Armor", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
result, err := ValidateProtectedTerms(Request{ProtectedTerms: tc.terms, CandidateProposal: []proposals.EnrichedCorrectionProposal{{CorrectionProposal: proposals.CorrectionProposal{OriginalText: tc.before, CorrectedText: tc.after}}}})
if err != nil || len(result.Decisions) != 1 || result.Decisions[0].Approved != tc.approved {
t.Fatalf("result=%+v err=%v", result, err)
}
})
}
}
func TestSharedAliasRetainsAllIdentities(t *testing.T) {
entries := []glossary.Entry{
{Collection: "a", ID: "first", Name: "First", Aliases: []string{"Shared"}},
{Collection: "b", ID: "second", Name: "Second", Aliases: []string{"Shared"}},
}
vocabulary := NewProtectedVocabulary(glossary.Vocabulary(entries, nil))
identities := vocabulary.occurrencesByIdentity("Shared")
if len(identities) != 2 || len(identities[glossary.Identity{Collection: "a", ID: "first"}]) != 1 || len(identities[glossary.Identity{Collection: "b", ID: "second"}]) != 1 {
t.Fatalf("identities=%v", identities)
}
}
func TestApplicationChecksCurrentTextWithSelectedProtection(t *testing.T) {
active := []glossary.Entry{{Collection: "spells", ID: "spell", Name: "Shield"}}
protected := append(append([]glossary.Entry(nil), active...), glossary.Entry{Collection: "items", ID: "item", Name: "Shield", Aliases: []string{"Aegis"}})
vocabulary := NewProtectedVocabulary(glossary.Vocabulary(protected, active))
transcript := &schema.Transcript{Segments: []schema.Segment{{ID: 1, Text: "Shield and Aegis"}}}
candidates := []proposals.EnrichedCorrectionProposal{
{CorrectionProposal: proposals.CorrectionProposal{TargetSegmentID: 1, OriginalText: "Shield", CorrectedText: "Barrier", Confidence: 1}, ProposalMetadata: proposals.ProposalMetadata{ProposalIndex: 0}},
{CorrectionProposal: proposals.CorrectionProposal{TargetSegmentID: 1, OriginalText: "egis", CorrectedText: "rmor", Confidence: 1}, ProposalMetadata: proposals.ProposalMetadata{ProposalIndex: 1}},
}
result := proposals.ApplyProposals(transcript, candidates, proposals.ReplacementPolicyRequireUnique, func(before, after string) bool {
return vocabulary.AllowsChange(before, after, false)
})
if result.Transcript.Segments[0].Text != "Barrier and Aegis" || len(result.Applied) != 1 || len(result.Skipped) != 1 || result.Skipped[0].SkipReason != proposals.SkipReasonProtectedTerm {
t.Fatalf("unexpected application: %+v", result)
}
}
func TestValidationContextIncludesEachSharedDefinition(t *testing.T) {
entries := []glossary.Entry{
{Collection: "spells", ID: "shield", Name: "Shield", Category: "spell"},
{Collection: "items", ID: "shield", Name: "Shield", Category: "item"},
{Collection: "items", ID: "sword", Name: "Sword", Category: "item"},
}
segments := []schema.Segment{{ID: 1, Text: "Shield"}}
_, selected := validationContext(segments, 0, entries, NewProtectedVocabulary(glossary.Vocabulary(entries, nil)), "Barrier")
if len(selected) != 2 || selected[0].Category != "spell" || selected[1].ID != "shield" || selected[1].Collection != "items" {
t.Fatalf("selected=%+v", selected)
}
}