Complete Phase 7 runner orchestration
This commit is contained in:
163
internal/framework/runner/runner.go
Normal file
163
internal/framework/runner/runner.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"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/proposals"
|
||||
)
|
||||
|
||||
const (
|
||||
ModuleStatusSuccess = "success"
|
||||
ModuleStatusFailed = "failed"
|
||||
)
|
||||
|
||||
// ModuleFactory resolves one module instance for one run spec.
|
||||
type ModuleFactory interface {
|
||||
ModuleForSpec(spec contracts.ModuleRunSpec) (contracts.TranscriptModule, error)
|
||||
}
|
||||
|
||||
// Runner executes module instances sequentially against a working transcript.
|
||||
type Runner struct {
|
||||
factory ModuleFactory
|
||||
}
|
||||
|
||||
// ModuleResult captures deterministic per-module execution output.
|
||||
type ModuleResult struct {
|
||||
ModuleKey string `json:"module_key"`
|
||||
ModuleInstance string `json:"module_instance"`
|
||||
ReplacementPolicy proposals.ReplacementPolicy `json:"replacement_policy"`
|
||||
Status string `json:"status"`
|
||||
ProposalCount int `json:"proposal_count"`
|
||||
AppliedChanges []proposals.AppliedChange `json:"applied_changes,omitempty"`
|
||||
SkippedChanges []proposals.SkippedChange `json:"skipped_changes,omitempty"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
CompletedAt time.Time `json:"completed_at"`
|
||||
}
|
||||
|
||||
// RunInput is the deterministic runner input.
|
||||
type RunInput struct {
|
||||
Config *config.Config
|
||||
Transcript *schema.Transcript
|
||||
Glossary *schema.Glossary
|
||||
ModuleSpecs []contracts.ModuleRunSpec
|
||||
}
|
||||
|
||||
// RunOutput is the deterministic runner output.
|
||||
type RunOutput struct {
|
||||
FinalTranscript *schema.Transcript `json:"-"`
|
||||
ModuleResults []ModuleResult `json:"module_results"`
|
||||
}
|
||||
|
||||
func New(factory ModuleFactory) *Runner {
|
||||
return &Runner{factory: factory}
|
||||
}
|
||||
|
||||
func (r *Runner) Run(ctx context.Context, input RunInput) (RunOutput, error) {
|
||||
if r == nil || r.factory == nil {
|
||||
return RunOutput{}, fmt.Errorf("runner module factory is required")
|
||||
}
|
||||
|
||||
working := cloneTranscript(input.Transcript)
|
||||
results := make([]ModuleResult, 0, len(input.ModuleSpecs))
|
||||
|
||||
for _, spec := range input.ModuleSpecs {
|
||||
startedAt := time.Now().UTC()
|
||||
module, err := r.factory.ModuleForSpec(spec)
|
||||
if err != nil {
|
||||
failed := ModuleResult{
|
||||
ModuleKey: spec.ModuleKey,
|
||||
ModuleInstance: spec.InstanceName,
|
||||
Status: ModuleStatusFailed,
|
||||
ErrorMessage: err.Error(),
|
||||
StartedAt: startedAt,
|
||||
CompletedAt: time.Now().UTC(),
|
||||
}
|
||||
results = append(results, failed)
|
||||
return RunOutput{FinalTranscript: working, ModuleResults: results}, fmt.Errorf("module %q setup failed: %w", spec.InstanceName, err)
|
||||
}
|
||||
|
||||
policy := module.ReplacementPolicy()
|
||||
proposed, err := module.Propose(ctx, contracts.ProposalRequest{
|
||||
ExecutionContext: contracts.ExecutionContext{
|
||||
Config: input.Config,
|
||||
WorkingTranscript: working,
|
||||
Glossary: input.Glossary,
|
||||
},
|
||||
RunSpec: contracts.ModuleRunSpec{
|
||||
ModuleKey: spec.ModuleKey,
|
||||
InstanceName: spec.InstanceName,
|
||||
ReplacementPolicy: policy,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
failed := ModuleResult{
|
||||
ModuleKey: spec.ModuleKey,
|
||||
ModuleInstance: spec.InstanceName,
|
||||
ReplacementPolicy: policy,
|
||||
Status: ModuleStatusFailed,
|
||||
ErrorMessage: err.Error(),
|
||||
StartedAt: startedAt,
|
||||
CompletedAt: time.Now().UTC(),
|
||||
}
|
||||
results = append(results, failed)
|
||||
return RunOutput{FinalTranscript: working, ModuleResults: results}, fmt.Errorf("module %q failed: %w", spec.InstanceName, err)
|
||||
}
|
||||
|
||||
enriched := make([]proposals.EnrichedCorrectionProposal, 0, len(proposed))
|
||||
for i, p := range proposed {
|
||||
enriched = append(enriched, proposals.EnrichedCorrectionProposal{
|
||||
CorrectionProposal: p,
|
||||
ProposalMetadata: proposals.ProposalMetadata{
|
||||
ProposalIndex: i,
|
||||
ModuleKey: spec.ModuleKey,
|
||||
ModuleInstance: spec.InstanceName,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
applyResult := proposals.ApplyProposals(working, enriched, policy)
|
||||
working = applyResult.Transcript
|
||||
|
||||
results = append(results, ModuleResult{
|
||||
ModuleKey: spec.ModuleKey,
|
||||
ModuleInstance: spec.InstanceName,
|
||||
ReplacementPolicy: policy,
|
||||
Status: ModuleStatusSuccess,
|
||||
ProposalCount: len(enriched),
|
||||
AppliedChanges: applyResult.Applied,
|
||||
SkippedChanges: applyResult.Skipped,
|
||||
StartedAt: startedAt,
|
||||
CompletedAt: time.Now().UTC(),
|
||||
})
|
||||
}
|
||||
|
||||
return RunOutput{FinalTranscript: working, ModuleResults: results}, nil
|
||||
}
|
||||
|
||||
func cloneTranscript(t *schema.Transcript) *schema.Transcript {
|
||||
if t == nil {
|
||||
return &schema.Transcript{}
|
||||
}
|
||||
segments := make([]schema.Segment, len(t.Segments))
|
||||
for i, s := range t.Segments {
|
||||
var categories []string
|
||||
if s.Categories != nil {
|
||||
categories = append([]string(nil), s.Categories...)
|
||||
}
|
||||
segments[i] = schema.Segment{
|
||||
ID: s.ID,
|
||||
Speaker: s.Speaker,
|
||||
Start: s.Start,
|
||||
End: s.End,
|
||||
Text: s.Text,
|
||||
Categories: categories,
|
||||
}
|
||||
}
|
||||
return &schema.Transcript{Segments: segments}
|
||||
}
|
||||
152
internal/framework/runner/runner_test.go
Normal file
152
internal/framework/runner/runner_test.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"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/proposals"
|
||||
)
|
||||
|
||||
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
|
||||
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 nil }
|
||||
func (m fakeModule) Propose(ctx context.Context, req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
|
||||
if m.proposeF == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return m.proposeF(req)
|
||||
}
|
||||
|
||||
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 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 ptrConfig(c config.Config) *config.Config { return &c }
|
||||
Reference in New Issue
Block a user