diff --git a/internal/app/app.go b/internal/app/app.go index 1d03da6..4114d86 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -566,6 +566,7 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro metadata: metadata, metadataPath: metadataPath, preflightPath: preflightPath, + priorSnapshot: priorSnapshot, recentChanges: recentChanges, renderResult: renderResult, renderer: renderer, @@ -646,6 +647,7 @@ type generatedReportRequest struct { metadata state.Metadata metadataPath string preflightPath string + priorSnapshot *state.PriorSnapshot recentChanges []changes.Change renderResult *scriptorium.RenderResult renderer Renderer @@ -755,6 +757,7 @@ func generateTextTemplateReport(ctx context.Context, req generatedReportRequest) NotificationPath: notificationPath, Metadata: req.metadata, MetadataPath: metadataPath, + PriorSnapshot: req.priorSnapshot, RecentChanges: req.recentChanges, RenderResult: req.renderResult, StructuredRunResult: structuredResult, @@ -1129,6 +1132,12 @@ func buildRenderContext(definition report.Definition, metadata briefing.Metadata return nil, fmt.Errorf("report template %q requires hourly generated text for report %q", definition.TemplateID, definition.ID) } return generatedtext.BuildHourlyRenderContext(metadata, snapshot, hourly, reportFacts.Collected, reportFacts.Derived) + case "tomorrow": + tomorrow, ok := generated.(generatedtext.Tomorrow) + if !ok { + return nil, fmt.Errorf("report template %q requires tomorrow generated text for report %q", definition.TemplateID, definition.ID) + } + return generatedtext.BuildTomorrowRenderContext(metadata, snapshot, tomorrow, reportFacts.Collected, reportFacts.Derived) default: return nil, fmt.Errorf("report template %q is not supported for report %q", definition.TemplateID, definition.ID) } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 4f8912f..573ec57 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -813,7 +813,6 @@ func TestGeneratedTextValidationDispatchSupportsKnownSchemas(t *testing.T) { } tomorrowDefinition := report.DefaultRegistry().MustLookup(report.Tomorrow) - tomorrowDefinition.GeneratedTextSchemaID = "tomorrow" tomorrow, normalized, err := validateGeneratedText(tomorrowDefinition, []byte(`{ "summary": " Storms become more likely tomorrow. ", "forecast_discussion": [" A front will keep showers in the forecast. ", ""] @@ -842,6 +841,18 @@ func TestBuildRenderContextRejectsMismatchedGeneratedText(t *testing.T) { if !strings.Contains(err.Error(), `requires hourly generated text`) { t.Fatalf("buildRenderContext() error = %v, want hourly generated text requirement", err) } + + tomorrowDefinition := report.DefaultRegistry().MustLookup(report.Tomorrow) + _, err = buildRenderContext(tomorrowDefinition, briefing.Metadata{}, module.Snapshot{}, ReportFacts{}, generatedtext.Hourly{ + Summary: "Storm chances increase.", + ForecastDiscussion: "A front will keep showers in the forecast.", + }) + if err == nil { + t.Fatal("buildRenderContext() error = nil, want tomorrow type mismatch") + } + if !strings.Contains(err.Error(), `requires tomorrow generated text`) { + t.Fatalf("buildRenderContext() error = %v, want tomorrow generated text requirement", err) + } } func TestGenerateReportDisabledNotificationDoesNotCallNotifier(t *testing.T) { @@ -1275,9 +1286,9 @@ func TestGenerateTomorrowReportUsesTomorrowBriefingDate(t *testing.T) { t.Fatalf("ResolveGenerate() error = %v", err) } renderer := &recordingRenderer{ - renderResult: &scriptorium.RenderResult{ExitCode: 0}, - runResult: &scriptorium.RunResult{ExitCode: 0}, - runBody: "# Tomorrow Planning Brief\n", + renderResult: &scriptorium.RenderResult{ExitCode: 0}, + structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0}, + structuredRunBody: validTomorrowGeneratedTextJSON(), } result, err := GenerateReport(context.Background(), ReportRequest{ @@ -1288,10 +1299,22 @@ func TestGenerateTomorrowReportUsesTomorrowBriefingDate(t *testing.T) { if err != nil { t.Fatalf("GenerateReport() error = %v", err) } + if renderer.runCalls != 0 { + t.Fatalf("markdown run calls = %d, want none", renderer.runCalls) + } + if renderer.structuredRunCalls != 1 { + t.Fatalf("structured run calls = %d, want 1", renderer.structuredRunCalls) + } + if renderer.structuredRunRequest.OutputPath != result.GeneratedTextRawPath { + t.Fatalf("structured run OutputPath = %q, want %q", renderer.structuredRunRequest.OutputPath, result.GeneratedTextRawPath) + } if result.Metadata.ReportID != report.Tomorrow || result.Metadata.Variant != "tomorrow" { t.Fatalf("metadata report/variant = %q/%q, want tomorrow", result.Metadata.ReportID, result.Metadata.Variant) } + if result.Metadata.GeneratedTextSchemaID != "tomorrow" || result.Metadata.GeneratedTextPath != result.GeneratedTextPath || result.Metadata.RenderContextPath != result.RenderContextPath || result.Metadata.RenderedReportPath != result.ReportPath { + t.Fatalf("metadata generated-text links = %#v, want tomorrow generated-text artifacts", result.Metadata) + } dailySummary, ok, err := module.StanzaValue[map[string]any](result.ModuleSnapshot, "derived_daily_summary") if err != nil { t.Fatalf("decode daily summary: %v", err) @@ -1305,6 +1328,25 @@ 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) + renderContext, err := os.ReadFile(result.RenderContextPath) + if err != nil { + t.Fatalf("read render context: %v", err) + } + for _, want := range []string{`"Title": "Saturday's Weather"`, `"GeneratedText": {`, `"forecast_discussion": [`, `"Dayparts": [`} { + if !strings.Contains(string(renderContext), want) { + t.Fatalf("render context missing %q:\n%s", want, string(renderContext)) + } + } + reportData, err := os.ReadFile(result.ReportPath) + if err != nil { + t.Fatalf("read report: %v", err) + } + for _, want := range []string{"# Saturday's Weather", "## Daypart Forecast", "## Forecast Discussion", "Tomorrow starts with showers before improving."} { + if !strings.Contains(string(reportData), want) { + t.Fatalf("tomorrow report missing %q:\n%s", want, string(reportData)) + } + } } func TestTomorrowReportCanCompareAgainstPriorTomorrowSnapshot(t *testing.T) { @@ -1334,9 +1376,9 @@ func TestTomorrowReportCanCompareAgainstPriorTomorrowSnapshot(t *testing.T) { t.Fatalf("ResolveGenerate(current) error = %v", err) } renderer := &recordingRenderer{ - renderResult: &scriptorium.RenderResult{ExitCode: 0}, - runResult: &scriptorium.RunResult{ExitCode: 0}, - runBody: "# Tomorrow Planning Brief\n", + renderResult: &scriptorium.RenderResult{ExitCode: 0}, + structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0}, + structuredRunBody: validTomorrowGeneratedTextJSON(), } result, err := GenerateReport(context.Background(), ReportRequest{ @@ -1671,7 +1713,7 @@ func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) { name: "tomorrow", kind: ReportTomorrow, wantID: report.Tomorrow, - wantPrompt: "weather.daily_report", + wantPrompt: "weather.tomorrow_generated_text", wantStart: "2026-05-30T00:00:00-05:00", wantEnd: "2026-05-31T00:00:00-05:00", requestDate: time.Time{}, @@ -2109,6 +2151,10 @@ func validHourlyGeneratedTextJSON() string { return `{"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"}` } +func validTomorrowGeneratedTextJSON() string { + return `{"summary":"Tomorrow 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."}` +} + func generateDailyReportForTest(t *testing.T, cfg config.Config) *ReportResult { t.Helper() cfg.Workspace.Root = t.TempDir() @@ -2450,6 +2496,13 @@ func (r *selectiveRenderer) Run(_ context.Context, req scriptorium.RunRequest) ( func (r *selectiveRenderer) StructuredRun(_ context.Context, req scriptorium.StructuredRunRequest) (*scriptorium.StructuredRunResult, error) { r.structuredRunCalls++ + body := validHourlyGeneratedTextJSON() + if req.PromptID == "weather.tomorrow_generated_text" { + body = validTomorrowGeneratedTextJSON() + } + if err := os.WriteFile(req.OutputPath, []byte(body), 0o600); err != nil { + return nil, err + } return &scriptorium.StructuredRunResult{ExitCode: 0, OutputPath: req.OutputPath}, nil } diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index cadab2d..4f9e7e6 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -130,7 +130,7 @@ func TestRunGenerateTomorrowWritesMarkdownReport(t *testing.T) { if err != nil { t.Fatalf("read report: %v", err) } - if !strings.Contains(string(report), "# Daily Report") { + if !strings.Contains(string(report), "# Saturday's Weather") { t.Fatalf("report output missing markdown:\n%s", string(report)) } dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "*.data_package.yaml") @@ -1176,13 +1176,31 @@ if [ "$1" = "render" ]; then fi if [ "$1" = "run" ]; then out="" + prompt="" while [ "$#" -gt 0 ]; do if [ "$1" = "--out" ]; then shift out="$1" + elif [ "$1" = "--prompt" ]; then + shift + prompt="$1" fi shift done + if [ "$prompt" = "weather.tomorrow_generated_text" ]; then + cat > "$out" <<'JSON' +{ + "summary": "Tomorrow 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." +} +JSON + printf 'wrote generated text\n' >&2 + exit 0 + fi printf '# Daily Report\n\nGenerated by fake scriptorium.\n' > "$out" printf 'wrote report\n' >&2 exit 0 @@ -1225,6 +1243,20 @@ if [ "$1" = "run" ]; then "precipitation_timing": "A cold front is moving into the region.", "confidence": "Medium" } +JSON + printf 'wrote generated text\n' >&2 + exit 0 + fi + if [ "$prompt" = "weather.tomorrow_generated_text" ]; then + cat > "$out" <<'JSON' +{ + "summary": "Tomorrow 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." +} JSON printf 'wrote generated text\n' >&2 exit 0 diff --git a/internal/report/daily_report.go b/internal/report/daily_report.go index 010eedd..3c8c773 100644 --- a/internal/report/daily_report.go +++ b/internal/report/daily_report.go @@ -24,18 +24,20 @@ func dailyTodayDefinition() Definition { func tomorrowDefinition() Definition { return Definition{ - ID: Tomorrow, - Name: "Tomorrow Report", - PromptID: "weather.daily_report", - GenerationMode: GenerationModeScriptoriumMarkdown, - ComparisonStrategy: CompareSameValidDate, - ArtifactGroup: "tomorrow", - BatchOutputName: "tomorrow.md", - Generated: true, - CompatiblePriorIDs: []ID{Tomorrow}, - Modules: tomorrowModules(), - Evening: true, - resolve: resolveTomorrow, + ID: Tomorrow, + Name: "Tomorrow Report", + PromptID: "weather.tomorrow_generated_text", + GenerationMode: GenerationModeGeneratedTextTemplate, + TemplateID: "tomorrow", + GeneratedTextSchemaID: "tomorrow", + ComparisonStrategy: CompareSameValidDate, + ArtifactGroup: "tomorrow", + BatchOutputName: "tomorrow.md", + Generated: true, + CompatiblePriorIDs: []ID{Tomorrow}, + Modules: tomorrowModules(), + Evening: true, + resolve: resolveTomorrow, } } diff --git a/internal/report/period_test.go b/internal/report/period_test.go index 41fbf16..e3ae1db 100644 --- a/internal/report/period_test.go +++ b/internal/report/period_test.go @@ -42,8 +42,17 @@ func TestTomorrowValidPeriodFromEveningGeneration(t *testing.T) { t.Fatalf("Resolve() error = %v", err) } assertPeriod(t, resolved.ValidPeriod, "2026-05-30T00:00:00-05:00", "2026-05-31T00:00:00-05:00") - if resolved.Definition.PromptID != "weather.daily_report" { - t.Fatalf("PromptID = %q, want weather.daily_report", resolved.Definition.PromptID) + if resolved.Definition.PromptID != "weather.tomorrow_generated_text" { + t.Fatalf("PromptID = %q, want weather.tomorrow_generated_text", resolved.Definition.PromptID) + } + if resolved.Definition.GenerationMode != GenerationModeGeneratedTextTemplate { + t.Fatalf("GenerationMode = %q, want generated_text_template", resolved.Definition.GenerationMode) + } + if resolved.Definition.TemplateID != "tomorrow" { + t.Fatalf("TemplateID = %q, want tomorrow", resolved.Definition.TemplateID) + } + if resolved.Definition.GeneratedTextSchemaID != "tomorrow" { + t.Fatalf("GeneratedTextSchemaID = %q, want tomorrow", resolved.Definition.GeneratedTextSchemaID) } } @@ -268,15 +277,16 @@ func TestRegistryDefinitionsDeclareGenerationMetadata(t *testing.T) { if !definition.Generated { continue } - if definition.ID == Hourly { + if definition.ID == Hourly || definition.ID == Tomorrow { + wantTemplate := string(definition.ID) if definition.GenerationMode != GenerationModeGeneratedTextTemplate { t.Fatalf("%s GenerationMode = %q, want %q", definition.ID, definition.GenerationMode, GenerationModeGeneratedTextTemplate) } - if definition.TemplateID != "hourly" { - t.Fatalf("%s TemplateID = %q, want hourly", definition.ID, definition.TemplateID) + if definition.TemplateID != wantTemplate { + t.Fatalf("%s TemplateID = %q, want %s", definition.ID, definition.TemplateID, wantTemplate) } - if definition.GeneratedTextSchemaID != "hourly" { - t.Fatalf("%s GeneratedTextSchemaID = %q, want hourly", definition.ID, definition.GeneratedTextSchemaID) + if definition.GeneratedTextSchemaID != wantTemplate { + t.Fatalf("%s GeneratedTextSchemaID = %q, want %s", definition.ID, definition.GeneratedTextSchemaID, wantTemplate) } continue }