Complete comparison command output

This commit is contained in:
2026-08-02 06:02:24 +00:00
parent b489c56a48
commit ccf6b66880
4 changed files with 355 additions and 8 deletions

View File

@@ -3,6 +3,7 @@ package cli
import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
@@ -12,6 +13,7 @@ import (
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
"gitea.maximumdirect.net/eric/weatherreporter/internal/comparison"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
@@ -175,13 +177,110 @@ func TestExecuteComparisonUsesOneExecutorAndInjectedApplication(t *testing.T) {
}
}
func TestCompareRemainsAbsentFromRootDispatchAndHelp(t *testing.T) {
runner := comparisonRunner(t, t.TempDir())
var stdout, stderr bytes.Buffer
if err := runner.Run(context.Background(), []string{"compare", "today"}, &stdout, &stderr); err == nil || err.Error() != `unknown command "compare"` {
t.Fatalf("Run(compare) error = %v", err)
func TestCompareCommandWritesStructuredPartialFailure(t *testing.T) {
workingDir := t.TempDir()
configPath := comparisonConfigPath(t, "weather_api:\n base_url: https://weather.api.example.com/\n")
profileFailure := comparison.NewSafeError("generation", "execute prompt failed")
result := comparisonResult("/reports/comparison-daily", []app.ComparisonProfileResult{
{Position: 1, ProfileID: "weather-light", BackendID: "local", ModelName: "light", Status: comparison.StatusSucceeded, ValidationStatus: promptexec.ValidationPassed, ReportPath: "/reports/comparison-daily/01-weather-light.md", LLMDebugPath: "/debug/comparison-light"},
{Position: 2, ProfileID: "weather-deep", BackendID: "cloud", ModelName: "deep", Status: comparison.StatusFailed, Error: &profileFailure},
})
partialErr := errors.New("comparison completed with 1 failed profiles")
factoryCalls, applicationCalls := 0, 0
runner := Runner{
Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 29, 8, 30, 0, 0, time.UTC)}, WorkingDir: workingDir,
ExecutorFactory: func(PromptExecutorConfig) (promptexec.Executor, error) {
factoryCalls++
return &factoryExecutor{}, nil
},
compareDetailed: func(context.Context, app.ComparisonRequest) (*app.ComparisonResult, error) {
applicationCalls++
return result, partialErr
},
}
if err := runner.Run(context.Background(), []string{"--help"}, &stdout, &stderr); err != nil || strings.Contains(stdout.String(), "compare") {
var stdout, stderr bytes.Buffer
err := runner.Run(context.Background(), []string{"compare", "daily", "--date", "2026-05-29", "--profile", "weather-light", "--profile", "weather-deep", "--config", configPath}, &stdout, &stderr)
if !errors.Is(err, partialErr) || factoryCalls != 1 || applicationCalls != 1 || stderr.Len() != 0 {
t.Fatalf("error/calls/stderr = %v/%d/%d/%q", err, factoryCalls, applicationCalls, stderr.String())
}
var summary comparisonSummary
if err := json.Unmarshal(stdout.Bytes(), &summary); err != nil {
t.Fatalf("decode summary: %v\n%s", err, stdout.String())
}
if summary.Command != commandCompare || summary.Status != summaryStatusFailed || summary.Error == nil || summary.Error.Message != partialErr.Error() || summary.OutputDirectory != result.OutputDirectory || len(summary.Results) != 2 || summary.Results[0].ReportPath != result.Results[0].ReportPath || summary.Results[0].LLMDebugPath != result.Results[0].LLMDebugPath || summary.Results[1].Error == nil {
t.Fatalf("summary = %#v", summary)
}
}
func TestCompareCommandWritesSuccessForDefaultAndExplicitDestinations(t *testing.T) {
workingDir := t.TempDir()
configPath := comparisonConfigPath(t, "weather_api:\n base_url: https://weather.api.example.com/\noutput:\n directory: configured-reports\n")
for _, test := range []struct {
name string
outputArgument []string
outputDirectory string
wantRequestPath string
}{
{name: "configured default", outputDirectory: filepath.Join(workingDir, "configured-reports", "comparison-daily-2026-05-29")},
{name: "explicit directory", outputArgument: []string{"--out-dir", "published"}, outputDirectory: filepath.Join(workingDir, "published"), wantRequestPath: filepath.Join(workingDir, "published")},
} {
t.Run(test.name, func(t *testing.T) {
var received app.ComparisonRequest
runner := comparisonRunner(t, workingDir)
runner.compareDetailed = func(_ context.Context, req app.ComparisonRequest) (*app.ComparisonResult, error) {
received = req
return comparisonResult(test.outputDirectory, []app.ComparisonProfileResult{
{Position: 1, ProfileID: "weather-light", ModelName: "light", Status: comparison.StatusSucceeded, ValidationStatus: promptexec.ValidationPassed, ReportPath: filepath.Join(test.outputDirectory, "01-weather-light.md")},
{Position: 2, ProfileID: "weather-deep", ModelName: "deep", Status: comparison.StatusSucceeded, ValidationStatus: promptexec.ValidationPassed, ReportPath: filepath.Join(test.outputDirectory, "02-weather-deep.md")},
}), nil
}
args := []string{"compare", "daily", "--date", "2026-05-29", "--profile", "weather-light", "--profile", "weather-deep", "--config", configPath}
args = append(args, test.outputArgument...)
var stdout, stderr bytes.Buffer
if err := runner.Run(context.Background(), args, &stdout, &stderr); err != nil {
t.Fatalf("Run() error = %v", err)
}
var summary comparisonSummary
if err := json.Unmarshal(stdout.Bytes(), &summary); err != nil || summary.Status != summaryStatusSucceeded || summary.OutputDirectory != test.outputDirectory || stderr.Len() != 0 || received.OutputDir != test.wantRequestPath {
t.Fatalf("summary/error/stderr/request = %#v/%v/%q/%#v", summary, err, stderr.String(), received)
}
})
}
}
func TestCompareCommandQuietPreservesFailure(t *testing.T) {
configPath := comparisonConfigPath(t, "weather_api:\n base_url: https://weather.api.example.com/\n")
failure := errors.New("comparison completed with 2 failed profiles")
calls := 0
runner := comparisonRunner(t, t.TempDir())
runner.compareDetailed = func(context.Context, app.ComparisonRequest) (*app.ComparisonResult, error) {
calls++
return comparisonResult("/reports/comparison-daily", nil), failure
}
var stdout, stderr bytes.Buffer
err := runner.Run(context.Background(), []string{"compare", "daily", "--date", "2026-05-29", "--profile", "weather-light", "--profile", "weather-deep", "--quiet", "--config", configPath}, &stdout, &stderr)
if !errors.Is(err, failure) || calls != 1 || stdout.Len() != 0 || stderr.Len() != 0 {
t.Fatalf("error/calls/stdout/stderr = %v/%d/%q/%q", err, calls, stdout.String(), stderr.String())
}
}
func TestCompareCommandLeavesPreExecutionFailuresUnstructured(t *testing.T) {
runner := comparisonRunner(t, t.TempDir())
called := false
runner.compareDetailed = func(context.Context, app.ComparisonRequest) (*app.ComparisonResult, error) {
called = true
return nil, nil
}
var stdout, stderr bytes.Buffer
err := runner.Run(context.Background(), []string{"compare", "today", "--profile", "only-one"}, &stdout, &stderr)
if err == nil || called || stdout.Len() != 0 || stderr.Len() != 0 {
t.Fatalf("error/called/stdout/stderr = %v/%t/%q/%q", err, called, stdout.String(), stderr.String())
}
}
func TestCompareHelpIncludesCommand(t *testing.T) {
var stdout, stderr bytes.Buffer
if err := (Runner{}).Run(context.Background(), []string{"--help"}, &stdout, &stderr); err != nil || !strings.Contains(stdout.String(), "weatherreporter compare REPORT") || !strings.Contains(stdout.String(), "--profile PROFILE") {
t.Fatalf("help/error = %q/%v", stdout.String(), err)
}
}
@@ -202,3 +301,23 @@ func comparisonConfigPath(t *testing.T, contents string) string {
}
return path
}
func comparisonResult(outputDirectory string, results []app.ComparisonProfileResult) *app.ComparisonResult {
started := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
result := &app.ComparisonResult{
ComparisonID: "comparison_run-123", ReportID: "daily", ReportName: "Daily Report",
PromptID: "weather.daily_generated_text", PromptVersion: "2.0.0", PromptHash: strings.Repeat("a", 64),
StartedAt: started, FinishedAt: started.Add(time.Minute), Timezone: "America/Chicago",
ValidPeriod: timeutil.Period{Start: started, End: started.Add(24 * time.Hour)}, OutputDirectory: outputDirectory,
ManifestPath: filepath.Join(outputDirectory, "comparison.json"), DataPackagePath: filepath.Join(outputDirectory, "data-package.yml"),
Results: append([]app.ComparisonProfileResult(nil), results...), Total: len(results),
}
for _, profile := range results {
if profile.Status == "succeeded" {
result.Succeeded++
} else {
result.Failed++
}
}
return result
}

View File

@@ -1,9 +1,15 @@
package cli
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
"gitea.maximumdirect.net/eric/weatherreporter/internal/comparison"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
@@ -12,6 +18,7 @@ import (
const (
commandGenerate = "generate"
commandRun = "run"
commandCompare = "compare"
summaryStatusSucceeded = "succeeded"
summaryStatusFailed = "failed"
@@ -67,6 +74,41 @@ type batchSummary struct {
Error string `json:"error,omitempty"`
}
type comparisonSummary struct {
Command string `json:"command"`
ComparisonID string `json:"comparisonId"`
ReportID report.ID `json:"reportId"`
ReportName string `json:"reportName"`
PromptID string `json:"promptId"`
PromptVersion string `json:"promptVersion"`
PromptHash string `json:"promptHash"`
Status string `json:"status"`
StartedAt time.Time `json:"startedAt"`
FinishedAt time.Time `json:"finishedAt"`
Timezone string `json:"timezone"`
ValidPeriod timeutil.Period `json:"validPeriod"`
OutputDirectory string `json:"outputDirectory"`
ManifestPath string `json:"manifestPath,omitempty"`
DataPackagePath string `json:"dataPackagePath,omitempty"`
Total int `json:"total"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
Results []comparisonProfileSummary `json:"results"`
Error *comparison.SafeError `json:"error,omitempty"`
}
type comparisonProfileSummary struct {
Position int `json:"position"`
ProfileID string `json:"profileId"`
BackendID string `json:"backendId,omitempty"`
ModelName string `json:"modelName"`
Status string `json:"status"`
ValidationStatus string `json:"validationStatus,omitempty"`
ReportPath string `json:"reportPath,omitempty"`
LLMDebugPath string `json:"llmDebugPath,omitempty"`
Error *comparison.SafeError `json:"error,omitempty"`
}
func newGenerateSummary(result *app.ReportResult, err error) generateSummary {
summary := generateSummary{Command: commandGenerate}
if result == nil {
@@ -139,6 +181,66 @@ func newBatchSummary(result *app.BatchResult) batchSummary {
return summary
}
func newComparisonSummary(result *app.ComparisonResult, err error) comparisonSummary {
summary := comparisonSummary{Command: commandCompare, Results: []comparisonProfileSummary{}}
if result == nil {
return summary
}
summary.ComparisonID = result.ComparisonID
summary.ReportID, summary.ReportName = result.ReportID, result.ReportName
summary.PromptID, summary.PromptVersion, summary.PromptHash = result.PromptID, result.PromptVersion, result.PromptHash
summary.StartedAt, summary.FinishedAt = result.StartedAt, result.FinishedAt
summary.Timezone, summary.ValidPeriod = result.Timezone, result.ValidPeriod
summary.OutputDirectory = result.OutputDirectory
summary.ManifestPath, summary.DataPackagePath = result.ManifestPath, result.DataPackagePath
summary.Total, summary.Succeeded, summary.Failed = result.Total, result.Succeeded, result.Failed
for _, profile := range result.Results {
summary.Results = append(summary.Results, comparisonProfileSummary{
Position: profile.Position, ProfileID: profile.ProfileID, BackendID: profile.BackendID, ModelName: profile.ModelName,
Status: profile.Status, ValidationStatus: string(profile.ValidationStatus), ReportPath: profile.ReportPath,
LLMDebugPath: profile.LLMDebugPath, Error: profile.Error,
})
}
summary.Status = comparisonSummaryStatus(result, err)
if err != nil {
summary.Error = safeComparisonSummaryError(err)
}
return summary
}
func comparisonSummaryStatus(result *app.ComparisonResult, err error) string {
if result == nil || err != nil || result.Total < 2 || result.Succeeded != result.Total || result.Failed != 0 || result.ManifestPath == "" || result.DataPackagePath == "" {
return summaryStatusFailed
}
return summaryStatusSucceeded
}
func safeComparisonSummaryError(err error) *comparison.SafeError {
message := "comparison did not complete"
if aggregate, ok := comparisonAggregateErrorMessage(err.Error()); ok {
message = aggregate
} else if errors.Is(err, context.DeadlineExceeded) {
message = "comparison deadline exceeded"
} else if errors.Is(err, context.Canceled) {
message = "comparison canceled"
}
safe := comparison.NewSafeError("application", message)
return &safe
}
func comparisonAggregateErrorMessage(value string) (string, bool) {
const prefix = "comparison completed with "
const suffix = " failed profiles"
if !strings.HasPrefix(value, prefix) || !strings.HasSuffix(value, suffix) {
return "", false
}
count, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(value, prefix), suffix))
if err != nil || count < 1 {
return "", false
}
return fmt.Sprintf("comparison completed with %d failed profiles", count), true
}
func batchSummaryStatus(result *app.BatchResult) string {
if result == nil {
return ""

View File

@@ -1,12 +1,15 @@
package cli
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
"gitea.maximumdirect.net/eric/weatherreporter/internal/comparison"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
@@ -29,3 +32,106 @@ func TestGenerateSummaryUsesActiveResultFields(t *testing.T) {
}
}
}
func TestComparisonSummaryUsesLockedOrderAndSafeFields(t *testing.T) {
started := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
profileFailure := comparison.NewSafeError("generation", "execute prompt failed")
result := &app.ComparisonResult{
ComparisonID: "comparison_run-123", ReportID: report.Daily, ReportName: "Daily Report",
PromptID: "weather.daily_generated_text", PromptVersion: "2.0.0", PromptHash: strings.Repeat("a", 64),
StartedAt: started, FinishedAt: started.Add(time.Minute), Timezone: "America/Chicago",
ValidPeriod: timeutil.Period{Start: started, End: started.Add(24 * time.Hour)}, OutputDirectory: "/reports/comparison-daily",
ManifestPath: "/reports/comparison-daily/comparison.json", DataPackagePath: "/reports/comparison-daily/data-package.yml",
Total: 2, Succeeded: 1, Failed: 1,
Results: []app.ComparisonProfileResult{
{Position: 1, ProfileID: "weather-light", BackendID: "local", ModelName: "light", Status: comparison.StatusSucceeded, ValidationStatus: promptexec.ValidationPassed, ReportPath: "/reports/comparison-daily/01-weather-light.md"},
{Position: 2, ProfileID: "weather-deep", BackendID: "cloud", ModelName: "deep", Status: comparison.StatusFailed, Error: &profileFailure},
},
}
summary := newComparisonSummary(result, errors.New("comparison completed with 1 failed profiles"))
if summary.Command != commandCompare || summary.Status != summaryStatusFailed || summary.Error == nil || summary.Error.Message != "comparison completed with 1 failed profiles" || len(summary.Results) != 2 || summary.Results[0].ReportPath == "" || summary.Results[1].Error == nil {
t.Fatalf("summary = %#v", summary)
}
data, err := json.Marshal(summary)
if err != nil {
t.Fatal(err)
}
previous := -1
for _, field := range []string{"command", "comparisonId", "reportId", "reportName", "promptId", "promptVersion", "promptHash", "status", "startedAt", "finishedAt", "timezone", "validPeriod", "outputDirectory", "manifestPath", "dataPackagePath", "total", "succeeded", "failed", "results", "error"} {
position := strings.Index(string(data), `"`+field+`":`)
if field == "error" {
position = strings.LastIndex(string(data), `"`+field+`":`)
}
if position <= previous {
t.Fatalf("field order for %q in %s", field, data)
}
previous = position
}
for _, unsafe := range []string{"provider response", "rawOutput", "renderedPrompt", "endpoint"} {
if strings.Contains(string(data), unsafe) {
t.Fatalf("summary includes unsafe content %q: %s", unsafe, data)
}
}
}
func TestComparisonSummaryOmitsUnpublishedArtifactsAndBoundsErrors(t *testing.T) {
result := &app.ComparisonResult{ComparisonID: "comparison_run-123", ReportID: report.Daily, Results: []app.ComparisonProfileResult{{Position: 1, ProfileID: "weather-light", ModelName: "light", Status: comparison.StatusFailed}}}
summary := newComparisonSummary(result, context.Canceled)
if summary.Status != summaryStatusFailed || summary.Error == nil || summary.Error.Message != "comparison canceled" || summary.Results == nil {
t.Fatalf("summary = %#v", summary)
}
data, err := json.Marshal(summary)
if err != nil {
t.Fatal(err)
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(data, &fields); err != nil {
t.Fatal(err)
}
for _, omitted := range []string{"manifestPath", "dataPackagePath"} {
if _, exists := fields[omitted]; exists {
t.Fatalf("summary includes unpublished %s: %s", omitted, data)
}
}
publicationResult := &app.ComparisonResult{
ComparisonID: "comparison_run-123", ReportID: report.Daily, OutputDirectory: "/reports/comparison-daily", Total: 2, Succeeded: 2,
Results: []app.ComparisonProfileResult{
{Position: 1, ProfileID: "weather-light", ModelName: "light", Status: comparison.StatusSucceeded},
{Position: 2, ProfileID: "weather-deep", ModelName: "deep", Status: comparison.StatusSucceeded},
},
}
unsafe := errors.New("comparison completed with 1 failed profiles; provider response contains sensitive material")
publicationSummary := newComparisonSummary(publicationResult, unsafe)
if publicationSummary.Status != summaryStatusFailed || publicationSummary.Error == nil || publicationSummary.Error.Message != "comparison did not complete" || strings.Contains(publicationSummary.Error.Message, "sensitive") || publicationSummary.ManifestPath != "" || publicationSummary.DataPackagePath != "" {
safe := publicationSummary.Error
t.Fatalf("safe error = %#v", safe)
}
}
func TestComparisonSummaryClassifiesCompleteAndAllFailedResults(t *testing.T) {
started := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
failure := comparison.NewSafeError("generation", "execute prompt failed")
complete := &app.ComparisonResult{
ComparisonID: "comparison_run-123", ReportID: report.Daily, PromptID: "weather.daily_generated_text", PromptVersion: "2.0.0", PromptHash: strings.Repeat("a", 64),
StartedAt: started, FinishedAt: started, Timezone: "America/Chicago", ValidPeriod: timeutil.Period{Start: started, End: started.Add(time.Hour)},
OutputDirectory: "/reports/comparison-daily", ManifestPath: "/reports/comparison-daily/comparison.json", DataPackagePath: "/reports/comparison-daily/data-package.yml",
Total: 2, Succeeded: 2,
Results: []app.ComparisonProfileResult{
{Position: 1, ProfileID: "weather-light", ModelName: "light", Status: comparison.StatusSucceeded, ValidationStatus: promptexec.ValidationPassed, ReportPath: "/reports/comparison-daily/01-weather-light.md"},
{Position: 2, ProfileID: "weather-deep", ModelName: "deep", Status: comparison.StatusSucceeded, ValidationStatus: promptexec.ValidationPassed, ReportPath: "/reports/comparison-daily/02-weather-deep.md"},
},
}
if summary := newComparisonSummary(complete, nil); summary.Status != summaryStatusSucceeded || summary.Error != nil {
t.Fatalf("complete summary = %#v", summary)
}
allFailed := *complete
allFailed.Succeeded, allFailed.Failed = 0, 2
allFailed.Results = []app.ComparisonProfileResult{
{Position: 1, ProfileID: "weather-light", ModelName: "light", Status: comparison.StatusFailed, Error: &failure},
{Position: 2, ProfileID: "weather-deep", ModelName: "deep", Status: comparison.StatusFailed, Error: &failure},
}
summary := newComparisonSummary(&allFailed, errors.New("comparison completed with 2 failed profiles"))
if summary.Status != summaryStatusFailed || summary.Error == nil || summary.Error.Message != "comparison completed with 2 failed profiles" || summary.Results[0].ReportPath != "" || summary.Results[1].Error == nil {
t.Fatalf("all-failed summary = %#v", summary)
}
}

View File

@@ -27,6 +27,7 @@ Usage:
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--llm-debug-dir PATH] [--quiet]
weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--llm-debug-dir PATH] [--quiet]
weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--llm-debug-dir PATH] [--quiet]
weatherreporter compare REPORT --profile PROFILE --profile PROFILE [--config PATH] [--units VALUE] [--tz NAME] [--date YYYY-MM-DD] [--out-dir PATH] [--replace] [--llm-debug-dir PATH] [--quiet]
Options:
-h, --help Show this help message.
@@ -36,8 +37,10 @@ Options:
--tz NAME Override weather API timezone.
--out PATH Write the generated Markdown report to PATH.
--llm-debug-dir PATH Write sensitive prompt debug artifacts under PATH.
--out-dir PATH Write generated Markdown reports beneath PATH for run commands.
--quiet Suppress successful generate and run output.
--profile PROFILE Select a prompt profile for compare; repeat for every profile.
--out-dir PATH Write generated Markdown reports beneath PATH for run commands, or select the exact comparison directory.
--replace Authorize replacement of a recognized comparison bundle.
--quiet Suppress successful action output.
`
type Runner struct {
@@ -109,6 +112,23 @@ func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr
}
}
return err
case "compare":
req, opts, err := r.resolveComparisonAction(args[1:])
if err != nil {
return err
}
compareDetailed := r.compareDetailed
if compareDetailed == nil {
compareDetailed = app.CompareDetailed
}
result, err := compareDetailed(ctx, req)
if result != nil {
summary := newComparisonSummary(result, err)
if encodeErr := writeActionResult(stdout, stderr, summary, outputOptions{Quiet: opts.Quiet}, nil); encodeErr != nil {
return encodeErr
}
}
return err
default:
return fmt.Errorf("unknown command %q", args[0])
}