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

@@ -19,6 +19,7 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
@@ -50,6 +51,8 @@ type GenerateRequest struct {
Date time.Time
Collector Collector
Notifier Notifier
Executor promptexec.Executor
Store state.Store
}
type BatchRequest struct {
@@ -90,28 +93,25 @@ type ReportRequest struct {
}
type ReportResult struct {
ModuleSnapshot module.Snapshot
ModuleSnapshotPath string
DataPackage promptinput.Package
DataPackagePath string
PreparationPath string
ExecutionPath string
LLMDebugPath string
PreflightPath string
ReportPath string
OutputPath string
NotificationPath string
Metadata state.Metadata
MetadataPath string
PriorSnapshot *state.PriorSnapshot
RecentChanges []changes.Change
RenderResult *scriptorium.RenderResult
StructuredRunResult *scriptorium.StructuredRunResult
GeneratedTextRawPath string
GeneratedTextResultPath string
GeneratedTextPath string
RenderContextPath string
Notification *NotificationResult
ModuleSnapshot module.Snapshot
ModuleSnapshotPath string
DataPackage promptinput.Package
DataPackagePath string
PreparationPath string
ExecutionPath string
LLMDebugPath string
PreflightPath string
ReportPath string
OutputPath string
NotificationPath string
Metadata state.Metadata
MetadataPath string
PriorSnapshot *state.PriorSnapshot
RecentChanges []changes.Change
GeneratedTextRawPath string
GeneratedTextPath string
RenderContextPath string
Notification *NotificationResult
}
type BatchResult struct {
@@ -275,20 +275,27 @@ func GenerateDetailed(ctx context.Context, req GenerateRequest) (*ReportResult,
if now.IsZero() {
now = time.Now()
}
collection, err := collectWeather(ctx, req.Config, req.Collector)
if err != nil {
return nil, err
}
resolved, err := ResolveGenerate(req, now)
if err != nil {
return nil, err
}
return GenerateReport(ctx, ReportRequest{
Config: req.Config,
Resolved: resolved,
OutputPath: req.OutputPath,
Collection: *collection,
Notifier: req.Notifier,
inspection, err := InspectPromptExecution(ctx, PromptInspectionRequest{
Resolved: resolved,
Executor: req.Executor,
Promptkit: req.Config.Promptkit,
})
if err != nil {
return nil, err
}
collection, err := collectWeather(ctx, req.Config, req.Collector)
if err != nil {
return nil, err
}
return generatePromptReport(ctx, promptReportRequest{
GenerateRequest: req,
Resolved: resolved,
Collection: *collection,
Inspection: inspection,
})
}
@@ -340,7 +347,7 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
item.MetadataPath = paths.Metadata
}
outputPath := plannedBatchOutputPath(req.OutputDir, planned)
reportResult, err := GenerateReport(ctx, ReportRequest{
reportResult, err := generateLegacyBatchReport(ctx, ReportRequest{
Config: req.Config,
Resolved: resolved,
OutputPath: outputPath,
@@ -494,7 +501,9 @@ func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*weatherda
return bundle, nil
}
func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, error) {
// generateLegacyBatchReport is the temporary Scriptorium implementation used
// only by batch commands while their Promptkit migration is deferred.
func generateLegacyBatchReport(ctx context.Context, req ReportRequest) (*ReportResult, error) {
bundle := req.Collection.Bundle
if bundle == nil {
return nil, fmt.Errorf("collected weather bundle is required")
@@ -724,40 +733,34 @@ func generateTextTemplateReport(ctx context.Context, req generatedReportRequest)
return nil, err
}
return renderedReportResult(reportResultRequest{
moduleSnapshot: req.moduleSnapshot,
moduleSnapshotPath: req.moduleSnapshotPath,
dataPackage: req.dataPackage,
dataPackagePath: req.dataPackagePath,
preflightPath: req.preflightPath,
reportPath: reportPath,
finalized: finalized,
priorSnapshot: req.priorSnapshot,
recentChanges: req.recentChanges,
renderResult: req.renderResult,
structuredRunResult: structuredResult,
generatedTextRawPath: req.paths.GeneratedTextRaw,
generatedTextResultPath: generatedTextResultPath,
generatedTextPath: generatedTextPath,
renderContextPath: renderContextPath,
moduleSnapshot: req.moduleSnapshot,
moduleSnapshotPath: req.moduleSnapshotPath,
dataPackage: req.dataPackage,
dataPackagePath: req.dataPackagePath,
preflightPath: req.preflightPath,
reportPath: reportPath,
finalized: finalized,
priorSnapshot: req.priorSnapshot,
recentChanges: req.recentChanges,
generatedTextRawPath: req.paths.GeneratedTextRaw,
generatedTextPath: generatedTextPath,
renderContextPath: renderContextPath,
}), err
}
return renderedReportResult(reportResultRequest{
moduleSnapshot: req.moduleSnapshot,
moduleSnapshotPath: req.moduleSnapshotPath,
dataPackage: req.dataPackage,
dataPackagePath: req.dataPackagePath,
preflightPath: req.preflightPath,
reportPath: reportPath,
finalized: finalized,
priorSnapshot: req.priorSnapshot,
recentChanges: req.recentChanges,
renderResult: req.renderResult,
structuredRunResult: structuredResult,
generatedTextRawPath: req.paths.GeneratedTextRaw,
generatedTextResultPath: generatedTextResultPath,
generatedTextPath: generatedTextPath,
renderContextPath: renderContextPath,
moduleSnapshot: req.moduleSnapshot,
moduleSnapshotPath: req.moduleSnapshotPath,
dataPackage: req.dataPackage,
dataPackagePath: req.dataPackagePath,
preflightPath: req.preflightPath,
reportPath: reportPath,
finalized: finalized,
priorSnapshot: req.priorSnapshot,
recentChanges: req.recentChanges,
generatedTextRawPath: req.paths.GeneratedTextRaw,
generatedTextPath: generatedTextPath,
renderContextPath: renderContextPath,
}), nil
}
@@ -770,46 +773,40 @@ func finalizeResultEmpty(result finalizeRenderedReportResult) bool {
}
type reportResultRequest struct {
moduleSnapshot module.Snapshot
moduleSnapshotPath string
dataPackage promptinput.Package
dataPackagePath string
preflightPath string
reportPath string
finalized finalizeRenderedReportResult
priorSnapshot *state.PriorSnapshot
recentChanges []changes.Change
renderResult *scriptorium.RenderResult
structuredRunResult *scriptorium.StructuredRunResult
generatedTextRawPath string
generatedTextResultPath string
generatedTextPath string
renderContextPath string
moduleSnapshot module.Snapshot
moduleSnapshotPath string
dataPackage promptinput.Package
dataPackagePath string
preflightPath string
reportPath string
finalized finalizeRenderedReportResult
priorSnapshot *state.PriorSnapshot
recentChanges []changes.Change
generatedTextRawPath string
generatedTextPath string
renderContextPath string
}
func renderedReportResult(req reportResultRequest) *ReportResult {
return &ReportResult{
ModuleSnapshot: req.moduleSnapshot,
ModuleSnapshotPath: req.moduleSnapshotPath,
DataPackage: req.dataPackage,
DataPackagePath: req.dataPackagePath,
PreparationPath: req.preflightPath,
ExecutionPath: req.generatedTextResultPath,
PreflightPath: req.preflightPath,
ReportPath: req.reportPath,
OutputPath: req.finalized.OutputPath,
NotificationPath: req.finalized.NotificationPath,
Metadata: req.finalized.Metadata,
MetadataPath: req.finalized.MetadataPath,
PriorSnapshot: req.priorSnapshot,
RecentChanges: req.recentChanges,
RenderResult: req.renderResult,
StructuredRunResult: req.structuredRunResult,
GeneratedTextRawPath: req.generatedTextRawPath,
GeneratedTextResultPath: req.generatedTextResultPath,
GeneratedTextPath: req.generatedTextPath,
RenderContextPath: req.renderContextPath,
Notification: req.finalized.Notification,
ModuleSnapshot: req.moduleSnapshot,
ModuleSnapshotPath: req.moduleSnapshotPath,
DataPackage: req.dataPackage,
DataPackagePath: req.dataPackagePath,
PreparationPath: req.preflightPath,
ExecutionPath: req.finalized.Metadata.GeneratedTextResultPath,
PreflightPath: req.preflightPath,
ReportPath: req.reportPath,
OutputPath: req.finalized.OutputPath,
NotificationPath: req.finalized.NotificationPath,
Metadata: req.finalized.Metadata,
MetadataPath: req.finalized.MetadataPath,
PriorSnapshot: req.priorSnapshot,
RecentChanges: req.recentChanges,
GeneratedTextRawPath: req.generatedTextRawPath,
GeneratedTextPath: req.generatedTextPath,
RenderContextPath: req.renderContextPath,
Notification: req.finalized.Notification,
}
}

View File

@@ -18,6 +18,7 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
@@ -72,6 +73,56 @@ func TestFetchAndSaveBundle(t *testing.T) {
}
}
type promptExecutorTest struct {
err error
afterPreparationErr error
validation promptexec.ValidationStatus
}
func (e promptExecutorTest) 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 (e promptExecutorTest) InspectProfile(_ context.Context, id string) (promptexec.ProfileInspection, error) {
return promptexec.ProfileInspection{ProfileID: id, BackendID: "test", ModelName: "test-model"}, nil
}
func (e promptExecutorTest) Execute(_ context.Context, request promptexec.ExecuteRequest, callback promptexec.PreparationCallback) (*promptexec.Execution, error) {
if e.err != nil {
return nil, e.err
}
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
}
if e.afterPreparationErr != nil {
return nil, e.afterPreparationErr
}
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.","confidence":"Medium"}`)
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.","confidence":"Medium"}`)
}
validation := e.validation
if validation == "" {
validation = promptexec.ValidationPassed
}
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(validation, "json_schema", "generated_text.schema.json", nil),
}, nil
}
func TestFetchAndSaveBundleRequiresOutputPath(t *testing.T) {
_, err := FetchAndSaveBundle(context.Background(), FetchBundleRequest{Config: config.Defaults()})
if err == nil {
@@ -94,6 +145,7 @@ func TestGenerateUsesProvidedCollector(t *testing.T) {
Date: mustParse("2026-05-29T12:00:00-05:00"),
Now: mustParse("2026-05-29T05:00:00-05:00"),
Collector: collector,
Executor: promptExecutorTest{},
})
if err == nil {
t.Fatal("Generate() error = nil, want collector error")
@@ -114,20 +166,13 @@ func TestGenerateCollectsOnceForSingleReport(t *testing.T) {
cfg := dailyWorkspaceConfig(t, server)
collection := collectionForTest(t, cfg)
collector := &recordingCollector{result: &collection}
markerPath := filepath.Join(t.TempDir(), "scriptorium-called")
binaryPath := filepath.Join(t.TempDir(), "scriptorium")
script := fmt.Sprintf("#!/bin/sh\nprintf called > %q\nprintf render failed >&2\nexit 1\n", markerPath)
if err := os.WriteFile(binaryPath, []byte(script), 0o755); err != nil {
t.Fatalf("write scriptorium marker script: %v", err)
}
cfg.Scriptorium.Binary = binaryPath
err := Generate(context.Background(), GenerateRequest{
Config: cfg,
Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"),
Now: mustParse("2026-05-29T05:00:00-05:00"),
Collector: collector,
Executor: promptExecutorTest{err: errors.New("prompt execution failed")},
})
if err == nil {
t.Fatal("Generate() error = nil, want render error")
@@ -135,22 +180,12 @@ func TestGenerateCollectsOnceForSingleReport(t *testing.T) {
if len(collector.requests) != 1 {
t.Fatalf("collector requests = %d, want 1", len(collector.requests))
}
if _, statErr := os.Stat(markerPath); statErr != nil {
t.Fatalf("scriptorium marker stat error = %v, want report execution reached renderer", statErr)
}
}
func TestGenerateCollectionFailureStopsBeforeReportExecution(t *testing.T) {
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = ""
cfg.Workspace.Root = t.TempDir()
markerPath := filepath.Join(t.TempDir(), "scriptorium-called")
binaryPath := filepath.Join(t.TempDir(), "scriptorium")
script := fmt.Sprintf("#!/bin/sh\nprintf called > %q\nexit 0\n", markerPath)
if err := os.WriteFile(binaryPath, []byte(script), 0o755); err != nil {
t.Fatalf("write scriptorium marker script: %v", err)
}
cfg.Scriptorium.Binary = binaryPath
collector := &recordingCollector{err: errors.New("collection unavailable")}
err := Generate(context.Background(), GenerateRequest{
@@ -159,13 +194,11 @@ func TestGenerateCollectionFailureStopsBeforeReportExecution(t *testing.T) {
Date: mustParse("2026-05-29T12:00:00-05:00"),
Now: mustParse("2026-05-29T05:00:00-05:00"),
Collector: collector,
Executor: promptExecutorTest{},
})
if err == nil {
t.Fatal("Generate() error = nil, want collector error")
}
if _, statErr := os.Stat(markerPath); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("scriptorium marker stat error = %v, want marker absent after collection failure", statErr)
}
}
func TestGenerateDetailedReturnsReportResult(t *testing.T) {
@@ -183,6 +216,7 @@ func TestGenerateDetailedReturnsReportResult(t *testing.T) {
Date: mustParse("2026-05-29T12:00:00-05:00"),
Now: mustParse("2026-05-29T05:00:00-05:00"),
Collector: collector,
Executor: promptExecutorTest{},
})
if err != nil {
t.Fatalf("GenerateDetailed() error = %v", err)
@@ -196,7 +230,7 @@ func TestGenerateDetailedReturnsReportResult(t *testing.T) {
if result.OutputPath != outputPath {
t.Fatalf("OutputPath = %q, want requested output copy %q", result.OutputPath, outputPath)
}
assertPathsExist(t, result.ModuleSnapshotPath, result.DataPackagePath, result.PreflightPath, result.ReportPath, result.MetadataPath, outputPath)
assertPathsExist(t, result.ModuleSnapshotPath, result.DataPackagePath, result.PreparationPath, result.ExecutionPath, result.ReportPath, result.MetadataPath, outputPath)
if len(collector.requests) != 1 {
t.Fatalf("collector requests = %d, want one collection", len(collector.requests))
}
@@ -214,6 +248,7 @@ func TestGenerateReturnsUnderlyingErrorOnly(t *testing.T) {
Date: mustParse("2026-05-29T12:00:00-05:00"),
Now: mustParse("2026-05-29T05:00:00-05:00"),
Collector: &recordingCollector{err: wantErr},
Executor: promptExecutorTest{},
})
if !errors.Is(err, wantErr) {
t.Fatalf("Generate() error = %v, want underlying collector error", err)
@@ -235,6 +270,7 @@ func TestGenerateDetailedNotificationFailureReturnsInspectableResult(t *testing.
Now: mustParse("2026-05-29T08:30:00-05:00"),
Collector: &recordingCollector{result: &collection},
Notifier: notifier,
Executor: promptExecutorTest{},
})
if err == nil {
t.Fatal("GenerateDetailed() error = nil, want notification error")
@@ -252,7 +288,7 @@ func TestGenerateDetailedNotificationFailureReturnsInspectableResult(t *testing.
if result.NotificationPath == "" || result.ReportPath == "" || result.MetadataPath == "" {
t.Fatalf("result paths = report %q metadata %q notification %q, want inspectable artifact paths", result.ReportPath, result.MetadataPath, result.NotificationPath)
}
assertPathsExist(t, result.ReportPath, outputPath, result.MetadataPath, result.NotificationPath, result.GeneratedTextRawPath, result.GeneratedTextResultPath, result.GeneratedTextPath, result.RenderContextPath)
assertPathsExist(t, result.ReportPath, outputPath, result.MetadataPath, result.NotificationPath, result.GeneratedTextRawPath, result.GeneratedTextPath, result.RenderContextPath)
if result.Notification != nil {
t.Fatalf("Notification = %#v, want nil notification result when notifier returned only an error", result.Notification)
}
@@ -261,6 +297,87 @@ func TestGenerateDetailedNotificationFailureReturnsInspectableResult(t *testing.
}
}
func TestGenerateDetailedInspectsBeforeCollectionOrArtifactWrites(t *testing.T) {
cfg := config.Defaults()
cfg.Workspace.Root = t.TempDir()
collector := &recordingCollector{err: errors.New("collector must not run")}
_, err := GenerateDetailed(context.Background(), GenerateRequest{
Config: cfg, Report: ReportDaily, Date: mustParse("2026-05-29T12:00:00-05:00"),
Now: mustParse("2026-05-29T05:00:00-05:00"), Collector: collector, Executor: failingPromptInspectionExecutor{},
})
if err == nil || promptexec.CategoryOf(err) != promptexec.InvalidConfiguration {
t.Fatalf("GenerateDetailed() error/category = %v/%q, want invalid configuration", err, promptexec.CategoryOf(err))
}
if len(collector.requests) != 0 {
t.Fatalf("collector requests = %d, want inspection failure before collection", len(collector.requests))
}
entries, readErr := os.ReadDir(cfg.Workspace.Root)
if readErr != nil || len(entries) != 0 {
t.Fatalf("workspace entries = %#v, err %v, want none", entries, readErr)
}
}
func TestGenerateDetailedPersistsPromptFailureReceiptsWithoutRawOutput(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyWorkspaceConfig(t, server)
collection := collectionForTest(t, cfg)
result, err := GenerateDetailed(context.Background(), GenerateRequest{
Config: cfg, Report: ReportDaily, Date: mustParse("2026-05-29T12:00:00-05:00"),
Now: mustParse("2026-05-29T05:00:00-05:00"), Collector: &recordingCollector{result: &collection},
Executor: promptExecutorTest{afterPreparationErr: promptexec.NewError(promptexec.Generation, "provider unavailable", nil)},
})
if err == nil || result == nil {
t.Fatalf("GenerateDetailed() result/error = %#v/%v, want partial result and error", result, err)
}
if result.PreparationPath == "" || result.ExecutionPath == "" || result.MetadataPath == "" || result.GeneratedTextRawPath != "" {
t.Fatalf("result paths = %#v, want preparation/execution/metadata without raw output", result)
}
if result.Metadata.SchemaVersion != state.MetadataSchemaVersion || result.Metadata.PreparationPath != result.PreparationPath || result.Metadata.ExecutionPath != result.ExecutionPath {
t.Fatalf("metadata = %#v, want linked V2 prompt artifacts", result.Metadata)
}
store := recordingFilesystemStore(t, cfg)
execution, loadErr := store.LoadPromptExecution(context.Background(), result.ExecutionPath)
if loadErr != nil || execution.Status != state.PromptExecutionFailed || execution.Paths.RawOutputPath != "" {
t.Fatalf("execution/load error = %#v/%v, want failed receipt without raw path", execution, loadErr)
}
}
func TestGenerateDetailedPersistsRawOutputForValidationRejection(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyWorkspaceConfig(t, server)
collection := collectionForTest(t, cfg)
result, err := GenerateDetailed(context.Background(), GenerateRequest{
Config: cfg, Report: ReportDaily, Date: mustParse("2026-05-29T12:00:00-05:00"),
Now: mustParse("2026-05-29T05:00:00-05:00"), Collector: &recordingCollector{result: &collection},
Executor: promptExecutorTest{validation: promptexec.ValidationFailed},
})
if err == nil || result == nil || promptexec.CategoryOf(err) != promptexec.ValidationRejected {
t.Fatalf("GenerateDetailed() result/error/category = %#v/%v/%q, want validation rejection", result, err, promptexec.CategoryOf(err))
}
if result.GeneratedTextRawPath == "" || result.ExecutionPath == "" || result.GeneratedTextPath != "" {
t.Fatalf("result paths = %#v, want raw and execution paths only", result)
}
store := recordingFilesystemStore(t, cfg)
execution, loadErr := store.LoadPromptExecution(context.Background(), result.ExecutionPath)
if loadErr != nil || execution.Status != state.PromptExecutionValidationRejected || execution.Paths.RawOutputPath != result.GeneratedTextRawPath {
t.Fatalf("execution/load error = %#v/%v, want rejected execution with raw path", execution, loadErr)
}
}
type failingPromptInspectionExecutor struct{}
func (failingPromptInspectionExecutor) InspectPrompt(context.Context, string, string) (promptexec.PromptInspection, error) {
return promptexec.PromptInspection{}, errors.New("unavailable")
}
func (failingPromptInspectionExecutor) InspectProfile(context.Context, string) (promptexec.ProfileInspection, error) {
return promptexec.ProfileInspection{}, errors.New("unexpected")
}
func (failingPromptInspectionExecutor) Execute(context.Context, promptexec.ExecuteRequest, promptexec.PreparationCallback) (*promptexec.Execution, error) {
return nil, errors.New("unexpected")
}
func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyWorkspaceConfig(t, server)
@@ -283,7 +400,7 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
outputPath := filepath.Join(t.TempDir(), "daily.md")
store := recordingFilesystemStore(t, cfg)
result, err := GenerateReport(context.Background(), ReportRequest{
result, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
@@ -292,7 +409,7 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
Store: store,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
t.Fatalf("generateLegacyBatchReport() error = %v", err)
}
if renderer.renderCalls != 1 {
@@ -316,7 +433,7 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
if got, want := strings.Join(store.calls, ","), "module_snapshot,data_package,preflight,metadata,generated_text_result,metadata,generated_text,metadata,render_context,metadata,prepare_report,metadata"; !strings.HasPrefix(got, want) {
t.Fatalf("store calls = %v, want prefix %s", store.calls, want)
}
assertPathsExist(t, result.ModuleSnapshotPath, result.DataPackagePath, result.PreflightPath, result.GeneratedTextRawPath, result.GeneratedTextResultPath, result.GeneratedTextPath, result.RenderContextPath, result.ReportPath, result.MetadataPath, outputPath)
assertPathsExist(t, result.ModuleSnapshotPath, result.DataPackagePath, result.PreflightPath, result.GeneratedTextRawPath, result.Metadata.GeneratedTextResultPath, result.GeneratedTextPath, result.RenderContextPath, result.ReportPath, result.MetadataPath, outputPath)
snapshotData, err := os.ReadFile(result.ModuleSnapshotPath)
if err != nil {
t.Fatalf("read module snapshot: %v", err)
@@ -520,14 +637,14 @@ func TestGeneratedTemplateReportsUseRichArtifactsAndCuratedDataPackages(t *testi
}, tt.now.Format(time.RFC3339))
renderer := successfulGeneratedTextRenderer("")
result, err := GenerateReport(context.Background(), ReportRequest{
result, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
Renderer: renderer,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
t.Fatalf("generateLegacyBatchReport() error = %v", err)
}
if renderer.renderCalls != 1 || renderer.structuredRunCalls != 1 {
t.Fatalf("renderer calls render=%d structured=%d, want generated-template workflow", renderer.renderCalls, renderer.structuredRunCalls)
@@ -667,7 +784,7 @@ func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow(t *testing.T) {
}`,
}
result, err := GenerateReport(context.Background(), ReportRequest{
result, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
@@ -675,7 +792,7 @@ func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow(t *testing.T) {
Store: store,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
t.Fatalf("generateLegacyBatchReport() error = %v", err)
}
if renderer.renderCalls != 1 {
@@ -699,7 +816,7 @@ func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow(t *testing.T) {
result.DataPackagePath,
result.PreflightPath,
result.GeneratedTextRawPath,
result.GeneratedTextResultPath,
result.Metadata.GeneratedTextResultPath,
result.GeneratedTextPath,
result.RenderContextPath,
result.ReportPath,
@@ -762,15 +879,12 @@ func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow(t *testing.T) {
if result.OutputPath != result.ReportPath {
t.Fatalf("OutputPath = %q, want managed report path %q", result.OutputPath, result.ReportPath)
}
if result.StructuredRunResult == nil || result.StructuredRunResult.OutputPath != result.GeneratedTextRawPath {
t.Fatalf("StructuredRunResult = %#v, want captured structured run result", result.StructuredRunResult)
}
if len(result.RecentChanges) != 0 {
t.Fatalf("RecentChanges = %#v, want none for hourly report", result.RecentChanges)
}
if result.Metadata.GeneratedTextSchemaID != "hourly" ||
result.Metadata.GeneratedTextRawPath != result.GeneratedTextRawPath ||
result.Metadata.GeneratedTextResultPath != result.GeneratedTextResultPath ||
result.Metadata.GeneratedTextResultPath == "" ||
result.Metadata.GeneratedTextPath != result.GeneratedTextPath ||
result.Metadata.RenderContextPath != result.RenderContextPath ||
result.Metadata.RenderedReportPath != result.ReportPath {
@@ -792,7 +906,7 @@ func TestGenerateHourlyReportCopiesOutputAndNotifiesManagedReport(t *testing.T)
notifier.result = successfulNotificationResult()
renderer := successfulGeneratedTextRenderer(validHourlyGeneratedTextJSON())
result, err := GenerateReport(context.Background(), ReportRequest{
result, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
@@ -802,7 +916,7 @@ func TestGenerateHourlyReportCopiesOutputAndNotifiesManagedReport(t *testing.T)
Notifier: notifier,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
t.Fatalf("generateLegacyBatchReport() error = %v", err)
}
if result.OutputPath != outputPath {
t.Fatalf("OutputPath = %q, want requested copy %q", result.OutputPath, outputPath)
@@ -878,7 +992,7 @@ func TestGenerateTodayReportCopiesOutputAndNotifiesTodayTemplateValues(t *testin
}
renderer := successfulGeneratedTextRenderer(validTodayGeneratedTextJSON())
result, err := GenerateReport(context.Background(), ReportRequest{
result, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
@@ -887,7 +1001,7 @@ func TestGenerateTodayReportCopiesOutputAndNotifiesTodayTemplateValues(t *testin
Notifier: notifier,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
t.Fatalf("generateLegacyBatchReport() error = %v", err)
}
if result.OutputPath != outputPath {
t.Fatalf("OutputPath = %q, want requested copy %q", result.OutputPath, outputPath)
@@ -959,7 +1073,7 @@ func TestGenerateHourlyReportNotificationFailureFailsReport(t *testing.T) {
structuredRunBody: validHourlyGeneratedTextJSON(),
}
_, err := GenerateReport(context.Background(), ReportRequest{
_, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
@@ -969,11 +1083,11 @@ func TestGenerateHourlyReportNotificationFailureFailsReport(t *testing.T) {
Notifier: notifier,
})
if err == nil {
t.Fatal("GenerateReport() error = nil, want notification error")
t.Fatal("generateLegacyBatchReport() error = nil, want notification error")
}
var notificationErr *NotificationError
if !errors.As(err, &notificationErr) {
t.Fatalf("GenerateReport() error = %T %v, want NotificationError", err, err)
t.Fatalf("generateLegacyBatchReport() error = %T %v, want NotificationError", err, err)
}
if !strings.Contains(err.Error(), `notify report "hourly"`) || !strings.Contains(err.Error(), "upload rejected") {
t.Fatalf("error = %q, want hourly notification context", err.Error())
@@ -1010,7 +1124,7 @@ func TestGenerateReportSavesFinalMetadata(t *testing.T) {
structuredRunBody: validHourlyGeneratedTextJSON(),
}
result, err := GenerateReport(context.Background(), ReportRequest{
result, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
@@ -1018,7 +1132,7 @@ func TestGenerateReportSavesFinalMetadata(t *testing.T) {
Store: store,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
t.Fatalf("generateLegacyBatchReport() error = %v", err)
}
saved := readMetadataForTest(t, result.MetadataPath)
@@ -1027,7 +1141,7 @@ func TestGenerateReportSavesFinalMetadata(t *testing.T) {
}
if saved.GeneratedTextSchemaID != "hourly" ||
saved.GeneratedTextRawPath != result.GeneratedTextRawPath ||
saved.GeneratedTextResultPath != result.GeneratedTextResultPath ||
saved.GeneratedTextResultPath != result.Metadata.GeneratedTextResultPath ||
saved.GeneratedTextPath != result.GeneratedTextPath ||
saved.RenderContextPath != result.RenderContextPath {
t.Fatalf("saved generated-text metadata = %#v, want generated-text artifact links", saved)
@@ -1047,7 +1161,7 @@ func TestGenerateTomorrowReportNotificationUsesTomorrowTemplateValues(t *testing
notifier := &recordingNotifier{}
renderer := successfulGeneratedTextRenderer(validTomorrowGeneratedTextJSON())
result, err := GenerateReport(context.Background(), ReportRequest{
result, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
@@ -1055,7 +1169,7 @@ func TestGenerateTomorrowReportNotificationUsesTomorrowTemplateValues(t *testing
Notifier: notifier,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
t.Fatalf("generateLegacyBatchReport() error = %v", err)
}
if len(notifier.requests) != 1 {
t.Fatalf("notification requests = %d, want 1", len(notifier.requests))
@@ -1094,7 +1208,7 @@ func TestGenerateHourlyReportPersistsPreflightFailure(t *testing.T) {
err: errors.New("scriptorium render exited with code 1: render failed"),
}
_, err := GenerateReport(context.Background(), ReportRequest{
_, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
@@ -1141,7 +1255,7 @@ func TestGenerateHourlyReportPersistsStructuredRunFailure(t *testing.T) {
structuredRunBody: validHourlyGeneratedTextJSON(),
}
_, err := GenerateReport(context.Background(), ReportRequest{
_, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
@@ -1176,7 +1290,7 @@ func TestGenerateHourlyReportPreservesRawTextOnValidationFailure(t *testing.T) {
"details": "not allowed"
}`)
_, err := GenerateReport(context.Background(), ReportRequest{
_, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
@@ -1205,7 +1319,7 @@ func TestGenerateHourlyReportRejectsUnsupportedTemplateBeforeStructuredRun(t *te
resolved.Definition.TemplateID = "missing-template"
renderer := successfulGeneratedTextRenderer(validHourlyGeneratedTextJSON())
_, err := GenerateReport(context.Background(), ReportRequest{
_, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
@@ -1241,7 +1355,7 @@ func TestGenerateReportDisabledNotificationDoesNotCallNotifier(t *testing.T) {
}, "2026-05-29T05:00:00-05:00")
notifier := &recordingNotifier{}
_, err := GenerateReport(context.Background(), ReportRequest{
_, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
@@ -1249,7 +1363,7 @@ func TestGenerateReportDisabledNotificationDoesNotCallNotifier(t *testing.T) {
Notifier: notifier,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
t.Fatalf("generateLegacyBatchReport() error = %v", err)
}
if len(notifier.requests) != 0 {
t.Fatalf("notification requests = %#v, want none when disabled", notifier.requests)
@@ -1268,7 +1382,7 @@ func TestGenerateReportNotifiesManagedReportPath(t *testing.T) {
}
outputPath := filepath.Join(t.TempDir(), "daily-copy.md")
result, err := GenerateReport(context.Background(), ReportRequest{
result, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
@@ -1277,7 +1391,7 @@ func TestGenerateReportNotifiesManagedReportPath(t *testing.T) {
Notifier: notifier,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
t.Fatalf("generateLegacyBatchReport() error = %v", err)
}
if result.Notification == nil {
t.Fatal("Notification = nil, want notification result")
@@ -1343,7 +1457,7 @@ func TestGenerateReportNotificationFailureFailsReport(t *testing.T) {
notifier := &recordingNotifier{err: errors.New("upload rejected")}
store := recordingFilesystemStore(t, cfg)
_, err := GenerateReport(context.Background(), ReportRequest{
_, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
@@ -1352,7 +1466,7 @@ func TestGenerateReportNotificationFailureFailsReport(t *testing.T) {
Notifier: notifier,
})
if err == nil {
t.Fatal("GenerateReport() error = nil, want notification error")
t.Fatal("generateLegacyBatchReport() error = nil, want notification error")
}
if !strings.Contains(err.Error(), "notify report") || !strings.Contains(err.Error(), "upload rejected") {
t.Fatalf("error = %q, want notification context", err.Error())
@@ -1409,7 +1523,7 @@ func TestGenerateReportDoesNotNotifyAfterRenderOrRunFailure(t *testing.T) {
}, "2026-05-29T05:00:00-05:00")
notifier := &recordingNotifier{}
_, err := GenerateReport(context.Background(), ReportRequest{
_, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
@@ -1417,7 +1531,7 @@ func TestGenerateReportDoesNotNotifyAfterRenderOrRunFailure(t *testing.T) {
Notifier: notifier,
})
if err == nil {
t.Fatal("GenerateReport() error = nil, want generation error")
t.Fatal("generateLegacyBatchReport() error = nil, want generation error")
}
if len(notifier.requests) != 0 {
t.Fatalf("notification requests = %#v, want none after generation failure", notifier.requests)
@@ -1440,7 +1554,7 @@ func TestGenerateReportRequiresCollectedBundleBeforeStateWrites(t *testing.T) {
notifier := &recordingNotifier{}
store := recordingFilesystemStore(t, cfg)
_, err := GenerateReport(context.Background(), ReportRequest{
_, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg,
Resolved: resolved,
Renderer: successfulRenderer("# Daily Report\n"),
@@ -1448,10 +1562,10 @@ func TestGenerateReportRequiresCollectedBundleBeforeStateWrites(t *testing.T) {
Notifier: notifier,
})
if err == nil {
t.Fatal("GenerateReport() error = nil, want collected bundle error")
t.Fatal("generateLegacyBatchReport() error = nil, want collected bundle error")
}
if !strings.Contains(err.Error(), "collected weather bundle is required") {
t.Fatalf("GenerateReport() error = %q, want collected bundle context", err.Error())
t.Fatalf("generateLegacyBatchReport() error = %q, want collected bundle context", err.Error())
}
if len(notifier.requests) != 0 {
t.Fatalf("notification requests = %#v, want none without collected data", notifier.requests)
@@ -1477,14 +1591,14 @@ func TestGenerateReportPersistsFailedPreflight(t *testing.T) {
err: errors.New("scriptorium render exited with code 1: render failed"),
}
_, err := GenerateReport(context.Background(), ReportRequest{
_, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
Renderer: renderer,
})
if err == nil {
t.Fatal("GenerateReport() error = nil, want render error")
t.Fatal("generateLegacyBatchReport() error = nil, want render error")
}
store := recordingFilesystemStore(t, cfg)
paths, err := store.Paths(resolved)
@@ -1523,14 +1637,14 @@ func TestGenerateReportReturnsRunErrorAfterPreflight(t *testing.T) {
structuredRunErr: errors.New("scriptorium run exited with code 2: validation failed"),
}
_, err := GenerateReport(context.Background(), ReportRequest{
_, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
Renderer: renderer,
})
if err == nil {
t.Fatal("GenerateReport() error = nil, want run error")
t.Fatal("generateLegacyBatchReport() error = nil, want run error")
}
if renderer.renderCalls != 1 || renderer.structuredRunCalls != 1 || renderer.runCalls != 0 {
t.Fatalf("calls render=%d structured=%d run=%d, want render and structured run only", renderer.renderCalls, renderer.structuredRunCalls, renderer.runCalls)
@@ -1567,7 +1681,7 @@ func TestGenerateReportIncludesRecentChangesFromPriorSnapshot(t *testing.T) {
}, "2026-05-29T05:00:00-05:00")
renderer := successfulRenderer("")
result, err := GenerateReport(context.Background(), ReportRequest{
result, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: currentResolved,
@@ -1575,7 +1689,7 @@ func TestGenerateReportIncludesRecentChangesFromPriorSnapshot(t *testing.T) {
Store: store,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
t.Fatalf("generateLegacyBatchReport() error = %v", err)
}
if len(result.RecentChanges) == 0 {
t.Fatal("RecentChanges length = 0, want changes from prior snapshot")
@@ -1605,7 +1719,7 @@ func TestGenerateTodayReportUsesTodayIdentityAndRecentChanges(t *testing.T) {
}, "2026-05-29T05:00:00-05:00")
renderer := successfulGeneratedTextRenderer(validTodayGeneratedTextJSON())
result, err := GenerateReport(context.Background(), ReportRequest{
result, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: currentResolved,
@@ -1613,7 +1727,7 @@ func TestGenerateTodayReportUsesTodayIdentityAndRecentChanges(t *testing.T) {
Store: store,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
t.Fatalf("generateLegacyBatchReport() error = %v", err)
}
if renderer.runCalls != 0 || renderer.structuredRunCalls != 1 {
t.Fatalf("renderer calls run=%d structured=%d, want generated-text flow", renderer.runCalls, renderer.structuredRunCalls)
@@ -1667,14 +1781,14 @@ func TestGenerateTomorrowReportUsesTomorrowBriefingDate(t *testing.T) {
}, "2026-05-29T18:00:00-05:00")
renderer := successfulGeneratedTextRenderer(validTomorrowGeneratedTextJSON())
result, err := GenerateReport(context.Background(), ReportRequest{
result, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
Renderer: renderer,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
t.Fatalf("generateLegacyBatchReport() error = %v", err)
}
if renderer.runCalls != 0 {
t.Fatalf("markdown run calls = %d, want none", renderer.runCalls)
@@ -1705,7 +1819,7 @@ func TestGenerateTomorrowReportUsesTomorrowBriefingDate(t *testing.T) {
if !strings.Contains(filepath.Base(result.ReportPath), "tomorrow") {
t.Fatalf("ReportPath = %q, want managed tomorrow report path", result.ReportPath)
}
assertPathsExist(t, result.GeneratedTextRawPath, result.GeneratedTextResultPath, result.GeneratedTextPath, result.RenderContextPath, result.ReportPath)
assertPathsExist(t, result.GeneratedTextRawPath, result.Metadata.GeneratedTextResultPath, result.GeneratedTextPath, result.RenderContextPath, result.ReportPath)
renderContext, err := os.ReadFile(result.RenderContextPath)
if err != nil {
t.Fatalf("read render context: %v", err)
@@ -1740,7 +1854,7 @@ func TestTomorrowReportCanCompareAgainstPriorTomorrowSnapshot(t *testing.T) {
}, "2026-05-29T18:00:00-05:00")
renderer := successfulGeneratedTextRenderer(validTomorrowGeneratedTextJSON())
result, err := GenerateReport(context.Background(), ReportRequest{
result, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: currentResolved,
@@ -1748,7 +1862,7 @@ func TestTomorrowReportCanCompareAgainstPriorTomorrowSnapshot(t *testing.T) {
Store: store,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
t.Fatalf("generateLegacyBatchReport() error = %v", err)
}
if result.PriorSnapshot == nil {
t.Fatal("PriorSnapshot = nil, want compatible prior tomorrow snapshot")
@@ -1771,7 +1885,7 @@ func TestDailyReportIgnoresPriorTomorrowSnapshot(t *testing.T) {
Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, "2026-05-29T05:00:00-05:00")
result, err := GenerateReport(context.Background(), ReportRequest{
result, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: currentResolved,
@@ -1779,7 +1893,7 @@ func TestDailyReportIgnoresPriorTomorrowSnapshot(t *testing.T) {
Store: store,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
t.Fatalf("generateLegacyBatchReport() error = %v", err)
}
if result.PriorSnapshot != nil {
t.Fatalf("PriorSnapshot = %#v, want nil for prior tomorrow snapshot", result.PriorSnapshot)
@@ -1797,14 +1911,14 @@ func TestInspectGeneratedReportArtifacts(t *testing.T) {
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, "2026-05-29T05:00:00-05:00")
renderer := successfulRenderer("")
result, err := GenerateReport(context.Background(), ReportRequest{
result, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
Renderer: renderer,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
t.Fatalf("generateLegacyBatchReport() error = %v", err)
}
records, err := InspectReports(context.Background(), InspectReportsRequest{Config: cfg, Limit: 1})
@@ -1860,12 +1974,12 @@ func TestInspectPriorSnapshot(t *testing.T) {
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, "2026-05-29T05:00:00-05:00")
renderer := successfulRenderer("")
if _, err := GenerateReport(context.Background(), ReportRequest{Config: cfg, Collection: collectionForTest(t, cfg), Resolved: priorResolved, Renderer: renderer, Store: store}); err != nil {
t.Fatalf("GenerateReport(prior) error = %v", err)
if _, err := generateLegacyBatchReport(context.Background(), ReportRequest{Config: cfg, Collection: collectionForTest(t, cfg), Resolved: priorResolved, Renderer: renderer, Store: store}); err != nil {
t.Fatalf("generateLegacyBatchReport(prior) error = %v", err)
}
current, err := GenerateReport(context.Background(), ReportRequest{Config: cfg, Collection: collectionForTest(t, cfg), Resolved: currentResolved, Renderer: renderer, Store: store})
current, err := generateLegacyBatchReport(context.Background(), ReportRequest{Config: cfg, Collection: collectionForTest(t, cfg), Resolved: currentResolved, Renderer: renderer, Store: store})
if err != nil {
t.Fatalf("GenerateReport(current) error = %v", err)
t.Fatalf("generateLegacyBatchReport(current) error = %v", err)
}
prior, err := InspectPriorSnapshot(context.Background(), InspectRunRequest{Config: cfg, RunID: current.Metadata.RunID})
@@ -3373,14 +3487,14 @@ func generateDailyReportForTest(t *testing.T, cfg config.Config) *ReportResult {
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
result, err := GenerateReport(context.Background(), ReportRequest{
result, err := generateLegacyBatchReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
Renderer: successfulRenderer("# Daily Report\n"),
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
t.Fatalf("generateLegacyBatchReport() error = %v", err)
}
return result
}
@@ -3572,7 +3686,7 @@ func readBatchNotificationForTest(t *testing.T, path string) state.BatchDistribu
func assertGeneratedReportError(t *testing.T, err error, resolved report.Resolved, operation string) {
t.Helper()
if err == nil {
t.Fatal("GenerateReport() error = nil, want generated-text report error")
t.Fatal("generateLegacyBatchReport() error = nil, want generated-text report error")
}
text := err.Error()
for _, want := range []string{

View File

@@ -0,0 +1,321 @@
package app
import (
"context"
"fmt"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
)
type promptReportRequest struct {
GenerateRequest
Resolved report.Resolved
Collection collect.Result
Inspection PromptInspectionResult
}
func generatePromptReport(ctx context.Context, req promptReportRequest) (*ReportResult, error) {
bundle := req.Collection.Bundle
if bundle == nil {
return nil, fmt.Errorf("collected weather bundle is required")
}
store := req.Store
var err error
if store == nil {
store, err = defaultStore(req.Config)
if err != nil {
return nil, err
}
}
paths, err := store.Paths(req.Resolved)
if err != nil {
return nil, err
}
result := &ReportResult{ReportPath: paths.RenderedReport}
priorSnapshot, err := store.FindPriorSnapshot(ctx, req.Resolved)
if err != nil {
return result, err
}
reportFacts, err := BuildReportFacts(ModuleSnapshotRequest{Config: req.Config, Resolved: req.Resolved}, bundle)
if err != nil {
return result, generatedReportError(req.Resolved, req.Resolved.Metadata().RunID, "build report facts", err)
}
moduleSnapshot, err := BuildModuleSnapshotFromFacts(ModuleSnapshotRequest{Config: req.Config, Resolved: req.Resolved}, reportFacts)
if err != nil {
return result, generatedReportError(req.Resolved, req.Resolved.Metadata().RunID, "build module snapshot", err)
}
moduleSnapshotPath, err := store.SaveModuleSnapshot(ctx, req.Resolved, moduleSnapshot)
if err != nil {
return result, err
}
result.ModuleSnapshot = moduleSnapshot
result.ModuleSnapshotPath = moduleSnapshotPath
result.PriorSnapshot = priorSnapshot
recent, err := recentChanges(ctx, store, priorSnapshot, req.Resolved.Definition.ID, moduleSnapshot, req.Config.RecentChange)
if err != nil {
return result, err
}
result.RecentChanges = recent
briefingMetadata := briefing.BuildMetadata(briefingBuildContext(req.Config, req.Resolved, reportFacts.Collected))
metadata := state.BuildPromptMetadataFromBriefingMetadata(req.Resolved, briefingMetadata, state.ArtifactPaths{
ModuleSnapshot: moduleSnapshotPath,
Metadata: paths.Metadata,
DataPackage: paths.DataPackage,
Preparation: paths.Preparation,
Execution: paths.Execution,
RenderedReport: paths.RenderedReport,
GeneratedTextRaw: paths.GeneratedTextRaw,
GeneratedText: paths.GeneratedText,
RenderContext: paths.RenderContext,
})
dataPackage, err := promptinput.Build(promptinput.BuildRequest{
Metadata: promptMetadata(metadata),
Modules: moduleSnapshot,
RecentChanges: recent,
})
if err != nil {
return result, generatedReportError(req.Resolved, metadata.RunID, "build data package", err)
}
data, err := promptinput.MarshalYAML(dataPackage)
if err != nil {
return result, err
}
dataPackagePath, err := store.SaveDataPackageBytes(ctx, req.Resolved, data)
if err != nil {
return result, err
}
metadata.DataPackagePath = dataPackagePath
result.DataPackage = dataPackage
result.DataPackagePath = dataPackagePath
handler, err := generatedtext.LookupDefinition(req.Resolved.Definition)
if err != nil {
return result, generatedReportError(req.Resolved, metadata.RunID, "lookup generated text catalog", err)
}
prepared := false
callbackFailed := false
callback := func(preparation promptexec.Preparation, _ *promptexec.PreparationDebug) error {
artifact := state.PromptPreparationArtifact{
SchemaVersion: state.PromptPreparationSchemaVersion,
Status: state.PromptPreparationSucceeded,
ReportID: req.Resolved.Definition.ID,
RunID: metadata.RunID,
PromptID: req.Inspection.PromptID,
PromptVersion: req.Inspection.PromptVersion,
DataPackagePath: dataPackagePath,
Preparation: &preparation,
StartedAt: preparation.StartedAt,
EndedAt: preparation.EndedAt,
Duration: preparation.Duration,
}
path, err := store.SavePromptPreparation(ctx, req.Resolved, artifact)
if err != nil {
callbackFailed = true
return err
}
prepared = true
result.PreparationPath = path
metadata.PreparationPath = path
metadataPath, err := store.SaveMetadata(ctx, metadata)
if err != nil {
callbackFailed = true
return err
}
result.Metadata = metadata
result.MetadataPath = metadataPath
return nil
}
execution, executeErr := req.Executor.Execute(ctx, promptexec.ExecuteRequest{
PromptID: req.Inspection.PromptID,
PromptVersion: req.Inspection.PromptVersion,
ProfileID: req.Inspection.ProfileID,
DataPackage: data,
DataPackagePath: dataPackagePath,
CaptureDebug: false,
}, callback)
if executeErr != nil {
if callbackFailed {
return result, executeErr
}
executeErr = classifiedPromptError("prompt execution failed", executeErr)
if !prepared {
artifact := state.PromptPreparationArtifact{
SchemaVersion: state.PromptPreparationSchemaVersion, Status: state.PromptPreparationFailed,
ReportID: req.Resolved.Definition.ID, RunID: metadata.RunID, PromptID: req.Inspection.PromptID,
PromptVersion: req.Inspection.PromptVersion, DataPackagePath: dataPackagePath,
StartedAt: time.Now(), EndedAt: time.Now(), Error: state.NewPromptArtifactError(executeErr),
}
path, saveErr := store.SavePromptPreparation(ctx, req.Resolved, artifact)
if saveErr != nil {
return result, saveErr
}
metadata.PreparationPath = path
metadataPath, saveErr := store.SaveMetadata(ctx, metadata)
if saveErr != nil {
return result, saveErr
}
result.PreparationPath, result.Metadata, result.MetadataPath = path, metadata, metadataPath
return result, generatedReportError(req.Resolved, metadata.RunID, "prepare prompt", executeErr)
}
if promptexec.CategoryOf(executeErr) != "" {
artifact := failedPromptExecutionArtifact(req.Resolved, metadata, req.Inspection, executeErr)
path, saveErr := store.SavePromptExecution(ctx, req.Resolved, artifact)
if saveErr != nil {
return result, saveErr
}
metadata.ExecutionPath = path
metadataPath, saveErr := store.SaveMetadata(ctx, metadata)
if saveErr != nil {
return result, saveErr
}
result.ExecutionPath, result.Metadata, result.MetadataPath = path, metadata, metadataPath
}
return result, generatedReportError(req.Resolved, metadata.RunID, "execute prompt", executeErr)
}
if execution == nil {
err := promptexec.NewError(promptexec.Generation, "prompt executor returned no execution", nil)
artifact := failedPromptExecutionArtifact(req.Resolved, metadata, req.Inspection, err)
executionPath, saveErr := store.SavePromptExecution(ctx, req.Resolved, artifact)
if saveErr != nil {
return result, saveErr
}
metadata.ExecutionPath = executionPath
metadataPath, saveErr := store.SaveMetadata(ctx, metadata)
if saveErr != nil {
return result, saveErr
}
result.ExecutionPath, result.Metadata, result.MetadataPath = executionPath, metadata, metadataPath
return result, generatedReportError(req.Resolved, metadata.RunID, "execute prompt", err)
}
if execution.Validation.Status != promptexec.ValidationPassed && execution.Validation.Status != promptexec.ValidationFailed {
err := promptexec.NewError(promptexec.OperationalValidation, "prompt execution did not complete validation", nil)
artifact := failedPromptExecutionArtifact(req.Resolved, metadata, req.Inspection, err)
executionPath, saveErr := store.SavePromptExecution(ctx, req.Resolved, artifact)
if saveErr != nil {
return result, saveErr
}
metadata.ExecutionPath = executionPath
metadataPath, saveErr := store.SaveMetadata(ctx, metadata)
if saveErr != nil {
return result, saveErr
}
result.ExecutionPath, result.Metadata, result.MetadataPath = executionPath, metadata, metadataPath
return result, generatedReportError(req.Resolved, metadata.RunID, "validate prompt execution", err)
}
rawPath, err := store.SaveGeneratedTextRaw(ctx, req.Resolved, execution.RawOutput)
if err != nil {
return result, err
}
result.GeneratedTextRawPath = rawPath
executionArtifact := state.PromptExecutionArtifact{
SchemaVersion: state.PromptExecutionSchemaVersion,
ReportID: req.Resolved.Definition.ID, RunID: metadata.RunID,
PromptID: req.Inspection.PromptID, PromptVersion: req.Inspection.PromptVersion,
Provenance: ptr(state.PromptExecutionProvenanceFrom(*execution)), Validation: &execution.Validation,
Paths: state.PromptExecutionPaths{RawOutputPath: rawPath},
StartedAt: execution.StartedAt, EndedAt: execution.EndedAt, Duration: execution.Duration,
}
if execution.Validation.Status == promptexec.ValidationPassed {
executionArtifact.Status = state.PromptExecutionSucceeded
} else {
executionArtifact.Status = state.PromptExecutionValidationRejected
}
executionPath, err := store.SavePromptExecution(ctx, req.Resolved, executionArtifact)
if err != nil {
return result, err
}
metadata.ExecutionPath = executionPath
metadata.GeneratedTextRawPath = rawPath
metadataPath, err := store.SaveMetadata(ctx, metadata)
if err != nil {
return result, err
}
result.ExecutionPath, result.Metadata, result.MetadataPath = executionPath, metadata, metadataPath
if execution.Validation.Status == promptexec.ValidationFailed {
return result, generatedReportError(req.Resolved, metadata.RunID, "validate prompt execution", promptexec.NewError(promptexec.ValidationRejected, "prompt output did not satisfy its schema", nil))
}
generatedText, normalized, err := handler.Validate(execution.RawOutput)
if err != nil {
return result, generatedReportError(req.Resolved, metadata.RunID, "validate generated text", err)
}
generatedTextPath, err := store.SaveGeneratedText(ctx, req.Resolved, normalized)
if err != nil {
return result, err
}
metadata.GeneratedTextPath = generatedTextPath
metadataPath, err = store.SaveMetadata(ctx, metadata)
if err != nil {
return result, err
}
result.GeneratedTextPath, result.Metadata, result.MetadataPath = generatedTextPath, metadata, metadataPath
renderContext, err := handler.BuildRenderContext(briefingMetadata, moduleSnapshot, reportFacts.Collected, reportFacts.Derived, generatedText)
if err != nil {
return result, generatedReportError(req.Resolved, metadata.RunID, "build render context", err)
}
renderContextPath, err := store.SaveRenderContext(ctx, req.Resolved, renderContext)
if err != nil {
return result, err
}
metadata.RenderContextPath = renderContextPath
metadataPath, err = store.SaveMetadata(ctx, metadata)
if err != nil {
return result, err
}
result.RenderContextPath, result.Metadata, result.MetadataPath = renderContextPath, metadata, metadataPath
rendered, err := handler.Render(renderContext)
if err != nil {
return result, generatedReportError(req.Resolved, metadata.RunID, "render template", err)
}
reportPath, err := store.PrepareRenderedReport(ctx, req.Resolved)
if err != nil {
return result, err
}
if err := fileutil.WriteFileAtomic(reportPath, rendered); err != nil {
return result, err
}
result.ReportPath = reportPath
finalized, err := finalizeRenderedReport(ctx, finalizeRenderedReportRequest{
Config: req.Config, Store: store, Resolved: req.Resolved, Metadata: metadata,
ManagedReportPath: reportPath, OutputPath: req.OutputPath, Notifier: req.Notifier,
})
result.OutputPath, result.NotificationPath = finalized.OutputPath, finalized.NotificationPath
result.Metadata, result.MetadataPath, result.Notification = finalized.Metadata, finalized.MetadataPath, finalized.Notification
if err != nil {
return result, err
}
return result, nil
}
func failedPromptExecutionArtifact(resolved report.Resolved, metadata state.Metadata, inspection PromptInspectionResult, err error) state.PromptExecutionArtifact {
now := time.Now()
return state.PromptExecutionArtifact{
SchemaVersion: state.PromptExecutionSchemaVersion, Status: state.PromptExecutionFailed,
ReportID: resolved.Definition.ID, RunID: metadata.RunID, PromptID: inspection.PromptID,
PromptVersion: inspection.PromptVersion, StartedAt: now, EndedAt: now,
Error: state.NewPromptArtifactError(err),
}
}
func classifiedPromptError(operation string, err error) error {
if promptexec.CategoryOf(err) != "" {
return err
}
return promptexec.NewError(promptexec.Generation, operation, err)
}
func ptr[T any](value T) *T { return &value }

View File

@@ -92,7 +92,7 @@ func newGenerateSummary(result *app.ReportResult, err error) generateSummary {
}
summary.ExecutionPath = result.ExecutionPath
if summary.ExecutionPath == "" {
summary.ExecutionPath = result.GeneratedTextResultPath
summary.ExecutionPath = result.Metadata.GeneratedTextResultPath
}
summary.LLMDebugPath = result.LLMDebugPath
summary.GeneratedTextRawPath = result.GeneratedTextRawPath

View File

@@ -214,6 +214,10 @@ func (r Runner) resolveGenerateAction(args []string) (app.GenerateRequest, commo
if err != nil {
return app.GenerateRequest{}, commonOptions{}, err
}
executor, err := r.promptExecutor(cfg.Promptkit)
if err != nil {
return app.GenerateRequest{}, commonOptions{}, err
}
location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone)
if err != nil {
return app.GenerateRequest{}, commonOptions{}, err
@@ -224,6 +228,7 @@ func (r Runner) resolveGenerateAction(args []string) (app.GenerateRequest, commo
Report: reportKind,
OutputPath: opts.Output,
Now: r.Clock.Now(),
Executor: executor,
}
switch reportKind {

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)

View File

@@ -122,12 +122,20 @@ func (s *FilesystemStore) SaveModuleSnapshot(_ context.Context, resolved report.
}, snapshot)
}
func (s *FilesystemStore) SaveDataPackage(_ context.Context, resolved report.Resolved, pkg promptinput.Package) (string, error) {
func (s *FilesystemStore) SaveDataPackage(ctx context.Context, resolved report.Resolved, pkg promptinput.Package) (string, error) {
data, err := promptinput.MarshalYAML(pkg)
if err != nil {
return "", err
}
return s.SaveDataPackageBytes(ctx, resolved, data)
}
func (s *FilesystemStore) SaveDataPackageBytes(_ context.Context, resolved report.Resolved, data []byte) (string, error) {
paths, err := s.Paths(resolved)
if err != nil {
return "", err
}
if err := promptinput.Save(paths.DataPackage, pkg); err != nil {
if err := fileutil.WriteFileAtomic(paths.DataPackage, data); err != nil {
return "", err
}
return paths.DataPackage, nil

View File

@@ -15,6 +15,7 @@ type Store interface {
Paths(report.Resolved) (ArtifactPaths, error)
SaveModuleSnapshot(context.Context, report.Resolved, module.Snapshot) (string, error)
SaveDataPackage(context.Context, report.Resolved, promptinput.Package) (string, error)
SaveDataPackageBytes(context.Context, report.Resolved, []byte) (string, error)
SavePreflight(context.Context, report.Resolved, PreflightArtifact) (string, error)
SavePromptPreparation(context.Context, report.Resolved, PromptPreparationArtifact) (string, error)
SavePromptExecution(context.Context, report.Resolved, PromptExecutionArtifact) (string, error)