// Package writeback persists turn results as REPORT files. The REPORT is
// both the human-facing record and the telemetry trail (model, tier, class,
// tokens, rounds) that makes routing decisions auditable.
package writeback
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"git.knownelement.com/reachableceo/MOPAC/harness/internal/task"
)
// Report is everything that lands in a REPORT file.
type Report struct {
Time time.Time
Vertical string
Task task.Task
Class string
Tier string
Model string
PromptTokens int
CompletionTokens int
TotalTokens int
Rounds int
ToolCalls int
Denied int
Duration time.Duration
StopReason string
Content string
}
// Write renders r to
/REPORT---.md and
// refreshes /REPORT-latest.md. Both writes are atomic (tmp + rename).
func Write(dir string, r Report) (string, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", fmt.Errorf("writeback: %w", err)
}
name := fmt.Sprintf("REPORT-%s-%s-%s.md",
sanitize(r.Vertical), sanitize(r.Task.ID), r.Time.Format("20060102-150405"))
body := r.Render()
path := filepath.Join(dir, name)
if err := atomicWrite(path, body); err != nil {
return "", fmt.Errorf("writeback: %w", err)
}
if err := atomicWrite(filepath.Join(dir, "REPORT-latest.md"), body); err != nil {
return "", fmt.Errorf("writeback: %w", err)
}
return path, nil
}
// Render formats the REPORT markdown.
func (r Report) Render() string {
var b strings.Builder
fmt.Fprintf(&b, "# REPORT - %s - %s: %s\n\n", r.Vertical, r.Task.ID, r.Task.Subject)
fmt.Fprintf(&b, "- date: %s\n", r.Time.Format(time.RFC3339))
fmt.Fprintf(&b, "- task source: %s\n", r.Task.Source)
fmt.Fprintf(&b, "- model: %s (tier %s, class %s)\n", r.Model, r.Tier, r.Class)
fmt.Fprintf(&b, "- usage: prompt=%d completion=%d total=%d tokens\n",
r.PromptTokens, r.CompletionTokens, r.TotalTokens)
fmt.Fprintf(&b, "- turn: rounds=%d tool_calls=%d denied=%d stop=%s\n",
r.Rounds, r.ToolCalls, r.Denied, r.StopReason)
fmt.Fprintf(&b, "- duration: %s\n", r.Duration.Round(time.Millisecond))
fmt.Fprintf(&b, "\n## Result\n\n")
content := r.Content
if content == "" {
content = "(no content produced)"
}
b.WriteString(content)
b.WriteString("\n")
return b.String()
}
func atomicWrite(path, body string) error {
tmp := path + ".tmp"
if err := os.WriteFile(tmp, []byte(body), 0o644); err != nil {
return err
}
return os.Rename(tmp, path)
}
func sanitize(s string) string {
var b strings.Builder
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '_', r == '-':
b.WriteRune(r)
default:
b.WriteByte('-')
}
}
return b.String()
}