1520 lines
59 KiB
Go
1520 lines
59 KiB
Go
package runner
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"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/modules"
|
|
"gitea.maximumdirect.net/eric/audita/internal/framework/proposal_generation"
|
|
"gitea.maximumdirect.net/eric/audita/internal/framework/proposals"
|
|
"gitea.maximumdirect.net/eric/audita/internal/framework/responseschema"
|
|
"gitea.maximumdirect.net/eric/audita/internal/framework/validators"
|
|
validatormetadata "gitea.maximumdirect.net/eric/audita/internal/validators/metadata"
|
|
)
|
|
|
|
type fakeFactory struct {
|
|
modules map[string]contracts.TranscriptModule
|
|
}
|
|
|
|
func (f fakeFactory) ModuleForSpec(spec contracts.ModuleRunSpec) (contracts.TranscriptModule, error) {
|
|
m, ok := f.modules[spec.InstanceName]
|
|
if !ok {
|
|
return nil, errors.New("module not registered")
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
type fakeModule struct {
|
|
key string
|
|
policy proposals.ReplacementPolicy
|
|
validators []contracts.Validator
|
|
proposeF func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error)
|
|
}
|
|
|
|
func (m fakeModule) Key() string { return m.key }
|
|
func (m fakeModule) ReplacementPolicy() proposals.ReplacementPolicy { return m.policy }
|
|
func (m fakeModule) Validators() []contracts.Validator { return m.validators }
|
|
func (m fakeModule) Propose(ctx context.Context, req contracts.ProposalRequest) (contracts.ProposalResult, error) {
|
|
if m.proposeF == nil {
|
|
return contracts.ProposalResult{}, nil
|
|
}
|
|
proposalsOut, err := m.proposeF(req)
|
|
return contracts.ProposalResult{Proposals: proposalsOut}, err
|
|
}
|
|
|
|
type fakeValidator struct {
|
|
name string
|
|
validateF func(req contracts.ValidationRequest) (validators.Result, error)
|
|
}
|
|
|
|
func (v fakeValidator) Name() string { return v.name }
|
|
func (v fakeValidator) Validate(ctx context.Context, req contracts.ValidationRequest) (validators.Result, error) {
|
|
_ = ctx
|
|
return v.validateF(req)
|
|
}
|
|
|
|
type classifiedFakeValidator struct {
|
|
fakeValidator
|
|
class validatormetadata.ExecutionClass
|
|
}
|
|
|
|
func (v classifiedFakeValidator) ExecutionClass() validatormetadata.ExecutionClass {
|
|
return v.class
|
|
}
|
|
|
|
func TestReorderValidatorsDeterministicBeforeLLMBacked(t *testing.T) {
|
|
llm := classifiedFakeValidator{
|
|
fakeValidator: fakeValidator{name: "llm", validateF: func(req contracts.ValidationRequest) (validators.Result, error) {
|
|
return validators.Result{ValidatorName: "llm"}, nil
|
|
}},
|
|
class: validatormetadata.ExecutionClassLLMBacked,
|
|
}
|
|
deterministic := classifiedFakeValidator{
|
|
fakeValidator: fakeValidator{name: "deterministic", validateF: func(req contracts.ValidationRequest) (validators.Result, error) {
|
|
return validators.Result{ValidatorName: "deterministic"}, nil
|
|
}},
|
|
class: validatormetadata.ExecutionClassDeterministic,
|
|
}
|
|
|
|
ordered, _ := reorderValidatorsForPipeline([]contracts.Validator{llm, deterministic})
|
|
if len(ordered) != 2 {
|
|
t.Fatalf("expected 2 validators, got %d", len(ordered))
|
|
}
|
|
if ordered[0].Name() != "deterministic" || ordered[1].Name() != "llm" {
|
|
t.Fatalf("unexpected validator order: %s, %s", ordered[0].Name(), ordered[1].Name())
|
|
}
|
|
}
|
|
|
|
func TestReorderValidatorsDefaultsUnclassifiedToDeterministic(t *testing.T) {
|
|
unclassified := fakeValidator{name: "plain", validateF: func(req contracts.ValidationRequest) (validators.Result, error) {
|
|
return validators.Result{ValidatorName: "plain"}, nil
|
|
}}
|
|
llm := classifiedFakeValidator{
|
|
fakeValidator: fakeValidator{name: "llm", validateF: func(req contracts.ValidationRequest) (validators.Result, error) {
|
|
return validators.Result{ValidatorName: "llm"}, nil
|
|
}},
|
|
class: validatormetadata.ExecutionClassLLMBacked,
|
|
}
|
|
|
|
ordered, _ := reorderValidatorsForPipeline([]contracts.Validator{llm, unclassified})
|
|
if len(ordered) != 2 {
|
|
t.Fatalf("expected 2 validators, got %d", len(ordered))
|
|
}
|
|
if ordered[0].Name() != "plain" || ordered[1].Name() != "llm" {
|
|
t.Fatalf("unexpected validator order with unclassified validator: %s, %s", ordered[0].Name(), ordered[1].Name())
|
|
}
|
|
}
|
|
|
|
func TestRunnerOneModuleAppliesProposal(t *testing.T) {
|
|
transcript := &schema.Transcript{Segments: []schema.Segment{{ID: 1, Speaker: "A", Start: 0, End: 1, Text: "teh cat"}}}
|
|
r := New(fakeFactory{modules: map[string]contracts.TranscriptModule{
|
|
"grammar": fakeModule{key: "grammar", policy: proposals.ReplacementPolicyRequireUnique, proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
|
|
return []proposals.CorrectionProposal{{TargetSegmentID: 1, OriginalText: "teh", CorrectedText: "the", Confidence: 0.9}}, nil
|
|
}},
|
|
}})
|
|
|
|
out, err := r.Run(context.Background(), RunInput{Transcript: transcript, ModuleSpecs: []contracts.ModuleRunSpec{{ModuleKey: "grammar", InstanceName: "grammar"}}})
|
|
if err != nil {
|
|
t.Fatalf("Run error: %v", err)
|
|
}
|
|
if out.FinalTranscript.Segments[0].Text != "the cat" {
|
|
t.Fatalf("expected corrected text, got %q", out.FinalTranscript.Segments[0].Text)
|
|
}
|
|
if len(out.ModuleResults) != 1 || len(out.ModuleResults[0].AppliedChanges) != 1 {
|
|
t.Fatalf("expected one module with one applied change, got %+v", out.ModuleResults)
|
|
}
|
|
}
|
|
|
|
func TestRunnerModulesRunSequentially(t *testing.T) {
|
|
transcript := &schema.Transcript{Segments: []schema.Segment{{ID: 1, Speaker: "A", Start: 0, End: 1, Text: "teh cat"}}}
|
|
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, proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
|
|
if req.WorkingTranscript.Segments[0].Text != "the cat" {
|
|
t.Fatalf("second module did not see first module changes: %q", req.WorkingTranscript.Segments[0].Text)
|
|
}
|
|
return []proposals.CorrectionProposal{{TargetSegmentID: 1, OriginalText: "cat", CorrectedText: "dog", Confidence: 1}}, nil
|
|
}},
|
|
}})
|
|
|
|
out, err := r.Run(context.Background(), RunInput{Transcript: transcript, ModuleSpecs: []contracts.ModuleRunSpec{{ModuleKey: "m1", InstanceName: "m1"}, {ModuleKey: "m2", InstanceName: "m2"}}})
|
|
if err != nil {
|
|
t.Fatalf("Run error: %v", err)
|
|
}
|
|
if out.FinalTranscript.Segments[0].Text != "the dog" {
|
|
t.Fatalf("expected sequential updates, got %q", out.FinalTranscript.Segments[0].Text)
|
|
}
|
|
}
|
|
|
|
func TestRunnerProposalsExecutePerChunkSection(t *testing.T) {
|
|
cfg := config.Default()
|
|
cfg.MaxSectionTokens = 3
|
|
cfg.MinSectionTokens = 0
|
|
|
|
transcript := &schema.Transcript{Segments: []schema.Segment{
|
|
{ID: 1, Text: "one two"},
|
|
{ID: 2, Text: "three four"},
|
|
{ID: 3, Text: "five six"},
|
|
}}
|
|
|
|
var (
|
|
mu sync.Mutex
|
|
seenBySection = make(map[int]contracts.SectionMetadata)
|
|
segmentCountBySec = make(map[int]int)
|
|
)
|
|
r := New(fakeFactory{modules: map[string]contracts.TranscriptModule{
|
|
"m": fakeModule{key: "m", policy: proposals.ReplacementPolicyRequireUnique, proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
|
|
if req.Section == nil {
|
|
t.Fatalf("expected section metadata on proposal request")
|
|
}
|
|
mu.Lock()
|
|
seenBySection[req.Section.Index] = *req.Section
|
|
segmentCountBySec[req.Section.Index] = len(req.WorkingTranscript.Segments)
|
|
mu.Unlock()
|
|
return nil, nil
|
|
}},
|
|
}})
|
|
|
|
out, err := r.Run(context.Background(), RunInput{
|
|
Config: &cfg,
|
|
Transcript: transcript,
|
|
ModuleSpecs: []contracts.ModuleRunSpec{
|
|
{ModuleKey: "m", InstanceName: "m"},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Run error: %v", err)
|
|
}
|
|
if len(out.ModuleResults) != 1 {
|
|
t.Fatalf("expected one module result, got %+v", out.ModuleResults)
|
|
}
|
|
if len(seenBySection) != 3 {
|
|
t.Fatalf("expected 3 chunked proposal calls, got %d", len(seenBySection))
|
|
}
|
|
for i := 0; i < 3; i++ {
|
|
section, ok := seenBySection[i]
|
|
if !ok {
|
|
t.Fatalf("expected section %d to be observed", i)
|
|
}
|
|
if section.Index != i {
|
|
t.Fatalf("expected section index %d, got %+v", i, section)
|
|
}
|
|
if segmentCountBySec[i] != 1 {
|
|
t.Fatalf("expected one segment per section call, got %d at index %d", segmentCountBySec[i], i)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunnerProposalSectionConcurrencyBoundedByProposalLLMConcurrency(t *testing.T) {
|
|
cfg := config.Default()
|
|
cfg.MaxSectionTokens = 3
|
|
cfg.MinSectionTokens = 0
|
|
cfg.TotalLLMConcurrency = 2
|
|
cfg.ProposalLLMConcurrency = 2
|
|
|
|
transcript := &schema.Transcript{Segments: []schema.Segment{
|
|
{ID: 1, Text: "one two"},
|
|
{ID: 2, Text: "three four"},
|
|
{ID: 3, Text: "five six"},
|
|
{ID: 4, Text: "seven eight"},
|
|
}}
|
|
|
|
var inFlight int32
|
|
var maxInFlight int32
|
|
entered := make(chan struct{}, len(transcript.Segments))
|
|
release := make(chan struct{})
|
|
scheduler, err := llm.NewScheduler(2)
|
|
if err != nil {
|
|
t.Fatalf("NewScheduler: %v", err)
|
|
}
|
|
r := New(fakeFactory{modules: map[string]contracts.TranscriptModule{
|
|
"m": fakeModule{key: "m", policy: proposals.ReplacementPolicyRequireUnique, proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
|
|
err := req.LLMScheduler.Run(context.Background(), func(context.Context) error {
|
|
current := atomic.AddInt32(&inFlight, 1)
|
|
for {
|
|
prior := atomic.LoadInt32(&maxInFlight)
|
|
if current <= prior || atomic.CompareAndSwapInt32(&maxInFlight, prior, current) {
|
|
break
|
|
}
|
|
}
|
|
entered <- struct{}{}
|
|
<-release
|
|
atomic.AddInt32(&inFlight, -1)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return nil, nil
|
|
}},
|
|
}})
|
|
|
|
resultCh := make(chan error, 1)
|
|
go func() {
|
|
_, err := r.Run(context.Background(), RunInput{
|
|
Config: &cfg,
|
|
Transcript: transcript,
|
|
ProposalLLMScheduler: scheduler,
|
|
ModuleSpecs: []contracts.ModuleRunSpec{
|
|
{ModuleKey: "m", InstanceName: "m"},
|
|
},
|
|
})
|
|
resultCh <- err
|
|
}()
|
|
|
|
waitForRunnerEntries(t, entered, 2, "proposal workers to enter")
|
|
close(release)
|
|
|
|
err = <-resultCh
|
|
if err != nil {
|
|
t.Fatalf("Run error: %v", err)
|
|
}
|
|
if maxInFlight < 2 {
|
|
t.Fatalf("expected proposal execution to run concurrently, got max in-flight %d", maxInFlight)
|
|
}
|
|
if maxInFlight > 2 {
|
|
t.Fatalf("expected proposal concurrency <= 2, got %d", maxInFlight)
|
|
}
|
|
}
|
|
|
|
func TestRunnerProposalIndexOrderingIsDeterministicAcrossParallelSections(t *testing.T) {
|
|
cfg := config.Default()
|
|
cfg.MaxSectionTokens = 3
|
|
cfg.MinSectionTokens = 0
|
|
cfg.TotalLLMConcurrency = 3
|
|
cfg.ProposalLLMConcurrency = 3
|
|
|
|
transcript := &schema.Transcript{Segments: []schema.Segment{
|
|
{ID: 1, Text: "alpha one"},
|
|
{ID: 2, Text: "bravo two"},
|
|
{ID: 3, Text: "charlie three"},
|
|
}}
|
|
|
|
started := make(chan int, len(transcript.Segments))
|
|
releaseByID := map[int]chan struct{}{
|
|
1: make(chan struct{}),
|
|
2: make(chan struct{}),
|
|
3: make(chan struct{}),
|
|
}
|
|
|
|
r := New(fakeFactory{modules: map[string]contracts.TranscriptModule{
|
|
"m": fakeModule{key: "m", policy: proposals.ReplacementPolicyRequireUnique, proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
|
|
if len(req.WorkingTranscript.Segments) != 1 {
|
|
t.Fatalf("expected one segment per section, got %d", len(req.WorkingTranscript.Segments))
|
|
}
|
|
seg := req.WorkingTranscript.Segments[0]
|
|
started <- seg.ID
|
|
<-releaseByID[seg.ID]
|
|
return []proposals.CorrectionProposal{
|
|
{TargetSegmentID: seg.ID, OriginalText: seg.Text, CorrectedText: strings.ToUpper(seg.Text), Confidence: 1},
|
|
}, nil
|
|
}},
|
|
}})
|
|
|
|
resultCh := make(chan struct {
|
|
out RunOutput
|
|
err error
|
|
}, 1)
|
|
go func() {
|
|
out, err := r.Run(context.Background(), RunInput{
|
|
Config: &cfg,
|
|
Transcript: transcript,
|
|
ModuleSpecs: []contracts.ModuleRunSpec{
|
|
{ModuleKey: "m", InstanceName: "m"},
|
|
},
|
|
})
|
|
resultCh <- struct {
|
|
out RunOutput
|
|
err error
|
|
}{out: out, err: err}
|
|
}()
|
|
|
|
waitForRunnerSectionIDs(t, started, map[int]struct{}{1: {}, 2: {}, 3: {}})
|
|
close(releaseByID[3])
|
|
close(releaseByID[2])
|
|
close(releaseByID[1])
|
|
|
|
result := <-resultCh
|
|
out, err := result.out, result.err
|
|
if err != nil {
|
|
t.Fatalf("Run error: %v", err)
|
|
}
|
|
if len(out.ModuleResults) != 1 {
|
|
t.Fatalf("expected one module result, got %+v", out.ModuleResults)
|
|
}
|
|
if got := len(out.ModuleResults[0].AppliedChanges); got != 3 {
|
|
t.Fatalf("expected three applied changes, got %d", got)
|
|
}
|
|
for i, change := range out.ModuleResults[0].AppliedChanges {
|
|
wantID := i + 1
|
|
if change.TargetSegmentID != wantID {
|
|
t.Fatalf("expected deterministic applied-change order by section/segment, got %+v", out.ModuleResults[0].AppliedChanges)
|
|
}
|
|
}
|
|
wantFinal := []string{"ALPHA ONE", "BRAVO TWO", "CHARLIE THREE"}
|
|
for i, seg := range out.FinalTranscript.Segments {
|
|
if seg.Text != wantFinal[i] {
|
|
t.Fatalf("expected deterministic final transcript regardless of section completion order; got %+v", out.FinalTranscript.Segments)
|
|
}
|
|
}
|
|
}
|
|
|
|
func waitForRunnerEntries(t *testing.T, entered <-chan struct{}, want int, label string) {
|
|
t.Helper()
|
|
deadline := time.After(350 * time.Millisecond)
|
|
got := 0
|
|
for got < want {
|
|
select {
|
|
case <-entered:
|
|
got++
|
|
case <-deadline:
|
|
t.Fatalf("timed out waiting for %d entries (%s), got %d", want, label, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func waitForRunnerSectionIDs(t *testing.T, started <-chan int, want map[int]struct{}) {
|
|
t.Helper()
|
|
deadline := time.After(350 * time.Millisecond)
|
|
seen := map[int]struct{}{}
|
|
for len(seen) < len(want) {
|
|
select {
|
|
case id := <-started:
|
|
seen[id] = struct{}{}
|
|
case <-deadline:
|
|
t.Fatalf("timed out waiting for section IDs %v, got %v", mapKeys(want), mapKeys(seen))
|
|
}
|
|
}
|
|
}
|
|
|
|
func mapKeys(values map[int]struct{}) []int {
|
|
keys := make([]int, 0, len(values))
|
|
for key := range values {
|
|
keys = append(keys, key)
|
|
}
|
|
return keys
|
|
}
|
|
|
|
func TestRunnerValidationStartsBeforeAllSectionProposalsComplete(t *testing.T) {
|
|
cfg := config.Default()
|
|
cfg.MaxSectionTokens = 3
|
|
cfg.MinSectionTokens = 0
|
|
cfg.TotalLLMConcurrency = 2
|
|
cfg.ProposalLLMConcurrency = 2
|
|
|
|
transcript := &schema.Transcript{Segments: []schema.Segment{
|
|
{ID: 1, Text: "alpha one"},
|
|
{ID: 2, Text: "bravo two"},
|
|
}}
|
|
|
|
releaseSectionOne := make(chan struct{})
|
|
validatorStarted := make(chan struct{}, 1)
|
|
|
|
validator := fakeValidator{
|
|
name: "capture",
|
|
validateF: func(req contracts.ValidationRequest) (validators.Result, error) {
|
|
if len(req.CandidateProposal) == 1 && req.CandidateProposal[0].TargetSegmentID == 1 {
|
|
select {
|
|
case validatorStarted <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
decisions := make([]validators.Decision, 0, len(req.CandidateProposal))
|
|
for _, p := range req.CandidateProposal {
|
|
decisions = append(decisions, validators.Decision{
|
|
ProposalIndex: p.ProposalIndex,
|
|
Approved: true,
|
|
ReasonCode: validators.ReasonApproved,
|
|
Message: "ok",
|
|
})
|
|
}
|
|
return validators.Result{ValidatorName: "capture", Decisions: decisions}, nil
|
|
},
|
|
}
|
|
|
|
r := New(fakeFactory{modules: map[string]contracts.TranscriptModule{
|
|
"m": fakeModule{
|
|
key: "m",
|
|
policy: proposals.ReplacementPolicyRequireUnique,
|
|
validators: []contracts.Validator{validator},
|
|
proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
|
|
seg := req.WorkingTranscript.Segments[0]
|
|
if req.Section != nil && req.Section.Index == 1 {
|
|
<-releaseSectionOne
|
|
}
|
|
return []proposals.CorrectionProposal{
|
|
{TargetSegmentID: seg.ID, OriginalText: seg.Text, CorrectedText: strings.ToUpper(seg.Text), Confidence: 1},
|
|
}, nil
|
|
},
|
|
},
|
|
}})
|
|
|
|
resultCh := make(chan error, 1)
|
|
go func() {
|
|
_, err := r.Run(context.Background(), RunInput{
|
|
Config: &cfg,
|
|
Transcript: transcript,
|
|
ModuleSpecs: []contracts.ModuleRunSpec{
|
|
{ModuleKey: "m", InstanceName: "m"},
|
|
},
|
|
})
|
|
resultCh <- err
|
|
}()
|
|
|
|
select {
|
|
case <-validatorStarted:
|
|
case <-time.After(350 * time.Millisecond):
|
|
t.Fatal("expected section-level validator work before all proposal sections complete")
|
|
}
|
|
|
|
close(releaseSectionOne)
|
|
if err := <-resultCh; err != nil {
|
|
t.Fatalf("Run error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunnerProposalAndValidationLLMCanOverlapWithinModule(t *testing.T) {
|
|
cfg := config.Default()
|
|
cfg.MaxSectionTokens = 3
|
|
cfg.MinSectionTokens = 0
|
|
cfg.TotalLLMConcurrency = 2
|
|
cfg.ProposalLLMConcurrency = 2
|
|
validationCap := 2
|
|
cfg.ValidationLLMConcurrency = &validationCap
|
|
|
|
transcript := &schema.Transcript{Segments: []schema.Segment{
|
|
{ID: 1, Text: "alpha one"},
|
|
{ID: 2, Text: "bravo two"},
|
|
}}
|
|
|
|
global, err := llm.NewScheduler(2)
|
|
if err != nil {
|
|
t.Fatalf("NewScheduler: %v", err)
|
|
}
|
|
scheduler := &trackingScheduler{inner: global}
|
|
|
|
proposalSectionOneStarted := make(chan struct{}, 1)
|
|
releaseProposalSectionOne := make(chan struct{})
|
|
|
|
releaseValidation := make(chan struct{})
|
|
client := &stageAwareStructuredClient{
|
|
startedSection: make(chan int, 8),
|
|
releaseValidation: releaseValidation,
|
|
}
|
|
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) {
|
|
seg := req.WorkingTranscript.Segments[0]
|
|
err := req.LLMScheduler.Run(context.Background(), func(context.Context) error {
|
|
if req.Section != nil && req.Section.Index == 1 {
|
|
select {
|
|
case proposalSectionOneStarted <- struct{}{}:
|
|
default:
|
|
}
|
|
<-releaseProposalSectionOne
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return []proposals.CorrectionProposal{
|
|
{TargetSegmentID: seg.ID, OriginalText: seg.Text, CorrectedText: strings.ToUpper(seg.Text), Confidence: 1},
|
|
}, nil
|
|
},
|
|
},
|
|
}})
|
|
|
|
resultCh := make(chan struct {
|
|
out RunOutput
|
|
err error
|
|
}, 1)
|
|
go func() {
|
|
out, err := r.Run(context.Background(), RunInput{
|
|
Config: &cfg,
|
|
Transcript: transcript,
|
|
ModuleSpecs: []contracts.ModuleRunSpec{{ModuleKey: "m", InstanceName: "m"}},
|
|
ProposalLLMScheduler: scheduler,
|
|
ValidationLLMScheduler: scheduler,
|
|
ValidationLLMClient: client,
|
|
})
|
|
resultCh <- struct {
|
|
out RunOutput
|
|
err error
|
|
}{out: out, err: err}
|
|
}()
|
|
|
|
select {
|
|
case <-proposalSectionOneStarted:
|
|
case <-time.After(350 * time.Millisecond):
|
|
t.Fatal("expected section-1 proposal to start")
|
|
}
|
|
|
|
select {
|
|
case section := <-client.startedSection:
|
|
if section != 0 {
|
|
t.Fatalf("expected first validation to start for section 0, got section %d", section)
|
|
}
|
|
case <-time.After(350 * time.Millisecond):
|
|
t.Fatal("expected validation call for section 0 while later section proposal still running")
|
|
}
|
|
|
|
close(releaseProposalSectionOne)
|
|
close(releaseValidation)
|
|
|
|
result := <-resultCh
|
|
if result.err != nil {
|
|
t.Fatalf("Run error: %v", result.err)
|
|
}
|
|
if got := atomic.LoadInt32(&scheduler.maxInFlight); got > 2 {
|
|
t.Fatalf("expected combined proposal+validation in-flight <= 2, got %d", got)
|
|
}
|
|
if got := atomic.LoadInt32(&scheduler.maxInFlight); got < 2 {
|
|
t.Fatalf("expected observed overlap/in-flight utilization of at least 2, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestRunnerValidationLLMConcurrencyRespected(t *testing.T) {
|
|
cfg := config.Default()
|
|
cfg.MaxSectionTokens = 3
|
|
cfg.MinSectionTokens = 0
|
|
cfg.TotalLLMConcurrency = 4
|
|
cfg.ProposalLLMConcurrency = 4
|
|
validationCap := 1
|
|
cfg.ValidationLLMConcurrency = &validationCap
|
|
|
|
transcript := &schema.Transcript{Segments: []schema.Segment{
|
|
{ID: 1, Text: "one one"},
|
|
{ID: 2, Text: "two two"},
|
|
{ID: 3, Text: "three three"},
|
|
}}
|
|
|
|
validationSchedulerInner, err := llm.NewScheduler(1)
|
|
if err != nil {
|
|
t.Fatalf("NewScheduler(validation): %v", err)
|
|
}
|
|
validationScheduler := &trackingScheduler{inner: validationSchedulerInner}
|
|
|
|
releaseValidation := make(chan struct{})
|
|
client := &stageAwareStructuredClient{
|
|
startedSection: make(chan int, 16),
|
|
releaseValidation: releaseValidation,
|
|
}
|
|
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) {
|
|
seg := req.WorkingTranscript.Segments[0]
|
|
return []proposals.CorrectionProposal{
|
|
{TargetSegmentID: seg.ID, OriginalText: seg.Text, CorrectedText: strings.ToUpper(seg.Text), Confidence: 1},
|
|
}, nil
|
|
},
|
|
},
|
|
}})
|
|
|
|
resultCh := make(chan error, 1)
|
|
go func() {
|
|
_, err := r.Run(context.Background(), RunInput{
|
|
Config: &cfg,
|
|
Transcript: transcript,
|
|
ModuleSpecs: []contracts.ModuleRunSpec{{ModuleKey: "m", InstanceName: "m"}},
|
|
ValidationLLMScheduler: validationScheduler,
|
|
ValidationLLMClient: client,
|
|
})
|
|
resultCh <- err
|
|
}()
|
|
|
|
select {
|
|
case <-client.startedSection:
|
|
case <-time.After(350 * time.Millisecond):
|
|
t.Fatal("expected validation to start")
|
|
}
|
|
close(releaseValidation)
|
|
|
|
if err := <-resultCh; err != nil {
|
|
t.Fatalf("Run error: %v", err)
|
|
}
|
|
if got := atomic.LoadInt32(&validationScheduler.maxInFlight); got > 1 {
|
|
t.Fatalf("expected validation in-flight <= 1, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestRunnerMixedProposalValidationFIFOOrder(t *testing.T) {
|
|
cfg := config.Default()
|
|
cfg.MaxSectionTokens = 3
|
|
cfg.MinSectionTokens = 0
|
|
cfg.TotalLLMConcurrency = 1
|
|
cfg.ProposalLLMConcurrency = 1
|
|
validationCap := 1
|
|
cfg.ValidationLLMConcurrency = &validationCap
|
|
|
|
transcript := &schema.Transcript{Segments: []schema.Segment{
|
|
{ID: 1, Text: "alpha one"},
|
|
{ID: 2, Text: "bravo two"},
|
|
}}
|
|
|
|
global, err := llm.NewScheduler(1)
|
|
if err != nil {
|
|
t.Fatalf("NewScheduler: %v", err)
|
|
}
|
|
proposalScheduler := global
|
|
validationScheduler := global
|
|
|
|
events := make(chan string, 8)
|
|
releaseProposalSectionOne := make(chan struct{})
|
|
releaseValidation := make(chan struct{})
|
|
sectionZeroEntered := make(chan struct{})
|
|
sectionOneAttempted := make(chan struct{}, 1)
|
|
|
|
client := &stageAwareStructuredClient{
|
|
startedSection: make(chan int, 8),
|
|
releaseValidation: releaseValidation,
|
|
eventSink: events,
|
|
}
|
|
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) {
|
|
seg := req.WorkingTranscript.Segments[0]
|
|
if req.Section != nil && req.Section.Index == 1 {
|
|
<-sectionZeroEntered
|
|
select {
|
|
case sectionOneAttempted <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
err := req.LLMScheduler.Run(context.Background(), func(context.Context) error {
|
|
if req.Section != nil {
|
|
events <- "p" + strconv.Itoa(req.Section.Index)
|
|
if req.Section.Index == 0 {
|
|
select {
|
|
case sectionZeroEntered <- struct{}{}:
|
|
default:
|
|
}
|
|
<-sectionOneAttempted
|
|
}
|
|
if req.Section.Index == 1 {
|
|
<-releaseProposalSectionOne
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return []proposals.CorrectionProposal{
|
|
{TargetSegmentID: seg.ID, OriginalText: seg.Text, CorrectedText: strings.ToUpper(seg.Text), Confidence: 1},
|
|
}, nil
|
|
},
|
|
},
|
|
}})
|
|
|
|
resultCh := make(chan error, 1)
|
|
go func() {
|
|
_, err := r.Run(context.Background(), RunInput{
|
|
Config: &cfg,
|
|
Transcript: transcript,
|
|
ModuleSpecs: []contracts.ModuleRunSpec{{ModuleKey: "m", InstanceName: "m"}},
|
|
ProposalLLMScheduler: proposalScheduler,
|
|
ValidationLLMScheduler: validationScheduler,
|
|
ValidationLLMClient: client,
|
|
})
|
|
resultCh <- err
|
|
}()
|
|
|
|
if got := <-events; got != "p0" {
|
|
t.Fatalf("expected first event p0, got %q", got)
|
|
}
|
|
if got := <-events; got != "p1" {
|
|
t.Fatalf("expected second event p1 (FIFO queued proposal), got %q", got)
|
|
}
|
|
|
|
close(releaseProposalSectionOne)
|
|
close(releaseValidation)
|
|
|
|
if got := <-events; !strings.HasPrefix(got, "v") {
|
|
t.Fatalf("expected validator event after queued p1, got %q", got)
|
|
}
|
|
|
|
if err := <-resultCh; err != nil {
|
|
t.Fatalf("Run error: %v", err)
|
|
}
|
|
}
|
|
|
|
var sectionStagePattern = regexp.MustCompile(`section-(\d+)`)
|
|
|
|
type stageAwareStructuredClient struct {
|
|
startedSection chan int
|
|
releaseValidation <-chan struct{}
|
|
eventSink chan<- string
|
|
}
|
|
|
|
type captureContractStructuredClient struct {
|
|
lastRequest contracts.StructuredCompletionRequest
|
|
}
|
|
|
|
func (c *captureContractStructuredClient) CompleteStructured(ctx context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
|
|
_ = ctx
|
|
c.lastRequest = req
|
|
if target, ok := out.(*validators.LLMValidationResponse); ok {
|
|
*target = validators.LLMValidationResponse{
|
|
Validations: []validators.LLMValidationDecision{
|
|
{CorrectionIndex: 0, Approved: true, Confidence: 0.9, Reason: "ok"},
|
|
},
|
|
}
|
|
}
|
|
return contracts.StructuredCompletionResponse{}, nil
|
|
}
|
|
|
|
func (c *stageAwareStructuredClient) CompleteStructured(ctx context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
|
|
section := parseSectionFromStage(req.StageName)
|
|
if c.startedSection != nil {
|
|
select {
|
|
case c.startedSection <- section:
|
|
default:
|
|
}
|
|
}
|
|
if c.eventSink != nil {
|
|
c.eventSink <- "v" + strconv.Itoa(section)
|
|
}
|
|
if c.releaseValidation != nil {
|
|
select {
|
|
case <-c.releaseValidation:
|
|
case <-ctx.Done():
|
|
return contracts.StructuredCompletionResponse{}, ctx.Err()
|
|
}
|
|
}
|
|
|
|
target := out.(*validators.LLMValidationResponse)
|
|
*target = validators.LLMValidationResponse{
|
|
Validations: []validators.LLMValidationDecision{
|
|
{CorrectionIndex: section, Approved: true, Confidence: 0.99, Reason: "ok"},
|
|
},
|
|
}
|
|
return contracts.StructuredCompletionResponse{}, nil
|
|
}
|
|
|
|
func parseSectionFromStage(stage string) int {
|
|
match := sectionStagePattern.FindStringSubmatch(stage)
|
|
if len(match) != 2 {
|
|
return 0
|
|
}
|
|
n, err := strconv.Atoi(match[1])
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return n
|
|
}
|
|
|
|
func TestValidationLLMClientAdapterPassesResponseSchema(t *testing.T) {
|
|
capture := &captureContractStructuredClient{}
|
|
adapter := validationLLMClientAdapter{client: capture}
|
|
schema := responseschema.MustLookup(responseschema.ValidatorDecisionSetKey)
|
|
|
|
_, err := adapter.CompleteStructured(context.Background(), validators.StructuredCompletionRequest{
|
|
StageName: "module:validator:batch-0000",
|
|
Messages: []validators.LLMMessage{
|
|
{Role: "system", Content: "system"},
|
|
{Role: "user", Content: "user"},
|
|
},
|
|
Model: "test-model",
|
|
ResponseSchema: &schema,
|
|
}, &validators.LLMValidationResponse{})
|
|
if err != nil {
|
|
t.Fatalf("CompleteStructured error: %v", err)
|
|
}
|
|
|
|
if capture.lastRequest.ResponseSchema == nil {
|
|
t.Fatalf("expected response schema to be forwarded")
|
|
}
|
|
if capture.lastRequest.ResponseSchema.ID != schema.ID ||
|
|
capture.lastRequest.ResponseSchema.Version != schema.Version ||
|
|
capture.lastRequest.ResponseSchema.Name != schema.Name ||
|
|
capture.lastRequest.ResponseSchema.SHA256 != schema.SHA256 {
|
|
t.Fatalf("unexpected forwarded schema metadata: got=%+v want=%+v", *capture.lastRequest.ResponseSchema, schema)
|
|
}
|
|
}
|
|
|
|
type trackingScheduler struct {
|
|
inner contracts.LLMScheduler
|
|
inFlight int32
|
|
maxInFlight int32
|
|
}
|
|
|
|
func (s *trackingScheduler) Run(ctx context.Context, fn func(context.Context) error) error {
|
|
return s.inner.Run(ctx, func(callCtx context.Context) error {
|
|
current := atomic.AddInt32(&s.inFlight, 1)
|
|
for {
|
|
prior := atomic.LoadInt32(&s.maxInFlight)
|
|
if current <= prior || atomic.CompareAndSwapInt32(&s.maxInFlight, prior, current) {
|
|
break
|
|
}
|
|
}
|
|
defer atomic.AddInt32(&s.inFlight, -1)
|
|
return fn(callCtx)
|
|
})
|
|
}
|
|
|
|
func TestRunnerSkippedRecorded(t *testing.T) {
|
|
transcript := &schema.Transcript{Segments: []schema.Segment{{ID: 1, Speaker: "A", Start: 0, End: 1, Text: "word word"}}}
|
|
r := New(fakeFactory{modules: map[string]contracts.TranscriptModule{
|
|
"m": fakeModule{key: "m", policy: proposals.ReplacementPolicyRequireUnique, proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
|
|
return []proposals.CorrectionProposal{{TargetSegmentID: 1, OriginalText: "word", CorrectedText: "term", Confidence: 1}}, nil
|
|
}},
|
|
}})
|
|
out, err := r.Run(context.Background(), RunInput{Transcript: transcript, ModuleSpecs: []contracts.ModuleRunSpec{{ModuleKey: "m", InstanceName: "m"}}})
|
|
if err != nil {
|
|
t.Fatalf("Run error: %v", err)
|
|
}
|
|
if len(out.ModuleResults[0].SkippedChanges) != 1 {
|
|
t.Fatalf("expected one skipped change, got %+v", out.ModuleResults[0].SkippedChanges)
|
|
}
|
|
}
|
|
|
|
func TestRunnerFailureReturnsPartialProgress(t *testing.T) {
|
|
transcript := &schema.Transcript{Segments: []schema.Segment{{ID: 1, Speaker: "A", Start: 0, End: 1, Text: "teh cat"}}}
|
|
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, proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
|
|
return nil, errors.New("boom")
|
|
}},
|
|
}})
|
|
|
|
out, err := r.Run(context.Background(), RunInput{Transcript: transcript, ModuleSpecs: []contracts.ModuleRunSpec{{ModuleKey: "m1", InstanceName: "m1"}, {ModuleKey: "m2", InstanceName: "m2"}}})
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
if out.FinalTranscript.Segments[0].Text != "the cat" {
|
|
t.Fatalf("expected partial transcript progress preserved, got %q", out.FinalTranscript.Segments[0].Text)
|
|
}
|
|
if len(out.ModuleResults) != 2 || out.ModuleResults[1].Status != ModuleStatusFailed {
|
|
t.Fatalf("expected second module failed in results, got %+v", out.ModuleResults)
|
|
}
|
|
}
|
|
|
|
func TestRunnerDoesNotMutateInputTranscript(t *testing.T) {
|
|
transcript := &schema.Transcript{Segments: []schema.Segment{{ID: 1, Speaker: "A", Start: 0, End: 1, Text: "teh"}}}
|
|
before := transcript.Segments[0].Text
|
|
r := New(fakeFactory{modules: map[string]contracts.TranscriptModule{
|
|
"m": fakeModule{key: "m", policy: proposals.ReplacementPolicyRequireUnique, proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
|
|
return []proposals.CorrectionProposal{{TargetSegmentID: 1, OriginalText: "teh", CorrectedText: "the", Confidence: 1}}, nil
|
|
}},
|
|
}})
|
|
|
|
_, err := r.Run(context.Background(), RunInput{Config: ptrConfig(config.Default()), Transcript: transcript, ModuleSpecs: []contracts.ModuleRunSpec{{ModuleKey: "m", InstanceName: "m"}}})
|
|
if err != nil {
|
|
t.Fatalf("Run error: %v", err)
|
|
}
|
|
if transcript.Segments[0].Text != before {
|
|
t.Fatalf("expected input transcript unchanged, got %q", transcript.Segments[0].Text)
|
|
}
|
|
}
|
|
|
|
func TestRunnerRepeatedModuleInstanceNames(t *testing.T) {
|
|
specs, err := contracts.ResolveModuleRunSpecs([]string{"glossary", "glossary"})
|
|
if err != nil {
|
|
t.Fatalf("ResolveModuleRunSpecs error: %v", err)
|
|
}
|
|
if specs[0].InstanceName != "glossary_1" || specs[1].InstanceName != "glossary_2" {
|
|
t.Fatalf("unexpected instance names: %+v", specs)
|
|
}
|
|
}
|
|
|
|
func TestRunnerValidatorApprovedProposalApplied(t *testing.T) {
|
|
transcript := &schema.Transcript{Segments: []schema.Segment{{ID: 1, Text: "teh cat"}}}
|
|
allowAll := fakeValidator{name: "allow", validateF: func(req contracts.ValidationRequest) (validators.Result, error) {
|
|
decisions := make([]validators.Decision, len(req.CandidateProposal))
|
|
for i, p := range req.CandidateProposal {
|
|
decisions[i] = validators.Decision{ProposalIndex: p.ProposalIndex, Approved: true, ReasonCode: validators.ReasonApproved, Message: "approved"}
|
|
}
|
|
return validators.Result{ValidatorName: "allow", Decisions: decisions}, nil
|
|
}}
|
|
r := New(fakeFactory{modules: map[string]contracts.TranscriptModule{
|
|
"m": fakeModule{
|
|
key: "m",
|
|
policy: proposals.ReplacementPolicyRequireUnique,
|
|
validators: []contracts.Validator{allowAll},
|
|
proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
|
|
return []proposals.CorrectionProposal{{TargetSegmentID: 1, OriginalText: "teh", CorrectedText: "the", Confidence: 1}}, nil
|
|
},
|
|
},
|
|
}})
|
|
|
|
out, err := r.Run(context.Background(), RunInput{Transcript: transcript, ModuleSpecs: []contracts.ModuleRunSpec{{ModuleKey: "m", InstanceName: "m"}}})
|
|
if err != nil {
|
|
t.Fatalf("Run error: %v", err)
|
|
}
|
|
if out.FinalTranscript.Segments[0].Text != "the cat" {
|
|
t.Fatalf("expected proposal applied, got %q", out.FinalTranscript.Segments[0].Text)
|
|
}
|
|
if len(out.ModuleResults[0].ValidatorDecisions) != 1 {
|
|
t.Fatalf("expected validator decisions recorded")
|
|
}
|
|
}
|
|
|
|
func TestRunnerValidatorRejectedProposalNotApplied(t *testing.T) {
|
|
transcript := &schema.Transcript{Segments: []schema.Segment{{ID: 1, Text: "teh cat"}}}
|
|
rejectAll := fakeValidator{name: "reject", validateF: func(req contracts.ValidationRequest) (validators.Result, error) {
|
|
decisions := make([]validators.Decision, len(req.CandidateProposal))
|
|
for i, p := range req.CandidateProposal {
|
|
decisions[i] = validators.Decision{ProposalIndex: p.ProposalIndex, Approved: false, ReasonCode: validators.ReasonNoEffect, Message: "rejected"}
|
|
}
|
|
return validators.Result{ValidatorName: "reject", Decisions: decisions}, nil
|
|
}}
|
|
r := New(fakeFactory{modules: map[string]contracts.TranscriptModule{
|
|
"m": fakeModule{key: "m", policy: proposals.ReplacementPolicyRequireUnique, validators: []contracts.Validator{rejectAll}, proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
|
|
return []proposals.CorrectionProposal{{TargetSegmentID: 1, OriginalText: "teh", CorrectedText: "the", Confidence: 1}}, nil
|
|
}},
|
|
}})
|
|
out, err := r.Run(context.Background(), RunInput{Transcript: transcript, ModuleSpecs: []contracts.ModuleRunSpec{{ModuleKey: "m", InstanceName: "m"}}})
|
|
if err != nil {
|
|
t.Fatalf("Run error: %v", err)
|
|
}
|
|
if out.FinalTranscript.Segments[0].Text != "teh cat" {
|
|
t.Fatalf("expected rejected proposal not applied, got %q", out.FinalTranscript.Segments[0].Text)
|
|
}
|
|
if len(out.ModuleResults[0].ValidatorRejected) != 1 {
|
|
t.Fatalf("expected validator rejection recorded, got %+v", out.ModuleResults[0].ValidatorRejected)
|
|
}
|
|
}
|
|
|
|
func TestRunnerMultipleValidatorsRunInOrderAndFilterSurvivors(t *testing.T) {
|
|
transcript := &schema.Transcript{Segments: []schema.Segment{{ID: 1, Text: "one two"}}}
|
|
first := fakeValidator{name: "first", validateF: func(req contracts.ValidationRequest) (validators.Result, error) {
|
|
if len(req.CandidateProposal) != 2 {
|
|
t.Fatalf("expected first validator to see 2 candidates, got %d", len(req.CandidateProposal))
|
|
}
|
|
return validators.Result{
|
|
ValidatorName: "first",
|
|
Decisions: []validators.Decision{
|
|
{ProposalIndex: req.CandidateProposal[0].ProposalIndex, Approved: true, ReasonCode: validators.ReasonApproved, Message: "ok"},
|
|
{ProposalIndex: req.CandidateProposal[1].ProposalIndex, Approved: false, ReasonCode: validators.ReasonNoEffect, Message: "reject"},
|
|
},
|
|
}, nil
|
|
}}
|
|
second := fakeValidator{name: "second", validateF: func(req contracts.ValidationRequest) (validators.Result, error) {
|
|
if len(req.CandidateProposal) != 1 {
|
|
t.Fatalf("expected second validator to see only survivors, got %d", len(req.CandidateProposal))
|
|
}
|
|
return validators.Result{ValidatorName: "second", Decisions: []validators.Decision{{ProposalIndex: req.CandidateProposal[0].ProposalIndex, Approved: true, ReasonCode: validators.ReasonApproved, Message: "ok"}}}, nil
|
|
}}
|
|
r := New(fakeFactory{modules: map[string]contracts.TranscriptModule{
|
|
"m": fakeModule{key: "m", policy: proposals.ReplacementPolicyRequireUnique, validators: []contracts.Validator{first, second}, proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
|
|
return []proposals.CorrectionProposal{
|
|
{TargetSegmentID: 1, OriginalText: "one", CorrectedText: "ONE", Confidence: 1},
|
|
{TargetSegmentID: 1, OriginalText: "two", CorrectedText: "TWO", Confidence: 1},
|
|
}, nil
|
|
}},
|
|
}})
|
|
|
|
out, err := r.Run(context.Background(), RunInput{Transcript: transcript, ModuleSpecs: []contracts.ModuleRunSpec{{ModuleKey: "m", InstanceName: "m"}}})
|
|
if err != nil {
|
|
t.Fatalf("Run error: %v", err)
|
|
}
|
|
if out.FinalTranscript.Segments[0].Text != "ONE two" {
|
|
t.Fatalf("expected only survivor applied, got %q", out.FinalTranscript.Segments[0].Text)
|
|
}
|
|
}
|
|
|
|
func TestRunnerValidatorCardinalityErrorStopsPipelineWithPartialProgress(t *testing.T) {
|
|
transcript := &schema.Transcript{Segments: []schema.Segment{{ID: 1, Text: "teh cat"}}}
|
|
good := 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
|
|
}}
|
|
badValidator := fakeValidator{name: "bad", validateF: func(req contracts.ValidationRequest) (validators.Result, error) {
|
|
// Missing one decision triggers cardinality error.
|
|
return validators.Result{ValidatorName: "bad", Decisions: nil}, nil
|
|
}}
|
|
bad := fakeModule{key: "m2", policy: proposals.ReplacementPolicyRequireUnique, validators: []contracts.Validator{badValidator}, proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
|
|
return []proposals.CorrectionProposal{{TargetSegmentID: 1, OriginalText: "cat", CorrectedText: "dog", Confidence: 1}}, nil
|
|
}}
|
|
r := New(fakeFactory{modules: map[string]contracts.TranscriptModule{"m1": good, "m2": bad}})
|
|
|
|
out, err := r.Run(context.Background(), RunInput{Transcript: transcript, ModuleSpecs: []contracts.ModuleRunSpec{{ModuleKey: "m1", InstanceName: "m1"}, {ModuleKey: "m2", InstanceName: "m2"}}})
|
|
if err == nil {
|
|
t.Fatal("expected cardinality error")
|
|
}
|
|
if out.FinalTranscript.Segments[0].Text != "the cat" {
|
|
t.Fatalf("expected partial progress preserved, got %q", out.FinalTranscript.Segments[0].Text)
|
|
}
|
|
if len(out.ModuleResults) != 2 || out.ModuleResults[1].Status != ModuleStatusFailed {
|
|
t.Fatalf("expected second module failed")
|
|
}
|
|
}
|
|
|
|
func TestRunnerApplicationSkipAfterValidatorApprovalReported(t *testing.T) {
|
|
transcript := &schema.Transcript{Segments: []schema.Segment{{ID: 1, Text: "word word"}}}
|
|
allow := fakeValidator{name: "allow", validateF: func(req contracts.ValidationRequest) (validators.Result, error) {
|
|
return validators.Result{ValidatorName: "allow", Decisions: []validators.Decision{{ProposalIndex: req.CandidateProposal[0].ProposalIndex, Approved: true, ReasonCode: validators.ReasonApproved, Message: "ok"}}}, nil
|
|
}}
|
|
r := New(fakeFactory{modules: map[string]contracts.TranscriptModule{
|
|
"m": fakeModule{key: "m", policy: proposals.ReplacementPolicyRequireUnique, validators: []contracts.Validator{allow}, proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
|
|
return []proposals.CorrectionProposal{{TargetSegmentID: 1, OriginalText: "word", CorrectedText: "term", Confidence: 1}}, nil
|
|
}},
|
|
}})
|
|
out, err := r.Run(context.Background(), RunInput{Transcript: transcript, ModuleSpecs: []contracts.ModuleRunSpec{{ModuleKey: "m", InstanceName: "m"}}})
|
|
if err != nil {
|
|
t.Fatalf("Run error: %v", err)
|
|
}
|
|
if len(out.ModuleResults[0].SkippedChanges) != 1 {
|
|
t.Fatalf("expected application skip recorded")
|
|
}
|
|
if len(out.ModuleResults[0].ValidatorRejected) != 0 {
|
|
t.Fatalf("expected no validator rejection")
|
|
}
|
|
}
|
|
|
|
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 TestRunnerLLMValidatorMalformedResponseRejectsBatchAndKeepsPartialProgress(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.Fatalf("expected malformed validator response to downgrade, got %v", err)
|
|
}
|
|
if out.FinalTranscript.Segments[0].Text != "the cat" {
|
|
t.Fatalf("expected partial progress retained")
|
|
}
|
|
if len(out.ModuleResults) != 2 || len(out.ModuleResults[1].ValidatorRejected) != 1 {
|
|
t.Fatalf("expected second module rejection, got %+v", out.ModuleResults)
|
|
}
|
|
if len(out.ModuleResults[1].Warnings) != 1 || out.ModuleResults[1].Warnings[0].ReasonCode != validators.ReasonValidatorMalformed {
|
|
t.Fatalf("expected malformed warning, got %+v", out.ModuleResults[1].Warnings)
|
|
}
|
|
}
|
|
|
|
func TestRunnerLLMValidatorMissingDecisionRejectsBatch(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.Fatalf("expected missing decision downgrade, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunnerLLMValidatorDuplicateDecisionRejectsBatch(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.Fatalf("expected duplicate decision downgrade, got %v", err)
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
type proposalGenerationModule struct {
|
|
key string
|
|
policy proposals.ReplacementPolicy
|
|
}
|
|
|
|
func (m proposalGenerationModule) Key() string { return m.key }
|
|
func (m proposalGenerationModule) ReplacementPolicy() proposals.ReplacementPolicy { return m.policy }
|
|
func (m proposalGenerationModule) Validators() []contracts.Validator { return nil }
|
|
func (m proposalGenerationModule) Propose(ctx context.Context, req contracts.ProposalRequest) (contracts.ProposalResult, error) {
|
|
result, err := proposal_generation.GenerateCandidates(ctx, proposal_generation.Request{
|
|
ModuleKey: req.RunSpec.ModuleKey,
|
|
ModuleInstance: req.RunSpec.InstanceName,
|
|
ReplacementPolicy: req.RunSpec.ReplacementPolicy,
|
|
WorkingTranscript: req.WorkingTranscript,
|
|
Config: req.Config,
|
|
Glossary: req.Glossary,
|
|
Messages: []contracts.LLMMessage{
|
|
{Role: "system", Content: "return transcript corrections"},
|
|
{Role: "user", Content: "produce one safe correction"},
|
|
},
|
|
LLMClient: req.LLMClient,
|
|
Scheduler: req.LLMScheduler,
|
|
DiagnosticsDir: req.DiagnosticsDir,
|
|
})
|
|
if err != nil {
|
|
return contracts.ProposalResult{}, err
|
|
}
|
|
return contracts.ProposalResult{Proposals: result.Corrections, Warnings: result.Warnings}, nil
|
|
}
|
|
|
|
type fakeProposalStructuredClient struct {
|
|
responses []proposal_generation.StructuredCorrectionSet
|
|
}
|
|
|
|
func (f *fakeProposalStructuredClient) CompleteStructured(ctx context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
|
|
_ = ctx
|
|
_ = req
|
|
if len(f.responses) == 0 {
|
|
return contracts.StructuredCompletionResponse{}, errors.New("unexpected call")
|
|
}
|
|
target, ok := out.(*proposal_generation.StructuredCorrectionSet)
|
|
if !ok {
|
|
return contracts.StructuredCompletionResponse{}, errors.New("unexpected output type")
|
|
}
|
|
*target = f.responses[0]
|
|
f.responses = f.responses[1:]
|
|
return contracts.StructuredCompletionResponse{}, nil
|
|
}
|
|
|
|
func TestRunnerProposalGenerationHelperFlowsThroughPipeline(t *testing.T) {
|
|
client := &fakeProposalStructuredClient{
|
|
responses: []proposal_generation.StructuredCorrectionSet{
|
|
{
|
|
Corrections: []proposal_generation.StructuredCorrectionProposal{
|
|
{TargetSegmentID: 1, OriginalText: "teh", CorrectedText: "the", Confidence: 0.9},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
scheduler := &countingScheduler{}
|
|
cfg := config.Default()
|
|
diagDir := t.TempDir()
|
|
|
|
r := New(fakeFactory{modules: map[string]contracts.TranscriptModule{
|
|
"m": proposalGenerationModule{key: "m", policy: proposals.ReplacementPolicyRequireUnique},
|
|
}})
|
|
|
|
out, err := r.Run(context.Background(), RunInput{
|
|
Config: &cfg,
|
|
Transcript: &schema.Transcript{Segments: []schema.Segment{{ID: 1, Text: "teh cat"}}},
|
|
ModuleSpecs: []contracts.ModuleRunSpec{{ModuleKey: "m", InstanceName: "m"}},
|
|
ProposalLLMClient: client,
|
|
ProposalLLMScheduler: scheduler,
|
|
ProposalDiagnosticsDir: diagDir,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected run error: %v", err)
|
|
}
|
|
if out.FinalTranscript.Segments[0].Text != "the cat" {
|
|
t.Fatalf("expected proposal-generated correction to apply, got %q", out.FinalTranscript.Segments[0].Text)
|
|
}
|
|
if scheduler.runs != 1 {
|
|
t.Fatalf("expected proposal scheduler use, got %d runs", scheduler.runs)
|
|
}
|
|
if len(out.ModuleResults) != 1 || len(out.ModuleResults[0].AppliedChanges) != 1 {
|
|
t.Fatalf("expected one applied change, got %+v", out.ModuleResults)
|
|
}
|
|
matches, globErr := filepath.Glob(filepath.Join(diagDir, "m", "*proposal-generation*response-payload.json"))
|
|
if globErr != nil {
|
|
t.Fatalf("glob diagnostics: %v", globErr)
|
|
}
|
|
if len(matches) == 0 {
|
|
t.Fatalf("expected proposal-generation diagnostics artifacts in %s", filepath.Join(diagDir, "m"))
|
|
}
|
|
}
|
|
|
|
func TestRunnerGrammarModuleUsesConfidenceThreshold(t *testing.T) {
|
|
client := &fakeProposalStructuredClient{
|
|
responses: []proposal_generation.StructuredCorrectionSet{
|
|
{
|
|
Corrections: []proposal_generation.StructuredCorrectionProposal{
|
|
{TargetSegmentID: 1, OriginalText: "hello ,world", CorrectedText: "Hello, world", Confidence: 0.5},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
cfg := config.Default()
|
|
cfg.Thresholds.Grammar = 0.9
|
|
factory := modules.NewFactory(modules.Dependencies{})
|
|
out, err := New(factory).Run(context.Background(), RunInput{
|
|
Config: &cfg,
|
|
Transcript: &schema.Transcript{Segments: []schema.Segment{{ID: 1, Text: "hello ,world"}}},
|
|
Glossary: &schema.Glossary{},
|
|
ModuleSpecs: []contracts.ModuleRunSpec{{ModuleKey: "grammar", InstanceName: "grammar"}},
|
|
ProposalLLMClient: client,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Run error: %v", err)
|
|
}
|
|
if out.FinalTranscript.Segments[0].Text != "hello ,world" {
|
|
t.Fatalf("expected no changes due to confidence threshold, got %q", out.FinalTranscript.Segments[0].Text)
|
|
}
|
|
if len(out.ModuleResults) != 1 || len(out.ModuleResults[0].ValidatorRejected) == 0 {
|
|
t.Fatalf("expected validator rejection, got %+v", out.ModuleResults)
|
|
}
|
|
if out.ModuleResults[0].ValidatorRejected[0].ReasonCode != validators.ReasonLowConfidence {
|
|
t.Fatalf("expected low confidence reason, got %+v", out.ModuleResults[0].ValidatorRejected[0])
|
|
}
|
|
}
|
|
|
|
func TestRunnerGlossaryModuleUsesConfidenceThreshold(t *testing.T) {
|
|
client := &fakeProposalStructuredClient{
|
|
responses: []proposal_generation.StructuredCorrectionSet{
|
|
{
|
|
Corrections: []proposal_generation.StructuredCorrectionProposal{
|
|
{TargetSegmentID: 1, OriginalText: "gestures", CorrectedText: "Jesters", Confidence: 0.5},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
cfg := config.Default()
|
|
cfg.Thresholds.Glossary = 0.9
|
|
factory := modules.NewFactory(modules.Dependencies{})
|
|
out, err := New(factory).Run(context.Background(), RunInput{
|
|
Config: &cfg,
|
|
Transcript: &schema.Transcript{Segments: []schema.Segment{{ID: 1, Text: "There were gestures"}}},
|
|
Glossary: &schema.Glossary{Entries: []schema.GlossaryEntry{{Name: "Jesters", Category: "faction", Summary: "Faction"}}},
|
|
ModuleSpecs: []contracts.ModuleRunSpec{{ModuleKey: "glossary", InstanceName: "glossary"}},
|
|
ProposalLLMClient: client,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Run error: %v", err)
|
|
}
|
|
if out.FinalTranscript.Segments[0].Text != "There were gestures" {
|
|
t.Fatalf("expected no changes due to glossary confidence threshold, got %q", out.FinalTranscript.Segments[0].Text)
|
|
}
|
|
if len(out.ModuleResults) != 1 || len(out.ModuleResults[0].ValidatorRejected) == 0 {
|
|
t.Fatalf("expected validator rejection, got %+v", out.ModuleResults)
|
|
}
|
|
if out.ModuleResults[0].ValidatorRejected[0].ReasonCode != validators.ReasonLowConfidence {
|
|
t.Fatalf("expected low confidence reason, got %+v", out.ModuleResults[0].ValidatorRejected[0])
|
|
}
|
|
}
|