Render fixed Markdown reports and shared completion counts
This commit is contained in:
@@ -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
|
||||
|
||||
180
internal/core/reporting/markdown.go
Normal file
180
internal/core/reporting/markdown.go
Normal 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>")
|
||||
}
|
||||
79
internal/core/reporting/markdown_test.go
Normal file
79
internal/core/reporting/markdown_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
43
internal/core/reporting/testdata/report.md
vendored
Normal file
43
internal/core/reporting/testdata/report.md
vendored
Normal 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`.
|
||||
Reference in New Issue
Block a user