Use Promptkit for single report generation

This commit is contained in:
2026-07-31 04:41:02 +00:00
parent 9a17a8de93
commit 06b26d5e88
14 changed files with 772 additions and 281 deletions

View File

@@ -13,10 +13,51 @@ import (
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func testRunner() Runner {
return testRunnerWithClock(fixedClock())
}
func testRunnerWithClock(clock timeutil.Clock) Runner {
return Runner{Clock: clock, ExecutorFactory: func(PromptExecutorConfig) (promptexec.Executor, error) {
return cliPromptExecutor{}, nil
}}
}
type cliPromptExecutor struct{}
func (cliPromptExecutor) InspectPrompt(_ context.Context, id string, version string) (promptexec.PromptInspection, error) {
name := strings.TrimSuffix(strings.TrimPrefix(id, "weather."), "_generated_text")
return promptexec.PromptInspection{
PromptID: id, PromptVersion: version, PromptHash: "prompt-hash", DefaultProfileID: "test-profile",
Inputs: []promptexec.InputDefinition{{Name: "data_package", Required: true, ContentType: "application/yaml"}},
Output: promptexec.OutputContract{Format: "json", ValidationMode: "json_schema", SchemaPath: name + ".generated_text.schema.json"},
}, nil
}
func (cliPromptExecutor) InspectProfile(_ context.Context, id string) (promptexec.ProfileInspection, error) {
return promptexec.ProfileInspection{ProfileID: id, BackendID: "test", ModelName: "test-model"}, nil
}
func (cliPromptExecutor) Execute(_ context.Context, request promptexec.ExecuteRequest, callback promptexec.PreparationCallback) (*promptexec.Execution, error) {
now := time.Now().UTC()
if err := callback(promptexec.Preparation{PromptID: request.PromptID, PromptVersion: request.PromptVersion, PromptHash: "prompt-hash", RenderedPromptHash: "rendered-hash", ProfileID: request.ProfileID, BackendID: "test", ModelName: "test-model", DataPackagePath: request.DataPackagePath, StartedAt: now, EndedAt: now}, nil); err != nil {
return nil, err
}
raw := []byte(`{"summary": "Showers are possible during the selected day.", "forecast_discussion": ["A front will keep rain chances in the forecast."], "precipitation_timing": "Rain is most likely during the afternoon."}`)
if request.PromptID == "weather.today_generated_text" {
raw = []byte(`{"summary": "Today starts with showers before improving.", "forecast_discussion": ["Morning showers should taper as drier air arrives.", "Afternoon conditions trend quieter."], "precipitation_timing": "The best rain chance is during the morning."}`)
}
if request.PromptID == "weather.hourly_generated_text" {
raw = []byte(`{"summary":"Storm chances increase through late morning.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":"A cold front is moving into the region."}`)
}
return &promptexec.Execution{RunID: "provider-run", PromptID: request.PromptID, PromptVersion: request.PromptVersion, PromptHash: "prompt-hash", RenderedPromptHash: "rendered-hash", ProfileID: request.ProfileID, BackendID: "test", ModelName: "test-model", GeneratedHash: "generated-hash", StartedAt: now, EndedAt: now, DataPackagePath: request.DataPackagePath, RawOutput: raw, Validation: promptexec.NewValidation(promptexec.ValidationPassed, "json_schema", "generated_text.schema.json", nil)}, nil
}
func TestRunHelpLongFlag(t *testing.T) {
output, err := runRootCommand(t, "--help")
if err != nil {
@@ -73,7 +114,7 @@ func TestRunUnknownCommand(t *testing.T) {
func TestRunGenerateTomorrowWritesMarkdownReport(t *testing.T) {
fixture := newCLIFixture(t, writeFakeScriptorium)
outPath := fixture.path("tomorrow.md")
runner := Runner{Clock: fixedClock()}
runner := testRunner()
_, err := runTestCommand(t, runner,
"generate", "tomorrow",
@@ -95,7 +136,7 @@ func TestRunGenerateTomorrowWritesMarkdownReport(t *testing.T) {
func TestRunEveningGeneratesTomorrowReport(t *testing.T) {
fixture := newCLIFixture(t, writeFakeScriptorium)
runner := Runner{Clock: fixedClock()}
runner := testRunner()
_, err := runTestCommand(t, runner,
"run", "evening",
@@ -113,7 +154,7 @@ func TestRunEveningGeneratesTomorrowReport(t *testing.T) {
func TestRunMorningGeneratesTodayAndTomorrow(t *testing.T) {
fixture := newCLIFixture(t, writeFakeScriptorium)
runner := Runner{Clock: fixedClock()}
runner := testRunner()
_, err := runTestCommand(t, runner,
"run", "morning",
@@ -129,7 +170,7 @@ func TestRunMorningGeneratesTodayAndTomorrow(t *testing.T) {
func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) {
fixture := newCLIFixture(t, writeFailingScriptorium)
runner := Runner{Clock: fixedClock()}
runner := testRunner()
output, err := runTestCommand(t, runner,
"run", "morning",
@@ -322,7 +363,7 @@ func TestBatchStatusDoesNotRepeatBatchNotificationErrorPerReport(t *testing.T) {
func TestRunEveningUsesOutputDirectoryAndSummary(t *testing.T) {
fixture := newCLIFixture(t, writeFakeScriptorium)
outputDir := fixture.path("copies")
runner := Runner{Clock: fixedClock()}
runner := testRunner()
output, err := runTestCommand(t, runner,
"run", "evening",
@@ -349,7 +390,7 @@ func TestRunEveningUsesOutputDirectoryAndSummary(t *testing.T) {
func TestRunQuietSuppressesSuccessfulOutput(t *testing.T) {
fixture := newCLIFixture(t, writeFakeScriptorium)
runner := Runner{Clock: fixedClock()}
runner := testRunner()
output, err := runTestCommand(t, runner,
"run", "evening",
@@ -390,7 +431,7 @@ func TestRunEveningReportsOmitsPerReportNotification(t *testing.T) {
t.Setenv("CLI_DISTRIBUTOR_TOKEN", "cli-secret-token")
var stdout bytes.Buffer
var stderr bytes.Buffer
runner := Runner{Clock: fixedClock()}
runner := testRunner()
err := runner.Run(context.Background(), []string{
"run", "evening",
@@ -442,7 +483,7 @@ func TestRunEveningReportsDoesNotRequirePerReportDistributorToken(t *testing.T)
configPath := writeTestConfigWithDisabledBatchDistributor(t, server, scriptoriumPath, workspaceRoot, distributorServer.URL)
var stdout bytes.Buffer
var stderr bytes.Buffer
runner := Runner{Clock: fixedClock()}
runner := testRunner()
err := runner.Run(context.Background(), []string{
"run", "evening",
@@ -490,7 +531,7 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
outPath := fixture.path("daily.md")
var stdout bytes.Buffer
var stderr bytes.Buffer
runner := Runner{Clock: fixedClock()}
runner := testRunner()
err := runner.Run(context.Background(), []string{
"generate", "daily",
@@ -524,13 +565,13 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
!strings.Contains(string(data), "timezone: UTC") {
t.Fatalf("data package missing configured location with overridden timezone:\n%s", string(data))
}
preflightPath := oneArtifact(t, fixture.workspaceRoot, "preflight", "daily", "2026-05-29", "render.*.json")
preflight, err := os.ReadFile(preflightPath)
preparationPath := oneArtifact(t, fixture.workspaceRoot, "preflight", "daily", "2026-05-29", "prompt_preparation.*.json")
preparation, err := os.ReadFile(preparationPath)
if err != nil {
t.Fatalf("read preflight: %v", err)
t.Fatalf("read preparation: %v", err)
}
if !strings.Contains(string(preflight), `ok`) {
t.Fatalf("preflight missing fake render output:\n%s", string(preflight))
if !strings.Contains(string(preparation), `"status": "succeeded"`) {
t.Fatalf("preparation missing successful status:\n%s", string(preparation))
}
_ = oneArtifact(t, fixture.workspaceRoot, "reports", "daily", "2026-05-29", "report.*.md")
rawGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "daily", "2026-05-29", "generated_text_raw.*.json")
@@ -548,7 +589,7 @@ func TestRunGenerateTodayWritesGeneratedTextReport(t *testing.T) {
outPath := fixture.path("today.md")
var stdout bytes.Buffer
var stderr bytes.Buffer
runner := Runner{Clock: fixedClock()}
runner := testRunner()
err := runner.Run(context.Background(), []string{
"generate", "today",
@@ -612,7 +653,7 @@ func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) {
outPath := fixture.path("hourly.md")
var stdout bytes.Buffer
var stderr bytes.Buffer
runner := Runner{Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 29, 11, 0, 0, 0, time.UTC)}}
runner := testRunnerWithClock(timeutil.FixedClock{Time: time.Date(2026, 5, 29, 11, 0, 0, 0, time.UTC)})
err := runner.Run(context.Background(), []string{
"generate", "hourly",
@@ -650,7 +691,7 @@ func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) {
validatedGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "hourly", "2026-05-29", "generated_text.*.json")
renderContextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "hourly", "2026-05-29", "render_context.*.json")
managedReportPath := oneArtifact(t, fixture.workspaceRoot, "reports", "hourly", "2026-05-29", "report.*.md")
assertFileContains(t, rawGeneratedTextPath, `"summary": " Storm chances increase through late morning. "`)
assertFileContains(t, rawGeneratedTextPath, `"summary":"Storm chances increase through late morning."`)
assertFileContains(t, validatedGeneratedTextPath, `"summary":"Storm chances increase through late morning."`)
assertFileContains(t, renderContextPath, `"Report": {`)
assertFileContains(t, renderContextPath, `"Title": "Hourly Report"`)
@@ -663,7 +704,7 @@ func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) {
func TestRunGenerateQuietSuppressesSuccessfulOutput(t *testing.T) {
fixture := newCLIFixture(t, writeStructuredOutputScriptorium)
outPath := fixture.path("today.md")
runner := Runner{Clock: fixedClock()}
runner := testRunner()
output, err := runTestCommand(t, runner,
"generate", "today",
@@ -684,7 +725,7 @@ func TestRunGenerateQuietSuppressesSuccessfulOutput(t *testing.T) {
func TestRunGeneratePreRunErrorEmitsNoJSON(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
runner := Runner{Clock: fixedClock()}
runner := testRunner()
err := runner.Run(context.Background(), []string{"generate", "daily"}, &stdout, &stderr)
if err == nil {
@@ -706,7 +747,7 @@ func TestRunGenerateNotificationFailureEmitsFailureSummary(t *testing.T) {
workspaceRoot := filepath.Join(tempDir, "workspace")
configPath := writeTestConfigWithDistributor(t, server, scriptoriumPath, workspaceRoot, distributorServer.URL)
t.Setenv("CLI_DISTRIBUTOR_TOKEN", "")
runner := Runner{Clock: fixedClock()}
runner := testRunner()
output, err := runTestCommand(t, runner,
"generate", "daily",
@@ -733,7 +774,7 @@ func TestRunGenerateNotificationFailureEmitsFailureSummary(t *testing.T) {
func TestRunInspectTodayArtifacts(t *testing.T) {
fixture := newCLIFixture(t, writeFakeScriptorium)
runner := Runner{Clock: fixedClock()}
runner := testRunner()
var stdout bytes.Buffer
var stderr bytes.Buffer
@@ -778,7 +819,7 @@ func TestRunInspectTodayArtifacts(t *testing.T) {
func TestRunInspectGeneratedArtifacts(t *testing.T) {
fixture := newCLIFixture(t, writeFakeScriptorium)
runner := Runner{Clock: fixedClock()}
runner := testRunner()
var stdout bytes.Buffer
var stderr bytes.Buffer
@@ -826,7 +867,7 @@ func TestRunInspectMissingMetadata(t *testing.T) {
configPath := writeWorkspaceConfig(t, filepath.Join(tempDir, "workspace"))
var stdout bytes.Buffer
var stderr bytes.Buffer
runner := Runner{Clock: fixedClock()}
runner := testRunner()
err := runner.Run(context.Background(), []string{"inspect", "metadata", "--config", configPath, "missing"}, &stdout, &stderr)
if err == nil {
@@ -842,7 +883,7 @@ func TestRunInspectRejectsQuiet(t *testing.T) {
configPath := writeWorkspaceConfig(t, filepath.Join(tempDir, "workspace"))
var stdout bytes.Buffer
var stderr bytes.Buffer
runner := Runner{Clock: fixedClock()}
runner := testRunner()
err := runner.Run(context.Background(), []string{"inspect", "reports", "--config", configPath, "--quiet"}, &stdout, &stderr)
if err == nil {
@@ -856,7 +897,7 @@ func TestRunInspectRejectsQuiet(t *testing.T) {
func TestRunInspectRunCommandsParseRunIDAndConfig(t *testing.T) {
tempDir := t.TempDir()
configPath := writeWorkspaceConfig(t, filepath.Join(tempDir, "workspace"))
runner := Runner{Clock: fixedClock()}
runner := testRunner()
commands := []string{"metadata", "modules", "data-package", "prior", "sources"}
for _, command := range commands {
@@ -889,7 +930,7 @@ func TestRunInspectRunCommandsParseRunIDAndConfig(t *testing.T) {
}
func TestResolveGenerateCommands(t *testing.T) {
runner := Runner{Clock: fixedClock()}
runner := testRunner()
tests := []struct {
name string
args []string
@@ -915,7 +956,7 @@ func TestResolveGenerateCommands(t *testing.T) {
}
func TestResolveGenerateSupportsEveryReportCommandName(t *testing.T) {
runner := Runner{Clock: fixedClock()}
runner := testRunner()
for _, name := range report.CommandNames() {
t.Run(name, func(t *testing.T) {
args := []string{name}
@@ -942,7 +983,7 @@ func TestResolveGenerateSupportsEveryReportCommandName(t *testing.T) {
}
func TestResolveGenerateHourlyAppliesSharedFlags(t *testing.T) {
runner := Runner{Clock: fixedClock()}
runner := testRunner()
configPath := writeConfigFile(t, "weather_api:\n units: metric\n timezone: UTC\n")
req, err := runner.resolveGenerate([]string{"hourly", "--config", configPath, "--units", "us", "--tz", "America/Chicago", "--out", "./hourly.md"})
@@ -968,7 +1009,7 @@ func TestResolveGenerateHourlyAppliesSharedFlags(t *testing.T) {
}
func TestResolveGenerateHourlyRejectsDateAndStormBounds(t *testing.T) {
runner := Runner{Clock: fixedClock()}
runner := testRunner()
for _, args := range [][]string{
{"hourly", "--date", "2026-05-29"},
@@ -988,7 +1029,7 @@ func TestResolveGenerateHourlyRejectsDateAndStormBounds(t *testing.T) {
}
func TestResolveGenerateDailyRequiresDate(t *testing.T) {
runner := Runner{Clock: fixedClock()}
runner := testRunner()
req, err := runner.resolveGenerate([]string{"daily"})
if err == nil {
@@ -1003,7 +1044,7 @@ func TestResolveGenerateDailyRequiresDate(t *testing.T) {
}
func TestResolveGenerateDailyRejectsMalformedDate(t *testing.T) {
runner := Runner{Clock: fixedClock()}
runner := testRunner()
_, err := runner.resolveGenerate([]string{"daily", "--date", "bad-date"})
if err == nil {
@@ -1015,7 +1056,7 @@ func TestResolveGenerateDailyRejectsMalformedDate(t *testing.T) {
}
func TestResolveGenerateDailyParsesDate(t *testing.T) {
runner := Runner{Clock: fixedClock()}
runner := testRunner()
req, err := runner.resolveGenerate([]string{"daily", "--date", "2026-05-29"})
if err != nil {
@@ -1031,7 +1072,7 @@ func TestResolveGenerateDailyParsesDate(t *testing.T) {
}
func TestResolveGenerateTodayDate(t *testing.T) {
runner := Runner{Clock: fixedClock()}
runner := testRunner()
defaultReq, err := runner.resolveGenerate([]string{"today"})
if err != nil {
@@ -1064,7 +1105,7 @@ func TestResolveGenerateTodayDate(t *testing.T) {
}
func TestResolveGenerateAppliesSharedFlags(t *testing.T) {
runner := Runner{Clock: fixedClock()}
runner := testRunner()
req, err := runner.resolveGenerate([]string{"daily", "--date", "2026-05-29", "--units", "metric", "--tz", "UTC", "--out", "./daily.md"})
if err != nil {
@@ -1083,7 +1124,7 @@ func TestResolveGenerateAppliesSharedFlags(t *testing.T) {
}
func TestResolveGenerateRejectsRetiredHourlyCommand(t *testing.T) {
runner := Runner{Clock: fixedClock()}
runner := testRunner()
retired := strings.Join([]string{"near", "term"}, "-")
_, err := runner.resolveGenerate([]string{retired})
@@ -1096,7 +1137,7 @@ func TestResolveGenerateRejectsRetiredHourlyCommand(t *testing.T) {
}
func TestResolveGenerateRejectsRetiredReports(t *testing.T) {
runner := Runner{Clock: fixedClock()}
runner := testRunner()
for _, name := range []string{"three-day", "weekend", "storm"} {
if _, err := runner.resolveGenerate([]string{name}); err == nil {
t.Fatalf("resolveGenerate(%q) error = nil, want unknown report", name)