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" "gitea.maximumdirect.net/eric/audita/internal/framework/validators" ) 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"` ValidatorDecisions []ValidatorDecisionRecord `json:"validator_decisions,omitempty"` ValidatorRejected []ValidatorRejectedChange `json:"validator_rejected,omitempty"` 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"` } 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"` } type ValidatorRejectedChange struct { ValidatorName string `json:"validator_name"` ProposalIndex int `json:"proposal_index"` ModuleKey string `json:"module_key"` ModuleInstance string `json:"module_instance"` TargetSegmentID int `json:"target_segment_id"` OriginalText string `json:"original_text"` CorrectedText string `json:"corrected_text"` ReasonCode string `json:"reason_code"` Message string `json:"message"` } // 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, }, }) } validatorDecisions := make([]ValidatorDecisionRecord, 0) validatorRejected := make([]ValidatorRejectedChange, 0) eligible := enriched for _, validator := range module.Validators() { vResult, vErr := validator.Validate(ctx, contracts.ValidationRequest{ WorkingTranscript: working, CandidateProposal: eligible, ModuleKey: spec.ModuleKey, ModuleInstance: spec.InstanceName, ReplacementPolicy: policy, Glossary: input.Glossary, Config: input.Config, }) if vErr != nil { failed := ModuleResult{ ModuleKey: spec.ModuleKey, ModuleInstance: spec.InstanceName, ReplacementPolicy: policy, Status: ModuleStatusFailed, ProposalCount: len(enriched), ValidatorDecisions: validatorDecisions, ValidatorRejected: validatorRejected, ErrorMessage: vErr.Error(), StartedAt: startedAt, CompletedAt: time.Now().UTC(), } results = append(results, failed) return RunOutput{FinalTranscript: working, ModuleResults: results}, fmt.Errorf("module %q validator %q failed: %w", spec.InstanceName, validator.Name(), vErr) } if err := validators.EnforceDecisionCardinality(eligible, vResult.Decisions); err != nil { failed := ModuleResult{ ModuleKey: spec.ModuleKey, ModuleInstance: spec.InstanceName, ReplacementPolicy: policy, Status: ModuleStatusFailed, ProposalCount: len(enriched), ValidatorDecisions: validatorDecisions, ValidatorRejected: validatorRejected, ErrorMessage: err.Error(), StartedAt: startedAt, CompletedAt: time.Now().UTC(), } results = append(results, failed) return RunOutput{FinalTranscript: working, ModuleResults: results}, fmt.Errorf("module %q validator %q cardinality failed: %w", spec.InstanceName, validator.Name(), err) } nextEligible := make([]proposals.EnrichedCorrectionProposal, 0, len(eligible)) byIndex := make(map[int]proposals.EnrichedCorrectionProposal, len(eligible)) for _, p := range eligible { byIndex[p.ProposalIndex] = p } for _, d := range vResult.Decisions { validatorDecisions = append(validatorDecisions, ValidatorDecisionRecord{ ValidatorName: validator.Name(), ProposalIndex: d.ProposalIndex, Approved: d.Approved, ReasonCode: d.ReasonCode, Message: d.Message, }) if d.Approved { nextEligible = append(nextEligible, byIndex[d.ProposalIndex]) continue } p := byIndex[d.ProposalIndex] validatorRejected = append(validatorRejected, ValidatorRejectedChange{ ValidatorName: validator.Name(), ProposalIndex: p.ProposalIndex, ModuleKey: p.ModuleKey, ModuleInstance: p.ModuleInstance, TargetSegmentID: p.TargetSegmentID, OriginalText: p.OriginalText, CorrectedText: p.CorrectedText, ReasonCode: d.ReasonCode, Message: d.Message, }) } eligible = nextEligible } applyResult := proposals.ApplyProposals(working, eligible, policy) working = applyResult.Transcript results = append(results, ModuleResult{ ModuleKey: spec.ModuleKey, ModuleInstance: spec.InstanceName, ReplacementPolicy: policy, Status: ModuleStatusSuccess, ProposalCount: len(enriched), ValidatorDecisions: validatorDecisions, ValidatorRejected: validatorRejected, 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} }