From 61af29ecb8df83c49f5001a49e87bd81810afd27 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Thu, 24 Sep 2026 17:31:45 +0000 Subject: [PATCH] Map processing outcomes to versioned YAML reports --- docs/roadmap/implementation.md | 10 + internal/core/reporting/report.go | 254 +++++++----- internal/core/reporting/report_test.go | 149 ++----- internal/framework/processreport/llm_test.go | 9 +- internal/framework/processreport/report.go | 368 +++++++++++------- .../framework/processreport/report_test.go | 212 +++------- 6 files changed, 473 insertions(+), 529 deletions(-) diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 77010b9..6c8509f 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -619,6 +619,16 @@ filesystem requirements for disabled features. ## Stage 8 — Map Outcomes to Versioned YAML Reports +**Completed 2026-09-24.** The old ordinary JSON report DTO was replaced with +versioned errors/recoveries YAML DTOs and one processing snapshot. Mapping derives +nullable summary counts, grouped recoverable work-unit failures, run failures, +combined recovery reasons, and deterministic semantic order without raw causes or +transcript payloads. Tests cover partial and empty work, multiple failures, +initialization unknowns, ordered run errors, safe redaction, and YAML scalar +types. `go test ./internal/core/reporting ./internal/framework/processreport` +and `git diff --check` passed. CLI and diagnostics consumers of the removed JSON +schema remain to be reconnected in later stages. + **Work:** Replace ordinary report DTOs and assembly in `core/reporting` and `framework/processreport` with F. Map actual runner state once into an immutable report snapshot consumed by all presentations. Preserve debug-specific ledger and diff --git a/internal/core/reporting/report.go b/internal/core/reporting/report.go index c4bba11..0dd586a 100644 --- a/internal/core/reporting/report.go +++ b/internal/core/reporting/report.go @@ -1,138 +1,180 @@ +// Package reporting owns the public report schema, separate from processing. package reporting import ( "fmt" - "os" "time" "gitea.maximumdirect.net/eric/audita/internal/core/redaction" - "gitea.maximumdirect.net/eric/audita/internal/framework/proposals" - stagewarnings "gitea.maximumdirect.net/eric/audita/internal/framework/warnings" + "gopkg.in/yaml.v3" ) -type ProcessReport struct { - ReportMetadata ReportMetadata `json:"report_metadata"` - Phase string `json:"phase"` - Status string `json:"status"` - Operation string `json:"operation"` - TranscriptPath string `json:"transcript_path"` - GlossaryPath string `json:"glossary_path"` - OutputPath string `json:"output_path,omitempty"` - Modules []string `json:"modules"` - StartedAt time.Time `json:"started_at"` - CompletedAt *time.Time `json:"completed_at,omitempty"` - ErrorMessage string `json:"error_message,omitempty"` - ErrorPhase string `json:"error_phase,omitempty"` - InputSegmentCount *int `json:"input_segment_count,omitempty"` - NormalizedSegmentCount *int `json:"normalized_segment_count,omitempty"` - NormalizationMerges *int `json:"normalization_merges,omitempty"` - NormalizationIDReassignments *int `json:"normalization_id_reassignments,omitempty"` - NormalizationSkipped NormalizationSkipped `json:"normalization_skipped,omitempty"` - Chunking *ChunkingSummary `json:"chunking,omitempty"` - Diagnostics *DiagnosticsMetadata `json:"diagnostics,omitempty"` - LLMSummary *LLMSummary `json:"llm_summary,omitempty"` - ModulesSummary *ModulesSummary `json:"modules_summary,omitempty"` - ModuleResults []ModuleReport `json:"module_results,omitempty"` -} - const ( - DefaultProcessReportSchemaName = "audita-process-report" - DefaultProcessReportSchemaVersion = "v2" + StatusInProgress = "in_progress" + StatusCompleted = "completed" + StatusFailed = "failed" ) -type ReportMetadata struct { - ReportSchemaName string `json:"report_schema_name"` - ReportSchemaVersion string `json:"report_schema_version"` - OutputSchema string `json:"output_schema"` - ConfigVersion *int `json:"config_version,omitempty"` +// Nil counts mean unavailable; a pointer to zero means a known empty count. +type Summary struct { + WorkUnitsTotal *int `yaml:"work_units_total"` + WorkUnitsStarted *int `yaml:"work_units_started"` + WorkUnitsCompleted *int `yaml:"work_units_completed"` + WorkUnitsInterrupted *int `yaml:"work_units_interrupted"` + WorkUnitsNotStarted *int `yaml:"work_units_not_started"` + WorkUnitsWithErrors *int `yaml:"work_units_with_errors"` + RunErrors *int `yaml:"run_errors"` + CorrectionsApplied *int `yaml:"corrections_applied"` + LLMOperations *int `yaml:"llm_operations"` + ProposalResponses *int `yaml:"proposal_responses"` + Recoveries *int `yaml:"recoveries"` + ProposalResponsesWithFiltering *int `yaml:"proposal_responses_with_filtering"` } -type ModuleReport struct { - ModuleKey string `json:"module_key"` - ModuleInstance string `json:"module_instance"` - ReplacementPolicy string `json:"replacement_policy,omitempty"` - Status string `json:"status"` - ProposalCount int `json:"proposal_count"` - Warnings []stagewarnings.StageWarning `json:"warnings,omitempty"` - ValidatorDecisions []ValidatorDecisionReport `json:"validator_decisions,omitempty"` - ValidatorRejected []ValidatorRejectedReport `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,omitempty"` - CompletedAt *time.Time `json:"completed_at,omitempty"` +type ExecutionCounts struct { + ProviderAttempts *int `yaml:"provider_attempts,omitempty"` + TransientRetries *int `yaml:"transient_retries,omitempty"` + StructuralRepairs *int `yaml:"structural_repairs,omitempty"` + RetriesExhausted *bool `yaml:"retries_exhausted,omitempty"` + HTTPStatus *int `yaml:"http_status,omitempty"` } -type ValidatorDecisionReport struct { - 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 Failure struct { + Operation string `yaml:"operation"` + Validator string `yaml:"validator,omitempty"` + BatchIndex *int `yaml:"batch_index,omitempty"` + Reason string `yaml:"reason"` + Message string `yaml:"message"` + ProposalIndices []int `yaml:"proposal_indices,omitempty"` + ExecutionCounts `yaml:",inline"` } -type ValidatorRejectedReport 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"` +type ErrorRecord struct { + Scope string `yaml:"scope"` + StageIndex *int `yaml:"stage_index,omitempty"` + ModuleKey string `yaml:"module_key,omitempty"` + ModuleInstance string `yaml:"module_instance,omitempty"` + ChunkIndex *int `yaml:"chunk_index,omitempty"` + StartSegmentID *int `yaml:"start_segment_id,omitempty"` + EndSegmentID *int `yaml:"end_segment_id,omitempty"` + CorrectionsApplied *int `yaml:"corrections_applied,omitempty"` + Failures []Failure `yaml:"failures,omitempty"` + Phase string `yaml:"phase,omitempty"` + Reason string `yaml:"reason,omitempty"` + Message string `yaml:"message,omitempty"` + Operation string `yaml:"operation,omitempty"` + Validator string `yaml:"validator,omitempty"` + BatchIndex *int `yaml:"batch_index,omitempty"` + OutputPath string `yaml:"output_path,omitempty"` } -type ModulesSummary struct { - ModuleCount int `json:"module_count"` - TotalAppliedChanges int `json:"total_applied_changes"` - TotalSkippedChanges int `json:"total_skipped_changes"` - FailedModuleInstance string `json:"failed_module_instance,omitempty"` +type FilteredProposal struct { + ProposalIndex int `yaml:"proposal_index"` + Reason string `yaml:"reason"` + Count int `yaml:"count"` } -type DiagnosticsMetadata struct { - DirectoryPath string `json:"directory_path,omitempty"` - SourceTranscriptPath string `json:"source_transcript_path,omitempty"` - ParsedSourceTranscriptPath string `json:"parsed_source_transcript_path,omitempty"` - NormalizedTranscriptPath string `json:"normalized_transcript_path,omitempty"` - NormalizationSummaryPath string `json:"normalization_summary_path,omitempty"` - ChunkingSummaryPath string `json:"chunking_summary_path,omitempty"` - UtilizationSummaryPath string `json:"utilization_summary_path,omitempty"` - LLMExecutionPath string `json:"llm_execution_path,omitempty"` - SelectedProfilesPath string `json:"selected_profiles_path,omitempty"` - CorrectionLedgerPath string `json:"correction_ledger_path,omitempty"` - InvocationMetadataPath string `json:"invocation_metadata_path,omitempty"` - RedactedEffectiveConfigPath string `json:"redacted_effective_config_path,omitempty"` - ErrorLogPath string `json:"error_log_path,omitempty"` +type RecoveryRecord struct { + StageIndex int `yaml:"stage_index"` + ModuleKey string `yaml:"module_key"` + ModuleInstance string `yaml:"module_instance"` + ChunkIndex int `yaml:"chunk_index"` + StartSegmentID int `yaml:"start_segment_id"` + EndSegmentID int `yaml:"end_segment_id"` + Operation string `yaml:"operation"` + Validator string `yaml:"validator,omitempty"` + BatchIndex *int `yaml:"batch_index,omitempty"` + Reasons []string `yaml:"reasons"` + Message string `yaml:"message"` + ExecutionCounts `yaml:",inline"` + FilteredProposals []FilteredProposal `yaml:"filtered_proposals,omitempty"` } -type NormalizationSkipped struct { - DifferentSpeakers *int `json:"different_speakers,omitempty"` - GapTooLarge *int `json:"gap_too_large,omitempty"` - DurationExceeded *int `json:"duration_exceeded,omitempty"` - TokenLimitExceeded *int `json:"token_limit_exceeded,omitempty"` +type ErrorsDocument struct { + Schema string `yaml:"schema"` + Version int `yaml:"version"` + RunID string `yaml:"run_id"` + Status string `yaml:"status"` + StartedAt string `yaml:"started_at"` + CompletedAt *string `yaml:"completed_at"` + Summary Summary `yaml:"summary"` + Errors []ErrorRecord `yaml:"errors"` } -type ChunkingSummary struct { - ChunkCount int `json:"chunk_count"` - MinEstimatedTokens int `json:"min_estimated_chunk_tokens"` - MaxEstimatedTokens int `json:"max_estimated_chunk_tokens"` - TotalEstimatedTokens int `json:"total_estimated_transcript_tokens"` - TargetSections *int `json:"target_sections,omitempty"` - MaxSectionTokens int `json:"max_section_tokens"` - MinSectionTokens int `json:"min_section_tokens"` +type RecoveriesDocument struct { + Schema string `yaml:"schema"` + Version int `yaml:"version"` + RunID string `yaml:"run_id"` + Status string `yaml:"status"` + StartedAt string `yaml:"started_at"` + CompletedAt *string `yaml:"completed_at"` + Summary Summary `yaml:"summary"` + Recoveries []RecoveryRecord `yaml:"recoveries"` } -func WriteProcessReport(path string, report ProcessReport, secrets ...string) error { - payload, err := redaction.New(secrets).JSON(report) +// WorkUnit is payload-free matrix input for report.md. +type WorkUnit struct { + StageIndex int + ModuleKey string + ModuleInstance string + ChunkIndex int + StartSegmentID int + EndSegmentID int + State string + CorrectionsApplied int + ErrorCount int + RecoveryCount int +} + +// Snapshot is built once from processing state and shared by presentations. +type Snapshot struct { + RunID string + Status string + StartedAt time.Time + CompletedAt *time.Time + TranscriptPath string + GlossaryPath string + OutputPath string + StageNames []string + WorkUnits []WorkUnit + Summary Summary + Errors []ErrorRecord + Recoveries []RecoveryRecord +} + +func (s Snapshot) ErrorsDocument() ErrorsDocument { + errors := s.Errors + if errors == nil { + errors = []ErrorRecord{} + } + return ErrorsDocument{Schema: "audita-errors", Version: 1, RunID: s.RunID, Status: s.Status, StartedAt: s.StartedAt.UTC().Format(time.RFC3339), CompletedAt: completionTime(s.CompletedAt), Summary: s.Summary, Errors: errors} +} + +func (s Snapshot) RecoveriesDocument() RecoveriesDocument { + recoveries := s.Recoveries + if recoveries == nil { + recoveries = []RecoveryRecord{} + } + return RecoveriesDocument{Schema: "audita-recoveries", Version: 1, RunID: s.RunID, Status: s.Status, StartedAt: s.StartedAt.UTC().Format(time.RFC3339), CompletedAt: completionTime(s.CompletedAt), Summary: s.Summary, Recoveries: recoveries} +} + +func completionTime(t *time.Time) *string { + if t == nil { + return nil + } + value := t.UTC().Format(time.RFC3339) + return &value +} + +// YAML redacts only string nodes after typed serialization. +func YAML(document any, secrets ...string) ([]byte, error) { + raw, err := yaml.Marshal(document) if err != nil { - return fmt.Errorf("failed to marshal process report JSON: %w", err) + return nil, fmt.Errorf("encode report YAML: %w", err) } - payload = append(payload, '\n') - - if err := os.WriteFile(path, payload, 0o644); err != nil { - return fmt.Errorf("failed to write report JSON file %q: %w", path, err) + safe, err := redaction.New(secrets).YAML(raw) + if err != nil { + return nil, fmt.Errorf("redact report YAML: %w", err) } - return nil + return safe, nil } diff --git a/internal/core/reporting/report_test.go b/internal/core/reporting/report_test.go index 5506346..a54e390 100644 --- a/internal/core/reporting/report_test.go +++ b/internal/core/reporting/report_test.go @@ -1,130 +1,41 @@ package reporting import ( - "encoding/json" + "strings" "testing" "time" - "gitea.maximumdirect.net/eric/audita/internal/framework/proposals" + "gopkg.in/yaml.v3" ) -func TestProcessReportModuleResultsJSONSuccessAndSkipped(t *testing.T) { - now := time.Now().UTC() - report := ProcessReport{ - ReportMetadata: ReportMetadata{ - ReportSchemaName: DefaultProcessReportSchemaName, - ReportSchemaVersion: DefaultProcessReportSchemaVersion, - OutputSchema: "bare-segments", - }, - Phase: "default_pipeline", - Status: "success", - ModuleResults: []ModuleReport{ - { - ModuleKey: "grammar", - ModuleInstance: "grammar_1", - ReplacementPolicy: "require_unique", - Status: "success", - ProposalCount: 2, - ValidatorDecisions: []ValidatorDecisionReport{{ - ValidatorName: "confidence_threshold", - ProposalIndex: 0, - Approved: true, - ReasonCode: "approved", - Message: "approved", - }}, - ValidatorRejected: []ValidatorRejectedReport{{ - ValidatorName: "confidence_threshold", - ProposalIndex: 1, - ModuleKey: "grammar", - ModuleInstance: "grammar_1", - TargetSegmentID: 1, - OriginalText: "bad", - CorrectedText: "worse", - ReasonCode: "low_confidence", - Message: "rejected", - }}, - AppliedChanges: []proposals.AppliedChange{{ - ProposalIndex: 0, - ModuleKey: "grammar", - }}, - SkippedChanges: []proposals.SkippedChange{{ - ProposalIndex: 1, - ModuleKey: "grammar", - SkipReason: proposals.SkipReasonMissingOriginalText, - }}, - StartedAt: &now, - CompletedAt: &now, - }, - }, - ModulesSummary: &ModulesSummary{ModuleCount: 1, TotalAppliedChanges: 1, TotalSkippedChanges: 1}, +func TestYAMLRedactsStringsWithoutChangingScalarTypes(t *testing.T) { + zero, one := 0, 1 + falseValue := false + snapshot := Snapshot{RunID: "secret\\\"key", Status: StatusCompleted, StartedAt: time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC), + Summary: Summary{RunErrors: &zero, CorrectionsApplied: &one}, + Errors: []ErrorRecord{{Scope: "run", Phase: "processing", Reason: "internal_failure", Message: "secret\\\"key 1"}}, + Recoveries: []RecoveryRecord{{StageIndex: 0, Reasons: []string{"service_recovered"}, ExecutionCounts: ExecutionCounts{RetriesExhausted: &falseValue}}}} + for _, document := range []any{snapshot.ErrorsDocument(), snapshot.RecoveriesDocument()} { + payload, err := YAML(document, `secret\"key`, "1") + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(payload), `secret\"key`) { + t.Fatalf("secret leaked: %s", payload) + } + var decoded map[string]any + if err := yaml.Unmarshal(payload, &decoded); err != nil { + t.Fatal(err) + } + if decoded["version"] != 1 || decoded["started_at"] != "2026-09-24T00:00:00Z" { + t.Fatalf("wrong envelope types: %#v", decoded) + } + summary := decoded["summary"].(map[string]any) + if summary["corrections_applied"] != 1 || summary["work_units_total"] != nil { + t.Fatalf("changed count types: %#v", summary) + } } - - b, err := json.Marshal(report) - if err != nil { - t.Fatalf("marshal report: %v", err) - } - if !json.Valid(b) { - t.Fatalf("expected valid json") - } - - var parsed ProcessReport - if err := json.Unmarshal(b, &parsed); err != nil { - t.Fatalf("unmarshal report: %v", err) - } - if len(parsed.ModuleResults) != 1 { - t.Fatalf("expected one module result, got %d", len(parsed.ModuleResults)) - } - if len(parsed.ModuleResults[0].ValidatorDecisions) != 1 || len(parsed.ModuleResults[0].ValidatorRejected) != 1 { - t.Fatalf("expected validator result payloads in module report") - } - if parsed.ModulesSummary == nil || parsed.ModulesSummary.TotalSkippedChanges != 1 { - t.Fatalf("unexpected module summary: %+v", parsed.ModulesSummary) - } - if parsed.ReportMetadata.ReportSchemaName != DefaultProcessReportSchemaName || - parsed.ReportMetadata.ReportSchemaVersion != DefaultProcessReportSchemaVersion || - parsed.ReportMetadata.OutputSchema != "bare-segments" { - t.Fatalf("unexpected report metadata after roundtrip: %+v", parsed.ReportMetadata) - } -} - -func TestProcessReportModuleResultsJSONFailedModule(t *testing.T) { - now := time.Now().UTC() - report := ProcessReport{ - ReportMetadata: ReportMetadata{ - ReportSchemaName: DefaultProcessReportSchemaName, - ReportSchemaVersion: DefaultProcessReportSchemaVersion, - OutputSchema: "audita-v1", - }, - Phase: "default_pipeline", - Status: "failed", - ModuleResults: []ModuleReport{ - { - ModuleKey: "grammar", - ModuleInstance: "grammar_1", - Status: "failed", - ErrorMessage: "boom", - StartedAt: &now, - CompletedAt: &now, - }, - }, - ModulesSummary: &ModulesSummary{ModuleCount: 1, FailedModuleInstance: "grammar_1"}, - } - - b, err := json.Marshal(report) - if err != nil { - t.Fatalf("marshal report: %v", err) - } - var decoded map[string]any - if err := json.Unmarshal(b, &decoded); err != nil { - t.Fatalf("unmarshal map: %v", err) - } - if _, ok := decoded["module_results"]; !ok { - t.Fatalf("expected module_results field") - } - if _, ok := decoded["report_metadata"]; !ok { - t.Fatalf("expected report_metadata field") - } - if _, ok := decoded["modules_summary"]; !ok { - t.Fatalf("expected modules_summary field") + if payload, err := YAML(snapshot.RecoveriesDocument(), "1"); err != nil || !strings.Contains(string(payload), "retries_exhausted: false") { + t.Fatalf("boolean changed: %s %v", payload, err) } } diff --git a/internal/framework/processreport/llm_test.go b/internal/framework/processreport/llm_test.go index 2fc1a53..2ea2aad 100644 --- a/internal/framework/processreport/llm_test.go +++ b/internal/framework/processreport/llm_test.go @@ -45,13 +45,8 @@ func TestExecutionReportingGroupsPredispatchFailureAndKeepsSafeMilliseconds(t *t } func TestReportSummaryAvailability(t *testing.T) { - input := BuildInput{Status: "failed", StartedAt: time.Now(), CompletedAt: time.Now()} - if got := Build(input).LLMSummary; got != nil { - t.Fatalf("unavailable summary: %+v", got) - } - input.RunOutput = &runner.RunOutput{ObservationsAvailable: true, LLMSummary: llm.Summarize(llm.ObservationSnapshot{})} - got := Build(input).LLMSummary - if got == nil || got.LogicalCalls != 0 || !got.Usage.Total.Known || !got.Usage.Total.Complete { + got := mapLLMSummary(llm.Summarize(llm.ObservationSnapshot{})) + if got.LogicalCalls != 0 || !got.Usage.Total.Known || !got.Usage.Total.Complete { t.Fatalf("initialized empty summary: %+v", got) } } diff --git a/internal/framework/processreport/report.go b/internal/framework/processreport/report.go index aed4511..f930225 100644 --- a/internal/framework/processreport/report.go +++ b/internal/framework/processreport/report.go @@ -1,164 +1,252 @@ package processreport import ( + "sort" "time" - "gitea.maximumdirect.net/eric/audita/internal/core/chunking" - "gitea.maximumdirect.net/eric/audita/internal/core/config" - "gitea.maximumdirect.net/eric/audita/internal/core/diagnostics" - "gitea.maximumdirect.net/eric/audita/internal/core/normalization" "gitea.maximumdirect.net/eric/audita/internal/core/reporting" + "gitea.maximumdirect.net/eric/audita/internal/framework/contracts" + "gitea.maximumdirect.net/eric/audita/internal/framework/outcomes" "gitea.maximumdirect.net/eric/audita/internal/framework/runner" - stagewarnings "gitea.maximumdirect.net/eric/audita/internal/framework/warnings" ) -// BuildInput contains already-computed process execution facts for report assembly. +// RunFailure describes a fatal or output failure without carrying its raw cause. +type RunFailure struct { + Phase string + Reason outcomes.ReasonCode + OutputPath string + Origin *outcomes.Reference + StageIndex *int +} + type BuildInput struct { - Status string - TranscriptPath string - GlossaryPath string - OutputPath string - Modules []string - OutputSchema string - ConfigVersion *int - StartedAt time.Time - CompletedAt time.Time - ErrorMessage string - ErrorPhase string - RunDirectoryPath string - NormalizationSummary *normalization.NormalizationSummary - ChunkingSummary *chunking.Summary - RunOutput *runner.RunOutput - Backends map[string]config.Backend + RunID string + Status string + StartedAt time.Time + CompletedAt *time.Time + TranscriptPath string + GlossaryPath string + OutputPath string + ModuleSpecs []contracts.ModuleRunSpec + RunOutput *runner.RunOutput + RunFailures []RunFailure } -// Build creates the public process report without owning command parsing or config loading. -func Build(input BuildInput) reporting.ProcessReport { - report := reporting.ProcessReport{ - ReportMetadata: reporting.ReportMetadata{ - ReportSchemaName: reporting.DefaultProcessReportSchemaName, - ReportSchemaVersion: reporting.DefaultProcessReportSchemaVersion, - OutputSchema: input.OutputSchema, - ConfigVersion: input.ConfigVersion, - }, - Phase: "default_pipeline", - Status: input.Status, - Operation: "process", - TranscriptPath: input.TranscriptPath, - GlossaryPath: input.GlossaryPath, - OutputPath: input.OutputPath, - Modules: append([]string(nil), input.Modules...), - StartedAt: input.StartedAt, - CompletedAt: &input.CompletedAt, - ErrorPhase: input.ErrorPhase, +// Build maps processing facts once into an immutable presentation snapshot. +func Build(input BuildInput) reporting.Snapshot { + snapshot := reporting.Snapshot{ + RunID: input.RunID, Status: input.Status, StartedAt: input.StartedAt.UTC(), CompletedAt: input.CompletedAt, + TranscriptPath: input.TranscriptPath, GlossaryPath: input.GlossaryPath, OutputPath: input.OutputPath, + StageNames: make([]string, len(input.ModuleSpecs)), Errors: make([]reporting.ErrorRecord, 0), + Recoveries: make([]reporting.RecoveryRecord, 0), WorkUnits: make([]reporting.WorkUnit, 0), } - if input.RunDirectoryPath != "" { - runSucceeded := input.Status == "success" - metadata := diagnostics.BuildDiagnosticsMetadata(input.RunDirectoryPath, runSucceeded) - report.Diagnostics = &metadata + for i, spec := range input.ModuleSpecs { + snapshot.StageNames[i] = spec.InstanceName } - if input.ErrorMessage != "" { - report.ErrorMessage = input.ErrorMessage - } - if input.NormalizationSummary != nil { - report.InputSegmentCount = &input.NormalizationSummary.InputSegmentCount - report.NormalizedSegmentCount = &input.NormalizationSummary.OutputSegmentCount - report.NormalizationMerges = &input.NormalizationSummary.MergesPerformed - report.NormalizationIDReassignments = &input.NormalizationSummary.IDsReassigned - report.NormalizationSkipped.DifferentSpeakers = &input.NormalizationSummary.SkippedMerges.DifferentSpeakers - report.NormalizationSkipped.GapTooLarge = &input.NormalizationSummary.SkippedMerges.GapTooLarge - report.NormalizationSkipped.DurationExceeded = &input.NormalizationSummary.SkippedMerges.DurationExceeded - report.NormalizationSkipped.TokenLimitExceeded = &input.NormalizationSummary.SkippedMerges.TokenLimitExceeded - } - if input.ChunkingSummary != nil { - report.Chunking = &reporting.ChunkingSummary{ - ChunkCount: input.ChunkingSummary.ChunkCount, - MinEstimatedTokens: input.ChunkingSummary.MinEstimatedTokens, - MaxEstimatedTokens: input.ChunkingSummary.MaxEstimatedTokens, - TotalEstimatedTokens: input.ChunkingSummary.TotalEstimatedTokens, - TargetSections: input.ChunkingSummary.TargetSections, - MaxSectionTokens: input.ChunkingSummary.MaxSectionTokens, - MinSectionTokens: input.ChunkingSummary.MinSectionTokens, - } - } - report.ModulesSummary, report.ModuleResults = buildModuleReporting(input.RunOutput) - if input.RunOutput != nil && input.RunOutput.ObservationsAvailable { - summary := mapLLMSummary(input.RunOutput.LLMSummary) - report.LLMSummary = &summary - } - return report -} - -func buildModuleReporting(runOutput *runner.RunOutput) (*reporting.ModulesSummary, []reporting.ModuleReport) { - if runOutput == nil || len(runOutput.ModuleResults) == 0 { - return nil, nil - } - - moduleReports := make([]reporting.ModuleReport, 0, len(runOutput.ModuleResults)) - summary := &reporting.ModulesSummary{ModuleCount: len(runOutput.ModuleResults)} - for _, r := range runOutput.ModuleResults { - startedAt := r.StartedAt - completedAt := r.CompletedAt - moduleReports = append(moduleReports, reporting.ModuleReport{ - ModuleKey: r.ModuleKey, - ModuleInstance: r.ModuleInstance, - ReplacementPolicy: string(r.ReplacementPolicy), - Status: r.Status, - ProposalCount: r.ProposalCount, - Warnings: append([]stagewarnings.StageWarning(nil), r.Warnings...), - ValidatorDecisions: mapValidatorDecisions(r.ValidatorDecisions), - ValidatorRejected: mapValidatorRejected(r.ValidatorRejected), - AppliedChanges: r.AppliedChanges, - SkippedChanges: r.SkippedChanges, - ErrorMessage: r.ErrorMessage, - StartedAt: &startedAt, - CompletedAt: &completedAt, + if input.RunOutput != nil { + units := append([]runner.WorkUnitOutcome(nil), input.RunOutput.WorkUnits...) + sort.SliceStable(units, func(i, j int) bool { + if units[i].ModuleIndex != units[j].ModuleIndex { + return units[i].ModuleIndex < units[j].ModuleIndex + } + return units[i].Section.Index < units[j].Section.Index }) - summary.TotalAppliedChanges += len(r.AppliedChanges) - summary.TotalSkippedChanges += len(r.SkippedChanges) + len(r.ValidatorRejected) - if r.Status == runner.ModuleStatusFailed && summary.FailedModuleInstance == "" { - summary.FailedModuleInstance = r.ModuleInstance + total, started, completed, interrupted, notStarted, withErrors := len(units), 0, 0, 0, 0, 0 + corrections, llmOperations, proposalResponses, recoveryCount, filteringResponses := 0, 0, 0, 0, 0 + for _, unit := range units { + if unit.State == outcomes.WorkNotStarted { + notStarted++ + } else { + started++ + } + if unit.State == outcomes.WorkCompleted { + completed++ + } + if unit.State == outcomes.WorkInterrupted { + interrupted++ + } + corrections += unit.CorrectionsApplied + row := reporting.WorkUnit{StageIndex: unit.ModuleIndex, ModuleKey: unit.ModuleKey, ModuleInstance: unit.ModuleInstance, + ChunkIndex: unit.Section.Index, StartSegmentID: unit.Section.StartSegmentID, EndSegmentID: unit.Section.EndSegmentID, + State: string(unit.State), CorrectionsApplied: unit.CorrectionsApplied} + operations := append([]outcomes.Operation(nil), unit.Operations...) + sort.SliceStable(operations, func(i, j int) bool { return operationLess(operations[i], operations[j]) }) + failures := make([]reporting.Failure, 0) + for _, operation := range operations { + if operation.Reference.Kind != outcomes.OperationValidationInput { + llmOperations++ + } + if operation.Reference.Kind == outcomes.OperationProposal && operation.Succeeded { + proposalResponses++ + } + if operation.Succeeded && len(operation.RecoveryReasons) > 0 { + recoveryCount++ + row.RecoveryCount++ + if len(operation.Filtered) > 0 && operation.Reference.Kind == outcomes.OperationProposal { + filteringResponses++ + } + snapshot.Recoveries = append(snapshot.Recoveries, mapRecovery(unit, operation)) + } + if operation.FailureReason != "" && isRecoverableReason(operation.FailureReason) { + failures = append(failures, mapFailure(operation)) + } + } + if len(failures) > 0 { + withErrors++ + row.ErrorCount = 1 + stage, chunk, start, end, applied := unit.ModuleIndex, unit.Section.Index, unit.Section.StartSegmentID, unit.Section.EndSegmentID, unit.CorrectionsApplied + snapshot.Errors = append(snapshot.Errors, reporting.ErrorRecord{Scope: "work_unit", StageIndex: &stage, + ModuleKey: unit.ModuleKey, ModuleInstance: unit.ModuleInstance, ChunkIndex: &chunk, + StartSegmentID: &start, EndSegmentID: &end, CorrectionsApplied: &applied, Failures: failures}) + } + snapshot.WorkUnits = append(snapshot.WorkUnits, row) } + runErrors := len(input.RunFailures) + snapshot.Summary = reporting.Summary{ + WorkUnitsTotal: &total, WorkUnitsStarted: &started, WorkUnitsCompleted: &completed, + WorkUnitsInterrupted: &interrupted, WorkUnitsNotStarted: ¬Started, WorkUnitsWithErrors: &withErrors, + RunErrors: &runErrors, CorrectionsApplied: &corrections, LLMOperations: &llmOperations, + ProposalResponses: &proposalResponses, Recoveries: &recoveryCount, + ProposalResponsesWithFiltering: &filteringResponses, + } + } else if len(input.RunFailures) > 0 { + runErrors := len(input.RunFailures) + snapshot.Summary.RunErrors = &runErrors } - - return summary, moduleReports + for _, failure := range sortedRunFailures(input.RunFailures) { + record := reporting.ErrorRecord{Scope: "run", Phase: failure.Phase, Reason: string(failure.Reason), + Message: safeMessage(failure.Reason), OutputPath: failure.OutputPath, StageIndex: failure.StageIndex} + if failure.Origin != nil { + record.ModuleKey, record.ModuleInstance = failure.Origin.ModuleKey, failure.Origin.ModuleInstance + chunk := failure.Origin.SectionIndex + record.ChunkIndex = &chunk + record.Operation = string(failure.Origin.Kind) + record.Validator = failure.Origin.ValidatorName + record.BatchIndex = failure.Origin.BatchIndex + } + snapshot.Errors = append(snapshot.Errors, record) + } + return snapshot } -func mapValidatorDecisions(in []runner.ValidatorDecisionRecord) []reporting.ValidatorDecisionReport { - if len(in) == 0 { - return nil +func mapFailure(operation outcomes.Operation) reporting.Failure { + indices := append([]int(nil), operation.ProposalIndices...) + sort.Ints(indices) + return reporting.Failure{Operation: string(operation.Reference.Kind), Validator: operation.Reference.ValidatorName, + BatchIndex: operation.Reference.BatchIndex, Reason: string(operation.FailureReason), Message: safeMessage(operation.FailureReason), + ProposalIndices: indices, ExecutionCounts: mapCounts(operation.Counts)} +} + +func mapRecovery(unit runner.WorkUnitOutcome, operation outcomes.Operation) reporting.RecoveryRecord { + reasons := make([]string, len(operation.RecoveryReasons)) + for i, reason := range operation.RecoveryReasons { + reasons[i] = string(reason) } - out := make([]reporting.ValidatorDecisionReport, len(in)) - for i, d := range in { - out[i] = reporting.ValidatorDecisionReport{ - ValidatorName: d.ValidatorName, - ProposalIndex: d.ProposalIndex, - Approved: d.Approved, - ReasonCode: d.ReasonCode, - Message: d.Message, - DiagnosticArtifactPath: d.DiagnosticArtifactPath, - } - } - return out -} - -func mapValidatorRejected(in []runner.ValidatorRejectedChange) []reporting.ValidatorRejectedReport { - if len(in) == 0 { - return nil - } - out := make([]reporting.ValidatorRejectedReport, len(in)) - for i, d := range in { - out[i] = reporting.ValidatorRejectedReport{ - ValidatorName: d.ValidatorName, - ProposalIndex: d.ProposalIndex, - ModuleKey: d.ModuleKey, - ModuleInstance: d.ModuleInstance, - TargetSegmentID: d.TargetSegmentID, - OriginalText: d.OriginalText, - CorrectedText: d.CorrectedText, - ReasonCode: d.ReasonCode, - Message: d.Message, + sort.Strings(reasons) + filtered := make([]reporting.FilteredProposal, len(operation.Filtered)) + for i, item := range operation.Filtered { + filtered[i] = reporting.FilteredProposal{ProposalIndex: item.ProposalIndex, Reason: string(item.Reason), Count: item.Count} + } + sort.Slice(filtered, func(i, j int) bool { return filtered[i].ProposalIndex < filtered[j].ProposalIndex }) + return reporting.RecoveryRecord{StageIndex: unit.ModuleIndex, ModuleKey: unit.ModuleKey, ModuleInstance: unit.ModuleInstance, + ChunkIndex: unit.Section.Index, StartSegmentID: unit.Section.StartSegmentID, EndSegmentID: unit.Section.EndSegmentID, + Operation: string(operation.Reference.Kind), Validator: operation.Reference.ValidatorName, BatchIndex: operation.Reference.BatchIndex, + Reasons: reasons, Message: "Model operation succeeded after recovery or proposal filtering.", + ExecutionCounts: mapCounts(operation.Counts), FilteredProposals: filtered} +} + +func mapCounts(counts outcomes.ExecutionCounts) reporting.ExecutionCounts { + if !counts.Available { + return reporting.ExecutionCounts{} + } + attempts, retries, repairs, exhausted := counts.ProviderAttempts, counts.TransientRetries, counts.StructuralRepairs, counts.RetriesExhausted + return reporting.ExecutionCounts{ProviderAttempts: &attempts, TransientRetries: &retries, + StructuralRepairs: &repairs, RetriesExhausted: &exhausted, HTTPStatus: counts.HTTPStatus} +} + +func isRecoverableReason(reason outcomes.ReasonCode) bool { + switch reason { + case outcomes.ReasonOperationTimeout, outcomes.ReasonCapacityExceeded, outcomes.ReasonTransportFailed, + outcomes.ReasonProviderResponseInvalid, outcomes.ReasonProviderFailed, + outcomes.ReasonInvalidOutputExhausted, outcomes.ReasonInvalidOutput, + outcomes.ReasonValidationInputTooLarge, outcomes.ReasonValidationDecisionsInvalid: + return true + default: + return false + } +} + +func safeMessage(reason outcomes.ReasonCode) string { + switch reason { + case outcomes.ReasonOperationTimeout: + return "Model operation timed out." + case outcomes.ReasonCapacityExceeded: + return "Model capacity was unavailable." + case outcomes.ReasonTransportFailed: + return "Model transport failed." + case outcomes.ReasonProviderResponseInvalid: + return "Provider response was invalid." + case outcomes.ReasonProviderFailed: + return "Provider request failed." + case outcomes.ReasonInvalidOutputExhausted: + return "Model output remained invalid after repair attempts." + case outcomes.ReasonInvalidOutput: + return "Model output was invalid." + case outcomes.ReasonValidationInputTooLarge: + return "Validation input exceeded its size limit." + case outcomes.ReasonValidationDecisionsInvalid: + return "Validation decisions were unusable." + case outcomes.ReasonCanceled: + return "Processing was canceled." + case outcomes.ReasonSetupFailure: + return "Processing setup failed." + default: + return "Processing failed." + } +} + +func operationLess(a, b outcomes.Operation) bool { + rank := func(kind outcomes.OperationKind) int { + switch kind { + case outcomes.OperationProposal: + return 0 + case outcomes.OperationValidationInput: + return 1 + default: + return 2 } } + if rank(a.Reference.Kind) != rank(b.Reference.Kind) && (a.Reference.Kind == outcomes.OperationProposal || b.Reference.Kind == outcomes.OperationProposal) { + return rank(a.Reference.Kind) < rank(b.Reference.Kind) + } + order := func(p *int) int { + if p == nil { + return -1 + } + return *p + } + if order(a.Reference.ValidatorOrder) != order(b.Reference.ValidatorOrder) { + return order(a.Reference.ValidatorOrder) < order(b.Reference.ValidatorOrder) + } + return rank(a.Reference.Kind) < rank(b.Reference.Kind) || (rank(a.Reference.Kind) == rank(b.Reference.Kind) && order(a.Reference.BatchIndex) < order(b.Reference.BatchIndex)) +} + +func sortedRunFailures(in []RunFailure) []RunFailure { + out := append([]RunFailure(nil), in...) + phase := map[string]int{"configuration": 0, "destination": 1, "initialization": 2, "input": 3, "normalization": 4, "chunking": 5, "processing": 6, "encoding": 7, "transcript_write": 8, "report_write": 9, "debug": 10} + sort.SliceStable(out, func(i, j int) bool { + a, aok := phase[out[i].Phase] + b, bok := phase[out[j].Phase] + if !aok { + a = 100 + } + if !bok { + b = 100 + } + if a != b { + return a < b + } + return out[i].OutputPath < out[j].OutputPath + }) return out } diff --git a/internal/framework/processreport/report_test.go b/internal/framework/processreport/report_test.go index 4de2487..c626c2a 100644 --- a/internal/framework/processreport/report_test.go +++ b/internal/framework/processreport/report_test.go @@ -1,180 +1,78 @@ package processreport import ( - "path/filepath" + "reflect" + "strings" "testing" "time" - "gitea.maximumdirect.net/eric/audita/internal/core/chunking" - "gitea.maximumdirect.net/eric/audita/internal/core/diagnostics" - "gitea.maximumdirect.net/eric/audita/internal/core/normalization" "gitea.maximumdirect.net/eric/audita/internal/core/reporting" - "gitea.maximumdirect.net/eric/audita/internal/framework/proposals" + "gitea.maximumdirect.net/eric/audita/internal/framework/contracts" + "gitea.maximumdirect.net/eric/audita/internal/framework/outcomes" "gitea.maximumdirect.net/eric/audita/internal/framework/runner" + "gopkg.in/yaml.v3" ) -func TestBuildSuccessReportMapsExecutionFacts(t *testing.T) { - startedAt := time.Date(2026, 5, 23, 10, 0, 0, 0, time.UTC) - completedAt := startedAt.Add(time.Second) - configVersion := 4 - targetSections := 2 - inputSegments := 5 - outputSegments := 4 - merges := 1 - reassigned := 2 - differentSpeakers := 3 - - report := Build(BuildInput{ - Status: "success", - TranscriptPath: "transcript.json", - GlossaryPath: "glossary.yaml", - OutputPath: "out.json", - Modules: []string{"grammar"}, - OutputSchema: "default", - ConfigVersion: &configVersion, - StartedAt: startedAt, - CompletedAt: completedAt, - RunDirectoryPath: filepath.Join( - "tmp", - "audita-run", - ), - NormalizationSummary: &normalization.NormalizationSummary{ - InputSegmentCount: inputSegments, - OutputSegmentCount: outputSegments, - MergesPerformed: merges, - IDsReassigned: reassigned, - SkippedMerges: struct { - DifferentSpeakers int `json:"different_speakers"` - GapTooLarge int `json:"gap_too_large"` - DurationExceeded int `json:"duration_exceeded"` - TokenLimitExceeded int `json:"token_limit_exceeded"` - }{ - DifferentSpeakers: differentSpeakers, - }, - }, - ChunkingSummary: &chunking.Summary{ - ChunkCount: 3, - MinEstimatedTokens: 10, - MaxEstimatedTokens: 20, - TotalEstimatedTokens: 45, - TargetSections: &targetSections, - MaxSectionTokens: 200, - MinSectionTokens: 50, - }, - RunOutput: &runner.RunOutput{ - ModuleResults: []runner.ModuleResult{ - { - ModuleKey: "grammar", - ModuleInstance: "grammar", - ReplacementPolicy: proposals.ReplacementPolicyRequireUnique, - Status: runner.ModuleStatusSuccess, - ProposalCount: 2, - ValidatorDecisions: []runner.ValidatorDecisionRecord{ - { - ValidatorName: "proposal_shape", - ProposalIndex: 1, - Approved: true, - ReasonCode: "approved", - Message: "ok", - DiagnosticArtifactPath: "diagnostics/validator.json", - }, - }, - ValidatorRejected: []runner.ValidatorRejectedChange{ - { - ValidatorName: "protected_term", - ProposalIndex: 2, - ModuleKey: "grammar", - ModuleInstance: "grammar", - TargetSegmentID: 7, - OriginalText: "old", - CorrectedText: "new", - ReasonCode: "protected_term", - Message: "blocked", - }, - }, - AppliedChanges: []proposals.AppliedChange{ - {ProposalIndex: 1, TargetSegmentID: 7, OriginalText: "old", CorrectedText: "new"}, - }, - SkippedChanges: []proposals.SkippedChange{ - {ProposalIndex: 3, TargetSegmentID: 8, SkipReason: proposals.SkipReasonMissingSegment}, - }, - StartedAt: startedAt, - CompletedAt: completedAt, - }, - }, - }, - }) - - if report.ReportMetadata.ReportSchemaName != reporting.DefaultProcessReportSchemaName || - report.ReportMetadata.ReportSchemaVersion != reporting.DefaultProcessReportSchemaVersion || - report.ReportMetadata.OutputSchema != "default" || - report.ReportMetadata.ConfigVersion == nil || - *report.ReportMetadata.ConfigVersion != configVersion { - t.Fatalf("unexpected report metadata: %+v", report.ReportMetadata) +func TestBuildPartialUnitsAndCombinedRecovery(t *testing.T) { + started := time.Date(2026, 9, 24, 12, 0, 0, 0, time.UTC) + completed := started.Add(time.Minute) + proposal := outcomes.Operation{Reference: outcomes.Reference{Kind: outcomes.OperationProposal}, Succeeded: true, + Counts: outcomes.ExecutionCounts{Available: true, ProviderAttempts: 2, TransientRetries: 1, StructuralRepairs: 1}, + RecoveryReasons: []outcomes.RecoveryReason{outcomes.RecoveryServiceRecovered, outcomes.RecoveryOutputRepaired, outcomes.RecoveryProposalsFiltered}, + Filtered: []outcomes.FilteredProposal{{ProposalIndex: 4, Reason: outcomes.ReasonInvalidOutput, Count: 1}, {ProposalIndex: 2, Reason: outcomes.ReasonInvalidOutput, Count: 1}}} + batch := 1 + validation := outcomes.Operation{Reference: outcomes.Reference{Kind: outcomes.OperationValidation, ValidatorName: "review", ValidatorOrder: intPtr(1), BatchIndex: &batch}, FailureReason: outcomes.ReasonTransportFailed, ProposalIndices: []int{9, 3}} + inputFailure := outcomes.Operation{Reference: outcomes.Reference{Kind: outcomes.OperationValidationInput, ValidatorName: "review", ValidatorOrder: intPtr(1)}, FailureReason: outcomes.ReasonValidationInputTooLarge, ProposalIndices: []int{7}} + output := &runner.RunOutput{WorkUnits: []runner.WorkUnitOutcome{ + {ModuleIndex: 1, ModuleKey: "grammar", ModuleInstance: "grammar_2", Section: contracts.SectionMetadata{Index: 0, StartSegmentID: 1, EndSegmentID: 2}, State: outcomes.WorkNotStarted}, + {ModuleIndex: 0, ModuleKey: "grammar", ModuleInstance: "grammar_1", Section: contracts.SectionMetadata{Index: 0, StartSegmentID: 1, EndSegmentID: 2}, State: outcomes.WorkCompleted, CorrectionsApplied: 1, Operations: []outcomes.Operation{validation, proposal, inputFailure}}, + }} + snapshot := Build(BuildInput{RunID: "run-1", Status: reporting.StatusCompleted, StartedAt: started, CompletedAt: &completed, + ModuleSpecs: []contracts.ModuleRunSpec{{ModuleKey: "grammar", InstanceName: "grammar_1"}, {ModuleKey: "grammar", InstanceName: "grammar_2"}}, RunOutput: output}) + if snapshot.Summary.WorkUnitsTotal == nil || *snapshot.Summary.WorkUnitsTotal != 2 || *snapshot.Summary.WorkUnitsWithErrors != 1 || *snapshot.Summary.Recoveries != 1 || *snapshot.Summary.ProposalResponsesWithFiltering != 1 || *snapshot.Summary.LLMOperations != 2 { + t.Fatalf("summary: %+v", snapshot.Summary) } - if report.Phase != "default_pipeline" || report.Operation != "process" || report.Status != "success" { - t.Fatalf("unexpected process identity fields: phase=%q operation=%q status=%q", report.Phase, report.Operation, report.Status) + if len(snapshot.Errors) != 1 || len(snapshot.Errors[0].Failures) != 2 || snapshot.Errors[0].Failures[0].Operation != "validation_input" || !reflect.DeepEqual(snapshot.Errors[0].Failures[1].ProposalIndices, []int{3, 9}) { + t.Fatalf("failure ordering: %+v", snapshot.Errors) } - if report.Diagnostics == nil || report.Diagnostics.CorrectionLedgerPath != filepath.Join("tmp", "audita-run", diagnostics.ArtifactCorrectionLedger) { - t.Fatalf("unexpected diagnostics metadata: %+v", report.Diagnostics) + if len(snapshot.Recoveries) != 1 || !reflect.DeepEqual(snapshot.Recoveries[0].Reasons, []string{"output_repaired", "proposals_filtered", "service_recovered"}) || snapshot.Recoveries[0].FilteredProposals[0].ProposalIndex != 2 { + t.Fatalf("recovery: %+v", snapshot.Recoveries) } - if report.InputSegmentCount == nil || *report.InputSegmentCount != inputSegments || - report.NormalizationSkipped.DifferentSpeakers == nil || - *report.NormalizationSkipped.DifferentSpeakers != differentSpeakers { - t.Fatalf("unexpected normalization summary: %+v", report) + if snapshot.WorkUnits[0].ModuleInstance != "grammar_1" || snapshot.WorkUnits[1].State != "not_started" { + t.Fatalf("unit order: %+v", snapshot.WorkUnits) } - if report.Chunking == nil || report.Chunking.ChunkCount != 3 || report.Chunking.TargetSections == nil || *report.Chunking.TargetSections != targetSections { - t.Fatalf("unexpected chunking summary: %+v", report.Chunking) + errorsYAML, err := reporting.YAML(snapshot.ErrorsDocument()) + if err != nil { + t.Fatal(err) } - if report.ModulesSummary == nil || - report.ModulesSummary.ModuleCount != 1 || - report.ModulesSummary.TotalAppliedChanges != 1 || - report.ModulesSummary.TotalSkippedChanges != 2 { - t.Fatalf("unexpected modules summary: %+v", report.ModulesSummary) + var decoded map[string]any + if err := yaml.Unmarshal(errorsYAML, &decoded); err != nil { + t.Fatal(err) } - if len(report.ModuleResults) != 1 || - len(report.ModuleResults[0].ValidatorDecisions) != 1 || - len(report.ModuleResults[0].ValidatorRejected) != 1 { - t.Fatalf("unexpected module reports: %+v", report.ModuleResults) + if decoded["version"] != 1 || decoded["schema"] != "audita-errors" || decoded["status"] != "completed" { + t.Fatalf("envelope: %#v", decoded) + } + if strings.Contains(string(errorsYAML), "provider_attempts: null") || !strings.Contains(string(errorsYAML), "errors:") { + t.Fatal(string(errorsYAML)) } } -func TestBuildFailedReportPreservesErrorAndFailureSummary(t *testing.T) { - startedAt := time.Date(2026, 5, 23, 10, 0, 0, 0, time.UTC) - completedAt := startedAt.Add(time.Second) - - report := Build(BuildInput{ - Status: "failed", - TranscriptPath: "transcript.json", - GlossaryPath: "glossary.yaml", - Modules: []string{"grammar"}, - OutputSchema: "default", - StartedAt: startedAt, - CompletedAt: completedAt, - ErrorPhase: "module", - ErrorMessage: "module failed", - RunDirectoryPath: "run-dir", - RunOutput: &runner.RunOutput{ - ModuleResults: []runner.ModuleResult{ - { - ModuleKey: "grammar", - ModuleInstance: "grammar", - Status: runner.ModuleStatusFailed, - ErrorMessage: "module failed", - StartedAt: startedAt, - CompletedAt: completedAt, - }, - }, - }, - }) - - if report.Status != "failed" || report.ErrorPhase != "module" || report.ErrorMessage != "module failed" { - t.Fatalf("unexpected failure fields: %+v", report) +func TestBuildInitializationNoWorkAndRunFailure(t *testing.T) { + start := time.Now().UTC() + initial := Build(BuildInput{RunID: "r", Status: reporting.StatusInProgress, StartedAt: start}) + initialDoc := initial.ErrorsDocument() + if initialDoc.CompletedAt != nil || initialDoc.Summary.WorkUnitsTotal != nil || initialDoc.Errors == nil { + t.Fatalf("initial: %+v", initialDoc) } - if report.Diagnostics == nil || report.Diagnostics.ErrorLogPath == "" { - t.Fatalf("expected failure diagnostics metadata, got %+v", report.Diagnostics) + finished := Build(BuildInput{RunID: "r", Status: reporting.StatusCompleted, StartedAt: start, RunOutput: &runner.RunOutput{}}) + if finished.Summary.WorkUnitsTotal == nil || *finished.Summary.WorkUnitsTotal != 0 || finished.RecoveriesDocument().Recoveries == nil { + t.Fatalf("empty: %+v", finished) } - if report.ModulesSummary == nil || report.ModulesSummary.FailedModuleInstance != "grammar" { - t.Fatalf("unexpected failed module summary: %+v", report.ModulesSummary) + failed := Build(BuildInput{RunID: "r", Status: reporting.StatusFailed, StartedAt: start, + RunFailures: []RunFailure{{Phase: "report_write", Reason: outcomes.ReasonInternalFailure, OutputPath: "z.yml"}, {Phase: "processing", Reason: outcomes.ReasonCanceled}, {Phase: "report_write", Reason: outcomes.ReasonInternalFailure, OutputPath: "a.yml"}}}) + if len(failed.Errors) != 3 || failed.Errors[0].Phase != "processing" || failed.Errors[1].OutputPath != "a.yml" || failed.Errors[2].OutputPath != "z.yml" || failed.Summary.WorkUnitsTotal != nil || *failed.Summary.RunErrors != 3 { + t.Fatalf("run failures: %+v", failed) } } + +func intPtr(value int) *int { return &value }