Hardened the proposal modules to skip malformed proposals rather than hard failing the entire run
All checks were successful
ci/woodpecker/tag/release Pipeline was successful

This commit is contained in:
2026-05-17 07:19:24 -05:00
parent a84941d681
commit 9c2d8338d7
17 changed files with 436 additions and 137 deletions

View File

@@ -4,6 +4,9 @@ import (
"context"
"fmt"
"strings"
"gitea.maximumdirect.net/eric/audita/internal/core/schema"
"gitea.maximumdirect.net/eric/audita/internal/framework/proposals"
)
type ConfidenceThresholdValidator struct{}
@@ -62,11 +65,26 @@ type NonEmptyCorrectionValidator struct{}
func (v NonEmptyCorrectionValidator) Name() string { return "non_empty_corrected_text" }
func (v NonEmptyCorrectionValidator) Validate(_ context.Context, req Request) (Result, error) {
segments := make(map[int]string)
if req.WorkingTranscript != nil {
for _, seg := range req.WorkingTranscript.Segments {
segments[seg.ID] = seg.Text
}
}
decisions := make([]Decision, 0, len(req.CandidateProposal))
for _, c := range req.CandidateProposal {
if strings.TrimSpace(c.CorrectedText) == "" {
decisions = append(decisions, rejection(c.ProposalIndex, ReasonEmptyCorrectedText, "corrected_text must not be empty"))
continue
segmentText, ok := segments[c.TargetSegmentID]
if ok {
preview := proposals.PreviewProposalForSegment(
&schema.Segment{ID: c.TargetSegmentID, Text: segmentText},
c.CorrectionProposal,
req.ReplacementPolicy,
)
if preview.Applicable && strings.TrimSpace(preview.CorrectedSegmentText) == "" {
decisions = append(decisions, rejection(c.ProposalIndex, ReasonEmptyCorrectedText, "corrected segment text must not be empty"))
continue
}
}
decisions = append(decisions, approval(c.ProposalIndex))
}

View File

@@ -143,10 +143,7 @@ func (v *LLMBackedValidator) Validate(ctx context.Context, req Request) (Result,
return Result{}, fmt.Errorf("LLM validator %q completion failed: %w", v.name, err)
}
batchDecisions, err := mapLLMResponseToDecisions(batch.Items, response)
if err != nil {
return Result{}, fmt.Errorf("LLM validator %q response invalid: %w", v.name, err)
}
batchDecisions := mapLLMResponseToDecisions(batch.Items, response)
for i := range batchDecisions {
batchDecisions[i].DiagnosticArtifactPath = artifacts.ResponsePayloadPath
}
@@ -259,34 +256,48 @@ func promptBuilderForType(validatorType LLMValidatorType) (LLMPromptBuilder, err
}
}
func mapLLMResponseToDecisions(items []LLMValidationItem, response LLMValidationResponse) ([]Decision, error) {
func mapLLMResponseToDecisions(items []LLMValidationItem, response LLMValidationResponse) []Decision {
expected := make(map[int]LLMValidationItem, len(items))
for _, item := range items {
expected[item.CorrectionIndex] = item
}
if len(response.Validations) == 0 {
return nil, fmt.Errorf("missing validations in structured response")
}
seen := make(map[int]LLMValidationDecision, len(response.Validations))
forcedReject := make(map[int]bool)
for _, d := range response.Validations {
if d.Confidence < 0.0 || d.Confidence > 1.0 {
return nil, fmt.Errorf("confidence for correction_index %d must be between 0.0 and 1.0", d.CorrectionIndex)
}
if _, ok := expected[d.CorrectionIndex]; !ok {
return nil, fmt.Errorf("unknown correction_index %d", d.CorrectionIndex)
continue
}
if _, exists := seen[d.CorrectionIndex]; exists {
return nil, fmt.Errorf("duplicate correction_index %d", d.CorrectionIndex)
forcedReject[d.CorrectionIndex] = true
continue
}
if d.Confidence < 0.0 || d.Confidence > 1.0 {
forcedReject[d.CorrectionIndex] = true
continue
}
seen[d.CorrectionIndex] = d
}
decisions := make([]Decision, 0, len(items))
for _, item := range items {
if forcedReject[item.CorrectionIndex] {
decisions = append(decisions, Decision{
ProposalIndex: item.CorrectionIndex,
Approved: false,
ReasonCode: ReasonValidatorMalformed,
Message: "validator returned malformed decision payload for this proposal index",
})
continue
}
d, ok := seen[item.CorrectionIndex]
if !ok {
return nil, fmt.Errorf("missing correction_index %d", item.CorrectionIndex)
decisions = append(decisions, Decision{
ProposalIndex: item.CorrectionIndex,
Approved: false,
ReasonCode: ReasonValidatorMissing,
Message: "validator did not return a decision for this proposal index",
})
continue
}
reasonCode := ReasonApproved
if !d.Approved {
@@ -299,5 +310,5 @@ func mapLLMResponseToDecisions(items []LLMValidationItem, response LLMValidation
Message: strings.TrimSpace(d.Reason),
})
}
return decisions, nil
return decisions
}

View File

@@ -259,18 +259,24 @@ func TestLLMBackedValidatorMalformedOutputFails(t *testing.T) {
}
}
func TestLLMBackedValidatorMissingDecisionFails(t *testing.T) {
func TestLLMBackedValidatorMissingDecisionSoftRejects(t *testing.T) {
client := &fakeStructuredLLMClient{responses: []LLMValidationResponse{{Validations: []LLMValidationDecision{}}}}
v, _ := NewLLMBackedValidator("spoken_form_plausibility_review", LLMValidatorTypeSpokenFormPlausibility, "test-model")
req := makeReq([]proposals.EnrichedCorrectionProposal{mk(0, "gestures", "Jesters")})
req.LLMClient = client
_, err := v.Validate(context.Background(), req)
if err == nil || !strings.Contains(err.Error(), "response invalid") {
t.Fatalf("expected missing decision error, got %v", err)
res, err := v.Validate(context.Background(), req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(res.Decisions) != 1 || res.Decisions[0].Approved {
t.Fatalf("expected one soft rejection, got %+v", res.Decisions)
}
if res.Decisions[0].ReasonCode != ReasonValidatorMissing {
t.Fatalf("expected %q, got %+v", ReasonValidatorMissing, res.Decisions[0])
}
}
func TestLLMBackedValidatorDuplicateDecisionFails(t *testing.T) {
func TestLLMBackedValidatorDuplicateDecisionSoftRejects(t *testing.T) {
client := &fakeStructuredLLMClient{responses: []LLMValidationResponse{{Validations: []LLMValidationDecision{
{CorrectionIndex: 0, Approved: true, Confidence: 0.9, Reason: "ok"},
{CorrectionIndex: 0, Approved: false, Confidence: 0.9, Reason: "dup"},
@@ -278,20 +284,32 @@ func TestLLMBackedValidatorDuplicateDecisionFails(t *testing.T) {
v, _ := NewLLMBackedValidator("spoken_form_plausibility_review", LLMValidatorTypeSpokenFormPlausibility, "test-model")
req := makeReq([]proposals.EnrichedCorrectionProposal{mk(0, "gestures", "Jesters")})
req.LLMClient = client
_, err := v.Validate(context.Background(), req)
if err == nil || !strings.Contains(err.Error(), "duplicate") {
t.Fatalf("expected duplicate decision error, got %v", err)
res, err := v.Validate(context.Background(), req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(res.Decisions) != 1 || res.Decisions[0].Approved {
t.Fatalf("expected one soft rejection, got %+v", res.Decisions)
}
if res.Decisions[0].ReasonCode != ReasonValidatorMalformed {
t.Fatalf("expected %q, got %+v", ReasonValidatorMalformed, res.Decisions[0])
}
}
func TestLLMBackedValidatorUnknownProposalIndexFails(t *testing.T) {
func TestLLMBackedValidatorUnknownProposalIndexSoftRejectsMissing(t *testing.T) {
client := &fakeStructuredLLMClient{responses: []LLMValidationResponse{{Validations: []LLMValidationDecision{{CorrectionIndex: 99, Approved: true, Confidence: 0.9, Reason: "unknown"}}}}}
v, _ := NewLLMBackedValidator("spoken_form_plausibility_review", LLMValidatorTypeSpokenFormPlausibility, "test-model")
req := makeReq([]proposals.EnrichedCorrectionProposal{mk(0, "gestures", "Jesters")})
req.LLMClient = client
_, err := v.Validate(context.Background(), req)
if err == nil || !strings.Contains(err.Error(), "unknown") {
t.Fatalf("expected unknown index error, got %v", err)
res, err := v.Validate(context.Background(), req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(res.Decisions) != 1 || res.Decisions[0].Approved {
t.Fatalf("expected one soft rejection, got %+v", res.Decisions)
}
if res.Decisions[0].ReasonCode != ReasonValidatorMissing {
t.Fatalf("expected %q, got %+v", ReasonValidatorMissing, res.Decisions[0])
}
}

View File

@@ -18,6 +18,8 @@ const (
ReasonEmptyCorrectedText = "empty_corrected_text"
ReasonNoEffect = "no_effect"
ReasonProtectedGlossaryTerm = "protected_glossary_term"
ReasonValidatorMalformed = "validator_malformed_response"
ReasonValidatorMissing = "validator_missing_decision"
)
// Request is the runtime input shared by deterministic validators.

View File

@@ -90,10 +90,14 @@ func TestOriginalTextPresenceValidator(t *testing.T) {
}
func TestNonEmptyCorrectionValidator(t *testing.T) {
req := Request{CandidateProposal: []proposals.EnrichedCorrectionProposal{
req := Request{
WorkingTranscript: &schema.Transcript{Segments: []schema.Segment{{ID: 1, Text: "hello"}}},
ReplacementPolicy: proposals.ReplacementPolicyRequireUnique,
CandidateProposal: []proposals.EnrichedCorrectionProposal{
mkCandidate(0, 1, "hello", "hi", 0.9),
mkCandidate(1, 1, "hello", " ", 0.9),
}}
},
}
res, err := (NonEmptyCorrectionValidator{}).Validate(context.Background(), req)
if err != nil {
t.Fatalf("Validate error: %v", err)
@@ -154,6 +158,8 @@ func TestStableReasonCodes(t *testing.T) {
ReasonEmptyCorrectedText,
ReasonNoEffect,
ReasonProtectedGlossaryTerm,
ReasonValidatorMalformed,
ReasonValidatorMissing,
}
for _, code := range codes {
if strings.TrimSpace(code) == "" {