Complete Phase 10 LLM validators
This commit is contained in:
@@ -3,11 +3,13 @@ package runner
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/audita/internal/core/config"
|
||||
"gitea.maximumdirect.net/eric/audita/internal/core/schema"
|
||||
"gitea.maximumdirect.net/eric/audita/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/audita/internal/framework/llm"
|
||||
"gitea.maximumdirect.net/eric/audita/internal/framework/proposals"
|
||||
"gitea.maximumdirect.net/eric/audita/internal/framework/validators"
|
||||
)
|
||||
@@ -27,6 +29,10 @@ type Runner struct {
|
||||
factory ModuleFactory
|
||||
}
|
||||
|
||||
type ValidationScheduler interface {
|
||||
Run(ctx context.Context, fn func(context.Context) error) error
|
||||
}
|
||||
|
||||
// ModuleResult captures deterministic per-module execution output.
|
||||
type ModuleResult struct {
|
||||
ModuleKey string `json:"module_key"`
|
||||
@@ -44,11 +50,12 @@ type ModuleResult struct {
|
||||
}
|
||||
|
||||
type ValidatorDecisionRecord struct {
|
||||
ValidatorName string `json:"validator_name"`
|
||||
ProposalIndex int `json:"proposal_index"`
|
||||
Approved bool `json:"approved"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
Message string `json:"message"`
|
||||
ValidatorName string `json:"validator_name"`
|
||||
ProposalIndex int `json:"proposal_index"`
|
||||
Approved bool `json:"approved"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
Message string `json:"message"`
|
||||
DiagnosticArtifactPath string `json:"diagnostic_artifact_path,omitempty"`
|
||||
}
|
||||
|
||||
type ValidatorRejectedChange struct {
|
||||
@@ -65,10 +72,13 @@ type ValidatorRejectedChange struct {
|
||||
|
||||
// RunInput is the deterministic runner input.
|
||||
type RunInput struct {
|
||||
Config *config.Config
|
||||
Transcript *schema.Transcript
|
||||
Glossary *schema.Glossary
|
||||
ModuleSpecs []contracts.ModuleRunSpec
|
||||
Config *config.Config
|
||||
Transcript *schema.Transcript
|
||||
Glossary *schema.Glossary
|
||||
ModuleSpecs []contracts.ModuleRunSpec
|
||||
ValidationLLMClient contracts.StructuredLLMClient
|
||||
ValidationLLMScheduler ValidationScheduler
|
||||
ValidationDiagnosticsDir string
|
||||
}
|
||||
|
||||
// RunOutput is the deterministic runner output.
|
||||
@@ -148,6 +158,16 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (RunOutput, error) {
|
||||
validatorRejected := make([]ValidatorRejectedChange, 0)
|
||||
eligible := enriched
|
||||
for _, validator := range module.Validators() {
|
||||
var diagnosticsWriter validators.InteractionDiagnosticsWriter
|
||||
if input.ValidationDiagnosticsDir != "" {
|
||||
diagnosticsWriter = &llmDiagnosticsWriterAdapter{
|
||||
writer: llm.NewDiagnosticsWriter(
|
||||
filepath.Join(input.ValidationDiagnosticsDir, spec.InstanceName),
|
||||
validatorSecrets(input.Config),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
vResult, vErr := validator.Validate(ctx, contracts.ValidationRequest{
|
||||
WorkingTranscript: working,
|
||||
CandidateProposal: eligible,
|
||||
@@ -156,6 +176,9 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (RunOutput, error) {
|
||||
ReplacementPolicy: policy,
|
||||
Glossary: input.Glossary,
|
||||
Config: input.Config,
|
||||
LLMClient: validationLLMClientAdapter{client: input.ValidationLLMClient},
|
||||
Scheduler: input.ValidationLLMScheduler,
|
||||
DiagnosticsWriter: diagnosticsWriter,
|
||||
})
|
||||
if vErr != nil {
|
||||
failed := ModuleResult{
|
||||
@@ -197,11 +220,12 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (RunOutput, error) {
|
||||
}
|
||||
for _, d := range vResult.Decisions {
|
||||
validatorDecisions = append(validatorDecisions, ValidatorDecisionRecord{
|
||||
ValidatorName: validator.Name(),
|
||||
ProposalIndex: d.ProposalIndex,
|
||||
Approved: d.Approved,
|
||||
ReasonCode: d.ReasonCode,
|
||||
Message: d.Message,
|
||||
ValidatorName: validator.Name(),
|
||||
ProposalIndex: d.ProposalIndex,
|
||||
Approved: d.Approved,
|
||||
ReasonCode: d.ReasonCode,
|
||||
Message: d.Message,
|
||||
DiagnosticArtifactPath: d.DiagnosticArtifactPath,
|
||||
})
|
||||
if d.Approved {
|
||||
nextEligible = append(nextEligible, byIndex[d.ProposalIndex])
|
||||
@@ -265,3 +289,62 @@ func cloneTranscript(t *schema.Transcript) *schema.Transcript {
|
||||
}
|
||||
return &schema.Transcript{Segments: segments}
|
||||
}
|
||||
|
||||
type validationLLMClientAdapter struct {
|
||||
client contracts.StructuredLLMClient
|
||||
}
|
||||
|
||||
func (a validationLLMClientAdapter) CompleteStructured(ctx context.Context, req validators.StructuredCompletionRequest, out any) (validators.StructuredCompletionResponse, error) {
|
||||
if a.client == nil {
|
||||
return validators.StructuredCompletionResponse{}, fmt.Errorf("validation structured LLM client is not configured")
|
||||
}
|
||||
messages := make([]contracts.LLMMessage, len(req.Messages))
|
||||
for i, m := range req.Messages {
|
||||
messages[i] = contracts.LLMMessage{Role: m.Role, Content: m.Content}
|
||||
}
|
||||
resp, err := a.client.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
|
||||
StageName: req.StageName,
|
||||
Messages: messages,
|
||||
Model: req.Model,
|
||||
}, out)
|
||||
if err != nil {
|
||||
return validators.StructuredCompletionResponse{}, err
|
||||
}
|
||||
return validators.StructuredCompletionResponse{
|
||||
PromptTokens: resp.PromptTokens,
|
||||
CompletionTokens: resp.CompletionTokens,
|
||||
TotalTokens: resp.TotalTokens,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type llmDiagnosticsWriterAdapter struct {
|
||||
writer *llm.DiagnosticsWriter
|
||||
}
|
||||
|
||||
func (a *llmDiagnosticsWriterAdapter) WriteInteraction(stage string, requestMetadata any, requestPayload any, responsePayload any, errorPayload any) (validators.InteractionArtifacts, error) {
|
||||
if a == nil || a.writer == nil {
|
||||
return validators.InteractionArtifacts{}, fmt.Errorf("diagnostics writer is not configured")
|
||||
}
|
||||
art, err := a.writer.WriteInteraction(stage, requestMetadata, requestPayload, responsePayload, errorPayload)
|
||||
if err != nil {
|
||||
return validators.InteractionArtifacts{}, err
|
||||
}
|
||||
return validators.InteractionArtifacts{
|
||||
RequestMetadataPath: art.RequestMetadataPath,
|
||||
RequestPayloadPath: art.RequestPayloadPath,
|
||||
ResponsePayloadPath: art.ResponsePayloadPath,
|
||||
ErrorPayloadPath: art.ErrorPayloadPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validatorSecrets(cfg *config.Config) []string {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
effective := cfg.EffectiveValidationLLMConfig()
|
||||
return []string{
|
||||
cfg.PrimaryLLM.APIKey,
|
||||
effective.APIKey,
|
||||
cfg.ValidationLLM.APIKey,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,14 @@ package runner
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/audita/internal/core/config"
|
||||
"gitea.maximumdirect.net/eric/audita/internal/core/schema"
|
||||
"gitea.maximumdirect.net/eric/audita/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/audita/internal/framework/llm"
|
||||
"gitea.maximumdirect.net/eric/audita/internal/framework/proposals"
|
||||
"gitea.maximumdirect.net/eric/audita/internal/framework/validators"
|
||||
)
|
||||
@@ -307,3 +310,246 @@ func TestRunnerApplicationSkipAfterValidatorApprovalReported(t *testing.T) {
|
||||
}
|
||||
|
||||
func ptrConfig(c config.Config) *config.Config { return &c }
|
||||
|
||||
type fakeStructuredClient struct {
|
||||
responses []validators.LLMValidationResponse
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeStructuredClient) CompleteStructured(ctx context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
|
||||
_ = ctx
|
||||
_ = req
|
||||
f.calls++
|
||||
if f.err != nil {
|
||||
return contracts.StructuredCompletionResponse{}, f.err
|
||||
}
|
||||
if len(f.responses) == 0 {
|
||||
return contracts.StructuredCompletionResponse{}, errors.New("unexpected call")
|
||||
}
|
||||
resp := f.responses[0]
|
||||
f.responses = f.responses[1:]
|
||||
target := out.(*validators.LLMValidationResponse)
|
||||
*target = resp
|
||||
return contracts.StructuredCompletionResponse{}, nil
|
||||
}
|
||||
|
||||
type countingScheduler struct{ runs int }
|
||||
|
||||
func (s *countingScheduler) Run(ctx context.Context, fn func(context.Context) error) error {
|
||||
s.runs++
|
||||
return fn(ctx)
|
||||
}
|
||||
|
||||
type lenEstimator struct{}
|
||||
|
||||
func (lenEstimator) EstimateTokens(text string) int { return len(text) }
|
||||
|
||||
func TestRunnerLLMValidatorApprovalApplied(t *testing.T) {
|
||||
client := &fakeStructuredClient{responses: []validators.LLMValidationResponse{{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.9, Reason: "ok"}}}}}
|
||||
llmValidator, err := validators.NewLLMBackedValidator("spoken_form_plausibility_review", validators.LLMValidatorTypeSpokenFormPlausibility, "")
|
||||
if err != nil {
|
||||
t.Fatalf("NewLLMBackedValidator: %v", err)
|
||||
}
|
||||
|
||||
r := New(fakeFactory{modules: map[string]contracts.TranscriptModule{
|
||||
"m": fakeModule{key: "m", policy: proposals.ReplacementPolicyRequireUnique, validators: []contracts.Validator{llmValidator}, proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
|
||||
return []proposals.CorrectionProposal{{TargetSegmentID: 1, OriginalText: "gestures", CorrectedText: "Jesters", Confidence: 1}}, nil
|
||||
}},
|
||||
}})
|
||||
cfg := config.Default()
|
||||
out, err := r.Run(context.Background(), RunInput{
|
||||
Config: &cfg,
|
||||
Transcript: &schema.Transcript{Segments: []schema.Segment{{ID: 1, Text: "There were gestures at the temple."}}},
|
||||
ModuleSpecs: []contracts.ModuleRunSpec{{ModuleKey: "m", InstanceName: "m"}},
|
||||
ValidationLLMClient: client,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run error: %v", err)
|
||||
}
|
||||
if out.FinalTranscript.Segments[0].Text != "There were Jesters at the temple." {
|
||||
t.Fatalf("expected applied LLM-approved proposal, got %q", out.FinalTranscript.Segments[0].Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerLLMValidatorRejectionPreventsApplication(t *testing.T) {
|
||||
client := &fakeStructuredClient{responses: []validators.LLMValidationResponse{{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: false, Confidence: 0.95, Reason: "reject"}}}}}
|
||||
llmValidator, _ := validators.NewLLMBackedValidator("spoken_form_plausibility_review", validators.LLMValidatorTypeSpokenFormPlausibility, "")
|
||||
r := New(fakeFactory{modules: map[string]contracts.TranscriptModule{
|
||||
"m": fakeModule{key: "m", policy: proposals.ReplacementPolicyRequireUnique, validators: []contracts.Validator{llmValidator}, proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
|
||||
return []proposals.CorrectionProposal{{TargetSegmentID: 1, OriginalText: "gestures", CorrectedText: "Jesters", Confidence: 1}}, nil
|
||||
}},
|
||||
}})
|
||||
cfg := config.Default()
|
||||
out, err := r.Run(context.Background(), RunInput{
|
||||
Config: &cfg,
|
||||
Transcript: &schema.Transcript{Segments: []schema.Segment{{ID: 1, Text: "There were gestures at the temple."}}},
|
||||
ModuleSpecs: []contracts.ModuleRunSpec{{ModuleKey: "m", InstanceName: "m"}},
|
||||
ValidationLLMClient: client,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run error: %v", err)
|
||||
}
|
||||
if out.FinalTranscript.Segments[0].Text != "There were gestures at the temple." {
|
||||
t.Fatalf("expected rejected proposal not applied")
|
||||
}
|
||||
if len(out.ModuleResults[0].ValidatorRejected) != 1 {
|
||||
t.Fatalf("expected validator rejection record")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerLLMValidatorMalformedResponseFailsWithPartialProgress(t *testing.T) {
|
||||
client := &fakeStructuredClient{responses: []validators.LLMValidationResponse{{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 99, Approved: true, Confidence: 0.9, Reason: "bad index"}}}}}
|
||||
llmValidator, _ := validators.NewLLMBackedValidator("spoken_form_plausibility_review", validators.LLMValidatorTypeSpokenFormPlausibility, "")
|
||||
r := New(fakeFactory{modules: map[string]contracts.TranscriptModule{
|
||||
"m1": fakeModule{key: "m1", policy: proposals.ReplacementPolicyRequireUnique, proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
|
||||
return []proposals.CorrectionProposal{{TargetSegmentID: 1, OriginalText: "teh", CorrectedText: "the", Confidence: 1}}, nil
|
||||
}},
|
||||
"m2": fakeModule{key: "m2", policy: proposals.ReplacementPolicyRequireUnique, validators: []contracts.Validator{llmValidator}, proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
|
||||
return []proposals.CorrectionProposal{{TargetSegmentID: 1, OriginalText: "cat", CorrectedText: "dog", Confidence: 1}}, nil
|
||||
}},
|
||||
}})
|
||||
cfg := config.Default()
|
||||
out, err := r.Run(context.Background(), RunInput{
|
||||
Config: &cfg,
|
||||
Transcript: &schema.Transcript{Segments: []schema.Segment{{ID: 1, Text: "teh cat"}}},
|
||||
ModuleSpecs: []contracts.ModuleRunSpec{{ModuleKey: "m1", InstanceName: "m1"}, {ModuleKey: "m2", InstanceName: "m2"}},
|
||||
ValidationLLMClient: client,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected llm validator failure")
|
||||
}
|
||||
if out.FinalTranscript.Segments[0].Text != "the cat" {
|
||||
t.Fatalf("expected partial progress retained")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerLLMValidatorMissingDecisionFails(t *testing.T) {
|
||||
client := &fakeStructuredClient{responses: []validators.LLMValidationResponse{{Validations: []validators.LLMValidationDecision{}}}}
|
||||
llmValidator, _ := validators.NewLLMBackedValidator("spoken_form_plausibility_review", validators.LLMValidatorTypeSpokenFormPlausibility, "")
|
||||
r := New(fakeFactory{modules: map[string]contracts.TranscriptModule{
|
||||
"m": fakeModule{key: "m", policy: proposals.ReplacementPolicyRequireUnique, validators: []contracts.Validator{llmValidator}, proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
|
||||
return []proposals.CorrectionProposal{{TargetSegmentID: 1, OriginalText: "gestures", CorrectedText: "Jesters", Confidence: 1}}, nil
|
||||
}},
|
||||
}})
|
||||
cfg := config.Default()
|
||||
_, err := r.Run(context.Background(), RunInput{
|
||||
Config: &cfg,
|
||||
Transcript: &schema.Transcript{Segments: []schema.Segment{{ID: 1, Text: "There were gestures at the temple."}}},
|
||||
ModuleSpecs: []contracts.ModuleRunSpec{{ModuleKey: "m", InstanceName: "m"}},
|
||||
ValidationLLMClient: client,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected missing decision failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerLLMValidatorDuplicateDecisionFails(t *testing.T) {
|
||||
client := &fakeStructuredClient{responses: []validators.LLMValidationResponse{{Validations: []validators.LLMValidationDecision{
|
||||
{CorrectionIndex: 0, Approved: true, Confidence: 0.9, Reason: "ok"},
|
||||
{CorrectionIndex: 0, Approved: false, Confidence: 0.9, Reason: "dup"},
|
||||
}}}}
|
||||
llmValidator, _ := validators.NewLLMBackedValidator("spoken_form_plausibility_review", validators.LLMValidatorTypeSpokenFormPlausibility, "")
|
||||
r := New(fakeFactory{modules: map[string]contracts.TranscriptModule{
|
||||
"m": fakeModule{key: "m", policy: proposals.ReplacementPolicyRequireUnique, validators: []contracts.Validator{llmValidator}, proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
|
||||
return []proposals.CorrectionProposal{{TargetSegmentID: 1, OriginalText: "gestures", CorrectedText: "Jesters", Confidence: 1}}, nil
|
||||
}},
|
||||
}})
|
||||
cfg := config.Default()
|
||||
_, err := r.Run(context.Background(), RunInput{
|
||||
Config: &cfg,
|
||||
Transcript: &schema.Transcript{Segments: []schema.Segment{{ID: 1, Text: "There were gestures at the temple."}}},
|
||||
ModuleSpecs: []contracts.ModuleRunSpec{{ModuleKey: "m", InstanceName: "m"}},
|
||||
ValidationLLMClient: client,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected duplicate decision failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerLLMValidatorBatchingAndSchedulerUsage(t *testing.T) {
|
||||
client := &fakeStructuredClient{responses: []validators.LLMValidationResponse{
|
||||
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.9, Reason: "ok"}}},
|
||||
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 1, Approved: true, Confidence: 0.9, Reason: "ok"}}},
|
||||
}}
|
||||
llmValidator, _ := validators.NewLLMBackedValidator("spoken_form_plausibility_review", validators.LLMValidatorTypeSpokenFormPlausibility, "")
|
||||
llmValidator.SetTokenEstimator(lenEstimator{})
|
||||
scheduler := &countingScheduler{}
|
||||
cfg := config.Default()
|
||||
cfg.ValidationMaxPromptTokens = 260
|
||||
r := New(fakeFactory{modules: map[string]contracts.TranscriptModule{
|
||||
"m": fakeModule{key: "m", policy: proposals.ReplacementPolicyReplaceAll, validators: []contracts.Validator{llmValidator}, proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
|
||||
return []proposals.CorrectionProposal{
|
||||
{TargetSegmentID: 1, OriginalText: "alpha", CorrectedText: strings.Repeat("B", 40), Confidence: 1},
|
||||
{TargetSegmentID: 1, OriginalText: "gamma", CorrectedText: strings.Repeat("D", 40), Confidence: 1},
|
||||
}, nil
|
||||
}},
|
||||
}})
|
||||
out, err := r.Run(context.Background(), RunInput{
|
||||
Config: &cfg,
|
||||
Transcript: &schema.Transcript{Segments: []schema.Segment{{ID: 1, Text: "alpha gamma"}}},
|
||||
ModuleSpecs: []contracts.ModuleRunSpec{{ModuleKey: "m", InstanceName: "m"}},
|
||||
ValidationLLMClient: client,
|
||||
ValidationLLMScheduler: scheduler,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected run error: %v", err)
|
||||
}
|
||||
if scheduler.runs < 2 {
|
||||
t.Fatalf("expected scheduler to run per batch, got %d", scheduler.runs)
|
||||
}
|
||||
if client.calls < 2 {
|
||||
t.Fatalf("expected multiple llm calls for batching, got %d", client.calls)
|
||||
}
|
||||
if len(out.ModuleResults[0].AppliedChanges) != 2 {
|
||||
t.Fatalf("expected both approved proposals applied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerLLMValidatorDiagnosticsWrittenAndRedacted(t *testing.T) {
|
||||
secret := "super-secret-key"
|
||||
client := &fakeStructuredClient{responses: []validators.LLMValidationResponse{{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.9, Reason: secret}}}}}
|
||||
llmValidator, _ := validators.NewLLMBackedValidator("spoken_form_plausibility_review", validators.LLMValidatorTypeSpokenFormPlausibility, "")
|
||||
cfg := config.Default()
|
||||
cfg.PrimaryLLM.APIKey = secret
|
||||
cfg.ValidationLLM.APIKey = secret
|
||||
diagDir := t.TempDir()
|
||||
r := New(fakeFactory{modules: map[string]contracts.TranscriptModule{
|
||||
"m": fakeModule{key: "m", policy: proposals.ReplacementPolicyRequireUnique, validators: []contracts.Validator{llmValidator}, proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
|
||||
return []proposals.CorrectionProposal{{TargetSegmentID: 1, OriginalText: "gestures", CorrectedText: "Jesters", Confidence: 1}}, nil
|
||||
}},
|
||||
}})
|
||||
out, err := r.Run(context.Background(), RunInput{
|
||||
Config: &cfg,
|
||||
Transcript: &schema.Transcript{Segments: []schema.Segment{{ID: 1, Text: "There were gestures at the temple."}}},
|
||||
ModuleSpecs: []contracts.ModuleRunSpec{{ModuleKey: "m", InstanceName: "m"}},
|
||||
ValidationLLMClient: client,
|
||||
ValidationDiagnosticsDir: diagDir,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run error: %v", err)
|
||||
}
|
||||
if len(out.ModuleResults[0].ValidatorDecisions) == 0 || out.ModuleResults[0].ValidatorDecisions[0].DiagnosticArtifactPath == "" {
|
||||
t.Fatalf("expected diagnostic artifact path on decision")
|
||||
}
|
||||
raw, readErr := os.ReadFile(out.ModuleResults[0].ValidatorDecisions[0].DiagnosticArtifactPath)
|
||||
if readErr != nil {
|
||||
t.Fatalf("read diagnostic: %v", readErr)
|
||||
}
|
||||
if strings.Contains(string(raw), secret) {
|
||||
t.Fatalf("secret leaked in diagnostics: %s", string(raw))
|
||||
}
|
||||
if !strings.Contains(string(raw), "[REDACTED]") {
|
||||
t.Fatalf("expected redaction marker in diagnostics")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerAcceptsLLMSchedulerType(t *testing.T) {
|
||||
s, err := llm.NewScheduler(1)
|
||||
if err != nil {
|
||||
t.Fatalf("NewScheduler: %v", err)
|
||||
}
|
||||
if s == nil {
|
||||
t.Fatal("expected scheduler instance")
|
||||
}
|
||||
}
|
||||
|
||||
78
internal/framework/validators/llm_batching.go
Normal file
78
internal/framework/validators/llm_batching.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package validators
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/audita/internal/core/chunking"
|
||||
)
|
||||
|
||||
type LLMValidationBatch struct {
|
||||
BatchIndex int `json:"batch_index"`
|
||||
Items []LLMValidationItem `json:"items"`
|
||||
TokenCount int `json:"token_count"`
|
||||
}
|
||||
|
||||
func ChunkLLMValidationItems(items []LLMValidationItem, maxPromptTokens int, estimator chunking.TokenEstimator) ([]LLMValidationBatch, error) {
|
||||
if maxPromptTokens <= 0 {
|
||||
return nil, fmt.Errorf("validation max prompt tokens must be greater than zero")
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil, fmt.Errorf("validation input must contain at least one proposal")
|
||||
}
|
||||
if estimator == nil {
|
||||
estimator = chunking.NewSimpleTokenEstimator()
|
||||
}
|
||||
|
||||
batches := make([]LLMValidationBatch, 0)
|
||||
current := make([]LLMValidationItem, 0)
|
||||
currentTokens := 0
|
||||
|
||||
for _, item := range items {
|
||||
singleTokens, err := estimateBatchTokens(estimator, []LLMValidationItem{item})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if singleTokens > maxPromptTokens {
|
||||
return nil, fmt.Errorf("single validation proposal exceeds max prompt tokens")
|
||||
}
|
||||
|
||||
candidate := append(append([]LLMValidationItem(nil), current...), item)
|
||||
candidateTokens, err := estimateBatchTokens(estimator, candidate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(current) > 0 && candidateTokens > maxPromptTokens {
|
||||
batches = append(batches, LLMValidationBatch{
|
||||
BatchIndex: len(batches),
|
||||
Items: append([]LLMValidationItem(nil), current...),
|
||||
TokenCount: currentTokens,
|
||||
})
|
||||
current = []LLMValidationItem{item}
|
||||
currentTokens = singleTokens
|
||||
continue
|
||||
}
|
||||
|
||||
current = candidate
|
||||
currentTokens = candidateTokens
|
||||
}
|
||||
|
||||
if len(current) > 0 {
|
||||
batches = append(batches, LLMValidationBatch{
|
||||
BatchIndex: len(batches),
|
||||
Items: append([]LLMValidationItem(nil), current...),
|
||||
TokenCount: currentTokens,
|
||||
})
|
||||
}
|
||||
|
||||
return batches, nil
|
||||
}
|
||||
|
||||
func estimateBatchTokens(estimator chunking.TokenEstimator, batch []LLMValidationItem) (int, error) {
|
||||
payload, err := json.Marshal(batch)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("marshal batch payload: %w", err)
|
||||
}
|
||||
return estimator.EstimateTokens(string(payload)), nil
|
||||
}
|
||||
71
internal/framework/validators/llm_models.go
Normal file
71
internal/framework/validators/llm_models.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package validators
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.maximumdirect.net/eric/audita/internal/core/schema"
|
||||
)
|
||||
|
||||
type LLMValidatorType string
|
||||
|
||||
const (
|
||||
LLMValidatorTypeSpokenFormPlausibility LLMValidatorType = "spoken_form_plausibility"
|
||||
LLMValidatorTypeMeaningReversal LLMValidatorType = "meaning_reversal"
|
||||
LLMValidatorTypeEditorialReview LLMValidatorType = "editorial_review"
|
||||
LLMValidatorTypeGrammarReview LLMValidatorType = "grammar_review"
|
||||
LLMValidatorTypeSpokenWordReview LLMValidatorType = "spoken_word_review"
|
||||
)
|
||||
|
||||
type LLMMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type StructuredCompletionRequest struct {
|
||||
StageName string `json:"stage_name"`
|
||||
Messages []LLMMessage `json:"messages"`
|
||||
Model string `json:"model,omitempty"`
|
||||
}
|
||||
|
||||
type StructuredCompletionResponse struct {
|
||||
PromptTokens int `json:"prompt_tokens,omitempty"`
|
||||
CompletionTokens int `json:"completion_tokens,omitempty"`
|
||||
TotalTokens int `json:"total_tokens,omitempty"`
|
||||
}
|
||||
|
||||
type StructuredLLMClient interface {
|
||||
CompleteStructured(ctx context.Context, req StructuredCompletionRequest, out any) (StructuredCompletionResponse, error)
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// LLMValidationRequest is the canonical request model for LLM-backed validators.
|
||||
type LLMValidationRequest struct {
|
||||
ValidatorName string `json:"validator_name"`
|
||||
ValidatorType LLMValidatorType `json:"validator_type"`
|
||||
ModuleKey string `json:"module_key"`
|
||||
ModuleInstance string `json:"module_instance"`
|
||||
ReplacementPolicy string `json:"replacement_policy"`
|
||||
Glossary *schema.Glossary `json:"-"`
|
||||
Items []LLMValidationItem `json:"corrections"`
|
||||
}
|
||||
|
||||
type LLMValidationDecision struct {
|
||||
CorrectionIndex int `json:"correction_index"`
|
||||
Approved bool `json:"approved"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type LLMValidationResponse struct {
|
||||
Validations []LLMValidationDecision `json:"validations"`
|
||||
}
|
||||
90
internal/framework/validators/llm_prompt_builders.go
Normal file
90
internal/framework/validators/llm_prompt_builders.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package validators
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func BuildSpokenFormPlausibilityMessages(validationPayload []LLMValidationItem) ([]LLMMessage, error) {
|
||||
payloadJSON, err := marshalPromptPayload(validationPayload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
system := "You are Audita, a conservative spoken-form validation assistant. Evaluate whether each proposed correction is plausibly explained by a homophone, phonetic similarity, or a common mistranscription of spoken English. Your job is not to improve style or readability. Approve only when the corrected text is a plausible recovery of the words that were likely spoken."
|
||||
user := "Review these proposed transcript corrections and decide whether each one is a plausible spoken-form correction.\n\n" +
|
||||
"Rules:\n" +
|
||||
"- Return one validation decision for every correction_index in the input.\n" +
|
||||
"- Approve when the original text and corrected text are plausibly related by homophone confusion, phonetic similarity, or a common spoken-word mistranscription, and the surrounding segment context supports the correction.\n" +
|
||||
"- Examples that may be approved when context supports them: changing \"gestures\" to \"Jesters\", \"rank\" to \"Hrank\", or \"dam\" to \"damn\".\n" +
|
||||
"- Reject unrelated substitutions like changing \"Lyra\" to \"Jesters\".\n" +
|
||||
"- Judge spoken-form plausibility, not whether the correction is cleaner, more formal, or more grammatical.\n" +
|
||||
"- Do not approve paraphrases, stylistic rewrites, or arbitrary semantic substitutions.\n" +
|
||||
"- If a correction includes categories, treat them as additional segment context.\n" +
|
||||
"- Each returned validation must contain only correction_index, approved, confidence, and reason.\n" +
|
||||
"- confidence must be between 0.0 and 1.0.\n\n" +
|
||||
fmt.Sprintf("Corrections to validate:\n%s", payloadJSON)
|
||||
return []LLMMessage{{Role: "system", Content: system}, {Role: "user", Content: user}}, nil
|
||||
}
|
||||
|
||||
func BuildMeaningReversalMessages(validationPayload []LLMValidationItem) ([]LLMMessage, error) {
|
||||
payloadJSON, err := marshalPromptPayload(validationPayload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
system := "You are Audita, a narrow semantic-reversal validation assistant. Evaluate whether each proposed correction changes a word to its antonym or otherwise reverses the meaning of the full segment. Do not treat every word substitution as a problem; focus specifically on antonyms and meaning reversals."
|
||||
user := "Review these proposed transcript corrections and decide whether each one avoids reversing the segment meaning.\n\n" +
|
||||
"Rules:\n" +
|
||||
"- Return one validation decision for every correction_index in the input.\n" +
|
||||
"- Reject corrections that introduce antonyms or otherwise reverse the meaning of the original segment.\n" +
|
||||
"- Reject examples like changing \"visible\" to \"invisible\" or \"up\" to \"down\" when that reverses the segment meaning.\n" +
|
||||
"- Evaluate the full original_segment_text and corrected_segment_text, not only the replacement span.\n" +
|
||||
"- Do not reject a correction merely because the literal written word changes.\n" +
|
||||
"- Do not act as a general semantic-style reviewer; this validator is only a guard against antonyms and meaning reversals.\n" +
|
||||
"- If a correction includes categories, treat them as additional segment context.\n" +
|
||||
"- Each returned validation must contain only correction_index, approved, confidence, and reason.\n" +
|
||||
"- confidence must be between 0.0 and 1.0.\n\n" +
|
||||
fmt.Sprintf("Corrections to validate:\n%s", payloadJSON)
|
||||
return []LLMMessage{{Role: "system", Content: system}, {Role: "user", Content: user}}, nil
|
||||
}
|
||||
|
||||
func BuildEditorialMessages(validationPayload []LLMValidationItem) ([]LLMMessage, error) {
|
||||
payloadJSON, err := marshalPromptPayload(validationPayload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
system := "You are Audita, a conservative editorial validation assistant. Evaluate whether each proposed correction is editorial in nature and preserves the segment's underlying meaning. Editorial revisions may include dysfluency cleanup, punctuation changes, capitalization changes, homophone or mistranscription corrections, and similar low-risk editorial cleanup."
|
||||
user := "Review these proposed transcript corrections and decide whether each one is an acceptable editorial revision.\n\n" +
|
||||
"Rules:\n" +
|
||||
"- Return one validation decision for every correction_index in the input.\n" +
|
||||
"- Approve editorial revisions that preserve the underlying meaning of the segment.\n" +
|
||||
"- Approve dysfluency cleanup, including cleanup of repeated words, repeated short phrases, filler words, hesitation artifacts, and similar common spoken dysfluencies.\n" +
|
||||
"- Approve punctuation, spacing, capitalization, and article cleanup when they preserve meaning.\n" +
|
||||
"- Approve low-risk homophone or mistranscription corrections when they are contextually well supported.\n" +
|
||||
"- Approve conservative combinations of these editorial changes when the overall revision remains meaning-preserving.\n" +
|
||||
"- Reject repetition cleanup when the repetition plausibly serves urgency, excitement, insistence, or deliberate rhetorical emphasis rather than dysfluency.\n" +
|
||||
"- Phrases such as \"Help! Help! Help!\", \"Stop! Stop! Stop!\", \"No! No! No!\", \"Yes! Yes! Yes!\", and \"Go! Go! Go!\" are often intentional emphasis and should usually be preserved.\n" +
|
||||
"- Approve repetition cleanup only when local context supports it as accidental repetition, hesitation, or verbal restart.\n" +
|
||||
"- Reject broad paraphrase, substantive semantic changes, substantive meaning changes, and revisions that materially change, obscure, distort, or reverse the segment's meaning.\n" +
|
||||
"- Evaluate the full original_segment_text and corrected_segment_text, not only the replacement span.\n" +
|
||||
"- If a correction includes categories, treat them as additional segment context.\n" +
|
||||
"- Each returned validation must contain only correction_index, approved, confidence, and reason.\n" +
|
||||
"- confidence must be between 0.0 and 1.0.\n\n" +
|
||||
fmt.Sprintf("Corrections to validate:\n%s", payloadJSON)
|
||||
return []LLMMessage{{Role: "system", Content: system}, {Role: "user", Content: user}}, nil
|
||||
}
|
||||
|
||||
func BuildGrammarReviewMessages(validationPayload []LLMValidationItem) ([]LLMMessage, error) {
|
||||
return BuildEditorialMessages(validationPayload)
|
||||
}
|
||||
|
||||
func BuildSpokenWordReviewMessages(validationPayload []LLMValidationItem) ([]LLMMessage, error) {
|
||||
return BuildEditorialMessages(validationPayload)
|
||||
}
|
||||
|
||||
func marshalPromptPayload(validationPayload []LLMValidationItem) (string, error) {
|
||||
payloadJSON, err := json.MarshalIndent(validationPayload, "", " ")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal validation payload: %w", err)
|
||||
}
|
||||
return string(payloadJSON), nil
|
||||
}
|
||||
260
internal/framework/validators/llm_validators.go
Normal file
260
internal/framework/validators/llm_validators.go
Normal file
@@ -0,0 +1,260 @@
|
||||
package validators
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/audita/internal/core/chunking"
|
||||
"gitea.maximumdirect.net/eric/audita/internal/core/config"
|
||||
"gitea.maximumdirect.net/eric/audita/internal/core/schema"
|
||||
"gitea.maximumdirect.net/eric/audita/internal/framework/proposals"
|
||||
)
|
||||
|
||||
type LLMPromptBuilder func(validationPayload []LLMValidationItem) ([]LLMMessage, error)
|
||||
|
||||
type LLMBackedValidator struct {
|
||||
name string
|
||||
validatorType LLMValidatorType
|
||||
promptBuilder LLMPromptBuilder
|
||||
model string
|
||||
estimator chunking.TokenEstimator
|
||||
}
|
||||
|
||||
func (v *LLMBackedValidator) Name() string {
|
||||
return v.name
|
||||
}
|
||||
|
||||
// SetTokenEstimator allows deterministic test control over batching behavior.
|
||||
func (v *LLMBackedValidator) SetTokenEstimator(estimator chunking.TokenEstimator) {
|
||||
if v == nil || estimator == nil {
|
||||
return
|
||||
}
|
||||
v.estimator = estimator
|
||||
}
|
||||
|
||||
func NewLLMBackedValidator(name string, validatorType LLMValidatorType, model string) (*LLMBackedValidator, error) {
|
||||
builder, err := promptBuilderForType(validatorType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return nil, fmt.Errorf("validator name must not be empty")
|
||||
}
|
||||
return &LLMBackedValidator{
|
||||
name: name,
|
||||
validatorType: validatorType,
|
||||
promptBuilder: builder,
|
||||
model: strings.TrimSpace(model),
|
||||
estimator: chunking.NewSimpleTokenEstimator(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (v *LLMBackedValidator) Validate(ctx context.Context, req Request) (Result, error) {
|
||||
if v == nil {
|
||||
return Result{}, fmt.Errorf("validator is nil")
|
||||
}
|
||||
if req.LLMClient == nil {
|
||||
return Result{}, fmt.Errorf("LLM-backed validator %q requires a structured LLM client", v.name)
|
||||
}
|
||||
if len(req.CandidateProposal) == 0 {
|
||||
return Result{ValidatorName: v.name, Decisions: nil}, nil
|
||||
}
|
||||
validationReq, immediate := BuildLLMValidationRequest(v.name, v.validatorType, req)
|
||||
if len(validationReq.Items) == 0 {
|
||||
all := append([]Decision(nil), immediate...)
|
||||
sort.SliceStable(all, func(i, j int) bool { return all[i].ProposalIndex < all[j].ProposalIndex })
|
||||
if err := EnforceDecisionCardinality(req.CandidateProposal, all); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
return Result{ValidatorName: v.name, Decisions: all}, nil
|
||||
}
|
||||
|
||||
maxTokens := config.DefaultValidationMaxPromptTokens
|
||||
if req.Config != nil && req.Config.ValidationMaxPromptTokens > 0 {
|
||||
maxTokens = req.Config.ValidationMaxPromptTokens
|
||||
}
|
||||
|
||||
batches, err := ChunkLLMValidationItems(validationReq.Items, maxTokens, v.estimator)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
llmDecisions := make([]Decision, 0)
|
||||
for _, batch := range batches {
|
||||
messages, err := v.promptBuilder(batch.Items)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
var response LLMValidationResponse
|
||||
call := func(callCtx context.Context) error {
|
||||
_, err = req.LLMClient.CompleteStructured(callCtx, StructuredCompletionRequest{
|
||||
StageName: fmt.Sprintf("%s:%s:batch-%04d", req.ModuleInstance, v.name, batch.BatchIndex),
|
||||
Messages: messages,
|
||||
Model: resolvedValidationModel(req.Config, v.model),
|
||||
}, &response)
|
||||
return err
|
||||
}
|
||||
if req.Scheduler != nil {
|
||||
err = req.Scheduler.Run(ctx, call)
|
||||
} else {
|
||||
err = call(ctx)
|
||||
}
|
||||
artifacts := InteractionArtifacts{}
|
||||
if req.DiagnosticsWriter != nil {
|
||||
stage := fmt.Sprintf("%s:%s:batch-%04d", req.ModuleInstance, v.name, batch.BatchIndex)
|
||||
artifacts, _ = req.DiagnosticsWriter.WriteInteraction(
|
||||
stage,
|
||||
map[string]any{"validator_name": v.name, "validator_type": v.validatorType, "batch_index": batch.BatchIndex},
|
||||
map[string]any{"messages": messages, "items": batch.Items},
|
||||
response,
|
||||
errPayload(err),
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
for i := range batchDecisions {
|
||||
batchDecisions[i].DiagnosticArtifactPath = artifacts.ResponsePayloadPath
|
||||
}
|
||||
llmDecisions = append(llmDecisions, batchDecisions...)
|
||||
}
|
||||
|
||||
all := append([]Decision(nil), immediate...)
|
||||
all = append(all, llmDecisions...)
|
||||
if err := EnforceDecisionCardinality(req.CandidateProposal, all); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
sort.SliceStable(all, func(i, j int) bool { return all[i].ProposalIndex < all[j].ProposalIndex })
|
||||
return Result{ValidatorName: v.name, Decisions: all}, nil
|
||||
}
|
||||
|
||||
func errPayload(err error) any {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{"error": err.Error()}
|
||||
}
|
||||
|
||||
func resolvedValidationModel(cfg *config.Config, override string) string {
|
||||
if strings.TrimSpace(override) != "" {
|
||||
return strings.TrimSpace(override)
|
||||
}
|
||||
if cfg == nil {
|
||||
return ""
|
||||
}
|
||||
return cfg.EffectiveValidationLLMConfig().Model
|
||||
}
|
||||
|
||||
func BuildLLMValidationRequest(validatorName string, validatorType LLMValidatorType, req Request) (LLMValidationRequest, []Decision) {
|
||||
items := make([]LLMValidationItem, 0, len(req.CandidateProposal))
|
||||
immediate := make([]Decision, 0)
|
||||
segments := make(map[int]schema.Segment)
|
||||
if req.WorkingTranscript != nil {
|
||||
segments = make(map[int]schema.Segment, len(req.WorkingTranscript.Segments))
|
||||
for _, seg := range req.WorkingTranscript.Segments {
|
||||
segments[seg.ID] = seg
|
||||
}
|
||||
}
|
||||
|
||||
for _, p := range req.CandidateProposal {
|
||||
seg, ok := segments[p.TargetSegmentID]
|
||||
if !ok {
|
||||
immediate = append(immediate, rejection(p.ProposalIndex, ReasonMissingTargetSegment, "target segment was not found"))
|
||||
continue
|
||||
}
|
||||
|
||||
preview := proposals.PreviewProposalForSegment(&seg, p.CorrectionProposal, req.ReplacementPolicy)
|
||||
if !preview.Applicable {
|
||||
immediate = append(immediate, rejection(p.ProposalIndex, string(preview.SkipReason), "proposal is not previewable for LLM validation"))
|
||||
continue
|
||||
}
|
||||
|
||||
items = append(items, LLMValidationItem{
|
||||
CorrectionIndex: p.ProposalIndex,
|
||||
SegmentID: p.TargetSegmentID,
|
||||
OriginalText: p.OriginalText,
|
||||
CorrectedText: p.CorrectedText,
|
||||
OriginalSegmentText: seg.Text,
|
||||
CorrectedSegmentText: preview.CorrectedSegmentText,
|
||||
Categories: append([]string(nil), seg.Categories...),
|
||||
})
|
||||
}
|
||||
|
||||
return LLMValidationRequest{
|
||||
ValidatorName: validatorName,
|
||||
ValidatorType: validatorType,
|
||||
ModuleKey: req.ModuleKey,
|
||||
ModuleInstance: req.ModuleInstance,
|
||||
ReplacementPolicy: string(req.ReplacementPolicy),
|
||||
Glossary: req.Glossary,
|
||||
Items: items,
|
||||
}, immediate
|
||||
}
|
||||
|
||||
func promptBuilderForType(validatorType LLMValidatorType) (LLMPromptBuilder, error) {
|
||||
switch validatorType {
|
||||
case LLMValidatorTypeSpokenFormPlausibility:
|
||||
return BuildSpokenFormPlausibilityMessages, nil
|
||||
case LLMValidatorTypeMeaningReversal:
|
||||
return BuildMeaningReversalMessages, nil
|
||||
case LLMValidatorTypeEditorialReview:
|
||||
return BuildEditorialMessages, nil
|
||||
case LLMValidatorTypeGrammarReview:
|
||||
return BuildGrammarReviewMessages, nil
|
||||
case LLMValidatorTypeSpokenWordReview:
|
||||
return BuildSpokenWordReviewMessages, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported LLM validator type %q", validatorType)
|
||||
}
|
||||
}
|
||||
|
||||
func mapLLMResponseToDecisions(items []LLMValidationItem, response LLMValidationResponse) ([]Decision, error) {
|
||||
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))
|
||||
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)
|
||||
}
|
||||
if _, exists := seen[d.CorrectionIndex]; exists {
|
||||
return nil, fmt.Errorf("duplicate correction_index %d", d.CorrectionIndex)
|
||||
}
|
||||
seen[d.CorrectionIndex] = d
|
||||
}
|
||||
|
||||
decisions := make([]Decision, 0, len(items))
|
||||
for _, item := range items {
|
||||
d, ok := seen[item.CorrectionIndex]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("missing correction_index %d", item.CorrectionIndex)
|
||||
}
|
||||
reasonCode := ReasonApproved
|
||||
if !d.Approved {
|
||||
reasonCode = "llm_rejected"
|
||||
}
|
||||
decisions = append(decisions, Decision{
|
||||
ProposalIndex: item.CorrectionIndex,
|
||||
Approved: d.Approved,
|
||||
ReasonCode: reasonCode,
|
||||
Message: strings.TrimSpace(d.Reason),
|
||||
})
|
||||
}
|
||||
return decisions, nil
|
||||
}
|
||||
193
internal/framework/validators/llm_validators_test.go
Normal file
193
internal/framework/validators/llm_validators_test.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package validators
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/audita/internal/core/chunking"
|
||||
"gitea.maximumdirect.net/eric/audita/internal/core/config"
|
||||
"gitea.maximumdirect.net/eric/audita/internal/core/schema"
|
||||
"gitea.maximumdirect.net/eric/audita/internal/framework/proposals"
|
||||
)
|
||||
|
||||
type fakeStructuredLLMClient struct {
|
||||
responses []LLMValidationResponse
|
||||
err error
|
||||
calls []StructuredCompletionRequest
|
||||
}
|
||||
|
||||
func (f *fakeStructuredLLMClient) CompleteStructured(ctx context.Context, req StructuredCompletionRequest, out any) (StructuredCompletionResponse, error) {
|
||||
_ = ctx
|
||||
f.calls = append(f.calls, req)
|
||||
if f.err != nil {
|
||||
return StructuredCompletionResponse{}, f.err
|
||||
}
|
||||
if len(f.responses) == 0 {
|
||||
return StructuredCompletionResponse{}, errors.New("unexpected call")
|
||||
}
|
||||
resp := f.responses[0]
|
||||
f.responses = f.responses[1:]
|
||||
target, ok := out.(*LLMValidationResponse)
|
||||
if !ok {
|
||||
return StructuredCompletionResponse{}, errors.New("unexpected output type")
|
||||
}
|
||||
*target = resp
|
||||
return StructuredCompletionResponse{}, nil
|
||||
}
|
||||
|
||||
func makeReq(candidates []proposals.EnrichedCorrectionProposal) Request {
|
||||
cfg := config.Default()
|
||||
cfg.ValidationMaxPromptTokens = 10000
|
||||
return Request{
|
||||
WorkingTranscript: &schema.Transcript{Segments: []schema.Segment{{ID: 1, Text: "There were gestures at the temple.", Categories: []string{"narration"}}}},
|
||||
CandidateProposal: candidates,
|
||||
ModuleKey: "homophones",
|
||||
ModuleInstance: "homophones",
|
||||
ReplacementPolicy: proposals.ReplacementPolicyRequireUnique,
|
||||
Config: &cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func mk(index int, orig, corr string) proposals.EnrichedCorrectionProposal {
|
||||
return proposals.EnrichedCorrectionProposal{
|
||||
CorrectionProposal: proposals.CorrectionProposal{TargetSegmentID: 1, OriginalText: orig, CorrectedText: corr, Confidence: 0.9},
|
||||
ProposalMetadata: proposals.ProposalMetadata{ProposalIndex: index, ModuleKey: "homophones", ModuleInstance: "homophones"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkLLMValidationItemsOneSmallBatch(t *testing.T) {
|
||||
items := []LLMValidationItem{{CorrectionIndex: 0, OriginalText: "a", CorrectedText: "b", OriginalSegmentText: "x", CorrectedSegmentText: "y"}}
|
||||
batches, err := ChunkLLMValidationItems(items, 1000, chunking.NewSimpleTokenEstimator())
|
||||
if err != nil {
|
||||
t.Fatalf("Chunk error: %v", err)
|
||||
}
|
||||
if len(batches) != 1 {
|
||||
t.Fatalf("expected 1 batch, got %d", len(batches))
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkLLMValidationItemsMultipleBatchesStableNoDropNoDup(t *testing.T) {
|
||||
items := []LLMValidationItem{
|
||||
{CorrectionIndex: 0, OriginalText: strings.Repeat("a", 20), CorrectedText: "b", OriginalSegmentText: "x", CorrectedSegmentText: "y"},
|
||||
{CorrectionIndex: 1, OriginalText: strings.Repeat("c", 20), CorrectedText: "d", OriginalSegmentText: "x", CorrectedSegmentText: "y"},
|
||||
{CorrectionIndex: 2, OriginalText: strings.Repeat("e", 20), CorrectedText: "f", OriginalSegmentText: "x", CorrectedSegmentText: "y"},
|
||||
}
|
||||
batches, err := ChunkLLMValidationItems(items, 40, chunking.NewSimpleTokenEstimator())
|
||||
if err != nil {
|
||||
t.Fatalf("Chunk error: %v", err)
|
||||
}
|
||||
if len(batches) < 2 {
|
||||
t.Fatalf("expected multiple batches, got %d", len(batches))
|
||||
}
|
||||
seen := make([]int, 0)
|
||||
for _, b := range batches {
|
||||
for _, it := range b.Items {
|
||||
seen = append(seen, it.CorrectionIndex)
|
||||
}
|
||||
}
|
||||
if len(seen) != 3 || seen[0] != 0 || seen[1] != 1 || seen[2] != 2 {
|
||||
t.Fatalf("unexpected ordering/drops/dups: %v", seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptBuildersContainRequiredContextAndInstructions(t *testing.T) {
|
||||
payload := []LLMValidationItem{{CorrectionIndex: 0, SegmentID: 1, OriginalText: "gestures", CorrectedText: "Jesters", OriginalSegmentText: "There were gestures", CorrectedSegmentText: "There were Jesters", Categories: []string{"narration"}}}
|
||||
tests := []struct {
|
||||
name string
|
||||
build func([]LLMValidationItem) ([]LLMMessage, error)
|
||||
mustHas []string
|
||||
}{
|
||||
{"spoken_form", BuildSpokenFormPlausibilityMessages, []string{"plausible spoken-form", "correction_index", "original_segment_text", "corrected_segment_text"}},
|
||||
{"meaning_reversal", BuildMeaningReversalMessages, []string{"meaning reversals", "correction_index", "original_segment_text", "corrected_segment_text"}},
|
||||
{"editorial", BuildEditorialMessages, []string{"acceptable editorial revision", "correction_index", "original_segment_text", "corrected_segment_text"}},
|
||||
{"grammar_review", BuildGrammarReviewMessages, []string{"acceptable editorial revision", "correction_index"}},
|
||||
{"spoken_word", BuildSpokenWordReviewMessages, []string{"acceptable editorial revision", "correction_index"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
msgs, err := tt.build(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("build err: %v", err)
|
||||
}
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("expected 2 messages, got %d", len(msgs))
|
||||
}
|
||||
combined := msgs[0].Content + "\n" + msgs[1].Content
|
||||
for _, needle := range tt.mustHas {
|
||||
if !strings.Contains(combined, needle) {
|
||||
t.Fatalf("expected prompt to contain %q", needle)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMBackedValidatorApprovalAndRejection(t *testing.T) {
|
||||
client := &fakeStructuredLLMClient{responses: []LLMValidationResponse{{Validations: []LLMValidationDecision{
|
||||
{CorrectionIndex: 0, Approved: true, Confidence: 0.9, Reason: "ok"},
|
||||
{CorrectionIndex: 1, Approved: false, Confidence: 0.95, Reason: "bad"},
|
||||
}}}}
|
||||
v, err := NewLLMBackedValidator("spoken_form_plausibility_review", LLMValidatorTypeSpokenFormPlausibility, "test-model")
|
||||
if err != nil {
|
||||
t.Fatalf("new validator error: %v", err)
|
||||
}
|
||||
req := makeReq([]proposals.EnrichedCorrectionProposal{mk(0, "gestures", "Jesters"), mk(1, "gestures", "Lyra")})
|
||||
req.LLMClient = client
|
||||
res, err := v.Validate(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("validate err: %v", err)
|
||||
}
|
||||
if len(res.Decisions) != 2 || !res.Decisions[0].Approved || res.Decisions[1].Approved {
|
||||
t.Fatalf("unexpected decisions: %+v", res.Decisions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMBackedValidatorMalformedOutputFails(t *testing.T) {
|
||||
client := &fakeStructuredLLMClient{err: errors.New("malformed structured output")}
|
||||
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(), "completion failed") {
|
||||
t.Fatalf("expected malformed output error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMBackedValidatorMissingDecisionFails(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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMBackedValidatorDuplicateDecisionFails(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"},
|
||||
}}}}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMBackedValidatorUnknownProposalIndexFails(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)
|
||||
}
|
||||
}
|
||||
@@ -29,14 +29,18 @@ type Request struct {
|
||||
ReplacementPolicy proposals.ReplacementPolicy `json:"replacement_policy"`
|
||||
Glossary *schema.Glossary `json:"-"`
|
||||
Config *config.Config `json:"-"`
|
||||
LLMClient StructuredLLMClient `json:"-"`
|
||||
Scheduler ValidationScheduler `json:"-"`
|
||||
DiagnosticsWriter InteractionDiagnosticsWriter `json:"-"`
|
||||
}
|
||||
|
||||
// Decision is one validator decision for one proposal index.
|
||||
type Decision struct {
|
||||
ProposalIndex int `json:"proposal_index"`
|
||||
Approved bool `json:"approved"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
Message string `json:"message"`
|
||||
ProposalIndex int `json:"proposal_index"`
|
||||
Approved bool `json:"approved"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
Message string `json:"message"`
|
||||
DiagnosticArtifactPath string `json:"diagnostic_artifact_path,omitempty"`
|
||||
}
|
||||
|
||||
// Result is one validator output containing exactly one decision per proposal index.
|
||||
@@ -45,6 +49,22 @@ type Result struct {
|
||||
Decisions []Decision `json:"decisions"`
|
||||
}
|
||||
|
||||
// ValidationScheduler provides bounded execution for validator LLM calls.
|
||||
type ValidationScheduler interface {
|
||||
Run(ctx context.Context, fn func(context.Context) error) error
|
||||
}
|
||||
|
||||
type InteractionDiagnosticsWriter interface {
|
||||
WriteInteraction(stage string, requestMetadata any, requestPayload any, responsePayload any, errorPayload any) (InteractionArtifacts, error)
|
||||
}
|
||||
|
||||
type InteractionArtifacts struct {
|
||||
RequestMetadataPath string `json:"request_metadata_path,omitempty"`
|
||||
RequestPayloadPath string `json:"request_payload_path,omitempty"`
|
||||
ResponsePayloadPath string `json:"response_payload_path,omitempty"`
|
||||
ErrorPayloadPath string `json:"error_payload_path,omitempty"`
|
||||
}
|
||||
|
||||
// Validator is the deterministic runtime validator interface.
|
||||
type Validator interface {
|
||||
Name() string
|
||||
|
||||
Reference in New Issue
Block a user