From 989b28e8c5f2ecf13e500dca798cc907d610f665 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Thu, 24 Sep 2026 17:34:03 +0000 Subject: [PATCH] Render fixed Markdown reports and shared completion counts --- docs/roadmap/implementation.md | 9 ++ internal/core/reporting/markdown.go | 180 +++++++++++++++++++++ internal/core/reporting/markdown_test.go | 79 +++++++++ internal/core/reporting/testdata/report.md | 43 +++++ 4 files changed, 311 insertions(+) create mode 100644 internal/core/reporting/markdown.go create mode 100644 internal/core/reporting/markdown_test.go create mode 100644 internal/core/reporting/testdata/report.md diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 6c8509f..8643a0c 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -652,6 +652,15 @@ no filesystem effects or verbose payload leakage. ## Stage 9 — Render the Fixed Markdown and CLI Summaries +**Completed 2026-09-24.** Markdown now renders the fixed error and recovery +matrices, all known chunks and configured stage columns, state legends, totals, +short reason counts, safe paths, and YAML references. The CLI completion line +uses the same snapshot counts. A representative golden fixture (updated only +with `UPDATE_REPORT_GOLDEN=1`) and semantic tests cover repeated stages, +partial/unattempted cells, empty work, escaped labels, redaction, and YAML count +agreement. `go test ./internal/core/reporting ./internal/framework/processreport` +and `git diff --check` passed. CLI stream/file wiring remains for Stage 12. + **Work:** Render F/H from the Stage 8 snapshot. Produce both full matrices, totals, fixed legends, short reason breakdowns, and safe file references. Display 1-based chunk labels without changing machine identity. Include interrupted and unattempted diff --git a/internal/core/reporting/markdown.go b/internal/core/reporting/markdown.go new file mode 100644 index 0000000..76b06fd --- /dev/null +++ b/internal/core/reporting/markdown.go @@ -0,0 +1,180 @@ +package reporting + +import ( + "fmt" + "sort" + "strings" + + "gitea.maximumdirect.net/eric/audita/internal/core/redaction" +) + +// CompletionLine is the brief successful CLI summary. It uses the same counts +// as both YAML documents and remains independent of file-output selection. +func CompletionLine(snapshot Snapshot) string { + return fmt.Sprintf("Completed: %d corrections applied; %d work units with errors; %d recoveries.", + count(snapshot.Summary.CorrectionsApplied), count(snapshot.Summary.WorkUnitsWithErrors), count(snapshot.Summary.Recoveries)) +} + +func count(value *int) int { + if value == nil { + return 0 + } + return *value +} + +// Markdown renders a fixed-layout human report from one processing snapshot. +func Markdown(snapshot Snapshot, secrets ...string) []byte { + r := redaction.New(secrets) + safe := func(value string) string { return escapeMarkdown(r.String(value)) } + var b strings.Builder + fmt.Fprintf(&b, "# Audita report: %s\n\n", safe(snapshot.RunID)) + fmt.Fprintf(&b, "Status: %s\n\n", safe(snapshot.Status)) + fmt.Fprintf(&b, "Corrections applied: %d \n", count(snapshot.Summary.CorrectionsApplied)) + fmt.Fprintf(&b, "Work units: %s total; %s completed; %s interrupted; %s not started \n", + showCount(snapshot.Summary.WorkUnitsTotal), showCount(snapshot.Summary.WorkUnitsCompleted), + showCount(snapshot.Summary.WorkUnitsInterrupted), showCount(snapshot.Summary.WorkUnitsNotStarted)) + fmt.Fprintf(&b, "Errors: %s work units; %s run errors \n", showCount(snapshot.Summary.WorkUnitsWithErrors), showCount(snapshot.Summary.RunErrors)) + fmt.Fprintf(&b, "Recoveries: %s of %s model operations \n", showCount(snapshot.Summary.Recoveries), showCount(snapshot.Summary.LLMOperations)) + fmt.Fprintf(&b, "Proposal responses: %s; with filtering: %s\n\n", + showCount(snapshot.Summary.ProposalResponses), showCount(snapshot.Summary.ProposalResponsesWithFiltering)) + fmt.Fprintf(&b, "Transcript input: %s \n", safe(snapshot.TranscriptPath)) + fmt.Fprintf(&b, "Glossary input: %s \n", safe(snapshot.GlossaryPath)) + output := snapshot.OutputPath + if output == "" { + output = "stdout" + } + fmt.Fprintf(&b, "Transcript output: %s\n\n", safe(output)) + + fmt.Fprintln(&b, "## Work-unit errors") + fmt.Fprintln(&b) + writeMatrix(&b, snapshot, safe, false) + fmt.Fprintln(&b) + fmt.Fprintln(&b, "## Recoveries") + fmt.Fprintln(&b) + writeMatrix(&b, snapshot, safe, true) + fmt.Fprintln(&b) + fmt.Fprintln(&b, "Legend: `—` = not started; `n*` = interrupted with n recorded errors or recoveries. Completed error cells are 0 or 1.") + fmt.Fprintln(&b) + writeReasons(&b, "Error reasons", errorReasons(snapshot.Errors), safe) + writeReasons(&b, "Recovery reasons", recoveryReasons(snapshot.Recoveries), safe) + fmt.Fprintln(&b, "A recovery can have multiple reasons; reason counts need not sum to the recovery total.") + fmt.Fprintln(&b) + fmt.Fprintln(&b, "Details: `errors.yml` and `recoveries.yml`.") + return []byte(b.String()) +} + +func showCount(value *int) string { + if value == nil { + return "unknown" + } + return fmt.Sprint(*value) +} + +func writeMatrix(b *strings.Builder, snapshot Snapshot, safe func(string) string, recovery bool) { + if len(snapshot.StageNames) == 0 || len(snapshot.WorkUnits) == 0 { + fmt.Fprintln(b, "No work units.") + return + } + columns := snapshot.StageNames + fmt.Fprint(b, "| Chunk |") + for _, name := range columns { + fmt.Fprintf(b, " %s |", safe(name)) + } + fmt.Fprint(b, "\n| --- |") + for range columns { + fmt.Fprint(b, " ---: |") + } + fmt.Fprintln(b) + byChunk := make(map[int]map[int]WorkUnit) + chunkMeta := make(map[int]WorkUnit) + keys := make([]int, 0) + for _, unit := range snapshot.WorkUnits { + if byChunk[unit.ChunkIndex] == nil { + byChunk[unit.ChunkIndex] = make(map[int]WorkUnit) + keys = append(keys, unit.ChunkIndex) + } + byChunk[unit.ChunkIndex][unit.StageIndex] = unit + chunkMeta[unit.ChunkIndex] = unit + } + sort.Ints(keys) + for _, chunk := range keys { + meta := chunkMeta[chunk] + fmt.Fprintf(b, "| Chunk %d (segments %d–%d) |", chunk+1, meta.StartSegmentID, meta.EndSegmentID) + for stage := range columns { + unit, exists := byChunk[chunk][stage] + cell := "—" + if exists && unit.State != "not_started" { + n := unit.ErrorCount + if recovery { + n = unit.RecoveryCount + } + cell = fmt.Sprint(n) + if unit.State == "interrupted" { + cell += "*" + } + } + fmt.Fprintf(b, " %s |", cell) + } + fmt.Fprintln(b) + } +} + +func writeReasons(b *strings.Builder, title string, counts map[string]int, safe func(string) string) { + fmt.Fprintf(b, "### %s\n\n", title) + if len(counts) == 0 { + fmt.Fprintln(b, "None.") + fmt.Fprintln(b) + return + } + keys := make([]string, 0, len(counts)) + for key := range counts { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + fmt.Fprintf(b, "- `%s`: %d\n", safe(key), counts[key]) + } + fmt.Fprintln(b) +} + +func errorReasons(records []ErrorRecord) map[string]int { + counts := make(map[string]int) + for _, record := range records { + seen := make(map[string]bool) + if record.Scope == "run" { + seen[record.Reason] = true + } + for _, failure := range record.Failures { + seen[failure.Reason] = true + } + for reason := range seen { + if reason != "" { + counts[reason]++ + } + } + } + return counts +} + +func recoveryReasons(records []RecoveryRecord) map[string]int { + counts := make(map[string]int) + for _, record := range records { + seen := make(map[string]bool) + for _, reason := range record.Reasons { + seen[reason] = true + } + for reason := range seen { + counts[reason]++ + } + } + return counts +} + +func escapeMarkdown(value string) string { + value = strings.ReplaceAll(value, "\\", "\\\\") + value = strings.ReplaceAll(value, "|", "\\|") + value = strings.ReplaceAll(value, "`", "\\`") + value = strings.ReplaceAll(value, "\r\n", "\n") + value = strings.ReplaceAll(value, "\r", "\n") + return strings.ReplaceAll(value, "\n", "
") +} diff --git a/internal/core/reporting/markdown_test.go b/internal/core/reporting/markdown_test.go new file mode 100644 index 0000000..3f966f6 --- /dev/null +++ b/internal/core/reporting/markdown_test.go @@ -0,0 +1,79 @@ +package reporting + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "gopkg.in/yaml.v3" +) + +func TestMarkdownFixedLayoutAndCounts(t *testing.T) { + one, two, three, four, zero := 1, 2, 3, 4, 0 + snapshot := Snapshot{ + RunID: "run-123", Status: StatusCompleted, StartedAt: time.Date(2026, 9, 24, 10, 0, 0, 0, time.UTC), + TranscriptPath: "in|put.json", GlossaryPath: "glossary`secret`.yaml", OutputPath: "out\nfile.json", + StageNames: []string{"grammar_1", "grammar_2"}, + WorkUnits: []WorkUnit{ + {StageIndex: 0, ModuleInstance: "grammar_1", ChunkIndex: 0, StartSegmentID: 1, EndSegmentID: 2, State: "completed", ErrorCount: 1, RecoveryCount: 1, CorrectionsApplied: 1}, + {StageIndex: 1, ModuleInstance: "grammar_2", ChunkIndex: 0, StartSegmentID: 1, EndSegmentID: 2, State: "interrupted", RecoveryCount: 2}, + {StageIndex: 0, ModuleInstance: "grammar_1", ChunkIndex: 1, StartSegmentID: 3, EndSegmentID: 4, State: "completed"}, + {StageIndex: 1, ModuleInstance: "grammar_2", ChunkIndex: 1, StartSegmentID: 3, EndSegmentID: 4, State: "not_started"}, + }, + Summary: Summary{WorkUnitsTotal: &four, WorkUnitsCompleted: &two, WorkUnitsInterrupted: &one, WorkUnitsNotStarted: &one, + WorkUnitsWithErrors: &one, RunErrors: &zero, CorrectionsApplied: &one, LLMOperations: &four, + ProposalResponses: &one, ProposalResponsesWithFiltering: &one, Recoveries: &three}, + Errors: []ErrorRecord{{Scope: "work_unit", Failures: []Failure{{Reason: "transport_failed"}, {Reason: "transport_failed"}}}}, + Recoveries: []RecoveryRecord{{Reasons: []string{"output_repaired", "proposals_filtered"}}, {Reasons: []string{"output_repaired"}}, {Reasons: []string{"service_recovered"}}}, + } + got := Markdown(snapshot, "secret") + fixture := filepath.Join("testdata", "report.md") + if os.Getenv("UPDATE_REPORT_GOLDEN") == "1" { + if err := os.WriteFile(fixture, got, 0600); err != nil { + t.Fatal(err) + } + } + want, err := os.ReadFile(fixture) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, want) { + t.Fatalf("Markdown differs from %s:\n%s", fixture, got) + } + text := string(got) + for _, fragment := range []string{"| grammar_1 | grammar_2 |", "| Chunk 1 (segments 1–2) | 1 | 0* |", "| Chunk 1 (segments 1–2) | 1 | 2* |", "| Chunk 2 (segments 3–4) | 0 | — |", "in\\|put.json", "out
file.json", "glossary\\`[REDACTED]\\`.yaml", "- `transport_failed`: 1", "- `output_repaired`: 2"} { + if !strings.Contains(text, fragment) { + t.Fatalf("missing %q in:\n%s", fragment, text) + } + } + if strings.Contains(text, "secret") { + t.Fatal("secret leaked") + } + var decoded struct { + Summary Summary `yaml:"summary"` + } + yamlBytes, err := YAML(snapshot.ErrorsDocument()) + if err != nil { + t.Fatal(err) + } + if err := yaml.Unmarshal(yamlBytes, &decoded); err != nil { + t.Fatal(err) + } + if CompletionLine(snapshot) != "Completed: 1 corrections applied; 1 work units with errors; 3 recoveries." || *decoded.Summary.WorkUnitsWithErrors != 1 || *decoded.Summary.Recoveries != 3 { + t.Fatalf("CLI/YAML count disagreement: %s %+v", CompletionLine(snapshot), decoded.Summary) + } +} + +func TestMarkdownNoWorkAndEscapedStageLabel(t *testing.T) { + text := string(Markdown(Snapshot{RunID: "r", StageNames: []string{"bad|label`\nsecret"}}, "secret")) + if strings.Count(text, "No work units.") != 2 || strings.Contains(text, "secret") { + t.Fatal(text) + } + text = string(Markdown(Snapshot{RunID: "r", StageNames: []string{"bad|label`\nsecret"}, WorkUnits: []WorkUnit{{StageIndex: 0, ChunkIndex: 0, State: "completed"}}}, "secret")) + if !strings.Contains(text, "bad\\|label\\`
[REDACTED]") { + t.Fatal(text) + } +} diff --git a/internal/core/reporting/testdata/report.md b/internal/core/reporting/testdata/report.md new file mode 100644 index 0000000..5874943 --- /dev/null +++ b/internal/core/reporting/testdata/report.md @@ -0,0 +1,43 @@ +# Audita report: run-123 + +Status: completed + +Corrections applied: 1 +Work units: 4 total; 2 completed; 1 interrupted; 1 not started +Errors: 1 work units; 0 run errors +Recoveries: 3 of 4 model operations +Proposal responses: 1; with filtering: 1 + +Transcript input: in\|put.json +Glossary input: glossary\`[REDACTED]\`.yaml +Transcript output: out
file.json + +## Work-unit errors + +| Chunk | grammar_1 | grammar_2 | +| --- | ---: | ---: | +| Chunk 1 (segments 1–2) | 1 | 0* | +| Chunk 2 (segments 3–4) | 0 | — | + +## Recoveries + +| Chunk | grammar_1 | grammar_2 | +| --- | ---: | ---: | +| Chunk 1 (segments 1–2) | 1 | 2* | +| Chunk 2 (segments 3–4) | 0 | — | + +Legend: `—` = not started; `n*` = interrupted with n recorded errors or recoveries. Completed error cells are 0 or 1. + +### Error reasons + +- `transport_failed`: 1 + +### Recovery reasons + +- `output_repaired`: 2 +- `proposals_filtered`: 1 +- `service_recovered`: 1 + +A recovery can have multiple reasons; reason counts need not sum to the recovery total. + +Details: `errors.yml` and `recoveries.yml`.