Render fixed Markdown reports and shared completion counts

This commit is contained in:
2026-09-24 17:34:03 +00:00
parent 61af29ecb8
commit 989b28e8c5
4 changed files with 311 additions and 0 deletions

View File

@@ -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

View File

@@ -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", "<br>")
}

View File

@@ -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<br>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\\`<br>[REDACTED]") {
t.Fatal(text)
}
}

View File

@@ -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<br>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`.