Align prompts and strengthen validation context

This commit is contained in:
2026-09-25 12:23:10 +00:00
parent 3eb73e70fc
commit 325c4ea7d3
23 changed files with 329 additions and 133 deletions

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"strings"
"gitea.maximumdirect.net/eric/audita/internal/core/modulecatalog"
"gitea.maximumdirect.net/eric/audita/internal/core/schema"
"gitea.maximumdirect.net/eric/audita/internal/framework/proposals"
)
@@ -145,6 +146,19 @@ func ValidateProtectedTerms(req Request, glossaryStage bool) (Result, error) {
} else {
reason = vocab.violationReason(c.OriginalText, c.CorrectedText)
}
if reason != "" && !glossaryStage && req.ModuleKey == modulecatalog.KeySpokenWord && 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 = ""
}
break
}
}
if reason != "" {
decisions = append(decisions, rejection(c.ProposalIndex, ReasonProtectedGlossaryTerm, reason))
continue

View File

@@ -0,0 +1,68 @@
package validators
import (
"gitea.maximumdirect.net/eric/audita/internal/core/chunking"
"gitea.maximumdirect.net/eric/audita/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(req Request, segmentID int, corrected string) ([]schema.Segment, []schema.GlossaryEntry) {
var neighbors []schema.Segment
var source string
if req.WorkingTranscript != nil {
segments := req.WorkingTranscript.Segments
for i, segment := range segments {
if segment.ID != segmentID {
continue
}
source = segment.Text
if i > 0 {
neighbors = append(neighbors, segments[i-1])
}
if i+1 < len(segments) {
neighbors = append(neighbors, segments[i+1])
}
break
}
}
texts := []string{source, corrected}
for _, neighbor := range neighbors {
texts = append(texts, neighbor.Text)
}
var entries []schema.GlossaryEntry
if req.Glossary != nil {
vocabulary := NewProtectedVocabulary(req.Glossary)
mentioned := make(map[int]bool)
for _, text := range texts {
for identity := range vocabulary.occurrencesByIdentity(text) {
mentioned[identity] = true
}
}
for i, entry := range req.Glossary.Entries {
if mentioned[i] {
entries = append(entries, entry)
}
}
}
return neighbors, entries
}
// fitValidationContext drops optional context before treating required candidate
// text as oversized. Glossary definitions take priority over adjacent segments.
func fitValidationContext(item LLMValidationItem, budget int, estimator chunking.TokenEstimator) LLMValidationItem {
for {
tokens, err := estimateBatchTokens(estimator, []LLMValidationItem{item})
if err != nil || tokens <= budget {
return item
}
if len(item.NeighboringSegments) > 0 {
item.NeighboringSegments = item.NeighboringSegments[:len(item.NeighboringSegments)-1]
} else if len(item.GlossaryEntries) > 0 {
item.GlossaryEntries = item.GlossaryEntries[:len(item.GlossaryEntries)-1]
} else {
return item
}
}
}

View File

@@ -0,0 +1,75 @@
package validators
import (
"encoding/json"
"strings"
"testing"
"gitea.maximumdirect.net/eric/audita/internal/core/chunking"
"gitea.maximumdirect.net/eric/audita/internal/core/schema"
"gitea.maximumdirect.net/eric/audita/internal/framework/proposals"
)
func TestValidationContextCarriesAdjacentEvidenceAndRelevantDefinitions(t *testing.T) {
req := makeReq([]proposals.EnrichedCorrectionProposal{mk(7, "gestures", "Jesters")})
req.WorkingTranscript = &schema.Transcript{Segments: []schema.Segment{
{ID: 1, Text: "The performers arrived."},
{ID: 2, Text: "Those gestures are back."},
{ID: 3, Text: "Lyra recognized the troupe."},
{ID: 4, Text: "Elsewhere."},
}}
req.CandidateProposal[0].TargetSegmentID = 2
req.Glossary = &schema.Glossary{Entries: []schema.GlossaryEntry{
{Name: "Jesters", Summary: "A traveling troupe"},
{Name: "Lyra", Summary: "The host"},
{Name: "Unrelated", Summary: "Not mentioned"},
}}
payload, immediate := BuildLLMValidationRequest("spoken_form_plausibility", LLMValidatorTypeSpokenFormPlausibility, req)
if len(immediate) != 0 || len(payload.Items) != 1 {
t.Fatalf("payload: %+v immediate: %+v", payload, immediate)
}
item := payload.Items[0]
if len(item.NeighboringSegments) != 2 || item.NeighboringSegments[0].ID != 1 || item.NeighboringSegments[1].ID != 3 || len(item.GlossaryEntries) != 2 || item.GlossaryEntries[0].Name != "Jesters" || item.GlossaryEntries[1].Name != "Lyra" {
t.Fatalf("context: %+v", item)
}
variables, err := BuildValidationVariables(payload.Items, "")
if err != nil {
t.Fatal(err)
}
var rendered []LLMValidationItem
if err := json.Unmarshal([]byte(variables["PayloadJSON"]), &rendered); err != nil {
t.Fatal(err)
}
if len(rendered[0].NeighboringSegments) != 2 || rendered[0].GlossaryEntries[0].Summary != "A traveling troupe" {
t.Fatalf("rendered context: %+v", rendered)
}
// The context-free endpoint must not reach beyond the current chunk.
req.WorkingTranscript.Segments = req.WorkingTranscript.Segments[1:2]
req.Glossary = nil
isolated, _ := BuildLLMValidationRequest("editorial_review", LLMValidatorTypeEditorialReview, req)
if len(isolated.Items[0].NeighboringSegments) != 0 || len(isolated.Items[0].GlossaryEntries) != 0 {
t.Fatalf("unexpected external context: %+v", isolated)
}
}
func TestValidationContextFitsBudgetWithoutDiscardingCandidate(t *testing.T) {
estimator := chunking.NewSimpleTokenEstimator()
base := LLMValidationItem{CorrectionIndex: 7, SegmentID: 1, OriginalText: "teh", CorrectedText: "the", OriginalSegmentText: "teh cat", CorrectedSegmentText: "the cat"}
required, err := estimateBatchTokens(estimator, []LLMValidationItem{base})
if err != nil {
t.Fatal(err)
}
enriched := base
enriched.NeighboringSegments = []schema.Segment{{ID: 2, Text: strings.Repeat("long context ", 300)}}
enriched.GlossaryEntries = []schema.GlossaryEntry{{Name: "cat", Summary: strings.Repeat("long definition ", 300)}}
fitted := fitValidationContext(enriched, required, estimator)
tokens, err := estimateBatchTokens(estimator, []LLMValidationItem{fitted})
if err != nil || tokens > required || fitted.OriginalSegmentText != base.OriginalSegmentText || fitted.CorrectedSegmentText != base.CorrectedSegmentText {
t.Fatalf("fitted: %+v tokens=%d err=%v", fitted, tokens, err)
}
full, _ := estimateBatchTokens(estimator, []LLMValidationItem{enriched})
batches, err := ChunkLLMValidationItems([]LLMValidationItem{enriched, enriched}, full, estimator)
if err != nil || len(batches) != 2 {
t.Fatalf("context must count toward batch budget: %+v %v", batches, err)
}
}

View File

@@ -11,8 +11,6 @@ const (
LLMValidatorTypeSpokenFormPlausibility LLMValidatorType = "spoken_form_plausibility"
LLMValidatorTypeMeaningReversal LLMValidatorType = "meaning_reversal"
LLMValidatorTypeEditorialReview LLMValidatorType = "editorial_review"
LLMValidatorTypeGrammarReview LLMValidatorType = "grammar_review"
LLMValidatorTypeSpokenWordReview LLMValidatorType = "spoken_word_review"
)
type StructuredCompletionRequest = structuredoutput.Request
@@ -21,13 +19,15 @@ type StructuredLLMClient = structuredoutput.Client
// LLMValidationItem is one candidate correction payload passed to LLM validators.
type LLMValidationItem struct {
CorrectionIndex int `json:"correction_index"`
SegmentID int `json:"id"`
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"`
CorrectionIndex int `json:"correction_index"`
SegmentID int `json:"id"`
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 []schema.Segment `json:"neighboring_segments,omitempty"`
GlossaryEntries []schema.GlossaryEntry `json:"glossary_entries,omitempty"`
}
// LLMValidationRequest is the canonical request model for LLM-backed validators.

View File

@@ -75,6 +75,9 @@ func (v *LLMBackedValidator) Validate(ctx context.Context, req Request) (Result,
maxTokens = req.Config.Validation.MaxPromptTokens
}
for i := range validationReq.Items {
validationReq.Items[i] = fitValidationContext(validationReq.Items[i], maxTokens, v.estimator)
}
oversized := oversizedValidationDecisions(validationReq.Items, maxTokens, v.estimator)
operations := make([]outcomes.Operation, 0)
sectionIndex := 0
@@ -232,10 +235,6 @@ func validatorPromptMetadata(validatorType LLMValidatorType) prompts.Metadata {
return prompts.MustLookupMetadata(prompts.PromptIDValidatorMeaningReversalReview)
case LLMValidatorTypeEditorialReview:
return prompts.MustLookupMetadata(prompts.PromptIDValidatorEditorialReview)
case LLMValidatorTypeGrammarReview:
return prompts.MustLookupMetadata(prompts.PromptIDValidatorGrammarReview)
case LLMValidatorTypeSpokenWordReview:
return prompts.MustLookupMetadata(prompts.PromptIDValidatorSpokenWordReview)
default:
return prompts.Metadata{}
}
@@ -272,6 +271,7 @@ func BuildLLMValidationRequest(validatorName string, validatorType LLMValidatorT
continue
}
neighbors, glossaryEntries := validationContext(req, seg.ID, preview.CorrectedSegmentText)
items = append(items, LLMValidationItem{
CorrectionIndex: p.ProposalIndex,
SegmentID: p.TargetSegmentID,
@@ -280,6 +280,8 @@ func BuildLLMValidationRequest(validatorName string, validatorType LLMValidatorT
OriginalSegmentText: seg.Text,
CorrectedSegmentText: preview.CorrectedSegmentText,
Categories: append([]string(nil), seg.Categories...),
NeighboringSegments: neighbors,
GlossaryEntries: glossaryEntries,
})
}

View File

@@ -373,8 +373,6 @@ func TestEveryValidationTypeSelectsEmbeddedPromptAndVariables(t *testing.T) {
{LLMValidatorTypeSpokenFormPlausibility, prompts.PromptIDValidatorSpokenFormPlausibility},
{LLMValidatorTypeMeaningReversal, prompts.PromptIDValidatorMeaningReversalReview},
{LLMValidatorTypeEditorialReview, prompts.PromptIDValidatorEditorialReview},
{LLMValidatorTypeGrammarReview, prompts.PromptIDValidatorGrammarReview},
{LLMValidatorTypeSpokenWordReview, prompts.PromptIDValidatorSpokenWordReview},
}
for _, tc := range cases {
t.Run(string(tc.kind), func(t *testing.T) {

View File

@@ -186,3 +186,22 @@ func validateCapitalizationTransitions(beforeByID, afterByID map[int][]protected
}
return ""
}
// preservesRepeatedIdentities permits a spoken-word proposal to reduce repeated
// mentions, but not remove an identity or replace its surviving spelling.
// Editorial and meaning validators still decide whether the cleanup is warranted.
func (v ProtectedVocabulary) preservesRepeatedIdentities(before, after string) bool {
original := v.occurrencesByIdentity(before)
result := v.occurrencesByIdentity(after)
reduced := false
for identity, occurrences := range original {
remaining := result[identity]
if len(remaining) == 0 {
return false
}
if len(remaining) < len(occurrences) {
reduced = true
}
}
return reduced && validateCapitalizationTransitions(original, result) == ""
}

View File

@@ -87,3 +87,29 @@ func TestValidateProtectedTermsGlossaryStageAllowsProtectedTermSwap(t *testing.T
t.Fatalf("expected approval for glossary-stage protected term correction, got %+v", res.Decisions)
}
}
func TestProtectedNameRepetitionRequiresSurvivingIdentityAndSpokenWordScope(t *testing.T) {
for _, tc := range []struct {
name, module, before, original, replacement string
allowed bool
}{
{"whole repetition", "spoken_word", "Jesters Jesters arrived", "Jesters Jesters", "Jesters", true},
{"span deletion", "spoken_word", "Jesters, Jesters arrived", "Jesters, ", "", true},
{"last reference", "spoken_word", "Jesters arrived", "Jesters ", "", false},
{"different identity", "spoken_word", "Jesters Jesters arrived", "Jesters Jesters", "Lyra", false},
{"spelling degraded", "spoken_word", "Jesters Jesters arrived", "Jesters Jesters", "jestERS", false},
{"grammar unchanged", "grammar", "Jesters Jesters arrived", "Jesters Jesters", "Jesters", false},
} {
t.Run(tc.name, func(t *testing.T) {
req := Request{ModuleKey: tc.module, ReplacementPolicy: proposals.ReplacementPolicyRequireUnique,
WorkingTranscript: &schema.Transcript{Segments: []schema.Segment{{ID: 1, Text: tc.before}}},
Glossary: &schema.Glossary{Entries: []schema.GlossaryEntry{{Name: "Jesters"}, {Name: "Lyra"}}},
CandidateProposal: []proposals.EnrichedCorrectionProposal{{CorrectionProposal: proposals.CorrectionProposal{TargetSegmentID: 1, OriginalText: tc.original, CorrectedText: tc.replacement, Confidence: 1}}},
}
result, err := ValidateProtectedTerms(req, false)
if err != nil || len(result.Decisions) != 1 || result.Decisions[0].Approved != tc.allowed {
t.Fatalf("result=%+v err=%v", result, err)
}
})
}
}