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
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
This commit is contained in:
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) == "" {
|
||||
|
||||
Reference in New Issue
Block a user