Align prompts and strengthen validation context
This commit is contained in:
@@ -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
|
||||
|
||||
68
internal/framework/validators/llm_context.go
Normal file
68
internal/framework/validators/llm_context.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
75
internal/framework/validators/llm_context_test.go
Normal file
75
internal/framework/validators/llm_context_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) == ""
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user